Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
411f997
fix(app): make menus, tooltips and overlay dialogs operable by assist…
simonvanlierde Oct 2, 2026
458774d
fix(app): expose selection, status and invalid state to assistive tech
simonvanlierde Oct 2, 2026
ab73957
fix(app): keep menu roles native-safe and hints out of invalid state
simonvanlierde Oct 2, 2026
36511f8
fix(app): legible button text in both schemes and palette-only borders
simonvanlierde Oct 2, 2026
f0bcbde
fix(app): let every button label inherit its variant colour
simonvanlierde Oct 2, 2026
053435c
fix(app): keep dialogs, signup and capture usable at 320px and large …
simonvanlierde Oct 2, 2026
07b5c94
fix(app): pad signup content for its footer and honour reduced motion…
simonvanlierde Oct 2, 2026
126df22
fix(app): accurate dataset and export copy, no draft-only Pi button
simonvanlierde Oct 2, 2026
eaed1bb
fix(app): keep the terms toast and product doc in future tense for da…
simonvanlierde Oct 2, 2026
0ff41da
fix(app): plain-language errors, offline delete guard, shared CPV loader
simonvanlierde Oct 2, 2026
b7fa0d8
fix(app): route remaining error alerts through the plain-language mapper
simonvanlierde Oct 2, 2026
90938b6
perf(app): load lightbox, live preview and startup dialogs on demand
simonvanlierde Oct 2, 2026
e8c4eb0
fix(app): return focus from dialogs that mount already open
simonvanlierde Oct 2, 2026
a50788e
fix(app): swallow lightbox prefetch failures
simonvanlierde Oct 2, 2026
216d0ed
fix(app): data type for every count and DESIGN.md matched to code
simonvanlierde Oct 2, 2026
d56d223
fix(app): keep the app alive when an on-demand chunk fails to load
simonvanlierde Oct 2, 2026
b18ff86
fix(app): name camera and timeout failures plainly
simonvanlierde Oct 2, 2026
96568dc
fix(app): keep overlay scaffolding out of the tab order and expose na…
simonvanlierde Oct 2, 2026
1c6e73f
test(app): update E2E specs for link nav and labelled profile rows
simonvanlierde Oct 2, 2026
c4adc1a
chore(app): record bundle and background image follow-ups
simonvanlierde Oct 2, 2026
9980378
docs(app): describe camera cell toggle state by selection mode
simonvanlierde Oct 2, 2026
323ba84
chore(app): record list rendering follow-ups
simonvanlierde Oct 2, 2026
1140000
feat(app): keep capturing while an offline create is queued
simonvanlierde Oct 2, 2026
81af221
fix(app): never hold a delete silently across a dropped connection
simonvanlierde Oct 2, 2026
9aee25e
fix(app): guard queued creates against stale double submits and repor…
simonvanlierde Oct 2, 2026
f07681e
fix(app): warn before sign-out discards queued items
simonvanlierde Oct 2, 2026
c04385d
fix(app): queue alerts so no failure notice is overwritten
simonvanlierde Oct 2, 2026
8c2d26c
fix(app): tell the user when signing out discards queued items
simonvanlierde Oct 2, 2026
8c46566
fix(app): ignore a stale dismiss from an already-closed dialog
simonvanlierde Oct 2, 2026
8c3075d
chore(app): record why form validation stays on the first-load path
simonvanlierde Oct 2, 2026
f85ac88
perf(app): virtualize the profile product list and tune the products …
simonvanlierde Oct 2, 2026
ba5b745
perf(app): serve the auth backgrounds as WebP
simonvanlierde Oct 2, 2026
988950a
fix(app): visible focus on measurement fields and keyboard menus
simonvanlierde Oct 2, 2026
ed66338
fix(app): announce list status on iOS and mark navigation as links
simonvanlierde Oct 2, 2026
9d0abc1
fix(app): clear safe areas and the keyboard on sheets, toasts and the…
simonvanlierde Oct 2, 2026
f057a68
refactor(app): align radii, data type and colour names with the desig…
simonvanlierde Oct 2, 2026
2f1701b
fix(app): mark the camera name required, announce profile paging, rov…
simonvanlierde Oct 2, 2026
4c899dc
feat(app): one press language for rows, links and icon buttons
simonvanlierde Oct 2, 2026
8b3c559
fix(app): one inline error idiom and consistent screen spinners
simonvanlierde Oct 2, 2026
dddf98b
perf(app): lighter list images, tighter grid windowing, first-page ca…
simonvanlierde Oct 2, 2026
d6db119
fix(app): use a real monospace stack for data on web
simonvanlierde Oct 2, 2026
ee069f2
feat(app): show account stats as a spec row
simonvanlierde Oct 2, 2026
e7bb484
feat(app): state read-only product facts as spec rows
simonvanlierde Oct 2, 2026
09caa9d
fix(app): set navigation labels in untracked type
simonvanlierde Oct 2, 2026
b9d93ea
fix(app): legible auth hero, opaque auth cards, muted disabled buttons
simonvanlierde Oct 2, 2026
78833df
fix(app): finish the one press language
simonvanlierde Oct 2, 2026
2278dfb
fix(app): calm the whole auth hero on narrow screens
simonvanlierde Oct 2, 2026
ad175cd
feat(app): extrude the dimension drawing on first appearance
simonvanlierde Oct 2, 2026
d5b0221
refactor(app): give data labels their own manila token
simonvanlierde Oct 2, 2026
e493b83
feat(app): animate disclosure and save state changes
simonvanlierde Oct 2, 2026
010ae55
fix(app): keep state motion on transforms and out of the way
simonvanlierde Oct 2, 2026
c740503
feat(app): grow photos into the lightbox and close it faster
simonvanlierde Oct 2, 2026
8a2245f
feat(app): slide component and material rows on add and remove
simonvanlierde Oct 2, 2026
c79c886
feat(app): crossfade from skeletons, raise the save bar, close overla…
simonvanlierde Oct 2, 2026
8a038b1
fix(app): put the auth wordmark on the card and Browse in the phone flow
simonvanlierde Oct 2, 2026
0d8279f
fix(app): one view-mode spec row for brand, model and type
simonvanlierde Oct 2, 2026
e8dc31e
fix(app): show public profile stats as a spec row
simonvanlierde Oct 2, 2026
cc13152
fix(app): one disabled treatment for every button variant
simonvanlierde Oct 2, 2026
074fa19
fix(app): fill the whole product card with its press tint
simonvanlierde Oct 2, 2026
4a10d11
docs(app): restore the Chips heading in the design system
simonvanlierde Oct 2, 2026
695c2ff
refactor(app): settle lint and the section-order test after the spec-…
simonvanlierde Oct 2, 2026
6c31835
fix(app): ignore keyboard presses on closing dialogs and menus
simonvanlierde Oct 2, 2026
7487164
fix(app): one sign-out notice for a deliberate sign-out
simonvanlierde Oct 2, 2026
559d1c4
fix(app): keep the sign-up footer below the form on short screens
simonvanlierde Oct 2, 2026
fdec1c1
fix(app): blue ink for outline and ghost buttons
simonvanlierde Oct 2, 2026
574fd42
docs(app): write down the motion system
simonvanlierde Oct 2, 2026
c6a8e78
fix(app): finish the one disabled treatment and required marking
simonvanlierde Oct 2, 2026
b41ea69
style(app): sort and wrap the variant-ink icon imports
simonvanlierde Oct 2, 2026
b358145
fix(app): link roles for navigation and flat compact errors
simonvanlierde Oct 2, 2026
696ccaf
Merge remote-tracking branch 'origin/main' into fix/app-audit
simonvanlierde Oct 2, 2026
5e4c0c6
build(app): declare babel-preset-expo so Jest resolves it from app/
simonvanlierde Oct 2, 2026
d4caa6b
feat(app): set data in IBM Plex Mono on web
simonvanlierde Oct 2, 2026
62045a1
refactor(app): one notice for discarded queued items
simonvanlierde Oct 2, 2026
1a6ae6a
refactor(app): label the camera form through ControlledTextField
simonvanlierde Oct 2, 2026
6889978
test(app): drop camera tests that restate constants
simonvanlierde Oct 2, 2026
1225184
refactor(app): share the press overlay, motion timings and list footer
simonvanlierde Oct 2, 2026
171b2fc
docs(app): point the press and motion rules at their code
simonvanlierde Oct 2, 2026
9e87215
refactor(app): load CPV types through react-query
simonvanlierde Oct 2, 2026
a64a8ce
refactor(app): classify capture failures once and call queries directly
simonvanlierde Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ not an automated computer-vision pipeline, not an elastic cloud platform.

## Platform

Web ships. `build` is `expo export -p web`, E2E runs Playwright against that export, and the
web (shipped). `build` is `expo export -p web`, E2E runs Playwright against that export, and the
deployed surface is `app.cml-relab.org` behind Caddy.

Native iOS and Android are configured in `app/app.json` but this repo's tooling does not build or
Expand Down Expand Up @@ -124,11 +124,13 @@ dataset.
enough to contribute usefully. The Pi rig only pays off for repeatable imagery across many products.
No surface may present it as a prerequisite.

**Uploads.** Images only in-app: jpeg, png, webp, gif, bmp; the app converts anything else (HEIC)
to JPEG. Per-image size and pixel caps follow the uploader's role (contributor 10 MB / 12.6 MP, lab
20 MB / 24 MP) and are enforced on the server; the app reads them from `/users/me` and uploads a
photo at full resolution unless it has to shrink to fit. The backend rejects
an upload unless extension, declared MIME type, and sniffed content agree. Uploads are sequential. Quotas are tiered by role (contributor 1,000 files / 3 GB; lab 20,000 files / 50 GB) and
**Uploads.** Images in the gallery: jpeg, png, webp, gif, bmp; the app converts anything else (HEIC)
to JPEG. Research files (lab-only, edit mode only) go in the separate "Research files" section, on
base products and components. Per-image size and pixel caps follow the uploader's role (contributor
10 MB / 12.6 MP, lab 20 MB / 24 MP) and are enforced on the server; the app reads them from
`/users/me` and uploads a photo at full resolution unless it has to shrink to fit. The backend
rejects an upload unless extension, declared MIME type, and sniffed content agree. Uploads are
sequential. Quotas are tiered by role (contributor 1,000 files / 3 GB; lab 20,000 files / 50 GB) and
`/users/me` reports limits and usage. There is no per-product limit.

**Idempotency.** Creates carry an `Idempotency-Key` minted once per draft, so a retry, a rehydrated
Expand All @@ -150,8 +152,8 @@ Recorded so nobody assumes them into existence:

- **Video is URL-only.** `Product.videos` holds links. Self-hosted video is deferred, roughly a year
out. Do not design upload affordances for it.
- **Non-image file upload is lab-only**, in a "Research files" block in the Media section, edit mode
only, base product only. No general-purpose upload affordance exists outside it.
- **Non-image file upload is lab-only**, in a separate "Research files" section, edit mode only, allowed on
base products and components. No general-purpose upload affordance exists outside it.
- **No localization.** All copy is hardcoded English. This becomes a real barrier at the repair-café
stage and is currently unplanned.
- **Three documented design goals are not shipped:** the value-return-to-contributors loop, taxonomy
Expand Down
121 changes: 97 additions & 24 deletions app/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ typography:
fontSize: 13px
lineHeight: 18px
data:
fontFamily: Menlo, monospace
fontFamily: IBM Plex Mono, ui-monospace, monospace
fontSize: 14px
lineHeight: 20px
eyebrow:
Expand Down Expand Up @@ -94,7 +94,7 @@ components:
textColor: '{colors.foreground}'
rounded: '{rounded.card}'
status-pill:
typography: '{typography.label}'
typography: '{typography.caption}'
rounded: '{rounded.control}'
height: 24px
---
Expand Down Expand Up @@ -174,7 +174,8 @@ live = manila.
small data labels, live/status pills, strategy tags. **Accent is for small text, never for
mass.** It never fills a button, never drives a hover or pressed state, and never paints bars,
big figures, or large areas. Interaction is always primary blue. *Mirrors
assets/DESIGN.md:167-184 — change it there first.*
assets/DESIGN.md:165-182 — change it there first.* In this app, data-label manila is spelled
`text-manila` in a className and `tokens.text.data` in a style object or an SVG fill.

**The One Tint Rule.** `tokens.surface.accent` (primary at 12%) is the single selected/tinted
fill: chips, history rows, toggles, active nav. The Tailwind spelling is `bg-primary/12`.
Expand All @@ -192,8 +193,11 @@ shade (`#143567` light / `#BAD3FF` dark), not alpha on the primary. In Tailwind:

**Display / UI Font:** platform system font (San Francisco on iOS, Roboto on Android, system UI
stack on web)
**Data / Label Font:** platform monospace (Menlo on iOS, `monospace` elsewhere), with
`font-variant: tabular-nums`
**Data / Label Font:** IBM Plex Mono 400 on web (self-hosted from `public/fonts/`, 14.7 KB,
`font-display: swap`, falling back to `ui-monospace, SFMono-Regular, Menlo, Consolas,
"Liberation Mono", monospace`); platform monospace on native (Menlo on iOS, `monospace` on
Android). Always with `font-variant: tabular-nums`. Take it from `MONO_FONT_FAMILY` in
`src/theme/tokens.ts`; a bare `monospace` on web falls to Courier New on Windows.

**Character:** Neutral and native. The app borrows the scale and palette of the brand's IBM Plex
system without shipping the typeface.
Expand All @@ -204,7 +208,7 @@ system without shipping the typeface.
- **Title** (24/30): Screen and section titles.
- **Heading** (19/24): Subsection headers, card titles.
- **Body** (16/26): Prose, descriptions, form values. Selectable by default.
- **Label** (13/18, +1.3 tracking): Field labels and dense chrome.
- **Label** (13/18, +1.3 tracking): Field labels. Navigation uses untracked body or caption.
- **Caption** (13/18): Helper text, timestamps, secondary annotations.
- **Data** (14/20, monospace, tabular figures): Every measurement, ID, count, and code. If it is
a number the user might compare to another number, it is `data`.
Expand All @@ -215,14 +219,14 @@ system without shipping the typeface.

**The System-Font Rule.** The app stays on platform system fonts (native feel, Dynamic Type
support, zero load cost). It adopts the brand's scale and palette, not its typeface. www and docs
use IBM Plex; do not "unify" this. *Mirrors assets/DESIGN.md:33-35 — change it there first.*
use IBM Plex; do not "unify" this. The one exception is the web `data` face, IBM Plex Mono. *Mirrors assets/DESIGN.md:31-36 — change it there first.*

Dynamic Type is capped at 2x on **every** text primitive: both `AppText` and `ui/text` apply the
cap by default. `ui/text` renders every button label plus HeroStats, ComponentRow, GoLiveDialog
and ProductDelete.

**The Eyebrow-Is-A-Datum Rule.** `eyebrow` labels a **value inside a compact tag**. It is not a
kicker above a heading; www and docs carry none. *Mirrors assets/DESIGN.md:169-173 —
kicker above a heading; www and docs carry none. *Mirrors assets/DESIGN.md:166-171 —
change it there first.*

**The Ramp Rule.** Every text size comes from the eight variants above. An inline `fontSize:`
Expand All @@ -232,6 +236,9 @@ is a defect unless it carries a comment naming the reason no ramp step fits.

`PageContainer` is the scaffold: a max-width column with gutters that widen at `md` (768) and
`lg` (1024), plus `fullBleed` and `phoneFullBleed` escape hatches for galleries and hero media.
On a detail screen the photo strip, the title block and the section cards share one left edge: the
strip sits inside the column and cancels the phone gutter (`-mx-4 md:mx-0`), so it is full bleed
only on a phone. Cards inside a section card are a defect; flatten a sub-block to a hairline.

Breakpoints are web-only: `useBreakpoint()` gates `isMd`/`isLg` on `Platform.OS === 'web'`, so a
native tablet reads as phone-tier.
Expand All @@ -251,11 +258,12 @@ above the page.

### Shadow Vocabulary

- **`shadow-overlay`** (light `0 8px 24px rgba(20,40,80,.16)`, dark `0 8px 24px rgba(0,0,0,.55)`):
- **Overlay shadow** (light `0 8px 24px rgba(20,40,80,.16)`, dark `0 8px 24px rgba(0,0,0,.55)`):
Menus, dialogs, bottom sheets, the FAB, toasts. This is the only shadow in the system.

`src/theme/tokens.ts` reads this from `designTokens.rn.shadowOverlay[scheme]`, the RN-shaped
variant the generator emits next to the CSS string. Never re-declare the values in the app. The
Components take it from `theme.tokens.elevation.overlay`, which `src/theme/tokens.ts` builds from
`designTokens.rn.shadowOverlay[scheme]`, the RN-shaped variant the generator emits next to the CSS
string. There is no `shadow-overlay` CSS utility in the app. Never re-declare the values. The
Android `elevation` is a scheme pair (8 light / 12 dark).

### Named Rules
Expand All @@ -268,9 +276,9 @@ current palette the pair gives 10.5:1 dark / 11.8:1 light for primary ink and 5.
muted.

**The One Tier Rule.** There is exactly one shadow. Inline surfaces get a hairline and no
shadow; floating surfaces get `shadow-overlay`. A second elevation tier, a coloured glow, or a
shadow; floating surfaces get `elevation.overlay`. A second elevation tier, a coloured glow, or a
shadow stacked on an already-floating element is a defect. *Mirrors
assets/DESIGN.md:119-127 — change it there first.*
assets/DESIGN.md:117-126 — change it there first.*

## Shapes

Expand All @@ -284,29 +292,39 @@ radii.
| `radius.overlay` | 12px | Dialogs, bottom sheets, menus, FAB |
| `radius.full` | 9999px | Avatars and **true pills only** |

All four map through `src/constants.ts:41`. Use the token, never a literal.
All four map through `src/constants.ts:42`. Use the token, never a literal.

### Named Rules

**The True-Pill Rule.** `radius.full` is for avatars and genuine pills only. A 44×44 icon button
with `rounded-full` is not a pill. *Mirrors assets/DESIGN.md:111-114 —
with `rounded-full` is not a pill. *Mirrors assets/DESIGN.md:112 —
change it there first.*

## Components

### Buttons

- **Shape:** Gently squared (6px, `radius.control`), 44px minimum height.
- **Primary:** Solid Prussian blue, white text. Pressed: `active:bg-primary/90`.
- **Primary:** Solid Prussian blue, white text. Pressed and hover: `primary-strong`.
- **Outline / Ghost / Tonal:** Blue ink, transparent or 12% tinted fill, hairline border on
outline.
- **Destructive:** Solid `#BA1A1A`, white text. Never the keyboard default in a dialog.
outline. Tonal pressed and hover: `primary-strong` fill with `primary-foreground` text.
- **Destructive:** Solid `#BA1A1A`, `destructive-foreground` text. Never the keyboard default in a dialog.
Pressed and hover: `destructive` at 90% (`active:bg-destructive/90`, `hover:bg-destructive/90`);
the palette has no destructive-strong shade, so this is the one sanctioned alpha press.
- **Loading:** Inline spinner tinted to the variant's foreground; the label stays.
A loading button is busy (`aria-busy`), not disabled-looking: it keeps its fill.
- **Disabled:** one treatment for every variant: a faint muted fill (`bg-muted/50`), the
hairline border and muted ink at 60% (`text-muted-foreground/60`); hover and pressed repeat
it. Same size and place, plainly inert in both schemes, never an opacity fade and never a
solid grey that reads as an enabled neutral button.

### Chips

- **Style:** Two segments — a title segment in blue on `card`, and a value segment on solid
primary with white ink. 6px radius, 44px minimum height.
- **Edit-mode controls only.** A chip is a button that edits its value. Read-only values (view-mode
brand, model, amount and type) are Spec Row facts, never a chip with nothing to do on press: a
solid-blue segment reads as a button.
- **Style:** Two segments — a title segment in blue on `surface.accent`, and a value segment on solid
primary with white ink (pressed: `primary-strong`, as the FAB). 6px radius, 44px minimum height.
- **Error:** Danger-tinted fill **plus** a border **plus** an alert icon **plus** ", required"
composed into the accessible name. Never colour alone.

Expand All @@ -321,18 +339,63 @@ change it there first.*
44px minimum height.
- **Error:** Message rendered by `FormField` with a `nativeID` linked via
`accessibilityLabelledBy`, so the error is programmatically associated, not merely adjacent.
- **Required:** only a field the form cannot submit without (sign-in, sign-up, onboarding's
username, the capture name, the camera name) shows "(required)" in its label and spreads
`requiredField()` from `utils/a11y`. The marker is lower-case, in the label step and muted ink
after the label text; `ControlledTextField` takes `required` for both. Data
fields stay unmarked: an empty observation is valid.

### Navigation

- **Below `lg`:** stack header plus custom bottom tab bar.
- **At `lg` (web):** persistent `TopNav`, stack header hidden.
- **Labels:** plain words in untracked type, medium weight: `body` in TopNav and the desktop
outline, `caption` in the phone chip row, the tab bar and the account pill. The tracked `label`
step is for field labels, not navigation.
- **Items:** `min-h-11`, active `bg-primary/12` with blue ink, inactive at 70% opacity, web
`focus-visible:ring-2`.
`WEB_FOCUS_RING`. Both bars mark destinations as links with `aria-current="page"`, not
tabs: a tab promises an in-page panel.

### Press feedback

One press language, implemented in `src/components/base/pressFeedback.ts`:

- Filled controls (primary, tonal, the chip value segment) press and hover to `primary-strong`.
- Everything else that responds to touch (rows, cards, links, bare icon buttons) takes the One
Tint, `bg-primary/12`, on press and on web hover, drawn by `PressOverlay`. On a tinted fill,
lay the tint over it.
- A disabled control drops the tint. Never dim on press: opacity is for disabled only.

### Motion

Motion says where something came from or went; it never decorates. Entrances run 150–300ms
with an ease-out, exits are plain fades shorter than their entrance, and every animation honours
`prefers-reduced-motion`. *Mirrors assets/DESIGN.md:135-138 — change it there first.*

- **Overlays** fade through `src/hooks/useModalPresence.ts` and take no input while closing.
- **Rows** in a short, non-virtualized list use the shared timings in
`src/components/base/motion.ts`; rows present on mount do not animate.
- **The lightbox** is the one focal moment and the one `ReduceMotion.Never`: under reduced
motion it keeps the fade and drops the scale.
- **CSS transitions** snap under `prefers-reduced-motion` (see `global.css`).

### Errors and loading

- **Errors:** one idiom, `ErrorState`. Full-height when the whole screen failed; `compact` for an
inline failure inside loaded content (a list header, an expanded row), drawn as a hairline-top
row with no fill or radius so it never nests a card in a card. The title names the
problem, the message names the recovery (`getErrorMessage()` maps the failure; its fallback
says what to do), and the one action retries.
- **Loading:** a skeleton where the content's shape is known (lists, detail screens); a spinner
for actions (saving, load more, capture) and for waits whose shape is unknown. A screen-level
spinner is `CenteredSpinner`; an action's spinner sits in or beside its control.

### Focus indicators

Every interactive control takes `WEB_FOCUS_RING` from `src/constants.ts`:
`focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-ring` — a 2px Prussian-blue outline, 2px clear of the control.
Text fields and role-carrying Pressables get the same outline from `global.css`, so never set an
inline `outline` on them: an inline style outranks the stylesheet and the ring disappears.

### Named Rules

Expand All @@ -349,23 +412,33 @@ without measuring it in a browser.

### Status Pill

24px tall, `radius.control`, `label` type. Solid or soft variant. The **live** pill is the one
24px tall, `radius.control`, `caption` type. Solid or soft variant. The **live** pill is the one
sanctioned manila fill in the app.

### Signature: the Spec Row

Monospace value, manila eyebrow label, hairline separator. This is the app's most characteristic
pattern.
pattern. `SpecFacts` (`src/components/base/SpecFacts.tsx`) is the one implementation: facts sit
left-aligned under a hairline and wrap onto more lines at narrow widths. Every read-only set of
facts uses it: the product spec-sheet header, the view-mode Overview (brand, model, amount and
type in one row, the type's description under it), and the account's and a public profile's
record counts. A fact still resolving keeps its label and pulses its value, so nothing pops in. An unset value is a
neutral "—", never an error. Don't render facts as hero-metric tiles (a centred number over a
tracked label on a tinted fill).

## Do's and Don'ts

### Do

- **Do** use `tokens.surface.accent` (or `bg-primary/12`) for every selected or tinted fill.
- **Do** use the `data` variant for every measurement, ID, count, and code.
One exception: a count inside a button label (`Capture 3`) stays in the button's label type;
a button label is one string.
- **Do** pair every colour-carried meaning with a second signal: an icon, a border, or text.
- **Do** apply `MIN_TAP_TARGET` (44) to every interactive control, including icon-only ones.
- **Do** pass `ReduceMotion.System` on every Reanimated animation.
- **Do** pass `ReduceMotion.System` on every Reanimated animation. The one exception is the
lightbox's `ReduceMotion.Never`, which keeps an opacity-only path under reduced motion (see
Motion).
- **Do** keep entrance motion in the 150–300ms band, with exits shorter.
- **Do** comment any departure from a token, naming what it departs from.

Expand Down
31 changes: 21 additions & 10 deletions app/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
// Jest's CommonJS runtime cannot evaluate `import()`, so React.lazy splits would throw in
// tests. There, rewrite it to an already-resolved require; bundles keep the real import.
// Jest runs CommonJS without --experimental-vm-modules, so a source `import()` throws.
// NOTE: tests only; Metro handles `import()` natively and splits the chunk.
const dynamicImportToRequire = ({ types: t }) => ({
visitor: {
CallExpression(path) {
if (path.node.callee.type !== 'Import') return;
path.replaceWith(
t.callExpression(t.memberExpression(t.identifier('Promise'), t.identifier('resolve')), [
t.callExpression(t.identifier('require'), path.node.arguments),
]),
t.callExpression(
t.memberExpression(
t.callExpression(
t.memberExpression(t.identifier('Promise'), t.identifier('resolve')),
[],
),
t.identifier('then'),
),
[
t.arrowFunctionExpression(
[],
t.callExpression(t.identifier('require'), path.node.arguments),
),
],
),
);
},
},
Expand All @@ -27,10 +39,9 @@ module.exports = (api) => {
presets: ['babel-preset-expo'],
// React Compiler is useful, but running it during every dev transform slows
// Metro feedback noticeably on this app. Keep it for production bundles.
plugins: isProduction
? [['babel-plugin-react-compiler', { target: '19' }]]
: env === 'test'
? [dynamicImportToRequire]
: [],
plugins: [
...(env === 'test' ? [dynamicImportToRequire] : []),
...(isProduction ? [['babel-plugin-react-compiler', { target: '19' }]] : []),
],
};
};
Loading
Loading