# Motion - what moves, what job it does, and the two numbers it does it in

Written 2026-08-15, Animation stage. The transcript is measured, not remembered: every number
below was taken on that date from the comment-stripped source and from computed style in a
browser, and the two halves are reported separately because they disagree in one place that
matters.

**Two durations, not three, and the count is the finding.** The stage asks for three, named by
job. The inventory of moments found two jobs with movement in them. The third is a decision
somebody takes out loud on the day a row asks for it, not a rung written in advance.

---

## 1. The transcript, source half

Four corpora, and the fate of each is different.

| corpus | files | `transition` | `animation` | `@keyframes` | duration literals | `transition:all` | expensive props |
|---|---|---|---|---|---|---|---|
| **system** `components/` | 54 | 32 | 3 shorthand + 3 longhand | 3 | **2**, both `.01ms` | **0** | **8** |
| **paint** `ui-visual/` | 106 | **0** | **0** | **0** | **0** | **0** | **0** |
| **stand** `ui-kit/` | 58 | 1 | 0 | 0 | 2, both `.15s` | 0 | 0 |
| **grey** `wireframes/` | 105 | 105 | 0 | 0 | 105, all `.2s` | 0 | 0 |

**The stage's most expensive trap was already shut and no rule said so.** Motion in a screen
file: 0 of the 106 documents in `ui-visual/`. The pack argues that ban pays for itself at
Rollout, when twenty subagents each invent a duration. Rollout already happened here, so the
payment has been collected and the rule was never written down. It is written now, in
`components/CLAUDE.md`, and what it protects is the tree that already stands.

**The stand's `.15s` reaches one document of the painted tree.** `ui-visual/overview.html` links
`ui-kit/_page.css` as well as `components/index.css`, and it renders 114 elements at `.15s`.
That document is the index of the tree rather than a screen in it, which is the same 106-versus-105
distinction every count in this repository has to declare. **105 of 105 painted SCREENS render
zero duration literals.**

**The grey tree has exactly one moving thing**, `transition: transform .2s ease` on the
navigation drawer, in all 105 files, and 0 `@keyframes`. It is not ported and not tokenised: a
grey file links no stylesheet, so it cannot hold a token, and its drawer moves so the tree can be
clicked through. See section 8.

### The instrument caught itself twice before the number was believed

A naive count of the string `transform` in the grey tree returns 2,263, of which **1,750 are
`text-transform: uppercase`**. A naive count of `animation` returns 14 and the number of real
`animation` declarations is **0**: the rest sit inside words and prose. Both counts were taken
again with the property boundary anchored.

---

## 2. The transcript, output half

The half that matters, because a stylesheet says what was written and a browser says what
renders. 163 documents, Chromium 151 and WebKit 26.5, normal and `reduce`, **652 page loads**.

**Positive control first, in each of the four combinations.** An element carrying
`transition: opacity 1234ms linear` and `animation: __nope__ 4321ms linear 3` was injected and
read back before any page data was believed: `1.234s / 4.321s / 3` in the normal pass and
`1e-05s / 1` under `reduce`, in both engines. **4 of 4.** The probe moves when the input moves.

**The product, 105 painted screens: 4,904 moving elements, and the two engines agree element for
element.**

| rendered duration | slots | rendered easing | slots |
|---|---|---|---|
| `.16s` | 9,528 | `ease` | **12,821** |
| `.12s` | 2,346 | `cubic-bezier(.2,.7,.2,1)` | 585 |
| `.18s` | 743 | | |
| `.3s` | 636 | | |
| `.25s` | 63 | | |

### The three headline numbers

1. **Five distinct durations, and not one of them a literal.** The ladder was already tokenised
   before this stage opened. The question was therefore never "reduce eight loose numbers to
   three"; it was whether five roles exist to spend five numbers on.
2. **Two rendered curves, and 95.6 per cent of the slots read no token.** 12,821 of 13,406
   easing slots are the bare keyword `ease`, typed out 54 times in the source and defaulted the
   rest. **This is where the drift was**, not in the durations. One of the two declared curves,
   `--ease-inout`, renders 0 times in the product: its single reader is the review harness.
3. **Eight expensive properties, zero `transition:all`, zero `will-change`.** The eight are
   `box-shadow` five times (`button`, `card`, `hero`, `iconbtn`, `input`), plus `max-height` in
   `header`, `width` in `hiw` and `left` in `toggle`. The last three make the browser lay the
   page out again on every frame. They go to step 5 by name.

### One role wearing four numbers

Grouping by job is what finds this; reading the files does not.

| the same job, hover and press | duration today |
|---|---|
| `.btn`, `.chip`, `.yesno`, `.icon-btn`, `.amount-input`, `.market-head`, `.rules-tab` and 11 more | `.16s` |
| `.btn-provider` transform, `.filter-panel li label` | `.12s` |
| `.icon-btn-photo`, `.icon-btn-lift`, `.related-more`, `.ptab-lbl`, `.chart .ml-line` | `.18s` |
| `.hero-trust`, `.hero-feature` | `.25s` |
| `.card` | `.3s` |

A hover is 160ms on a button and 300ms on a card, in one product. This is the stage's textbook
defect and it was here in the textbook's own shape.

### Two animations do not render on load, and that is a fact about the probe

`sheet-rise` and `betSheetUp` returned 0 across all 163 documents, because **0 of the 105
screens ship an open `<dialog>`** and the state is opened by script. Recording that zero would
have been recording the sweep. They were opened with `showModal()` and read again:

| | duration | under `reduce` | fill mode | placements |
|---|---|---|---|---|
| `sheet-rise` | `.25s` | `1e-05s`, by the net | `both` | 337 dialogs on 105 screens |
| `betSheetUp` | `.3s` | `animation:none`, by its own block | `none` | 4 bet sheets |

One job, two numbers, two mechanisms for less motion, two fill modes, and two spellings of the
same movement (`translate:0 100%` against `transform:translateY(100%)`).

---

## 3. The inventory of moments

Corpus: all 105 grey screens, `ia/docs/flows.md`, and every state selector in `components/`.
Each moment carries exactly one of three jobs, named before any animation was chosen.

### Response, 23 moments, 16 already done

The state exists in all 23, so the gate for the Animation stage passes on every one of them.
Seven have the state and no movement:

| component | states declared | placements in the paint |
|---|---|---|
| `navitem` | hover 2, active 2, aria 3 | **1,068 on 105 screens** |
| `position` | hover 2, active 2 | **996 on 105 screens** |
| `state-block` | hover 1, active 1 | 152 on 38 |
| `footer` | hover 5, active 5 | every screen |
| `cookie-consent` | hover, active, disabled 2 | 33 on 2 |
| `toc` | hover 2, active 1 | 1 |
| `base` focus ring | focus 2 | everywhere |

`navitem` and `position` are 2,064 placements between them, and neither answers a finger today.

### Connection, 10 moments, 5 already done

Done: the dialog sheet, the bet sheet, the condensed category band, the phone filter sheet, the
market accordion.

| not done | placements | state |
|---|---|---|
| `<details>` expands with no transition | **349 in the paint, 403 in grey** | `[open]` exists |
| `toc` expands | 1 | exists |
| a toast arrives and leaves | 4 on 1 screen | **NEEDS A STATE** |
| Load more appends cards | 9 | **NEEDS A STATE** |
| `edge-fade` on the scrollers | 90 elements | exists, job not named |

### Status, 2 moments, 0 done

**The skeleton does not pulse.** `skeleton.css` is 55 lines of flat `background:var(--bg-control)`:
zero animations, zero transitions, zero states. It stands **482 times on 19 screens, identically
in both trees**. The job "show that a process is running" is performed nowhere in this product,
and there are nineteen loading screens.

The second row is `edge-fade`, and it needs a ruling rather than an implementation: a mask bound
to scroll position is an affordance, not a moment, and none of the three jobs describes it.

### The estimate

| job | moments | done | left |
|---|---|---|---|
| response | 23 | 16 | **7** |
| connection | 10 | 5 | **5** |
| status | 2 | **0** | **2** |
| **total** | **35** | 21 | **14** |

30 fall to components, 0 to patterns, 5 to the shell. **Two rows say NEEDS A STATE** (the toast
and Load more) and both are orders on the Tokens and Components stage rather than work for this
one. 35 moments over 47 components is under one each, so no moment here is decoration wearing
a job's name.

---

## 4. The register: two durations, two curves

| token | value | job | where it came from |
|---|---|---|---|
| `--dur-fast` | `.16s` | a control answers a finger | 20 declarations, 71 per cent of every slot the product renders |
| `--dur-slow` | `.25s` | an element arrives | `sheet-rise` on 337 dialogs decided it against `betSheetUp`'s `.3s` on 4 |
| `--ease-standard` | `ease` | most transitions | 12,821 slots already render it, and the value is deliberately unchanged |
| `--ease-enter` | `cubic-bezier(.2,.7,.2,1)` | an arrival: sharp start, long settle | 585 slots, 8 declarations, formerly `--ease-out` |

**Why there is no third duration.** The middle job the stage names, a change inside a component
already on screen, has exactly one member in the inventory: `.market-chevron` turning over when
its market opens, which is feedback for a click and therefore a response. A third rung would
have sat 20ms from the one above it and been indistinguishable from it. It arrives the day the
inventory hands it a row.

**Why `--dur-fast` is 160ms and not 150.** A response slower than roughly 150ms stops reading as
an answer. 18 of the 20 response declarations cross-fade a colour, a border or a ground, and two
move something; the ceiling is about a control that moves under the finger. The 10ms is taken
knowingly, and reversing it is one line in `tokens.css` that 9,528 slots follow.

**Why there is no `--ease-exit`.** Nothing in this product animates a departure. A dialog closes
at once, the condensed band collapses through the rule that opened it, and no state in
`ui-visual/` has a leaving face. A token with no reader fails the idle control as loudly as an
undeclared case.

**Why there is no `--move-sm` or `--move-md`.** The stage asks for two distance tokens. This
system already answered that question on 2026-08-13 with a census and a different shape: of its
20 `transform` declarations only 5 are movement, and the five are 3px on a card, 2 on a badge
and 1 on a provider button, which the token's own comment calls three decisions and not a
ladder. The distance stays in the file that makes it and is multiplied by `--motion`, which the
reduced-motion block sets to 0. Adding `--move-sm` would be a fourth answer to a settled
question, and this folder has a trap written about exactly that.

### The five names that are aliases for one step

41 declarations read `--dur-quick`, 14 `--dur-base`, 5 `--dur-slower`, 8 `--ease-out` and 1
`--ease-inout`. Moving all 69 belongs to step 3, which reads each declaration and decides its
job. Until then they alias, so the tree renders while the decision is taken. A token that
survives this stage with an alias for a name is a token that was never merged.

---

## 5. The cost of a frame, and the instrument that was hiding the check

### Eight expensive properties: two converted, one refused with its reason, five kept with a count

`transform` and `opacity` are the two properties a browser can animate without laying the page out
again. `transition: all` is **0** and `will-change` is **0**, measured, and both were 0 before this
stage as well.

| where | what | done |
|---|---|---|
| `toggle.css` | the knob travelled by `left` | **converted to `transform: translateX(16px)`.** 22 minus 6 is 16, and the geometry is identical to the pixel: the knob's left edge reads 6 off and 22 on, in both engines, with the animations finished before the box was read |
| `hiw.css` | the step dot grew by `width` | **converted to `transform: scaleX(1.75)`.** 28 over 16 is exactly 1.75, so the resting state, which is two dots of every three, stays undistorted. The cost is named: scaling a pill horizontally scales its end caps, so the selected dot's caps go from a 2px radius to 3.5 on a bar 4px tall |
| `header.css` | the condensed band collapses by `max-height` | **kept, and it is the one refusal.** There is no transform that removes a box from the flow, and this band's whole job is to stop taking room. `scaleY` leaves the room behind, taking it out of flow puts it over the content it exists to push, and `grid-template-rows` from `0fr` is the same layout pass under a different name. **The cost is one element, once**: one band per document, relaid for 250ms when the header condenses |
| `button`, `card`, `hero`, `iconbtn`, `input` | `box-shadow` | **kept, five of them, and the count is the argument.** `box-shadow` does not touch layout; it loads painting, and the case against it is a list of twenty cards painting at once. All five are on the element's own hover or focus, so **at most one element in the document is animating a shadow at any moment**. A pointer is in one place |

**And the two conversions were each misread once before they were believed.** The toggle read
`translateX(0)` in both faces, then 0.56px in Chromium and 3.82 in WebKit, because the probe was
reading a transition in flight; finished first, both engines read exactly 16. The dot read
`transform: none` on all nine of its placements on `how-it-works.html`, which looked like a rule
that had not applied and was **nine elements that render at no width at all**, because they live
inside the shut how-it-works dialog. On `ui-kit/hiw.html`, where six of them do render, the selected
one reads `matrix(1.75, 0, 0, 1, 0, 0)`. **A computed value on a box that was never laid out is not
a reading of the rule**, and this is the shut-dialog trap the kit has already paid for once.

### Less motion: the mechanism, and the net that was standing on top of it

The mechanism is one block in `tokens.css` that redeclares the tokens. Every component reading a
`var()` obeys without knowing the block exists, and so does the component nobody has written yet.

`1ms` and not `0s`: a duration of zero removes the transition rather than shortening it, so
`transitionend` never fires and a script waiting on it stalls. 1ms keeps the event and removes
the movement, and it is the value the step 5 check looks for. **Anything above 1ms is a rule that
is not reading these tokens.**

`@media` works here and could not at the Responsive stage, and that is not a contradiction. A
width query cannot READ a custom property, which is why the rung ladder is a registry. This query
does not read one, it redeclares one inside itself.

### The check was blind, and then it was not: the net is gone

`base.css` carried a blanket net from before this stage:

```
@media(prefers-reduced-motion:reduce){
  *,*::before,*::after{ animation-duration:.01ms !important;
                        transition-duration:.01ms !important; ... } }
```

Measured with it in place, over 163 documents in two engines: **6,555 moving elements per engine
in the normal pass against 115,028 under the setting**, the difference being every element in the
DOM taking a duration from that one rule, and **0 rows above 1ms out of 230,056**.

That green said the net was present and said nothing else. Under `!important` on `*`, a component
that reads no token is indistinguishable from one that reads every token. **A sweep that cannot
fail is not a sweep.**

**Taken off at step 5 and re-measured, with the positive control first.** An injected element
carrying `transition: opacity 999ms linear`, which reads no token of ours, read back **999ms in
both engines**: the probe can see a rule that does not obey. Then 163 documents, Chromium 151 and
WebKit 26.5, emulating the setting:

| pass | elements above 1ms | where |
|---|---|---|
| net removed, first reading | **1,392** | **0 in `components/`.** 690 per engine from one `.15s` literal in `ui-kit/_page.css`, over ten kit pages and `ui-visual/overview.html`; 6 per engine from the demonstration pulse on `motion.html` |
| both repaired at source, second reading | **0** | |

**Every rule in the system obeyed the token override on its own**, which is the fact the net had
been standing on top of for as long as it existed. The two that did not were the stand, and a
stand is not a product and IS read by a person, so both were fixed rather than covered.

**The net is not put back.** Its only remaining argument was code that does not exist yet, and this
system already has a rule about paying rent on a decision nobody has taken. What it never reached
anyway is in the list above: the grey tree links no stylesheet, and a cycle needs replacing rather
than shortening, which no duration can express.

### Reduction removes the movement and never the state, measured

The rule that matters more than any amount of unnecessary animation: an element that appears has to
appear under the setting too. Three sheets opened with `showModal()` at 390px, settled, both
engines, both settings:

| | normal, 60ms in | under the setting |
|---|---|---|
| sign in | `translate: 0 21.1%`, still climbing | `translate: 0 0%`, arrived, opacity 1 |
| deposit | `translate: 0 25.2%` | `translate: 0 0%`, arrived, opacity 1 |
| bet sheet | at rest, its own block sets `animation:none` | identical, on screen, opacity 1 |

**0 states lost.** The skeleton is the same answer in the other direction: the marks stop moving and
stay fully opaque rather than disappearing.

### What the token override will not reach

Three entries, and the section is closed. **The first arrived at step 3, by writing a rule rather
than by auditing for one.**

**1. A cycle, and it is the case the mechanism cannot answer.** Setting a duration to 1ms shortens
a transition into nothing, which is the whole point. Applied to a repeating cycle it does the
opposite: 1ms per period at infinite iterations is a **flicker**, worse than the still box the
skeleton shipped before this stage. A cycle is therefore REPLACED and never shortened.
`skeleton.css` carries its own block, and so does the demonstration pulse on `motion.html`, which
would otherwise have been the page explaining reduced motion while still moving under it. Measured
over three loading screens in both engines: 60 marks running at 1.4s infinite in the normal pass,
**0 running and every mark back at full opacity under the setting**.

**2. `wireframes/`, and it is 105 of 105.** The grey tree links no stylesheet at all, so no token
declared anywhere can reach it, and every one of its documents carries the same inline
`transition: transform .2s ease` on the navigation drawer. Measured with the setting emulated in
both engines: **105 elements above 1ms over 105 of 105 documents.** It is not repaired here. A grey
file cannot hold a token, the drawer moves so the tree can be clicked through, and the honest answer
is the seventh declared boundary difference in `wireframes/_conventions.md` rather than 105 copies
of a media query. Written at step 6.

**3. The thirteen course documents, of which six move.** `research.html`, `voice.html`,
`concept.html`, `ia.html`, `seo.html` and `system.html` carry their own inline `<style>` and their
own literals: `.15s`, `.2s` and one `.4s cubic-bezier(.2,.7,.2,1)`. Same cause as the grey tree,
different tree: they link nothing from `components/`, so no token declared there can reach them.
They are the record of how this product was made rather than the product, and the honest answer is
the same one: named here rather than repaired by copying two numbers into six more places.

**4. A cross-document view transition, which is why there is not one.** It reads no duration token,
so choosing it would have bought a fourth entry for this list in exchange for a cross-fade. Ruled
out at step 4 for four reasons, of which this was the third.

**And nothing else, proven rather than assumed.** See the next section.

---

## 6. Components

Done 2026-08-15. Three rounds bottom up, atoms then molecules then organisms, and **the rounds were
not gated separately** because the same 69 declarations are one rewrite: stopping between levels
would have left the tree half tokenised with five aliases still standing, which is a state nobody
could have reviewed usefully.

### The rewrite: 69 declarations read, given a job, and moved

| from | reads | to | why |
|---|---|---|---|
| `--dur-quick` .16s | 41 | `--dur-fast` | every one a response |
| `--dur-base` .18s | 14 | `--dur-fast` | every one a response as well, which is why the middle rung had nobody to spend it |
| `--dur-slower` .3s | 5 | `--dur-slow` for the two arrivals, `--dur-fast` for the card | the card's was a hover at 300ms |
| `--dur-slow` .25s | 5 | kept for the dialog, the header band and the harness drawer; `--dur-fast` for the hero | the hero's two were hovers at 250ms |
| `--ease-out` | 8 | `--ease-enter` for arrivals, `--ease-standard` for responses | |
| `--ease-inout` | 1 | `--ease-enter` | its one reader is the harness drawer, an arrival, so the harness curve changed and the product's did not |
| bare `ease` | 53 | `var(--ease-standard)` | same value, now a token |

**The rewrite skipped every comment**, so the six historical quotes in `card.css`, `chip.css` and
`docs/decisions.md` still say what was true when they were written. A record that a later sweep
rewrites is not a record.

The five aliases were deleted in the same step, after the last reader moved.

### Seven components had a state and no movement, and six were given one

The gate held on all seven: the hover and the press were already declared with tokens in both
themes, so nothing had to be invented and no `.is-hover` was drawn.

| component | placements | what it does now |
|---|---|---|
| `navitem` | **1,068 on 105 screens** | the ground fades |
| `position` | **996 on 105 screens** | the plate's ground and edge, and the question's ink, on two selectors because the hover is written on the anchor above them |
| `state-block` | 152 on 38 | ground and underline |
| `footer` | every screen | ink and ground, and NOT the underline |
| `cookie-consent` | 33 on 2 | the policy link's underline |
| `toc` | 1 | the rail's rows and its head |

**The seventh is the focus ring and it stays instant, deliberately.** A ring is the answer to
"where am I", and a person moving through a form at speed passes an element in less time than any
duration worth writing. A ring that fades in is a ring that is not there yet when it is needed.

### The status job, performed for the first time

`skeleton.css` gained `sk-pulse`, an `opacity` cycle at `--pulse-period`, on all 482 marks across
19 loading screens. Opacity because it is one of the two properties a browser animates without
laying the page out again, and a loading screen is exactly where twenty cards pulse at once.

`--pulse-period` is 1.4s and it is **not** a third rung of the duration ladder: the two durations
answer "how long does this change take" and a period answers "how often does it come round". It
carries a different prefix so the next reader reconciling the ladder does not find a number on it
that was never on it. The amplitude is shallow on purpose, .55 of full: a plate that goes nearly
invisible reads as content flashing in and out rather than as a wait.

### The status job had a THIRD moment and this register could not see it, 2026-08-16

The inventory above counts **2 status moments** and names them: the skeleton, and `edge-fade`. There
were three. The third is `.quiet-box`, and it is the only moment in this product that is
definitionally "a process is running with an unknown end": an OAuth redirect, a third-party KYC
widget, a share card being generated, a resolution being fetched, a bet being registered on-chain.

**It was invisible to the inventory because the inventory was built out of what already moved.**
Step 1 swept the corpus for `transition`, `animation`, `@keyframes` and for declared STATES, and
`.quiet-box` had none of the four: it is a dashed rectangle with a sentence in it. A moment with no
existing movement and no state to hang one on cannot be found by a sweep that looks for movement or
state. **This is the twin of "a rule that cannot be rendered cannot be checked": a moment that is
already still is invisible to an instrument that measures stillness against motion.** What found it
was reading the placements of a component whose NAME made a claim, and checking the claim.

Closed the same day. `.quiet-box.qb-working` carries a 2px rule at `--size-2` sweeping across the
top edge, on **`--pulse-period`, the same period as `sk-pulse`**, because a spinner and a skeleton are
one job in two geometries and Stage 11's rule is that the same role takes the same duration. The
curve is `--ease-standard` over `alternate`, which slows the sweep at each end: a rotation would have
needed a fourth curve, since `ease` on a rotation reads as a stall and a stall is the one thing a
status must never say.

It is a COMPOUND selector, and the placement count is the reason. `.quiet-box` is worn by nine
placements and only five of them are running: two are an out-of-band pending state where nothing is
happening in this tab, and **two are a prototype disclaimer that will never finish**. A cycle on the
bare class would have drawn a progress bar on a legal notice.

Measured on both engines at 390: `translate` moves 0 to 77-82 per cent inside 350ms, the bar is 2px
tall and 34 per cent wide, and the four quiet placements gained no `::before` at all. Under
`prefers-reduced-motion` the animation reads `none` and the bar becomes the full 346px width, still:
**the cycle is REPLACED, never shortened**, the same ruling `sk-pulse` took, because 1ms on a sweep is
a flicker.

And the four screens carrying it announced nothing before this: `deposit-loading`, `sign-in-loading`,
`win-loading` and `loss-loading` had 0 `aria-busy` and 0 `role="status"`. The status-line pass of the
same morning walked `[aria-busy="true"]` regions, which is why it reached 19 loading screens and not
these four. The box is now the live region itself.

### What the product renders after step 3

| | before the stage | after step 3 |
|---|---|---|
| moving elements, 105 screens | 4,904 | **9,084** |
| distinct durations | 5 | **2**: `.16s` 19,349 slots, `.25s` 171 |
| duration literals in the system | 0 | **0** |
| bare easing keywords | 54 | **1**, declared |
| `transition: all` | 0 | **0** |
| animations rendering on load | `edge-fade` 90 | `edge-fade` 90, **`sk-pulse` 482** |
| components with movement | 20 of 47 | **26 of 47** |

Chromium 151 and WebKit 26.5 agree element for element on every line. 163 documents, 231,872
readings, 0 page errors and 0 HTTP errors.

**The 1 was published as 0 and the closing audit corrected it.** `catnav.css` writes
`animation-timing-function:linear` on the scroll-driven edge fade, and the check that produced the
zero read `transition:` and `animation:` and never looked at a longhand. The keyword stays, because
a scroll timeline maps progress from distance and the identity is the only correct function there,
and a `--ease-linear` read once would be a name for a constant. What changed is that the reason now
stands beside the rule and the number is right. **A check that reads the shorthand and calls the
answer a property of the family is the same defect as a count taken by matching a string.**

## 7. Patterns, screen states, and the crossing between documents

Done 2026-08-15. Two of the three parts came back empty, and in both cases the emptiness is the
answer rather than a gap.

### A state is a PAGE, so state transitions do not exist in this product

This is the structural fact the whole step turns on, and it is not a shortcoming: `wireframes/`
and `ui-visual/` both hold one document per state, 105 each, by a convention that predates this
stage. Loading, empty, error and success are therefore not things a screen moves INTO. There is
nothing to transition, because there is no before and after inside one document.

The one state family that does live inside a document is the dialog, and the tone check is
therefore a check of five surfaces rather than of twenty.

### The tone check, four quantities against the line the screen says

`voice.md` sets the tone and this stage does not get to choose it. Four quantities: duration,
curve, amplitude, direction.

| state | the line the product says | duration | curve | amplitude | direction | verdict |
|---|---|---|---|---|---|---|
| loading | silent, a skeleton grid; or "Registering your bet on-chain..." | 1.4s period | `--ease-standard` | .55 opacity, no movement | none | **agrees.** The rule says stay silent or name the thing, never hype. A shallow, even breath with no travel is the visual form of staying silent |
| success, the win | "You were right. +$13.16." then "See next events" | `--dur-slow` .25s | `--ease-enter` | 100 per cent translate, one axis | up from the bottom edge | **agrees, and this is the row that mattered.** Principle 4 is "mark the win without lighting a fuse", and the anti-example is confetti. The win dialog arrives on exactly the same rule as every other sheet |
| the loss | "Here's what happened: ... The market resolved NO; you held YES." | `--dur-slow` .25s | `--ease-enter` | same | same | **agrees, and it agrees BY BEING IDENTICAL to the win.** Two different arrivals would have been the product having an opinion about your result before you had read it |
| error | "Couldn't load your wallet. Your funds are safe; this is a display issue." | none | none | none | none | **agrees.** The rule asks for short, no spring, small amplitude. Every error surface here is a page, so the amplitude is zero, and nothing is calmer than nothing |
| empty | "No active bets yet. ... Find an event you have an opinion on." | none | none | none | none | **agrees.** The rule against a shift from below is about not implying that something is still loading, and a page that simply IS cannot imply it |

**0 disagreements, and `microcopy.md` was not edited.** The one row that could have gone wrong is
the win, and it did not, because the rule that would have broken it was written down five stages
ago and the dialog never had a face of its own.

### Patterns: 0 of 6 get a transition, and the reason is what the rung IS

Read from the comment-stripped source of all six files: **not one of them declares a selector that
toggles at run time.** `action-bar`, `card-grid`, `detail-shell`, `list-head` and `position-list`
have no state selector at all; `browse-shell` has one, `:not(:has(.subcat,.toc))`, and that is a
structural presence check rather than a state that changes while a person looks at it.

So the composition never recomposes, and **an arrangement that never changes has nothing to move
between**. This is the same invariant that keeps colour out of `patterns/` seen from the other
side: a rung that carries arrangement only carries the movement of an arrangement only, and there
is none. 59 declarations over 16 properties, and after this stage still 16.

### Ruling 1: the crossing between documents is A, and B is one line away

Every screen here is a separate HTML document, so the move from a feed card to its detail, or
from a bet to its result, is a navigation and an ordinary `transition` cannot see it. Three honest
answers exist and one had to be chosen out loud.

**Measured before choosing**, because "the browser probably supports it" is not a reading.
`@view-transition { navigation: auto }` on two test documents, navigated for real, listening for
`pagereveal` and asking whether the event carried a `viewTransition`:

| | over `http://` | over `file://` |
|---|---|---|
| Chromium 151 | **fires** | **fires** |
| WebKit 26.5 | **fires** | **does not** |

**The answer is A, do not animate the crossing**, and the reasons are in this order:

1. **`navigation: auto` alone is a cross-fade, and a cross-fade points at no source.** The
   connection job is defined as showing WHAT came from WHAT, and the direction of the movement is
   how it says so. A page dissolving into another page says only that a page changed. That is
   movement with no row of the inventory behind it, which is the one thing this stage cuts.
2. **Doing the job properly is a markup change in two trees.** It needs `view-transition-name` on
   the card and on the detail head, the name has to be unique in its document, and a feed ships up
   to nine cards, so the name would have to be written at click time by a script. That is a real
   piece of work and it deserves a row rather than a side effect of a one-line rule.
3. **It would add an obligation the token mechanism cannot reach.** A view transition does not
   read `--dur-fast`, so reduced motion for it is a separate rule, and this stage already has one
   such case and does not need a second bought for a cross-fade.
4. **On the path this repository actually reads from, one engine of two would not show it.** These
   pages are opened from disk, and WebKit does not fire the transition there, so half the reviews
   would be of a feature the reviewer cannot see.

**What would change the answer**: a named pair with a measured before and after. The line is
`@view-transition { navigation: auto }` in `base.css` and nothing else, and the measurement above
is banked so the decision does not have to be re-taken from zero.

### Ruling 2: a rung does not animate its own rebuild

Default and answer are both no. A width rung fires while a person is dragging the edge of a
window, which is the one moment they are not looking at the content, and animating a grid rebuild
is expensive on exactly the device that can least afford it.

The exception the stage allows is a surface that did not exist at the narrow width at all, and
this product has two: `.bet-panel` arrives at the DETAIL rung and the `.subcat` rail at RAIL.
**Both are refused on the same ground**: they arrive only during a drag. A person who opens the
product on a desk never sees the arrival, and a person on a phone never has it. An entrance
nobody can be present for is not a connection, it is a frame that happens to differ.

### Ruling 3: the scroll edge fade is an affordance and stays out of the register

`edge-fade` binds a mask to the element's own inline scroll rather than to time. It has no
duration, it reads no duration token, and **it is driven by the reader's own hand**: it changes
only while they scroll, and it changes by exactly as much as they scrolled.

Measured on the feed's category rail, both engines, both settings, at three scroll positions:
the left edge closes from `0px` to `32px` and the right opens from `calc(100% - 32px)` to `100%`.
**The mask moves with the scroll in all four combinations, including under `prefers-reduced-motion`
where the blanket net forces `animation-duration` to `1e-05s`**, because a scroll timeline maps
progress from scroll distance and not from that duration. It therefore needs no reduced-motion
treatment and gets none: movement a person makes themselves is not movement done to them.

So it stays, it stays OUT of the inventory of moments, and it is declared here instead. None of
the three jobs describes it, and rather than widen the three to fit one rule, the rule is named as
what it is.

**And the first reading of it was wrong, in this stage's own signature way.** The probe set
`scrollLeft` and read the computed mask in the same task, and reported the mask identical at every
scroll position in both engines, which reads exactly like a feature that does not work. Two
animation frames later the same probe reports it moving in all four combinations. **A scroll is not
a paint any more than a resize is a layout**: give the engine a frame, or measure the state it left
behind.

---

## 8. Two rulings this stage owes

**The grey tree and motion.** `wireframes/_conventions.md` declares six differences that ARE the
boundary between the trees rather than drift: the plate wrappers, the icon mechanism, the
photograph, the chart data, the `TBD` chip, and the page behind an overlay. **Motion is not among
them.** The paint takes its durations from tokens and the grey tree keeps one literal `.2s ease`
on its drawer, so from this stage the two trees disagree about something nobody declared, which
is drift by that document's own definition. It is a seventh declared difference, not a port: a
grey file links no stylesheet and therefore cannot hold a token, and its drawer moves so the tree
can be clicked through. Written at step 6.

**Motion at a rung.** Ruled at step 4, section 7: no, including for the two surfaces that qualify
for the exception, because both arrive only during a drag and nobody is present for them.

## 9. One arrival added 2026-08-17, and it is the register's first `animation` that is not a cycle

The search surface arrived on that date and it needed exactly one movement, of the three jobs an
**arrival**: the desktop panel says where it came from by coming from there.

| what | job | how | why not a transition |
|---|---|---|---|
| `.search-panel` | ARRIVAL | `search-panel-in`, `--dur-fast` `--ease-enter`, from `opacity:0` and `translate:0 calc(-4px * var(--motion))` | the panel is `hidden` when it is shut, and a `display` change has nothing to interpolate from. Every other one-shot in this system moves between two rendered states; this one moves between not-being and being |

**The travel and the direction are borrowed rather than chosen.** 4px down, exactly what
`header.css` gives the notification and account dropdowns, because the panel is the same gesture in
the same band: a surface that came out of the control above it. A second number would have been a
second answer to a question already answered.

**Reduced motion keeps the panel and loses the movement**, through `--motion` on the travel, and the
opacity stays. That is the stage's own rule that an element which appears still appears; the
register's earlier note about cycles does not apply, because this is a one-shot and 1ms is a correct
answer for a one-shot where it is a wrong one for a pulse.

**The sheet takes no movement at all, and that is a decision rather than an omission.** A
`<dialog>` opened with `showModal()` on a phone is the whole screen; a 150ms slide on a surface that
covers everything is a delay between a tap and a keyboard, and the keyboard is what the person came
for. The three jobs were read against it and none of them was namable: nothing is being connected
that a full-bleed replacement does not already say, no process is running, and the response was the
tap being accepted, which the sheet's own appearance is.

---

## 10. The census this stage never ran, 2026-08-17: a state that CHANGES and does not move

Reported by the user, on a screenshot of the category strip, and the report was right. Every
instrument section 1 describes reads a DURATION and groups it by role, which is the right question
for a product whose controls all move and the wrong one for finding a control that does not: **a
component with no `transition` at all contributes no rows to a census of durations.** So the stage
that unified 69 declarations and named six silent components could report `0 duration literals, 0
`transition: all`, one role one number` and be true about every one of them while the loudest chip
in the product changed six properties on the frame the pointer arrived.

**The question that finds it is the inverse and it is one line: does the element that CHANGES a
painted property have that property in its own `transition-property`?** Read from the browser's own
parsed rules crossed with computed style on every placement, over the 109 painted screens.

### The reading

| | declarations | property-on-placement readings |
|---|---|---|
| **snapping: a painted property changes with nothing between the two states** | **77** | **20,864** |
| state set at parse time and never changed again | 33 | 12,238 |
| `text-decoration-color` reached by the `text-decoration` shorthand | 6 | 5,758 |
| the focus ring, refused by this stage at step 6 | 12 | 3,178 |
| the phone filter sheet, an arrival drawn by `display` | 15 | 1,012 |
| `.ed-main > .card`, `transition:none` on purpose | 6 | 72 |
| the review harness, which is chrome and not the product | 17 | 49,486 |

**A reading is one property on one placement**, so a chip whose hover moves a ground, four border
sides and an ink counts six. After the pass: **6 declarations and 654 readings**, all of them one
control, and it is refused below.

### What it was, and it was two of five faces rather than a component

Twelve component files, and the shape repeated in three of them: **the ATOM wrote no response and
some of its FACES wrote their own.** `chip` had it on `rail` and `amount` and not on `quiet`, `nav`
or `lane`; `iconbtn` had it on `tile`, `lift`, `photo` and `bal-add` and not on the plain circle or
the bare mark; `button` had it on `.btn` and then `.btn-provider` **replaced** the list to add a
`transform`, which is what a `transition` declaration does, and the three sign-in buttons on every
screen lost `color` and `box-shadow` from it. In all three the fix is the same and it is smaller
than what it replaces: **one declaration on the atom, the union of everything any face changes, and
the face keeps only what it CHANGES.** `chip` 19,130 readings to 240, `iconbtn` 7,642 to 0,
`button` 334 to 0.

The other nine were controls nobody had ever given a response: the header's two disclosures and its
brand link, the notification list's footer, the footer's own lockup, the search seam, the skip step
in How it works, three kinds of tab, both faces of the compact outcome pair, the related row, the
three hero links, the breadcrumb, the sign-in fine print, the card's question, the panel's Change
link and the position plate's shadow.

**Verified by hovering, not by reading the file.** A real pointer on a reachable placement in both
engines, counting `transitionrun` by property, with a positive control proving the probe sees a
transition and does not see an element without one. Chromium and WebKit agree to the property on
every row. The probe fell into two traps first and both are worth keeping: **the first match of a
selector is usually the CHOSEN one**, whose face out-specifies its own hover so nothing changes, and
**an element under the sticky header takes no `:hover` at all**, so a reachability test has to come
before the reading.

### Four refusals, each with its reason

- **The focus ring, and the skip link with it.** 3,178 readings plus the 654 that survive the pass.
  Section 6 already decided the ring stays instant; the skip link is the same control in a different
  place, a thing that has to be fully there the moment a key is pressed. A ring that fades in is a
  ring that is not there yet when it is needed.
- **A state written into the document and never changed.** `[aria-current]` on a nav slot, the
  category chip's count, a selected option row, the contents rail's number, the chart legend. **A
  transition on a value decided at parse time is a rule that cannot render**, which is the one kind
  of rule nothing here can check. Where a control has both kinds, the live half pays for the
  declaration and the parse-time half rides on it for nothing: that is why the chip's chosen face
  fades and the count beside it does not.
- **`text-decoration-color`, 5,758 readings, and it is the instrument rather than the page.**
  `text-decoration:none` is a shorthand, so it sets a longhand that resolves to `currentColor`; the
  footer's 2,397 links declare it twice and draw no line at all. A census that reads a rule's
  longhands has to be read back against what the rule DRAWS.
- **The filter sheet**, which is the one finding a duration cannot close. `docs/backlog.md` 198.

### And the register did not change

Every declaration written today reads `--dur-fast` and `--ease-standard`, so the ladder is still two
durations and two curves and the token override still reaches all of them: **12 screens, both
engines, 1,473 moving elements per pass and 0 above 1ms under `prefers-reduced-motion`**, with an
injected `999ms` literal read back at 999ms first to prove the probe could fail.

---

## 11. The one thing the census could not close with a duration, and the duplicate it exposed

`docs/backlog.md` 198, filed and struck the same day.

**The phone's filter sheet arrived on one frame** - 1,012 property-on-placement readings over 44
screens, the ground, the top edge, the shadow and both labels at once - and no duration reaches it,
because the sheet goes from `display:none` to `display:flex` and `display` is discrete. Two
mechanisms were legal and the product already owns one of them.

| mechanism | verdict |
|---|---|
| `transition-behavior:allow-discrete` with `@starting-style` | correct, and used nowhere in this system. A second way of doing one thing is the cost |
| an `animation` on the open state | **taken.** `search.css` has done exactly this since the search panel was written, out of `hidden` rather than out of `display:none` |

### And the movement already existed twice

| file | name | property | readers |
|---|---|---|---|
| `dialog.css` | `sheet-rise` | `translate:0 100%` | 337 modal dialogs, below the desk rung |
| `betpanel.css` | `betSheetUp` | `transform:translateY(100%)` | 4 bet sheets, at every width |

Section 6 of this file had already written the sentence - *one job with two answers and two spellings
of the same 100 per cent translate* - and the stage unified the **duration**, which is what a census
of durations can see. **The third reader is what forces a duplicate**: closing 198 by writing a
third spelling would have been the drift growing. One `sheet-rise`, in `base.css`, on `translate`,
with three readers. `translate` because it is the individual property, so a movement written there
cannot clobber a `transform` an element carries for something else, and because 337 beats 4.

### A `@keyframes` name is document-wide and its DECLARATION is not

`sheet-rise` lived inside `dialog.css`'s `max-width:39.99875rem` block. The name is global, but it
only exists while that block applies, so above the desk rung it was **absent**: any second reader
outside the query would have resolved to no animation at all, with no error, at every width but one.
A condition belongs on the rule that APPLIES a movement, never on the declaration of the movement
itself. Both keyframes are unconditional in `base.css` now and all four call sites keep their own
scope, which is the same argument the focus ring and the 44px touch floor are kept by.

### Measured, both engines, identical to the value

| | normal | `prefers-reduced-motion` |
|---|---|---|
| filter sheet | `sheet-rise`, 250ms, starts `translate:0px 100%` with its top at 844 in an 844 viewport, settles at 578 | 1ms, still arrives at 578 |
| filter scrim | `sheet-appear`, 250ms, starts `opacity:0` | 1ms, still arrives |
| bet sheet | `sheet-rise`, 250ms | `animation:none`, its own rule, visible at 322 |
| phone modal | `sheet-rise` | 1ms |
| desk modal at 1280 | `sheet-appear`, `translate:none` | 1ms |

**An `animation` naming a keyframes that does not exist produces no animation at all**, so
`getAnimations().length` is itself the proof the name resolves after the move, and it reads 1 on
every sheet in both engines. Rest state proved unchanged by stripping comments, transitions,
animations and keyframes from all four stylesheets before and after: byte-identical, 4 of 4.

### And the probe read a document that is not there

The hover pass in section 10 reported the lane chip `NOT PRESENT` on `browse-politics.html` and it
was read as a reachability problem. It was a **404**: the browse screens are
`event-feed-politics.html` and its seven states. **A missing document and a missing control return
the same word.** Re-read on the file that exists, the lane chip fades five properties in both
engines. Check the response code before believing the census.

---

## 12. The fade that made a `z-index` inert, 2026-08-18, and the fifth defect a reader found first

**Every instrument in this record reads a duration, a curve, or a computed value at rest. Paint order
DURING a transition is none of the three.** A reader sent a screenshot of the feed's frequency menu
sitting under the first card and jumping on top a moment later.

`base.css` fades `details::details-content` from `opacity:0`, which is the connection job and is
right. **A box whose opacity is under 1 is a stacking context**, so for the whole 160ms of the
arrival `.filter-panel`'s `z-index:var(--z-menu)` was resolved inside that group rather than against
the page, and the group paints where the filter row sits in the flow, under `.card > *` at
`--z-content`. The declaration was present, correct and not applying.

Measured with `elementFromPoint` at each panel's own centre, over the whole set:

| | before | after |
|---|---|---|
| `filter-menu`, under on entry, Chromium | **14 of 14** | **0** |
| `filter-menu`, under on entry, WebKit | **14 of 14** | **0** |
| `filter-menu`, under on exit, Chromium | **14 of 14** | **0** |
| `avatar-menu` and `notif-menu`, either direction | 0 of 5 each | 0 |

**The two menus that were already clean are the fix.** `.app-header` is `position:sticky` at
`--z-header`, so its dropdowns open inside a group that already stands above the feed. The filter row
has no such group, so the menu had to become its own: the lift moved onto the `<details>` and the
panel's own number was deleted, because inside a menu that is now a context any positive value and no
value paint identically.

**Three decisions inside the fix, each measured rather than assumed.**

- **On `[open]`, not unconditionally.** A permanent lift raises the SUMMARY chip as well, and the
  chip scrolls under the sticky header, so it would draw over it. With the lift on `[open]` the
  closed chip reads HEADER on top at a 35px overlap and the open panel still reads PANEL on top at
  66px, which is the order `tokens.css` ranks.
- **The drop waits for the fade it is leaving.** `[open]` goes the instant the reader closes while
  `--dur-fast` still has to run, so the step carries `transition-delay:var(--dur-fast)` with the
  duration left at its initial `0s`. It is a delay, not a duration, and it is the only such
  declaration in the system.
- **`transition-behavior:allow-discrete` is what makes it work at all**, `z-index` going to `auto`
  being a discrete change, and without the keyword the drop was instant and the exit stayed broken.
  It is the same keyword `base.css` uses one rule away for `content-visibility`.

**And WebKit's zero on the way out is a different zero from Chromium's.** WebKit runs no closing fade,
hiding the content at once, so all 14 read `checkVisibility()` false. The first instrument could not
tell an absent panel from a covered one and reported both as covered, which is this record's own rule
about a zero having to say which zero it is, arriving one more time.

---

## 11. The step that could not move, 2026-08-20, and the two floors that said they held

Reported by the user, who asked for the how-it-works sheet to change its picture and slide its text
instead of swapping the whole dialog. **The block could not have moved as it stood**, and the reason
is one declaration: `.hiw-step` was `display:none` on two steps and `display:flex` on the third, and
an element that is not in the layout has no state to leave from. This register has one entry per
moving element and it has never once asked which moments have no element to move.

### What the measurement found before anything was written

Chromium 151 and WebKit 26.5, at 390 and at 1280, agreeing to the hundredth:

| | step 1 | step 2 | step 3 |
|---|---|---|---|
| sheet height | 563 | 586.5 | 610.5 |
| picture frame | 280 | **303.5** | 280 |
| Next, from the top of the step | 452 | **475.5** | 452 |
| dots | 512 | 535.5 | 559.5 |

**Two sentences in `components/hiw.css` said both of those were already handled.** The stage said
"280 is the tallest of the three stills plus its shadow, measured, so no still is ever clipped by
its own floor", and the bet panel on step 2 asks 303.5. The text said "the button that moves is not
the one a thumb is already aimed at: it is Next on one step and Create account on the other", and it
was Next against Next. **A `min-height` stops a box getting smaller and neither of these was
stopping one getting bigger**, so both arguments were sound and both were made about the wrong half.
The numbers in them, 552 / 641 / 506 and 39px, were true of a tree that had since moved: this is the
count rule from the root `CLAUDE.md` arriving inside a comment.

### The movement, and the job is ARRIVAL

| what | job | how |
|---|---|---|
| `.hiw-step-txt` | ARRIVAL | `opacity` `--dur-slow` `--ease-standard`, `transform:translateX(var(--hiw-shift))` `--dur-slow` `--ease-enter` |
| `.hiw-still` | ARRIVAL | the same transform at four tenths of the distance |
| `.hiw-stage` | ARRIVAL | `opacity` only, its own ground occluding the picture it replaces |
| `.hiw-nav` | none | `visibility`, switched with no transition at all |

`--hiw-shift` is one inherited length with three values, `+travel` for a step not yet reached, `0`
for the one being read, `-travel` for one already passed, and `--hiw-travel` is
`calc(var(--space-32) * var(--motion))`. **No new duration and no new curve**: the register still
holds two of each.

**The nav does not fade, and that asymmetry is why the pane itself carries no `opacity`.** An
opacity on the step would have taken the Next button with it, and two brass buttons crossing at half
opacity composite to 75 per cent, so the one element a thumb is aimed at would dim on every press.
Measured through the flight in both engines: the leaving nav is `hidden` on the first frame and the
arriving one `visible` on the same frame, so the button and the three dots are the fixed thing the
rest of the sheet moves against.

**`visibility` rather than `display`, and it is not a preference.** It is the one value that keeps an
element out of the tab order and out of hit testing while still letting it animate, and its
interpolation is the shape this needs: arriving, visible on the first frame; leaving, visible until
the last. Three panes are in the layout at all times and one is reachable: measured, the sheet
offers 1 focusable anchor or button on steps 1 and 2 and 3 on step 3, which is exactly the live
pane's own.

### Reduction removes the movement and keeps the state, measured rather than assumed

With `prefers-reduced-motion: reduce` asserted in the page, `--hiw-travel` computes to
`calc(32px * 0)` on all three panes and every transform reads `matrix(1, 0, 0, 1, 0, 0)`, while
`--dur-slow` is 1ms and the arriving step still arrives: `visibility:visible`, `opacity:1`, and the
geometry unchanged. That is the stage's rule holding one more time.

### The instrument, and what it caught

Mid-flight sampling on both engines, every frame for 300ms: the leaving text runs 0 to -32 and 1.00
to 0.00, the arriving text 32 to 0 and 0.00 to 1.00. **The travel was proved before the geometry was
believed**, because a probe that reads only the settled state cannot tell a movement from a swap.
The control read each step twice unchanged and differed on 0 rows in both engines with transitions
frozen, and **the two rows where it did differ were the instrument**: under reduced motion the freeze
was deliberately not injected, so a 1ms `visibility` transition was still in flight one frame later
and Chromium reported the previous pane. The geometry was constant in those rows too.

Height over both trees against HEAD, 356 renders on 178 documents at two widths: **6 rows moved and
all six are the three kit pages that stand the stepper open**, which are the three that had to.
**0 of the 116 painted screens**, because a `<dialog>` with no `[open]` draws nothing. 0 sideways
scroll, 0 duplicate ids.

### What it cost

The sheet is **634.5 tall at 390 on every step**, against a previous tallest of 610.5, so 24px is
the price of a frame that cannot resize and a control that cannot move. 23.5 of it is the picture
frame taking the height its tallest still already needed, and the rest is the reserved row under the
button. **The grey tree does not follow on the movement**, because motion is the seventh declared difference
between the trees and `wireframes/` links no stylesheet. **The geometry half was filed rather than
assumed, and answering it the same day split it in two**: the reserved row and the text floor are
structure and went into all 97 grey documents that carry the stepper, and the frame did not, because
a grey still is one dashed box and the grey stage was **already 180 on all three steps**. The table
is in `wireframes/_conventions.md` beside the seventh difference.

