How General Translation's blog and brand graphics are made with Prototemplate's graphics/ toolchain.
node scripts/install-skills.mjs gt-graphics --project <dir>Run it from a Prototemplate checkout. It links skills/gt-graphics into the project’s .claude/skills and .agents/skills; --copy vendors the folder instead, and --dry-run prints each step first.
General Translation's blog illustrations are HTML pages built in Prototemplate's graphics/ toolchain: real crops of the product, labels and leader lines drawn once, and a glyphfield export as the ground, rendered headlessly and exported for the blog's article column. The toolchain made the set for "Designing docs for humans" in September 2026 (thirty-three article visuals plus eight covers and social cards), and every later post runs the same procedure. Prototemplate (github.com/Kevin-Liu-01/Prototemplate) is Kevin's design and documentation repository for General Translation (GT), and gt-cloud is GT's monorepo, whose apps/landing serves generaltranslation.com and its blog. Paths are relative to a Prototemplate checkout ($PROTOTEMPLATE) unless they name $GT_CLOUD.
A visual is one HTML page with a 1600 by 900 stage. Covers and social cards may set their own stage, such as 1200 by 630 for OpenGraph. graphics/build/render.sh shoots each page at 7680 wide and Pillow halves it to 3840.
The page holds four kinds of content:
crop()), each cut from a measured rectangle of a registered capture;hl(), tag(), badge(), dot());line(), elbow());stage().Every label names something the reader can find in the product. Nothing in a visual is invented.
The review of the September 2026 set settled these rules (docs/GRAPHICS.md, What a visual is):
gen-lib.js still exports title() and caption(), and the current set calls neither.#f0524f) marks what was removed. Blue (#0078FF, with #60a5fa for lines and thumbs) marks the page and what replaced it. Before and after sit at the same scale.node_modules/heroicons (24 solid first, then 20 solid).The article column is about 700 CSS px wide on desktop: 720px on Prototemplate's /blog (BLOG_COLUMN_SIZES in src/lib/blog-image-sizes.ts) and 698px on generaltranslation.com (BLOG_COLUMN_SIZES in $GT_CLOUD/apps/landing/src/components/blog/imageSizes.ts). A 1600px stage is therefore shown at about 0.44x, and every size is chosen for that view.
| Element | Size on the stage |
|---|---|
| Any text | 26px or more after zoom-to-fit. MIN_TEXT in gen-lib.js holds the floor and audit.js fails the run below it. |
| Label chip | 28px Inter 600 in a 46px chip (chipStyle(28) in gen-visuals.js) |
| Tag and badge | 30px, 54px tall (.tag) and 50px tall (.badge) |
| Numbered dot | 56px disc with a 28px numeral (dot(x, y, n, color, 56)) |
| Panel header | 32px with a 34px icon (.plabel) |
| Measurement label | 28px Geist Mono on a dark backing pill (.measure) |
| Lines and rulers | 3px; ruler caps 18 by 3px |
| Crops | 1.2x or larger when they carry text the reader must read |
| Crop corners | 8px radius with a 1px #3f3f46 border; highlight boxes 6px |
| Ground | twice the stage height, aspect kept, image-rendering: pixelated; dimmed to 36 percent under article visuals (BG_WASH = 0.64), covers at their own light wash of 0.1 to 0.3 |
The stage script (CENTER_SCRIPT in gen-lib.js) centres the composition. When the composition is larger than 90 percent of the frame it scales it down to fit, never below 0.8 and never above 1, and the labels shrink with it. Covers (area H) skip the fit. When the audit reports text under the floor, make the composition narrower with smaller crops, shorter labels or one label column. Enlarging the type makes the composition wider, and the fit then scales it back down.
| Export | Format |
|---|---|
| Article visual | 3840 wide webp at quality 95 (export-blog.py) |
| Header cover | 3840 webp in a dark and a light version at the same framing |
| OpenGraph card | 2400 by 1260 PNG, dark and light |
| Clip | GIF 1400 wide (the column's 2x width) and an MP4 master at 30fps |
Prototemplate's /blog serves every image through next/image with BLOG_COLUMN_SIZES, quality 95 and the dense images.deviceSizes ladder in next.config.ts (640 to 3840), so a 1x, 2x or 3x screen gets a variant within a few percent of its device width. GIFs skip the optimizer (isGif). gt-cloud's landing does the same with its own BLOG_COLUMN_SIZES, and its BlogPostCover asks for quality 95 on a webp cover (gt-cloud main, 2026-10-05).
The first set looked soft because its 12px labels were shown at 0.44x and the dither's one-pixel dots aliased at 1x. The master resolution was already enough. A later report of blur on sharp masters came from the browser shrinking a 3840px image into a 700px column by itself, which cost about an eighth of the edge contrast on a 2x screen. Before changing the pipeline, read what the page serves: currentSrc, and the decoded width against clientWidth * devicePixelRatio.
Sources come from a local landing dev server built from gt-cloud main, captured with agent-browser at 1440 by 900 and a device pixel ratio of 4 or 5. Use 5 for pages that get cropped small, 4 for full-page references and 3 for pages shown whole. Themes come from agent-browser set media light|dark. Hide the Next.js dev overlay (nextjs-portal), park the pointer on plain text, and open menus with their real control. Convert each capture to lossless webp in graphics/shots/hi/ and register it in SHOTS with its dpr, so every rectangle is measured in CSS px at 1440. SSO-protected Vercel previews are captured by serializing the DOM from a signed-in Chrome to the graphics server. Read references/capture.md for the commands, the state cookies, the measuring snippet and the DOM-serialize route.
The grounds are exports of Kevin's glyphfield project (graphics/glyph/load.js). Forty-two of them sit in public/graphics/bg/user/ (u10 to u59, 1920 by 1080, lossless webp identical to the PNG originals), and public/graphics/bg/blue/ holds the blue duotone and light variants that carry the covers. graphics/bg is a link to public/graphics/bg, so the toolchain and the site read one set, and /graphics lists each ground with the visuals that sit on it.
Each article visual gets its own export through ASSIGN in gen-visuals.js, so a post never uses one ground twice (the thirty-three docs visuals sit on thirty-three grounds); the dark, light and OpenGraph versions of one cover share their blue ground. The generator throws when a visual's ground is missing from BG. Grounds are exported headlessly with graphics/glyph/gfboot.sh and gfexport.sh. Read references/glyphfield-export.md for the studio steps, the export width, the base64 transfer and the automation API.
pnpm install at the repository root (heroicons for the label icons) and pip install pillow, and put agent-browser and ffmpeg on the PATH. On a fresh clone run mkdir -p graphics/build/out: the folder is ignored by git, and render.sh and agent-browser's screenshot do not create it, so every render fails without it.pnpm graphics:serve keeps graphics/ on http://127.0.0.1:8765. Every later step reads pages from it.graphics/shots/hi/ and register them in SHOTS in gen-lib.js with their density (references/capture.md).N for the new docs and O for the old docs. Every crop, highlight and leader derives from these objects, so a visual can be re-cut when the page changes.add(id, area, name, bg, why, () => html, options) in gen-visuals.js. why is one sentence saying what the visual shows; /graphics prints it. Options are animated, wash, vignette, w, h and fit. Assign the visual its own ground in ASSIGN.pnpm graphics:gen writes graphics/build/visuals/<id>.html and graphics/build/manifest.json. Fix every name it prints under MISSING ICONS.pnpm graphics:audit [ids] opens each page at 1600 by 900, fails on any text under 26px after the fit, and lists overlapping boxes and boxes off the stage as warnings. Fix the failures and judge each warning, since a label sitting on the crop it names overlaps on purpose. pnpm graphics:audit --strict fails on overlaps too. The audit uses the agent-browser session gtdocs, as the render does.pnpm graphics:render [ids] writes graphics/build/out/<id>.png at 3840 wide. It prints FAILED <id> for a page that never centred.python3 graphics/build/sheet.py [ids] tiles the renders into graphics/build/out/_sheet<n>.png, sixteen per sheet at 640 by 360, close to the size at which a reader first sees them. Open a 1:1 crop of every visual with lines or small type.graphics/build/composite-videos.sh (references/stop-motion.md).pnpm graphics:export --covers writes the webp set, the GIFs, the dark and light header covers and both OpenGraph cards into public/static/blogs. Write the flags straight after the script name: pnpm 11 passes a -- through to the script, and argparse then reads --covers as a visual id and stops on a missing --covers.png. --dest $GT_CLOUD/apps/landing/public/static/blogs writes into a landing checkout instead. Bump the ?v=YYYYMMDD-HHMM stamp on every reference in the post so caches refresh. When a set ships, copy its sheets to public/graphics/sheets/<post>-sheet<n>.png.pnpm dev (port 3005) and check the set on http://localhost:3005/graphics and the post on http://localhost:3005/blog/<slug> in both themes. Kevin reviews new work on localhost before anything reaches main (Kevin, 2026-09-14).The primitives live in gen-lib.js, except the rows marked gen-visuals.js.
| Primitive | What it draws |
|---|---|
crop(shot, rect, { x, y, s, pad, clip, r, cls, filter, opacity }) | a padded cut of a capture at stage position x, y and scale s; 12px padding (PAD), none for full-page rects; clip keeps a neighbour from bleeding in |
hl(origin, s, rect, { color, label, icon, labelPos, labelDx, labelDy, dashed, fill, pad, num }) | a highlight box in page coordinates relative to a crop origin, with an optional tag above, below, left, right, inside or insideBottom |
org(x, y, rect) (gen-visuals.js) | the crop origin hl() takes: the stage position of a crop and the page rectangle it starts from |
tag(text, icon, x, y, color) | a 30px label tag, 54px tall with 8px corners, in blue, red, dark or white |
badge(x, y, text) | the Before and After marks |
dot(x, y, n, color, size) | a numbered disc; the default size is 44, and the set passes 56 |
line(x1, y1, x2, y2, { color, w, start, end, dash }), elbow(x1, y1, x2, y2, { mx, ... }) | leaders, 3px by default, with optional end dots |
panel(x, y, w, h, inner) | a transparent group frame |
plabel(icon, text, muted) (gen-visuals.js) | the 32px panel header with a 34px icon |
label, iconLabel, xLabel, chipStyle (gen-visuals.js) | 28px chips; xLabel carries the red x mark of the hit list |
stage(bg, html, { w, h, wash, vignette, fit }) | the page shell: fonts, ground, wash, vignette and the centering script |
gen-visuals.js and export-blog.py hold one post's set. The designing-docs- file prefix, VIDEOS, SKIP, POST_COVERS and the cover ids belong to that post, and src/lib/graphics.ts names the same prefix, areas, clips and cards for /graphics. A new post adds its own visuals and changes those names before exporting, so its files carry its own slug. Name the versions of one image with the suffixes -light, -og and -og-light; /graphics folds them into one row (splitVariant in src/lib/graphics.ts).
The recorder keeps one or two frames per second at a device pixel ratio of 4, and agent-browser record start reloads the page. Interactions are therefore captured as stop-motion: pause the Web Animations, step currentTime between screenshots, and assemble the frames with ffmpeg at variable durations. Screenshots re-fire trusted pointer events on the last clicked element, so the capture blocks real pointer events in the region and drives hovers with synthetic events. composite-videos.sh reads the crop rectangle from the rendered visual, overlays the clip on the still, and writes a 30fps MP4 and a GIF at 1400 wide with a 256-colour diff palette, sierra dither and rectangle diffing. graphics/build/capture-sidebar.sh is the worked example. Read references/stop-motion.md before recording.
A visual that shows the docs uses the docs' own measured system: #09090b ground, #fafafa text, #a1a1aa muted, #0078FF and #60a5fa blues, Inter and Geist Mono, radii from 4px on controls to 12px on panels, a 290px sidebar with 31px rows and a 746px content column from x 310. Icons follow the two tiers: Heroicons solid for anything that carries meaning and Lucide outline for controls. Flags are custom matte SVGs and never emoji. Read references/docs-tokens.md for the full table and the page rectangles, and re-measure when the docs change.
Blog visuals take the docs' colours because they depict the docs. Brand artwork takes the brand inks from gt-brand (ink #070707, paper #ffffff, #f2f2f0, #86a8ff, #2f5ce0).
public/media/ keeps the finished GT artwork made with the system, both the stills and the films. public/media/README.md describes every file in a table, and a new file gets its row there.
| Artwork | Files | Shown |
|---|---|---|
| Partnership globes | gt-globe-dithered.png, gt-globe-dithered-mark.png, gt-globe-glyphs.png, gt-globe-glyphs-light.png, each with a -transparent twin, 2048 by 2048 | /brand, Made with the system, Partnership globes (#made-with-the-system-partnership-globes), with download links for the PNG on its ground and the transparent PNG |
| X profile banner | gt-banner-signin.png and its @2x master, with banner-contact-sheet.png holding the ten explorations | /brand, Made with the system |
| Open Source reel and blog films | open-source-reel.mp4, *-film.mp4 and posters | /brand and /motion; films belong to gt-films |
The globes came from Kevin's request for "a small graphic" for a partnership (Kevin, 2026-10-01).
globe() in src/lib/dither.ts) printed through the 8x8 Bayer screen in #86a8ff on ink #070707 with 6px cells. Its settings are ambient 0.14, rim 0.16, landmass 0.42, gamma 1.15, radius 0.40 of the frame, tilt 0.15 and t 40; larger tilts bring the pole and its noise into view.#f2f2f0 over a three-cell knockout.#f2f2f0 and #86a8ff and ocean is half size in #2f5ce0, on 32px cells. Matching glyphs to ink density was rejected because dense CJK glyphs took over every bright area.#ffffff with land in #070707 and ocean in #2f5ce0 thinning to #86a8ff in the highlight. Its halftone runs the other way so ink carries the shadow.public/media.The globe sources live in motion/stills/partnership-globe/ (index.html?v=globe|gt|glyphs&mode=dark|light, render.mjs, carve.js, sheet.mjs). motion/ is untracked and belongs to the Videos session, so treat it as read-only from other lanes. dither-lib.js there is generated from src/lib/dither.ts by stripping the types, so the sign-in engine runs in the page unchanged. node render.mjs writes 2048px PNGs to motion/out/stills/ and refuses to overwrite an approved still whose bytes differ from its sha256 pin; REPIN=1 writes it and prints the new hash. Copy approved stills into public/media/ at their existing names.
/graphics is the page for still artwork and /motion the page for films (Kevin, 2026-10-03). Today /graphics carries the docs series, its contact sheets, its grounds and the figures of the earlier posts, and the globes and the banner are on /brand. Dithered photographs and scans of objects are artifact pictures, governed by gt-dither, and they carry no readable English text (Kevin, 2026-10-05).
Posts live in the generaltranslation/content repository, checked out in gt-cloud at apps/landing/content (blog/en-US/<slug>.mdx), and the images live in gt-cloud at apps/landing/public/static/blogs/. Prototemplate keeps its own copies of three posts under content/blog/ with their graphics under public/static/blogs/, read by /blog and /graphics; content/blog/designing-docs-for-humans.mdx is the reference for a post with carousels.
<Carousel label='…' width='3840' height='2160'> with one <CarouselItem src='…' alt='…' /> per visual. The blog's MDX renderer drops every expression prop, so blog components take string attributes and child elements.images, its light twin under imagesLight and the 2400 by 1260 card under ogImages, each with the ?v= stamp.public/static/blogs/, any new component) first and the content one second: every landing deploy checks out the tip of content main, so merging the content pull request publishes the post, and content first publishes it with broken images.references/blog.md).graphics/social/ holds the launch posts for each set (an X thread and a LinkedIn post) with the image each one carries.| Symptom | Cause | Fix |
|---|---|---|
| A "pixelated" cover | 1x dither aliasing, and the optimizer re-encoding at quality 75 | Ground at 2x with square pixels; webp covers served at quality 95 |
export-blog.py stopped on a missing --covers.png | pnpm graphics:export -- --covers passed the -- through, so argparse read --covers as an id | pnpm graphics:export --covers |
| Every render failed to save on a fresh clone | graphics/build/out/ is ignored and absent, and the screenshot does not create folders | mkdir -p graphics/build/out |
| "Blurry" diagrams | 12px labels shown at 0.44x | Labels of 26px or more, 3px lines, a narrower composition |
| Lines clipped | 1px rules and 1.5px caps fall under a device pixel | 3px everywhere, labels on backing pills |
| Soft images on a sharp master | The browser shrank the 3840px master itself | next/image with BLOG_COLUMN_SIZES and the deviceSizes ladder |
| Text under the floor after a type increase | The fit scaled the wider composition down | Narrow the composition |
| A captured Lucide glyph drew as a lone corner | The SVG inliner stripped width and height from the glyph's <rect> | svgFile() resizes the root tag only (fixed 2026-09-24) |
| A visual shipped as Chrome's "site can't be reached" page | The 8765 server was down during the render | render.sh skips a page that never centres; still check each render's pixel standard deviation (below) |
| A crop renders empty | The SHOTS file is missing; the library marks it missing and sizes it 1440 by 900 without throwing | Confirm every shot a visual uses exists before rendering |
| A sparkle icon appears in a label | The Heroicon name does not exist and ico() fell back to sparkles | Fix every name graphics:gen lists under MISSING ICONS |
| A cover the post embeds as a figure went stale | export-blog.py skips H* ids unless they are in POST_COVERS | List the cover in POST_COVERS |
| Servers and the scratchpad are gone after a break | The desktop app stops servers and clears the scratchpad when the date changes | Restart 8765 and the dev servers before rendering; keep review sheets under graphics/build/out/_*.png |
| The hover pill jumped after every click in a clip | Screenshots re-fire trusted pointer events | Block trusted pointer events in the region and drive hovers synthetically |
Carousel prop items undefined | next-mdx-remote strips expression props | Child elements with string attributes |
| The content preview build failed | The preview app did not know a new component | Stub every new component in the content repository's preview app |
| The PR policy check failed | A feat title without a Linear issue | A docs(blog) title, or link the issue |
A render that is a flat error page has a low standard deviation (under about 20 in grey). Check the whole set in one pass:
python3 -c "import sys; from PIL import Image, ImageStat; [print(round(ImageStat.Stat(Image.open(p).convert('L')).stddev[0], 1), p) for p in sys.argv[1:]]" graphics/build/out/*.png | sort -n | headpnpm graphics:audit passes with no text under 26px, and every overlap warning was judged.ASSIGN, dimmed by BG_WASH; covers keep their light wash of 0.1 to 0.3.?v= stamp in the post was bumped.next/image at the column's device width./graphics and the post on /blog were checked on localhost:3005 in both themes before anything landed.public/media/README.md, a transparent twin where a partner will place it, and a pinned hash for an approved render.Related skills: agent-browser (captures, the audit and the render), create-graphics (choosing a route for diagrams and illustrations outside this toolchain), design-engineering-polish (the final visual pass), hyperframes (motion beyond a stop-motion clip). In this set: gt-website (the blog section and its MDX components), gt-brand (the inks and marks), gt-dither (artifact pictures and live dither fields), gt-diagrams (doubled-line connectors), gt-isometric (isometric plates and exploded views), gt-films (blog trailers and the reel), gt-voice (alt text and post copy).
docs/GRAPHICS.md (What a visual is, Sizing, The files, Procedure, Capturing, Backgrounds, Clips, Handing off to a post, Where it went wrong); served at /docs/graphics.graphics/README.md; graphics/build/gen-lib.js (SHOTS, BG, MIN_TEXT, PAD, the primitives, CSS, CENTER_SCRIPT, stage); graphics/build/gen-visuals.js (N, O, add, ASSIGN, BG_WASH, the covers); graphics/build/audit.js; graphics/build/render.sh; graphics/build/export-blog.py; graphics/build/composite-videos.sh; graphics/build/capture-sidebar.sh; graphics/build/sheet.py; graphics/serve/server.js; graphics/glyph/gfboot.sh, gfexport.sh, gfsurvey.sh.src/lib/blog-image-sizes.ts, next.config.ts (images.deviceSizes, qualities), src/lib/graphics.ts, src/app/brand/page.tsx (GLOBES), src/app/brand/brand-sections.ts, src/lib/dither.ts (globe), public/media/README.md, content/blog/designing-docs-for-humans.mdx.motion/stills/partnership-globe/render.mjs (local, untracked, owned by the Videos session)..agents/skills/blog-graphics-pipeline, docs-source-capture, glyphfield-headless-export, stop-motion-ui-capture and gt-docs-visual-tokens (written 2026-09-18 in 3d87326; the pipeline revised the same day in 70380de and the tokens on 2026-09-21 in c18a362)..agents/skills/glyphfield/SKILL.md and references/source-map.md; apps/landing/src/lib/studio-field.ts; apps/landing/src/components/blog/BlogPostCover.tsx (webp covers at quality 95), imageSizes.ts; scripts/deploy-landing.sh (content main on every deploy).skills/engineering/create-graphics/SKILL.md.docs/GRAPHICS.md differ, the skill follows the code and gt-cloud main: the set is thirty-three visuals and eight covers and cards (the doc says thirty-six), the GIF is 1400 wide (its Clips section says 1600), the ground is drawn pixelated (its trap table says smooth), the fit never scales up (its table says sparse compositions zoom to fill), webp covers are served at quality 95 (it says 90), and the gt-cloud pull request merges before the content one (it says content first). graphics/README.md writes pnpm graphics:export -- --covers, which fails under pnpm 11.The folder skills/gt-graphics as agents fetch it. Each file opens raw.
SKILL.mdreferences/capture.mdreferences/docs-tokens.mdreferences/glyphfield-export.mdreferences/stop-motion.md