overlay-svg-text · git:20260512.885ab36 · 2026-05-12 · sha256 86364b533cbffe82
overlay-svg-text git:20260512.885ab36A
Immutable. This exact content never changes and is served at /api/v1/blob/86364b533cbffe82.
---
name: overlay-svg-text
description: How to overlay on-brand SVG text on Lossless OG / share imagery — Hack Bold gradient-filled h1, thin sans eyebrow, Poor Story handwritten note. Use whenever a generated OG image needs title/eyebrow/sub text composited on top before it ships (the empty-region zone from `generate-consistent-og-images` is the canvas this skill paints into), whenever an unfurl preview looks too anonymous without a wordmark or subtitle, whenever a fundraise-deck slide needs a brand-flavored title overlay on a hero image, whenever the user says "overlay text on the OG image", "add a title to the banner", "make the unfurl say something", "drop a wordmark on this", or names this skill directly. Encodes the brand-wide type system (Hack Bold for gradient h1, thin sans for eyebrow, Poor Story for handwritten notes), the per-site gradient-from-DESIGN.md discipline (the brand SVGs are raster-baked references, not editable gradient sources — the editable gradient lives in each project's DESIGN.md), the canonical SVG fill-with-gradient pattern (simpler and more portable than mask/union for this use case), and the sharp-based compositing pipeline that writes JPEG-out for delivery per the open-graph-share-seo-geo skill.
---
# Overlay SVG Text on Brand Imagery
> Two layers of brand identity on every share image. The base image — generated via `generate-consistent-og-images` — already follows the empty-region-first composition rule, leaving the top 1/3 of the frame deliberately clean. **This skill paints that clean zone** with three text elements: a thin sans eyebrow, a Hack-Bold gradient-filled h1, and a Poor Story handwritten note. Everything else (layout, colors, font weights) is locked at the project's `DESIGN.md` level.
## When to use this skill
- An OG image was just generated and needs a wordmark / title overlay before it ships
- An existing OG image looks too anonymous in unfurls — needs a brand-flavored title or subtitle
- A fundraise-deck slide / hero illustration needs a brand-consistent title overlay
- A user-facing share preview is launching and needs the project's name + a one-liner baked in
- The user says: "overlay text on this", "add a title to the banner", "make the unfurl say something", "drop a wordmark on this", "compose the OG with text"
## The Lossless brand type system for overlays
Three text elements, in canonical vertical order from top of frame to bottom of overlay zone:
| Element | Font | Weight | Color | Notes |
|---|---|---|---|---|
| **Eyebrow** | Project's display sans (or Inter as fallback) | Thin (200) or Light (300) | white at 70–80% alpha, OR `--color-text-soft` | Often uppercase with wide tracking (`0.18em–0.24em`); the project's `typography.eyebrow` token wins if present |
| **H1 (gradient)** | **Hack Bold** (Hack Nerd Font Mono Bold is what most Lossless dev machines have installed) | Bold (700) | **gradient-filled** from the project's `imagery.gradient` or `components.gradient-text.background` in DESIGN.md | Monospaced + gradient is the brand's "data" aesthetic — short noun-phrase or shell-command-style; never sentence-case prose |
| **Note (handwritten)** | **Poor Story** (Google Fonts, Korean handwriting family) — preferred. Any cartoonish handwriting font is acceptable as fallback (e.g. Caveat, Patrick Hand, Indie Flower) | Regular | **white** (`#ffffff`) — almost always | The handwriting/Hack contrast is the brand move; never serif, never another mono |
The three elements together compose the brand's signature: **clean thin sans label → monospaced gradient title → cartoonish white handwritten phrase**. The handwriting/mono contrast is the most-defining axis; don't lose it by going all-sans or all-mono.
## Where the gradient comes from
**Not** from `trademark__The-Lossless-Group--Vibrant.svg` or `gradient__The-Lossless-Group--Rounded-Rectangle.svg`. Those files are raster-baked exports (each is a `<clipPath>` of glyph/shape paths with a base64 JPEG as the fill) and the gradient inside them isn't an editable `<linearGradient>` element — it's pixel data. Treat them as **visual reference only**.
The editable gradient for any given project lives in that project's `DESIGN.md`. Look in this order:
1. **`imagery.gradient`** — a Lossless extension key if the project has declared an OG-specific gradient
2. **`components.gradient-text.background`** — most Lossless splashes define this for their hero gradient-text move
3. **The brand-spine colors** (`accent`, `accent-warm`, `thread` — or `primary`, `secondary`, `tertiary`) at 0% / 55% / 100% stops
For the **lossless-agent-skills** splash, that resolves to:
```
linear-gradient(90deg, #5eead4 0%, #fbbf24 55%, #e879f9 100%)
(teal) (amber) (magenta)
```
For the **ai-labs** splash:
```
linear-gradient(95deg, #ffb733 0%, #5cc8ff 55%, #d96fff 100%)
(sodium) (cyan) (plum)
```
Per-project gradient + brand-wide type system = images that look like family with a project on each member. **Use Lossless Group's brand gradient (teal/violet/etc. — the JPEG-baked one) only when the overlay belongs to The Lossless Group itself**, e.g. a corporate splash, an "About" page, a consulting-firm asset. Site-specific imagery uses site-specific gradients.
## The SVG-text-with-gradient technique
Two compatible patterns. The first is the default; the second is for the rare cases where you need the gradient to extend past the letterforms or to be clipped by a more complex shape.
### Pattern A — `fill="url(#gradient)"` on `<text>` (default)
```xml
<svg width="1312" height="736" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="55%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#e879f9"/>
</linearGradient>
</defs>
<text x="64" y="120"
font-family="Hack Nerd Font Mono, Hack, ui-monospace, monospace"
font-weight="700"
font-size="96"
fill="url(#brand)">
Agent Skills
</text>
</svg>
```
The gradient renders inside each glyph's bounding box. Simple, portable across librsvg / resvg / browser / sharp. **This is what 95% of overlays should use.**
### Pattern B — gradient rectangle clipped by text (mask/union style)
```xml
<defs>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">...</linearGradient>
<clipPath id="text-clip">
<text x="64" y="120"
font-family="Hack Nerd Font Mono"
font-weight="700"
font-size="96">
Agent Skills
</text>
</clipPath>
</defs>
<rect x="0" y="0" width="1312" height="200"
fill="url(#brand)"
clip-path="url(#text-clip)"/>
```
Use this when the gradient should sweep across a region wider than the text itself (e.g., the gradient should flow as if it were on a banner behind the text but be visible only through the letterforms). For all simple cases, Pattern A is shorter and renders identically.
## The compositing pipeline
The base OG image is a JPEG; the overlay is an SVG; the output is a JPEG with the SVG painted on top. The cleanest tool for this is `sharp` (Node, librsvg under the hood). The canonical script lives at `templates/compose-overlay.mjs` in this skill.
```bash
# One-time install at the splash project root:
pnpm add -D sharp
# Then run:
node templates/compose-overlay.mjs \
--base public/ogimage__Lossless-Agent-Skills--BannerImage.jpg \
--overlay overlays/banner.svg \
--out public/ogimage__Lossless-Agent-Skills--BannerImage--overlaid.jpg \
--quality 90
```
The script:
1. Reads the base JPEG and gets its dimensions
2. Reads the SVG, validates that its `width`/`height` match the base (or scales it)
3. Composites the SVG at (0, 0) — the SVG positions its own elements internally
4. Writes the result as JPEG at the chosen quality (default 90; the `open-graph-share-seo-geo` skill targets ~85–95 for OG)
5. Archives the prior canonical JPEG to `.ogimage-archive/<name>--<YYYY-MM-DD>.jpg` first if the output path already exists
### Alternative renderers (when sharp isn't an option)
- **`rsvg-convert`** (librsvg CLI, Homebrew): `rsvg-convert overlay.svg -o overlay.png` then composite with `ffmpeg -i base.jpg -i overlay.png -filter_complex "[0][1]overlay=0:0" out.jpg`
- **`resvg`** (Rust, faster than librsvg): `resvg overlay.svg overlay.png` + same ffmpeg step
- **ImageMagick**: `convert base.jpg overlay.svg -composite out.jpg` — works but text rendering is less reliable than librsvg
Sharp is the documented default because it's already in the Astro Knots ecosystem and produces byte-identical output to librsvg-CLI without the two-step PNG→JPEG dance.
## Font availability
Sharp's renderer (librsvg under the hood) reads fonts via fontconfig on Linux/macOS and via the system font store on Windows. For the local-dev case where fonts are installed in `~/Library/Fonts/` (macOS) or `~/.fonts/` (Linux), no extra step is needed — just reference the family name in the SVG's `font-family` attribute.
For CI builds (GitHub Actions, Vercel build container, etc.) where the host doesn't have Hack or Poor Story installed, **embed the WOFF2 binary as a data URL inside the SVG**:
```xml
<defs>
<style type="text/css">
@font-face {
font-family: 'Hack Bold';
src: url(data:font/woff2;base64,d09GMgABAAAA...) format('woff2');
font-weight: 700;
}
@font-face {
font-family: 'Poor Story';
src: url(data:font/woff2;base64,d09GMgABAAAA...) format('woff2');
font-weight: 400;
}
</style>
</defs>
```
Recipe to generate the data URL:
```bash
# Convert TTF/OTF to WOFF2 (smaller, faster decode)
# pnpm dlx fonttools (or use python -m fontTools.ttLib if installed)
fonttools ttLib --woff2 ~/Library/Fonts/HackNerdFontMono-Bold.ttf
# Then base64-encode:
base64 -i HackNerdFontMono-Bold.woff2 | tr -d '\n' > hack-bold.b64
```
A WOFF2-embedded SVG balloons by ~80–250 KB (Hack Bold is ~200 KB WOFF2; Poor Story is ~80 KB WOFF2). Acceptable for a one-time composited PNG/JPEG output — the resulting raster is the only thing that ships, the SVG is intermediate.
## Layout — where the three elements sit inside the empty-region zone
The base OG image was generated with `Top 1/3 of frame is empty negative space` as the locked composition. The overlay's safe zone is therefore the top third of the canvas. Within that:
```
┌────────────────────────────────────────────────┐ ← y = 0
│ ┌─ padding 6% of height ─┐ │
│ │ │ │
│ ┃ EYEBROW · THIN · ALLCAPS ← ~12% down (eyebrow baseline)
│ │ │ │
│ ┃ Gradient H1 Title ← ~22% down (h1 baseline; bold mono)
│ │ │ │
│ ┃ ✎ a handwritten phrase, white ← ~30% down │
│ │ │ │
└──┴─────────────────────────┴────────────────────┘ ← y = floor(height / 3)
↑ ↑
left padding 5–6% right edge stays clean
```
Vertical positions are expressed as percentages of the canvas height so the same SVG template re-aspects cleanly when fed different dimensions. For the four canonical aspect ratios from `generate-consistent-og-images`:
| Format | Dimensions | Top-zone height | Eyebrow y | H1 y | Note y |
|---|---|---|---|---|---|
| `BannerImage` | 1312×736 | 245 px | ~88 px | ~162 px | ~221 px |
| `BannerImageTall` | 896×1120 | 373 px | ~134 px | ~246 px | ~336 px |
| `PortraitImage` | 736×1312 | 437 px | ~157 px | ~289 px | ~394 px |
| `SquareImage` | 1024×1024 | 341 px | ~123 px | ~225 px | ~307 px |
The compose script computes these from the base image dimensions; never hard-code pixel positions for a specific aspect ratio in the template SVG. **Author the SVG once with percentage-based y-values; the script substitutes them per output.**
## The flow
Five steps. Steps 1–2 are setup; steps 3–5 are the per-image work.
### Step 1 — Confirm the base imagery is ready
The base JPEGs must already exist (output of `generate-consistent-og-images`). If they don't, hand off to that skill first. The overlay paints onto what's there; it can't conjure a base.
### Step 2 — Resolve the gradient
Read the project's `DESIGN.md`:
1. Look for `imagery.gradient` (an extension key on some projects)
2. Fall back to `components.gradient-text.background`
3. Fall back to constructing `linear-gradient(<angle>, <accent>, <accent-warm>, <thread>)` from the project's brand-spine semantic colors
For the **lossless-agent-skills** splash:
```css
linear-gradient(90deg, #5eead4 0%, #fbbf24 55%, #e879f9 100%)
```
For **ai-labs**:
```css
linear-gradient(95deg, #ffb733 0%, #5cc8ff 55%, #d96fff 100%)
```
Translate to SVG `<linearGradient>` stops; `x1/y1/x2/y2` derives from the CSS angle.
### Step 3 — Author the overlay SVG
Use `templates/overlay.svg` as the starting point. Fill in:
- The gradient stops (from step 2)
- The eyebrow text + the project's display-sans family
- The H1 text (short — 1–3 words; the gradient sweeps across one phrase, not a paragraph)
- The handwritten note (a phrase, never a full sentence; ≤32 chars reads best at any aspect ratio)
- The canvas `width` and `height` (match the base image exactly)
For multi-aspect runs (BannerImage + BannerImageTall + PortraitImage + SquareImage), author **one SVG template** with percentage-based positions and let the compose script re-render it per output. Don't hand-author four separate SVGs.
### Step 4 — Composite
Run `node compose-overlay.mjs --base <jpg> --overlay <svg> --out <jpg>` (or the per-format loop variant in the same script).
The script archives any existing canonical at `.ogimage-archive/<name>--<YYYY-MM-DD>.jpg` before writing the new one (per the `generate-consistent-og-images` preservation discipline).
### Step 5 — Verify visually
Open the output JPEG. Check:
- **Eyebrow is readable but not loud** — wide tracking + thin weight + 70–80% white alpha. If it's the loudest element, the rest of the brand hierarchy collapses.
- **H1 gradient is visible inside the letterforms, not bleeding outside them** — if the gradient looks pixelated or banded, the gradient stops are too close together or the canvas is too narrow.
- **Handwritten note reads as a note, not a headline** — Poor Story at the right size feels like a margin scribble. If it looks like a second headline, scale down or pick a more whimsical handwriting fallback.
- **No clipping at the safe-zone boundary** — the bottom of the handwritten note should be ≥1.5em above the subject's top edge in the base image.
If the overlay clips the subject (text overlapping the robots / bench / terminal in the base), the base image's empty-region zone wasn't large enough. Re-generate the base via `generate-consistent-og-images` with a longer "Top 1/3" clause or `Top 40%` in the prompt; don't shrink the overlay text below readable size to work around it.
## Anti-patterns
- **Cloning the Lossless Group brand SVGs as the gradient source.** They're raster-baked — there's no real gradient to extract, only a JPEG inside a clipPath. Use the project's DESIGN.md gradient instead. The brand SVGs are visual reference for the *aesthetic* (mono + gradient), not source data.
- **Putting Hack on the eyebrow or the handwritten note.** Hack is reserved for the gradient H1. The mono/handwriting contrast is the brand move; collapsing both to mono erases it.
- **Sentence-case prose in the H1.** Gradient on a phrase that wraps to two lines breaks the gradient's continuity — letters at the start of line 2 read in colors that don't follow from where line 1 left off. Keep H1 to 1–3 words, one line.
- **A handwritten note longer than ~32 characters.** The handwriting font becomes hard to read past that length; the move is "marginalia," not "subtitle."
- **Hard-coded pixel positions for a specific aspect ratio in the SVG template.** Use percentage-based y-values + let the compose script re-render per output. Otherwise you maintain four separate SVGs and they drift.
- **Outputting PNG when the deliverable is OG / share imagery.** Per the `open-graph-share-seo-geo` skill: JPEG-over-WebP for unfurler compatibility. Sharp's `.jpeg({ quality: 90 })` is the canonical output step.
- **Overwriting the canonical OG JPEG in place.** Same preservation rule as `generate-consistent-og-images` — archive the prior bytes to `.ogimage-archive/<name>--<YYYY-MM-DD>.jpg` before writing the new one. The unfurler URL stays stable; byte history survives.
- **Embedding fonts every time when running locally.** Wasteful when fontconfig already finds Hack and Poor Story in `~/Library/Fonts/`. Embed only for CI / cross-machine portability or when the SVG itself is the deliverable.
- **Letting the eyebrow tracking go below `0.12em`.** The brand eyebrow is wide-tracked on purpose; tight tracking reads as body type and breaks the hierarchy.
- **Choosing a serif or another mono as the handwriting fallback when Poor Story is unavailable.** The whole point of the handwritten element is the *contrast* against Hack. A serif or another mono erases the contrast. Pick Caveat, Patrick Hand, Indie Flower, Shadows Into Light, etc. — cartoonish-handwriting only.
## See also
- `templates/overlay.svg` — the canonical SVG scaffold with placeholders for gradient stops, eyebrow, h1, and handwritten note
- `templates/compose-overlay.mjs` — the canonical Node script that composites SVG onto JPEG via sharp, with multi-aspect loop + archive discipline
- `generate-consistent-og-images` skill — the upstream skill that produces the base JPEGs this skill overlays text onto; defines the empty-region-first composition that creates the overlay safe zone
- `maintain-design-md` skill — where the gradient stops actually live (`components.gradient-text.background` or `imagery.gradient`); this skill is a *consumer* of those tokens, not an author
- `open-graph-share-seo-geo` skill — the delivery-side discipline (JPEG-over-WebP, cache headers, the `og:image:type` invariant); cares that the overlaid JPEG ends up on the right URL, not how the text was painted
- Google Fonts — Poor Story: <https://fonts.google.com/specimen/Poor+Story>
- Source Foundry — Hack: <https://sourcefoundry.org/hack/>