A single band of light curving through the dark, cool turquoise at one end and warm amber at the other.

Night log 03 · Mobile app · Planning and recovery for an irregular roster

Aster Shift

One night, read forward from 19:40 to the afternoon after it.

Six screens, three edge states and twenty-eight public states, built as a running React Aria prototype rather than a picture of one. The product exists to place a sleep window around hours that move, and almost every design decision in it is a decision about what not to say. This page runs down the clock of the night it was built around, and the colour of each hour is the colour that hour carries on the product's own arc.

The shift
Fri 13 Mar 22:40 → Sat 14 Mar 07:10
Surface
6 screens · 3 edges · 28 public states
Component system
React Aria Components 1.19.0
Measured
38 renders · 146 tests · 56 axe scans
Accepted
Gate C round 2 · 1 repair spent
Two faces
Manrope · IBM Plex Mono

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-midnight

The 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.

The Shifts rail dimmed behind a New shift sheet. The sheet shows Night gate selected, Start 22:40 and End 07:10, a range line reading Fri 13 Mar 22:40 to Sat 14 Mar 07:10 with the second date in cyan, the derived sleep window beneath it, two scroll wheels for hour and minute, and a filled amber Save shift button.
Still 1 of 7shift-setup-wheel-end-cross-midnight at 390 × 844, device scale 3. Unframed and flush to the edge of the frame: the capture's own ground is the same night as this page's, so the screen has no boundary to draw.

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Today with the qualifying row expanded. Under the heading AGAINST YOUR OWN RECENT NIGHTS are three rows: 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.
Still 2 of 7today-reading-disclosure-open at 390 × 844, device scale 3. The panel opens under the arc it qualifies, and closes the same way; the arc behind it never moves.

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

Product copy · today-reading-disclosure-open

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-open

4 · 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-denied

The 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-default

Where 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.

Permission Rationale rewritten after a denied read: eyebrow HEALTH ACCESS, an amber headline reading Aster Shift received no sleep and no heart data, a paragraph naming two possible causes, a WHAT TO CHANGE card with three numbered lines, and three routes — two outlined, Try again and Enter my sleep manually, then a plain-text Not now. Today with one night of data. The arc still draws with its sleep segment at reduced confidence, the two-word state reads Still learning, and the expanded qualifying row carries a cyan 1 OF 3 chip above the lines explaining what the arc was drawn from. Recovery plan with Long body scan selected and carrying a cyan SILENT chip. Its reason line reads Running without audio. The timer and the visual are unchanged. The filled amber button beneath reads Start, 18 min.
Stills 3–5 of 7permission-denied · today-insufficient-disclosure-open · recovery-plan-silent-selected, at 390 × 844, device scale 3. Laid contiguously and unframed: on this ground the three screens run into one band of night, which is the point — the failures live inside the product, not in a separate error surface.

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-silent

The 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.

Today at its default reading. A 250-degree open arc fills the upper two thirds of a near-black screen: a bronze waking track, an amber band for the Night gate shift, a cyan band for the sleep window, six mono hour labels around the outside and a lavender NOW needle at the lower left. Inside the bowl, Short night, then SLEEP WINDOW 07:55 to 13:25, 5 h 30 m. Below, a collapsed row reading How tonight was read, then a recommendation region ending in a filled amber Start check-in button and a three-item tab bar.
Still 6 of 7today-default at 390 × 844, device scale 3. Amber is the waking side and is the only filled control on any screen in the product; cyan is the sleep side and every informational chip; lavender is reserved for the things that must be read — the NOW needle, the focus ring and the consequence label.

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
A band cut from the Guided Session: soft horizontal bars of warm amber light lying across a dark room, with no interface chrome over them.
Still 7 of 7session-playing, cropped to a band. This is the one screen with every piece of chrome removed: one close glyph, one title, one transport row, and the commissioned cover filling the frame. Elapsed time runs at the left, remaining time at the right and signed negative, which is the platform idiom for a running session and is kept because it carries meaning rather than because it is familiar.

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
00:00, upper right — repaired. The leader rule runs 7.63 px past the band's outer edge onto the night ground.
08:00, lower left — repaired. The NOW needle's clearance improved from 2.24 px to 14.50 px.
Both windows are cut from still 6 at the capture's own device resolution — one image pixel to one page pixel, so this is the 3× read the review was scored from, not a re-render.

The two rounds, scored

AxisR1R2
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.

apps/aster-shift/src/components/CircadianArc.tsx — the hour-label anchor
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.

apps/aster-shift/src/lib/fiction.ts — the three readings
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