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 rootCLAUDE.md first: it lists the frozen files and the
scoping rules that apply here.
Figma file
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:
> 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:
$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:
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.
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:
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
- Bind every value to a token. No raw hex, no arbitrary Tailwind values
such as
p-[13px], no magic numbers. - Never use a
.sam-*utility class. The nine inglobals.cssare unscoped and would bypass the theme variant. Rose gold comes fromaccent-textand thecta-intentpair. - Never write to
:root. All variables live under the theme variant selector,[data-theme-variant='modulus'](plushtml.dark[...]andhtml.dark [...]for dark mode). - Prefix every Tailwind key
modulus-. - Title every story
Modulus/*, under exactly three groups:Modulus/Foundations/<Topic>,Modulus/Components/<Category>/<Name>andModulus/Screens/<Area>/<Page>. See the story category table and Story titles below for the eight component categories, the naming rule and the full tree. - Prefix every exported identifier
Modulus/modulus. Components, cva recipes and prop types alike. Separately, every exported COMPONENT carries adisplayNamematching its export; a prop type has no runtime value and cannot. See Export naming below for scope and exclusions. - 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. - 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.
- A stylesheet that scopes from the theme variant carries BOTH selector
forms. The descendant
[data-theme-variant='modulus'] .thingAND the compound[data-theme-variant='modulus'].thing, whenever.thingcan sit on an element that also carries the attribute. See Scoping from the variant below. - 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 -
modulusCnresolves 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-widemodulusCncomes from./modulus-cn, nevercnfrom@/lib/utils. The Modulus Tailwind keys are token names rather than lengths, sotailwind-mergeneeds extra class groups to merge them; that configuration lives incomponents/modulus/modulus-cn.tsso the 142 files importing@/lib/utilskeep the stock merger untouched. The export is namedmodulusCn, notcn, because two functions answering tocngave a caller no way to tell which one animport { cn }had bound - the filename disambiguates the path, only the export name disambiguates the call site.lib/utils.tsremains the canonicalcnfor everything outside Modulus. Themodulus-prefix reads redundantly inside amodulus/folder on purpose: this file moves tocomponents/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.htmland.storybook/preview.tsxare shared with every other story file in the repo and are not ours to edit components/modulus/modulus-surface.cssIS imported inapp/layout.tsx, at line 20, alongsideapp/modulus-tokens.cssat 17. Unlikemodulus-storybook.cssit 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-noisewas 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. Grepapp/layout.tsxbefore repeating any claim here about what it imports; today that is six Modulus stylesheets, and the list moves.components/modulus/modulus-tag.cssis DELIBERATELY not imported there, and that decision is recorded atapp/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 withonRemoveor 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-composerquery 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.tsxandresearch.stories.tsx.tag.tsxandcomposer.tsxname 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-sizeis set by the.modulus-composer-queryclass thatcomposer.tsxputs 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-queriesis not installed and was not added for this. The plugin only supplies@container/@md:utility classes; the CSS feature needs nothing, andcomponents/preference-modal/preference-modal.module.cssalready 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.tsmeasures the row instead, andcomponents/modulus/composer.tsxcarries 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:566and every Mode Menu Panel variant carrying a radius withclipsContentfalse, 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 setsoverflow-hiddenand OptionList and RowActions setoverflow-y-autoon the same element as the radius — andoverflow-y: autowith a visible x-axis computes x toautotoo, 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 insetpinside radiusris contained whenp >= r(1 - 1/√2), not whenp >= r. The stricter rule flagged four ArtifactCards that do not bleed —r16,p9, 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.tsxis imported by the four siblingfoundations/*.mdxpages, as'./blocks'.tokens.tsis imported by those pages and byscripts/modulus-tokens/foundations-coverage.ts, which takes 10 of its exports and runs asnpm run foundations:report.
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-slotvalues. They stay unprefixed:data-slot='button'onModulusButton. 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.tsxstays, since the directory already says modulus. This holds only while the files live here. They are planned to move tocomponents/ui/, wherebutton.tsxandcard.tsxALREADY 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/Buttonis 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/Screensfile 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 aModulusChatScreenexport 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 threeModulus/Screensfiles underapp/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" propertynames the Figma set, which is not renamed;Button-only attributesinchip.tsxmeans the HTML<button>; andalongside Button, IconButton and Chipnames 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.
tsc or by lint, because a string label is valid TypeScript whatever it says:
- Quoted values.
label='Chip'becamelabel='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.
shadcn compliance
“shadcn compliant” here means matching the conventions of the shadcn components this repo already maintains incomponents/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.
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>.
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. Usepeer 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
peerand the tab, its next sibling inside the wrapping<label>, usespeer-focus-visible:. That is the~combinator, supported everywhere. Keep exactly one radio and one tab per label:~is a general sibling combinator, so apeersharing 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 tohas-[:focus-visible]here and only here, because the input is the only focusable descendant and a focused text input always matches:focus-visiblewhatever the input modality. Do not copy this to a control whose focusable element is a radio, checkbox or button: those do not match:focus-visibleon a mouse click, sofocus-within:would show a ring where the old rule did not.
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. Stockdefault 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 Buttons 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.
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 theModulusprefix: ChipLarge, SettingRow, ModalPanel, ConnectionSuccessDialog. That is the metacomponentunless the meta points at a wrapper local to the story file, asStatefulSelectordoes incomponents/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>isPagefor 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/Dialogsand are now four, one per dialog, sharingcomponents/business/business-story-fixtures.ts.
- 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.
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.
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
-
A label the variant cannot express without it.
Deleteon a destructive button. The tone is illegible without a destructive verb, so the word is carrying variant information rather than pretending to be copy. -
A size or state story naming its own rung.
Small,Medium,Large;Default,Hover,Focus,Disabled. The label IS the axis value. - 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.
-
A label paired with an icon, which names the action, because the pairing
is the subject.
Addbeside a plus. This also covers an icon-only control whosearia-labelis its only name. - 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.
-
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
TitleandDescriptionshows 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 inModulus/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 bindcontrol-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:
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 bindscontrol-height-lg44 (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, thecontrol/height/md36 action row and 2 hairlines.composer.tsxwas reverted out of the rebind, somdis right here and the 84 above never closed.max-lines:mobile-app-bar.tsx:110bindscontrol-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.
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
:activeusingcta-bg-activefor primary neutral,surface-pressedfor secondary and tertiary neutral,destructive-bg-activefor primary destructive,destructive-ghost-pressedfor tertiary destructive, and the flatcta-intentgradient for high-intent. - Disabled applies
disabled-opacityonce at the component root. Never on a child, since it compounds. - Focus uses
border-focusatfocus-width(2) withfocus-offset(2) outside the control, so the control does not shift on focus. - Transitions use
duration-fast(150ms) for controls andduration-base(200ms) for overlays, witheasing-standard.
One menu row colour convention
Rest istext-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 totext-primarylike 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-secondaryin every state. Only the primary label moves.
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 carriedtext-primary on its own span before this and the glyph could not follow it.
The three reach Focus differently, and the difference is forced
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 - StateDefault | 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’sLines=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 adark: 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 importlucide-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, socta-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, becausesize-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:
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 offdocument.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.timelinedoes not advance, so no CSS transition or animation progresses andgetAnimations()shows animations stuck at their start. A WAAPIonfinishnever fires; asetTimeoutfallback still does, which is howuse-mode-roll.ts’s 3x guard was seen working by accident.requestAnimationFramenever fires.ResizeObservernever delivers, because its delivery hangs off those same steps.
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:
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:
overflow-hiddenCLIPPED it.- The parent’s
after:top-fullseam strip PAINTED OVER it — a positioned pseudo-element generated after that box, fillingsurface-page, which is precisely the colour the cut bled to.
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
400pxso 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.
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 theclick 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:
- WebKit does not focus a
<button>on tap, so tapping a control beside a focused field BLURS the field. - The keyboard dismisses.
visualViewportresizes, and anything sized to it- the screen shell is - grows back.
- The control travels down the screen BETWEEN
pointerdownandclick.
+, 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 at0fr 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 ascontrol/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.
- 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.) 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.
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:
- Its own write.
scrollis 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. - A clamp. Tapping the bar hid the disclaimer, the thread’s box grew
558 -> 596, and the browser clamped
scrollTop746 -> 708. Separately, the field’s autosize setsheight: autofor 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. - 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.
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:
inert, so it does not know the
attribute as a DOM property. Yet the two environments differ:
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:
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 aboutinert, 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:
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:
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:
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 frombreakpoint/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.0surface-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-subtleis excluded whether the border identifies or reinforces. It measures 1.11 oversurface-pageand 1.06 oversurface-cardandsurface-raisedon 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 againstborder-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-strongfails as an IDENTIFIER ON THIS SURFACE, and only here. On the selected-plus-hover composite it measures 2.24 oversurface-pageand 2.36 oversurface-cardandsurface-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$descriptionrecords those figures. So the corollary quoted below still holds where it was written, and is superseded HERE rather than replaced, which is whatCONTRAST_ACCEPTEDmeans by “superseded for the selected case specifically”. Beside a fill that carries the state, as reinforcement, those figures are accepted.
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:
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:
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 atratio/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:
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 acontrol/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:
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. Composer2460:7424
carries two variant axes, Size and Breakpoint, and seven symbols:
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 themax() 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 againand 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/top104 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-anchorwith 104 as the floor, which is whatpt-modulus-entry-anchor-offsetcomputes. - Delegated — a composed component owns the inset, and the screen binds
nothing. The worked example is the
Sessionstory ofModulus/Screens/Chat/Page(chat.stories.tsx:564, rendered bySessionScreenat:578): it composes<ModulusThread>, applies no offset of its own, and is correct.
- 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.
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 usesrepeat(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 atmodal/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 leftmax-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.
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 inclassName,
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:
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:
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:
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.
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:
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-propertythey 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: allat 0s if a consumer omits it), so it is guarded by an assertion in__tests__/knob-motion-import.test.tsrather than by a default: every candidate fallback is worse than the check,var(--x, none)invalidating the declaration exactly as no fallback does, andvar(--x, width)silently making the 150ms release ramp 200ms.
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:
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:
- 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.tsas text, with comments stripped, so a group merely named in the prose above atheme.extendblock 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_EXEMPTis aMapfromcollection/groupto why it has no key. It currently holds exactly two entries:layout/breakpoint, becausetheme.screensreplaces the Tailwind defaults rather than extending them and is frozen; andfigma-only/card, becauseEXCLUDED_COLLECTIONSdrops 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.
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
positivedisc with a knocked-out tick means saved.
--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-pendingat 0.65 oversurface-selectedoversurface-raisedin 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.
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 bylayout/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 withtokens: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 quotesee 4a84c40 / PR #3671- a citationthe reason is not recorded- the honest form when there is no source
on the design owner's instruction- names an authority, cites no recordbecause the panel separates by its fill alone- supplies a rationale that was never givenX 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.
-
history-panel.tsx, 12 Aug 2026. “removed on the design owner’s instruction: the panel separates from the content beside it by itssurface-pagefill 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. -
history-panel.stories.tsx, 2 Sep 2026. “The panel separates by fill.” The panel’s fill issurface-page, which is the page’s fill. The seam measures 1.0000:1 in both modes. There is no separation at all. - 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.
-
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 betweenmdandlgis 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. -
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:verifyinto CI. Assertion 25 passes: exit 0, zero FAIL lines, 31 of 31. It was also introduced on 2026-08-31 in the Modulus rename, andtokens/modulus-design-tokens.jsonhas 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.
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 askedtext.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.-
text.includespassing 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. - 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.
-
Absence of a
PASSline 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, noFAILline 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. -
A
PASSline 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 toresults, and the loop that prints each line and incrementsfailedhad 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, whichcheck()catches as a failure - so the placement bug was masking a second bug that would otherwise have been loud.
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. - Placed after the reporting loop.
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:
- Place it where it gates. In
verify-figma-axes.mjsandverify-tokens.mjsthat means above the Report section, so the result is inresultsbefore the print loop reads them and beforefailedis counted. Verify by looking for the assertion’s ownPASS/FAILline in the output, not the summary count, which stays true while saying nothing. - 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.-
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 - “carryingdata-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, androw-actions.tsx’s one marker was on its portal rather than its root. - 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.
- 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.
-
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:verifypassed on staging, the run wasnpm run tokens:verifyfrom a worktree on another branch with only staging’sci.ymlchecked out - so the reported 32/32 was that branch’s script, not staging’s. Asked whethercontrol/composer/max-linesexisted, 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. Usegit show <ref>:<path>, orgit archive <ref>into a temporary directory when a whole tree is needed - and treat the working directory as a claim about nothing but itself. -
A
tscfilter that could not see the file the commit added, and the only one of these to reach CI. Local typecheck was run asnpx tsc --noEmit | grep -E "button|chip|row-actions"and reported clean. CI’s Typecheck job then failed on twoTS2322s 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:onPindoes not exist on that component (onPinnedChangedoes) andchildrenis required. A filter spellingrow-actionswould 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 filetscwas 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.
-
A
git ls-treepathspec that matched no files, read as “the system has no precedent.” Asked on 4 Sep 2026 whether any component animates anything, the search wasgit 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.tsxcarriedduration-modulus-base ease-modulus-standardin 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.tsxhad already recorded, at lines 326-328, “atfastthe 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 atfast150 while its chevron turned atbase200, 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. -
THREE PROSE HITS IN ONE TRACE, ON THE SAME QUESTION, 2026-09-21. Asked
which routes reach
components/ui/responsive-dialog.tsx, agit grep -lover symbol names returned three files that do not use it:app/(app)/layout.tsxappeared to render<PlaidLinkButton />(it is a comment explaining where the singleton really lives), anddrawer.tsxandresearch-ideas.tsxappeared to importmodal-showcase(both prose). A fourth,components/modulus/modal-showcase.tsx, appeared to consumeresponsive-dialogand 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.^importand^\s*<Componentreturn 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.
- 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 -cthrows away the only evidence that would show the count is wrong.grep -ncosts 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.
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 betweensurface-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 caseclaims. - It is FALSE of
CONTRAST_ACCEPTEDin 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.
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: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:
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 incomposer.tsx and its
siblings, all shipped, all caught in review rather than by any check:
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:
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:
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.
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 announcedThe 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 carriesdata-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:
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:- 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.
- A descendant-only selector still matches in Storybook for the same reason. It only stops matching where the root is the scope.
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.