> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CLAUDE

# Modulus design system

Components for the redesigned Silvia product and landing page. Built from a
Figma library and a DTCG token file, tested against the existing system via
PostHog. Read the root `CLAUDE.md` first: it lists the frozen files and the
scoping rules that apply here.

## Figma file

```
4Audgs6XUkkKEa1KsyZZ3K
```

This is the Modulus design system library. The Figma MCP read tools -
`get_metadata`, `get_design_context`, `get_screenshot`, `get_variable_defs` -
all require a `fileKey`, and this is it.

**Diff a component against Figma before opening its PR.** The node ids that
appear in component headers (`Figma component set 130:50`) resolve against
this file and nowhere else.

Recorded here because it was missing: the only Figma key anywhere in the repo
was `2V9wEQpWOOlmWskxgh3MX9` in `components/landing-page-v3.tsx`, which is the
landing page file and returns no edit access. Card was therefore built from a
written spec with no way to read the real component, which is exactly the
situation this line exists to prevent repeating.

## Source of truth

`modulus-design-tokens.json` is authoritative. Every value in these components
resolves to a token from it. Never hardcode a colour, size, radius, duration
or opacity, and never reach for a value that is close enough.

**The token file is maintained by hand outside the repo and handed over.** It is
not generated, and there is no design-side file to reconcile against. An earlier
version of this line said "generated in Figma", which is wrong and cost time: it
invites the conclusion that a stale copy should be fixed at a source that does
not exist. Do not edit it to fix a component. If a component needs a value the
tokens do not have, stop and say so.

Because it is handed over, the copy being edited can fall behind `staging`, which
has happened repeatedly, each time silently reverting corrections made in the
repo. **Read the current file before editing it.** Which command depends on where
the copy lives, and getting that wrong is its own failure mode.

**Inside the repo**, do not fetch at all. Git already has it, with no network and
no chance of writing a stray file:

```bash theme={null}
git show origin/staging:tokens/modulus-design-tokens.json
```

**For a copy outside the repo**, fetch over the API and redirect to that copy's
own full path, never a bare filename:

```bash theme={null}
gh api "repos/CFO-Silvia/Silvia-Web/contents/tokens/modulus-design-tokens.json?ref=staging" \
  -H "Accept: application/vnd.github.raw" > /full/path/to/your/modulus-design-tokens.json
```

**The bare filename is the trap.** Run with `> modulus-design-tokens.json` from
the repo root and it writes a root-level copy, leaving
`tokens/modulus-design-tokens.json` untouched. Everything then reads correctly
while the authoritative file is stale, which is the same silent-resolution shape
recorded further down: the command succeeded, the file exists, and the wrong one
is being edited.

Two notes on the API route. The bare `raw.githubusercontent.com` URL returns 404
rather than 401 on this private repo, which reads as "no such file" rather than
"not authorised". And `gh` supplies the auth from an existing login, so there is
no token to manage.

### Edit the token file as TEXT, never through a JSON round-trip

`JSON.parse` followed by `JSON.stringify` does not preserve this file. JavaScript
objects order integer-like keys numerically AHEAD of string keys, whatever the
insertion order, so a round-trip silently rewrites every group that mixes the
two. `primitive/space` holds `2`, `3`, `4` alongside `half` and `2half`, and it
comes back with `half` and `2half` moved: measured at 36 lines of churn from an
edit that changed three values.

**The authored order carries meaning, which is what makes this more than churn.**
`primitive/space` is written `$type, 0, half, 1, 2, 2half, 3, …, 26`: `half` sits
between `0` and `1` because 2px sits between them, and `2half` between `2` and
`3` for the same reason. A round-trip pulls every integer-like key to the front,
so all three string keys land after `26` and the off-grid steps stop sitting next
to the rungs they are off-grid FROM. Measured, before and after:

```
authored:         $type 0 half 1 2 2half 3 4 5 6 8 9 10 11 12 16 20 24 26
after round-trip: 0 1 2 3 4 5 6 8 9 10 11 12 16 20 24 26 $type half 2half
```

`$type` is the sharpest of the three, and it is easy to miss because it is not a
rung. It declares the group's type and is written FIRST for that reason; a
round-trip moves it from index 0 to index 16, behind every numeric step it
governs. So the reader scanning for what kind of token this group holds now finds
that declaration buried among the values it applies to.

Nothing is lost that a parser would notice: the key SET is identical, and so are
every value, the leaf count and the parsed object. Only the order moves, which is
why this is worth writing down rather than discovering twice.

**Measure both sides with the same command.** An earlier version of the listing
above showed `half 2half` moving and no `$type` at all, which implied the key had
been dropped. It had not: the authored side was read from the raw text and the
round-trip side through `Object.keys(...).filter(k => !k.startsWith("$"))`, so one
side hid what the other showed. A before-and-after whose halves come from
different code paths reports a difference the data does not contain, and it reads
exactly like a real finding. **The file is maintained
by hand outside the repo**, so a reordering handover fights every future diff on
both sides: the next handover arrives looking like it changed hundreds of lines,
with the real edit invisible inside the churn.

So edit the raw text in place, and **assert the key sequence is byte-identical
afterward** rather than trusting that it is:

```bash theme={null}
node -e '
const fs=require("fs"), cp=require("child_process");
const before=cp.execSync("git show origin/staging:tokens/modulus-design-tokens.json").toString();
const after=fs.readFileSync("tokens/modulus-design-tokens.json","utf8");
// Anchored to line start: this file is pretty-printed, so a key is always the
// first thing on its line, and a quoted substring inside a $description value
// never is. An unanchored /"[^"]+":/ would match those too.
const seq=s=>(s.match(/^\s*"[^"]+":/gm)||[]).map(x=>x.trim()).join("|");
const identical = seq(before)===seq(after);
console.log("key sequence identical:", identical ? "YES" : "NO");
if (!identical) process.exitCode = 1;
'
```

The non-zero exit is not decoration. An earlier version of this snippet printed
`NO` and exited 0, so wiring it into CI would have produced a job that reports
the reorder in its log and passes anyway. That is the shape this file is about,
and it shipped inside the guard against it: **a check that states its own failure
in prose but not in its exit code is not a check.** The rule two sections down
says to prove a new assertion fails with a non-zero exit; this snippet was
written after that rule and still did not obey it.

**This check must read the raw text, and must NOT parse either side.** The
suggestion to "parse both documents and compare their key sequences" arrives
looking like a robustness fix and is the one change that destroys the check:
`JSON.parse` is the operation being guarded against, so parsing both sides
normalises both into the same order and the comparison returns YES for every
input, including a file that has just been fully reordered. Measured, on this
file against a round-tripped copy of itself: the anchored raw comparison reports
the reorder, and a parsed comparison cannot report it at all. A guard that
launders its input through the very transform it exists to detect is the
canonical check that cannot fail.

A leaf-count check is not a substitute. Reordering preserves the count, the
parsed object and every value, so counting leaves reports a clean result for a
file that has been rewritten. That is the same shape as the section below.

### Counting `$extensions` keys: count keys, not occurrences

`com.modulus.` appears **eight** times in the token file and only **six** of
those are structural `$extensions` keys. The other two are prose, inside the
file-level `$description` that documents the convention.

| Where | Count | Load-bearing |
| - | - | - |
| `$extensions` keys, `unit` and `cssTransform`, on each of `disabled-opacity`, `noise-opacity`, `pulse-floor` | 6 | **yes** |
| Prose in the file-level `$description` | 2 | no |

This matters because a count is exactly the kind of check someone writes, and a
check on the total would pass while a structural key was deleted and a prose
mention added; the two errors cancel. The structural count is the one that
would break the pipeline.

It is also the number that moves. This note first read "five, not seven",
which went stale one commit later when `pulse-floor` gained its missing `unit`.
**So do not assert either number.** Assert the invariant instead: every token
carrying `cssTransform` also carries `unit`, and the reverse. That holds at any
count, which a literal does not.

### The three opacity tokens, and the asymmetry that was there

`semantic/effect` holds the only tokens where Figma and CSS disagree. Figma
stores opacity as percent and the importer writes values verbatim, so each is
stored Figma-native and divided by 100 at build time:

| Token | `$value` | `unit` | `cssTransform` | Emits |
| - | - | - | - | - |
| `disabled-opacity` | 60 | `percent` | `divideBy100` | `0.6` light, `0.5` dark |
| `noise-opacity` | 4 | `percent` | `divideBy100` | `0` light, `0.04` dark |
| `pulse-floor` | 70 | `percent` | `divideBy100` | `0.7` |

`pulse-floor` carried the transform **without** the unit until v3.43.0, against
the file's own convention that a token whose Figma and CSS values disagree
carries both. The token was what was wrong rather than the convention: it is a
percent by every observable property: 70 stored, `divideBy100` applied, `0.7`
emitted, two siblings of identical shape annotated. Fixed by adding
`"com.modulus.unit": "percent"`.

**It survived, up to v3.43.0, because nothing read `unit`.** Every consumer read
`cssTransform` and only that: `verify-tokens.mjs:1408`, `foundations/tokens.ts:531`
and `:921`, and the emission record in `docs/modulus-tokens/`. The annotation was
inert documentation, which is exactly how it drifted.

**That is now historical. Assertion 24 reads `unit`** and asserts the pairing in
both directions, so the gap is closed rather than merely described: a transform
without a unit fails, and a unit without a transform fails. Both are
negative-tested. `verify-tokens.mjs` is no longer silent on the annotation.

Worth knowing that assertion 22 already anticipated the annotation being
unreliable, and its own comment says so: it derives its subjects from
`cssTransform` **OR** membership of `semantic/effect`, because "a token stops
being an opacity by moving, not by losing an annotation". That fallback is why
deleting the transform does not create a hole, and equally why a missing
`unit` was invisible, since `unit` was not part of that derivation at all.

**Note what happened to this passage, because it is the third instance of the
shape it describes.** It read "nothing reads `unit`, so no assertion could fire"
and was true when written. Assertion 24 landed four commits later in the same
pull request and made it false, and it was a reviewer rather than a check that
noticed. The count note above went stale the same way, one commit after being
written.

So: **when writing that nothing checks something, check whether you are about to
add the check.** A note describing an absence is only true until someone fills
it, and the someone is usually you, in the same branch.

## Hard rules

1. **Bind every value to a token.** No raw hex, no arbitrary Tailwind values
   such as `p-[13px]`, no magic numbers.
2. **Never use a `.sam-*` utility class.** The nine in `globals.css` are
   unscoped and would bypass the theme variant. Rose gold comes from
   `accent-text` and the `cta-intent` pair.
3. **Never write to `:root`.** All variables live under the theme variant
   selector, `[data-theme-variant='modulus']` (plus `html.dark[...]` and
   `html.dark [...]` for dark mode).
4. **Prefix every Tailwind key `modulus-`.**
5. **Title every story `Modulus/*`, under exactly three groups:**
   `Modulus/Foundations/<Topic>`, `Modulus/Components/<Category>/<Name>` and
   `Modulus/Screens/<Area>/<Page>`. See the story category table and **Story
   titles** below for the eight component categories, the naming rule and the
   full tree.
6. **Prefix every exported identifier `Modulus` / `modulus`.** Components, cva
   recipes and prop types alike. Separately, every exported COMPONENT carries a
   `displayName` matching its export; a prop type has no runtime value and
   cannot. See **Export naming** below for scope and exclusions.
7. **Story content names the slot, never the product.** `Button`, `Title`,
   `Description`, `Label`. See **Story content** below for the six exceptions
   and why an unreviewed story that looks approved is the real cost.
8. **A claim about WHY a decision was made needs a citation to where it was
   made.** A file and line, a commit sha, a PR link, or a dated quote. If the
   reason is not recorded anywhere, write that instead of supplying one. See
   **Recorded reasons** below.
9. **A stylesheet that scopes from the theme variant carries BOTH selector
   forms.** The descendant `[data-theme-variant='modulus'] .thing` AND the
   compound `[data-theme-variant='modulus'].thing`, whenever `.thing` can sit
   on an element that also carries the attribute. See **Scoping from the
   variant** below.
10. **A composition that merges caller input with the component's own values
    decides which side wins, and the file says which values are load-bearing
    and why.** The load-bearing side goes LAST. Where the merge function
    itself resolves conflicts, check that it resolves the right KIND of
    conflict - `modulusCn` resolves Tailwind classes and will silently drop an
    id that reads as a competing utility. See **Merging two of the same kind**
    below.

## Structure

* One component per file, kebab-case: `button.tsx`, `icon-button.tsx`
* Stories colocated and same-named: `button.stories.tsx`
* Variants via `class-variance-authority`, with variant keys matching the
  Figma variant property names one to one
* Imports use the `@/` alias, which is rooted at the project root
* `tags: ['autodocs']` on each story meta, since autodocs is off repo-wide
* `modulusCn` comes from `./modulus-cn`, never `cn` from `@/lib/utils`. The
  Modulus Tailwind keys are token names rather than lengths, so `tailwind-merge`
  needs extra class groups to merge them; that configuration lives in
  `components/modulus/modulus-cn.ts` so the 142 files importing `@/lib/utils`
  keep the stock merger untouched. The export is named `modulusCn`, not `cn`,
  because two functions answering to `cn` gave a caller no way to tell which
  one an `import { cn }` had bound - the filename disambiguates the path, only
  the export name disambiguates the call site. `lib/utils.ts` remains the
  canonical `cn` for everything outside Modulus. The `modulus-` prefix reads
  redundantly inside a `modulus/` folder on purpose: this file moves to
  `components/ui/` with the four components, where the prefix is load-bearing
* Storybook-only CSS lives in `components/modulus/modulus-storybook.css`, imported
  by each story file. `.storybook/preview-head.html` and `.storybook/preview.tsx`
  are shared with every other story file in the repo and are not ours to edit
* **`components/modulus/modulus-surface.css` IS imported in `app/layout.tsx`, at
  line 20, alongside `app/modulus-tokens.css` at 17.** Unlike
  `modulus-storybook.css` it is product CSS, not Storybook chrome: it holds the
  shared surface treatments, currently the noise grain that Card carries and
  that Composer and Modal will want. The failure mode it guards against is
  silence: the class is on the element, the token resolves, and the grain simply
  does not appear, because the rule defining `.modulus-surface-noise` was never
  loaded. Nothing errors and nothing warns.
  **THIS BULLET READ "must be imported … when the app is wired up" AND
  "Storybook-only until that import lands" UNTIL 2026-09-17, AND THE IMPORT HAD
  ALREADY LANDED.** It was describing a state that had ended, in the section
  whose entire subject is failures that announce nothing - so a reader checking
  whether the grain reaches production would have been told it does not, by the
  paragraph they went to precisely because it is hard to check. Found by an
  audit, not by a reader. **The general form: an instruction written in the
  future tense does not retract itself when it is carried out.** Grep
  `app/layout.tsx` before repeating any claim here about what it imports;
  today that is six Modulus stylesheets, and the list moves.
* **`components/modulus/modulus-tag.css` is DELIBERATELY not imported there, and
  that decision is recorded at `app/layout.tsx:34`.** Not an oversight and not
  the same case as the bullet above: its rules apply only to a Tag carrying a
  remove button and to a Tag inside a Composer, and no product Tag is either
  today, so importing it would change nothing. It becomes required the first
  time a Tag ships with `onRemove` or inside a Composer. It holds the two things
  no utility can express: the
  `@media (hover: hover)` gate that stacks Tag's X on its leading icon and swaps
  them on hover — on touch the two sit side by side permanently instead, because
  there is no hover to reveal anything and a swap would mean the icon was never
  seen on a phone at all — and the `@container modulus-composer` query that drops
  Tag's label when the Composer is too narrow. That query has **two thresholds**,
  450 for a hovering pointer and 470 for touch, because the in-flow X makes the
  touch Tag exactly 20 wider. Also product CSS, also currently imported from
  story files (`tag.stories.tsx`, `composer.stories.tsx`), also silent when
  missing — the Tag renders correctly at rest and simply never swaps and never
  collapses. It is imported by FIVE story files, not the two this used to name:
  `tag.stories.tsx`, `chat.stories.tsx`, `composer.stories.tsx`,
  `radar.stories.tsx` and `research.stories.tsx`. `tag.tsx` and `composer.tsx`
  name the file in prose and do not import it, which is the code-form-versus-
  prose trap this document records elsewhere - a bare filename search returns
  seven hits and five of them are imports.
  Note the container side has two halves: `container-type: inline-size` is set
  by the `.modulus-composer-query` class that `composer.tsx` puts on its Card,
  and the query itself lives in this stylesheet, so neither works alone
* **Container queries are used directly, with no Tailwind plugin.**
  `@tailwindcss/container-queries` is not installed and was not added for this.
  The plugin only supplies `@container`/`@md:` utility classes; the CSS feature
  needs nothing, and `components/preference-modal/preference-modal.module.css`
  already uses it. Prefer a container query to a media query whenever the
  constraint is how wide a component's own surface is: a Composer can be 466
  wide inside a 1440 window, which is exactly what the Storybook docs page does,
  and a viewport query is wrong in every such case
* **But a container query needs a CONSTANT to compare against, and that is
  where it stops being the answer.** A threshold describes a width only while
  the thing being fitted has a fixed width. Tag's label is fixed by its own
  content, so 450 and 470 describe it. The composer's mode chip is the same
  shape of problem with the opposite answer: its label is the MODE, a runtime
  prop, and the three FULL CHIPS measure 113.41, 120.98 and 183.54 against a
  compact 74 - so one threshold is wrong for two of them. (The labels inside
  them are 31.41, 38.98 and 101.54; the chip is the compact 74 plus a label
  and its 8 gap. Naming the chips rather than the labels matters here, because
  the chip is what has to fit in the row.) Worse, the row it has to fit in also
  holds `startActions`, which is a caller slot. `use-composer-compact.ts`
  measures the row instead, and `components/modulus/composer.tsx` carries the
  worked comparison. The test is not "is this a width response" - both are -
  but "is the width I would be comparing against a constant". If a prop or a
  caller can change it, measure
* **A rounded container holding a filled or bordered child needs to clip, and
  the library already does — audited 2026-09-11, no exceptions found.** The
  canvas had OptionList `385:566` and every Mode Menu Panel variant carrying a
  radius with `clipsContent` false, so a selected row's fill squared off past
  the rounded border; Modal / Picker already clipped, which is why it never
  showed there. **The code equivalent does not exist.** ModeMenu sets
  `overflow-hidden` and OptionList and RowActions set `overflow-y-auto` on the
  same element as the radius — and `overflow-y: auto` with a visible x-axis
  computes x to `auto` too, so the box clips on both. Swept every rounded
  element in 16 stories: **0 bleeds**, with a deliberately bleeding element
  injected into the page each run to prove the detector fires. Two notes for
  whoever repeats it. The test is CONTAINMENT, not concentricity: a square
  child at inset `p` inside radius `r` is contained when `p >= r(1 - 1/√2)`,
  not when `p >= r`. The stricter rule flagged four ArtifactCards that do not
  bleed — `r` 16, `p` 9, and 9 > 4.69. And a rounded element whose children are
  only text or icons cannot bleed at all, so it is not a finding that it was
  skipped

## Export naming

**Every exported identifier carries the prefix.** Components, cva recipes and
prop types: `ModulusButton`, `modulusButtonVariants`, `ModulusButtonProps`.
Where a component is multi-part, every part takes it: all six of Card's, all
three of NavGroup's.

The `displayName` requirement is separate and narrower. It applies to exported
COMPONENTS only, where it matches the export string for string, because a prop
type has no runtime value to hang one on.

This is the `modulusCn` rule applied to components, and for the reason recorded
there. The filename disambiguates the path; only the export name disambiguates
the call site.

Be precise about what that buys, because it is easy to overstate. **Nothing
enforces the prefix itself.** `next lint` is the only linter that runs, and
neither `.eslintrc.json` nor `tsconfig.json` carries a naming rule, so a new
export that omits the prefix is caught by review and by this document, not by a
check. What the prefix does buy is that once the names are distinct, the
accident it exists to prevent can no longer happen QUIETLY: `import { ModulusButton } from '@/components/ui/button'` is a compile error because that
module has no such export, and so is `import { Button }` from ours. So "the two
systems are never mixed in one file" stops being a convention a reader has to
already know and becomes a failure at the import site. That is a narrower claim
than the compiler enforcing the rule, and it is the true one.

The collision is exact rather than theoretical. Measured against
`components/ui/` at the time of writing, **nine identifiers clash**: `Button`,
`buttonVariants` and `ButtonProps` from `components/ui/button.tsx`, and all six
of `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent` and
`CardFooter` from `components/ui/card.tsx`. An unprefixed `Button` in a second
library is the thing that gets imported by accident, and the window opens the
moment the file is on staging rather than at some later date. The house already
prefixes a namespaced set this way: `components/silvia-status-indicator.tsx`
exports `SilviaStatusIndicator`.

**A recipe whose name does not place it folds the component in.**
`fieldVariants` and `trackVariants` name nothing once prefixed, and a recipe
nobody can place is the one that gets edited by mistake. So
`modulusTextFieldFieldVariants`, `modulusSegmentedTrackVariants`,
`modulusSegmentedTabVariants`. The doubled `Field` in the first is deliberate:
`Field` is the Figma layer this recipe binds to, so keeping it stays greppable
against Figma.

**Where a component's internal identifier differs from its export, rename it
too.** `SegmentedRoot` and `HistoryPanelRoot` exist because a generic component
needs its `displayName` set before the `as` cast. An internal name that
disagrees with its own `displayName` is the same confusion the prefix exists to
remove.

### Scope: the component library, not `foundations/`

**The set is fifteen components** as of 9 Sep 2026: ArtifactCard, Button, Card,
Chip, Composer, EmptyState, HistoryPanel, HistoryRow, IconButton,
NavGroupHeader, RowActions, Segmented, Sidebar, Tag, TextField. The number is
the count of `components/modulus/*.tsx` less the story files, so it is checkable
in one command rather than trusted from this list, and it is the list that goes
stale first.

The rule covers the components in `components/modulus/` and their recipes and
types. **`components/modulus/foundations/` is exempt**, and the exemption is a
decision rather than an oversight.

`foundations/blocks.tsx` exports 23 unprefixed functions (`Scope`,
`ColourRoles`, `ContrastTable` and the rest); `foundations/tokens.ts` exports 54
(`MODES`, `resolvedHex`, `colourGroups` and the rest). Both are documentation
scaffolding rather than library surface.

Their consumers, checked rather than assumed:

* `blocks.tsx` is imported by the four sibling `foundations/*.mdx` pages, as
  `'./blocks'`.
* `tokens.ts` is imported by those pages **and by
  `scripts/modulus-tokens/foundations-coverage.ts`**, which takes 10 of its
  exports and runs as `npm run foundations:report`.

So the exemption does NOT rest on "no consumer outside `foundations/`" - there
is one. It rests on the narrower and true thing: neither file exports an
identifier that collides with anything in `components/ui/`, and no consumer is
product code or a component call site, so no import can bind the wrong library.
That is the harm the prefix exists to prevent, and it is absent here. Renaming
54 exports plus a script that imports them would be an API break with no
collision behind it, and they are already on staging.

The condition that would end the exemption is therefore not "gains any external
consumer", which is already the case. It is either of: a foundations export
starts being imported by product code, or one of them takes a name that clashes
with `components/ui/`. Prefix at that point.

### What the prefix does not reach

* **`data-slot` values.** They stay unprefixed: `data-slot='button'` on
  `ModulusButton`. They are kebab-case attributes, scoped by the element they
  sit on rather than being identifiers, and selectors depend on them. That is
  why the Axis 1 wording says "matching the component's UNPREFIXED name".
* **File names.** `button.tsx` stays, since the directory already says modulus.
  **This holds only while the files live here.** They are planned to move to
  `components/ui/`, where `button.tsx` and `card.tsx` ALREADY EXIST, so the
  filesystem itself collides at that destination and the decision has to be
  retaken as part of the move. Recorded here so the move does not rediscover it.
* **Story titles.** `Modulus/Components/Action/Button` is already namespaced by
  its first segment, so the last segment drops the prefix rather than
  repeating it: `Button`, `ChipLarge`, `SettingRow`. A composition with no
  export of its own takes its file name instead; see **Story titles**.
* **A screen story, which exports nothing at all.** A `Modulus/Screens` file in
  this folder is a story file with no component beside it, so this rule has
  nothing to bind to. Stated explicitly because the obvious way to satisfy a
  naming rule is to produce a name: do NOT add a `ModulusChatScreen` export so
  the rule has a subject. The absence of an export is the design, since
  anything exported here would be a second assembly of a screen the product
  already assembles from routes and real data. The three `Modulus/Screens`
  files under `app/` render the route's own component, which is product code
  outside this library, so the rule does not reach them either.
* **Prose in comments.** This one looks like an omission and is not. Short names
  in prose frequently do not refer to our components at all: `Figma's
  IconButton "Icon size" property` names the Figma set, which is not renamed;
  `Button-only attributes` in `chip.tsx` means the HTML `<button>`; and
  `alongside Button, IconButton and Chip` names story titles. A blanket prose
  rename corrupts all three. Code references inside **backticks** do follow,
  because a doc pointer that no longer greps is simply wrong.

**Renaming by word boundary alone will break labels, twice over.** Both of these
happened during the pass that introduced this rule, and neither is caught by
`tsc` or by lint, because a string label is valid TypeScript whatever it says:

* Quoted values. `label='Chip'` became `label='ModulusChip'` in 13 places, and
  the Chip in the docs would have read "ModulusChip".
* JSX text. `<Tag ...>Tag</Tag>` puts the component in the tag and the LABEL in
  the child. Four of those were caught by a guard, and a fifth,
  `<CardTitle>Card {i + 1}</CardTitle>`, slipped past it because the text
  continues into an interpolation instead of ending at the closing bracket.

So mask string literals before replacing, guard JSX text, and afterwards grep
for quoted and JSX-text occurrences of the prefix rather than trusting a green
build.

# shadcn compliance

"shadcn compliant" here means matching the conventions of the shadcn components
this repo already maintains in `components/ui/`, not the upstream registry.
`components/ui/button.tsx` is the reference: it is the closest analogue, and the
maintainer explicitly restored `forwardRef` to it in `d568e51e3` after a v4
refresh had removed it.

Compliance is judged on two independent axes. Axis 1 is unconditional. Axis 2 is
a decision rule, and the answer is usually "no primitive".

## Axis 1 — conventions. Every component, no exceptions.

| Rule | Requirement |
| - | - |
| `forwardRef` | The ref reaches the root DOM element, or the primary interactive element where the root is a wrapper. Document which, and why, in the file. `text-field.tsx` forwards to the inner `<input>` because a form library needs it, matching `components/ui/form-field.tsx`. |
| `displayName` | Explicit, immediately after the definition, a literal string. A named function expression passed to `forwardRef` sets `.render.name`, **not** `.displayName` - so it is not enough. For a generic component, set it on the `forwardRef` object *before* any `as` cast, or the component surfaces under the inner function's name. |
| `cva()` | Every class-producing decision lives in a `cva` recipe. No ternaries and no `&&` returning class strings at the call site. Static class strings passed to `cn` are fine; conditionals are not. |
| Recipe exported | `export { X, xVariants }`. A consumer with no recipe and no `asChild` has no way to extend the component at all. |
| `VariantProps` | The props type extends the native element's HTML attributes **plus** `VariantProps<typeof xVariants>`. See the discriminated-union ordering rule below - it is load-bearing. |
| `cn()` | `className` positionally last, so a caller can override. |
| `...props` | Rest spread onto the root element, so a caller can pass `id`, `data-*` and event handlers. |
| `data-slot` | First attribute on the root, kebab-case, matching the component's UNPREFIXED name: `data-slot='icon-button'` on `ModulusIconButton`. See **Export naming**. |
| `defaultVariants` | Every variant group defaulted. |
| Named exports | A trailing `export { … }` block. No default export. Story files are the exception: CSF requires `export default meta`, and every other story file in the repo does it. |
| `'use client'` | Line 1 whenever the file uses a hook or owns a handler. Taking a handler through `...props` does **not** count - `button.tsx` needs no directive; `segmented.tsx` owns an `onChange` and calls `useId`, so it does. |
| Composition | Multi-part components expose sibling parts, each with its own `forwardRef` and `displayName`, in one trailing export block - the `components/ui/card.tsx` shape. No dot notation, no `Object.assign`. Avoid closed content-prop APIs where a caller would reasonably pass children. |

## Axis 2 — primitives. Only where behaviour demands it.

Adopt a Radix primitive when the component owns **focus management, portalling,
or open/close state**. Otherwise use the native element and stop. Wrapping a
native control in a primitive that adds nothing is a cost, not compliance:
shadcn's own Button is a native `<button>`.

| Component | Primitive | Reason |
| - | - | - |
| Button, IconButton, Chip | None. Native `<button>` | shadcn's own Button is a native button. Radix would add nothing. |
| Button, Chip | `@radix-ui/react-slot` for `asChild` only | Needed to render as a link. |
| TextField | None | Native `<input>` with `htmlFor` and `aria-describedby` already carries the wiring. |
| Segmented | None. Native radios | A shared `name` gives one tab stop and arrow keys natively, superseding a roving tabindex. |
| Card | None | No behaviour. |
| Composer | None | Textarea plus buttons. |
| NavItem | `@radix-ui/react-tooltip` when collapsed | Portalling and delayed open. The label must survive as the accessible name. |
| NavGroupHeader | `@radix-ui/react-collapsible` | Open/close state, plus `aria-expanded` and `aria-controls`. |
| AccountRow | `@radix-ui/react-dropdown-menu` if it opens a menu | Focus trap, portalling, keyboard navigation. |
| Sidebar | `@radix-ui/react-dialog` for the mobile panel | Portal, focus trap, escape handling. |

Verified present in `package.json` at the time of writing: `react-slot` ^1.3.0,
`react-tooltip` ^1.2.10, `react-collapsible` ^1.1.14, `react-dropdown-menu`
^2.1.18, `react-dialog` ^1.1.17, `react-tabs` ^1.1.15, `react-radio-group`
^1.4.1. **`@radix-ui/react-toggle-group` is not installed at any level.**
`components/ui/sheet.tsx` does not exist, so do not reach for shadcn's Sheet.
**Install nothing.** If a primitive you believe is needed is absent, stop and
report.

## Axis 3 — robustness. Three rules, each written after it broke something.

**1. Do not style an ancestor from a descendant's state.** Use `peer` for a
sibling, or put the state class on the element that carries the state. `:has()`
only where no alternative exists, and then always with a fallback rule.

`:has()` was the one selector in this system without universal support, and both
uses of it turned out to be avoidable:

* **Segmented** paints the ring on the tab while focus lands on a visually
  hidden radio. The radio is given `peer` and the tab, its next sibling inside
  the wrapping `<label>`, uses `peer-focus-visible:`. That is the `~`
  combinator, supported everywhere. Keep exactly one radio and one tab per
  label: `~` is a general sibling combinator, so a `peer` sharing a parent with
  several tabs would ring all the later ones.
* **TextField** genuinely binds its border, height, padding, radius and fill to
  the Field wrapper, because that frame is what Figma draws and what holds the
  optional prefix. It uses `focus-within:`, which has universal support. That is
  equivalent to `has-[:focus-visible]` *here* and only here, because the input
  is the only focusable descendant and a focused text input always matches
  `:focus-visible` whatever the input modality. Do not copy this to a control
  whose focusable element is a radio, checkbox or button: those do not match
  `:focus-visible` on a mouse click, so `focus-within:` would show a ring where
  the old rule did not.

**2. Every single-axis variant must be independently complete.**
`compoundVariants` may only override an appearance that already exists. It must
never be the sole source of one, so an unmatched combination still renders
something valid.

Button and IconButton failed this: `emphasis` and `tone` were both `''` and
every appearance lived in `compoundVariants`, so a pairing no compound matched -
one computed at runtime, or from a call site that is not typechecked - rendered
a control with no fill, no text colour and a transparent focus ring. Each
`emphasis` now carries its complete neutral appearance and `tone` is a modifier.

An empty variant value is still fine when the recipe's **base** already provides
a complete appearance - `textFieldVariants`' `disabled: false` is empty because
the base holds the layout. The test is not "is any value empty" but "can a
combination render with no appearance at all".

When a tone override replaces a declaration, check it lands in the same
tailwind-merge class group as the thing it replaces, or both will survive.
`bg-modulus-cta-intent` displaces `bg-modulus-cta-bg` because twMerge routes every
`bg-*` value through the colour catch-all, gradient keys included.

**3. A component rendering a native form element** (radio, checkbox, input,
select) **must document its `name` scoping and its behaviour inside a `<form>`
in the file header.** Two instances must never be able to share a `name` by
accident. Default to a per-instance `useId` so the safe case needs no thought,
and say plainly in the prop's doc that supplying a `name` transfers that
guarantee to the caller. `segmented.tsx` is the worked example.

## Four things that will bite, all measured

**Size keys collide with stock at different pixel values.** Stock `default` is
36px, which equals Modulus `md`. Stock `sm` is 32 against Modulus's 24, and stock
`lg` is 40 against Modulus's 44; stock has no `md` at all, while `md` (36) is
Modulus's default. The sharpest collision is stock `xs`, which is 24 - the same
pixel height Modulus calls `sm`, one key apart. Modulus keeps its own ladder
because it maps to Figma one to one. **The two systems are never mixed in one
file** - importing both `Button`s into one module also collides on the exported
name.

**`Omit` destroys union enforcement.** `Omit<ButtonProps, 'size'>` flattens the
discriminated union: it accepts every invalid `emphasis` and `tone` pairing, and
accepts icon-only with no `aria-label` - the exact a11y hole the union exists to
close. Wrappers must use `ComponentProps<typeof Button>` or
`ButtonProps & { … }`, both of which preserve it. `components/modulus` has a
type-level regression test for this hazard; keep it failing on purpose.

**Invalid variant pairings render unstyled.** `emphasis` and `tone` are declared
as empty strings in `variants`, and all appearance lives in `compoundVariants`,
so a pairing no compound covers produces a control with no background and no
text colour. The type union is the only guard. Never widen it, and never let
`VariantProps` win: intersect it **before** the discriminated unions, so the
union arms narrow cva's open props rather than the reverse.

**Story titles go under three groups, and components under the eight
categories below.** No others. `Foundations` and `Screens` are groups, not
categories: `Modulus/Foundations/<Topic>` and `Modulus/Screens/<Area>/<Page>`
take no category segment. Every component sits at
`Modulus/Components/<Category>/<Name>`, grouped by what it *is*: Chip is
`Modulus/Components/Action/Chip` because it renders a `<button>` with no value
semantics, alongside Button and IconButton.

Nothing sorts the sidebar to match this table. With no `storySort` (and
`.storybook/preview.tsx` is not ours to edit, see **Structure**), Storybook
keeps index order, and `.storybook/main.ts` globs `app/**` before
`components/**`, so `Screens` shows first under Modulus.

| Category | Holds | Queued |
| - | - | - |
| Action | Button, IconButton, Chip, ChipLarge, SelectableChip, FilterPill, Link, PromptRow, RowActions, and the two entry-screen strips with their entry arrangements: ContactStrip, ComposerStrip, EntryComposerWithContactStrip, EntryComposerWithComposerStrip | |
| Data Input | TextField, Select, DateField, SearchField, Segmented, Switch, Checkbox, Slider, SettingRow, DocumentRow, Composer | |
| Navigation | NavItem, NavGroupHeader, AccountRow, Sidebar, Topbar, MobileAppBar, TextTab, HistoryRow, HistoryPanel, PortfolioSelectorMenu | |
| Surface | Card, ArtifactCard, InfoPanel, RadarCard | |
| Overlay | ModalPanel, ModalPicker, ModalShowcase, ModeMenu, Drawer, OptionList, Pane, Settings, and the product's own dialogs: SettingsModal, SettingsUnsavedDialog, BusinessAddChoiceDialog, BusinessDialog, BusinessLinkAccountsDialog, BusinessRemoveDialog, ConnectionSuccessDialog, WebviewConnectEscapeDialog | Tooltip |
| Feedback | Tag, Avatar, EmptyState | StatusRing, and an Alert once designed |
| Conversation | MessageSilvia, MessageUser, Thread | |
| GenUI | every component the UI catalog renders, and Compare (legacy beside Modulus): see **GenUI** below | |

### Story titles

**Every story sits under its proper place in the tree below, existing ones
included.** Set on 2026-10-06. The brief, with its paths set in code: "TARGET:
everything under Modulus, in exactly three groups:
`Modulus/Foundations/<Topic>` tokens shown visually: colour, type, spacing,
radii, icons, motion / `Modulus/Components/<Category>/<Name>` every reusable
component, each in a category / `Modulus/Screens/<Area>/<Screen>` full screens
and flows", and "Update the Modulus rules doc with the final tree, so new
stories follow it." Then, the same day: "The naming rule applies to ALL
stories, existing ones included. Every story must sit under its proper
category, even though more story IDs change."

**The naming rule.**

* **Foundations:** `Modulus/Foundations/<Topic>`, the topic in plain Title Case
  words: Colour, Typography, Spacing, Motion, Icons, Logo, Logo Icon, Logo
  Wordmark.
* **Components:** `Modulus/Components/<Category>/<Name>`. `<Category>` is
  spelled as in the table above. `<Name>` is the component the file documents,
  the one its stories render, without the `Modulus` prefix: ChipLarge,
  SettingRow, ModalPanel, ConnectionSuccessDialog. That is the meta `component`
  unless the meta points at a wrapper local to the story file, as
  `StatefulSelector` does in
  `components/portfolios/portfolio-selector-menu.stories.tsx`. A
  composition with no component of its own takes its file name in PascalCase:
  Compare, Settings, EntryComposerWithContactStrip,
  EntryComposerWithComposerStrip.
* **Screens:** `Modulus/Screens/<Area>/<Page>`. A screen is a full page, a
  route, and nothing else. `<Area>` is the product's name for the route or
  family of routes (Chat, Research, Radar, Portfolio, Sign Up). `<Page>` is
  `Page` for the area's own whole screen. Another route in the area takes that
  page's own name: Portfolio/Businesses for `/assets-liabilities/businesses`,
  Portfolio/Business for `/assets-liabilities/businesses/[businessUuid]`. A
  state of a route that has its own story file takes the canvas frame's or the
  state's name: Radar/Entry, Sign Up/Webview.
* **One title per file, one component per title.** A file that covered
  several components is split: the business dialogs were one file under
  `Modulus/Business/Dialogs` and are now four, one per dialog, sharing
  `components/business/business-story-fixtures.ts`.

**Where the less obvious ones went.**

* **The two entry composer arrangements are in Action, beside their strips.**
  They are not routes, so not Screens, and both strips belong to the entry
  composer: the Contact Strip is "Entry-screen composer only"
  (`contact-strip.tsx:26`), and the Composer Strip is "tucked under the
  entry-screen Composer" (`composer-strip.tsx:26`).
* **ContactStrip and ComposerStrip are in Action, not Conversation.** The
  Contact Strip's spec says it "never appears in the conversation view"
  (`contact-strip.tsx:26-27`), and the Composer Strip is a checklist under the
  entry composer, "each one a link" (`composer-strip.tsx:26-27`).
* **Conversation holds MessageSilvia, MessageUser and Thread.** Conversation
  draws the turns and the column that stacks them; GenUI draws what the catalog
  puts inside a turn. The category's own recorded reason is the docblock on the
  meta in `message-user.stories.tsx`.
* **PortfolioSelectorMenu stays in Navigation.**
* **The logo marks are in Foundations, not a component category,** as brand
  marks shown visually, like Icons.

Those five placements were proposed in the pass that set this tree and
accepted on 2026-10-06 with "Everything else stands as before".

**Stories outside this folder follow the same rule.** The product's own
components, dialogs and pages titled `Modulus/*` live in `components/business/`,
`components/settings/`, `components/connection-success/`,
`components/dialogs/`, `components/portfolios/` and `app/`, and are filed in
the tree like any story here. `packages/ui-catalog/README.md` repeats the GenUI
title; keep it in step with **GenUI** below.

**Pick a new story's place from the tree, and do not rename without a
reason.** Storybook derives a story's id from its title unless the meta sets
`id`, and no Modulus meta does, so a rename changes every story id in the file
and an old `?path=/story/...` link stops resolving.

The tree on 2026-10-07. Paths are relative to `components/modulus/` unless
they start with `components/` or `app/`. It is a snapshot and goes stale first;
the live list is one command,
`grep -rEho --include='*.stories.tsx' --include='*.mdx' "title[:=] *['\"]Modulus/[^'\"]*" app components | sort -u`.

```
Modulus/
  Foundations/
    Colour                          foundations/Colour.mdx
    Typography                      foundations/Typography.mdx
    Spacing                         foundations/Spacing.mdx
    Motion                          foundations/Motion.mdx
    Icons                           icons.stories.tsx
    Logo                            logo-lockup.stories.tsx
    Logo Icon                       logo-icon.stories.tsx
    Logo Wordmark                   logo-wordmark.stories.tsx
  Components/
    Action/
      Button                        button.stories.tsx
      Chip                          chip.stories.tsx
      ChipLarge                     chip-large.stories.tsx
      ComposerStrip                 composer-strip.stories.tsx
      ContactStrip                  contact-strip.stories.tsx
      EntryComposerWithComposerStrip  entry-composer-with-composer-strip.stories.tsx
      EntryComposerWithContactStrip   entry-composer-with-contact-strip.stories.tsx
      FilterPill                    filter-pill.stories.tsx
      IconButton                    icon-button.stories.tsx
      Link                          link.stories.tsx
      PromptRow                     prompt-row.stories.tsx
      RowActions                    row-actions.stories.tsx
      SelectableChip                selectable-chip.stories.tsx
    Data Input/
      Checkbox                      checkbox.stories.tsx
      Composer                      composer.stories.tsx
      DateField                     date-field.stories.tsx
      DocumentRow                   document-row.stories.tsx
      SearchField                   search-field.stories.tsx
      Segmented                     segmented.stories.tsx
      Select                        select.stories.tsx
      SettingRow                    setting-row.stories.tsx
      Slider                        slider.stories.tsx
      Switch                        switch.stories.tsx
      TextField                     text-field.stories.tsx
    Navigation/
      AccountRow                    account-row.stories.tsx
      HistoryPanel                  history-panel.stories.tsx
      HistoryRow                    history-row.stories.tsx
      MobileAppBar                  mobile-app-bar.stories.tsx
      NavGroupHeader                nav-group-header.stories.tsx
      NavItem                       nav-item.stories.tsx
      PortfolioSelectorMenu         components/portfolios/portfolio-selector-menu.stories.tsx
      Sidebar                       sidebar.stories.tsx
      TextTab                       text-tab.stories.tsx
      Topbar                        topbar.stories.tsx
    Surface/
      ArtifactCard                  artifact-card.stories.tsx
      Card                          card.stories.tsx
      InfoPanel                     info-panel.stories.tsx
      RadarCard                     radar-card.stories.tsx
    Overlay/
      BusinessAddChoiceDialog       components/business/business-add-choice-dialog.stories.tsx
      BusinessDialog                components/business/business-dialog.stories.tsx
      BusinessLinkAccountsDialog    components/business/business-link-accounts-dialog.stories.tsx
      BusinessRemoveDialog          components/business/business-remove-dialog.stories.tsx
      ConnectionSuccessDialog       components/connection-success/connection-success-dialog.stories.tsx
      Drawer                        drawer.stories.tsx
      ModalPanel                    modal-panel.stories.tsx
      ModalPicker                   modal-picker.stories.tsx
      ModalShowcase                 modal-showcase.stories.tsx
      ModeMenu                      mode-menu.stories.tsx
      OptionList                    option-list.stories.tsx
      Pane                          pane.stories.tsx
      Settings                      settings.stories.tsx
      SettingsModal                 components/settings/settings-modal-loading.stories.tsx
      SettingsUnsavedDialog         components/settings/settings-unsaved-dialog.stories.tsx
      WebviewConnectEscapeDialog    components/dialogs/webview-connect-escape-dialog.stories.tsx
    Feedback/
      Avatar                        avatar.stories.tsx
      EmptyState                    empty-state.stories.tsx
      Tag                           tag.stories.tsx
    Conversation/
      MessageSilvia                 message-silvia.stories.tsx
      MessageUser                   message-user.stories.tsx
      Thread                        thread.stories.tsx
    GenUI/
      ApprovalRequest               genui/approval-request.stories.tsx
      AssetProposal                 genui/asset-proposal.stories.tsx
      BarChart                      genui/bar-chart.stories.tsx
      Compare                       genui/compare.stories.tsx
      DetailCard                    genui/detail-card.stories.tsx
      EmailDraft                    genui/email-draft.stories.tsx
      InfoCard                      genui/info-card.stories.tsx
      LineChart                     genui/line-chart.stories.tsx
      MetricCard                    genui/metric-card.stories.tsx
      MetricChange                  genui/metric-change.stories.tsx
      MetricGrid                    genui/metric-grid.stories.tsx
      PieChart                      genui/pie-chart.stories.tsx
      ProgressBar                   genui/progress-bar.stories.tsx
      ProgressTracker               genui/progress-tracker.stories.tsx
      Scenario                      genui/scenario.stories.tsx
      StatusCard                    genui/status-card.stories.tsx
      Table                         genui/table.stories.tsx
      Timeline                      genui/timeline.stories.tsx
  Screens/
    Chat/Page                       chat.stories.tsx
    Research/Page                   research.stories.tsx
    Radar/Entry                     radar.stories.tsx
    Radar/Page                      radar-page.stories.tsx
    Portfolio/Business              app/(app)/assets-liabilities/businesses/[businessUuid]/_components/business-detail-screen.stories.tsx
    Portfolio/Businesses            app/(app)/assets-liabilities/businesses/_components/businesses-detail-screen.stories.tsx
    Sign Up/Webview                 app/(public)/sign-up/[[...sign-up]]/apple-webview.stories.tsx
```

### GenUI

**Every component Silvia's UI catalog (`packages/ui-catalog`) can put in a
conversation lives in `components/modulus/genui/`, with its stories and tests,
and is titled `Modulus/Components/GenUI/<Name>`.** Added on 2026-10-01 on
instruction: "We need all gen-ui components to live together under components
directory, so it becomes easy to find them and their stories. If I'm on
storybook inside modulus I want a gen-ui directory with all them inside."

GenUI was titled `Modulus/GenUI/<Name>` until 2026-10-06, when it moved under
`Components` with the rest of the tree: the brief quoted under **Story titles**
puts "every reusable component, each in a category" there, and GenUI becoming
a component category was accepted the same day with "Everything else stands
as before".

It is one of the eight component categories, and the only one grouped by
*where a component is used* rather than *what it is*, which the taxonomy below
otherwise forbids. The instruction above is the reason recorded for the
exception: GenUI components
are found by asking "what can Silvia show?", and the catalog is the list that
question is answered from, so the folder mirrors it. A component used both in
GenUI and elsewhere (Table is) still lives here; other surfaces import it from
`genui/`.

**Adding a catalog component therefore means a file here.** Put the Modulus
component in `genui/`, its story beside it as
`Modulus/Components/GenUI/<Name>`, and its tests in `genui/__tests__/`. The
manifest check (`verify-figma-axes.mjs`
assertions 3 and 10) and `__tests__/dialog-layer.test.tsx` list `genui/` as
well as this folder, so a component here is covered exactly like one at the
top level. The product wiring that draws a catalog part (the renderer map,
`components/chat/chat-ui-part.tsx`) stays outside `components/modulus/`, since
it calls product hooks.

The pre-Modulus GenUI widgets in `components/chat/generative/` are not here.
They moved as each got a catalog entry and a Modulus version: metric grid,
metric card, detail card and info card on 2026-10-01, then table, comparison
table, the three charts, status card, timeline, progress tracker and scenario
on 2026-10-05 (CFO-9142). **All thirteen have moved.** The chat and the
dashboard's saved widgets draw them through the catalog as their Modulus
versions (`CATALOG_DRAWN_FENCES` in `lib/generative-ui/fence-ui-part.ts`);
emailed reports still render their own HTML. The legacy files stay in
`components/chat/generative/` because the Compare stories and the
`CATALOG_DRAWN_FENCES` fallback still draw them.

**They are interactive where it makes sense (CFO-9222):** a `table` sorts on
a header click (not a `comparison-table`, whose rows are the features being
compared), chart legends hide and show series, a pie highlights a slice and
puts its value in the centre, and a long timeline that mixes income and
expenses gets an All / Income / Expenses filter. Each interaction is local to
the widget and reported to PostHog as `generative_ui_interacted`; the catalog
entry's `userInteractions` tells Silvia it is there.

**The charts have no Figma component.** `genui/chart-parts.tsx` records where
their colours come from (`chart-1`..`chart-6`, and `chart/area-*` for a
filled line) and why no node id is cited.

**A moved widget gets a story in `Modulus/Components/GenUI/Compare`**
(`genui/compare.stories.tsx`), which feeds one fence through BOTH renderers -
the legacy registry and the catalog - with a `show` control to flip between
them. Asked for on 2026-10-01: "can we also have on storybook previous and
new one so we can compare them by one click how different they will be?"
That story imports product code (the two renderers) on purpose: comparing the
real paths is its subject. It is not the only story under `Modulus/` that
renders product code: the three entry screens mount `EntryAnchor` from
`components/chat/entry-anchor.tsx`, and every story filed into the tree from
outside this folder (see **Story titles**) renders a product component.

**A SCREEN IS A ROUTE. A MODAL IS SOMETHING A ROUTE OPENS.** That is the test
between Screens and Overlay, and Settings failed it: it shipped under
`Modulus/Screens` because it is an assembly, and an assembly is not the
criterion. `Modal / Panel`'s own
description calls it "the centred-overlay modal shell for complex content that
needs persistent navigation — settings, multi-step forms, asset managers", so
Settings is a composition OF that shell and belongs beside Mode Menu and
Modal / Picker. Moved 15 Sep 2026.

**The same test files the product's own dialogs.** Restated on 2026-10-06 on
instruction, paths set in code here: "Product dialogs and Settings are modals,
not screens. File them under `Modulus/Components/Overlay/<Name>` (Settings, Add
assets, the portfolio dialogs). Screens is only for full pages (routes): Chat,
Research, Radar, Portfolio, Sign Up. The rules doc already says this ("a modal
is something a route opens"), so keep it." So ConnectionSuccessDialog and
WebviewConnectEscapeDialog, which were under `Modulus/Screens`, and the
business dialogs, which were under `Modulus/Business/Dialogs`, are in Overlay
beside Settings. A product dialog that gets a story later, Add assets among
them, is filed the same way.

**Four titles were misfiled on staging before the tree set on 2026-10-06**, found in the 15 Sep
pass and left then because each was a rename in a file that branch did not
otherwise touch. `Modulus/Action/Selectable Chip` and
`Modulus/Overlay/ModalShowcase` were missing the `Components` segment;
`Modulus/Surface/ModalPicker` was missing it and filed a modal as a surface;
`Modulus/Components/Surface/Drawer` filed a portalled, scrimmed, focus-trapped
sheet as a surface. All four are in the tree now, at Action/SelectableChip,
Overlay/ModalShowcase, Overlay/ModalPicker and Overlay/Drawer.

`Modulus/Components/Surface/InfoPanel` was checked in the same pass and stays:
its own description says it is "an inline surface at container/measure (768)
embedded in a page column, not an overlay", so Surface is right for it.

**`Screens` is a group, not a category, and it holds routes only:**
`Modulus/Screens/<Area>/<Page>`, the areas today being the five the
instruction above names (Chat, Research, Radar, Portfolio, Sign Up). It was added on 3 Sep 2026
as a category, on instruction: "Add Screens as an eighth category, and
give it the reason the table's own test demands: a screen composes components
rather than being one, so it has no component to file under any of the seven,
and product copy has nowhere else to live." It became one of the three groups
on 2026-10-06, per the brief quoted under **Story titles**.

Both halves of that are checkable in this file rather than taken on trust. A
screen COMPOSES components, so it has no component to file under any component
category: filing Chat under Navigation or Surface would be filing the
assembly under one of its parts. And product copy has nowhere else to live,
because hard rule 7 sends it to Screens and forbids it everywhere else, so
before Screens existed that rule pointed at a category hard rule 5 did not
sanction. (Exception 6 below has since widened that to any composition of
several components where the assembly is the subject, which is how Settings
keeps its copy in Overlay.)

Figma reached the same conclusion independently, for the same reason. The
conventions note on the Components page, node `2600:1488`, records that the
Page frame on each screen is "scaffolding, not a component. Its children
differ entirely per screen, so every instance would override everything -
componentising it gains nothing." Composed on both sides.

**A screen story in this folder is a story file with NO component beside
it.** No `chat.tsx`, only `chat.stories.tsx`. That is the point rather than an
omission: the product assembles screens from routes and real data, so anything
this folder exported would be a second, diverging assembly that a route might
import by mistake. The story demonstrates the arrangement; it does not ship
it.

**The three screens filed from `app/` are the other kind, and the reason above
does not reach them.** Portfolio/Business, Portfolio/Businesses and Sign
Up/Webview each render the route's own component (`BusinessDetailScreen`,
`BusinessesDetailScreen`, and the sign-up route's `page.tsx` as `SignUpPage`)
from a story file beside it. What the rule bars is a second assembly, and
these render the first one, the one the route ships. So the rule above holds
for `components/modulus/`, which is why it says "in this folder", and not for
`Modulus/Screens` as a whole.

**A SHELL THAT TAKES EVERY SCREEN-SPECIFIC THING AS `children` IS NOT WHAT
THAT RULE IS ABOUT**, and the distinction is recorded here so the next session
does not re-argue it or, worse, work around it.

Read the reason above rather than the sentence: what is barred is a second
ASSEMBLY - real data, real copy, a whole Chat a route could import by mistake
and then diverge from. `components/modulus/screen-shell.tsx` is not that. It
holds the app CHROME every screen mounts inside - the nav, the top bar, the
history panel, the footer, the responsive bands and the theme scope - and no
SCREEN's content: no greeting, no composer, no suggestions, no thread. Those
arrive as `children`. Nothing in it answers "what is this screen", which is
the question a second copy must not be able to answer differently.

**It does hold the chrome's own copy, and that is not the same thing.** The
nav labels, the history sections and their items, the row menu's Rename and
Delete, the footer's Upgrade and account row are real strings in that file.
They are the copy of the components it DRAWS, they are identical on every
screen, and a screen cannot change them without changing the chrome. Stated
explicitly because an earlier draft of this paragraph said "no screen content
of any kind", which reads as "no content" and was false.

So the test is not "is there a `.tsx` beside the story", nor "does the file
contain a string". It is **"could a route import this and get a screen?"** and
**"does this file DRAW the thing whose copy it holds?"** Chrome copy beside
the chrome passes both. A screen's greeting, suggestion list or thread fails
the first. Copy for a component the file does not render fails the second -
which is why the composer's mode set lives in `composer-modes.ts` and not in
the shell: the shell draws no composer, so holding its labels, summaries and
bullets would have made this very argument untrue. That one was caught in
review rather than shipped.

It is still recorded in `component-manifest.mjs` rather than exempted quietly,
with `figmaNode: null` and the reason: a shell is an ARRANGEMENT two screen
frames draw, not a component set, and the cells it composes - Sidebar, Topbar,
MobileAppBar, HistoryPanel, Pane, AccountRow - are each recorded there in
their own right.

**Tag is in `Feedback`, and that placement is worth ratifying.** The argument
for it: Feedback holds anything whose only job is to report state, and both
Tag tones do exactly that — the purple context marker on a Composer and the
neutral `New` badge in the sidebar report a state rather than offering an
action. Its remove button no more makes it an Action than a Card's footer
Buttons make Card one; the control sits on the thing, not instead of it. The
argument against: `Feedback` reads as system-initiated messaging, and Tag,
Avatar, Badge and a future Sparkline may be better served by a `Display`
category. That would be a ninth component category, so it needs the reason the
rule below demands. If it arrives, Tag moves with it, and Avatar, filed beside
Tag.

**The taxonomy is functional, not by complexity.** A category answers "what
does this component *do* for the user", not "how big is it" or "how many
variants does it have". Surface holds containers, Overlay holds anything that
floats above the page and traps or portals, Feedback holds anything whose only
job is to report state.

Every component category is occupied now. The table's Queued column lists
members not built yet: Tooltip in Overlay, StatusRing and an Alert once
designed in Feedback. `Screens` is occupied too, by seven titles across its
five areas.

**A new category needs a reason, not a component that does not fit.** "Card is
not an action" is not an argument for a Surface category; "containers are a
distinct functional role, and there are two of them queued" is. If a component
genuinely fits none of the eight component categories, that is a signal to
check whether the component is doing two jobs before it is a signal to add a
ninth.

## Story content

**A story shows what the component IS, not what a screen would put in it.**
Placeholder content names the slot: `Button`, `Title`, `Description`, `Label`.
Product copy belongs in Screens and the other compositions exception 6 covers,
where real data is being assembled, and nowhere else.

Two reasons, and the second is the one that makes this more than tidiness.
Someone reading Chip's docs is identifying which variant does what, and
"Spot trends and blind spots" makes them read the sentence instead of the
component. And a story full of finished-looking copy **looks approved**. Copy
that ships has been reviewed; copy in a story has not, and once the two are on
a page together they are indistinguishable. A story is not a source for product
text, and it should not be able to pass as one.

### The six exceptions, and only these

1. **A label the variant cannot express without it.** `Delete` on a destructive
   button. The tone is illegible without a destructive verb, so the word is
   carrying variant information rather than pretending to be copy.
2. **A size or state story naming its own rung.** `Small`, `Medium`, `Large`;
   `Default`, `Hover`, `Focus`, `Disabled`. The label IS the axis value.
3. **A story whose subject is text behaviour** - wrapping, truncation, long
   labels. Use realistic LENGTH, and say in the story description that this is
   why. Note that length is what the exception buys, not content: a generic
   string of the right length is better than a product one, because it satisfies
   the exception without looking approved.
4. **A label paired with an icon**, which names the action, because the pairing
   is the subject. `Add` beside a plus. This also covers an icon-only control
   whose `aria-label` is its only name.
5. **A composition story of ONE COMPONENT'S OWN PARTS** — Card's header over
   content over footer, EmptyState inside a panel. Realistic content is allowed
   because the subject is how the parts sit together, and placeholder strings of
   equal length would not exercise the wrapping or the gaps. Say so in the
   description, and say that the copy is unreviewed. The component is still the
   subject, so the copy is a concession.
6. **A COMPOSITION OF SEVERAL COMPONENTS, WHERE THE ASSEMBLY IS THE SUBJECT** -
   a screen, or a modal built from a shell and eleven components. Here the rule
   inverts outright rather than being conceded.

   THE TWO ARE TOLD APART BY HOW MANY COMPONENTS ARE IN PLAY, not by how the
   copy reads: exception 5 is one component's parts and the copy is a
   concession; exception 6 is several components and the copy is the point. They
   overlapped until 2026-09-16, when both said "the subject is the arrangement"
   and an author could not tell which applied.

   Every other story names the slot because the component is
   the subject; this kind uses REAL content because assembling real data is the
   thing it demonstrates. One built from `Title` and `Description` shows nothing
   a reader could not get from the component stories it is made of. This is the
   one exception that is not a narrow carve-out.

   **IT WAS WRITTEN AS "a story in `Modulus/Screens`" AND THAT WAS THE WRONG
   AXIS.** It named a CATEGORY when what it meant was a KIND, and the two came
   apart the first time a composition turned out not to be a screen: Settings is
   an assembly of Modal / Panel and eleven components with real product copy,
   and it belongs under Overlay because a screen is a route and a modal is
   something a route opens. Under the old wording, moving it to its correct
   category would have made its copy a violation — so the category would have
   had to stay wrong to keep the copy legal. Reworded 15 Sep 2026.

   The test is now the one the reason always implied: is the SUBJECT of this
   story the arrangement rather than any one component in it? A screen passes. A
   composed modal passes. A component story does not, whatever it is filed
   under.

### Three calls worth not relitigating

**HistoryPanel keeps its section labels and replaces its item labels.** `Pinned`,
`Today`, `Yesterday`, `This week`, `Older` and `Recent` are the sectioning
MODEL, not data: they are what the sections story demonstrates, and replacing
them with `Section one` removes the thing being shown. The 12 item labels
underneath them are data, and became `Chat name one..six` and
`Artifact name one..six`, numbered because several stories need the rows to
differ. The one long item label stays under exception 3.

**Segmented keeps `Chat` and `Research`.** They are the real mode names and
mode is what the control switches, so they are naming the axis rather than
decorating it - the same argument as exception 2, one level up. What did change
is the four-segment story: `1M / 3M / 1Y / All` became `One / Two / Three /
Four`, because that story's subject is the segment COUNT being data-driven, and
a date range says nothing about a count. The group labels went from
`Composer mode` to `Mode` for the same reason the item labels went: the concept
is the axis, the product surface is not.

**Card's Composed story keeps realistic content**, under exception 5. The
subject is the arrangement - header over content over footer, the gap between
the two footer buttons, how the description wraps against the title - and
placeholder strings of equal length would not exercise any of it. EmptyState's
`InAPanel` keeps its copy for the same reason. Both descriptions say the copy is
illustrative and unreviewed, which is what stops exception 5 becoming a way to
smuggle product text back in.

## Control sizing

Height is a token, not a result of padding, and the label centres inside it.
MOST controls bind `control-height-lg` (44), several take `control-pad-x-lg`
(20), and the rest sit a rung lower. Counted across `components/modulus/*.tsx`
with stories excluded: 26 bindings on `lg`, 12 on `md`, 4 on `sm`.

This paragraph read "every control binds `control-height-md` (36) and
`control-pad-x-md` (16)" until 2026-09-14, and that was true of the day it was
written. The rebind of 2026-09-13 moved most controls up a rung to hold the
pixel sizes they were drawn at, and the five files reverted out of it -
`button.tsx`, `icon-button.tsx`, `segmented.tsx`, `composer.tsx` and its test -
are most of what still binds `md`. **There is no universal rung. Read the
component.**

Vertical padding is zero, and the zero **is** a token: `primitive.space.0`
resolves through the spacing, gap, width, height, size, minWidth and maxWidth
scales, so write `py-modulus-space-0`, `p-modulus-space-0`, `gap-modulus-space-0`
and `min-w-modulus-space-0` rather than the unprefixed utilities. There is no
zero member of `layout.border`, so `border-0` stays as it is.

`sm` (24) and `lg` (44) exist in the tokens and are implemented as a `size`
prop via `cva`. Only `md` is drawn in Figma, so `md` is the default and the
other two are built from the token values without a Figma reference. `xs` (18)
is a fourth rung that only IconButton exposes, and only on its tertiary arm.

Icon-only controls are square: bind both width and height to
`control-height-md`.

### The whole ladder moved down one rung

`8f17c31062` (#3820) rewrote both scales in `app/modulus-tokens.css`:

```
           xs      sm      md      lg
height   24→18   36→24   44→36   48→44
pad-x      -      16→12   20→16   24→20
```

**The token file lagged it by a day and has been brought level.** The CSS
shipped these numbers on 2026-09-09; `tokens/modulus-design-tokens.json` went
on reading `{primitive.space.11}` (44) for `md` and `{primitive.space.9}` (36)
for `sm` until 2026-09-10, when both scales were corrected in place - on
Robert's instruction, and on the statement that the handover source will be
corrected before it is handed over again, which is the only thing that keeps
the next handover from reverting this. Do NOT take it as licence to edit the
token file generally: **Source of truth** above still holds, and an in-repo
correction nobody is mirroring outside the repo is a correction with a
deadline.

`xs` is now a LITERAL 18 where its three siblings stay references. 18 is off
the 4 grid `primitive/space` is built on - 16 at `space/4`, 20 at `space/5`,
nothing between, and the two off-grid rungs it does carry, `half` 2 and
`2half` 10, do not reach here. Minting a `4half` would put a rung on the
spacing scale to serve one control height.

**Nothing had caught the divergence, and nothing would have.** No assertion
diffs the two files rung by rung: `tokens:verify` reads the JSON for each
token's `$type`, `$extensions` and group membership and checks the CSS against
THOSE - shape (20), modes (21), unitless (23), opacity transforms (22, 24),
Tailwind-key coverage (17), version header (30) - so it passed 33/33 through
the whole divergence. The one assertion that pins a control number, 3, was
edited inside #3820 itself to expect 36. Nothing generates the CSS either.
A value can therefore disagree between the two files indefinitely, and the
only reason this one surfaced was a component reaching for the smallest
primary icon button that fits a rename field: by the JSON, none; by the CSS,
`sm`.

**What renders is the CSS.** Tailwind emits `var(--modulus-control-height-sm)`
rather than the token's value - `modulusGroup` in `tailwind.config.ts` maps a
token path to a `var()`, never to a literal - so a class resolves through the
stylesheet and the JSON figure never reaches the browser. The JSON figure is
not inert, though: the Foundations pages print it. `controlRows()` in
`components/modulus/foundations/tokens.ts` resolves `$value` for the Control
sizing table while the swatches beside it bind the variable, so through the
divergence that page printed 44 next to a box drawn at 36.

Four derived figures elsewhere in the token file were recomputed with it, and
they are recorded here because each is arithmetic somebody will otherwise
re-derive from the wrong rung.

**AND THEN THIS PARAGRAPH WENT STALE THE SAME WAY, which is worth more than
the figures it carries.** Every number below was recomputed on 2026-09-13
against `control/height/md` 36, correctly, and the rebind later that evening
moved those same rows back to `control/height/lg` 44. The note written to stop
people deriving from the wrong rung was itself deriving from the wrong rung
within hours. Corrected on 2026-09-14 and stated with its bindings so the next
reader can check rather than trust:

* `layout/logo/height`: the chrome row binds `control-height-lg` 44
  (`topbar.tsx:151`, `sidebar.tsx:183`), so a 20 glyph centres at **46**.
* `control/composer`: chrome is **86** - 2 x 16 padding, a 16 gap, the
  `control/height/md` 36 action row and 2 hairlines. `composer.tsx` was
  reverted out of the rebind, so `md` is right here and the 84 above never
  closed.
* `max-lines`: `mobile-app-bar.tsx:110` binds `control-height-lg`, so the phone
  bar is 16 + 44 + 16 = **76** and the usable height at an 812 viewport is
  **736**. Six lines is 232, **31.5** per cent of it; seven is 256, **34.8**.
* The conclusion still holds and never moved: six lines stays under the third
  that was the constraint, seven breaks it.

The measurement records further down - IconButton at 44x44, TextField's 44
field - were taken before the ladder moved and are left as recorded. **Only one
of them reads differently today.** IconButton was reverted out of the rebind and
defaults to `md`, so it renders 36. TextField binds `control-height-lg` and
renders the 44 it always did.

## States

* Hover, focus and disabled are drawn in Figma. Match them exactly.
* Pressed is not drawn, being transient. Implement on `:active` using
  `cta-bg-active` for primary neutral, `surface-pressed` for secondary and
  tertiary neutral, `destructive-bg-active` for primary destructive,
  `destructive-ghost-pressed` for tertiary destructive, and the flat
  `cta-intent` gradient for high-intent.
* Disabled applies `disabled-opacity` once at the component root. Never on a
  child, since it compounds.
* Focus uses `border-focus` at `focus-width` (2) with `focus-offset` (2)
  outside the control, so the control does not shift on focus.
* Transitions use `duration-fast` (150ms) for controls and `duration-base`
  (200ms) for overlays, with `easing-standard`.

## One menu row colour convention

**Rest is `text-secondary`. Hover, Focus and Selected are `text-primary`.**
Recorded on the canvas on 2026-09-10, on all three of the components it binds:
OptionRow 385:565, HistoryRow 2046:875 and NavItem 173:145, each carrying it in
its own description. Applied in code on 2026-09-11.

* **The leading glyph takes the same value as the label**, rather than carrying
  one of its own, so the row lifts as one thing.
* **Nested rows sit one step quieter at rest, on `text-muted`**, and lift to
  `text-primary` like the rest. The quieter value is a REST value only, so a
  nested selected row is not a step behind a top-level one.
* **A second line stays on `text-secondary` in every state.** Only the primary
  label moves.

**WHY, so it is not re-argued.** Three components disagreed: Text Tab moved
colour on hover, NavItem moved it only on selection, and OptionRow and
HistoryRow held `text-primary` in every state and leaned on the fill alone. A
menu row has no selected state most of the time, so a colour that only moves on
selection never moves at all and the fill does all the work. Text Tab's reading
won.

It also closed a defect: NavItem's drawn Hover was identical to its Default, so
that state existed in the matrix and not on the screen.

### Put the colour where BOTH the label and the glyph inherit it

That is what "as one thing" means in practice, and it decides placement rather
than being a style note. The glyph is a SIBLING of the label in all three, never
a child, so a colour on the label can never reach it. OptionList's label carried
`text-primary` on its own span before this and the glyph could not follow it.

### The three reach Focus differently, and the difference is forced

| | how rest and hover arrive | how FOCUS arrives |
| - | - | - |
| `option-list.tsx` | on the row | `focus-visible:` on the row - it IS the focusable element |
| `history-row.tsx` | on the root, inherited | `peer` on the overlay + `peer-focus-visible:` on the glyph and label |
| `nav-item.tsx` | on the row | `focus-visible:` on the row |

HistoryRow is the odd one because its focus target is an absolute overlay
rather than its root, and a parent cannot react to a descendant's
`:focus-visible` without `:has()`. **`focus-within` is the wrong answer there**
and it is the trap Axis 3 rule 1 already records: the overlay is a `<button>`,
and a button does not match `:focus-visible` on a mouse click but does satisfy
`:focus-within`, so the label would lift on every click. `peer` is what
`segmented.tsx` already uses for this shape, and it works only because the
overlay is the FIRST child - `peer-*` compiles to the `~` combinator, which
reaches later siblings only. A reorder would silently stop the lift, which is
why `__tests__/menu-row-colour.test.tsx` asserts the child index.

### NavItem's Focus value is code-only, and the canvas cannot tell you

NavItem 173:145 has SIX variants - State `Default | Hover | Selected` against
Nested `Off | On` - and **no Focus variant at all**, unlike OptionRow and
HistoryRow which both draw one. So its focus colour follows from the convention
rather than from a cell. Recorded because the next reader diffing that file
against the set will find a value with no variant behind it, and has no other
way to learn that the absence is the drawing's rather than a mistake in the
code.

### What the convention does NOT yet reach

**The two-line clause has no consumer.** It is drawn on OptionRow's
`Lines=Two` cells, and `ModulusOptionList` is one-line in code - its items carry
a `label` and no description. The product's two-line rows are Modal / Picker
2868:1739's five **Document Row** instances, and `ModulusDocumentRow`
(2866:2787) is a different component whose canvas carries **no description at
all** and draws its name `text-primary` in all six of its variants. So both the
drawing and the code have it the old way there, consistently, and changing one
of them alone would create the disagreement this convention exists to remove.
Raised rather than resolved: extending the ruling to DocumentRow is a design
decision, not a code one.

## Colour and mode

Light and dark both come from the same variable names, resolved by selector.
Never branch on mode in component code, and never write a `dark:` variant for
a value that already has a dark token.

Text roles carry their contrast ratios in the token descriptions. Do not
substitute a lighter or darker neighbour for visual preference: several were
tuned specifically to clear 4.5.

## Icons

**Never import `lucide-react` inside `components/modulus/`. Import the glyph
from `components/modulus/icons.ts`.** That file is the icon map: one entry per
`icon/*` component in the Modulus library, keyed by the FIGMA component name,
carrying the node id and the component that draws it. `MODULUS_ICONS` holds the
90; `MODULUS_ICON_NODES` holds their node ids, checked against the same key set
by a `satisfies` so a name without an id is a compile error.

**It is enforced, not just written down here.** `.eslintrc.json` carries an
override banning `lucide-react` across `components/modulus/**` with `icons.ts`
as the only excluded file. It is registered under
`@typescript-eslint/no-restricted-imports` rather than the base
`no-restricted-imports` deliberately: rule options in an ESLint override
REPLACE rather than merge, so the base rule name would have silently lifted the
repo's Clerk import ban (the CFO-7094 guard) for this whole folder. Two rule
names, no interaction — verified by checking that the Clerk ban still fires
here.

**Why the map exists, since the identity mapping makes it look redundant.** The
Figma component names ARE lucide's names for 88 of the 90, so most of the map is
the identity function. What it buys is that a wrong glyph becomes a wrong line
in one file instead of a guess buried in a component. Before it existed, three
glyphs were wrong and nothing could catch any of them: the history control drew
`panel-left-open` where the canvas says `rotate-ccw-clock`, the Composer's mode
chip drew `Sparkles` where the canvas says `message-square`, and
`sidebar.stories.tsx` and `chat.stories.tsx` disagreed with each other about two
sidebar rows — `Briefcase`/`BriefcaseBusiness` for Portfolio and
`FileText`/`BookText` for Vault. Two files in one folder, different glyphs, no
failure.

**Three of the 90 have no identity export, and each is explicit.** `astroid`
(205:2670) and `phi` (341:5249) have no lucide equivalent at all and are
committed from the canvas as components — they are the only two icons with
manifest entries, because a lucide re-export is not a component of ours.
`rotate-ccw-clock` is lucide's newer name for the glyph the installed 0.469.0
exports as `History`; that equivalence was established by comparing the Figma
node's export against lucide's own node array subpath by subpath, not from
memory, and it is the one line to change if lucide-react is upgraded past the
rename.

**A glyph the Figma set does not have goes in `MODULUS_ICONS_NOT_IN_SET`,**
which is a gap list to close on the design side rather than a second sanctioned
set. Keep it short and keep knowing what is on it. It has been closed once
already, which is the pattern to repeat: `Download` sat on it because
`row-actions.tsx` ships and the set had no download glyph, `icon/download`
2850:1558 was then drawn to the set's convention, and it is now an ordinary map
entry. As of 2026-09-10 the list holds seven, all story-only illustration.

Colour the inner vector, never the wrapper, so dark mode flips correctly. Sizes
are 16, 20 and 24, and the container stays at `control-height-md`.

There is one icon recipe, `iconVariants` in `components/modulus/icon-variants.ts`,
shared by Button, IconButton and Chip. It exposes an `iconSize` axis over the
three `layout/icon` members and defaults to `sm` (16), which is what Figma binds
on all 48 Button cells and what the token file's own description of `icon.sm`
records ("Default in buttons"). The icon never varies with the control's `size`:
the label is `type-label` at a constant 14 across sm, md and lg, so there is
nothing to track. Any new component that renders a Lucide glyph uses this recipe
rather than restating a `size-modulus-icon-*` class.

## Typography

Cormorant Garamond for display, Inter for body and UI.

**THAT SPLIT ASSUMES ROOM, AND THE CONDITION IS ON THE ROLES RATHER THAN HERE.**
`semantic/type`'s own group description carries it, because that is what somebody
building a document reads: a Cormorant level must sit at least **1.25** above the
Inter level directly beneath it **when size and face are the only levers in play**,
and never below **16**. If the medium cannot give a title that room, the title is
not Cormorant there, it is Inter, and everything below it compresses freely.

**That condition is load-bearing and this file stated the rule without it until
2026-09-16.** **The two constraints are independent and only one of them is about
the pair.** The 1.25 RATIO governs a Cormorant-over-Inter pair and is what the
condition qualifies. The 16 FLOOR is a rendering limit on Cormorant alone and
holds whatever sits beneath it, or if nothing does: below 16 its solid coverage
falls under two thirds and the hairlines wash to grey.

**The scope was established by a CONTROL, not by an example of the governed
pair.** Inter 400 at 16 over Inter 400 at 14 was rendered with every other lever
removed - one colour, one weight, even spacing - and it reads correctly at a
ratio of 1.14, far below the floor. That is an Inter-over-Inter step, which the
1.25 does NOT govern, and it is the measurement that proved the scope: the ratio
is a face-pair rule rather than a general one.

Why that follows is structural. Within one face, cap height and ink both scale
with size, so the upper level leads on both measures and the hierarchy cannot
invert. Across the two faces they come apart, because Inter carries two to three
times Cormorant's ink at equal size, so a larger Cormorant can still trail. Do
not apply 1.25 to a step within a face, and do not reject a layout for lacking
colour or spacing separation.

This was a real defect, not a precaution. A research PDF built from these roles at
18/14/12 inverted its own hierarchy: Inter 600 at 12 outranked Cormorant 400 at 14
directly above it, because Inter's caps are 16.4 per cent taller at equal size and
a 1.167 step does not cover that. The same group description carries the four-level
ranking recipe and, for each level, which existing spacing rung sits above it. The
gaps live in `layout/` and not in `type/` because the same role takes different
space in different containers.

Both faces are loaded by the
`@import` at the top of `components/modulus/modulus-storybook.css`, since
`next/font` is stubbed in Storybook and only Figtree, Playfair, Fraunces and
Jost are injected. Without that import, type silently falls back to Georgia and
system-ui.

They are deliberately **not** in `.storybook/preview-head.html`. That file is
global, so a `<link>` there loaded both families into all 807 pre-existing story
entries as well; keeping them in a Modulus-owned stylesheet means they reach only
the stories that need them.

Line heights are absolute pixels in the tokens. Use them as given rather than
converting to multipliers, which reintroduces fractional values.

Add font keys through `theme.extend.fontFamily`. `theme.fontFamily` replaces
the Tailwind defaults and overwriting it breaks `font-sans` repo-wide.

## Gradients

Figma cannot bind gradient stops to variables, so the paint styles there are
a mirror rather than a source. In code, build gradients from the CSS
variables directly, so `cta-intent-from` and `cta-intent-to` drive them.

## Measuring in Storybook

A new component that introduces a Tailwind key no existing component already
uses requires a Storybook **restart** before any measurement is trustworthy.
Tailwind generates classes only for the files present when the dev server
started, so a class added afterwards is on the element but has no rule behind
it. A reload is not enough.

This fails silently and looks like a component bug. IconButton first measured
16x16 instead of 44x44 with a transparent focus ring, because
`size-modulus-control-height-*` and `outline-modulus-cta-intent-text` were new to
it: `bg-modulus-cta-bg` worked, since Button had already caused it to be
generated. The container had simply shrunk to its icon. Restarting fixed it
with no code change.

Symptom to look for: the class is in `className`, but the computed value is the
unstyled default and no matching rule exists in any stylesheet.

**Check `window.innerWidth` before trusting any measurement.** A browser tab can
report a 0x0 viewport, and a zero viewport does not fail loudly. It produces
*plausible wrong numbers rather than none*. Every `w-full` element resolves
against a zero-sized containing block and measures \~0, while every fixed-width
element still measures correctly. A dump then reads as a component whose
fill-width parts have collapsed and whose fixed parts are fine, which is
indistinguishable from a real layout bug. That is how one was diagnosed,
committed and then retracted here. Segmented's track was reported 8 wide with
24 tabs, and TextField's Default story was reported at ZERO width. Re-measured
at 1280x800 the track is 280x44 with 136/136 tabs, and TextField's Default is
1216 with a 44 field. Nothing had collapsed and nothing needed fixing.

So in the story frame, before reading any geometry:

```js theme={null}
window.innerWidth // must be the viewport you think you set
```

The corollary is what makes this cheap to recover from: **a zero viewport only
corrupts fill-width numbers.** Anything measured on a fixed box (a 24, 44 or
280 control), and anything read from Figma metadata, is viewport-independent and
survives. Only the `w-full` measurements have to be retaken. Sort a suspect pass
that way rather than discarding it whole.

Prefer `/iframe.html?id=<story-id>&viewMode=story` over the manager UI when
measuring. The story is then the top-level document, so `innerWidth` is the
viewport itself rather than whatever the sidebar and addons panel leave over, and
the number is reproducible across sessions.

### A STALLED FRAME cannot be measured, and the pane does not tell you it has one

**Count frames before you file anything against a component.** That is the
whole of this section's advice and it is the step neither of two sessions took
on 2026-09-16, which cost a false bug report each.

**Measured 2026-09-10, corrected 2026-09-16.** This used to read "a hidden
document runs no frame" and keyed off `document.hidden`. That heading is wrong:
a hidden document may well run a frame, and the two are measured separately
below. In an agent browser pane the story document's "update the rendering"
steps can stop running, and then these consequences follow, each of which looks
like a component bug:

* `document.timeline` does not advance, so **no CSS transition or animation
  progresses** and `getAnimations()` shows animations stuck at their start. A
  WAAPI `onfinish` never fires; a `setTimeout` fallback still does, which is
  how `use-mode-roll.ts`'s 3x guard was seen working by accident.
* **`requestAnimationFrame` never fires.**
* **`ResizeObserver` never delivers**, because its delivery hangs off those
  same steps.

```js theme={null}
document.hidden          // NOT a reliable discriminator - see below
```

**`document.hidden` is not the test, and trusting it as one costs you a false
bug report.** Measured 2026-09-16: two tabs in the same pane, both reporting
`document.hidden === true` and `visibilityState === 'hidden'`, one running 61
`requestAnimationFrame` ticks in 500ms and the other running **0**. The flag
says the pane is not on screen; it does not say whether the frame is being
driven, and a tab opened by `preview_start` was driven while an older tab in the
same pane was not. So `document.hidden` over-reports the problem — it is true in
the good case too.

Count frames instead. It is three lines and it answers the actual question:

```js theme={null}
let n = 0; requestAnimationFrame(function t() { n++; requestAnimationFrame(t); });
await new Promise(r => setTimeout(r, 500));
n // ~30-60 means the frame is live; 0 means every note above applies
```

**A stalled frame also suppresses the `resize` EVENT**, which the list above
does not say and which is the one that bites hardest, because it takes a
component's breakpoint arm with it. `resize_window` changes the emulated
metrics and re-lays-out the page either way — so `window.innerWidth` updates,
the layout visibly changes, and every JS-derived value silently keeps its
mounted state. Measured in a stalled tab: an `h1` went 47px to 672px wide
across a 375→1000 resize while an armed `resize` listener and an armed
`ResizeObserver` both counted 0. In a live tab the same call fires `resize`
every time, and `EntryAnchor` re-anchors across desktop, floor and mobile with
no remount.

Two ways that reads as a component bug rather than as the pane. `EntryAnchor`
held the mobile 32 padding at three desktop heights, which looks like an anchor
that never recomputes. A component whose breakpoint arm is derived in JS
renders **the wrong arm, confidently** — `useModulusShellBand` reads
`window.innerWidth` in a `useState` initialiser and on a `resize` listener, so
with neither firing the composer drew 36px icon buttons and no collapsed arm at
a 375 viewport (measured on `modulus-composer-canvas-parity`).

So: mount fresh at each size rather than resizing and re-reading, unless you
have counted frames first. `resize_window` then `navigate` is always safe,
because the remount reads the new size directly.

The recovery is the same shape as the zero-viewport one above: **static
geometry survives, anything time- or frame-driven does not.** Box sizes,
computed styles, class lists and attributes are all still correct. So measure
placement, sizes and state from attributes and classes, and drive anything
frame-based by hand - dispatching a `resize` event exercises the same code an
observer would call.

Prove the environment before concluding the code is wrong: attach a CONTROL
`ResizeObserver` of your own to the same element. If that does not fire either,
the pane is the answer. That is how the `ResizeObserver` trigger in
`use-menu-placement.ts` was cleared rather than "fixed".

**The control tells you the pane is the answer. It does not tell you WHY, and
that gap is where a second mistake lives.** Both sessions on 2026-09-16 ran a
correct control, correctly concluded "not the component" — and then each named a
mechanism it had not tested, blaming the `resize_window` tool. It dispatches
`resize` and delivers ResizeObserver callbacks perfectly well on a live tab.
A control that comes back 0/0 has ruled the component out and nothing else;
either count frames to learn the real cause, or write down that you do not know
it. See **Recorded reasons** — a mechanism asserted from one observation reads
as checked and is the expensive kind of wrong.

### Two causes at the same pixel: why the right fix reads as no fix

**A defect with two independent causes landing on the same pixel looks like a
defect that neither change touches.** Fix one, the other goes on producing the
identical screenshot, and the change you just made — the correct one — looks
disproven. The rational next move is to revert it, and that is the trap.

**MEASURED 2026-09-22.** The session composer's card was losing its bottom
border, its rounded corners and part of its action row whenever the page had
been scrolled. `ModulusScreenStoryRoot`'s inner box is `h-svh` anchored at the
DOCUMENT's top; the shell inside it takes `top: pageTop` to sit on the visible
region, so any displacement Safari pays out of the layout scroll puts the
shell's foot past that box — `deco -372/342` against `shell 0/377`, the last 35
of the shell, which is exactly where the composer's bottom lives.

Two things ate that 35:

1. **`overflow-hidden` CLIPPED it.**
2. **The parent's `after:top-full` seam strip PAINTED OVER it** — a positioned
   pseudo-element generated after that box, filling `surface-page`, which is
   precisely the colour the cut bled to.

The 2×2, each cell a device screenshot measuring the card's painted bottom
against its own rect:

| | no z-index | `relative z-10` |
| - | - | - |
| `overflow-hidden` | cut at 342 | cut at 342 |
| removed | cut at 342 | **card complete** |

Three of four cells are identical, and the two single-change cells are the ones
that mislead: each removes a real cause and changes nothing visible.

**THIS ALREADY COST A REVERT IN THIS REPO.** The clip removal was tried, showed
no change, and was reverted as an unjustified edit to shared story chrome —
which was the right call on the evidence available and still wrong about the
code. The reasoning that recovered it was not "try it again"; it was noticing
that the first A/B **could not have shown anything**: it forced the shell to
`top: 100`, which put the decorator's bottom within 5pt of Safari's own chrome
edge, so both arms hid the composer for a third reason.

**How to apply.**

* **When a fix you believe in produces no change, suspect a second cause at the
  same coordinate before you suspect the fix.** Ask what else terminates at that
  exact pixel. Here both answers were on the same two elements.
* **Check your control can discriminate.** A control that produces the same
  output for both arms has told you nothing, and it is easy to build one by
  accident — a forced offset that happens to collide with browser chrome, a
  colour probe on an animated surface. State, before running it, what the
  failing arm should look like and confirm it looks different.
* **Separate the causes physically, not just logically.** The measurement that
  settled this shrank the decorator to `400px` so its bottom sat far from any
  other boundary; the composer then vanished outright and the mechanism was no
  longer arguable.
* **Vary one thing, but enumerate the cells.** With two suspects that is four
  runs, not two, and the diagonal is the only cell that answers the question.

Same family as \[\[probe-with-a-loud-control-before-fixing]] and the
**A missing signal read as a clean result** entries under **Recorded reasons**:
there the signal never arrived; here it arrived unchanged, which reads as a
verdict instead of as an absence.

### On iOS, a blur moves the control out from under the click

**A tap that dismisses the software keyboard also moves everything above it,
and Safari dispatches the `click` at the point the finger went down.** So the
click lands on whatever has taken that place. The control's handler never
runs, nothing errors, nothing warns, and it reads exactly like a handler that
was deleted.

The chain, all of it WebKit's and none of it ours:

1. WebKit does not focus a `<button>` on tap, so tapping a control beside a
   focused field BLURS the field.
2. The keyboard dismisses. `visualViewport` resizes, and anything sized to it
   * the screen shell is - grows back.
3. The control travels down the screen BETWEEN `pointerdown` and `click`.

**Measured on an iPhone 17 Pro, 2026-09-22**, with document-level capture
listeners printing each event's target into the page, because the device has
no console:

```
pd BUTTON:Chat | cl DIV:Reply to S
```

The pointerdown reached the composer's mode chip and the click landed on the
Card. The mode menu did not open and nothing reported a failure.

**THIS IS NOT ABOUT THE CHIP.** Every control that sits beside a focused field
is exposed: the `+`, the microphone, and send, where a lost click is a message
that was never sent. It is one mechanism, so it is fixed in one place -
`composer.tsx`'s `handleSurfaceMouseDown` cancels the mousedown for a
`button`, `a` or `[role="button"]` inside the surface, which is what stops the
blur. A field, an `input` and a `select` keep their own default, because that
default is the caret.

**Where the design WANTS the keyboard down** - the composer's two menus both
do, so the panel can open upward into the room the keyboard was using - blur
deliberately from the click handler, after the open state is set. Then the
sequence is yours rather than WebKit's, and the reflow happens once the click
has already been delivered.

**How to recognise it, since reading the handler cannot.** A control that
"does nothing" on a phone but works with the keyboard down is this, every
time. Log the pointerdown target and the click target before reading any code:
the two disagreeing IS the diagnosis, and no amount of reading the component
produces it.

### Safari reveals a focused field once, and not if it is hidden at that moment

**Found from a real iPhone on 2026-09-23, on the product route.** Tapping the
one-line bar raised the keyboard and left the growing composer behind it. The
growth focuses the field inside the tap - the keyboard needs that - while the
field column is at `0fr` behind `overflow: hidden` and the field is at opacity
0\. iOS WebKit scrolls a newly focused field into view exactly ONCE, and skips
it if the field is transparent (layer opacities multiplying under 0.01) or
fully clipped (border box empty after ancestor clips) at that moment. It does
not retry when the field later becomes visible. Read in WebKit main
a16dca68c7 - `_zoomToRevealFocusedElement` in WKContentViewInteraction.mm and
`isTransparentOrFullyClipped` in WebPageCocoa.mm - and re-checked by a second
reader.

**It could not show in Storybook**, because ModulusScreenShell sizes itself to
the visible viewport and never needs Safari's reveal. The product route does
not use that shell. Established as a 2x2 on a simulator: product-like layout
with the growth on, hidden; with it off, revealed; the story's shell, fine both
ways.

**And scrolling it into view yourself does not hold either.** The first fix
scrolled the card into view when the keyboard arrived; on a real iPhone it came
up "more visible... still not fully above the keyboard". The page kept moving
under the scroll - the disclaimer unmounts on the same occlusion, Safari's
toolbar changes `innerHeight` and the route re-measures - and an on-screen
readout showed the scroll room running out with the card 98 below the visible
bottom. What holds is LAYOUT: /chat's scroller now follows the visible viewport
while the keyboard is up (`keyboardScrollerHeight` in page.tsx), as
ModulusScreenShell has since 5874636b91. The composer's scroll stays as a late
fallback for a host that does not.

**How to apply.** Any animation that starts with a focused field clipped or
transparent will defeat Safari's reveal, so a host of the growing composer
must place it above the keyboard by layout. Test keyboard behaviour on the
product's own code - `Pages/Chat` in Storybook renders the real route - and
not only on a story shell that already solves the problem: that is exactly the
harness that cannot find it. (`Pages/Chat` throws a duplicate-PlaidLinkButton
error today because its story wraps a second PlaidLinkProvider inside the
global one; drop the story's wrapper locally to use it.)

### A constant that names the row is wrong on every arm with a different row

**Twice on 2026-09-23, in two places, the same defect.** A figure subtracted
from or added around the action row was written as `control/height/lg` 44,
because every arm had a 44 row when it was written. The session phone arm's
row is 32, so on that arm alone the figure was 12 wrong - and nothing failed,
because each one is a `min-height` or a `calc()` that renders SOMETHING.

1. **The bar.** The one-line bar kept the open arm's 44 floor
   (`ROW_CONTROL_RUNG`) around its 32 controls and measured 62 against a drawn
   48\. (The 50 it measured next was a different cause - the border sitting
   outside the padding - and is not this defect.)
2. **`CHROME_TERMS`.** The text area's floor is the rung less the chrome, and
   the chrome's row term was a literal 44. On the 32 arm the floor came out 12
   short, a single line's own height took over, and the card landed at 106
   under a 108 rung - which the rung then took back in ONE frame at the end of
   every growth. It showed only as a 2px snap in a frame-by-frame WebKit trace,
   `106 -> 108`.

**The repair is to name the arm's row, not to add a second literal.**
`composer.tsx` derives `rowFloor` once (the 32 on the session phone arm, the 44
everywhere else) and substitutes it into the chrome, the groups' floor and the
growth. `CHROME_TERMS`' own header already recorded the same defect when the row
moved from 36 to 44 - "leaving this at 36 would have put every composer 8 past
its rung" - so the rule existed and a third arm still slipped past it.

**How to apply.** When an arm changes a control's rung, grep the file for the
OLD rung's variable, not for the figure: `control-height-lg` appeared in the
chrome, the floor and the rung derivation, and all three had to follow the arm.
Then measure the card against the drawing at REST and at the END of a
transition - a floor that disagrees with its content is invisible at rest and
only snaps when the thing holding it lets go.

### A scroll event is not the reader, so do not decide from one

**Measured 2026-09-23, on the simulator and by review in WebKit.** The Session
story pins its thread to the end: `scrollTop = scrollHeight`, and a handler that
releases the pin when the reader scrolls away. Every version that decided
"away" from a `scroll` event released it for a scroll nobody made:

1. **Its own write.** `scroll` is delivered after the write, and on the phone a
   turn above had reflowed taller in between, so the handler read the thread 24
   short and released it. Headless WebKit delivered the event first and never
   showed it.
2. **A clamp.** Tapping the bar hid the disclaimer, the thread's box grew
   558 -> 596, and the browser clamped `scrollTop` 746 -> 708. Separately, the
   field's autosize sets `height: auto` for a moment, the band shrinks, and the
   thread clamps to that transient end - 326 -> 302 while it was 666 tall. Either
   way the event reads as a reader scrolling up.
3. **The patch for the first swallowed the reader.** "Ignore a scroll at the
   position the pin wrote" also ignored the reader's scroll BACK to the end,
   which lands on the same value, so after one round trip the pin never re-armed
   and the next tap left the thread 60 short. Found by the adversarial review,
   not by the author.

**The repair: release on INPUT that scrolls up - a wheel up, a finger dragging
down, the up keys, a scrollbar dragged away from the end - and re-arm on ANY
scroll that lands at the end.** Clamps, reflows and the pin's own writes then
move `scrollTop` freely without being mistaken for a choice. And observe the
scroller's OWN box as well as its content: the clamp in 2 changed no turn, so an
observer on the turns fired zero times through the whole open and close.

Established with an on-screen readout of `clientHeight`, `scrollTop`, the band
and each observer callback - the device has no console - and with the review's
wheel-driven WebKit repros for 2 and 3.

### The simulator tool's `text` action connects a hardware keyboard

**Measured 2026-09-23 on a freshly booted iPhone 17 simulator.** Tapping the
one-line bar raised the software keyboard, and typing into it with
`mcp__Claude_Code_iOS_Simulator__control`'s `text` action dismissed the
keyboard mid-sentence and left iOS's hardware-keyboard bar - arrows and a
check - at the bottom of the screen. From then on NO field on that device
raises the keyboard, Safari's own address bar included. The first device had
been typed into with the same action earlier that day - the session transcript
shows `hardware-kb-test` and `hello` sent to it - and was in that state all
afternoon
while `ConnectHardwareKeyboard` read 0 in the Simulator's defaults - the
preference does not describe it.

**So "the keyboard does not open" on a simulator that has been typed into is
the simulator, not the component.** Test it with a control before reading any
code: tap Safari's address bar. If that raises no keyboard either, nothing the
page does can. Recover with Simulator > I/O > Keyboard > Toggle Software
Keyboard (Cmd-K), or use a device that has not been typed into by the tool.

### Storybook and vitest are not the same React runtime

**Measured 2026-09-10, and no mechanism is claimed for it.** Anything verified
in Storybook and tested in vitest is being checked against two runtimes that
disagree on at least two observable behaviours. Raised for Felipe; this note
records the measurements rather than a diagnosis.

`package.json` declares `react` and `react-dom` at `^18`, and exactly one copy
of each is installed:

```bash theme={null}
node -p "require('react-dom/package.json').version"           # 18.3.1
find node_modules -maxdepth 4 -path '*/react-dom/package.json' # one hit
grep -c inert node_modules/react-dom/cjs/react-dom.development.js  # 0
```

That package has **zero** occurrences of `inert`, so it does not know the
attribute as a DOM property. Yet the two environments differ:

| | Storybook, in Chrome | vitest, in jsdom |
| - | - | - |
| `inert={true}` renders as | `inert=""`, IDL `true`, focus blocked | nothing; `hasAttribute('inert')` false **← and this is what the product does** |
| `useId()` emits | `_r_0_` | `:r0:` (`:R0:` under `renderToStaticMarkup`) |

Both halves were taken with controls. The `inert` reading used the OPEN panel
as its negative control - the same node IS focusable then - so the probe could
have failed and did not. The id reading was taken from every generated id on
the page at once, and none was colon-form:

```js theme={null}
[...document.querySelectorAll('[id]')].map((e) => e.id).filter((i) => /^:r/i.test(i))  // []
```

### When the two disagree, believe vitest

**This section originally ended "Neither is a bug in the component; both are
the runtime split showing through", and told you not to resolve a
disagreement by changing a component. That was wrong about `inert`, and the
correction matters more than the observation.**

The product is Next.js running the **installed** `react-dom` - the same 18.3.1
that vitest loads. So on any question about what react-dom does, **vitest is
the truthful environment and the Storybook preview is the anomaly.** The
Storybook reading said boolean `inert` applies; the package says it cannot,
and SSR with that package proves it:

```bash theme={null}
node -e "const R=require('react'),{renderToStaticMarkup:r}=require('react-dom/server');
  console.log(r(R.createElement('div',{inert:true})))"   # <div></div>  - dropped
```

So every `inert={true}` in this folder was a **no-op in production**, each on a
container that also carried `aria-hidden` - `aria-hidden` over descendants
still in the tab order, which is axe's `aria-hidden-focus`. A real defect,
found by CodeRabbit on #3876 and fixed on 2026-09-10 by
`components/modulus/inert.ts`: pass `modulusInert(on)`, never a boolean.
`__tests__/inert.test.tsx` covers it, and covering it is only possible because
the string form applies in jsdom too.

The `useId` half is different and still stands: a value reaching a CSS selector
needs `CSS.escape` for the colon form, which is why the focus return in
`use-menu-dismiss.ts` escapes it. That one is defensive against both formats
rather than a bug in either.

**The rule to take from this:** a Storybook measurement is authoritative about
LAYOUT and COMPUTED STYLE, which is what it is for. It is not authoritative
about react-dom's behaviour. For that, read the installed package or assert it
in vitest.

**Do not trust a computed COLOUR after hand-toggling dark mode.** Adding `dark`
to `<html>` from the console flips the custom properties correctly. Read them
off the element and `--modulus-surface-hover` really does go `#0000000A` →
`#FFFFFF0D`, and `--modulus-text-secondary` `#525252` → `#CFCFCF`. What it does
**not** do is invalidate the `var()`-derived computed values. `getComputedStyle(el)
.backgroundColor` and `.color` keep reporting the light result on the very
element whose variables have already changed. Waiting past `duration-fast`
(150ms) does not clear it; this is invalidation, not the colour transition.

So a manual toggle gives you two readings that disagree, and the derived one is
the wrong half. Both modulus variant blocks are present and correct in the story
frame, `[data-theme-variant='modulus']` and
`html.dark[data-theme-variant='modulus'], html.dark [data-theme-variant='modulus']`,
and every utility does resolve through `var(--modulus-*)`, so a disagreement
here is the measurement, not the CSS.

**The sound fallback is a structural check.** Assert on what the element is
declared to do rather than on what a computed read says it resolved to:

```js theme={null}
/hover:bg-modulus-surface-hover/.test(el.className) // is the class in className?
```

Read that for exactly what it proves, which is that the utility is **in
`className`** and nothing more. It does not establish that a rule stands behind
the class; that is the separate silent failure **Keys that resolve to nothing**
covers below. Two questions, two checks, and conflating them is how a class with
no rule behind it reads as a pass.

It is still the right check for the claim it fits, because it is mode-independent
by construction: for anything of the form "this paints no background", a class
that is absent cannot paint in either mode, whatever any variable resolves to.

When rule PRESENCE is the claim, walk the loaded stylesheets, and **recurse**. A
`hover:` variant is a plain selector, but a responsive variant, Tag's X swap and
the Composer collapse all sit inside `@media` or `@container`, so they are not in
a sheet's top-level `cssRules` at all and a flat scan reports zero for a rule
that is present and working:

```js theme={null}
const flatten = (list) => [...list].flatMap((r) => [r, ...(r.cssRules ? flatten(r.cssRules) : [])]);
[...document.styleSheets]
  .flatMap((s) => { try { return flatten(s.cssRules) } catch { return [] } })
  .filter((r) => r.selectorText?.includes('bg-modulus-surface-hover'));
```

The `try` is load-bearing rather than defensive: the story frame pulls two
Google Fonts stylesheets cross-origin, and reading `.cssRules` on one of those
throws a `SecurityError` that takes the whole expression down instead of
skipping a single sheet. Measured in the Tag story: 1 of 13 sheets throws.

**Keep the rule AND recurse. Do not write the ternary.** In a browser that
supports CSS nesting a plain `CSSStyleRule` also exposes `.cssRules`, as a
truthy EMPTY list, so the tempting
`(r.cssRules ? flatten(r.cssRules) : [r])` sends every style rule down the
recursion branch and returns nothing for it. Measured in the same frame: the
ternary yields 157 rules and **zero** matches for anything, the form above
yields 5255 rules and 6 matches for `.modulus-tag-remove`. A flat scan with no
recursion yields 4350 and finds only 1 of those 6, since the other five sit
inside `@media (hover: hover)` and `@container modulus-composer`. All three
report "no rule" in a way that looks like a component bug.

Reserve computed colour reads for a single mode, light as the frame loads, and
get real dark-mode evidence from a screenshot rather than from
`getComputedStyle`.

**A story renders one page margin NARROWER than the product at the same
viewport.** Every Modulus story decorator carries its own `p-modulus-space-4` (16
each side) inside the viewport, so a component measured in a story has 32 less
width than it would in the app, where `grid/margin-sm` supplies the only
inset. At a 375 viewport the Composer is **311** wide in Storybook and **343** in
the product. That is a whole responsive step: the Composer's action row wraps to
three lines in the story and two in the product at the same nominal device width,
and at a 320 viewport the story overlaps where the product does not.

So a width measured in a story is not the width the user gets. Either force the
element to the product width before measuring, or add the 32 back by hand — and
say which basis a reported number is on, because the two disagree by exactly one
reflow. The decorator is duplicated in all seven story files; do not "fix" one of
them in isolation.

## Layout

Three regions: **nav**, **content**, **panel**. The grid anchors to the
**content area**, which starts at the nav's right edge — not at the viewport
edge. No page reasons about the nav or the panel. Both are shell, and a page
that offsets itself to dodge one is redoing work the shell has already done —
then doing it wrong the moment the shell changes.

The panel is **inline** from `breakpoint/xl`, an **overlay** from `md` to `xl`,
and a **full sheet** below `md`. Only the inline form takes width out of the
content area; the other two sit above it and change nothing about the grid.

**The nav never collapses on its own.** Expanded `sidebar/width` 280 from `lg`,
forced to the `sidebar/rail` 72 rail between `md` and `lg`, a drawer below `md`.
Collapsing is a **user action only** — never a width threshold, never a
content-driven heuristic. The forced rail between `md` and `lg` is not a
counter-example: that is the shell answering a viewport it cannot fit, not the
nav deciding on its own.

**Content width resolves as `min(container/measure, available - 2 *
grid/margin)`**, where `available` is the viewport less the nav and, when the
panel is inline, less `sidebar/width`. It reads `sidebar/width` and not
`panel/width` because that is what the panel actually binds: HistoryPanel is
`w-modulus-sidebar-width`. `panel/width` had no consumer when this was written
and has one now - `mode-menu.tsx:348` binds `w-modulus-panel-width` for its
desktop arm - so the token is live and this formula still reads the wrong one
of the two. The two tokens
hold the same 280, so the formula gave the right number from the wrong source.
The column **centres in the content area** and **re-centres when either region
changes**: opening an inline panel moves the column, it does not only narrow
it.

Dashboards take **no measure** and run fluid. A measure is for reading; a
dashboard is for scanning, and capping one strands the tiles.

Tile grids use `repeat(auto-fit, minmax(min(var(--modulus-min-width-tile),
100%), 1fr))` at a `grid/column-gap` gap.

**The `min()` is required, not defensive.** Without it the track holds at its
288 floor below a 288 container and the page overflows instead of the tile
shrinking. That is what breaks reflow at 400% zoom, where the content area is
narrower than a single tile — a horizontal scrollbar on the page, which is the
one thing reflow exists to prevent. Column count is a **consequence of container
width, never a media query**.

**A selected surface carries no border, or a `text-primary` border - never a
border in a quiet colour.** Filter Pill already does the first; Chip / Large,
which shows a border when selected, takes the second.

**"Quiet colour" and NOT "hairline", and the word is not to be restored.**
`layout/border/hairline` is a WIDTH token - 1, against `thick`'s 2 - and the
`text-primary` border this rule endorses is 1px as well. Written as "never a
hairline" the sentence appears to ban a 1px border two words after endorsing
one, and it misreads for exactly the reader it is written for. What is excluded
is the COLOUR, at any width.

**SCOPE: this is a border that IDENTIFIES the selected state.** That is WCAG
1.4.11's subject and the case the superseded corollary covered. Where the fill
is the primary signal and the border only REINFORCES it, `border-strong` is
accepted below 3.0 - see `CONTRAST_ACCEPTED["border|border-strong|Light"]` in
scripts/modulus-tokens/verify-tokens.mjs, whose stated rationale is that "the
row state already identifies it and the outline is not the identifying
signal". Stating the scope restores what the parent corollary already said
rather than weakening this rule.

The pairing spans **18 backgrounds per mode**, not the four a cross-reference
in composer.tsx once claimed. `border-strong`'s measured worst in light is
**2.18 on `surface-pressed` over `surface-page`**, failing 15 of 18. The
allowlist entry cited above records 2.15 and names the selected-plus-hover
stack as its worst; both are stale, and #3764 carries the recompute.

***

## READ THE REGIME BEFORE READING ANY FIGURE BELOW

**Every ratio in this section is measured post-v3.44.0 (2026-09-04) and names
the base it composited over.** That is not decoration. Before v3.44.0
`surface-selected` was an opaque `neutral/100`, so "`border-strong` on
`surface-selected`" denoted ONE measurement. v3.44.0 made it a 10/12 per cent
alpha overlay, so the same phrase now denotes one measurement PER BASE - the
name survived the change that altered what it referred to, which is exactly
how the figures here went stale while still reading as precise. A figure
carrying its base cannot do that.

**AND WHERE A CLAIM IS ABOUT ORDERING OR IDENTITY RATHER THAN A RATIO, IT SAYS
SO.** Those are the ones that go stale with no number in them to look wrong.
Three in this section alone did: "the composite is exactly `border-subtle`"
(an IDENTITY claim, false since v3.44.0), "`surface-selected` WAS a translucent
state layer" (a STATE claim whose tense inverted - it is translucent again),
and "the pairing spans four backdrops" (a CARDINALITY claim - it is 18). None
contained a wrong-looking digit. A sweep found them; a reader would not have.

**THE TWO COLOURS FAIL DIFFERENTLY.** One sentence covering both made the
weaker case look like the stronger one.

* **`border-subtle` is excluded whether the border identifies or reinforces.**
  It measures 1.11 over `surface-page` and 1.06 over `surface-card` and
  `surface-raised` on the selected-plus-hover composite. A border that close
  to its background reinforces nothing, so identification never comes into it.
  **IDENTITY CLAIM, NOW FALSE:** this used to read "at 1.00 it is not
  insufficient, it is INVISIBLE - exactly the composite colour". That was
  exact pre-v3.44.0, where the composite landed on 225.88 against
  `border-subtle`'s own 226. They are no longer the same colour, so the border
  is now faintly visible rather than absent. The conclusion survives and the
  reason changed.
* **`border-strong` fails as an IDENTIFIER ON THIS SURFACE, and only here.**
  On the selected-plus-hover composite it measures 2.24 over `surface-page`
  and 2.36 over `surface-card` and `surface-raised`; on the selected surface
  WITHOUT hover, 2.44 / 2.57 / 2.57. All six are short of the 3.0
  discrimination floor. On the three BARE surfaces it measures 3.07/3.23/3.23
  in light and clears - its own `$description` records those figures. So the
  corollary quoted below still holds where it was written, and is superseded
  HERE rather than replaced, which is what `CONTRAST_ACCEPTED` means by
  "superseded for the selected case specifically". Beside a fill that carries
  the state, as reinforcement, those figures are accepted.

This **extends** the corollary recorded against `border-subtle`'s accepted
contrast failures - *where a border must identify a component, use
`border-strong` or elevation* - rather than replacing it.

**STATE CLAIM, TENSE INVERTED BY v3.44.0.** This paragraph used to say the
corollary "was written when `surface-selected` was a translucent state layer",
which now tells a reader the opposite of the truth. `surface-selected` is
translucent RIGHT NOW: `#0000001A` light, `#FFFFFF1F` dark. The three phases,
each sourced from the token file's own descriptions: translucent until
v3.30.0, opaque `neutral/100` from v3.30.0 (`surface-pressed`'s description
records this), translucent overlay again from v3.44.0 (`surface-selected`'s
own says "Was an opaque primitive until v3.44.0"). The corollary and this rule
were both written during the OPAQUE phase.

Measured on the selected **and hovered** composite in light, one column per
base, because there is no single such surface:

| Border | over `surface-page` | over `surface-card` | over `surface-raised` | 3.0 floor |
| - | - | - | - | - |
| `border-subtle` | 1.11 | 1.06 | 1.06 | fails |
| `border-strong` | 2.24 | 2.36 | 2.36 | fails |
| `text-muted` | 3.34 | 3.51 | 3.51 | passes |
| `text-primary` | 11.30 | 11.88 | 11.88 | passes |

The composite channels are 215 over `surface-page` and 220 over `surface-card`
and `surface-raised`. `text-muted`'s "passes" is against this table's 3.0
border floor only - as TEXT it carries a 4.5 floor and fails all three.

The collision is arithmetic rather than a colour choice, and the arithmetic
that used to be printed here is dead: it read `240 x (1 - 15/255) = 225.88`,
where 240 was the opaque `surface-selected` and 15/255 the old `surface-hover`
alpha. **Both operands are gone and the expression still evaluates, which is
why it read as verified.** `surface-hover` is now 3.92 per cent black -
`#0000000A`, alpha byte 10, 10/255 - not the 5.88 per cent this paragraph
claimed. Going darker still makes it worse rather than better: at
`neutral/300` a pressed-and-selected composite drops `text-secondary` to 3.61
against a 4.5 floor. That number reproduces to the digit, but its PREMISE is
pre-v3.44.0, since `surface-selected` is no longer an opaque rung to swap.

`text-muted` clears the border floor and is still not the answer - a muted
edge reads as disabled on a surface the user just selected. `text-primary` is
the only border that both clears 1.4.11 and looks deliberate.

Figures are `scripts/modulus-tokens/color.mjs`, which composites in float and is
the same maths assertion 18 enforces. On the 8-bit-rounded composites
`border-strong` and `text-primary` read 2.25 and 11.32 at channel 215, and
2.36 and 11.88 at channel 220; the conclusions are unchanged either way.

**Chart series take their colour from `positive` and `negative` when
directional, or `chart-1` through `chart-6` when compositional, and the area
fill is always that same colour derived through `chart/area-from` and
`chart/area-to` rather than a gradient token of its own.**

### Content starts on one edge; only backgrounds may pass it

**The rule (Ahmed):** in the default state, every element's content starts on the shared left edge. Only hover and selected backgrounds may extend past it; content never does.

Recorded 2026-09-26 with the Settings modal nav fix, word for word as given, so
future work follows it rather than re-deriving it from a screen.

**What it means in practice** is that a list's padding sets where a row's FILL
starts, and the row's own padding carries its CONTENT the rest of the way to the
edge everything else sits on. The modal shell is the worked example, measured
against Settings · General 2975:5404 and Modal / Settings Mobile 2586:1269:

| | title | list padding | row padding | row content | row fill starts |
| - | - | - | - | - | - |
| desktop | 24 | 12 | 12 | 24 | 12 |
| mobile | 16 | 4 | 12 | 16 | 4 |

So on desktop a selected or hovered row's fill reaches 12 left of the line the
title and every glyph sit on, and on mobile 12 left of it too - the fill is the
only thing allowed there.

**It is implemented once, in the shell, and that is part of the rule.**
`modulusModalPanelNavVariants` in modal-panel.tsx carries both arms of the list,
and the nav row is `ModulusNavItem` - NavItem 173:145, the sidebar's row - so any
modal built on `ModulusModalPanel` inherits the edge instead of restating it.
Before 2026-09-26 the desktop rows carried `control/pad-x/lg` 20 on top of the
list's 12, putting their glyphs 8 right of the title, and the mobile list was a
hand-built `div` in a story with 16 all round, putting them 20 right.

**The check is the content's x, not the box's.** Measure the title's text, the
row glyph and the row label, each from the modal's inner edge - a box measured
with `getBoundingClientRect` includes padding the rule is about, and an icon
button's box sits 12 left of its own glyph.

### Entry pages are anchored, not topped

**Entry pages (Chat, Deep Research and radar mode) carry a greeting and a
composer instead of a title.** The cluster is placed with its **centre at
`ratio/entry-anchor` of the viewport height**, falling back to `page/top` on
short viewports.

Optical centre sits **above** geometric centre, which is why the anchor is 0.42
rather than 0.5: a block anchored near the top of a tall viewport reads as
**stuck rather than placed**.

**Only the cluster is anchored, never the whole column.** The suggestion lists
differ in length per mode, and anchoring those would move the greeting on every
mode change.

#### One implementation, and it is `EntryAnchor`

`components/chat/entry-anchor.tsx` is the only place this rule exists. There is
no Tailwind key for it and **minting one is the mistake to avoid**, not a
shortcut nobody got round to.

It had two implementations until 2026-09-16 — that component for the live pages
and `pt-modulus-entry-anchor-offset` for the three `Modulus/Screens` entry
stories — and they drifted **38px apart at 1440 × 900** before anyone noticed,
because each rendered a page that looked right on its own. 30px of that was a
change only the measured form could take; 8px was a chrome term going stale
under a token re-cut. The stories mount the component now: Entry under
`Modulus/Screens/Chat/Page`, and the entry stories under
`Modulus/Screens/Radar/Entry` and `Modulus/Screens/Research/Page`. The rule is not
expressible as one `max()` anyway: it depends on **where the container actually
sits**, which is the term that lets a demo banner appear above the column
without pushing the greeting off its guide.

**That last sentence was a REASON with nothing behind it, and it was false when
written.** Depending on the term is not the same as watching it. A
`ResizeObserver` reports size, not position, so until 2026-09-16 a banner
mounting above moved the container while every observed box kept its
dimensions and nothing re-measured — measured on `Chat → Entry` at 1440 × 900,
a 60px row above the scroll column: 0 callbacks across the cluster, the
container, the inner wrapper and the shell column, the cluster 60px down, the
padding stale. CodeRabbit raised it on #4072. `EntryAnchor` now gates on a
scroll-invariant `flowTop` driven by observers on the whole ancestor chain, and
the claim above is true for the first time. The lesson is Hard rule 8's: the
sentence read as checked because it named a real mechanism, and naming one is
not the same as having run it.

`pt-modulus-ratio-entry-anchor` still exists and still resolves `calc(100svh *
var(--modulus-ratio-entry-anchor))`, but nothing uses it and it is not the way
in. The transform form it was minted for (`+ -translate-y-1/2`) leaves a hole
the size of half the cluster between the cluster and what follows, because a
transform does not give its layout space back, and it has nowhere to put the
floor.

#### The cluster is one shape, and Chat keeps a space it does not fill

Greeting, sub-line and prompt rows sit on the **same guides** across Chat,
Research and radar entry. The cluster is 316 on all three:

| | | |
| - | - | - |
| greeting | 56 | `type/h1` line box |
| gap | 16 | `primitive/space/4` |
| sub-line slot | 20 | `type/body-small` line box |
| gap | 64 | `layout/page/title-gap` |
| composer reserve | 160 | `control/composer/lg` |

**Chat has no sub-line and keeps its space anyway, empty.** That is deliberate
and it is not an oversight for someone to tidy away later. Closing the slot up
would keep Chat's greeting on the guide — the anchor sees to that — and move
everything *below* the greeting 36px up, so its composer, its pills and its
prompt rows would all miss the guide the other two share. That is what shipped
until 2026-09-16: Research's composer sat 36px below Chat's on two screens the
user reads as one flow.

An earlier fix got half of this. On 2026-09-14, on the instruction *"can we make
chat and research new state pages to be on the same exact position? move chat a
bit up to match what we have on research page on the distance from the top"*,
the component stopped halving each page's own cluster and started halving a
constant canonical one. That put every **greeting** on one line and the file
recorded the trade honestly — a shorter cluster was no longer centred on 0.42.
Reserving the slot removes the trade rather than restating it, because there is
no longer a shorter cluster.

#### The composer's centre is the fixed thing, not its top edge

The composer sits **centred in a `control/composer/lg` 160 reserve**. That is
what lets a taller composer take room above and below equally, and it is the
premise the growth behaviours below rest on. The reserve is bound with `min-h-`,
never `h-`.

#### Two growth behaviours, and they are not the same act

This was **three**, and the first was not a growth behaviour at all. It read
*"Switching mode grows the composer from its centre"* — but nothing derives the
composer's rung from mode. `size` is a literal at every call site, and Composer
`2460:7424` carries a Size axis and a Breakpoint axis and no Mode axis, so
neither the code nor the canvas has a mechanism for it. What is real is a
difference **between screens**, and it is not growth: Research mounts `lg` 160
where Chat and Radar take the `md` 136 default, the reserve is the largest of
them, and the three screens put the rows beneath them in the same place. No
switch occurs — reaching Research from Chat is a navigation, not a chip.

**Typing on an entry screen grows it downward.** Past 160 the frame grows in
normal flow, so the top edge holds and every further pixel goes down. The
greeting is worth protecting and there is nothing above it worth pushing.

**Typing in a session grows it upward, on desktop as on mobile.** The keyboard
is the reason on a phone but it is not the only reason: the composer is pinned
to the foot of a thread, so downward has nowhere to go on either. The floor is
fixed and the thread is what can afford to lose a line.

**These two are the same gesture with opposite answers, and the temptation is
to unify them. Do not.** There is nothing to unify: `ModulusComposer` has **no
directional term at all**. Its autosize sets `height: auto` then `height:
scrollHeight`, bounded by the rung as a `min-height` and by `max-lines` as a
`max-height`. It is a box that becomes taller. Direction is a property of the
layout it is mounted in, which is why it is expressed in the entry anchor and
in the thread rather than as a prop on the composer.

**A session has an ABSORBER; an entry screen has a BUFFER.** That is the
asymmetry, and it is sharper than "there is room below". The session thread is
`min-h-0 flex-1 overflow-y-auto` — it gives up height indefinitely, which is
why the session composer's bottom edge holds, and why a missing `min-h-0` on
its ancestor broke the arrangement completely and put the composer 242px below
the fold at 1440 × 560 (measured on `modulus-composer-canvas-parity` before
`75cd7e6d25`). An entry screen has no such element. It has slack, and slack
runs out.

Which is why the entry buffer is **bounded by the frame rather than by the gap
under the greeting**. Under a fixed `h-`, the composer overflowed its frame and
climbed into `page/title-gap`: at the six-line cap the gap under the greeting
was down to 25. Under `min-h-` the frame grows instead, so the composer can
never cross the frame's own top edge, and the only slack growth can eat is
`160 − rung` — 24 on Chat's `md`, **zero** on Research's `lg`. Measured:
Research / Suggested holds its composer top at 376 at 0, 3, 4, 5, 6 and 7
lines; Chat / Entry at a floored 1440 × 560 goes 340 → 329 → 328 and stops, and
the gap under the greeting floors at 100 — `space/4` 16 + sub-line 20 +
`title-gap` 64 — at every line count and every viewport height.

Measured on `Modulus/Screens/Chat/Page → Entry` at 1440 × 900, driving the real
field, six lines of input:

| | top edge |
| - | - |
| at rest | 370 |
| grown, under `h-` | 323 — climbs 47 into the gap under the greeting |
| grown, under `min-h-` | 358 — holds; the rows below move 551 → 784 |

`min-h-modulus-control-composer-*` did not exist before this. `composer.tsx`'s
**THE RUNG RULE** raised the gap as issue #3744 — *"either `control/composer`
also belongs in the `minHeight` scale, or that comment needs revisiting"* — and
the first option is now taken.

#### The mobile composer ladder is a decision waiting, not a defect

**Do not mint a rung to close this.** A rung minted for one surface, before
the token side has decided whether to carry a breakpoint axis at all, is how a
scale gets holes.

**The canvas has already decided; the tokens have not.** Composer `2460:7424`
carries **two** variant axes, Size and Breakpoint, and seven symbols:

| Size | Breakpoint=Desktop | Breakpoint=Mobile | tokens |
| - | -: | -: | -: |
| `sm` | 112 | **120** | `control/composer/sm` 112 |
| `md` | 136 | **144** | `control/composer/md` 136 |
| `lg` | 160 | **168** | `control/composer/lg` 160 |
| | | `Mobile collapsed` **60** | *(none)* |

So a full mobile ladder exists as drawn variants — 120 / 144 / 168, plus a
collapsed 60 (`3059:5822`) — and the token file carries only the desktop three
with no breakpoint axis. The entry screens therefore mount `md` 136 inside an
`lg` 160 reserve **on a phone**, both desktop rungs, not because nobody drew
the mobile ones but because no token names them. Same class as two figures
already corrected in this path — `376`, a desktop pixel, and `page/top` 104, a
desktop origin.

**THAT LIST USED TO END "and the same shape as `composer.tsx:804`, which still
records the mobile variants as 112/136/160 from a 2026-09-11 reading", and the
merge of 2026-09-22 made both halves false in one commit.** The line number was
pointing at a motion-token paragraph by then, and `composer.tsx` now carries the
2026-09-16 re-read — 120 / 144 / 168, under a section titled "THE BREAKPOINT
AXIS THAT IS ONE AFTER ALL" — with the superseded figures kept only as the
reading it corrects. Neither this file nor that one changed in a way a reviewer
of either diff would have noticed: the sentence lived on one side of the merge
and the thing it described on the other, so nothing conflicted and nothing
failed. Corrected here rather than deleted, because the general form is the one
this document keeps re-learning — **a cross-file pointer goes stale in the merge
that lands the other file, and a merge is not a diff anybody reads.**

The open question is therefore narrower than "does mobile want its own
ladder": it is whether `control/composer` grows a breakpoint axis to carry the
three the canvas already draws.

**The reserve follows the ladder; it is not a separate decision.** A reserve is
not sized to fit what it holds — it is sized to the largest rung the surface
can mount, and the difference is the slack that absorbs typing. Measured at
1440 × 900 with `md` 136 in the `lg` 160 frame: lines 0–2 move the rows below
not at all, the third line takes the composer to 158 and still moves nothing,
and only past 160 do the rows move. Size the reserve to what it holds and the
first line pushes the rows. So once mobile's ladder is settled the reserve
follows from it — largest rung, plus whatever absorption is wanted — and
picking it first would be picking the ladder by implication.

#### The session page is an exception and stays one

A thread pins its composer flush to the bottom. That is a different rule and the
right one; the anchor is for entry screens only.

#### Below `md` the page is topped, not anchored

`page/top-sm` 32, measured from **under the mobile bar**, and no anchor at all.

The anchor's arithmetic is provably wrong on a phone, and by a different amount
per screen. The sum halves a cluster built from `--modulus-type-h1-line-height`,
which is the **desktop 56 at every width**, while the `.modulus-type-h1` class
drops to the mobile 36 on 40 below 768. Measured at 375 × 812: Chat's greeting
renders **40** on one line, Research's wraps to **80** with its supporting line
wrapping to 40, for a real cluster of **360** against the 316 the sum assumes.
One formula, 8px out on one screen and 22px out on the other, in opposite
directions. The keyboard then takes roughly half
the viewport, so a centre computed against the unfocused height moves the
greeting the moment the user taps in. And Figma draws no mobile entry frame for
any of the three screens, so an anchor number here would be invented rather than
read.

`page/top-sm` is applied **raw**, not through the desktop subtraction: it counts
from under the bar where `page/top` counts from the viewport with the chrome row
inside it. The token's own description warns that two tokens named `top` and
`top-sm` invite the assumption that they share an origin. They do not.

Both previous answers were accidents rather than decisions: the component
dropped to natural flow and padded **0**, the deleted static class had no `md:`
gate at all and kept anchoring, padding **141** at 375 × 812.

#### The floor inverts the rule, and that is worth knowing

Below roughly 786px of viewport height the `max()` hands over to `page/top` 104
and the cluster's **top** is what is fixed, not its centre. Nothing overlaps —
the rows flow after the cluster and the column scrolls — but the anchoring rule
is simply not in force down there, and a screen that looks wrong at 700px is
probably looking at the floor rather than at a bug.

The anchor sits in **`layout/ratio`**, not in `layout/page`, as of v3.35.0. That
group exists so a proportion is never filed among lengths: a bare `0.42` reaching
a spacing scale emits an invalid length that the browser drops in silence, and
separating the two in the token file is a better guard than a predicate in
`tailwind.config.ts`.

### The placement grid is design-time only

Six columns of 108 at a 24 gutter inside the measure: `6 × 108 + 5 × 24 = 768`,
which is `container/measure` exactly. A 3 span is `3 × 108 + 2 × 24 = 372` — the
same width the tile grid produces at two columns inside the measure, since
`(768 - 24) / 2` is also 372. The agreement is the point: a tile placed on the
Figma grid and a tile produced by `auto-fit` land on one number rather than two.

Code uses **fractional spans and `auto-fit`**, never the column count.
`grid/columns` exists so Figma can draw the guide.

### Message / User has no token

**Message / User hugs its content, capped at 80% of the content measure** — 614
inside a 768 thread. No token holds this and none can: the cap is a percentage
of its parent, and Figma cannot express a percentage max-width. It is a
deliberate exception to Hard rule 1, recorded here because an undocumented
arbitrary value is indistinguishable from a mistake. It is not a precedent —
every other width in the thread resolves to a token.

### The top offset can belong to a component, not the screen

**A screen that binds no top offset is not necessarily missing one.**
`thread.tsx` carries `layout/page/top` in its own cva base (`thread.tsx:82`), so
the inset arrives with the component rather than from the page. Its header
(`thread.tsx:25`) states the rule as a heading:

> THREAD CARRIES page/top ITSELF, so a page must not add it again

and gives the failure by name at `thread.tsx:32-33`:

> So a screen composing this must not also apply `pt-modulus-page-top`, or the
> thread starts 208 down.

So a screen gets its top offset one of **three** ways, not two:

* **Titled page** — the title sits `page/top` 104 from the *viewport* top, with
  the chrome row inside that 104. The screen applies 104 minus the chrome above
  it, never 104 itself.
* **Entry screen** — the greeting and composer cluster is anchored on
  `ratio/entry-anchor` with 104 as the *floor*, which is what
  `pt-modulus-entry-anchor-offset` computes.
* **Delegated** — a composed component owns the inset, and the screen binds
  nothing. The worked example is the **`Session` story of
  `Modulus/Screens/Chat/Page`** (`chat.stories.tsx:564`, rendered by
  `SessionScreen` at `:578`): it composes
  `<ModulusThread>`, applies no offset of its own, and is correct.

Two consequences, and both are ways this has nearly gone wrong:

* **Do not "fix" a screen that binds no offset by adding one.** Check first
  whether something it composes already owns the inset. Adding a second is the
  208 bug, not a repair.
* **Do not add an offset alongside an owning component.** The two stack, and
  nothing in the build catches it.

**The product chat route is not an example of this, and the difference is the
point.** `app/(app)/chat/[[...sessionId]]/page.tsx` composes plenty of Modulus —
Topbar, Button, IconButton, MessageUser, MessageSilvia — but deliberately **not**
`ModulusThread`, and it says so at `page.tsx:4896`: *"NOT ModulusThread, AND NOT
BECAUSE THE SWAP WAS MISSED."* One of the two reasons it gives is this very rule,
read in the other direction (`page.tsx:4909-4910`):

> Thread carries `pt-modulus-page-top` 104 in both arms. This page already has a
> sticky header in that band.

So delegation is not free: a page can only hand the inset to a component if it
has not already spent that band itself. Check which of the two you are before
reaching for the component — the Storybook screen delegates, the product route
cannot.

Recorded here because it was written down only in `thread.tsx`'s own header,
where the author of a *page* has no reason to look. It was nearly missed for
exactly that reason: the session screen reads as a screen that forgot its
offset, and every obvious repair — `pt-modulus-page-top`, the subtracted calc,
or the entry anchor — ships a different bug.

## Responsiveness

Default to reflow, not breakpoints. A grid whose column count changes uses
`repeat(auto-fit, minmax(min(var(--modulus-min-width-tile), 100%), 1fr))` and no
media query at all. The `min()` is not optional — see **Layout** above for what
it prevents at 400% zoom. The Figma equivalent is a single wrapping auto-layout
frame with a min width set on the instance, not a variant per size.

A Breakpoint variant is only for **transformation** — where the layout changes
kind rather than degree: modal to sheet, sidebar to drawer, table to cards.
Never reach for one to adjust spacing, type or column count; those reflow.

**Control size follows input, and the rule is a FLOOR rather than a pair of rung
names.** A control a finger can miss needs 44 of target; one driven by a pointer
does not. So a touch surface binds whichever rung holds at least 44, and a
pointer surface takes the default. Today that selects `control/height/lg` for
touch and `control/height/md` for the pointer, but those names are the
consequence of the rule and not the rule itself.

Stated as a floor it survives the next ladder move and only the rung it selects
changes. Stated as two names it does not, and that is not hypothetical: the
ladder moved on 2026-09-09 and every sentence in this repo that had paired a rung
with a number went stale in one commit, including one on `control/height/md`
claiming it cleared a touch target it no longer reached.
`control/height/lg`'s own description carries the full record, including why 48
has no rung any more.

FilterPill is the only component that switches on this today,
`h-modulus-control-height-lg md:h-modulus-control-height-md`, and it is a proper
Breakpoint use rather than an exception to the paragraph above: touch and pointer
are different KINDS of input, not degrees of one.

Only use `sm`, `md`, `lg`, `xl` and `2xl`. The repo also defines `xs` 390 and
`desktop` 1150 in `tailwind.config.ts`, neither of which has a Modulus token, so
a Modulus component using them is styling against a value the design system
cannot see. The five that do map are exact: `sm` 640, `md` 768, `lg` 1024,
`xl` 1280, `2xl` 1536.

`2xl` is the newest of the five and the only one that reaches code as a
**Tailwind screen the design system did not put there**. `breakpoint/2xl` was
added to the token file at the value `theme.screens` already carried, so the
two agree today by construction rather than by accident. Retune one and you
must retune the other; nothing fails if you do not.

Responsive type is headings only. `display`, `h1` and `h2` have mobile
variants; `body`, `label` and `caption` never change size. The switch is at
`md`, and it is already generated — the `.modulus-type-*` classes take their
mobile values by default and their desktop values from 768px up, so a heading
needs no media query of its own.

Step the padding tier down rather than inventing a value: `surface` 24 to
`container` 16. Where a grid needs it, use `column-gap-sm` and `margin-sm`,
which exist for exactly this.

Figma mobile text styles live under a `Text/Mobile/*` namespace — `Text/Mobile/H2`
— rather than carrying a suffix.

For a modal or dialog, compose the existing
`components/ui/responsive-dialog.tsx` rather than rebuilding the switch.

**Its breakpoint is 1024px, not `md`** — and it does not own that number, which
is the thing to know before reaching for it. `responsive-dialog.tsx` imports
`useIsMobile` at :20 and calls it at :61. The constant lives at
`hooks/use-is-mobile.ts:13`, under a docstring calling it "the single source of
truth for the mobile/desktop layout breakpoint … Below this: MobileHeader,
MobileNavigation, mobile drawers. Above this: Left sidebar, AppHeader,
PageLayout right sidebar."

Below the breakpoint it renders `Drawer` (vaul) with `max-h-[96vh]`; above it
renders `Dialog`. It takes `title`, `description`, `children`, `footer` and a
separate class hook per branch (`dialogClassName`, `drawerClassName`).

### The 768 / 1024 conflict is permanent until `components/ui` is retired

**RULED 2026-09-21: leave `useIsMobile` at 1024 and do not touch it. Modulus
uses 768 for its own sheet threshold. The two disagree, by design, for as long
as both systems are live.**

The reason is not a compromise between two numbers: *"changing the live app's
breakpoint to match ours would be a product decision made by a design system,
which is backwards."* Modulus is a clean build running beside the product, not
an authority over it.

**MOVING IT IS AN APP-WIDE LAYOUT CHANGE, NOT A DIALOG FIX**, which is what the
earlier wording here invited someone to try. Measured on `origin/main`,
2026-09-21: **22 runtime consumers** of `use-is-mobile` with tests, stories and
docs excluded — `components/sidebar.tsx`, `mobile-header.tsx`,
`floating-chat-bubble.tsx`, `modulus/composer.tsx`, `unified-input-box.tsx`,
`hooks/use-dashboard.ts`, and the chat, radar, research, real-estate and vault
pages among them. So there are two edits, not one, and both are larger than
they look: change the CONSTANT and the sidebar and mobile header move for the
whole app; change only this dialog and it is decoupled from the hook the
codebase names as its single source of truth.

**WHAT RESPONSIVE-DIALOG ITSELF REACHES IS SMALL, AND THAT IS THE ASYMMETRY.**
Four files import it, all four render it, and they land on three surfaces:
`/connect-account` (the institution panel and the connection-details dialog),
`/sam` (the subscription-interest success modal), and the Plaid
connection-issue dialog. That last one is mounted app-wide through
`PlaidLinkProvider` in `app/(app)/providers.tsx:67`, but its JSX is gated on
`institutionIssuesData && institutionIssuesData.failureHandlingSnapshot`, and
the only non-test caller wiring `onConnectionFailed` / `onRetryInstitution` is
the connect-account institution panel — so in practice it lights on
`/connect-account`. Not *only*: the file's own comment warns those callbacks
"can flip mid-dialog on cross-surface sessions", and `useOpenPlaid` has eight
non-test callers.

`components/modulus/modal-showcase.tsx` is NOT a consumer. It names
`responsive-dialog.tsx` in prose, in a comment about this very conflict.

### The first-paint flash is a separate defect and does not depend on the number

**It is a JS switch, not a media query.** `useIsMobile` initialises to `false`,
so the FIRST PAINT IS ALWAYS THE DESKTOP BRANCH and corrects after mount. Any
sheet-first design therefore flashes a dialog before it becomes a sheet — at
1024, at 768, or at any threshold anyone picks.

Recorded under its own heading on instruction, 2026-09-21, because it was a
sub-point of the breakpoint paragraph and read as a consequence of the number.
It is not. Picking a different threshold does not touch it; only initialising
from a media query, or accepting a hidden first paint, does. Nothing in Modulus
depends on `useIsMobile` today - `ModulusModalPanel` takes its arm from a
`breakpoint` prop - so this is a hazard to know about before composing
`responsive-dialog`, not a bug Modulus currently has.

## Modals

Width is a ceiling, never a set width: full width, capped at `modal/sm`, `md`
or `lg`.

Height has two behaviours, chosen separately from width. **Hug** is the
default, bounded by
`max-height: calc(100dvh - 2 * var(--modulus-modal-viewport-inset))`. **Fill**
is for a modal carrying its own navigation, resolving to
`min(calc(100dvh - 2 * var(--modulus-modal-viewport-inset)), var(--modulus-modal-height-panel))`
so that moving between sections does not resize the modal under the cursor.

`dvh`, not `vh`. Mobile browser chrome changes `vh` as it hides and shows, and
the panel jumps.

Header and footer stay pinned; only the middle scrolls. Below `md` a modal
becomes a bottom sheet at
`calc(100dvh - var(--modulus-modal-sheet-inset))` — but see the breakpoint
conflict noted above before wiring that up.

`modal/lg` is 1120 for a derived reason, not a round one: three tiles at
`min-width/tile` (864) plus two `grid/column-gap` (48) needs 912, and with
`padding/surface` either side the modal must clear 960. Below that the grid
silently drops to two columns, which is the failure this number exists to
prevent. The slack over 960 is what lets a tile sit above its 288 floor rather
than exactly on it.

## Tailwind keys for the modal, tile, switch and sidebar tokens

These fifteen used to be CSS variables with no Tailwind key, which left
`max-w-[var(--modulus-modal-lg)]` as the only way to reach them — an arbitrary
value, and against Hard rule 1. They now have real keys. Use the utility, not
the variable.

| Use | Utility |
| - | - |
| Modal width ceiling | `max-w-modulus-modal-sm` · `max-w-modulus-modal-md` · `max-w-modulus-modal-lg` |
| Tile reflow floor | `min-w-modulus-min-width-tile` |
| Dropdown height cap | `max-h-modulus-dropdown-max-height` |
| Modal nav column | `w-modulus-modal-nav-width` |
| Modal fill height | `h-modulus-modal-height-panel` |
| Modal insets | `p-modulus-modal-viewport-inset` · `p-modulus-modal-sheet-inset` |
| Switch track | `w-modulus-control-switch-track-width` · `h-modulus-control-switch-track-height` |
| Switch knob | `h-modulus-control-switch-knob-height` · `w-modulus-control-switch-knob-width` |
| Switch knob inset | `p-modulus-control-switch-knob-clearance` |
| Sidebar column | `w-modulus-sidebar-width` (280) · `w-modulus-sidebar-rail` (72) |

**Which scale a key lives in follows the token, not a house style.** Modal
`sm`/`md`/`lg` are `maxWidth` because modal width is a ceiling — see **Modals**
above. `modal/nav-width` and both sidebar values are `width`, because those are
set widths: `layout/sidebar`'s own group description records that the nav and
the history drawer both bind to width, so the centre column resolves to viewport
minus twice the value. A clamp would let the column drift and contradict that.

The two insets, all four switch values and **both sidebar values** also live in
`spacing`, so they work as `m-`, `gap-`, `px-`, `inset-` and the rest of the
spacing family. That is deliberate for the sidebar: a fixed nav column has to be
reachable as `ml-modulus-sidebar-width` or `pl-modulus-sidebar-rail` to offset the
content beside it, not only as `w-` to size the column itself. Those, the switch
values and the insets are the only ones here you should ever apply as padding.

`layout/sidebar` has **no `maxWidth` and no `minWidth` key**, and that absence is
deliberate rather than an oversight. Do not add one to make a layout work; if a
sidebar genuinely needs to clamp rather than be set, that is a design question
for the token, not a Tailwind question.

The Tailwind key is always identical to the CSS variable name, so
`--modulus-modal-lg` ↔ `max-w-modulus-modal-lg`. There is one name to remember,
not two. That is also why two of them read redundantly
(`min-w-modulus-min-width-tile`, `max-h-modulus-dropdown-max-height`): matching
the variable won over reading well.

Only reach for `var(--modulus-*)` directly where no utility can express the
value — inside a `calc()`, as in the modal height rules above. That is not an
arbitrary value and is fine.

**A newly added key needs Storybook restarted, not reloaded.** Tailwind
generates classes only for the files present when the dev server started, so
the first component to use one of these fifteen will have the class in
`className` with no rule behind it, and will measure as the unstyled default.
A reload will not fix it. See **Measuring in Storybook** for the full symptom;
IconButton lost time to exactly this.

`w-modulus-sidebar-width` cleared this on its first use: HistoryRow's story
column wrote it, a restarted Storybook generated the rule, and the headers and
rows measured 280 rather than collapsing. Four story files stand their content
in that column now. So the key is live and no longer the one to watch.

`w-modulus-sidebar-rail` still has **no consumer**, and neither do several of
the modal and switch keys. The rule is about the FIRST use of any key, not about
which key is newest: whichever component writes one of those first must restart
rather than reload, and must confirm the rule exists before trusting a
measurement. The one-line check is under **Keys that resolve to nothing** below.

### Keys that resolve to nothing

**A Tailwind class with no rule behind it is silent.** It stays in `className`,
nothing errors, nothing warns, and the element simply renders as though the
class were absent. Most groups above are wired into ONE Tailwind scale, so
reaching for one through a different prefix produces exactly that. This has now
cost time four separate times in this work:

| Written | Emits | Actually rendered | Correct |
| - | - | - | - |
| `rounded-modulus-container` | nothing | `border-radius: 0px` | `rounded-modulus-radius-container` |
| `w-modulus-container-narrow` | nothing | full width, `max-width: none` | `max-w-modulus-container-narrow` |
| `w-modulus-modal-sm` | nothing | full width — the "Narrow" Composer story rendered at **1216**, wider than every other story | `max-w-modulus-modal-sm` + `w-full` |
| `px-modulus-grid-margin`, `px-modulus-grid-margin-sm` | nothing | no page inset at all, content flush to the content-area edge | nothing to rewrite: the class was right and `theme.extend` had no `padding` entry. Fixed in PR #3813 |

The first three share a pattern: a *ceiling* token (`layout/container`,
`layout/modal` sm/md/lg) reached for as `w-`, or a group whose Tailwind key is
the full variable name reached for by its short name. In each the class was
wrong and the fix was at the call site.

**The fourth has a different cause, which is why this list is worth keeping
open rather than closing on a pattern.** There the class was *correct*.
`layout/grid` reached the `gap` scale and no other, `theme.extend` carried no
`padding` entry, and padding therefore fell back to `spacing`, which is never
passed `layout/grid`. Nothing at the call site could have been written
differently, and `container/measure` had already documented its own formula,
`min(measure, available - 2 * grid/margin)`, against a utility that did not
exist. Assertion 17 could not catch it: it asks whether a group is referenced
anywhere in the config, and `gap` is a reference.

**Assertion 33 is the guard.** It checks that a token described as a horizontal
inset resolves in the `padding` scale specifically, not in whichever scale
happens to name the group, so a documented use and the scale that serves it
cannot drift apart again. `layout/grid` now sits in two scales, `gap` and
`padding`, which is why the sentence above reads *most* groups and not *every*
group.

**How to check in one line, before trusting a measurement.** In the story
frame:

```js theme={null}
getComputedStyle(el).width // or whichever property the class should set
```

If it reads the unstyled default, confirm the rule exists at all rather than
assuming the value is being overridden. Use the recursing, `try`-guarded walk
from **Measuring in Storybook** above rather than a flat `cssRules` scan, for the
two reasons recorded there: a cross-origin sheet throws and takes the whole
expression with it, and anything inside `@media` or `@container` is not in a
sheet's top-level `cssRules`:

```js theme={null}
const flatten = (list) => [...list].flatMap((r) => [r, ...(r.cssRules ? flatten(r.cssRules) : [])]);
[...document.styleSheets]
  .flatMap((s) => { try { return flatten(s.cssRules) } catch { return [] } })
  .filter((r) => r.selectorText === '.w-modulus-modal-sm');
```

An empty array means the key does not exist. `grep` the `theme.extend` group in
`tailwind.config.ts` to find which scale it actually lives in — and note that
several groups are deliberately filtered with `isModulusNamed(...)`, so a group
can be present in `width` for one member and absent for the rest, which is
exactly how `w-modulus-modal-nav-width` exists while `w-modulus-modal-sm` does
not.

### The silent-resolution family, of which the above is one member

FIVE separate failures in this system share one shape: **the thing you wrote is
present, nothing errors, nothing warns, and the result is the unstyled default.**
(It read "four" until the fifth row was added on 2026-09-16 and the count was
not moved with it — a stale figure inside the section about stale figures, which
is worth leaving recorded rather than quietly correcting.)
They are worth naming as one family, because recognising the shape is faster
than rediscovering each member.

| Member | What is present | Why nothing fires |
| - | - | - |
| A Tailwind class in the wrong scale | the class, in `className` | no rule was ever generated for it |
| A component outside the theme-variant wrapper | the class and the rule | `var(--modulus-*)` resolves to nothing outside the scope |
| A CSS variable with no Tailwind key in any scale | the variable | the only route left is `w-[var(--modulus-*)]`, an arbitrary value |
| **A utility minted for a leaf that makes no sense in that scale** | the class AND a rule | the *value* is invalid for the property, and CSS drops an invalid declaration silently |
| **A SHORTHAND in a shared stylesheet, on a class a component also styles** | both classes, both rules, both declarations | the shorthand resets the whole property, the two selectors tie on specificity, and IMPORT ORDER silently decides which half survives |

The fourth is the least obvious of the first four, because unlike the other
three the rule genuinely exists. The fifth is worse than all of them, because
the outcome is not even constant. See both below.

### A shared stylesheet's shorthand deletes the consumer's own declaration

Found 2026-09-16, and it had shipped. `modulus-knob-motion.css` declared
`transition: width …` on `.modulus-knob`; `modulus-switch.css` declared
`transition: transform …` and `modulus-slider.css` `transition: left …, width …`
on the same elements. Every one of those selectors is **0,2,0**, and `transition`
is a shorthand that resets `transition-property` — which is ONE list-valued
property that two rules cannot each contribute an entry to. So the
last-imported stylesheet won outright and the other's half vanished.

**The outcome therefore differed per story file, which is what made it so hard
to see.** Measured computed styles:

| Story file | order | knob computed |
| - | - | - |
| `switch.stories.tsx` | knob-motion LAST | `width \| 0.15s` — no travel |
| `slider.stories.tsx` | knob-motion LAST | `width \| 0.15s` — no travel |
| `settings.stories.tsx` | knob-motion MIDDLE | switch clobbered, slider intact |
| `setting-row.stories.tsx` | knob-motion MIDDLE | same, DERIVED not measured |

**The fourth row is derived, and it says so because the other three are not.**
`setting-row.stories.tsx` imports the three sheets in exactly the order
`settings.stories.tsx` does, so it had the identical outcome — but it was missed
when the table was written, and by the time the omission surfaced the fix had
landed and the broken state was no longer reproducible without reverting. A
table that mixes measurements with an inference has to label which is which, or
the inference inherits the authority of the measurements beside it.

The Switch had no `transform` transition anywhere, so it never travelled at all
— it teleported and then played its 200ms stretch at the destination, which
reads as a jump. The Slider teleported in its own story while its FILL, a
separate element nothing clobbered, went on sliding for 200ms behind it.

**Three lessons, none of which is "check the import order".**

* **A shorthand is a claim over the whole property.** Two stylesheets may share
  an element only if at most one of them declares any given shorthand. Longhands
  do **not** rescue it — measured in both orders — whenever both sheets need the
  same longhand, which for `transition-property` they always do.
* **Equal specificity plus split ownership means the result is a function of
  the importer, not of the component.** That is why a story file could make one
  component behave two ways, and why the fix cannot be "fix the order": the
  sheets are destined for `app/layout.tsx`, where the order changes again.
* **The repair moves the shared property into ONE declaration** and has each
  consumer supply only the part that genuinely differs — here
  `--modulus-knob-travel-property`, the property name, with the timing shared.
  That failure is then total rather than partial (`transition-property: all` at
  0s if a consumer omits it), so it is guarded by an assertion in
  `__tests__/knob-motion-import.test.ts` rather than by a default: every
  candidate fallback is worse than the check, `var(--x, none)` invalidating the
  declaration exactly as no fallback does, and `var(--x, width)` silently making
  the 150ms release ramp 200ms.

**And note how it was established, because the correlation was not enough.** The
computed style said `width`; that is consistent with several causes. Deleting
that one declaration from the live CSSOM returned `transform | 0.2s` to the
switch, and restoring it took it away again. A reversible intervention is what
turns "these two facts co-occur" into "this one causes that one".

### A whole-group spread mints a utility for every leaf, including the nonsense ones

`modulusGroup(modulusTokens.x.y, ['x','y'])` with no filter emits a key for
**every** leaf in the group. When a group is not uniform, that includes leaves
that mean nothing in the target scale, and the failure is silent in a new way:
the class exists, the rule exists, and the browser drops the declaration because
the value is not valid for the property.

`control/composer` is the worked example. It holds three heights and one line
COUNT, and it was spread whole into `height`, so the moment
`control/composer/max-lines` landed, `h-modulus-control-composer-max-lines`
appeared on its own and emitted `height: 6`. Six of nothing. No error, no
warning, and a Tailwind class that looks exactly as legitimate as its three
siblings.

**So a group that is not uniform must be filtered on the way into a scale.**
`layout/grid` has always done this, a unitless `columns` beside four lengths
filtered with `isModulusDimension`. `layout/ratio` exists precisely so a
proportion is never filed among lengths in the first place.

### A group-level `$type` is invisible to a leaf-level filter

This is the trap, and it is invisible until something disappears.

`walkTokens` yields each leaf **exactly as written** and does not inherit an
ancestor's `$type`. So a predicate like `isModulusDimension`, which tests
`token.$type === 'dimension'`, sees `undefined` for every leaf in a group that
declares its type once at group level, and keeps **nothing**.

The two groups look identical from the outside and behave oppositely:

| Group | Where `$type` is declared | `isModulusDimension` keeps |
| - | - | - |
| `layout/grid` | on each leaf | the four lengths, dropping `columns`, which is correct |
| `control/composer` | on the **group**, overridden on the count | **nothing**; sm, md and lg all vanish |

Measured, not reasoned: filtering `control/composer` with `isModulusDimension`
dropped `h-modulus-control-composer-sm`, `-md` and `-lg` along with the count,
which is a worse outcome than the bug it was meant to fix, and it would have
been invisible until a Composer measured its unstyled default height.

**So filter by the exclusion, not the inclusion, unless you have checked where
the group declares its type.** `isNotModulusCount` tests `$type !== 'number'`,
which keeps a leaf that inherits `dimension` and drops one that declares
`number`. It is also stable in both directions with no config change: a new rung
inherits `dimension` and stays in, a new count declares `number` and stays out.

Either predicate is fine on a group that types its leaves. Only the exclusion is
safe on a group that types itself. Check first. The difference does not show up
in the token file's shape, only in what survives the filter.

### Assertion 17, and the group that had no key at all

The table above records keys reached for through the *wrong* scale. There is a
second, quieter failure: a token group with a CSS variable and **no Tailwind key
in any scale**. `layout/sidebar` shipped that way. `--modulus-sidebar-width` and
`--modulus-sidebar-rail` emitted correctly for three token versions while
`w-modulus-sidebar-width` did not exist, so the only way to reach the value was
`w-[var(--modulus-sidebar-width)]` — an arbitrary value, and a breach of Hard
rule 1. Nothing failed. `tokens:verify` was green throughout, because assertions
1–16 check the CSS side of the pipeline exhaustively and the Tailwind side not
at all. It was found by reading the config.

**Assertion 17 in `scripts/modulus-tokens/verify-tokens.mjs` now closes that.**
For every group in the exported collections it asserts a matching
`modulusTokens.<collection>.<group>` reference exists in `tailwind.config.ts`,
and names the group when one does not:

```
17. Every token group in the exported collections has a Tailwind key
      -> 1 group(s) with a CSS variable but no Tailwind key: layout/sidebar.
```

Three things about it worth knowing before you trip it:

* **It checks groups, not tokens.** The config consumes whole groups through
  `modulusGroup`, so a new leaf inside a wired-up group needs no config change
  and correctly does not fail. A new *group* always fails, which is the case it
  exists for. When Figma adds one, expect this to go red — that is the check
  working, not a bug in it.
* **It reads `tailwind.config.ts` as text, with comments stripped**, so a group
  merely named in the prose above a `theme.extend` block does not count as a
  reference. Stripping can only remove a reference, never invent one, so it
  cannot produce a false pass.
* **Exemptions are a decision and carry their reason.** `TAILWIND_KEY_EXEMPT` is
  a `Map` from `collection/group` to why it has no key. It currently holds
  exactly two entries: `layout/breakpoint`, because `theme.screens` replaces the
  Tailwind defaults rather than extending them and is frozen; and
  `figma-only/card`, because `EXCLUDED_COLLECTIONS` drops that whole collection
  from code export so it has no CSS variable either. Adding an entry is how you
  say "deliberately no key" — but it needs the reason the rule above demands,
  and a stale entry naming a group the token file no longer has fails the
  assertion too, so the exemption list cannot rot.

If assertion 17 fails on a group you have just added, the fix is almost always
to wire it into a `theme.extend` scale, not to exempt it.

## Status indicator semantics

The modal nav shows one 16px indicator per category, in a slot reserved on
every row whether or not it currently shows anything. The reserved slot is what
stops rows shifting as state changes.

Shape carries the state, so nothing depends on colour or animation alone:

* A **ring** means unsaved data.
* A **pulsing filled disc** means a connection or load is running.
* A filled **`positive` disc with a knocked-out tick** means saved.

There are two paths, not one progression. A connected category goes
none → loading → saved. A manually entered one goes none → unsaved → saved.

The pulse animates opacity between 100 percent and `--modulus-pulse-floor` over
`--modulus-duration-pulse` using `--modulus-easing-loop`, and holds at full
opacity under `prefers-reduced-motion`.

The floor is 70. It was 65, derived against a selected row on `surface-card`,
but the modal nav sits on `surface-raised`. Re-measured 2026-09-08 with
scripts/modulus-tokens/color.mjs, each figure naming its base:

* `status-pending` at 0.65 over `surface-selected` over `surface-raised` in
  dark measures **2.79**. That figure is CURRENT, not inherited - under
  pre-v3.44.0 values the same placement was 3.64, so it belongs to this regime
  and not the old one.
* **70 DOES NOT CLEAR 3:1, and this said it did.** It measures **2.9972**. The
  first integer opacity that clears is **71**, at 3.0393. The claim survived
  only because 2.9972 rounds to 3.00 at two decimal places, which is the
  rounding this file's own ratchet note warns about in the other direction.
  The floor stays 70 by decision rather than by arithmetic, and that is now
  what this says.
* A selected AND hovered row there measures **2.6753** at 0.70, not the 2.52
  recorded here, and clears 3:1 at opacity **80** (3.0268) rather than 86 - at
  86 it is 3.2523. The "fourteen-point swing" was derived from the 86 and goes
  with it; the real swing from 70 to 80 is ten points.

It remains an accepted failure, and the reason is unchanged and does not
depend on any of the corrected figures: shape carries the state, and a floor
high enough to clear 3:1 on the deepest composite stops reading as motion.
**CARDINALITY CLAIM, ALSO WRONG:** the token file describes this as the one
sub-3:1 placement. It is **five of the 18 dark placements**. See #3764.

The earlier note saying not to lower it has gone: it was steering readers away
from the number being short rather than protecting a floor.

The saved tick binds to `surface-card`, not white. A fixed white tick measures
2.71 against the dark-mode green and fails the 3:1 floor; bound to
`surface-card` it inverts to a near-black tick at 6.00.

Note the emitted variable names above. The generator drops the `effect`
segment and keeps the `easing` group verbatim, so the tokens
`semantic/effect/pulse-floor` and `motion/easing/loop` become
`--modulus-pulse-floor` and `--modulus-easing-loop` — not `--modulus-effect-pulse-floor`
or `--modulus-ease-loop`. `--modulus-easing-loop` sits beside the existing
`--modulus-easing-standard`.

## Shadows

Figma effect styles carry no modes. That is the whole reason the shadow colours
are their own tokens: binding an effect's colour to a mode-aware variable is
the only way one Figma style resolves correctly in both modes.

In code that indirection is not needed, so `--modulus-shadow-raised` and
`--modulus-shadow-overlay` stay complete `box-shadow` strings and you should use
them directly. `--modulus-elevation-raised` and `--modulus-elevation-overlay`
exist to serve Figma and to feed those two strings; a component has no reason
to reach for them.

They are named `elevation-*` rather than `shadow-*` because
`semantic/color/shadow-raised` and `semantic/shadow/raised` both flatten to
`--modulus-shadow-raised`, and one would have silently overwritten the other.

Both shadows resolve fully transparent in dark mode by design. Separation
there comes from `surface-raised` plus a hairline instead, so a dark-mode
component that looks flat is correct, not broken.

## Figma-only scaffolding

Field components in Figma wrap their control in a frame padded by
`layout/focus/offset` carrying a hideable stroke. This exists because Figma
cannot bind a stroke's position to a variable but can bind padding.

In code that wrapper does not exist. Focus is `focus-visible:outline` with
`outline-offset`. Do not mirror the wrapper into the DOM.

## When to stop and ask

* A token does not exist for a value a component needs
* A design calls for editing a frozen file. The list is under **Read-only. Do
  not edit these files.** in the root `CLAUDE.md`, alongside the scoping rules
  this file's Hard rules restate
* A name would collide with the existing system
* The Figma component and the token file disagree

## Recorded reasons

Hard rule 8. A comment may state a measurement without a citation, because the
measurement is checkable against the thing it describes. A comment may not
state a REASON without one, because a reason is not checkable against anything.

The distinction is not pedantry. Measurements in this repo get caught: a wrong
number disagrees with the canvas, a wrong token disagrees with
`tokens:verify`, a wrong class resolves to nothing and shows up. Reasons get
caught by nobody. They are prose about intent, they read as authoritative
precisely because they explain rather than assert, and no test, linter or
reviewer can tell a recorded reason from an invented one.

**Write it one of these ways.**

* `removed on 12 Aug 2026 on instruction: "<quote>"` - a dated quote
* `see 4a84c40 / PR #3671` - a citation
* `the reason is not recorded` - the honest form when there is no source

**Never any of these.**

* `on the design owner's instruction` - names an authority, cites no record
* `because the panel separates by its fill alone` - supplies a rationale that
  was never given
* `X was invoked to justify Y, and Y then justified Z` - a causal chain
  assembled from two unrelated facts
* a quote that stops before the sentence which qualifies it. **A truncated
  quote is worse than no citation**, because it carries the authority of the
  source while reversing what the source says, and the reader has no reason to
  go and check a claim that already looks sourced.

Those three are real. All three were written into this design system, all
three were plausible, and all three would have passed review:

1. **`history-panel.tsx`, 12 Aug 2026.** "removed on the design owner's
   instruction: the panel separates from the content beside it by its
   `surface-page` fill alone." A real instruction existed and said "Remove the
   panel's left border entirely" - the left border, with no reason given. The
   comment widened it to every edge, invented the rationale, and dressed a
   one-line directive as a ruling. It was then cited back as a recorded
   decision in four later places, and CodeRabbit absorbed one into its
   knowledge base.

2. **`history-panel.stories.tsx`, 2 Sep 2026.** "The panel separates by fill."
   The panel's fill is `surface-page`, which is the page's fill. The seam
   measures 1.0000:1 in both modes. There is no separation at all.

3. **A generated review claim, 2 Sep 2026.** "Symmetry was invoked to justify
   the width binding, and the width binding was then cited to justify
   separating by fill instead of by border." No text in the repo says the
   second half. The symmetry argument is about width parity and mentions no
   edge at all.

4. **A truncated quote of this very file, 2 Sep 2026.** A review quoted the
   Responsiveness section as saying collapsing is a "user action only" and
   "never a width threshold", then concluded that production violates it,
   citing the auto-collapse below 1200px in `components/sidebar.tsx`. The quote
   stopped one sentence early. The next sentence reads: "The forced rail
   between `md` and `lg` is not a counter-example: that is the shell answering
   a viewport it cannot fit, not the nav deciding on its own." The rule
   explicitly permits the thing the review flagged as a breach of it.

5. **The design owner's own claim about a check, 3 Sep 2026.** "Assertion 25
   sitting red across eleven token versions" - offered as the argument for
   wiring `tokens:verify` into CI. Assertion 25 passes: exit 0, zero FAIL
   lines, 31 of 31. It was also introduced on 2026-08-31 in the Modulus
   rename, and `tokens/modulus-design-tokens.json` has no commits before that
   date, so there was no eleven-version window for it to be red across. Both
   the number and the duration were invented, most likely conflated with the
   19 stale contrast claims, where a check genuinely WAS red and nothing ran
   it.

   **This one is listed because it is the owner's, and a rule that only
   catches other people's claims is not being applied.** The shape was
   identical to the four above: a claim about what happened, carrying a
   specific citation, plausible enough to pass unchecked. The underlying
   argument was sound and CI was wired up anyway. Only the evidence for it was
   fabricated, which is precisely what makes the pattern hard to see.

Number 1 stood for three weeks and propagated into three systems. Numbers 2
through 5 were each caught within minutes, all four by measurement rather than
by review: someone computed the contrast, someone went looking for the sentence
supposedly being cited, someone read the paragraph past where the quote ended,
and someone ran the assertion. **That is the check.** When a comment explains
why, find where the why is written down. When it quotes, read the sentence
after the quote. When it cites a check, run the check. If you cannot find the
source, the comment is the only place it exists, and it should say so.

Numbers 4 and 5 are the sharpest, because both carried a real citation. Rule 8
is not satisfied by the presence of a reference; it is satisfied by the
reference supporting the claim.

### The same rule applied to an automated check

A green assertion is a claim, and its citation is the code that runs. Rule 8
holds there too, and it caught something the same week.

**Assertion 32's first version passed while a twin was missing.** It asked
`text.includes(twin)` - a substring search over the whole stylesheet - so the
twin for a DIFFERENT selector satisfied it:
`[data-theme-variant='modulus'].modulus-surface-noise` is a substring of
`[data-theme-variant='modulus'].modulus-surface-noise::after`. The assertion
was green, and the invariant it named was broken.

**A check that cannot fail is worse than no check**, because it converts an
open question into a settled one. Nothing about reading the assertion revealed
it; what revealed it was deleting a twin and watching the assertion stay
green. So a new assertion is not finished when it passes. It is finished when
it has been made to fail for each reason it claims to catch - five of them for
assertion 32, including a twin present but in the wrong rule.

#### A missing signal read as a clean result

Rule 8 is about citations, and this is the citation being EMPTY. Naming the
shape is the reason for grouping these rather than counting them: each took an
absence of evidence for evidence of absence, and each produced a green reading
of a broken thing.

1. **`text.includes` passing on the wrong rule.** Assertion 32 v1, above. The
   search could not distinguish "the twin is present" from "some substring
   resembling it is present", so a missing twin read as a satisfied invariant.
   The citation was a search that could not come back empty.

2. **A read tool reporting a miss inside its payload.** A node lookup that
   found nothing returned that as ordinary content rather than as an error, so
   a caller asking whether the call had FAILED saw that it succeeded. The
   absence arrived dressed as an answer. Recorded here for the first time - it
   had no entry before this one.

3. **Absence of a `PASS` line read as absence of a failure.** Negative-testing
   the component-manifest assertions, 3 Sep 2026. Fourteen of fifteen
   deliberate mutations were caught and the fifteenth reported MISSED. The
   assertion was fine. The mutation had broken the module's SYNTAX - a regex
   `'[^']*'` stopped at an escaped apostrophe inside the string it was
   rewriting - so nothing ran, no `FAIL` line was printed, and the harness's
   "is the target among the failures?" test could not tell a silent crash from
   an assertion that declined to fire.

4. **A `PASS` line from an assertion that never ran.** An assertion written for
   the component manifest, 4 Sep 2026. It reported the full count passed and was
   inert for TWO independent reasons, either of which alone was enough:

   * **Placed after the reporting loop.** `check()` pushes to `results`, and the
     loop that prints each line and increments `failed` had already finished. Its
     result could neither print nor affect the exit code. The count line, printed
     later still, was arithmetically true and totally uninformative.
   * **Calling two helpers that do not exist.** Both were invented at the
     keyboard. Had the check run, each would have thrown `ReferenceError`, which
     `check()` catches as a failure - so the placement bug was masking a second
     bug that would otherwise have been loud.

   **What caught it was checking whether the helpers existed**, not reading the
   output: `grep -cE "(const|function) <name>"` returned 0 for both. That is the
   transferable part. **A passing check whose helpers do not exist is passing for
   the wrong reason**, and the output cannot tell you so, because a green line is
   exactly what an inert check produces.

   Recorded without softening because of where it happened: on a change whose
   entire subject was checks that cannot fail, in the file that catalogues them.
   Writing one while documenting them is evidence about how easy the shape is to
   produce, not an embarrassment to leave out.

**The correction is the same in every case: check that the signal could have
arrived.** A search must be able to come back empty. A call must be able to
report failure as failure. A negative test must assert the harness RAN - a
`PASS` or `FAIL` line exists, or the exit code is the expected 1 rather than a
crash code - before it asserts anything about which assertion fired. Where the
signal cannot be made to arrive at all, say so in the check's own message
rather than letting green stand in for it.

**For a new assertion specifically, two things before its `PASS` line means
anything.** Both were missing from instance 4:

1. **Place it where it gates.** In `verify-figma-axes.mjs` and
   `verify-tokens.mjs` that means above the Report section, so the result is in
   `results` before the print loop reads them and before `failed` is counted.
   Verify by looking for the assertion's own `PASS`/`FAIL` line in the output,
   not the summary count, which stays true while saying nothing.
2. **Prove it fails.** Mutate the thing it guards and confirm the assertion names
   it, with a non-zero exit. An assertion never observed failing is
   indistinguishable from one that cannot.

#### The inverse: a signal arriving from the wrong place

The instances above are a signal that never arrived. These are a signal that
arrived from somewhere it had no business coming from: **a pattern matched
PROSE, and the match was read as a fact about code.**

1. **The marker count, and it is the worst of these because it overrode
   someone who was right.** `grep -c "data-theme-variant='modulus'"` over the
   ten component files returned at least 1 for every one, so all ten were
   reported as stamping their own root. **None did.** `button.tsx`'s single
   occurrence is at line 19, inside the header comment - *"carrying
   `data-theme-variant='modulus'`. Outside that scope every..."* - and Chip's
   and IconButton's are the same sentence. A subagent had reported those three
   as unstamped and was corrected on the strength of this count; the count was
   then published in an issue before anyone checked it. Matching the attribute
   as CODE - a line that is only the attribute - gives the real answer: on
   staging at that moment, zero roots carried it, and `row-actions.tsx`'s one
   marker was on its portal rather than its root.

2. **The test count.** A test file was described as having 33 tests. Running it
   gives 14 - six components times two cases, plus two compound-selector cases.
   The number had been asserted rather than measured, and the arithmetic that
   would have caught it is the arithmetic the file's own structure hands you.

3. **The icon count, wrong three times running** before the shape was named.
   Same cause each time, which is what makes it worth listing beside the other
   two: a repeated wrong number is evidence about the METHOD, not about
   whoever was unlucky with it.

4. **A file read from a CHECKOUT, reported as a fact about the BRANCH.** Twice
   on 3 Sep 2026, and both times the answer looked definitive. Asked whether
   `tokens:verify` passed on staging, the run was `npm run tokens:verify` from
   a worktree on another branch with only staging's `ci.yml` checked out - so
   the reported 32/32 was that branch's script, not staging's. Asked whether
   `control/composer/max-lines` existed, the token file was read from a
   worktree still on an older branch, and the answer came back "not present,
   v3.42.0" while staging had it at v3.43.0. A third read landed in a
   DIFFERENT session's worktree and was right only by luck, that branch being
   the one that introduced the token.
   **A file read from a checkout is a claim about that checkout, not about the
   branch, and the two diverge silently the moment anything merges.** There is
   no error and no warning; the file simply says something else. Use
   `git show <ref>:<path>`, or `git archive <ref>` into a temporary directory
   when a whole tree is needed - and treat the working directory as a claim
   about nothing but itself.

5. **A `tsc` filter that could not see the file the commit added, and the only
   one of these to reach CI.** Local typecheck was run as
   `npx tsc --noEmit | grep -E "button|chip|row-actions"` and reported clean.
   CI's Typecheck job then failed on two `TS2322`s in
   `__tests__/theme-variant-aschild.test.tsx` - a path containing none of
   those three substrings, so the filter discarded the errors before anyone
   saw them. The commit added that file. A second blind check compounded it:
   **vitest does not typecheck**, so twelve green tests said nothing about
   types. The runtime assertions were correct throughout; the file simply did
   not compile.

   **THE SECOND INSTANCE, 8 Sep 2026, generalises both halves.** A filter is
   not what failed there; a filter naming the SUBJECT was. The inverted method
   above - run whole, assert no error names a file in the diff - was applied to
   a RowActions fix and immediately named
   `__tests__/row-actions-nested-contrast.test.tsx`: `onPin` does not exist on
   that component (`onPinnedChange` does) and `children` is required. A filter
   spelling `row-actions` would have caught both by luck of the filename. A
   filter naming the component under test - `RowActions`, `text-secondary`,
   the recipe - would not have, because **the errors were in the harness, not
   in the subject.** The general case, and the reason the inverted method is
   the rule rather than a longer filter:

   > **A filter naming the subject misses the harness.** The files a change
   > breaks include the ones that TEST it, and those name the subject only by
   > convention. Assert over the DIFF, which contains both, rather than over a
   > pattern describing what you think you touched.

   And the vitest half is the general case too, not a detail of that one
   commit: on 8 Sep the suite reported **48 passed** on the very file `tsc` was
   failing, in the same minute.

   > **A green test suite says nothing about whether the file compiles.**
   > vitest strips types rather than checking them, so passing assertions and
   > a non-compiling file are fully compatible. Neither is evidence about the
   > other, and reporting a green suite as though it covered types is the same
   > move as citing a gate for a property it does not test - see the
   > gate-as-evidence entry below.

6. **A `git ls-tree` pathspec that matched no files, read as "the system has
   no precedent."** Asked on 4 Sep 2026 whether any component animates
   anything, the search was
   `git ls-tree -r origin/staging -- 'components/modulus/*.tsx'` feeding a
   per-file grep. The pathspec matched nothing, so every grep ran over nothing,
   and the empty output was reported as *"no component uses any motion class -
   the build would be the first, with no precedent."* `segmented.tsx` carried
   `duration-modulus-base ease-modulus-standard` in two places at the time, and
   the control case that would have exposed it - a pattern known to match -
   was never run.
   **The cost was the precedent itself, not the wrong claim.** `segmented.tsx`
   had already recorded, at lines 326-328, *"at `fast` the text lands first and
   the pill catches up"* - the measured description of a bug the build then
   went on to make: a chip whose fill ran at `fast` 150 while its chevron
   turned at `base` 200, landing one state change in two pieces 50ms apart. The
   house had met this and written it down. A working search would have handed
   over the answer instead of the bug.
   **An empty result has two explanations, and "it does not exist" is the less
   likely one.** Run the pattern against a case known to match before believing
   the emptiness.

7. **THREE PROSE HITS IN ONE TRACE, ON THE SAME QUESTION, 2026-09-21.** Asked
   which routes reach `components/ui/responsive-dialog.tsx`, a `git grep -l`
   over symbol names returned three files that do not use it:
   `app/(app)/layout.tsx` appeared to render `<PlaidLinkButton />` (it is a
   comment explaining where the singleton really lives), and `drawer.tsx` and
   `research-ideas.tsx` appeared to import `modal-showcase` (both prose). A
   fourth, `components/modulus/modal-showcase.tsx`, appeared to consume
   `responsive-dialog` and does not - its mention sits in a comment about the
   768/1024 conflict itself.

   **TWO WERE CAUGHT AND ONE SHIPPED INTO A REPORT**, which is the part worth
   recording: the two obviously-wrong ones were checked because a product file
   importing a Storybook showcase looked odd, and the plausible one went
   through unexamined and was corrected afterwards by a second pass. Plausible
   is what makes a prose hit dangerous, not obscurity.

   The habit that fixes all four is the same and costs nothing: match the CODE
   FORM. `^import` and `^\s*<Component` return the real consumers; a bare
   symbol grep returns the documentation as well, and this repo's files carry
   more prose than code.

   Density is the reason it keeps happening here rather than a run of bad luck.
   The worst offenders for false hits are the best-documented files, so the
   pattern gets worse exactly where the header is longest.

**The correction: check what the match actually matched.** The habits that
buy the most, in order:

* **Match the code form, not the token.** An attribute is a line that is only
  that attribute, or a hit inside JSX; a bare string search over a file that
  documents itself will find the documentation. The same goes for a class, an
  import, or a call - these files carry long headers, so prose hits outnumber
  code hits in the ones with the least code.
* **Print the matches, never just the count.** `grep -c` throws away the only
  evidence that would show the count is wrong. `grep -n` costs nothing and
  makes instance 1 self-evident on sight.
* **When a count contradicts another source, diff the two SETS.** Instance 1
  was a count contradicting a person. The count won because it looked like a
  measurement. Listing both sides - which files, which lines - decides it in
  one step and cannot be won by whichever number sounds more precise.

**For a compiler or linter specifically, do not filter its output at all.**
Run it whole, collect the error lines, and assert that none of them names a
file in the diff:

```sh theme={null}
git diff --name-only origin/staging...HEAD > /tmp/changed
npx tsc --noEmit 2>&1 | grep -E 'error TS' > /tmp/errs
while read -r f; do
  grep -qF "$f" /tmp/errs && echo "ERRORS IN $f"
done < /tmp/changed
```

This inverts the question from *"do any errors match my filter?"* - which a
new path silently fails - to *"does any error name something I touched?"*,
which a new path answers correctly by construction. It also tolerates a local
`node_modules` that differs from CI's: a baseline of unrelated errors is fine
as long as none of them is yours. On the branch that caught this, 33 errors
remained in files it did not touch, and zero in the four it did. Applied on
the next commit, it immediately caught a dangling `props.id` left by a rename
that the substring method would have missed for the same reason.

#### A gate offered as evidence for a claim the gate does not test

This is its own family, and it is WORSE than an unverified claim, because it
arrives with a citation. A reader who would have questioned a bare assertion
stops at the green check.

**THE INSTANCE, and it is this file's own token change.** The v3.44.0 commit
message is the only place the coupling argument between `surface-hover` and
`surface-selected` appears in full. It argues that dropping hover from 6/8 to
4/5 was load-bearing rather than taste, because it keeps the
selected-plus-hover stack just INSIDE `surface-pressed` - 215 against 212 in
light, 77 against 80 in dark - so `surface-pressed` stays the worst composite
and, in its words, "every recorded figure stays exact". It then offers
`tokens:verify` **33/33** as the evidence.

Both halves check out and the conclusion still does not follow:

* The 215/212 and 77/80 are exact. Verified.
* "Every recorded figure stays exact" is TRUE of the token file's own
  canonical `Contrast X light, Y dark, worst case` claims.
* It is FALSE of `CONTRAST_ACCEPTED` in scripts/modulus-tokens/verify-tokens.mjs,
  which is the one artefact in the repo that had recorded the STACK as its
  worst background. Fifteen of its seventeen text and border entries name the
  stack in their `// worst:` comment.

**And 33/33 cannot tell those two apart.** The ratchet is one-directional by
design: it fails when a measured worst is WORSE than the recorded one, and
says nothing when fifteen worsts IMPROVE. A green run is therefore consistent
with "every figure is exact" and with "fifteen figures are now stale in the
safe direction" - the same output for both. The gate was cited for a property
it does not measure.

The correction to that commit message, stated rather than softened, is that
the exactness claim holds for the token file's canonical figures and not for
the allowlist, and that 33/33 is not evidence either way. A merged commit
message cannot be edited without rewriting history, which is why the
correction lives here instead.

**The check.** Before citing a gate, say which of the claim's terms the gate
actually evaluates, and in which DIRECTION. A one-sided check cited for a
two-sided claim is the whole failure. If the answer is "it would not have
gone red either way", the gate is not evidence and the claim is still bare.

#### A stale figure with live arithmetic beside it

Every other stale figure in this section was a bare number, checkable only
against the system. **This one arrived with a check written next to it, and the
check passed while describing nothing.**

**THE INSTANCE.** The Layout section of this file printed, as the derivation of
why a hairline collides with a selected surface:

```
240 x (1 - 15/255) = 225.88
```

240 was the opaque `surface-selected` and 15/255 was `surface-hover`'s alpha.
v3.44.0 replaced both: `surface-selected` became a 10/12 per cent overlay and
`surface-hover` dropped to 4/5, i.e. 10/255. **Both operands were dead and the
expression still evaluated** - 240 x (1 - 15/255) really is 225.88 - so a
reader who checked the arithmetic found it correct and moved on. Nothing in
the line names a token, so nothing in it can look stale. The real composite is
215 over `surface-page` and 220 over `surface-card`.

> **Arithmetic shown beside a figure proves the figure FOLLOWS FROM ITS
> OPERANDS. It says nothing about whether the operands are current.** It
> verifies the DERIVATION and not the CLAIM.

**This is the same shape as the gate-as-evidence entry above**, and the two
belong read together. Both attach a proof to a claim, and in both the proof is
incapable of failing in the direction the claim runs: a one-directional ratchet
cannot tell "every figure is exact" from "fifteen figures improved", and a
correct derivation cannot tell "current operands" from "dead ones". In both,
the citation is what makes the claim worse than a bare assertion - a bare
number invites a re-measure, and a number with a proof beside it closes the
question.

**The check.** Re-resolve every operand from the emitted stylesheet or the
token file before trusting a result. A literal operand is a SNAPSHOT WITH NO
EXPIRY, and arithmetic around it is precisely what makes the snapshot look
live. Where the derivation matters, write it against token names and resolve
them at read time; where a literal is unavoidable, date it.

**A THIRD VARIETY, from the same section: rounded past the thing that
mattered.** The pulse floor claimed opacity 70 clears 3:1 for
`status-pending` on `surface-selected` over `surface-raised` in dark. It
measures **2.9972**. At the two decimal places this file prints, that displays
as 3.00 and reads as clearing. The first integer opacity that actually clears
is 71, at 3.0393.

> **Round for display, never for comparison.** A figure shown at 2dp against a
> threshold of 3.0 has half a unit of its last displayed digit in which to be
> wrong, and it is silent in exactly the band where it matters most - AT the
> threshold. Compare at full precision and display the rounded value beside it,
> never the rounded value alone.

That figure was neither stale nor unverifiable. It was current, checkable, and
wrong at the only precision anyone ever acted on.

**A FOURTH VARIETY, AND THE ONLY ONE YOU INFLICT ON YOURSELF: a measurement
taken before a change and quoted after it.** Every variety above is someone
else's snapshot going stale under you. This one you invalidate yourself, in the
same sitting, and then cite.

**THE INSTANCE, 14 Sep 2026.** A review asked whether the revealed message
actions clear the 44 touch floor. The controls were measured on the branch at
**24**, because the control-ladder rebind was still applied there and `xs` bound
`control/height/sm`. `icon-button.tsx` was then reverted out of that rebind -
by the same hand, minutes later - which put `xs` back on `control/height/xs` at
**18**. The 24 was then written into `control/height/xs`'s own description, into
a follow-up issue, and into a reply on the PR, all AFTER the revert. Seven
figures were wrong in the token file: the rendered size, the 32 centre pitch
(really 26), the 12 overlap (really 18), the 26 ceiling quoted as 32, the gutter
remedy, and a WCAG claim that inverted - 18 x 18 is BELOW SC 2.5.8's 24 x 24
minimum and conforms only through its spacing exception, where 24 x 24 would
have met it outright. The token description asserted conformance at a size the
component does not render, in the sentence a reader reaches for when deciding
whether that row is safe to tighten.

> **A measurement is evidence about a WORKING TREE AT AN INSTANT, not about a
> component.** Your own edit ages it exactly like anyone else's. Re-derive after
> every change you make between taking a figure and publishing it - especially a
> revert, which reads as "undoing" and therefore as "returning to the state the
> measurement described", when it does the opposite if the measurement was taken
> from the changed state.

**This is the same failure that opened the episode it closes.** `layout/page/top`
took its Topbar geometry from `topbar.tsx`'s own stale comment - 44 copied from a
line written before the ladder moved - and the rebind that followed was audited
by a check that assumed nobody had already compensated. Stale citation, stale
premise, stale self-measurement: one shape, three times, in one week. The
citation being YOURS is not a defence; it is the aggravating factor, because no
one else will think to re-measure a number whose author just measured it.

**A SECOND INSTANCE, AND THE TIGHTEST WINDOW YET: thirty-two minutes.** The same
`layout/page/top` description was corrected on 2026-09-13 at 20:29 in
`3100e5cb9c`, and that correction was RIGHT when it was made: Topbar bound
`control/height/md`, md was 36, so the band was 24 + 36 = 60 and the clearance 44.
At 21:01 the same author's rebind `1b7bf8482a` moved Topbar to `control/height/lg`
to hold the 44 it was drawn at - restoring the 68/36/172 the description had just
been changed away from, and falsifying the new prose thirty-two minutes after it
was written. The figures survived another day and a merge before three
independent readings - the binding, the emitted token, and `PAGE_TOP_OFFSET`'s
subtraction - were put beside them and disagreed.

> **Editing a description to match the code, and then editing the code, is two
> correct acts that compose into a wrong record.** Neither commit is a mistake on
> its own; the defect is entirely in the ORDER and in nobody re-reading the first
> after the second. When a change moves a binding, grep the token file and this
> file for the OLD rung name before committing it - the prose that cited it is
> exactly the prose that is now wrong, and it will not conflict, fail a test, or
> show up in a diff you are reading.

The `PAGE_TOP_OFFSET` docblock makes the cost concrete: it cites this very
description as "exactly the derivation #3901 put in the token's description", so
for a day the code and the token each pointed at the other as the authority while
stating different numbers. A citation between two documents is not corroboration
when only one of them was re-derived.

**A FIFTH VARIETY, AND THE ONLY ONE NO RE-DERIVATION CATCHES: a description that
has never matched its value, at any setting.** Every variety above is a figure
that WAS true and went stale, so re-resolving its operands finds it. This one was
false the day it was written and stayed false through every setting since - so
checking it against the current ladder, or any past one, returns a mismatch that
looks exactly like the staleness you are already hunting, and gets "corrected" to
the current rung rather than questioned.

**THE INSTANCE, 31 Aug to 14 Sep 2026.** `layout/dropdown/max-height` 360 said
"eight single-line OptionRows at `control/height/md` plus the panel padding top
and bottom, which is enough for a partial ninth row to peek". The number is
8 x 44 + 8 of padding, exactly - and it omits the seven 2px gaps between the rows
and the panel's own 2px border, sixteen pixels in all. Rendered and counted in the
deployed build, 360 holds SEVEN whole rows and 28 of an eighth. Eight flush rows
need 376. At `md` 36 the same formula gives nine rows and a partial tenth.
**There is no ladder setting at which that sentence was true.**

**IT SURVIVED A FORTNIGHT OF LADDER CHURN BECAUSE BOTH HALVES MOVED.** The rung
name went stale when the ladder moved and the figures went stale with it, so every
reader who checked found a mismatch and charged it to the staleness they already
knew about. A wrong statement that is wrong for a reason you have already named is
the easiest kind to keep not fixing: the known defect is cover for the unknown one.

> **Re-deriving a figure checks whether it FOLLOWS from today's operands. It does
> not check whether it ever followed from any.** Where a derivation is written
> out, count its terms against the RENDERED element once - gaps, borders and
> padding included - instead of re-resolving the rungs it names. A derivation that
> omits a term is wrong at every value of the terms it keeps, and re-resolving
> those terms forever will never say so.

The value was HELD at 360 rather than moved to 376. The intent was never met at
any value, and a rendering change across every OptionList and Mode Menu to honour
it is not worth the churn mid-relaunch. The description now states what the number
does, and says why the eight-row rule must not be restored by moving the rung: the
rung is not what is short.

### The one sentence all of these reduce to

Instances across counts, filters, tool payloads and test harnesses.
**Neither a running total nor a timeframe is kept here, deliberately** - both
are claims about a list that grows by design, so both go stale on the next
addition, and nothing counts list items or checks a date, so they go stale
silently. Where the timing of a particular instance is the point, that
instance records its own date. This file has been bitten by a maintained count
twice already: the story-file figure that went stale in thirteen places at
once, removed by #3694 rather than maintained, and a test count asserted as 33
that measured 14. The number was never the point; stating the correction once
rather than per instance is:

> **A search can prove presence and never absence.** A negative result is the
> BEGINNING of the check, not its end.

Every NUMBERED instance above is that move - a pattern returned nothing, or
returned something that looked like an answer, and the absence was taken for a
fact about the world rather than a fact about the pattern. `text.includes`
could not come back empty. The marker grep matched prose. The `tsc` filter
could not see a new path. The `ls-tree` pathspec matched no files at all, so
every grep downstream of it ran over nothing. In each case the next step was
cheap and skipped: read what matched, widen the pattern, or ask what a true
negative would have looked like.

**THE LAST TWO SUBSECTIONS ARE A DIFFERENT MOVE AND DO NOT REDUCE TO IT.**
Worth saying outright, because this heading used to claim they did: it said
"every instance above" while two of them were not searches at all - a
universal that went stale the moment the section grew, inside the section that
exists to record that failure. It is the third cardinality claim caught this
week and the only one that was self-referential.

A gate cited for a property it does not test, and arithmetic that verifies a
derivation rather than a claim, are the opposite shape. Nothing was missing and
nothing came back empty. Something came back GREEN. Their one sentence is:

> **A check attached to a claim is not evidence for the claim unless it can
> fail in the direction the claim runs.** A one-directional ratchet, a correct
> derivation over dead operands, and a figure rounded to a coarser precision
> than its own threshold all pass while saying nothing.

#### Every gate here checks SHAPE, and every divergence has been a VALUE

**Two independent confirmations in one week, reached from opposite ends, and
neither gate went red for either.**

`tokens:verify` reads each token's `$type`, `$extensions`, group membership and
Tailwind-key coverage. `verify-figma-axes.mjs` assertion 8 digests a component's
prop and axis NAMES. Between them they cover the SHAPE of the system
exhaustively. **Neither pins a value**, and every divergence found this week was
a value:

| | gate | state throughout |
| - | - | - |
| `control/switch/track-width` held 44 while the canvas and the code both drew 56 | `tokens:verify` | **35/35 green** |
| Text Tab's tap area went 27 -> 44 and gained an underline node | `verify-figma-axes` 8 | **green** — prop and axis names never moved |
| The Slider's `figmaNode` was an id that returns "node not found" | both | green — nothing asserts an id RESOLVES |
| The underline bound `primitive/space/half` where the drawing binds `layout/border/thick` | both | green — same 2 pixels, different token |

**THE 44 IS THE SHARPEST, because the token was the LAST copy still saying it.**
The canvas said 56, `modulus-switch.css` rendered 56 through a marked literal,
and the one artefact a gate actually reads was the one that was wrong. A green
`tokens:verify` was consistent with the whole system agreeing on 56 and with one
file dissenting, and reported the same number either way.

**AND THE COLLISION THAT MAKES VALUE-CHECKING HARD IS REAL, not hypothetical.**
36 is simultaneously `primitive/space/9`, `primitive/font/size/display-md` and
`control/height/md`. A guard that matched a component's measured 36 against "some
token holds 36" would bind a switch knob's stretch to a font size and call it
verified. That is the same move as the 44/56 disagreement, arrived at from the
other direction: there a name looked bound and was stale, here a number looks
bound and means something else.

> **Before adding a gate, say which of the claim's terms it evaluates. If the
> answer is a NAME, it cannot see a value move, and every divergence recorded in
> this file so far has been a value.**

A digest of names catches DRIFT in shape. It cannot catch a value that was wrong
when written, an intent the drawing does not carry, or an instance override on a
screen. Naming that limit in the check's own failure message is the difference
between a guard and a guard that reads as more than it is.

## Merging two of the same kind

**When a component merges caller input with its own values, the load-bearing
side goes last, and the file says which values are load-bearing and why.**

Three instances in one file on 3 Sep 2026, all in `composer.tsx` and its
siblings, all shipped, all caught in review rather than by any check:

| Composition | What was quietly discarded |
| - | - |
| `data-theme-variant='modulus'` written BEFORE `{...props}` | the theme scope, to a caller's own variant |
| `style={{...}}` written BEFORE `{...props}` | `maxHeight`, the only thing capping the field at eight rows |
| `modulusCn(describedBy, hintId)` | a description id, to Tailwind class-conflict resolution |

**In every case both sides were strings, so no type check could object, and in
every case the result looked correct.** That is what makes this a rule rather
than a review note: the toolchain has no opinion about which of two strings
should win, and reading the line does not tell you either.

The first two are ordering. An attribute or an object written before the
spread is a caller-overridable DEFAULT; written after, it is a guarantee.
Neither reading is wrong in general - the question is which values must not
be replaced - so the file has to answer it. `card.tsx` and `tag.tsx` carry the
note for the theme marker; `composer.tsx` carries it for the row cap.

The third is different and worse, because reordering does not fix it.
`modulusCn` is extended `tailwind-merge`: its job is to resolve conflicts
between CLASSES. Handed identifiers it does that job anyway. Measured:

```
modulusCn('p-4', 'p-2', 'hint-1')  =>  'p-2 hint-1'
```

`p-4` is gone. Two `aria-describedby` ids falling in one merge group lose one,
and the description it named stops being announced with nothing to show it
happened. **So check that the merge function resolves the right KIND of
conflict.** `modulusCn` is for `className` and nothing else.

### Reordering alone is not the fix, because `undefined` after a spread DELETES

**SEVEN sites on 2026-09-10, found by CodeRabbit on #3877 in TWO passes, and
the naive correction introduces a second defect.** Each had its component-owned
accessibility state written BEFORE `{...props}`, so every one was a
caller-overridable default where it should have been a guarantee:

| Site | What a caller could replace |
| - | - |
| `pane.tsx` | a CLOSED pane's `aria-hidden` and `inert` |
| `nav-item.tsx` | `aria-current`, the collapsed `aria-label`, all three state mirrors |
| `logo-icon.tsx`, `logo-wordmark.tsx`, `logo-lockup.tsx` | `role` and `aria-hidden` derived from `decorative` |
| `mode-menu.tsx`, `option-list.tsx` | a CLOSED panel's `aria-hidden` and `inert` |

**THE LAST TWO ARE THE LESSON, and they cost a whole review round.** The first
pass fixed exactly the five sites the review named and did not sweep the folder
for the same shape, so the two panels shipped with it and were found on the
next pass - one of them Major, and one of them a file this very branch had
added. When a review names instances of a CLASS, the instances are a sample.
Sweep for the class before pushing, and say what the sweep covered.

Sweeping is cheap and its result is not uniform, which is the other half:
thirteen elements in this folder write a component-owned attribute before the
spread, and most are NOT defects. `icon-astroid.tsx` and `icon-phi.tsx` are
deliberately that way - they expose no `decorative` prop, so the raw attribute
is their only control surface. One apparent hit was a chevron's `aria-hidden`
inside `nav-group-header.tsx`, a different element entirely. The test is not
"is the attribute before the spread" but "is this value load-bearing, and does
the component expose a prop for wanting it otherwise".

Pane's is the sharpest: `aria-hidden={false}` through the spread puts a
closed drawer's contents back into the accessibility tree, and overriding
`inert` alongside it puts them back in the tab order.

**It is NOT axe's `aria-hidden-focus`, and this passage said it was.** That
rule's selector is `[aria-hidden="true"]` - verified against the installed
axe-core, not recalled - so a caller UNHIDING the element takes it out of the
selector and the rule cannot fire. The rule fires on the INVERSE
configuration, `aria-hidden` present while nothing removes the descendants
from the tab order, which is the #3876 defect and is stated correctly in
`inert.ts` and in the runtime section above. The correct formulation already
existed in this file when the wrong one was written next to it.

**But moving the spread up is not sufficient, and on its own it breaks the
other direction.** Each of these attributes is a ternary with an `undefined`
arm, and **`attr={undefined}` written after a spread REMOVES the attribute the
spread supplied.** That is not a subtlety to be discovered - this repo already
DEPENDS on it deliberately, at `pane.tsx`'s `Dialog.Content`, whose comment
records it: Radix emits `aria-labelledby` and `aria-describedby`
unconditionally, `...contentProps` spreads after them, "so `undefined` here
removes both". The same mechanism that removes a dangling IDREF on purpose
removes a caller's own `aria-label` by accident.

So `aria-label={collapsed ? label : undefined}` after the spread makes an
EXPANDED nav row unnameable by its caller, and `role={decorative ? undefined :
'img'}` after the spread deletes a caller's `role` on a decorative mark. The
hole is closed and a new one opens beside it.

**Assert the load-bearing arm and fall through to the caller on the other.**

```tsx theme={null}
{...props}
aria-hidden={closedBox ? true : props['aria-hidden']}
inert={modulusInert(closedBox || Boolean(props.inert))}
```

That is the general form: the component wins where the value is load-bearing,
the caller keeps what they passed everywhere else, and which arm is which is
stated in the file per hard rule 10. Note the second line also routes a
caller's own boolean through `modulusInert`, since a bare `inert={true}` is
dropped by the installed react-dom - see the runtime section above.

**The prop surface is what makes asserting correct rather than merely strict.**
Each of these components already exposes the axis a caller needs - `open` on
Pane, `selected` and `collapsed` on NavItem, `decorative` on the three logos,
and `aria-label` destructured as a first-class prop rather than left to the
spread. A caller changes what the component IS and the reported state follows.
Where no such prop exists the raw attribute is the only control surface and
must stay overridable, which is why `icon-astroid.tsx` and `icon-phi.tsx` were
fixed the other way on #3876: those glyphs have no `decorative` prop.

### Why this is a rule and not a comment

`text-field.tsx:321` already documented the `aria-describedby` case, in almost
these words, before Composer repeated it:

> a utility in the same conflict group as another would be dropped silently,
> and the description it names would stop being announced

The knowledge existed, one file away, and was not reached for. A note that
only the author of that file will read prevents nothing in the next one, which
is the whole argument for this sitting here. When the same defect appears in a
second file, the record belongs where every component's author already looks.

## Scoping from the variant

Hard rule 9. **A descendant selector stops matching the element that becomes
the scope.** That is the whole rule; everything below is why it costs so much
here.

Every component root carries `data-theme-variant='modulus'`, because nothing
can put it on `<html>`. `lib/theme/theme-variant.ts:119` returns `undefined`
for control and otherwise the variant, and `:64` declares the union as
`'control' | 'dark-variant-b'`, so `'modulus'` is not a value that function can
produce. The token block at `app/modulus-tokens.css:24` has no descendant
combinator, so it matches the element itself, and a component in a live route
otherwise has no ancestor in the scope at all.

So a class that sits on a component ROOT is on the same element as the
attribute, not below it. `[data-theme-variant='modulus'] .thing` requires
`.thing` to be a DESCENDANT of the scope, and it no longer is. The rule stops
matching and nothing reports it.

**Write both forms.** `app/modulus-tokens.css` already does this for every
`.modulus-type-*` class, so follow that precedent rather than inventing one:

```css theme={null}
[data-theme-variant='modulus'] .modulus-surface-noise,
[data-theme-variant='modulus'].modulus-surface-noise {
```

The two are **mutually exclusive**, verified with a DOM matcher rather than by
eye: with the scope on the element the compound form matches and the descendant
form does not; with the scope on an ancestor the reverse. So exactly one applies
per configuration, nothing is applied twice, and adding the twin cannot regress
the ancestor case.

That exclusivity is asserted rather than remembered, in
`components/modulus/__tests__/theme-variant-scope.test.tsx` - which checks the
compound form matches AND that the descendant form does not, so the test fails
if it ever stops proving what it claims. Established in PR #3698.

A class on a CHILD of the root needs no twin. `.modulus-tag-remove` sits inside
`.modulus-tag`, so it is a descendant of the scope either way.

### Why Storybook cannot catch any of this

**The story decorator is always an ancestor carrying the attribute.** That one
fact hides both halves of this problem, and it has now produced two findings
rather than one:

1. A component that does not stamp the scope on its own root still renders
   correctly in Storybook, because the decorator supplies it. The variables
   only fail to resolve where no ancestor has it.
2. A descendant-only selector still matches in Storybook for the same reason.
   It only stops matching where the root is the scope.

Both failures therefore appear in the product and nowhere else, and both are
silent: an undefined `var()` is invalid at computed-value time, so backgrounds
go transparent, spacing collapses and colour inherits, with nothing in the
console. When `modulus-surface.css` and `modulus-tag.css` gained their twins,
the bug they fixed had never been visible in a single story.

**So a green Storybook is not evidence that the scope is wired correctly.** The
check that is evidence renders each component with NO decorator, and
`components/modulus/__tests__/theme-variant-scope.test.tsx` does that: for
every component it covers, the root carries the scope, a root-borne scoped
class is matched by the compound selector and not by the descendant form, and
the marker survives a caller passing another variant.

What that test does not prove is that a variable RESOLVES. jsdom implements no
cascade for custom properties, so `getComputedStyle` reads empty for every
`--modulus-*` whatever the markup says. That half needs a real browser, and
nothing covers it today.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.