Every hand-drawn surface of the app (a progress grid, a chart, an editor, a game board) is one Skia
<Canvas> whose single <Picture> is recorded on the UI thread from the surface's pure draw(),
animated by a timeline that a self-stopping scene clock plays, and proven by unit tests, a draw-call
budget and pixel goldens. This skill ships the canvas kit (src/canvas/), a template surface and two
checkers that prove the result.
Rules that must hold
- One
<Canvas>per surface with one<Picture>recorded inuseDerivedValuefrom the surface'sdraw(), and every computed picture lives in the kit or a surface folder. Why: dynamic pictures are Skia's immediate-mode case; one picture per frame keeps cost predictable; a computed canvas elsewhere (a Picture sparkline incomponents/, animated Skia nodes in a child component) escapes every rule below. Retained nodes only for static art: a declarative gradient, blur or fixed shape may live anywhere (sizing it throughonSizeis layout, not motion), and art recorded once (createPicture) is listed in the contract'sstaticCanvases. An atlas only for hundreds of sprites. draw(canvas, frame)is a pure worklet in a'worklet'file: it reads onlyframe(view,fx,highlight,colors,layout,kit), imports Skia types only, allocates no Skia objects, reads no clock or random source, and imports values only from other'worklet'modules. Why: the same function then renders on device, in Jest goldens and in Node with identical pixels, and a plain function called on the UI thread crashes at runtime only.- An opaque canvas paints every pixel first.
SurfaceCanvasis opaque by default, sodraw()starts withcanvas.drawColor(colors.color.background); a canvas that must show what lies behind it is declared"opaque": falsein the contract and everySurfaceHost/SurfaceCanvasrendering it passes a literalisOpaque={false}. Why: on Android an opaque canvas is a SurfaceView, neither clipped by a rounded parent nor blended with what lies behind it, so unpainted pixels show black on device while iOS and the goldens look right; the declaration alone changes nothing on screen. - Push one
sceneshared value per committed change (makeScene(seq, view, tracks),startAt: NOT_STARTED); the frame runner stampsstartAt = info.timestampon its first frame. Never usetimeSinceFirstFrame. Why: Reanimated resets it to 0 on every re-activation, which froze every animation after the first (verified on Reanimated 4.5.1). - The frame callback runs only while something moves and its body is one call into
runSceneFrame, which holds thetry/catch; never Skia'suseClock()orwithRepeat(…, -1). Why: an idle surface must cost nothing (both of those run every frame forever), an uncaught UI-thread error kills the app, and React Compiler bails out of hooks with value blocks insidetry/catch. - Persist before animating; a new change fast-forwards; only changes committed while the surface is mounted animate. The store applies and saves, then
presentChangecancels old cues, pushes the final view and schedules new cues; the change present at mount is shown without animation. Why: killing the app mid-animation must lose nothing, the view is always the final state, and a screen opened again must not replay its last celebration. buildTimeline(events, motion)is pure and honours'reduced': half durations, no particles or shake, no overshoot. Why: reduce motion is an accessibility requirement, and as data it is testable.- An effect layer that is not a tween of something visible (flash, glow, burst, shake) draws only
while its track plays (
isPlaying:ageMs > 0 && progress < 1). Why: a sample holds a track'sfrombefore it starts, so an unguarded effect shows at full strength at moment 0. - Selection and hints are UI state the screen passes as
highlight(CanvasHighlight,EMPTY_HIGHLIGHT), and a drag preview sits exactly on the hovered cell. The gesture layer applies the surface'sdragLiftPtonce; draw adds no offset. Why: stores, undo and persistence never see a selection, and the previewed cell must be the cell that receives the drop. - The layout is a pure worklet of the actual canvas size, shared by draw and hit-testing. Why: tablets, landscape, foldables and resizable windows need no special case, and draw and touch cannot drift.
- Never pixel-flip. A surface that opts in (
isMirroredInRtl) mirrors positions in the layout. Why:scaleX(-1)mirrors letters and digits. - Digits are localised in
toView()and drawn withdrawText(drawFittedTextin a small shape); words and complex scripts use a cached SkiaParagraph; widths come from glyph widths; fonts come from the bundled TTF through oneuseFontsprovider on both platforms. Why:drawTextdoes no shaping,measureTextis unimplemented in CanvasKit (Jest, Node), and Skia's Android font manager cannot see fonts embedded with theexpo-fontplugin. - Paths are built once with
Skia.PathBuilderat unit size and placed withcanvas.translate/scale. Why: the mutableSkPathAPI is deprecated in Skia 2.6 and allocates. - Palettes are semantic tokens in sets with identical keys (
light,dark, plus the opt-in setscontracts/canvas-surfaces.jsonlists), beside acanvas-contrast.jsonsaying which colour sits on which; every declared pair meets WCAG 2.2 AA in every set (text 4.5:1, graphics and shapes 3:1,distinctkinds apart under colour-blind simulation); meaning is never carried by colour alone. Why: a missing token is a crash on one theme only, and contrast is provable only when the pairs are declared and measured;accessibility-as-code's contrast check measures the same file, so its format is stable and the pairs are never declared a second time incontracts/contrast.json. - Stop everything when the surface may not run, and switch the clock only through
scene-clock-state.useCanvasLifecycle(app active, screen focused and not covered) stops the clock, cancels cues and suspends audio; a scene pushed while the surface may not run is held until it may; every resume records at least one frame; a done message names its run, and JS never reads the scene shared value back. Why: an overlay or an unfocused screen that stays mounted must not keep a display link running, no cue or sound may fire once the app leaves the foreground, and a stale done read throughscene.get()froze the picture after an interruption (the case study intimeline-and-clock.md). - iOS: keep
CADisableMinimumFrameDurationOnPhonetrue. Expo's prebuild template sets it, so a Continuous Native Generation app needs no key; never override it withfalsein the config or a plugin, and committed native folders keep it inios/<App>/Info.plist. Why: without it iOS caps the app at 60 fps on ProMotion iPhones. Android needs no key. - Every canvas is one accessible image:
accessible,accessibilityRole="image"(orrole="img") andaccessibilityLabel(oraria-label) =t()ofsurface.describe(view); a purely decorative canvas opts out explicitly (accessible={false}). Why: Skia content is invisible to VoiceOver and TalkBack, labels must be translated, and React Native 0.86 defaultsaccessibleto false: iOS ignores the label and role of a view that is not accessible. - Every surface is proven three ways: pure unit tests (timeline, clock, layout), a draw-call budget test that also asserts every localised label, and pixel goldens at 3 sizes × moments 0, 0.5, 1 with 0.1 % tolerance, accepted only after opening every PNG. Why: a two-digit label is below the golden tolerance, so pixels alone miss it; goldens catch what assertions forget.
- Draw-call budget per surface on the busiest frame, never above 1,000. Why: counting canvas calls is the headless stand-in for GPU cost; a higher number needs an on-device frame report.
- Server data becomes changes through
useCommittedChange(data, diffEvents)with a pure, testeddiffEvents(previous, next)in the surface folder: the first data and identical refetches animate nothing; an optimistic update and its rollback are two changes. Why: a query has no reducer to return events, and a chart that replays on every refetch is noise.
Workflow
- Decide and classify. Read references/architecture.md: draw with
Skia only when the picture is computed; pick the kind (timeline: this skill end to end;
simulate-then-replay or live: the loop comes from
realtime-game-loop, palettes and drawing rules from here). A surface that takes touch also needscanvas-gestures-and-hit-testingin the same session. A game board: also read references/game-boards.md. - Copy the kit. Copy
templates/contracts/canvas-surfaces.jsontocontracts/and fill in the first surface, then from the repo root runnode ${CLAUDE_SKILL_DIR}/scripts/check-canvas-files.mjs .: it lists every file to copy. Copy eachkit-file-missingfile verbatim fromtemplates/to the path its first line names (templates/src/canvas/**→src/canvas/,templates/scripts/quality/**,templates/test/goldens/skia-golden.ts), andtemplates/src/hooks/use-is-app-active.ts(+ test) →src/hooks/when the app does not have it yet. Akit-import-unresolvedline names the prerequisite and the skill that creates it (the table in architecture.md). The iOS 120 Hz key comes from Expo's template (plist-120hzfails only an override or a committedInfo.plistwithout it); the canonicaljest.config.jshas the golden project (jest-golden-project). - Read before writing the surface: references/timeline-and-clock.md
for
buildTimeline, the clock and the lifecycle, references/drawing.md fordraw(), the kit, palettes, layout and text, references/api-traps.md for the worklet rules and API traps. Imitate the template surface and examples/weekly-chart/. - Copy the surface templates from
templates/src/features/__FEATURE__/canvas/tosrc/features/<feature>/canvas/, the golden test and its nine baselines fromtemplates/test/goldens/canvas/, and the contract's schema (templates/contracts/canvas-surfaces.schema.json). Replace__FEATURE__(tasks),__SURFACE__(kebab,progress-grid),__SURFACE_PASCAL__and__SURFACE_CAMEL__in file names and contents. The copy compiles and passes as is; then adapt the model, the view, the events, the layers, the palettes and the contrast pairs. - Build test-first, in this order: palettes and
canvas-contrast.json→canvas-model.ts→to-view.ts→build-timeline.tswith its test (budget, reduced motion, cues) →layout-<id>.tswith its any-size test →draw-<id>.tswith the budget, moment-0, highlight and label tests →<id>-surface.ts(isMirroredInRtl, thedescribekey in every locale catalog,dragLiftPtandtargetsOfwhen the surface drags or suggests). Runnpx jest src/features/<feature>/canvas --ci --selectProjects unitafter each step (paths first:--selectProjectswould swallow them). - Wire the screen. The feature's model hook passes the store's
CommittedChange(server data:useCommittedChange(data, diffEvents), the timeline reference's server section), theViewFormat, the livemotion,useIsFocused(),isInterrupted, the colours, the kit (built once theuseFontsprovider exists, drawing.md) and the highlight toSurfaceHost(display-only), or callsuseSurfacePlayerand rendersSurfaceCanvaswith the gesture layer'sinput(interactive). The label ist(surface.describe(view)). A canvas that shows what lies behind it passesisOpaque={false}(written out on the element, not through a spread) and declares"opaque": falsein the contract. - Lint, format and type-check:
npx eslint --fix src/canvas src/features/<feature>/canvas test/goldens scripts/quality,npx prettier --writeon them (import order and wrapping depend on the names you chose), thennpm run typecheck. - Create the pixel goldens deliberately (references/testing-and-goldens.md):
npx jest test/goldens/canvas/<id>.golden.test.ts --selectProjects golden -u, open all nine PNGs and compare them with the intended look (moment 0: old geometry, no effect that starts later, labels from the view already final; 0.5 mid-flight; 1 the final view), thenCI=1 npx jest --ci --selectProjects golden. - Run the checks:
node ${CLAUDE_SKILL_DIR}/scripts/check-canvas-code.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-canvas-files.mjs .. Fix everyFAILline (each names the file, the rule and the fix) and rerun until both printRESULT: PASS. - Look at it for real. A Release build on a named iOS simulator and a named Android emulator, screenshots in every locale (one RTL locale at least), then the developer confirms 120 Hz and the hitch rate on real phones with a frame-time recorder (simulators are not devices).
Definition of done
- The kit is in
src/canvas/with its tests, plustest/goldens/skia-golden.tsand the worklet boundary gate inscripts/quality/;npx jest src/canvas scripts/quality --ci --selectProjects unitpasses. - Each surface folder has palettes (identical tokens in every set) with
canvas-contrast.json,to-view.ts,build-timeline.ts,layout-<id>.ts,draw-<id>.ts,<id>-surface.ts, and their tests pass; every pair incanvas-contrast.jsonmeets its ratio in every palette set (the contrast rules ofcheck-canvas-files.mjs), and nocontracts/contrast.jsonentry names the surface's palettes (contrast-declared-once). - Moment 0 draws no effect that starts later (a draw test and the moment-0 goldens), the highlight is drawn, and a surface with a drag lift draws its preview on the dropped cell.
- The draw test asserts that the first call is
drawColor(or the surface is declared"opaque": falseand every element rendering it passesisOpaque={false}); every computed canvas lives in the kit or a surface folder, and no surface file sits outside one. - A server-data surface has
diff-events.tswith its test and feedsuseCommittedChange; reopening the screen replays nothing (use-surface-player.test.tspasses). - The busiest frame stays within the surface's draw-call budget (≤ 1,000) and every localised label is asserted through the recording canvas.
- Nine golden PNGs (3 sizes × 0, 0.5, 1) exist, were opened and looked at, and
CI=1 npx jest --ci --selectProjects goldenpasses. -
npm run typecheckandnpx eslint --max-warnings 0pass; no__PLACEHOLDER__is left. - Reduced motion removes particles, shake and overshoot; every canvas is
accessiblewith an image role and a translatedaccessibilityLabel(or opts out as decorative); the surface runs only while the app is active, the screen focused and nothing covers it (nouseClock, no endless repeat); nothing turns the iOS 120 Hz key off. -
npx jest src/canvas/scene-clock --ci --selectProjects unitpasses (push while paused, resume after done, stale done, and both recorded traces). -
node ${CLAUDE_SKILL_DIR}/scripts/check-canvas-code.mjs .printsRESULT: PASS. -
node ${CLAUDE_SKILL_DIR}/scripts/check-canvas-files.mjs .printsRESULT: PASS.
Neither checker prints SKIP lines. Both print NOT APPLICABLE only when no package.json declares
@shopify/react-native-skia, and both stop with exit 2 when a configured canvasSurfaceDirs glob
matches no surface folder or contracts/canvas-surfaces.json is malformed.
Anti-patterns
- Animating with
timeSinceFirstFrameor at0stored on JS. The second change freezes. StampstartAtfrominfo.timestampin the frame runner. - Creating
Skia.Paint(),Skia.Color()or paths insidedraw()or at module import. Native allocations every frame, and module scope breaks tests. Build once in the kit (makeDrawKit,buildPaths); one recorder per canvas viauseState. - Putting per-frame data in React state or re-rendering a label per frame. A 120 Hz frame is 8.3 ms. Positions live in shared values and the picture; React renders on committed changes.
- Storing only "the change" in the view and reading old state in
draw(). The view is the final state; anything that vanishes carries its own position in a track. - A frame callback that keeps running on an idle surface, on an unfocused screen or under an
overlay. Stop on
onDone(run, seq)throughfinishRunand throughuseCanvasLifecycle. - Guarding
onDonewithscene.get().seqon the JS thread. Until the UI applies a push, the JS read returns the old scene, so the done of the scene before an interruption stops the next scene on its first frame. Compare runs inscene-clock-state. - Starting the clock for a scene pushed while the surface is covered or unfocused. Its animation
plays where nobody sees it;
pushSceneholds it until the surface may run. - Reading Reanimated's
useReducedMotion()in canvas code. It reads the setting once at app start, so a later change is missed; pass the live setting asmotion. - Flipping the canvas with
scaleX: -1for RTL. Mirror positions in the layout instead. - Leaving the background unpainted on the default opaque canvas. iOS and the goldens look fine;
Android shows black corners. Start draw with
drawColor, or passisOpaque={false}and declare it. - Embedding fonts with the
expo-fontplugin and resolving them withmatchFontorSkia.FontMgr.System()for the canvas. Works on iOS only; Android falls back to a system font. Load the TTF withuseFontsand pass the provider everywhere. - Presenting the change a screen mounts with, or clearing store events to avoid it. The player
shows the mounted change's final view and animates only later
seqs. - Setting
structuralSharing: false, or puttingDate,Map,Setor class instances into the query data orselectresult a surface reads. Only plain JSON keeps its identity across refetches, so each refetch becomes a change and the chart replays. - A computed
<Canvas>(a<Picture>, shared values, draw calls) insrc/ui/orcomponents/, or a surface's files in a folder no surface glob matches. Make it a surface folder and render it withSurfaceHost; a static declarative canvas is fine anywhere. - Animating a canvas with Skia's
useClock()orwithRepeat(…, -1). The frame loop runs for as long as the canvas is mounted (idle, unfocused, covered); play a timeline through the scene clock. - A canvas with an image role and a label but no
accessible. TalkBack may read it, VoiceOver never focuses it; write all three (SurfaceCanvasdoes). - Declaring a surface's colour pairs again in
contracts/contrast.json. Two tables drift. The pairs live once, in the surface'scanvas-contrast.json, which this skill andaccessibility-as-codeboth measure. If that skill reportscanvas-palettes.jsonas an undeclared colour table, update it (drawing.md). isRtl && TextDirection.RTL. RTL is 0; choose explicitly.- Raising the golden tolerance or re-running
-uwithout opening the PNGs. Fix the code, or accept a deliberate change and say why. - Calling a plain JS helper from
draw()orlayout(). It throws on the UI thread at runtime only; move the helper into a'worklet'module (the template'scanvas-ids.ts). - Asserting small text through pixels. Use the recording canvas; 0.1 % tolerance cannot see a missing number.
- Drawing a flash or glow whenever its entry exists. It shows at full strength at moment 0; guard
it with
isPlaying. - Offsetting a drag preview in
draw()(row - 1). The item lands elsewhere and an edge row becomes unreachable; setdragLiftPton the surface and let the gesture layer lift the pointer. - Keeping a selection or hint in the store. Undo and persistence would see it; the screen passes it
as
highlight. - Measuring text with
measureText, or drawing golden text without a loaded typeface. CanvasKit returnsundefinedfor the first and draws nothing for the second.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/architecture.md | When to use Skia, data flow, file map, the kit's public API, the CanvasSurface contract, surface kinds, prerequisites and owners, the contract file and config keys, When multi-package |
Workflow step 1, before any canvas work |
| references/timeline-and-clock.md | Tracks, sampling, buildTimeline, the startAt fix, scene-clock-state, the frozen-surface case study with its traces, presenter, cues, lifecycle on iOS and Android, the clock trace and canvas-frame probe, 120 Hz |
Step 3, before writing a timeline or touching the clock |
| references/drawing.md | draw() rules, highlights and effects, kit and palettes, contrast pairs and the colour-blind profile, paths, layout, RTL, text and fonts, particles, atlas, performance, accessibility, the template and the example |
Step 3, before writing draw, layout, view or palettes |
| references/testing-and-goldens.md | Test levels, the draw-call budget, golden set-up and acceptance, failures, the device-only marker, headless previews, simulators, emulators and the E2E probe | Step 8, and whenever a golden fails |
| references/api-traps.md | Worklet rules, the SDK 57 API churn table, the re-verify list | Step 3, and when something works in Jest but not on a device |
| references/game-boards.md | Games appendix: board folders, kinds, Lights Out from the template, selection, hints and drag, recurring needs | Step 1, when the surface is a game board |
templates/src/canvas/ |
The kit: layout, types, timeline, draw kit, scene, clock decisions (+ the recorded-trace test), frame runner, clock hook, recorder, text, canvas (isOpaque), presenter, cues, lifecycle, player, host, server-data change hook, E2E probe, PNG painter, with tests (layout, types, track and sample are canonical synced copies, identical in every skill that carries them) |
Step 2 (copy verbatim) |
templates/src/hooks/ |
use-is-app-active.ts (+ test): the shared foreground hook the lifecycle reads, a synced copy of the canonical file react-components-and-hooks owns; keep the app's copy if it already has one |
Step 2 (copy verbatim, unless the app has it) |
templates/src/features/__FEATURE__/canvas/ |
The template surface (the Tasks progress grid): palettes, contrast pairs (canvas-contrast.json, format 1 in drawing.md), model, ids, view, timeline, layout, draw, surface object, tests |
Step 4 |
templates/test/goldens/ |
skia-golden.ts (synced canonical matcher), the __SURFACE__.golden.test.ts golden and its nine reviewed baselines |
Steps 2, 4 and 8 |
templates/scripts/quality/ |
check-worklet-boundary.ts (+ test) and worklet-transform.test.ts: the npm test gate for 'worklet' modules |
Step 2 (copy verbatim) |
templates/contracts/ |
canvas-surfaces.json (the app's surfaces, kinds, opacity, palette sets, staticCanvases) and its JSON schema |
Steps 2 and 4 |
| examples/weekly-chart/ | A second, complete surface folder to imitate: a server-fed bar chart with diff-events.ts, tweens, a guarded flash, RTL mirroring, two regions, fitted digits, its tests and a golden test that loads the bundled TTF (no baselines: you accept them in step 8) |
Steps 3 and 6 |
scripts/check-canvas-code.mjs |
Checks worklets, the frame clock and idle clocks, the worklets API, pure and opaque draw, the opacity contract, per-frame allocation, the Skia API, where computed canvases and surface files live, accessibility, lifecycle, the clock decisions and the reduce-motion source | Step 9 and at the end |
scripts/check-canvas-files.mjs |
Checks kit files and their imports, the layout probe's containment, surface files, palettes, the contrast pairs (measured in every set, never declared again in contracts/contrast.json), timeline and draw tests, goldens and baselines, the iOS 120 Hz key (no override, committed Info.plist), the Jest golden project |
Steps 2 and 9 and at the end |
scripts/selftest.mjs |
Proves both checkers pass the templates and catch each planted bug | After changing a checker or a template |
scripts/lib/ |
canvas-scope.mjs (kit and surface folders, the contract, declared packages), canvas-contrast.mjs (the contrast file's keys, WCAG and colour-blind maths), source-scan.mjs (synced: worklet, JSX and import helpers) and plist.mjs (synced: reads Info.plist) |
When changing a checker |
scripts/check-lib.mjs |
The shared script library (synced; never edit here) | Never by hand |
assets/shared.json |
The shared files this skill copies in | When adding a shared file |
assets/template-placeholders.json |
The placeholder values the template checks fill in (tasks, progress-grid) |
When renaming the template surface |
tests/bases/app/ |
Fixture base beside the templates: stand-ins for files other skills own (AppText, renderWithProviders, app.config.ts, jest.config.js), package.json, a live surface and the contract |
When changing a fixture |
tests/fixtures/ |
One overlay suite per checker: good/ plus one planted bug per case (mutation.json + EXPECT.txt) |
When adding a rule to a checker |
Related skills
canvas-gestures-and-hit-testing- touch on the canvas: gestures, hit-testing, drag, the pointer sample (load with this skill for any interactive surface).networking-and-server-state- the queries, keys and optimistic updates a server-fed surface reads.realtime-game-loop- fixed-step loops, simulate-then-replay and live surfaces.game-rules-engine- rules, events and seeded randomness for game boards.audio-and-haptics-feedback- the audio and haptics ports the cue scheduler calls.accessibility-as-code- contrast and colour-blind checks of the palettes, the live reduce-motion value, per-cell actions.golden-and-snapshot-tests- the golden policy and accepting baselines.performance-budgets- frame-time recording and on-device budgets.i18n-strings-and-catalogsandrtl-and-direction- the describe keys, digits and direction.unit-and-component-tests- the Jest projects, the Skia unit mock and the device-only marker.design-system-structure- the tokens the palettes take their values from, andAppText.e2e-maestroanddebug-menu-and-test-hooks- flows that read the canvas probe, and the switch that shows it.motion-and-animation- Reanimated animations of ordinary views.icons-app-icon-and-splash- code-drawn icons and store art from the same Skia code.expo-sdk-upgrade- moving Skia, Reanimated and Worklets to the next SDK.