CLAUDE.md — read this before touching anything here
What this project is
A design → dev handoff artefact: a framework-free, faithful reference
implementation of Lumosity 2 mobile screens, built to be translated into
the real Flutter app. It is the single source of truth for every screen and
every UI state — when design and engineering disagree on what the error /
empty / loading state looks like, the answer lives here, rendered.
Nothing here is lifted verbatim (the app is Dart/Flutter; there is no HTML to
paste). The markup is the pixel-true spec + contract: class names, data-*
attributes and comments are the integration spec — an AI-assisted dev reads
them and rebuilds the markup as Flutter widgets. Model shapes, state machines,data-field names and DATA/ACTIONS lines carry over 1:1 into widget
props and Riverpod providers. Optimise for accuracy + self-describing markup +
reuse, not just visual match.
So whenever you add or change something, ask: will a dev (or an AI dev) reading
only this file know exactly which real widget, token, and model field to bind
to? If not, it's not done.
The tooling
python3 check.py is the deterministic gate — the single source of truth for
mechanical conformance. Its twenty-five rules are enumerated in its own header
(check.py:22) — read them there, and don't restate them in prose
here: if a rule is mechanical it belongs in the gate, not in this doc. Taste,
faithfulness and "is this the right repo binding" stay human. It must exit 0 — a
pre-commit hook (.githooks/, via core.hooksPath) runs it on every commit. The
rules match the web artefact's; only the CONFIG block differs (no email/ here
and REPO_AVATAR_DIR=None, so those two rules are dormant).
Its companions — all four derived/local and gitignored:
shoot.py— visual baselines: screenshots every cataloguedcomponent/statespecimen per feature and pixel-diffs against<feature>/baselines/;--updateblesses intentional changes,--theme dark
runs the dark sweep (baselines suffixed__dark.png). On-demand only — see
Verify.spec.py— emits<feature>/spec.json, the machine-readable hand-off
manifest per feature; refuses while any HAND-OFF NOTE still carries aDATA/ACTIONSTODO.docs.py— keeps each feature README's component/state coverage current in
adocs:coverageblock, so a new state never blocks a commit on a doc chore
(runs in the pre-commit hook).repo_map.py— writesREPO-MAP.md, the grep-able reuse index ofconsumer-mobile.
Fast add-loop vs. handoff boundary. Adding a component/state is kept cheap:
reuse-grep REPO-MAP.md → scaffold → write markup/states → check.py → one
headless screenshot glance (light theme only). Everything else — filling theDATA/ACTIONS contract (/contracts) and folding README prose — is
deferred to the /handoff pre-flight, which refuses to pass until it is
settled. A missed deferred step can only delay a handoff, never corrupt one
(spec.py won't emit on a TODO contract). Visual regression is not on
this path (see Verify).
The handoff itself is two commands with a commit between them: /handoff
runs the pre-flight and FIXES the working tree (contracts, README) — run it BEFORE
committing; /handoff-diff reads the committed range and writes the dev
change summary into handoffs/ (gitignored, local-only) — run it AFTER. Split
because the pre-flight edits files while the diff note reads committed history.
The real app is the source of truth — reuse, don't reinvent
The production repo is /Users/jaydeepgajera/code/GitHub/consumer-mobile —
the "Lumosity 2" Flutter app (Dart · Riverpod for state · go_router for
navigation · GraphQL (lib/network/graphql/, feature queries/ classes) +
Realm DAOs for data). Treat it strictly read-only — we mine it, we never
edit it.
Before building a new screen or component, survey the repo and reuse what
exists — colours, typography, assets and whole widgets are pulled from the
repo, not invented:
| Need | Where in consumer-mobile |
|---|---|
| Repo conventions / docs index | AGENTS.md + .cursor/rules/*.mdc (read ui-components, graphql-patterns, routing, providers first) |
| Colour tokens (light + dark) | lib/theme.dart (LumosityColors context accessors, LumosityGradients, LumosityDecorations) — copied into common/tokens.css |
| Type roles | lib/components/words.dart (FontStyle enum, ~120 styles; the canonical Words(text, FontStyle.x) widget — raw Text() is forbidden in the app; every artefact text element carries a .type-* class naming a FontStyle value) |
| Spacing / radii / durations | lib/utils/layout_constants.dart (kSpacing* / kRadius* / kDurationMs*) |
| Fonts | fonts/ (Museo Sans .otf + Source Serif Pro .ttf) → common/fonts.css |
| Asset registry | lib/resources/images.dart (Images.*) + vector_images.dart; artwork under assets/ (~110 feature subfolders, SVG-heavy) |
| Shared widget pool | lib/components/ — its INVENTORY.md is the human index; a widget shared by 2+ features gets promoted there + an INVENTORY.md row in the same PR (AGENTS.md rule) |
| App shell (tab bar) | lib/screens/main_screen.dart (MainTab enum: Today / Games / My Brain + conditional Discover / Tests) + lib/components/custom_bottom_nav_bar.dart → our components/MobileShell/ |
| Routing | lib/router/ (go_router) — data-action nav targets become routes |
The rule for a first-time screen: look for an existing repo widget, token, or
asset that already does this. If one exists, reuse it and bind to it — record the
binding in data-repo as the repo-relative .dart path
(lib/components/cards/challenge_card.dart), not a bare directory name; a bare
snake_case stem is allowed only when it matches exactly one file under lib/
(check.py rule 13 enforces the precision). If you're unsure whether to reuse
vs. build new, ask the user — they will tell you. Net-new UI (no repo
equivalent yet) is marked data-repo="new".
How to actually look — read the curated index, don't just grep. Start at the
"Curated widget index" section at the top of REPO-MAP.md (the app's ownlib/components/INVENTORY.md, inlined) and scan the category you're building
into; its Use when column is the only place a widget is described by what
it's FOR. The long auto-generated "Widgets" table below it is harvested from
filenames and class names and most rows have an empty Purpose — so a concept
grep ("dialog", "avatar", "progress") returns nothing even when a perfect widget
exists. A failed grep is not evidence that nothing exists, and treating it as
such is how reuses get missed.
Three things follow from that, all gated:
data-repo="new"is a claim, not a default — it requires aREUSE
line in the HAND-OFF NOTE saying what you checked in the curated index and why
it doesn't cover the case (rule 21). Recording a real repo GAP there ("no
avatar widget exists anywhere inlib/") is a first-class outcome.- Every raw value is either a token or classified. A colour, a spacing px, an
art dimension (--icon-size-*mirrorskIconSize*— an icon or medal size
is as bindable as a padding) or a duration in component/feature CSS must
resolve through avar()or carry thedesign valuemarker (rule 20a/c/d/e).
The marker is the deliverable: it tells the integrator "there is no app token
for this — don't go hunting, register a new one." - Typography is the exception: there is no marker escape, and no "nearest"
escape either.common/tokens.cssis a 1:1 MIRROR of theFontStyle
enum — one role per enum entry, named after it EXACTLY (.type-headerMdBold
ISFontStyle.headerMdBold), generated fromwords.dartand never
hand-edited. So the set of faces this artefact can render is the set of
faces the app ships. A component stylesheet never spells a face out infont/font-size/line-height; it names the role on the markup (rules 20b +
23), and the role name IS the binding — no comment to read, no nearest-match
judgement left to the integrator.
This replaced 42 "design value" roles, faces with noFontStyle
equivalent that each named their nearest real one in a comment. That gap
between the class name and the binding is precisely where drift entered: a
Flutter dev reading class names rather than CSS comments snapped them to a
DIFFERENT nearest (13px meta →captionNormal12, 14/500 bubbles →bodySmall14/300). A name that can't be misread can't be mis-snapped.
A face the design wants that the app does not ship is no longer expressible
here — log it under "Type rungs to register" inREADME.md, render the
nearest shippedFontStylemeanwhile, and the artefact keeps showing only
what the app can actually build today. When a rung lands inwords.dart,
re-run the generator and move the markup onto it.
Two roles on one element is a bug, not a merge — the later one intokens.css
silently wins; use.u-subdued/.u-disabledfor colour. A control's HEIGHT is
never a face:line-height:44to make a tap target is a layout job for the
component (min-height), becauseWords()never carries a control's height. - Every tappable declares what it becomes. A
<button>, arole="button"or
an<a data-action>carriesdata-repo(rule 22) —shadow_button.dartfor a
real CTA,press_scale.dartfor a chip,dialog_close_button.dartfor an icon
close, or"new"with the REUSE line above. A control that declares nothing is
how an invented button gets mistaken for a deliberate one. - Buttons are never a judgement call — see the next subsection.
Buttons are ShadowButton — never invent one
Every tappable button in this artefact IS lib/components/shadow_button.dart,
and its visuals live in exactly one place: common/buttons.css. Do not
author a height, a font, a radius, a fill or a :active for a button in a
component stylesheet — the component contributes only LAYOUT (margin, flex,
width in a row). check.py rule 19 enforces both halves.
- Compose, don't restyle:
.shadow-button+ a height (--h40/--h52; the
base is ShadowButton's own default 64) + a width (--blockor--flexible,
see below) + optionally--pad-<n>+ a variant (--outlined,--outlined-neutral,--outlined-primary,--destructive-outlined,--danger-filled,--tertiary,--premium-gold).
Each variant is a real call-site recipe from the app, named in the file. - Every carrier declares itself:
data-repo="lib/components/shadow_button.dart",data-variant="<recipe>-height-<40|52|53|56|64>", and an HTML comment above it
spelling out the constructor call — that comment IS the integration
instruction, and rules 24–25 check it rather than trusting it. - Width and label inset are PARAMS, not CSS you get to pick. A button's
horizontal padding islabelHorizontalPaddingand its full-width-ness isexpandHorizontally; both must be stated in the comment and must match the
markup (rule 25). Width has two true spellings —--block(width:100%) and--flexible(aFlexiblechild, wherewidth:100%would break the flex
ratio); either meansexpandHorizontally: true. Reaching forpaddingon a
button in a feature stylesheet is a rule 19b failure: add a--pad-<n>. - A recipe citation is load-bearing. When a comment cites
some_screen.dart:NN, rule 24 resolves it and compares: every param that call
site passes which changes how the button LOOKS must appear in your comment.
Carry it, or name it and say why you deviate — silently dropping one (this is
how--outlined-neutralonce shipped the wrong pressed colour) fails the gate. - Pressed is a COLOUR SWAP, never a scale. ShadowButton has no
PressScale:
a filled button drops to 50% opacity, an outlined one floods with its stroke
colour — unless the recipe passesbuttonPressedColor, which wins (the--outlined-neutralchip is quiet at rest and floodsprimaryAccentwith
white ink on press). It arrives free with the widget — never wrap one in a
press animation. - Loading keeps the label.
isLoadingleaves the text where it is and pins a
20×20 spinner atright:16; it does not disable by itself, so it is always
paired withisDisabled(.is-loading+ a realdisabledin the markup).
A label swap to "Leaving…" is wrong. - Icon-only closes are
dialog_close_button.dart; a 64px icon circle isround_button.dart. Both are represented incommon/buttons.css.
The backend does not exist yet for new features — this artefact is what they
get designed from. So every component's HAND-OFF NOTE carries the two contract
lines the gate requires: DATA (the proposed Riverpod provider + GraphQL
query class supplying its model, or the existing repo one) and ACTIONS
(every data-action its markup/JS fires → the proposed notifier method or
go_router route, and what triggers it). Model shapes + these lines ARE the
proposed API contract; <feature>/spec.json aggregates them per feature for the
backend designer.
The new-screen flow in one line: user gives a Figma (or a web feature to
port) → pull each block's widget reference, type + colour tokens and
assets from the repo, wire them in, and leave inline comments naming the
exact repo binding per block. (See Runbook B.)
Porting a web feature (the standing workflow)
There is no frozen v1 HTML in this repo — the reference for ported features
is the web designs repo (/Users/jaydeepgajera/code/GitHub/designs,
read-only), where each feature was first built against the React web app. The
standing workflow is porting those built features to mobile; /port-feature
is the flow's entry point. The split:
- Carries over verbatim: component ids + state machines,
data-fieldnames +
the domain model,data-actionnames,DATA/ACTIONScontracts (re-proposed
against GraphQL/Riverpod), copy text, the nav graph. - Redesigned for the phone: layout at 390 (single scroll column / horizontal
rails), the shell (tab bar, screen-owned headers), typography (each web.type-*role mapped to its mobileFontStylerole), touch affordances
(pressedstates, ≥44px targets). - Rebound, always: every
data-repois re-resolved againstconsumer-mobile'sREPO-MAP.md— a web binding is never carried.
Assets — always external files, never inlined
Every asset is a file in assets/, referenced by path — never an inline <svg>,
base64, or data: URI (check.py enforces this). Images/illustrations/medals →assets/<name>.png; icons & logos → assets/icons/<name>.svg; fonts →@font-face → assets/fonts/. Pull the real artwork from consumer-mobile
(assets/<feature>/…, registered in lib/resources/images.dart) or the supplied
design.
Every asset reference is ROOT-ABSOLUTE — /assets/icons/x.svg in HTML src,
CSS url(), JS literals and templateUrl/cssUrl alike. Pages live at different
depths (feature dirs vs the pool) and serving is always from the repo root
(python3 -m http.server), so absolute paths are the one shape that resolves
everywhere; never write assets/… or ../../assets/… relative forms.
For a single-colour icon that must theme with CSS (e.g. the active tab usingcurrentColor), use a CSS mask so it still recolours from a file — don't
inline the SVG to get theming. (This is the artefact-side analogue of the app'sColorFilter/srcIn SVG tinting — the integrator swaps the mask for a tintedSvgPicture.) Two gotchas that will bite (neither is mechanically caught —
heed them):
- Put the mask
url(/assets/icons/x.svg)in the component's own.css, and
keep it root-absolute. Do not set the url via a custom property in an inlinestyle=and consume it withmask:var(--icon), and don't use a relative path —
Chrome resolves a maskurl()relative to the stylesheet that uses it, so any
relative form breaks the moment a different-depth page (or the playground copy)
loads the sheet → 404 → invisible icon. - A standalone
.svgused as a mask must includexmlns="http://www.w3.org/2000/svg"(implied inside HTML but required in a file)
or it renders empty.
The mobile canvas — phone rules every page follows
Every screen and specimen renders on a 390px logical viewport
(--screen-width: 390px) — the .app-viewport is a centered phone column on a
neutral desk background. No fake bezel or status-bar pixels — the canvas is
the app's content area; --safe-top: 47px is reserved for the real status bar
and --safe-bottom for the home indicator. The rules:
- Touch targets ≥ 44px — anything tappable.
- Hover has no meaning on a phone — author
pressedstates instead (the
app'sPressScale); a hover-only affordance is a bug. - Gutter is
--screen-gutter: 16px— the screen edge inset. - Dark mode from day one. Every colour in
common/tokens.cssis a--color-*custom property with its light value in:rootand its dark value
in[data-theme="dark"]— mirroringLumosityColors' light/dark pairs (some
app dark values are placeholders; those are marked/* dark TBD in app */—
mirror, don't invent).common/theme.jsresolves?theme=param →
localStorage → light and sets<html data-theme>; the gallery and editor have
a theme toggle. Components usevar()only — dark then "just works".
Write components, not instances
Every piece of UI is a reusable, parameterised component — never a one-off
hard-coded instance. Each component owns exactly one DOM node and one state
machine, and a state change re-renders only that node, never the page.
- Abstract domain taxonomy for names. Name classes after the concept, keep
visible copy as designed. "North Harbor" is the value of.group-summary__name
(data-field="group.name"), not a.harbor-name. "Crew" →group; a
celebration is a neutral.feed-itemwith a--celebrationpayload, not a.chat-message. (The full model for a ported feature lives in the doc block
atop its main screen.html— carried over from the web artefact.) - State is data, not duplicated markup. A component declares its states once (a
finite state machine in its.js); switching state re-renders only that node.
Don't fork a component to make a second visual — add a state. - The
data-*hooks are the integration contract — seeINTEGRATION.mdfor the
full definition. Authoring nuance fordata-field(one field per text node):- Split composites:
150 / 200→group.points+group.target. - Pull static affixes out of the span so it holds only the bound value:
<span data-field="group.memberCount">10</span> members, not>10 members<. - The field names the raw model field to bind; the rendered text is
illustrative. Presentation formatting (units,+/−signs, dates,
relative-time, count→medal) lives in the component, not the field.
Inherently-formatted values ("Ends in 5 Days","12:42 PM") stay as-is but
must carry thedata-formattedmarker declaring that: a data-field node
mixing digits and letters is either split per-field or markeddata-formatted(check.py rule 15; "Scored 75 Tiles" unsplit fails). Mobile
copy compresses more composites than web, so expect more of these — the rule
is unchanged. - A preview-only state (a CSS media-query layout catalogued as a state so
it's inspectable in the editor/gallery) carriespreview: truein its
state object — spec.json surfaces it so no integrator ships it as a prop value.
- Split composites:
- Map each
.type-*role to itsFontStylevalue in a comment
(.type-headerMdBold=FontStyle.headerMdBold); prefer the repo'skSpacing*/kRadius*scales over invented px. - Every component
.jscarries aHAND-OFF NOTEheader: thedata-model
shape, theDATA+ACTIONScontract lines, and the widget it becomes
(GroupSummaryCard(...), a ConsumerWidget). Keep it — the gate checks all of it. - Comments are documentation — port and preserve them. When porting markup
from the web artefact, the inline comments come with it (rebindingdata-repo
as you go); never drop them.
How the artefact is structured
One shared component pool + one FEATURE WORKSPACE per feature, all
framework-free, run over a server from the repo root (see README.md). A feature
workspace (challenges/, …) is the unit of hand-off: a dev taking one feature
sees exactly the components/states/screens that feature touches. check.py
discovers a feature by its gallery.js — that file is the feature marker.
Feature attribution is by MEMBERSHIP (what its screens mount + its CATALOG
catalogues), not directory ownership: components are never created inside a
feature. The authoring-relevant map:
core/— the generic engine (knows nothing about Lumosity):Component(owns ONE
node, re-renders only itself),StateMachine,Registry,EditorBridge,gallery.js(the generic gallery renderer —bootGallery(CATALOG); each
feature's owngallery.jsis just imports + CATALOG).components/<Name>/— THE SHARED POOL, one folder per component:.html(ONE
root element — the engine mountsfirstElementChild),.js(definition:id,label,states+ transitions,update,onMount),.css(its visuals, loaded on mount). A component used by
two features exists ONCE here — the second feature adds states and catalogues
its slice; never fork.MobileShell— the shared chrome (bottom tab bar; mobile headers are
screen-owned, matchingmain_screen.dart) is a component too, but a
stateless reference stand-in for the repo shell (MainScreen+CustomBottomNavBar) — don't rebuild it on integration: a new screen is a
go_router route in the rightMainTabbranch. It carries no states (absent
from the editor and galleries); a screen names its active tab viadata-active-nav(today|games|my-brain|discover|tests) on itsslot-shell
element. Its tab links are root-absolute (/challenges/home.html) — it renders
on pages at any depth.<feature>/— the feature workspaces:<screen>.{html,js,css}at the feature
root,gallery.{html,js,css}(the CATALOG naming which pool components — and
optionally which of theirstates— this feature catalogues, plus its
overlay-neutralizer css loaded after the shared/gallery.csschrome),README.md, derivedspec.json/baselines/(gitignored). The hubindex.htmllinks every feature.common/— the shared component foundation + helpers:base.css(reset /
box-sizing baseline every component assumes),tokens.css(repo tokens as--color-*light/dark pairs +.type-*roles naming FontStyle values +--space-*/--radius-*/--dur-*fromlayout_constants.dart),fonts.css
(@font-face→ Museo Sans + Source Serif Pro;*Accent*roles render the
serif),theme.js(sets<html data-theme>— the FIRST script on every
page),interactions.js(feed reactions/quick-replies),motion.js(the
artefact-side motion layer — Flutter porting table inREADME.md).
Load order on every page:/common/fonts.css → /common/tokens.css → /common/base.css → page styles(root-absolute; check.py enforces the order;
skippingbase.cssis the classic bug — UA default margins on that page only).
Runbook A — add a COMPONENT (a reusable block on an existing screen)
A component is a self-contained, parameterised UI block. Before writing one, do the
reuse check: is there a repo widget (lib/components/*, the feature dirs), token,
or asset that already does this? If yes, bind to it; if unsure, ask the user. Then:
- Scaffold it —
/new-componentstamps the.{html,js,css}triad in the
required shape (single root element carryingdata-component/data-repo/data-model; the.jsexport defaultwith itsHAND-OFF NOTEheader,id,label, root-absolutetemplateUrl/cssUrl,initial,states,update,
optionalonMount) and both registrations (step 2). Don't hand-transcribe
the skeleton —.claude/skills/new-component/scaffold.pyis its exact shape. - Registration — both surfaces of its feature (check.py rule 5 fails if
either is missing; the scaffold does both, verify it landed):- the feature's screen controller (e.g.
challenges/challenges.js) —import <name>Def from '../components/<Name>/<Name>.js', add'slot-<name>': [<name>Def]to itsLAYOUT, and add<div id="slot-<name>"></div>to the screen.htmlwhere it renders. - the feature's
gallery.js— add{ def: <name>Def, width: <natural-column-px>, glyph, tint }toCATALOGso every state is
catalogued by some feature.
- the feature's screen controller (e.g.
- Write the markup + states — a
data-fieldon every data-bearing text node;
state variants keyed on[data-state="…"]; colours viavar(--color-*)only
(dark mode is then free). Shared look goes incommon/and is listed incssUrl.update()runs ONLY on this component's node — set[data-field]
text, toggle classes/disabled/src;data-stateis already applied bycore/Component. - Update docs — extend the doc block atop the screen
.htmlif the model grew;
update the feature'sREADME.mdcomponent table. - Verify — see "Verify".
Cross-feature reuse (the states-not-forks rule in practice). When feature Y
needs a component feature X already catalogues: add Y's new visuals as states
on the pool component, then catalogue Y's slice in Y/gallery.js with astates: ['its', 'states'] filter (X's entry keeps its own). check.py enforces
that the union of catalogued states across features covers every state — so each
state is owned by exactly the feature(s) that hand it off, and each feature's
baselines/spec/README carry only its slice.
State naming. The state key is the logical name and the component's address
(component-id / state-name); keep it kebab-case. It may differ from the CSSdata-state value update() sets — e.g. ChallengeCard's not-started state rendersdata-state="playable". A new visual is a new state (add it to states), never a
forked component or a duplicated CSS modifier.
Runbook B — add a SCREEN (a whole new page, inside a feature)
A screen is a composition of components plus static chrome — the same shape as
the Challenges screen (challenges/challenges.{html,js,css}). It lives at ITS
FEATURE's root (<feature>/<screen>.{html,js,css}); starting a brand-new feature?
Scaffold the workspace first with /new-feature (Runbook C). Build screens out of
components; a screen file should contain almost no bespoke UI of its own. The
pattern is locked — follow it exactly so multi-screen doesn't reintroduce drift
(check.py rule 7 enforces the structural half):
- Pull the design truth + reuse-survey — screenshot + per-node typography
from Figma (pull exactfont-size/line-height/weight/letter-spacing/colorfrom
leaf text nodes — don't eyeball; a "name" is often a header), or the web
artefact's screen if porting; then mineconsumer-mobilefor the shell,
type + colour tokens, fonts, and any existing widget this screen needs.
Copy each asset intoassets/as a file. List which blocks already exist
as components (reuse) vs. which are new (build via Runbook A), and note each
block's repo binding in an inline comment. - Scaffold the screen, named after the screen —
/new-screen <slug>stamps<feature>/<screen>.{html,js,css}in the locked shape (no genericindex.html/app.js):<head>doc block,/common/theme.jsas the first
script, the foundation linked in order and root-absolute
(/common/fonts.css → /common/tokens.css → /common/base.css) then the page's
own styles,.app-viewport,<div id="slot-shell" data-active-nav="…">, and a<screen>.jscontroller declaringLAYOUTwith'slot-shell': [mobileShellDef].
Then add a<div id="slot-…">per content component and itsLAYOUTentry —
active-nav is never a JS step. The scroll column clears the chrome viapadding-top: var(--safe-top)andpadding-bottom: calc(var(--tabbar-height) + var(--safe-bottom))(seechallenges/challenges.css) — NOT the web artefact's sidenav margins. - Fill the
<head>doc block — the scaffold stamps the frame withTODOs;
these are the sections and what each must actually say. It is the integration
spec for THIS screen only — shared conventions live once inINTEGRATION.md+README.md+ here, never copied per screen:- PURPOSE — one line: which screen, translated into which repo target (the
go_router route +<Screen>widget it becomes). - DOMAIN MODEL — the feature hierarchy; every class name maps to a node here.
- REPO COMPONENT MAP — each block → its repo binding, or NEW →
components/<Name>/(a NEW block's state machine = the widget'sstateparam). - PAGE BEHAVIOR — screen-owned orchestration no single component owns
(popovers, takeovers, page-level swaps, custom-event gates); each entry names
its integration target, and every screen-leveldata-actionis named here
(check.py rule 17). Write "none" if there is none. - ASSET MANIFEST — the
/assetsfiles this screen uses (name · size · purpose).
- PURPOSE — one line: which screen, translated into which repo target (the
- Make it reachable — never re-declare tab-bar markup. Point the matching tab
<a>incomponents/MobileShell/MobileShell.htmlat the screen,
root-absolute (href="/<feature>/<screen>.html"), or link it from another
screen in the feature — mobile nav is a graph, not a sidenav list. Plain
anchors, multi-page, no router: go_router is the integration target, noted not
rebuilt. Links to not-yet-built screens stay inerthref="#"placeholders. - Build/compose the components — reuse the existing pool (
components/*) where
the block exists; create the rest via Runbook A. Register every component in this
screen'sLAYOUTand in the feature'sgallery.jsCATALOG. - Verify — see "Verify".
Runbook C — add a FEATURE (a whole new workspace)
A feature workspace is the unit of hand-off: <feature>/ with its screens, its
gallery (CATALOG), its README and derived spec.json/baselines/. Use/new-feature — its scaffold stamps the gallery pair (the gallery.js
feature marker check.py discovers), the README skeleton (with the docs.py
coverage anchor), the first screen (via the new-screen scaffold) and the hub
link in index.html, then runs the gate. (Porting a feature that exists in the
web artefact? Start with /port-feature instead — it wraps this scaffold
with the carry-over/redesign/rebind rules above.) After the scaffold: build the
first screen (Runbook B step 1 + 5), catalogue every pool component the feature
uses (pin states: slices for components another feature already catalogues),
and shoot.py --feature <feature> --update once states exist. Components are
NEVER created inside the feature dir — always in the shared pool via Runbook A.
Verify (never trust — check)
Per add (the fast loop): the gate + one glance is enough to keep moving.
python3 check.py # the consistency gate; must print PASS and exit 0
python3 -m http.server 8000 # ALWAYS from the repo root
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless \
--disable-gpu --hide-scrollbars --force-device-scale-factor=2 \
--window-size=500,2400 --screenshot=/tmp/out.png \
http://localhost:8000/<feature>/<page>.html
At /handoff (the quality gate): contracts, settled before anything reaches a dev.
python3 spec.py # per-feature <feature>/spec.json; refuses on any DATA/ACTIONS TODO
Visual regression is on-demand only. shoot.py / /verify-visual are wired
into nothing — not the pre-commit hook, not /handoff, not the scaffolds.
Run a sweep by hand when you want one, in either palette (--theme dark for the
dark pass). A shoot.py CHANGED line means "a human must look at that address",
not "wrong" — if the change is the point, re-bless it with --update; if it
isn't, you just caught a regression.
check.py covers the mechanical floor. The things a script can't judge:
- Reuse check done — grep
REPO-MAP.md; repo tokens/fonts/assets/widgets
reused, not reinvented, and the right repo binding named indata-repo+ a comment. - Rendered headless — every state visually matches the design (switch via
the editor or gallery); nodata-field/ ARIA value contradicts what's shown.
(Glance the touched state, light theme; a full both-theme drift sweep is the
on-demand/verify-visual.) - Contracts + docs (handoff) —
DATA/ACTIONSTODOs filled (/contracts);
doc block / the feature'sREADME.mdtable folded if the model grew
(docs.pykeeps the gate's coverage current in the meantime).
Motion is gated on prefers-reduced-motion, degrades to the final state if the CDN
can't load, and only animates transform/opacity.