Hour 19:40
Waking track
The night is already decided before the shift starts
Nika Ferrand is a gate technician on a rotating roster at a container terminal. She is fictional, and so is every number on this page. What is not invented is the shape of her problem: her working hours move, so the only sleep she can plan is sleep placed relative to a shift she has not worked yet. Tonight she is on Night gate, 22:40 to 07:10, and the window she will actually sleep in opens at 07:55 tomorrow morning with the sun already up.
Aster Shift is a six-screen critical slice of an app for that person. It takes one concrete shift, derives the sleep window that shift produces, reads three biometric values if it is allowed to, offers three timed recovery actions before the window opens, and runs one of them. That is the whole product. There is no marketing surface in the build, no all-state menu and no debug toolbar — a state the test harness can reach is a state a user can reach, because there is no other way to reach anything.
The hard part is not the arc. It is the restraint.
A sleep app is one wrong sentence away from being a medical instrument. It has heart-rate numbers, a night's worth of sensor data and a user who is tired — every incentive points at a score, a percentage, a streak and a verdict. The interesting design work here was deciding, slot by slot, what the product is entitled to claim, and then finding a shape for the claim it is not allowed to make. Most of what follows is about that.
- Not a ring dashboardThe primary visual is a 250° open arc carrying 24 real hours. It has no completion percentage, no score inside it and no closed circle. A ring reports how much of a target is done; this arc reports where the night is.
- Not a calendarA shift is confirmed onto a continuous 24-hour rail, where a block's position and length are the information. There is no month grid and no calendar cell anywhere in the slice.
- Not a streak productNo consecutive-day count, no missed-day marker, no badge, no congratulation and no reproach appears on any screen. The check-in can be skipped and nothing is said about skipping it.
- Not a recurrence formEntry starts from one concrete shift. There is no repeat field and no roster template, because a recurrence field presented first is a tax on the user whose hours do not recur.
- Not a medical instrumentEvery sentence about a reading is an observation against the user's own recent nights, or an offer of an action. No sentence asserts a cause, predicts an outcome or names a condition.
Hour 22:40
Shift begins
The date changes inside the range, so both dates are printed
This is the smallest decision on the page and the one that most separates a night-shift product from a generic sleep product. A shift that runs 22:40 to 07:10 does not end on the day it started. Almost every date range in almost every app prints one date and a pair of times, which is correct until the range crosses midnight and then quietly wrong.
Aster Shift prints the second date whenever it differs, and sets it in cyan — the colour it uses nowhere else on that surface. The rule is conditional, not decorative: set the end to 23:50 and the second date disappears again, on the same sheet, with no reload. The build carries a public state for each half of that rule so the conditional is proven rather than asserted.
The other decision on this sheet is where the cost is stated. The sleep window a shift produces is not a confirmation-screen fact; it is the reason a person is choosing these hours at all. So it is printed on the wheel sheet itself, above the button, before anything is committed — together with the rule that produced it.
Sleep window after this shift
07:55 → 13:25 · 5 h 30 m
It opens 45 minutes after the shift ends. Move it later on Today.
Product copy · shift-setup-wheel-end-cross-midnightThe wheel is a React Aria ListBox per column inside a Modal + Dialog, so it is fully operable from a keyboard: sixteen ArrowDown presses move the hour column from 07 to 23, and the product agrees with the change — the range line reverts to one date as it passes.
Hour 23:00
Departures
Six departures, three diversions
Every transition in the slice has exactly one decided trigger, written down before the build and unchanged by it. Start has no automatic path into the guided session and the session has no automatic path out: nothing begins audio without T5 and nothing returns to Today without T6.
-
T1
Shift Setupto Permission Rationale
The wheel sheet is on its end-time step with the range reading 22:40 → 07:10; the user activates Save shift. The sheet dismisses, the rail behind it draws the committed block, and the rationale opens full-bleed over that rail.
-
T2
Permission Rationaleto Today
The user activates Allow health access and the platform grants the read. Today opens with the arc drawn from three nights of readings.
-
T3
Todayto Check-in
The user activates Start check-in in the recommendation region. The check-in opens as a sheet over a dimmed Today, which is never destroyed.
-
T4
Check-into Recovery Plan
Question 3 has an answer, so See recovery options is enabled; the user activates it. The sheet's content is replaced by the plan and the tab bar returns.
-
T5
Recovery Planto Guided Session
Exactly one action is selected; the user activates Start. Every piece of chrome is removed and the selected action's cover fills the frame.
-
T6
Guided Sessionto Today
The session reaches 00:00 remaining and one closing question replaces the transport; the user answers it and activates Done. Today re-opens with the recommendation region rewritten to the sleep window.
Diversions
Three edge states, deliberately on three different screens so that no single screen carries the whole failure surface of the product. Each has a primary exit and a decline, and neither exit removes the thing that failed.
-
E1 · on Permission Rationale
The health read comes back empty
The screen is rewritten in place: an eyebrow naming the capability, a headline in the only amber text on the surface stating the observable fact, a paragraph giving two possible causes rather than asserting one, and a card listing what to change. No filled control appears, because none of the ways forward is a confident one.
- Exit
- Try again — the rationale returns to its default reading and a granted request opens Today.
- Decline
- Enter my sleep manually — the same wheel takes one sleep start and one sleep end, and Today opens with a manual source.
-
E2 · on Today
Fewer than three nights of data
The arc still draws. Its sleep segment is drawn at reduced confidence, the two-word state reads Still learning, the biometric block is replaced by one qualifying row, and no number is presented that the product cannot support. Degradation is a disclosure at the point of the claim — never a banner, never a blank screen.
- Exit
- The qualifying row is opened, the missing input is named, and its Connect a device re-opens the rationale.
- Decline
- The row is left closed. Today stays usable, the recommendation region still offers a check-in, and nothing is withheld.
-
E3 · on Recovery Plan, continuing into Guided Session
The session audio is not on the device
The failing action carries a quiet inline marker while its two neighbours keep normal affordances, so one missing download does not read as an outage. Activating Start opens a sheet attached to that action: the failure is stated in the only amber text on the surface, the retention rule is given as plain prose, and three ways forward are ranked. The sheet carries no filled control.
- Exit
- Try again — the download completes and the session starts with audio.
- Decline
- Play without audio — the session runs silent, with the missing audio stated once inside it.
One thing the tab bar is not
Shifts and Sessions are not flow steps. They re-enter screens 1 and 5 as standing surfaces, which is what makes those two screens reachable a second time without inventing a seventh screen. The bar carries exactly three destinations and every one of them is a screen this slice specifies. It is also the only fixed chrome in the product: every header scrolls with its content.
Hour 00:00
Midnight
What Aster Shift will not say
Four restraints were decided at the copy deck and then held everywhere in the build. None of them is a disclaimer bolted to the bottom of a screen; each is a shape the interface had to be built into.
1 · No confidence number, anywhere
There is no percentage, no index, no grade, no rank, no streak count and no benchmark in the product — not because they were suppressed at the end, but because the copy deck enumerates every number the product is allowed to print and none of those is a score. The numbers that survive are clock times, durations, two raw readings, and positional counters like 1 of 3.
2 · Every reading is relative to the person, never to a population
A resting heart rate of 58 bpm means nothing on its own and the product does not pretend otherwise. It is printed with the only comparison it has any standing to make.
Resting heart rate 58 bpm
3 bpm above your recent nights
Heart-rate variability 41 ms
inside your usual range
Sleep before your shift 5 h 20 m
1 h 10 m less than your recent windows
Three rows is the ceiling, and the panel sits directly under the arc it explains rather than behind a help layer. There is no global explanation screen in the product at all: every qualification is written next to the number it qualifies.
3 · When it does not know, it says so and keeps working
With one night of data the verdict is withheld rather than estimated. The two-word state reads Still learning, a 1 OF 3 chip appears on the qualifying row, and the arc still draws — because the sleep window comes from the shift, not from a reading, and withholding it would have been a second, invented failure.
Aster Shift compares a night with your own recent nights once it has three of them. It has one.
A connected watch would add heart rate, which is the reading it has none of.
Product copy · today-insufficient-disclosure-open4 · It names the limit of its own knowledge
The strongest sentence in the product is the one where it declines to diagnose itself. When the health read returns empty there are two plausible causes, and asserting either one would have been the easy, confident, wrong thing to write.
There are two possible causes. The read may have been declined on this device. The watch may also not have synced since 22:00 yesterday. Aster Shift cannot tell which.
Product copy · permission-deniedThe same discipline closes the recovery plan, which is the screen most likely to be read as a prescription:
Every reason above is something Aster Shift measured. None of them is a prediction.
Product copy · recovery-plan-defaultWhere the restraint is enforced, not just intended
Restraint written only in a copy deck decays. This one is checked: apps/aster-shift/tests/public-guardrails.spec.ts reads the source tree from disk and walks every rendered surface at runtime, asserting that no states menu, help overlay, reduced-motion toggle, debug panel, inspector, flow-step list or per-screen concept disclosure exists in the public build, at any viewport, in any state. Nine of the twelve QA-only states are reached through a Storybook qa prop and the other three by driving the real control; every one of those props is a generic token and never a state ID, so a QA identifier appearing in src/ fails the suite.
Hour 02:15
Three failures
Losing a capability without losing the product
The three edge states are the reason this slice is six screens rather than four. Each one was designed against the same rule: never resolve a failure by removing the thing that failed. Edge 1 keeps a route to the product without the capability, edge 2 keeps the arc, and edge 3 keeps the action.
The failure is scoped to the row that failed
When one of the three recovery sessions has no audio on the device, the other two keep their normal affordances and the marker on the third is quiet and deliberately not amber — at that point it is a fact about one row, not an outage. Amber is spent only when the user tries to start that row and the sheet has to state a real failure.
Declining the retry does not cancel the session. The action keeps running, a SILENT chip appears on its row, and the loss is named exactly once inside the session itself rather than repeated as a warning band.
Audio is unavailable offline. The timer is running and the session still runs its full 18 minutes.
Product copy · session-silentThe retention rule that caused the failure is stated in the same sheet, as prose rather than as a policy link: downloads stay on the device for 30 days and are removed 48 hours after a session finishes.
Eight of the twenty-eight public states are edge states
Two on Permission Rationale, two on Today, three on Recovery Plan and one on Guided Session. They are ● public rather than QA-only because a denied permission, a first day with no history and a session that will not download are part of the real night-shift experience — not debug surfaces. That leaves twenty public non-edge states.
Hour 04:00
The arc
250 degrees, left open
The signature element could have been a ring. Rings are what this category does, they are easy to draw, and they photograph well. It is not a ring, and the gap is the argument: a closed circle says a target is this much complete, and Aster Shift has no target and issues no completion. The band is open at the foot of the frame, carries twenty-four real hours of one real shift, and every hour on it is exactly one of three things — waking track in bronze, shift in amber, sleep window in cyan.
The two values inside it are the two-word state and the sleep window. Nothing else is printed in the bowl, and nothing at all is printed in the centre that could be mistaken for a score.
The second viewport is a reflow, not a scale
At 430 × 932 the arc is re-solved rather than zoomed: the centre moves from (195, 300) to (215, 322), the radius from 152 to 168, and the three stroke widths from 16 / 22 / 16 to 18 / 24 / 18. The rail pitch and the tab metrics change with them. Ten of the twenty-eight public states are measured at that width as well as at 390, which is how the difference stays honest.
The colour roles are bound to waking and sleeping, never to clock position
A QA-only state carries the argument: given a 06:00 → 14:30 day shift, the window the bowl prints becomes 22:15 → 05:15 and it is still set in cyan, because cyan means sleep and not "the small hours". The painted bands themselves are a fixed fixture in this slice — the arc is drawn from one night's model — so what the QA state proves is the role of the colour, not a re-solve of the geometry.
One gradient in the whole project
A single radial cyan bloom sits behind the arc, and it is the only gradient token that exists. There is no linear scrim, no fade band, no tinted card and no purple gradient anywhere in the product. The bloom's worst composited ground was measured at #162432 and every text run over it was scored against that value rather than against the flat token.
Hour 05:30
The take
One night, walked once
Just under twenty-six seconds, one take, no cuts and no speed change. The path crosses midnight, is refused a capability, is refused an audio download, and finishes the session anyway — because a shift product whose demonstration never crosses midnight and never loses anything has not been demonstrated. Every step goes through a control resolved by role and accessible name, using the same drivers the acceptance suite uses. The pauses exist so the screens can be read.
- Duration
- 25.96 s, measured with ffprobe (the §17 window is 20–30 s)
- Frame
- 390 × 844, VP8 in WebM, 25 fps, 1,251,975 bytes
- Captions
- 12 cues, written by the recording run off the same clock as the take; the recording is silent
- Covers
- Shifts rail → Add shift → the end-time wheel and the cross-midnight print → Save shift → rationale → a denied read → Try again → Today → three check-in questions answered from the keyboard → the recovery plan with one row not downloaded → the offline sheet → Play without audio → the silent session → the closing question → Today rewritten
- Editing
- None. One take, real time
Hour 07:10
Shift ends
The review it did not pass
Gate C round 1 failed. Nine of the ten scored axes cleared their thresholds, most by a wide margin — and composition came in at 82 against a threshold of 84, which no average is allowed to rescue. The finding was on the signature element, in roughly nine of the thirty-eight renders.
Two of the six hour labels collided with the band they were supposed to sit outside. 00:00's leading glyph was painted on top of the amber shift segment, in a dark grey chosen for the night ground, so it lost most of its contrast; 08:00's trailing glyph was cut by the round end cap of the sleep band, with the NOW needle crossing the same corner.
No detector could have found this
The arc is an SVG. The DOM harness that measures overlap, overflow, truncation, touch targets, dead space and contrast measures neither its text nor its paths — all six detectors reported the arc-bearing states clean, and they were right to, because there was nothing in the DOM to see. Gate B recorded that blind spot in advance and handed the arc geometry over for a human read at full size. This is what that read found. Every automated number on this page was green while the product's most important element was broken.
The cause was one geometric mistake, in two places
The spec places each hour label horizontally, 14 px clear of the stroke. The implementation measured that clearance from the label's centre along the tick's ray. A horizontal label is 34.11 × 14, so it eats 17.06·|sin θ| + 7·|cos θ| of the offset, and wherever the ray is steep that term is larger than the offset available — the label walks back onto the band. 04:00 and 16:00 escaped only because their rays are shallow. The fix takes the clearance from the label's near edge instead and extends the hour tick out to meet it, which is the leader-rule treatment the two shallow labels already read as.
- Label box against the painted band, 00:00 vs the amber shift segment, at 390
- −6.42 → +6.72
- The same at 430, where the arc geometry is different and the anchor is re-solved
- −6.44 → +6.72
- 08:00 vs the waking track, at 390
- −5.85 → +8.47
- 08:00 vs the NOW needle, at 390
- +2.24 → +14.50
- Renders whose DOM changed, of 38 — exactly the set that draws the arc
- 12
- Renders byte-identical to the pre-repair commit
- 26
- Thresholds moved · exclusions added · tests relaxed
- 0 · 0 · 0
The two rounds, scored
- product realism (≥ 85)
- 88
- 88
- visual hierarchy (≥ 85)
- 89
- 89
- composition (≥ 84)
- 82
- 86
- typography (≥ 82)
- 89
- 89
- reference pattern fidelity (≥ 80)
- 91
- 91
- brand specificity (≥ 80)
- 89
- 89
- responsive quality (≥ 80)
- 88
- 88
- generic-template risk (≤ 20)
- 10
- 10
- information density (unscored)
- 86
- 86
- interaction clarity (unscored)
- 90
- 90
- hard failures
- 0
- 0
Everything else held through the repair: 146 Playwright tests, 56 axe scans at zero violations, 24 Storybook tests with every play() executed, and the detector totals unchanged. One repair of the one permitted.
What is still wrong, and was left
Measuring all six labels rather than only the two the review named turned up the same geometry at two more. 04:00's line box sits −7.08 into the amber segment and 12:00's −4.92 into the cyan — by the box measurement 04:00 is in fact the worst of the six. Their ink is clear, because at those bearings the tick runs along the label's own baseline and reads as the leader; the review scored them as the correct treatment and authorised a repair at the other two only. They also cannot be given the near-edge treatment at this frame width: solving the same anchor puts 04:00 at x 398.03 in a 390 px frame and 12:00 at x −5.32. Clearing them would mean rotating the type, shrinking it below its token size or reducing the arc radius — each a design change beyond the authorised repair.
One more thing this repair cannot claim: LABEL_BOX_HEIGHT = 14 is the line box of the hour-label type as it renders in this build, measured rather than derived from font metrics, and no test pins it. Changing the hour-label size or family would silently make the anchor wrong. The constant carries that warning in source, which is weaker than a test and is recorded as such.
Hour 07:55
Sleep window opens
What was measured, and by what
Coverage here is walked, not claimed. Every ● public state is driven through the real build by a control the product exposes — resolved by role and accessible name — then photographed and measured by six DOM detectors. There is no QA route to any of the twenty-eight, because the build has none.
- 28public states, all enumerated and all reached
- 0unreachable
- 38renders measured — 28 at 390 × 844, 10 at 430 × 932
- 12QA-only states, in Storybook, outside src/
- 146Playwright tests passed, across both viewports
- 56axe scans — 28 states × 2 viewports
The six detectors, across all 38 renders
- overlap — intersection ≥ 1 px on the minor axis
- 0
- overflow — beyond the frame > 0.5 px, spill > 1 px
- 0
- truncation — an ellipsis or line clamp actually firing
- 0
- contrast — below 4.5:1, or 3:1 for large text
- 0
- contrastIndeterminate — runs that could not be measured at all
- 0
- touch target — hit area under 44 × 44 px
- 5 rows
- dead space — a void over 0.15 × the device height
- 11 rows
- thresholds moved, or exclusion-list entries used to reach a zero
- none
The two non-zero rows are stated rather than closed. All five touch-target rows are React Aria's own VisuallyHidden native inputs — 13 × 13 and 129 × 16 — sitting underneath controls whose real hit areas are asserted at 44 px or more by a separate test; they were deliberately not added to the decorative-selector list, because excluding them would hide the same measurement on a control that mattered. Ten of the eleven dead-space rows are the check-in sheet, whose sparseness the screen spec states as intent: the sparse zone is what makes the question readable. Nothing was invented to close them.
Where the states live
- 1 · Shift Setup 6 public · 2 QA rail-default · wheel-start · wheel-end-cross-midnight · wheel-end-same-day · rail-actual · rail-day-empty
- 2 · Permission Rationale 4 public · 1 QA rationale-default · denied · manual-source · rationale-returning
- 3 · Today 5 public · 3 QA default · reading-disclosure-open · insufficient-data · insufficient-disclosure-open · post-session
- 4 · Check-in 4 public · 2 QA q1-unanswered · q1-answered · q2 · q3
- 5 · Recovery Plan 5 public · 2 QA default · second-selected · offline-marked · offline-sheet · silent-selected
- 6 · Guided Session 4 public · 2 QA playing · paused · silent · closing-question
Accessibility, measured rather than asserted
axe-core 4.12.1 runs over every public state at both viewports — 56 scans — against wcag2a wcag2aa wcag21a wcag21aa wcag22aa plus axe's best-practice pack. No rule is disabled, no selector is excluded and there is no waiver list. The result is 0 violations at every impact: critical 0, serious 0, moderate 0, minor 0. Seven axe defects were found during the build — one critical, four serious and two moderate — and every one was repaired rather than waived.
Contrast on a dark product, read off the pixels
Three of the six screens paint type over a photographic ground, and a DOM walk cannot read the colour under that type. Those runs were pre-computed from the token ledger and then re-measured on the composited frame: the glyphs are painted transparent, the device is captured, and every pixel of the em band behind each line is scored against the type colour. The run takes the worst of them, against the same 4.5:1 and 3:1 thresholds — a measurement, not an allowance.
- Text runs measured on composited pixels
- 37
- Runs that could not be measured at all, by either method
- 0
- Worst measured ratio anywhere — SLEEP WINDOW, 11 px / 700, at 430
- 4.99:1
- Its requirement
- 4.5:1
- Best measured ratio — a session timecode over its own cover
- 19.77:1
The worst run on the whole product clears its requirement by 0.49, which is thin and is printed here rather than rounded away.
The keyboard, on a component system chosen for behaviour
Ten keyboard tests, green at both viewports, exist to prove the React Aria components were imported for what they do rather than for their markup. The hour column of the time wheel moves 07 → 23 on sixteen ArrowDown presses and the product agrees, flipping to its same-day state. A check-in question is answered with Home, End and two arrows, and answering by keyboard alone unblocks the CTA and clears its disabled reason. The session scrubber's End key runs the session to its closing question — the whole T6 trigger without a pointer. Escape closes the wheel and leaves the rail behind it operable, and twenty Tab presses cannot leave an open sheet.
Two divergences, recorded rather than smoothed over
React Aria derives the tab panel's id from the tab list, which makes the tab bar the first tab stop where the screen spec puts it last. That was accepted as a divergence forced by the component system rather than fought.
Three states are reached by a slightly different affordance than the inventory's own table describes — the first health read is denied by design and granted on retry, the recovery plan arrives with its offline row already marked, and the session's closing question is reached by driving the scrubber rather than by freezing a clock this build does not have. Each is written into the audit registry's note field.
Hour 13:25
Window closes
Sources, stack, and what was not taken
On the use of references
Before any of this was designed, a set of shipping mobile products was studied for structure only: how a navigation register changes with the level of commitment a screen asks for, where a disclosure sits relative to the claim it qualifies, how a permission request is framed when it has one purpose and one consequence, and how an empty state keeps its container. Those findings were written up as patterns and it is the patterns that were carried across.
No source brand, wordmark, copy line, dataset, taxonomy, image, layout trace or business rule was taken. No reference application is named on this page and no reference screenshot is published in this package — the private study material is excluded from the repository and from every artefact in it. Every raster in this package is either a capture of this build or an image commissioned for it.
The stack, as installed
- react-aria-components 1.19.0
- 20 exported components imported across 9 files under src/ — Modal, Dialog, ListBox, Slider, RadioGroup, Tabs, ToggleButton, Disclosure, Button, Heading, and the sub-parts each of those brings with it
- react-aria 3.50.0
- the portal provider, which 1.19 no longer re-exports from the components package
- react 18.3.1 · typescript 5.9.3
- tsc -b && vite build passes with 0 errors
- vite 5.4.21
- bundle 366 kB (111 kB gzip), CSS 32.5 kB (9.3 kB gzip); dev server pinned to port 5175 so no other app's harness can attach to it
- @playwright/test 1.62.0
- 146 acceptance tests, plus a separate audit harness with its own config
- @axe-core/playwright 4.12.1
- 56 scans, no disabled rule, no excluded selector
- storybook 10.5.4
- 6 suites, 24 tests, every play() executed in Chromium
- Manrope · IBM Plex Mono
- self-hosted in the product and again in this page; a written sentence is never set in the face that carries a measured quantity
Two specimens
The first is the repair from 07:10, quoted with its comment intact — the comment is the part that matters, because it is what stops the next person re-introducing the same mistake.
const LEADER_LABELS = new Set(['00:00', '08:00'])
/** Line box of an hour label at `tickMono` 11, measured off the render. */
const LABEL_BOX_HEIGHT = 14
/** Night ground kept between the painted band and the nearest glyph box. */
const LABEL_CLEARANCE = 6
/** …and between the band-side edge of that box and the end of the leader rule. */
const LEADER_GAP = 3
/**
* Smallest anchor radius at which a label held by its near edge clears the band.
*
* The box is `LABEL_BOX_HEIGHT` tall and centred on the anchor, and it runs AWAY
* from the arc, so the corner that reaches back toward the centre is
* `(anchor.x, anchor.y ∓ h/2)`. Solving `|corner − centre| = outer + clearance`
* for the anchor radius gives the root below; `|cos θ|` covers both halves of the
* sweep, because the binding corner is always the one on the centre's side.
*/
function labelAnchorRadius(outer: number, bearingDeg: number): number {
const cos = Math.abs(Math.cos((bearingDeg * Math.PI) / 180))
const h = LABEL_BOX_HEIGHT
const t = outer + LABEL_CLEARANCE
return (h * cos + Math.sqrt(h * h * (cos * cos - 1) + 4 * t * t)) / 2
}
The second is the fixture that holds the readings. Every comparison string is stored beside its value, and there is nowhere in the type for a score, a percentile or a trend — the restraint is in the shape of the data, not only in the copy.
export interface Reading {
name: string
value: string
comparison: string
/** one sentence for the accessible name */
sr: string
}
export const READINGS: Reading[] = [
{
name: 'Resting heart rate',
value: '58 bpm',
comparison: '3 bpm above your recent nights',
sr: 'Resting heart rate, 58 bpm, 3 bpm above your recent nights.',
},
// …heart-rate variability, and sleep before the shift, in the same shape
]
What is in the package
- Product prototype — source, entry apps/aster-shift/src/main.tsx
- 6 screens
- Storybook — apps/aster-shift/stories/, outside src/
- 24 tests
- Screen stills, byte-identical copies of the Gate C round-2 captures
- 7
- Interaction video, one unbroken take
- 25.96 s
- Task-flow diagram — the departure board at 23:00, in HTML
- 6 + 3
- State coverage and accessibility summaries — at 07:55
- 38 renders
- Full accounting of every path and measurement
- PACKAGE.md