Performance without visual loss

How General Translation keeps visual surfaces fast with no change to how they look.

Covers
Measure first with the machine's load recorded, 60 fps motion and no lag on first use, quality tiers driven by measured frame time, one WebGL context per engine and full cleanup on unmount, frozen previews in galleries, shader and module weight cut with a pixel-identity proof while the library API stays, Lighthouse on production builds, live values hydrated on static pages, and budgets that exit 1 on a breach.
When to use
When a page or animation lags, before shipping a shader, canvas or heavy component, and when Kevin asks for Lighthouse or performance work.
Also in
Landing pages, Motion
Section 1pnpm skills:install

Install

pnpm skills:install gt-performance --project <dir>

Run it from a Prototemplate checkout. It links skills/gt-performance into the project’s .claude/skills and .agents/skills; --copy vendors the folder instead, and --dry-run prints each step first.

Section 2SKILL.md

Instructions

General Translation (GT) runs live canvas and WebGL material (the dither globe, the glyph field, the studio field, the horizon shader) on generaltranslation.com, on the dashboard and in Prototemplate, and those pages must also load fast. This skill is the procedure for making any such surface fast without changing a pixel: the rule, measurement, quality tiers, engine lifecycles, shrinking code, Lighthouse, live values on static pages and budgets. Page specifics stay with their skills. gt-website references/docs.md ("Performance") holds the docs investigation and the Lighthouse commands for generaltranslation.com. gt-motion section 6 and DESIGN.md section 11 hold the engine contract, gt-dither holds the engines and their copies, and gt-cloud's react-best-practices covers React rendering.

Paths are relative to a Prototemplate checkout ($PROTOTEMPLATE) unless marked gt-cloud ($GT_CLOUD, GT's product monorepo, origin/main on 2026-10-05). A direction is one of the design-exploration pages under Prototemplate's /d/<slug>. The two scripts in this folder's scripts/ run from a checkout that has playwright-core (Prototemplate does) and find Chrome for Testing the way gt-aesthetic's measure-type.mjs does.

1. The rule

  • Performance is part of done, and it never changes the look. Kevin, 2026-08-05: "fix the lighthouse without changing anything aesthetically". Fix every audit and every hitch whose fix leaves the pixels alone. List each one whose fix would change the design (color contrast, tap target size, a dropped effect, a lower resolution) with its measured gain, and Kevin decides.
  • On 2026-08-05 these fixes on the site redesign's prototype (now Prototemplate) left every pixel identical:
  • a <dl> whose cells held buttons became divs, with the CSS re-anchored on existing data attributes;
  • a decorative mock headline <h3> became a styled <div>, so the outline steps in order;
  • the existing rail div became <main>, with no new wrapper;
  • the marks got intrinsic width and height as ratio hints, and CSS keeps their size;
  • <body suppressHydrationWarning> silenced the hydration error that extensions such as Grammarly cause by stamping attributes on the body;
  • robots.ts was added.

color-contrast and target-size went to Kevin as design changes.

  • A target-size fix can keep the look when the hit area grows and the paint stays. gt-cloud's .sgdh-ins-mark (apps/landing/src/components/landing/home/sections/translate-window.css) is a 25 px invisible button whose 11 px badge is painted by ::before.
  • Contrast fixes move color tokens, so they wait for Kevin's call. He made it on 2026-08-07 with a preview's report and "review this lighthouse report again and resolve the issues". The flagged text rose to 4.5:1 in both themes, and gt-landing-pages ("Process") lists the floors that shipped.
  • Motion runs at a smooth 60 fps at full resolution. Kevin flagged the blue lines of the Locadex diagram for looking choppy where they should run at "smooth 60 fps" (2026-08-05), and rising glyphs that looked "less than 60fps" and needed to "look full resolution" (2026-08-06).
  • A pattern on a moving sprite moves by whole pattern cells. The glyph rain first moved its dithered sprites in whole CSS pixels to keep the Bayer pattern rigid, and slow rises then stepped at about 15 Hz (the "less than 60fps" report). The atlas then moved to device-pixel cells blitted on whole device pixels, so each step translates the pattern by whole cells (ditherAtlasRows in src/lib/glyph-field.ts). The moving rain still flickered, and Kevin removed the dither from it: "the dither is making it flicker every time it moves. remove the dither" (2026-08-06). Depth now reads by size and alpha (TIER_COVER), and the dither stays for static consumers.
  • Loops start full and with no lag. Kevin asked for a loop that "works without a startup lag" (2026-08-04). On 2026-08-06 he flagged black space at the bottom of the glyph field that "fixes itself as animation progresses". The field now re-measures and re-seeds on first visibility, so its first frame is complete (src/lib/glyph-field.ts, start()), and createDitherLoop paints one frame synchronously before its first rAF.
  • The first interaction and heavy events never stall. Lazy work runs on first use: a shader compiled on the frame of the first click, an atlas baked on first hit, a raster sampled on the first frame of a move. The glyph field rastered the next word on the morph's first frame and stalled for about 200 ms on a throttled phone. It now prepares the raster during the hold through requestIdleCallback (the prepared sample set in src/lib/glyph-field.ts). Measure each event twice, the first use and the second. Move anything heavier on the first use into idle time, behind a loading state, or into a warm-up render before the user can act.

2. Measure first

  1. Record a baseline before touching code, and the same numbers after. Report medians with their spread: · frame time: the median, p95 and the share of frames over budget; · long tasks during the interaction, as a count and total ms; · GPU work where the engine exposes it: draw calls or triangles per frame, and GPU time from the DevTools Performance panel; · weight: raw, minified and gzipped bytes and the share of the route chunk (section 5); · Lighthouse metrics for load work (section 6).
  2. Measure frame time with scripts/frame-probe.mjs. It samples requestAnimationFrame intervals for a fixed window after a warmup, and prints the median, p95, worst interval, slow share, long tasks, WebGL contexts created and lost, canvases in the DOM, any glyph-field tier, the GL renderer and the load average before and after (node skills/gt-performance/scripts/frame-probe.mjs <url> --runs 3; the flags and more examples are in references/recipes.md). Headless Chrome on Kevin's Mac draws WebGL on the real GPU (the renderer column reads ANGLE Metal). Where it reads SwiftShader, rerun with --headed. The Claude in-app browser pane reports document.hidden as true, pauses requestAnimationFrame and leaves shared-engine canvases blank, so never measure motion there. Discard the first probe after a dev server compiles a route: on 2026-08-07 a first probe of the hero read 21 fps with a 1.6 s frame, and unthrottled repeats read 118 fps.
  3. Keep a live readout in a corner of the dev build while working on motion: fps, median and p95 frame ms, and the quality tier. It is a dev tool and leaves before the pull request.
  4. Use the real app the way a user does: scroll, hover, click, navigate between routes, resize, zoom and flip the theme. Record every lag spike with its attribution (console, the DevTools Performance panel with CPU throttling, long task entries, Lighthouse), fix them one at a time and re-measure after each fix. Then read the authoritative guides for the stack (web.dev's performance guides, MDN's WebGL best practices, the library's own performance page) for further wins.
  5. Record the load average beside every timing (uptime, or the load frame-probe prints). The machine is shared with parallel agent pipelines: in September 2026 they pushed the one-minute load to between 100 and 370, and it read 280 on the 18-core machine while this skill was written. Every timing reads slow under that load. Rerun at low load, take the before and after runs back to back under similar load, and compare deltas. frame-probe warns when the load is above the core count.
  6. Report each metric before and after in a table, with how it was measured (tool, viewport, DPR, throttle, load) and what remains. gt-reporting holds the report's form.

3. Quality follows measured device performance

Kevin asked on 2026-08-07 whether the glyph rain matched the device's real performance or only its responsive width. It keyed off width alone, so a wide window on weak hardware got the heaviest tier and a narrow window on a fast laptop got the lean one. Kevin approved a frame-time governor the same day, and Prototemplate's src/lib/glyph-field.ts holds the pattern:

  • Width still sets the layout, and a narrow field (under 880 px) starts on the lean tier. Every quality site reads one combined flag, lean = narrow || govTier >= 2.
  • Cadence is judged over windows of 120 counted frames (GOV_WINDOW) after a 2.5 s warmup (GOV_WARMUP_MS), skipping any interval over 900 ms as a hidden tab (GOV_GAP_MS). A window trips when 35% or more of its frames exceed 22 ms (GOV_TRIP, GOV_BUDGET_MS), which is a sustained cadence below about 45 fps.
  • The trip counts slow frames. The first draft watched a rolling median above about 20 ms, and throttled hardware missed the budget on 34% of its frames while the median stayed healthy.
  • Tier 1 drops the device-pixel cap from 2 to 1.25 through one resize(), so the backing store, the atlas and every blit change together. Tier 2 binds the frame path to the lean pool (560 of 1,280 glyphs) and waits until no glyph is in flight, so no particle leaves the draw path mid-move.
  • The shipped ladder only steps down, so it can never oscillate. A governor that also steps up does so only after sustained headroom and never switches a tier in the middle of a move.
  • The tier is written to canvas.dataset.gfTier for probes. Proof on 2026-08-07: a CDP throttle at 12x stepped to tier 2 within the window and an unthrottled 10 s control never tripped. Reproduce with frame-probe's --cpu 12 (its tiers column).
  • A loop with a frame cap judges raw rAF cadence. The band rain drifts 2 to 8 px a second and draws at fpsCap: 30, which skips half the paints. Its governor counts every rAF tick, so a skipped draw never reads as a slow frame (src/app/d/production/sections/ink-field.ts, 2026-08-07).
  • The copies differ. On 2026-10-05 gt-cloud's packages/ui/src/lib/glyph-field.ts has the zoom watcher (section 4) and no governor, and Prototemplate's copy has the governor and no zoom watcher. A performance fix goes into every copy in the same round, the rule gt-dither section 2 sets for the studio field's copies.

4. WebGL and canvas lifecycles

Chrome keeps about 16 live WebGL contexts, and past that a field draws black with no error, so a shared engine holds one context per engine module per session, stops its loop when no field is on screen or the tab is hidden, and a canvas that grows never shrinks back. The full rules, the incidents behind them and the files that follow them are in references/webgl-lifecycles.md.

5. Shrinking shaders and modules

  1. Measure first. Kevin, 2026-08-13: "how many kb is our ditherglobe". Report the source, the minified module, the minified and gzipped module, and the tree-shaken bytes the page ships, which is the number that counts. The answer that day: 47.3 KB of source (mostly comments), 4.8 KB gzipped for the whole dither.ts, and 2.8 KB gzipped for what sign-in imports. Then read the built chunks for a string the minifier keeps, such as a GLSL uniform name (the loop is in references/recipes.md). One source module can still land in several route chunks: on the 2026-10-01 Prototemplate build the horizon shader sits in four chunks of 11.7 to 19.3 KB gzipped. Report the count with the sizes.
  2. Merge identical copies. On 2026-08-01 two direction forks each shipped an identical 20.9 KB horizon-field.ts. One shared src/lib/horizon-field.ts replaced both.
  3. Move GLSL comments out of template literals. A minifier cannot strip text inside a string, so every comment in a shader ships to every visitor. The narration lives in a TypeScript SHADER NOTES block beside the literal (src/lib/horizon-field.ts, src/lib/studio-field.ts). The horizon fragment literal went from 6.5 KB to 4.4 KB, and the module from about 7.4 KB to 4.6 KB gzipped.
  4. #version 300 es stays the literal's first characters (` const FRAG = #version 300 es ``). A compaction that put a newline before it failed the compile silently, and only the DOM fallback disc rendered until the screenshot pass caught it. After any edit to shader text, check that the shader draws and the fallback is hidden.
  5. Keep the full configurable library API in source. The same day a second cut folded about twelve unused uniforms into GLSL constants, made the setters table-driven and deleted pause, resume and renderStatic, for 2.5 KB gzipped. Kevin reversed it (2026-08-01): "add back the functions and whatever to configure, this is still good for making it into a library", and asked for the compiled output to be lighter at build time. Every parameter stays a uniform with a setter and a default, the build's minifier shrinks the shipped code, and any GLSL identifier minification runs as a build step over readable source.
  6. Prove identity with a pixel diff of a deterministic frame. Reduced motion renders one deterministic frame for every engine that follows the lifecycle contract. Capture the baseline twice and confirm the two match at zero. A baseline that differs from itself is not deterministic, and a diff against it proves nothing. Then make the change, capture with the same flags and compare with scripts/pixel-diff.mjs (capture <url> --out <png>, then compare <a.png> <b.png> --diff <png>, which exits 1 on any difference). The sequence is in references/recipes.md. Report residue exactly. The constant fold left 4 of the 4,039,200 channels in a 1,009,800-pixel capture off by 1/255 each, because GPU drivers may fuse multiply-adds differently after any source change. The revert diffed at zero.
  7. When a target is out of reach without loss, give a ledger. Kevin asked for 1.5 KB; the answer was 2.5 KB with the remainder named: about 1 KB of gzipped GLSL that draws the lens, plus the shared-context singleton, the visibility gating and the context-loss handling. Each further cut was named with what it would remove.
  8. Question every dependency added for one feature, and reuse what exists. Kevin, 2026-08-28, on three packages installed for one feature: he would "prefer to not have package bloat", and the system should reuse as much as possible. Judge each package on its own. cheerio was replaced by a small tag scanner, verified byte-identical against an 11-case reference harness and the live results, and ten packages left the lockfile. franc stayed because nothing in the monorepo detected language, and undici stayed for the DNS-pinning Agent that global fetch lacks; each kept package carries a one-line comment saying why. The contact route moved onto the shared limiter in gt-cloud's apps/landing/src/lib/rate-limit.ts.

6. Lighthouse

  • Measure a production build in a clean profile, or production after the merge. Dev mode ships unminified code and dev chunks, and an everyday Chrome adds extension work. Kevin's 2026-08-05 run against the dev server scored 0.65 on performance, and Lighthouse itself warned that Chrome extensions had slowed the load. The Lighthouse CLI launches Chrome with a fresh profile; for Prototemplate run pnpm build && pnpm start and point npx -y lighthouse@12 at port 3000 (the full command is in references/recipes.md). For generaltranslation.com, gt-website references/docs.md ("Performance") holds the local command, the cache check per framework and locale, and the way into SSO-protected previews.
  • Read field numbers and the mobile score. Kevin's "FCP and TTFB kinda slow no?" (2026-09-14) came from PageSpeed Insights field data: CrUX, the origin's 75th percentile, redirects included. The lab desktop run on the same page scored 95 with a 0.3 s FCP, and that did not answer the complaint. Report mobile and desktop, lab and field.
  • Run each cell three times and report medians with the range. The causes of past gaps, the landing's missing preview and the scope of a performance PR are in references/recipes.md.
  • Attach the real report. Kevin, 2026-09-15: "show the actual lighthouse scrfeenshotds". The pull request body carries screenshots of the Lighthouse report (scores and metrics, before and after) and the numbers in a table; gt-ship section 4 holds where PR images live. Confirm the body edit or comment landed. In September 2026 a gh pr comment inside a pipeline failed silently because the command takes no --jq flag, and the round's numbers never posted.

7. Static pages with live values

A prerendered page never bakes a live value into its HTML. On 2026-08-18 the GitHub star badge on a blog post was missing in production while localhost showed it: the prerender's GitHub fetch had failed, and the HTML served the empty result. Kevin: "maybe it shouldnt be a server side. we can cache on server side". The pattern on gt-cloud main (#4380):

  • The page stays static and renders the formatted known minimum (formatGitHubStarCountKnownMinimum in apps/landing/src/lib/github-stars.ts) as its first label.
  • apps/landing/src/app/api/github-stars/route.ts is a same-origin route. getGitHubStarCount() (apps/landing/src/lib/github-stars.server.ts) fetches GitHub with next: { revalidate: 3600 }, a 5 s timeout and a schema check. The route answers with Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, or with 503 and no-store when GitHub fails.
  • apps/landing/src/components/ui/GitHubStarCount.tsx fetches the route once on mount through useMountEffect, aborts on unmount and ignores values under the known minimum. It holds its slot, invisible while a deferred label resolves, so the badge never shifts the layout.

Any count, status or price that changes after the build uses the same shape.

8. Budgets are gates

  • A budget lives in the probe that measures it, and a breach exits 1 and blocks the change. In July 2026 a 3D scene's probe script held a BUDGET block: median triangles per frame at 1080p, every shadow pass included, over a fixed 70 s scripted run, capped at 7 million. Two content rounds raised the median to between 10 and 12 million, and the gate failed hard. The regression note named the rounds, the medians before and after, the mechanism and the fix.
  • frame-probe takes --max-median, --max-slow, --max-contexts and --max-long and exits 1 when the median run breaches one. Run it from Prototemplate against the gt-cloud page, and put the command and its output in the pull request body. Probe scripts stay out of the gt-cloud diff (gt-ship section 3, "Verification instruments stay out"). A red budget blocks the change like any other failing check.

Review checklist

  • [ ] Before and after numbers are recorded, each with its tool, viewport, DPR, throttle and load average, and the deltas were taken under similar load.
  • [ ] The look is unchanged: a pixel diff of a reduced-motion frame where the change touched a shader, canvas or style, with any residue counted; audits whose fix would change the design are listed for Kevin.
  • [ ] Motion holds 60 fps at full resolution, a pattern on a moving sprite moves by whole cells, loops start full, and each first use costs what the second use costs.
  • [ ] Quality tiers follow measured frame time with a warmup and gap skip, never switch mid-move, and width sets only the layout.
  • [ ] A shared engine holds one context and its canvas only grows; a component-owned context deletes its objects and loses the context on unmount; exactly one canvas exists after a strict-mode mount and a hot reload; ticker callbacks unhook offscreen; no per-frame blit reads from a willReadFrequently canvas.
  • [ ] Gallery previews are stills at rest and mount a live frame only on hover; a live frame off screen is frozen; animations run on rAF.
  • [ ] Engines used on several pages live in one shared library, resize and re-read the pixel ratio on zoom for every usage, and a fix reached every copy.
  • [ ] Weight is measured raw, gzipped and tree-shaken with its chunk count; GLSL comments sit outside the literals; #version comes first; the library API is intact; each new dependency is justified on its own.
  • [ ] Lighthouse comes from a production build or production, three runs per cell, mobile and desktop, field numbers read, any split or gap named with its cause, and the screenshots are in the pull request.
  • [ ] Live values hydrate from a cached same-origin route with a fallback.
  • [ ] Every budget passes, and its command and output are in the pull request body.

In this set: gt-motion (the engine contract, the governor's place in it, the moving type law), gt-dither (the engines, their copies and their cost tables), gt-website (docs performance, Lighthouse commands, deploys), gt-verify (proving a visual fix), gt-local-dev (review servers and dev environments), gt-reporting (the before and after report), gt-ship (the pull request body, its screenshots and what stays out of the diff), gt-landing-pages (the contrast floors), gt-lints (gates). In gt-cloud: react-best-practices, react-useeffect, glyphfield, gt-landing. Wiki: perf (the cheap-to-invasive investigation ladder), core-web-vitals, agent-browser.

Sources

Dated provenance for every rule, script and number in this skill is in references/sources.md.

Section 36 files

Files

The folder skills/gt-performance as agents fetch it. Each file opens raw.