MD Reader Open in MD Reader

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:

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 the
DATA/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 own
lib/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:

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.

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:

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-faceassets/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 using
currentColor), 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's
ColorFilter/srcIn SVG tinting — the integrator swaps the mask for a tinted
SvgPicture.) Two gotchas that will bite (neither is mechanically caught —
heed them):

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:

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.

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:

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:

  1. Scaffold it/new-component stamps the .{html,js,css} triad in the
    required shape (single root element carrying data-component/data-repo/
    data-model; the .js export default with its HAND-OFF NOTE header, id,
    label, root-absolute templateUrl/cssUrl, initial, states, update,
    optional onMount) and both registrations (step 2). Don't hand-transcribe
    the skeleton — .claude/skills/new-component/scaffold.py is its exact shape.
  2. 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 its LAYOUT, and add
      <div id="slot-<name>"></div> to the screen .html where it renders.
    • the feature's gallery.js — add { def: <name>Def, width: <natural-column-px>, glyph, tint } to CATALOG so every state is
      catalogued by some feature
      .
  3. Write the markup + states — a data-field on every data-bearing text node;
    state variants keyed on [data-state="…"]; colours via var(--color-*) only
    (dark mode is then free). Shared look goes in common/ and is listed in
    cssUrl. update() runs ONLY on this component's node — set [data-field]
    text, toggle classes/disabled/src; data-state is already applied by
    core/Component.
  4. Update docs — extend the doc block atop the screen .html if the model grew;
    update the feature's README.md component table.
  5. 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 a
states: ['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 CSS
data-state value update() sets — e.g. ChallengeCard's not-started state renders
data-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):

  1. Pull the design truth + reuse-survey — screenshot + per-node typography
    from Figma (pull exact font-size/line-height/weight/letter-spacing/color from
    leaf text nodes — don't eyeball; a "name" is often a header), or the web
    artefact's screen if porting; then mine consumer-mobile for the shell,
    type + colour tokens, fonts, and any existing widget this screen needs.
    Copy each asset into assets/ 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.
  2. Scaffold the screen, named after the screen/new-screen <slug> stamps
    <feature>/<screen>.{html,js,css} in the locked shape (no generic
    index.html/app.js): <head> doc block, /common/theme.js as 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>.js controller declaring LAYOUT with 'slot-shell': [mobileShellDef].
    Then add a <div id="slot-…"> per content component and its LAYOUT entry —
    active-nav is never a JS step. The scroll column clears the chrome via
    padding-top: var(--safe-top) and
    padding-bottom: calc(var(--tabbar-height) + var(--safe-bottom)) (see
    challenges/challenges.css) — NOT the web artefact's sidenav margins.
  3. Fill the <head> doc block — the scaffold stamps the frame with TODOs;
    these are the sections and what each must actually say. It is the integration
    spec for THIS screen only — shared conventions live once in INTEGRATION.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's state param).
    • 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-level data-action is named here
      (check.py rule 17). Write "none" if there is none.
    • ASSET MANIFEST — the /assets files this screen uses (name · size · purpose).
  4. Make it reachable — never re-declare tab-bar markup. Point the matching tab
    <a> in components/MobileShell/MobileShell.html at 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 inert href="#" placeholders.
  5. 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's LAYOUT and in the feature's gallery.js CATALOG.
  6. 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:

Motion is gated on prefers-reduced-motion, degrades to the final state if the CDN
can't load, and only animates transform/opacity.