Components
Airmond paints one ground, in the member’s own scheme. Exactly one surface is allowed a world of its own, and only while it is live: the recording stage, because the question it answers — “can she hear us?” — is asked by people two metres away who cannot see a setting. The call is live too, so it takes the stage’s dusk, and so does Ada’s meeting tile, because the people looking at it cannot see the member’s setting; everything else on this page is drawn on the one ground. That rule is dated 2026-09-19 and it cost a black: see the call.
Stock shadcn/ui with the smallest possible delta: the system is a theme (tokens) plus a tiny set of extension tokens. Everything below is rendered purely with the CSS variables from dist/css/airmond.css — no component forks.
--confirm is the working accent for active states, focus, links and confirmation. Primary buttons carry ink text (ink on coral 5.76:1; white on coral 2.80:1 fails). Confirmation is blue, never green. Waiting-on-you is amber/gold, the one state allowed louder than the accent. Destructive is stock shadcn red — distinct from coral by depth. A mark that separates or encloses takes --rule, never --border, and it is measured on the surface it actually sits on: see the thread's marks and the four controls a member could not find. The secondary button above is stock shadcn's filled neutral and keeps its fill; the APP's own secondary control is an outline, for the reason that section measures. A wart this records rather than hides: on the web theme --disabled-fill and --secondary are the same hex (#EAF0F4), so Not now and Not yet above are told apart by the outline and the muted label rather than by their plates. On the app side the question does not arise, because the app's secondary has no plate at all.Feather (light)
Feather
Low flame (dark)
Low flame
The recording stage
This is the surface the one-ground rule makes an exception of, and the only one. It paints its own world edge to edge and does not follow the member’s scheme: while she is listening the ground is dusk; the moment the member goes off the record the whole screen repaints amber. That is the feature, not decoration: the phone is lying flat on a table, and “can she hear us?” is being asked by people two metres away, so the answer has to survive a glance across a room. A state whose colour depended on a setting nobody in the room can see would be a state two people could describe differently.
⚠️ The exception is the AMBER, not the dusk. The listening ground is the dark scheme, read straight out of it, so what this screen actually claims is the right to stop following the member’s setting while it is live and to repaint itself entirely when it stops capturing. The call takes the same dusk for the same reason and has no amber, because it has no off-the-record state to announce. Everything else on this page follows the member.
#171E2A needs a luminance of at least 0.138; clearing 3:1 on #E8A317 needs at most 0.111. Those windows do not overlap, for any colour that exists — a near-black would read on the amber and vanish on the dusk, and vice versa. So the stage never asks one colour to do both jobs: off the record a speaker is identified by their NAME and the capsule outline, and Stop is found by its ink LABEL rather than by its edge. Both are asserted as must-fail claims, which is honest about the measurement and still goes loud if either value moves.The two grounds
The rule: each ground carries its own foreground, and one ink is drawn at a few opacities rather than a second set of hexes. The quiet fill is that ink at 0.1; the hairline is 0.3 on dusk and 0.28 on amber. Secondary is the one tone that needs two numbers — 0.68 on dusk, 0.75 on amber — because the amber ink has a far lighter ground to survive, and a shared value cannot serve both. The dusk pair is not a third scheme: it is the dark scheme, read straight out of it.
Dusk — she is listening
Amber — off the record
| Pairing | Dusk | Amber |
|---|---|---|
| Primary ink on the ground | 12.61:1 | 7.43:1 |
Secondary caption (ink at 0.68 → #9DA2A9 on dusk, 0.75 → #4C4226 on amber) | 6.51:1 | 4.58:1 |
This is where the second bug was. Both grounds shared one 0.68, and on the amber that measured 3.91:1 — under the 4.5 body-text bar, for every caption on the screen: the tap hint, the privacy note, the honesty caveat under the controls, the count. The amber ink was being asked to be loud enough at full strength and quiet enough at 0.68 against a ground far lighter than the dusk. Fixed by splitting the token: 0.75 on amber is the lowest step that clears the bar (4.58:1; 0.74 measures 4.44 and misses), deliberately chosen over a rounder 0.8 because a secondary tone that stops receding has stopped being secondary. Dusk keeps 0.68 at 6.51:1 — it never had the problem.
The capture card
The rule: transparent with a currentColor-strength outline, never a filled card. A filled card would need a surface colour per ground and would fight the wash the whole screen just performed; an outline at the ink’s own 0.22 follows it for free. Radius 12, padding 12, a kind chip that hugs its label, the headline in the ground’s primary ink and the detail in secondary.
Dusk — she is listening
Amber — off the record
| Part | Colours | Dusk | Amber |
|---|---|---|---|
| Kind chip | confirm blue on its own 18% wash (dusk) / the stage ink on its own 12% wash (amber) | 5.33:1 | 6.07:1 |
| Headline | primary ink on the ground | 12.61:1 | 7.43:1 |
| Detail line | secondary on the ground | 6.51:1 | 4.58:1 |
The chip is blue on dusk and ink on amber rather than blue on both: on the amber the feed is historical (“she wrote down, before the pause”), and a confirm blue that means “live” would be claiming something untrue about rows captured before the member paused her. Never green — confirmation is the blue pair on every Airmond surface.
The speaker chip
The rule: the hue is a MARK, never the text. One capsule holding two controls — the name (tap to mark them talking) and a ✕ (remove) as a sibling, never nested — with the roster hue as a 10px dot, the capsule fill and the capsule border. The name itself stays in the ground’s own ink, so a hue never has to carry a word. Resting: fill 0.1, border 0.3, 1px. Speaking: fill 0.28, border 0.95, 2px, and the label goes bold — three signals, because one is missable across a table. 44px tall, Apple’s minimum, because the member is tapping while looking at a person rather than at the phone.
Dusk — she is listening
Amber — off the record
Ines is the one marked speaking in both.
| Roster hue | On dusk — must clear 3.0 (a non-text mark) | On amber — asserted to stay UNDER 3.0 |
|---|---|---|
#5CB8F0 — speaker 1 | 7.62:1 | 1.01:1 must stay under 3 |
#FAB55C — speaker 2 | 9.42:1 | 1.22:1 must stay under 3 |
#B899F5 — speaker 3 | 7.10:1 | 1.09:1 must stay under 3 |
#EC8DEC — speaker 4 | 7.64:1 | 1.01:1 must stay under 3 |
#F58FAD — speaker 5 | 7.50:1 | 1.03:1 must stay under 3 |
build/color.mjs is a literal hex blocklist of the retired fern values, so a mint nine channel steps off one of them walked straight past it. The replacement is #EC8DEC, an orchid at hue 300°. The wheel it had to fit is small — green is banned outright, red already means “recording” on this very screen, and yellow collides with the amber ground — which leaves the arc between the purple (260°) and the pink (342°). It sits at that arc's midpoint, 40° clear of one neighbour and 42° of the other, is the exact complement of green rather than a neighbour of it, is not the brand coral (6°, reserved for identity marks), and measures 7.64:1 on dusk — inside the band the other four already occupy. The honest cost: the old roster's tightest pair sat 51° apart and the new one's sits at 40°, because dropping green gives up the only hue in a 169° arc and five chips must share what is left.The status pill and its live dot
The rule: this is the one element that must read from across a table, so it is drawn at near-full foreground strength — a 1.5px outline at the ink’s 0.9, not the 0.3 hairline everything else uses. Listening: a live dot that breathes (0.45 → 1, 900ms, and static under prefers-reduced-motion). Off the record: the dot is replaced by a pause glyph in the stage ink. The dot is recording-red (#E8574A) rather than the stage foreground, because “recording” is red everywhere else in the app — and it has no amber counterpart on purpose, since a red dot on a screen that is not capturing anything would be the single most dangerous pixel in the product.
Dusk — she is listening
Amber — off the record
| Part | Colours | Measured |
|---|---|---|
| Label, dusk | primary ink on the ground | 12.61:1 |
| Label, amber | primary ink on the ground | 7.43:1 |
Live dot #E8574A on dusk | a non-text mark, so 3.0 | 4.69:1 |
The stage control pair
The rule: two EQUAL halves of one switch, quiet on the left and the destructive on the right. Equal widths are the whole point — the pair reads as two directions of a single control rather than as an action with an afterthought beside it, which is also why the labels mirror each other (“Go off the record” / “Back on the record”) instead of describing what happens. 52px tall, radius 14, 12px between them, one line always. While listening the left half is quiet (the fill plus the hairline); off the record it is the solid ink, because coming back on the record is then the primary act.
Dusk — she is listening
Amber — off the record
iOS may still show its microphone dot: the recording stays open so she can pick up instantly, but no audio is being written.
| Part | Colours | Dusk | Amber |
|---|---|---|---|
| The quiet / solid half | ink on the fill, then the ground on solid ink | 12.61:1 | 7.43:1 |
Ink on Stop #FF453A / #FF3B30 | 17px semibold — body text, so 4.5 | 4.73:1 | 4.55:1 |
| White on Stop — the label it replaced | asserted to stay under 4.5, exactly as white-on-coral is | 3.41:1 must stay under 4.5 | 3.55:1 must stay under 4.5 |
| The Stop fill against its own ground | clears 3.0 on dusk; on amber it is asserted to stay under it, because the ink LABEL identifies the button there | 4.91:1 | 1.64:1 must stay under 3 |
#FF453A and #FF3B30 — Color.red as the native app resolved it in each appearance (cs-airmond-ios/App/Sources/Screens/RecordingStageView.swift:847). The app had drifted to a muted #DC2626 on dusk and a near-black brick #8C1D12 on amber, and nobody noticed until the button was called faded. Note why that drift is easy to make: white measures 4.83:1 and 9.13:1 on those two, against 3.41:1 and 3.55:1 on systemRed — it reads as somebody fixing a real contrast failure and paying for it with the one colour a room already knows means Stop. Both problems were real; only one of them was about the red. So systemRed stays and the label is now the brand ink (#18222B): 4.73:1 and 4.55:1, both clear of 4.5. That is not a new rule — it is the one this system already applies to coral, where white measures 2.80:1 and ink 5.76:1, which is why onCoral is ink.The count pill
The rule: the count appears only when there is something to count, and it never competes with the eyebrow it sits beside. A 13px secondary line at the end of the feed header — no chip, no border, no colour of its own. A zero state says its sentence instead (“nothing yet”), because a pill reading “0 saved” invites the reading that something failed.
Dusk — she is listening
Amber — off the record
| Part | Colours | Dusk | Amber |
|---|---|---|---|
| Section eyebrow | secondary on the ground | 6.51:1 | 4.58:1 |
| The count | secondary on the ground | 6.51:1 | 4.58:1 |
Same colour, same finding as the two grounds above: on the amber this is 4.58:1.
Consuming it
Web: the --stage-* variables in dist/css/airmond.css, in :root — scheme-independent by design, not by omission. React Native: stagePalette(listening) from dist/ts/tokens.ts, which returns the ground, its ink, the three opacity-derived tones and that ground’s Stop; the roster hues, the live dot and the raw opacities are on the stage export beside it. Until this page those values lived only in mb-airmond-app/src/features/recording/stage-palette.ts, which is what let both findings above ship unseen.
The call
The other surface that fills the screen while she is live, and as of 2026-09-19 the one that stopped painting a world of its own. It is on the one ground — the dusk, the same one the recording stage paints while she is listening — because it is the same moment: she is live on both. The two are told apart by what is on them, a portrait and a handset against a speaker roster and a capture feed, and never by two different blacks.
#000000 with the dusty coral breathed into the top at 0.34, living in mb-airmond-app/src/features/voice/components/call-ground.tsx with nothing upstream of it. Those are not two facts. A surface nobody draws on the guide is a surface whose colours nobody measures, and that file said so in its own words: “Pure black rather than a token because there is no token for it and there should not be.”
What the ember was, so nobody re-derives it. The coral fell from 0.34 at the top to 0.09 at a quarter and to nothing by the half, compositing to about #4D2C27 — a glow rather than a wash, and its falloff was not transcribed from native but measured down the left edge of a committed iPad capture, because SwiftUI and SVG do not ramp a colour and an alpha toward each other the same way. That was careful work and it is not why it goes. It goes because of the sentence underneath it: painting the call in ground “would make the two screens siblings at a glance, which is the one thing they must not be”. That premise is what the decision reverses. They are siblings. She is live on both.
The ground
The rule: Airmond paints one ground, in the member’s own scheme, and one surface is allowed a world of its own only while it is live. That surface is the recording stage, because the question it answers is asked by people two metres away who cannot see a setting. The call is a phone held by one person, so it has no claim on a third ground — but it is live, which is why it takes the stage’s dusk rather than the member’s current scheme.
Was · a ground of its own
Is · the dusk, read from the dark scheme
| Pairing | Measured |
|---|---|
| Primary ink on the call ground | 12.61:1 |
Secondary caption (ink at 0.68 → #9DA2A9) | 6.51:1 |
| Her portrait ring, an identity mark, against the ground | 6.14:1 |
Nothing in the table above is a new value. The ground and its ink are dark.background and dark.foreground read straight out of the dark scheme, exactly as the stage’s dusk is, so the two can never drift. The section below adds four things and borrows the rest.
Only one ground, so only one column
Every other component section on this page draws its specimens in both schemes side by side, because the surfaces they document follow the member’s setting. This one does not, and that is a decision rather than an omission: the call is pinned while it is live, exactly as the stage is. The one exception is the minimised banner, which is not on the call ground at all — it is a strip across the member’s own page, so it is drawn in both.
The specimen palette
The specimen carries the call palette on itself as the --ca-* set, mapped once in site/call.mjs from tokens/tokens.json. Two things in this section are not read from it, and both are deliberate: the retired black and its ember above, which have no token because they no longer exist, and the white on the minimised banner, which is white in both schemes and so belongs to no scheme set. Everything else re-renders when the token file changes.
| Variable | Token | What it draws |
|---|---|---|
--ca-ground | dark.background | The one ground. The dusk — the same one the stage paints while she is listening, because it is the same moment. |
--ca-ink | dark.foreground | Her name, the clock, every glyph and every heading on the screen. |
--ca-secondary | dark.foreground at stage.duskSecondaryOpacity | Captions: the sentence under the pill, a feed row’s detail line, the tally. |
--ca-fill | dark.foreground at stage.fillOpacity | The quiet chrome behind a feed row, at the stage’s own fill strength. |
--ca-hairline | dark.foreground at stage.cardHairlineOpacity | A capture card’s outline, the stage’s card hairline exactly. |
--ca-rule | mark.tone at mark.ruleOpacity | The one tone every mark that separates or encloses is drawn in. Here: the outline that makes a quiet control a shape. |
--ca-end-fill | stage.stopOnDusk | The End disc. Read from the stage so the control that ends a live conversation cannot drift from the one that ends a recording. |
--ca-on-end-glyph | call.onEndGlyph | The handset on that disc. White, and the one place on an Airmond red where white is correct — see the controls. |
--ca-live-dot | stage.liveDot | The breathing dot in the status pill. The stage’s, because the pill is the stage’s. |
--ca-coral | rn.dark.coral | Identity, and nothing else on this screen: her portrait ring. Ending a call is NOT coral — see the controls. |
--ca-confirm | dark.confirm | A row she got through. Never green. |
--ca-control-wash | dark.foreground at call.controlWashOpacity | The quiet disc behind an unarmed control — on this screen, the unmuted mic. It does not identify the control: see the measurement. |
The icon set
Seven symbols, drawn once into a <defs> block and referenced by <use>, each stroked in currentColor so it takes the colour of whatever it sits on. Nothing here is an emoji or a text character: a text glyph renders as an empty box, which is the defect the chat surface was revised to fix.
CallPortrait, and the shape the screen starts in
The rule: her size is the outcome of a subtraction, never a constant. The screen has one hard requirement — Mute and End are always reachable — and exactly one flexible thing above them. So the portrait takes whatever height is left after her name, the pill, the controls and the clock have had theirs, between 88 and 240 points. An iPhone SE mid-call has about 120 points of slack; a 6.9" phone has three times that. The ring is coral, and coral on this screen means identity and nothing else — which is exactly why ending a call is not coral.
Ada
04:12
ListeningShe will keep going until you say otherwise.
| Part | Colours | Measured |
|---|---|---|
| Her name and the clock | primary ink on the ground | 12.61:1 |
| The ring | coral as a non-text identity mark, so 3.0 | 6.14:1 |
| The caption under the pill | secondary on the ground | 6.51:1 |
CallHeader, once she has captured something
The rule: the clock is the same counter, left-aligned rather than centred — never a smaller one. Stacking her, her name and the counter cost over 250 points of header on an iPhone 17 and left the thing the screen exists to show with one visible row, so the working shape puts them in a row. The counter stays the largest thing in it: how long you have been on the phone is the one number a member looks up to check, and shrinking it the moment the screen got busier would make it harder to read exactly when there is more to read.
Ada
04:12
CallStatusPill
The rule: this is the recording stage’s pill, not a second one. Same radius, the same 1.5px outline at 0.9 of the foreground, the same paddings and label scale, and the same breathing dot at #E8574A. A member who lowers the screen mid-call and comes back to a busier one must not have to re-read a different status vocabulary to work out what changed, so one function turns a phase into words and both shapes mount this component rather than restating it. The dot is static under prefers-reduced-motion.
She will keep going until you say otherwise.
| Part | Colours | Measured |
|---|---|---|
| The label | primary ink on the ground | 12.61:1 |
The live dot #E8574A | a non-text mark, so 3.0 | 4.69:1 |
| The caption under it | secondary on the ground | 6.51:1 |
CallControls, and the one measurement this section came back with
The rule: ending a call is red, and on this screen red means only that. It used to be coral, which was right while coral did nothing else here; since the portrait grew its ring, a coral End button sits inches from a coral ring meaning something completely different, and one colour saying two things is how a signal stops working. The disc is #FF453A — the stage’s own Stop, read from there rather than typed again, so the control that ends a live conversation cannot drift from the one that ends a recording.
#3A414C and measures 1.63:1 against the ground — about half of the 3.0 that a container the member has to find is meant to clear. This is not a cost of the move. On the retired black the same wash was #2E2E2E at 1.55:1, so the new ground improved it by 0.08 and left it failing. Raising the opacity does not rescue it either: 0.30 reaches only 2.33:1, by which point the disc has stopped being quiet. So the fix is the one this kit already shipped for the card on 2026-09-14: an outline in rule, the single tone every separating mark is drawn in (#6E7986, 3.78:1). A mark that separates must clear 3.0 on the surface it actually sits on.Was · filled, and findable only by its glyph
Mute
End
Is · the same fill, with the rule outline
Mute
End
Two controls, not three. There is no audio-route button and that is not an oversight: AirmondLiveAudio follows the OS communication route — speaker, wired headset or Bluetooth — and reports the changes, so the platform stays the source of truth and the screen keeps only the controls whose behaviour is identical everywhere. The first version of this section drew a Speaker disc here, and the app has never had one. It is recorded rather than quietly deleted, because a specimen that invents a control is worse than no specimen: the next person builds it.
And muted is the loud state
Muted fills with primaryFill — the way a pressed key stays down — so “she cannot hear you” is legible without reading the pill. A filled disc takes no outline, and must not: it separates on its own, and an edge on top of a fill reads as a second state.
Live · she can hear you
Mute
End
Muted · she cannot
Unmute
End
| Part | Colours | Measured |
|---|---|---|
| The End disc against the ground | a control must be findable, so 3.0 | 4.91:1 |
| The white handset on the End disc | a 24pt NON-TEXT glyph, so 3.0 | 3.41:1 |
| White on the End disc, against the 4.5 TEXT bar | asserted to stay under it — the record of why a WORD on this red is ink | 3.41:1 must stay under 4.5 |
| The quiet wash against the ground | asserted to stay under 3.0: it is not what finds the control | 1.63:1 must stay under 3 |
| The rule outline against the ground | this is what finds it, so 3.0 | 3.78:1 |
| The glyph inside a quiet disc | primary ink on the wash | 7.76:1 |
| The MUTED disc against the ground | primaryFill — it separates on its own, which is why it takes no outline | 12.61:1 |
| Its glyph | onPrimary on that fill | 12.61:1 |
The app draws this, as of 2026-09-19. call-controls.tsx takes call.controlOutlineTone at control.card.borderWidth on an unarmed control and nothing on a filled one, pinned by an e2e test that asserts both halves — a test checking only that Mute has a border would pass on a build that put one on every control. For about an hour this note read “a change the app has to pick up”, which is the state every token in this kit starts in and the state none of them should stay in.
CaptureFeed
The rule: it is the recording stage’s own headings, taken by reference and not retyped, plus one the stage has no use for. It stopped being a single flat stream when Cédric asked to see “what you’re capturing, what tasks, what knowledge updates, and so forth”, so the feed carries the stage’s two headings and a third, What didn’t go through. The tally sits beside the heading and outside the scroll view: on one 514-second call nineteen of twenty-eight completions were refused and the member found out from her, near the end, while reloading another tab. A count that can be scrolled away from is a count that arrives too late.
Call Tony's Barbershop
Pfäffikon SZ, to check today's slots
Pick the voice v0.1 ships with
Male and female, one each at most
Add Thursday's slot to the calendar
She could not reach the calendar, and will try again
| Part | Colours | Measured |
|---|---|---|
| A row heading | primary ink on the ground | 12.61:1 |
| Its detail line and the tally | secondary on the ground | 6.51:1 |
| The row’s outline | the stage’s card hairline, a non-text mark, so 3.0 | 1.84:1 — fails 3 |
The feed is the clearest argument for the one ground there is: it draws the recording stage’s own two headings. Two screens sharing a heading and not a ground was the thing to fix.
MinimisedCallBanner
The rule: it is the other half of the minimise control, and it ships with it or neither ships. Without the strip a lowered call is a call the member cannot find, and the only remaining way to end it is the system’s own call UI. This is the one component in this section drawn in both schemes, because it is not on the call ground: it is a push-down strip across the member’s own page, so it follows the member’s setting like everything else there.
Feather
Low flame
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The sentence and the clock | white on confirmDeep | 8.06:1 | 6.74:1 |
Consuming it
Web: the --call-* variables in dist/css/airmond.css, in :root — scheme-independent by design, exactly as the --stage-* set is, because the call is pinned while it is live. React Native: the call export from dist/ts/tokens.ts, which carries the ground, its ink, the End disc and its glyph, the quiet wash and the outline that actually finds the control. Nothing in that export is a hex this file decided: the ground and ink are read from the dark scheme, the End disc from stage.stopOnDusk, the outline from rule. Until today these values lived only in mb-airmond-app/src/features/voice/components/, which is what let a whole ground exist without ever being measured.
Ada’s meeting tile
The Recall bot’s 1280x720 camera page: what everyone else in a Meet, Zoom or Teams call sees as Ada’s video. Her portrait and the meeting’s title at the top of a column, then what she is capturing as it happens: action items, notes, knowledge updates, answers and her summary, with the people who join. Decided 2026-09-30 while reviewing a mockup built from a real team call, and the speaking orb approved 2026-10-01. The decisions and their reasons are DESIGN.md’s “the meeting tile” section; this section is their values, drawn.
dark.background, lifted behind the column by a radial from dark.card to dark.sidebar.The tile, state by state
Each frame is the real page at 1280x720, scaled to fit. The bot’s browser draws it at 1:1, so every distance in this section is in the frame’s own pixels.

Speaking. The liquid orb behind her portrait, following her voice; “Reading aloud” on the answer she is saying; the live dot pulses. Once the meeting has a title it replaces her name in the header and her name moves to the subtitle. The button fades the orb out and back in.




Why the middle third, and why an orb rather than a border
The rule: once anyone has joined, everything sits in the middle third of the frame, and “she is speaking” is drawn inside it. Meet, Zoom and Teams crop the tile to fit their grids, and the worst case is the centre 720x720 square, which removes 280 px from each side. A coloured border round the whole frame, which is what the old tile drew, is invisible on at least two sides for everyone who sees that crop. The middle third runs from 427 to 853 px, inside the square, so the header, the orb and the feed survive every crop.

The shaded sides are what the 720x720 crop removes; the dashed band is the column every state draws in.
The kind icons
The rule: the app’s own icons for the same meanings, drawn bare, with no tinted box behind them. Phosphor regular, exactly as mb-airmond-app/src/ui/icon-glyphs.tsx maps them, so one set of icons means one set of things across the app and the tile. Each kind has one colour, and only the action item is loud: coral is reserved for it, so the summary, which is her speaking rather than a request to act, takes the confirm blue like an answer.
dark.coral-deepdark.muted-foregrounddark.waitingdark.confirmdark.confirmThe tags, rows and pills use six more from the same set: PencilSimple (Edited), Waveform (Reading aloud, Connecting voice), Clock (due), Check (confirmed), Question (the confirmation pin) and MicrophoneSlash (muted). Path data from phosphor-react-native 3.0.6, MIT.
MeetingHeader
The rule: her portrait is a small circle top-left of the column, beside the title. 64px in a 2px ring, the title in Schibsted Grotesk 700 at 24px (her name until the meeting has a title, clamped to 2 lines), and a 15px subtitle in Instrument Sans that says whether she is taking notes and how many people are in the call. The title starts 106px from the portrait’s left edge, which is exactly clear of the orb at full level. App components that show her portrait beside a title may follow this header.
Taking notes

Not yet confirmed

Speaking

Titled

FeedCard, each kind
The rule: a feed card is a dusk card, never a white one. dark.card with the dusk ink and a 12% hairline, at the system’s card radius. Next to camera feeds in a video grid, white cards read as a screen share and pull the eye off the people. Card text is 21px (an action item 22px at 600), because people read it at grid size. Drawn at 1:1, in the column’s own width.
FeedCard, each state
The rule: a state is a tag at the right of the kind row, and the words that changed stay visible for a moment. New has no tag: the card rises into the top of the feed over motion.arrive, and the next card pushes it down within seconds. Edited is the waiting amber, with the replaced words struck through under the new ones for six seconds. Removed strikes the text before the card leaves. Reading aloud is the coral wash, on the card she is saying.
New
Edited
Removed
Reading aloud
rn.dark.danger at 0.16 (rgba(242, 133, 124, 0.16)) composites to #41373F over the card, and the word measures 4.57:1. Decided 2026-10-01, and claimed in tokens.json.JoinRow, ConfirmedRow and the ConfirmationPin
The rule: people are rows, not cards, and joins that land together fold into one row. A row is lighter than a card: avatars in the roster hues with ink initials, then the words. Folded, at most 4 avatars overlap by 10px and at most 3 first names are spelled out, then “and N others”. The member she works for gets a blue check once they confirm in the app; until then the ConfirmationPin sits at the top of the feed, a dusk card with an amber hairline and the Question icon.
JoinRow · one person
JoinRow · folded
ConfirmedRow
ConfirmationPin
StatusPill
The rule: a state the room needs to know about is a pill on its own row between the header and the feed. The raised fill, the label in ink and the detail in muted. Muted says she is still taking notes, because mute only silences her voice, and a pill that just said “Muted” would read as “she has stopped”.
Hearing · connecting
Muted
LiquidOrb
The rule: she is speaking when the orb shows, and the orb follows her voice. Two conic layers of four hues, screen-blended behind her portrait: the brand coral and three of the roster (pink, amber, blue). The edge morphs through three shapes every 5s while the gradient turns every 7s (the inner layer 3.6s and 5s, reversed), and its size is 0.86 + level × 0.26, where level is her output amplitude from 0 to 1, the audio the page is already playing, eased over 130 ms. It fades in and out over 450 ms. While it shows, her ring becomes a 3px band of ground colour so her face stays separate from the light. Chosen over a halo, ripples, a waveform and an aurora because it is the one that still reads at Meet’s 3x3 grid size.
Behind the header portrait

Before anyone joins, at 1.2x the idle ring

The level here is a smoothed stand-in for her voice. On the tile it is the PCM amplitude of what she is saying. Under prefers-reduced-motion the shape stops morphing and turning, the level is held at 0.5, the live dot stops pulsing, and the fade takes motion.reduced. It animates only transform, opacity and border-radius, with no full-frame blur, so Recall’s headless Chrome draws it at full frame rate.
IdlePortrait
The rule: before anyone joins, her portrait is the whole tile, and it is crisp. A 330px ring, 3px wide, with the photo inset 7px from its outer edge so a band of dusk shows between them; centred at 50% across and 42% down, her name at 42px and whose assistant she is at 19px. When she speaks before anyone joins, the orb behind it is 1.2x the ring, and her name sits 236px below the centre, clear of the orb’s rim at full level (222 px); the token build fails if the two ever meet. An earlier version faded the photo into the ground with a vignette; Cédric did not like it, and the crisp circle matches the header she shrinks into.

Measured
Every pairing the tile introduces, measured on the surface it sits on. Text is measured against dark.card, which is also the ground’s lightest point, so a pairing that clears here clears anywhere on the ground. This table and the claims in tokens.json come from the same list (meetingPairs in build/meeting-tile.mjs), and the token build fails if a pairing has no claim.
| Pairing | Colours | Measured |
|---|---|---|
| Title and every card’s text | #DCE0E5 on #1F2833 | 11.24:1 |
| Subtitle, kind labels, meta, struck words | #93A0AD on #1F2833 | 5.58:1 |
| Status pill detail on the raised fill | #93A0AD on #2A3646 | 4.59:1 |
| Owner pill and status pill label | #DCE0E5 on #2A3646 | 9.24:1 |
| Edited tag | #D9BC6A on #3A3524 | 6.62:1 |
| Due date | #D9BC6A on #1F2833 | 8.05:1 |
| Knowledge update icon | #D9BC6A on #1F2833 | 8.05:1 |
| Confirmation pin icon | #D9BC6A on #1F2833 | 8.05:1 |
| Reading aloud tag | #FF9081 on #463A40 | 4.93:1 |
| Removed tag | #F2857C on #41373F | 4.57:1 |
| Confirmed line under a member | #8FB2DC on #1F2833 | 6.79:1 |
| Answer icon | #8FB2DC on #1F2833 | 6.79:1 |
| Note icon | #93A0AD on #1F2833 | 5.58:1 |
| Action icon | #FF9081 on #1F2833 | 6.78:1 |
| Summary icon | #8FB2DC on #1F2833 | 6.79:1 |
| Live dot | #FF9081 on #1F2833 | 6.78:1 |
| Confirmed badge on the ground | #8FB2DC on #171E2A | 7.62:1 |
| Check on the confirmed badge | #171E2A on #8FB2DC | 7.62:1 |
| Initials on speaker hue 1 | #18222B on #5CB8F0 | 7.35:1 |
| Initials on speaker hue 2 | #18222B on #FAB55C | 9.08:1 |
| Initials on speaker hue 3 | #18222B on #B899F5 | 6.84:1 |
| Initials on speaker hue 4 | #18222B on #EC8DEC | 7.36:1 |
| Initials on speaker hue 5 | #18222B on #F58FAD | 7.23:1 |
| Avatar 1 on the ground | #5CB8F0 on #171E2A | 7.62:1 |
| Avatar 2 on the ground | #FAB55C on #171E2A | 9.42:1 |
| Avatar 3 on the ground | #B899F5 on #171E2A | 7.10:1 |
| Avatar 4 on the ground | #EC8DEC on #171E2A | 7.64:1 |
| Avatar 5 on the ground | #F58FAD on #171E2A | 7.50:1 |
| Card hairline (recorded, not decided) | #363E48 on #1F2833 | 1.38:1 must stay under 3 |
| Card fill against the ground | #1F2833 on #171E2A | 1.12:1 must stay under 3 |
| Confirmation pin hairline | #535142 on #1F2833 | 1.86:1 must stay under 3 |
| Portrait ring at rest | #525862 on #171E2A | 2.33:1 must stay under 3 |
Four marks are recorded rather than relied on. The card’s 12% hairline and its fill do not separate from the ground by luminance, the pin’s amber hairline is a tint, and the portrait’s ring frames a photo. The card is found by its text and its icon, the pin by its icon and title, and her portrait by her face. Whether a feed card on a video tile is a mark the mark rule covers is recorded here as a question for the next pass, not settled by this one.
The specimen palette
The tile carries its palette on itself as the --mt-* set; the published variables are the --meeting-* set in dist/css/airmond.css. Both are generated from the same resolved tones, and each role names the token it reads. Only two of these are new colour decisions, and both are an existing hue at a new opacity: the pin’s hairline and the Removed tag’s wash.
| Variable | Published as | Reads | What it draws |
|---|---|---|---|
--mt-ground | --meeting-ground | dark.background | The dusk. Every state paints it; it never follows the member’s scheme. |
--mt-ground-centre | --meeting-ground-centre | dark.card | The lift behind the column, the radial’s centre. The lightest point of the ground, so every text claim is measured against it. |
--mt-ground-edge | --meeting-ground-edge | dark.sidebar | The radial’s edge, at the frame’s corners. |
--mt-card | --meeting-card | dark.card | A feed card and the confirmation pin: a dusk card, never a white one. |
--mt-ink | --meeting-ink | dark.foreground | The title, every card’s text, names in a row. |
--mt-muted | --meeting-muted | dark.muted-foreground | The subtitle, kind labels, meta rows, struck words, the idle role line. |
--mt-hairline | --meeting-hairline | rn.dark.border | A card’s 12% hairline. Texture: see the measurements. |
--mt-raised | --meeting-raised | dark.secondary | The status pill’s fill. |
--mt-portrait-ring | --meeting-portrait-ring | dark.foreground at stage.duskHairlineOpacity | The portrait’s ring at rest: the stage’s dusk hairline. |
--mt-portrait-ring-speaking | --meeting-portrait-ring-speaking | dark.background | The ring while the orb shows: a band of ground that keeps her face apart from the light. |
--mt-kind-action | --meeting-kind-action | dark.coral-deep | The action item icon (TasksIcon). |
--mt-kind-note | --meeting-kind-note | dark.muted-foreground | The note icon (DocumentIcon). Most of the feed, so it recedes. |
--mt-kind-memory | --meeting-kind-memory | dark.waiting | The knowledge update icon (LightbulbIcon). |
--mt-kind-answer | --meeting-kind-answer | dark.confirm | The answer icon (ChatIcon). |
--mt-kind-summary | --meeting-kind-summary | dark.confirm | The summary icon (SparkleIcon). |
--mt-pin-icon | --meeting-pin-icon | dark.waiting | The confirmation pin’s question icon. |
--mt-pin-hairline | --meeting-pin-hairline | dark.waiting at 0.28 | The confirmation pin’s amber hairline. New: the waiting amber at 0.28. |
--mt-edited-fill | --meeting-edited-fill | dark.waiting-soft | The Edited tag’s fill. |
--mt-edited-ink | --meeting-edited-ink | dark.waiting | The Edited tag’s word and pencil. |
--mt-removed-fill | --meeting-removed-fill | rn.dark.danger at 0.16 | The Removed tag’s fill. New: the dark danger at 0.16, the highest wash under which its word clears 4.5. |
--mt-removed-ink | --meeting-removed-ink | rn.dark.danger | The Removed tag’s word. |
--mt-reading-fill | --meeting-reading-fill | rn.dark.coralWash | The Reading aloud tag’s fill: the app’s own coral wash. |
--mt-reading-ink | --meeting-reading-ink | dark.coral-deep | The Reading aloud tag’s word and waveform. |
--mt-owner-fill | --meeting-owner-fill | dark.secondary | The owner pill on an action item. |
--mt-due | --meeting-due | dark.waiting | The due date and its clock. |
--mt-confirmed | --meeting-confirmed | dark.confirm | The confirmed badge and the line under a confirmed member. |
--mt-on-confirmed | --meeting-on-confirmed | dark.background | The check on that badge. |
--mt-live-dot | --meeting-live-dot | dark.coral-deep | The subtitle’s live dot, while she is taking notes. |
--mt-initials | --meeting-initials | light.foreground | Initials on an avatar: the brand ink, on every roster hue. |
--mt-avatar-ring | --meeting-avatar-ring | dark.background | The ring of ground between stacked avatars. |
--mt-shadow | --meeting-shadow | rn.dark.shadow | The portrait shadows’ ink. |
| Orb | Value | Reads |
|---|---|---|
--meeting-orb-hue-1 | #E38273 | dark.primary |
--meeting-orb-hue-2 | #F58FAD | stage.speakerHues.4 |
--meeting-orb-hue-3 | #FAB55C | stage.speakerHues.1 |
--meeting-orb-hue-4 | #8FB2DC | dark.confirm |
--meeting-orb-glow | 0 0 56px 14px rgba(227, 130, 115, 0.5), 0 0 120px 30px rgba(245, 143, 173, 0.22) | meetingTile.orb.glow |
--meeting-orb-size | 128px | behind the 64px portrait |
Geometry, and the reason for each number
Every distance on the tile is a meetingTile.layout token and carries its reason in the token file, printed here. The next change should argue with the reason rather than the number.
| Token | Value | Why |
|---|---|---|
frameWidth | 1280px | Recall's camera page is 1280x720, so the frame is exactly that and nothing is drawn outside it. |
frameHeight | 720px | The other half of the 16:9 frame Recall streams. |
columnLeft | 33.33% | The middle third starts at 427 px, inside the 280 px the tightest crop (the centre 720x720 square) removes from each side. |
columnWidth | 33.33% | Ends at 853 px, inside the 1000 px where that crop stops. Every crop Meet, Zoom and Teams apply keeps the whole band. |
columnInset | 12px | Keeps the cards' edges off the band's own edges; unchanged from the 2026-09-29 stage-feed plan in cs-airmond-api. |
headerHeight | 116px | Fits the portrait and a two-line title, so the feed starts at the same height whether or not the title wraps. |
portraitSize | 64px | Large enough to read as her face at Meet's 3x3 grid size (about a fifth of this), small enough to sit beside the title rather than above it. |
portraitTop | 26px | 26 px below the frame's top, as approved. At full level the orb reaches 72 px above the portrait's centre (at 58 px), so its top 14 px is clipped by the frame; the approved mockup draws it that way, and the light reads as rising off the top edge. |
portraitRingWidth | 2px | The resting ring, the stage's dusk hairline; a frame for the photo, not a mark anyone has to find. |
portraitRingSpeakingWidth | 3px | While the orb shows, the ring becomes a 3 px band of ground colour, so her face stays separate from the light behind it. |
headerTextLeft | 106px | From the portrait's left edge. The orb at full level reaches 104 px from that edge (its centre at 32 px plus 64 x 1.12), so the title never sits under the light. |
headerTextTop | 30px | Starts the title 4 px below the portrait's top, so a two-line title and the subtitle sit beside the portrait rather than under it. |
titleSize | 24px | Schibsted Grotesk 700: the meeting title, or her name until the meeting has one. The largest type in the column, because at grid size it is what identifies the tile. |
titleLineHeight | 1.15 | Tight enough that two lines still fit the header. |
titleMaxLines | 2 | A longer title is clamped rather than pushing the feed down. |
subtitleSize | 15px | Instrument Sans; whether she is taking notes and how many people are in the call. |
subtitleGap | 6px | Between the title and the subtitle. |
liveDotSize | 7px | Shown only while she is taking notes; it pulses while she speaks. |
pillPaddingY | 6px | The status pill, above and below its label. |
pillPaddingX | 12px | The status pill, each side (the glyph side sits two closer, optically). |
pillGap | 8px | Between the pill's glyph and its words. |
pillSpaceBelow | 12px | Between the pill and the first card, so the pill reads as state rather than as the first item. |
feedGap | 10px | Between two cards or rows in the feed. |
feedFadeFrom | 72% | The feed fades out over its last 28 %, so a card cut by the frame's bottom reads as more below rather than as broken. |
cardRadius | 16px (meta.radiusCard) | The system's card radius, read from meta rather than restated. |
cardPaddingX | 16px | Inside a card, left and right. |
cardPaddingTop | 14px | Inside a card, above the kind row. |
cardPaddingBottom | 15px | One more than the top, which optically balances the descenders of the last line. |
kindIconSize | 19px | The app's icon for the kind, bare, with no tinted box behind it (2026-09-30). |
kindGap | 8px | Between the kind icon and its label. |
labelSize | 15px | The kind label in muted, 600; the card's text is the thing to read, the label only sorts it. |
tagSize | 13px | The Edited, Removed and Reading aloud tags, 600, right-aligned in the kind row. |
tagIconSize | 13px | The glyph inside a tag. |
tagPaddingY | 3px | A tag, above and below its word. |
tagPaddingX | 9px | A tag, each side. |
tagGap | 5px | Between a tag's glyph and its word. |
cardTextSize | 21px | A note or an answer, read by people at grid size; never smaller than this. |
cardTextLineHeight | 1.28 | Open enough for three lines at 21 px to stay distinct when scaled down. |
cardTextWeight | 450 | A shade over regular, which keeps light-on-dark text from thinning at grid size. |
cardTextGap | 7px | Between the kind row and the text. |
actionTextSize | 22px | An action item asks someone to act, so its text is one step larger. |
actionTextWeight | 600 | And semibold, for the same reason. |
oldTextSize | 16px | The words an edit replaced, struck through in muted for six seconds under the new text. |
strikeWidth | 1.5px | The strike through replaced or removed words. |
metaSize | 15px | The owner, due date, and who asked. |
metaGap | 11px | Between the text and its meta row. |
ownerAvatarSize | 22px | The owner's initials inside the owner pill. |
ownerPaddingX | 11px | The owner pill's trailing side; the avatar sits 3 px from the leading edge. |
rowGap | 12px | Between a row's avatars and its words. |
pinTitleSize | 18px | The confirmation pin's title, 600. |
pinBodySize | 15px | The confirmation pin's instruction. |
rowTextSize | 19px | A join or confirmation row, which is lighter than a card. |
rowSubSize | 15px | The line under a confirmed member's name. |
avatarSize | 36px | A person in a join row. |
avatarOverlap | 10px | Folded joins stack their avatars, overlapping by this much. |
avatarMax | 4 | At most four avatars in a folded row; the words say how many more. |
namesShown | 3 | At most three first names in a folded row, then "and N others". |
avatarRingWidth | 2.5px | A ring of ground colour between stacked avatars. |
initialsSize | 13px | Initials on a 36 px avatar, 700. |
confirmedBadgeSize | 18px | The confirmed check on a member's avatar, bottom right. |
confirmedCheckSize | 11px | The check glyph inside it. |
pillTextSize | 15px | The status pill under the header: connecting voice, or muted. |
pillIconSize | 17px | The pill's glyph. |
idleRingSize | 330px | Before anyone joins, her portrait is the whole tile: a crisp circle whose ring is 330 px across, under half the 720 px square crop's height. |
idleRingWidth | 3px | The resting ring, drawn inside the 330 px. |
idlePhotoInset | 7px | The photo sits 7 px in from the ring's outer edge: the 3 px ring, then 4 px of dusk between ring and photo, so the ring reads as a frame. The photo is therefore 316 px across. |
idleCentreX | 50% | Centred across the frame. |
idleCentreY | 42% | Above centre, to leave room for her name and role under the circle and the orb. |
idleNameOffset | 236px | From the circle's centre to the top of her name. The idle orb is 1.2 x 330 = 396 px and at full level scales by 1.12 to a 222 px radius, so 236 keeps her name 14 px clear of its rim while she speaks; the build fails if the orb ever reaches it. Her name sits at 538 px, and her role line ends well inside the 720 px frame. |
idleNameSize | 42px | Schibsted Grotesk 700, her name under the circle. |
idleRoleSize | 19px | Whose assistant she is, in muted. |
Consuming it
Web: the --meeting-* variables in dist/css/airmond.css, in :root, scheme-independent like the --stage-* and --call-* sets. TypeScript: the meetingTile export from dist/ts/tokens.ts (tones, the ground gradient, the roster, the orb, the layout with each reason as a doc comment, and the fixed words). The consumer is cs-airmond-api/src/meetings/live-stage-page.ts, which builds the page the bot shows and, today, carries these values by hand.
The chat surface
The seven components a conversation with her is actually made of, every state, drawn in both schemes from the token file and from nothing else. This is the thread itself: the bar you type into, the two credential cards (one that keeps a secret and one that hands it straight to a live page and keeps nothing), the connector, the card that asks you to choose, her screen, and the page an OAuth round trip lands on.
confirm on confirmSoft) — the reference app this was drawn from uses a green tick and we do not. Coral is identity plus the ONE primary action per surface: the armed send disc is the only coral fill on this whole surface, and Save securely, Authorize and Reconnect are all the quiet fill. Every icon is a drawn SVG: the revision this section comes from exists because a text glyph rendered as an empty box.The specimen palette
Each specimen carries one scheme's palette on itself as the --ch-* set, mapped once in site/chat.mjs from tokens/tokens.json. There is no hex anywhere in the section: change a token and every specimen below re-renders. The mapping is the whole surface contract, so it is printed rather than described.
| Variable | Token | What it draws |
|---|---|---|
--ch-ground | rn.<scheme>.ground | The thread behind the bar and the cards. |
--ch-panel | rn.<scheme>.groundAlt | The recessed panel: the agent screen, and the option rows inside a card. |
--ch-surface | rn.<scheme>.surface | The card itself. |
--ch-field | rn.<scheme>.surfaceRaised | The message bar’s pill — the one raised surface on the thread. |
--ch-quiet | rn.<scheme>.sidebarAccent | The quiet fill: the + disc, every button that is not the primary action, the option keys. |
--ch-primary-fill | rn.<scheme>.primaryFill | The one FILLED control a card is allowed: CredentialHandoff’s Continue. Ink, not coral. |
--ch-on-primary | rn.<scheme>.onPrimary | The label on that fill. |
--ch-ink | rn.<scheme>.ink | Titles, the typed message, every glyph on a quiet fill. |
--ch-muted | rn.<scheme>.muted | Subtitles, placeholders, captions, footnotes. Quiet text is muted and never faint — see the note under the icon set. |
--ch-coral | rn.<scheme>.coral | Identity, and the ONE primary action per surface: the armed send disc, and nothing else here. |
--ch-on-coral | rn.<scheme>.onCoral | The glyph on coral. Ink, never white — the oldest measured claim in this system. |
--ch-confirm | rn.<scheme>.confirm | Confirmation, the shield, the waveform, the chosen option. Never green. |
--ch-confirm-soft | rn.<scheme>.confirmSoft | The wash under a confirm pill and under the connector’s logo tile. |
--ch-warning | rn.<scheme>.warning | A refusal, in the subtitle slot of the card it belongs to. |
--ch-line | rn.<scheme>.border | Every hairline inside a specimen: the pill, the cards, the option separators. |
--ch-coral-deep | <scheme>.coral-deep | The live dot, the required marker on a labelled field, and the state label above a specimen. Coral-deep rather than coral: full coral misses the 3.0 non-text bar on the panel. |
--ch-warning-soft | <scheme>.waiting-soft | The wash under the one refused pill (AuthComplete). |
--ch-edge | <scheme>.border | The specimen’s own outer edge, so a light specimen on a dark page still looks like a card. |
The icon set
Eleven symbols, drawn once into a <defs> block and referenced by <use>, every one of them stroked in currentColor so it takes the colour of whatever it sits on. Nothing in this section is an emoji or a text character. Two of them — Task and Knowledge — are drawn here but used by the reference densities inside a message bubble rather than by any card below; they are held in the same set so the thread cannot end up with two families of glyph.
Feather (light)
Low flame (dark)
muted, never faint. The app's faint tone measures 3.08:1 on the light field and 2.81:1 on the panel — under the 4.5 body bar and under even the 3.0 non-text minimum. So it carries no word and no mark in this section: every placeholder, caption, footnote and timestamp is muted (6.69:1 light, 4.87:1 dark). Both of those failures are recorded as must-fail claims in the token file, so the tone cannot quietly come back.Two things on this page are the page talking, not the component. A state label above a specimen goes coral-deep when it is the state the section is arguing about — that is a caption, outside the component, and it is not the surface spending its one coral. And a specimen page runs more ambient loops than a product screen is allowed (the motion budget is at most two per screen; this page draws a live dot per scheme per state), because every state has to be visible at once here.
MessageBar
The rule: one pill, and every control inside it. The + is not a disc standing beside the bar, it is the same 36px circular control as the mic, inset 6px inside a 24px radius, so the bar reads as one object rather than two. The field is 17/23. The trailing slot does two jobs and never both at once: it is the mic while there is nothing to send, and it becomes the coral send the moment there is.
On the web the same bar is one step down (chat density, the standard since 2026-09-24): 42 tall with 30px discs, still 6px in from every side, a 21px radius and a 14/20 field, the size of the messages it sends. Both are drawn from density.composer.
Feather (light)
Resting
Typing · the trailing slot becomes the send
Recording · the field becomes the waveform
Web · 42 tall, resting
Web · typing
Low flame (dark)
Resting
Typing · the trailing slot becomes the send
Recording · the field becomes the waveform
Web · 42 tall, resting
Web · typing
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Placeholder | muted on the field | 6.69:1 | 4.87:1 |
| What you typed | ink on the field | 16.12:1 | 9.80:1 |
The + glyph | ink on the quiet fill | 14.03:1 | 9.24:1 |
| The mic glyph | the ground on the ink disc | 15.41:1 | 12.61:1 |
| The send glyph | onCoral — ink, never white | 5.76:1 | 5.92:1 |
| The waveform | confirm on the field (a non-text mark, so 3.0) | 6.74:1 | 5.92:1 |
| The armed disc’s own plate against the field | 3.0 non-text — only the dark scheme clears it | 2.80:1 must stay under 3 | 4.77:1 |
The coral disc is found by its glyph, not by its edge. Coral on the white field measures 2.80:1, under the 3.0 a control boundary wants — the same property of this coral that makes onCoral ink rather than white. What identifies the send is the ink arrow on it at 5.76:1 and its fixed position at the end of the pill, so the claim is recorded as a must-fail rather than papered over with a deeper coral that would stop being the brand.
Recording keeps the + and arms the send. There is no use for a mic while a recording is already running, and the thing you want at that moment is to send it. The waveform and the elapsed time are drawn in confirm blue: the send disc stays the one coral fill on the surface.
SecureCapture
The rule: the ask, the field and the reassurance in one card — and a refusal never leaves it. A title, one line of why she needs the value, the field with a quiet Save securely beside it, and a shield footnote saying where the value goes. The button is the quiet fill, not coral: handing over a key is not the primary action of a conversation, it is a chore the card is asking you to finish.
Feather (light)
Empty
Filled
Saved
Refused · the reason sits in the subtitle slot
Low flame (dark)
Empty
Filled
Saved
Refused · the reason sits in the subtitle slot
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Title | ink on the card | 16.12:1 | 11.24:1 |
| Subtitle | muted on the card | 6.69:1 | 5.58:1 |
| The refusal, in the subtitle slot | the waiting amber on the card | 5.22:1 | 8.05:1 |
| The field | muted placeholder, then ink dots, on the recessed ground | 6.40:1 | 6.27:1 |
| Save securely | ink on the quiet fill | 14.03:1 | 9.24:1 |
| The shield footnote | muted text, confirm glyph | 6.74:1 | 6.79:1 |
| Saved | confirm on confirm-soft — blue, never green | 5.78:1 | 4.92:1 |
CredentialHandoff
The rule: this one hands values to a page that is open right now, keeps nothing, and asks for everything it can already see at once. She is standing in a sign-in form on your behalf and it wants an email and a password; she takes an ordered list of fields in ONE card rather than a card per field, because asking twice interrupts you twice and leaves you waiting mid-flow while she sits on a half-filled page. The values go into the page and are not retained, so the done state says Submitted and never Saved — there is nothing saved to speak of, and a card that says Saved about a value nobody kept is the most expensive kind of wrong.
The subtitle always says two things: why she needs it, and what she does next. "Sign in to Windy so I can pull weather for your flights. I'll click Login after it fills." The first half is what makes handing over a password reasonable; the second is what stops you sitting there wondering whether to press anything. A card that carries only one of the two halves is not finished.
| SecureCapture | CredentialHandoff | |
|---|---|---|
| What happens to the value | Stored for later use, so she can do this again next week | Typed into a live page now, and never retained |
| The reassurance | “Stored securely, never shown to Ada.” — on the asking card | “Typed straight into the page and never kept.” on the asking card, “Filled into the page. Secret values were never shown to Ada.” once it is done |
| The done state | Saved | Submitted |
| The fields | One, a placeholder only | An ORDERED LIST, each a label above the input with a required marker, plain or masked |
| The actions | One quiet Save securely | Three, in descending weight: Continue, Open the screen, Dismiss |
| When it appears | Once, when she needs a key she does not have | When a page she is driving asks — carrying every field she can already see |
Feather (light)
Awaiting · both fields in one card
Submitted
Refused
One field · the fallback, when a page reveals one she could not predict
Low flame (dark)
Awaiting · both fields in one card
Submitted
Refused
One field · the fallback, when a page reveals one she could not predict
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Title | ink on the card | 16.12:1 | 11.24:1 |
| What she will do next | muted on the card | 6.69:1 | 5.58:1 |
| The refusal, in the subtitle slot | the waiting amber on the card | 5.22:1 | 8.05:1 |
| Field label | muted on the card | 6.69:1 | 5.58:1 |
| The required marker | coral-deep on the card — an identity mark, not an action | 5.20:1 | 6.78:1 |
| The masked value | ink on the recessed ground | 15.41:1 | 12.61:1 |
| Continue, the filled action | onPrimary on primaryFill — ink, never coral | 16.12:1 | 12.61:1 |
| Open the screen, the secondary | ink on the quiet fill | 14.03:1 | 9.24:1 |
| Dismiss, plain text | muted on the card | 6.69:1 | 5.58:1 |
| Submitted | confirm on confirm-soft — the same pill as Saved and On | 5.78:1 | 4.92:1 |
Three actions, three weights, and only one of them is filled. Continue is the ink fill (16.12:1 light, 12.61:1 dark) rather than coral: the armed send disc is this surface's single coral, and a password card is not where to spend it. Open the screen is the quiet fill and carries the screen glyph, because it is the same object as the AgentScreen panel above — it is the takeover escape hatch, the one control that says "you do it, here is the live browser". Dismiss is plain text with no plate at all: it is always available and never the thing to press.
The label is above the input, not inside it. A placeholder is the one piece of text that disappears exactly when it is needed — the moment you start typing — and this is a field you are being asked to fill from memory, under a card that has to be read carefully. The required marker is coral-deep on the card (5.20:1 light, 6.78:1 dark): identity, not an action, and the one place the coral family appears on a card.
One card, every field she can already see — and the single-field card is the fallback, not the default. A sign-in form that wants an email and a password is one ask, so it is one card: two interruptions to answer one question is the version that makes a member wait twice while she holds a half-filled page open. The one-field shape stays for the case that actually needs it — a page that reveals a field she could not predict, a login split across two screens, an unexpected second factor — and each finished card stays in the thread as Submitted, which is also the honest record of what was handed over and when.
The promise is made before the value is typed, not only after. The shield footnote sits on the asking card, where you are about to paste a live password, and the subtitle repeats it once the card is done. A component that explains itself only on the way out has asked for a password in silence.
ConnectorCard
The rule: one row, one verb. A 40px logo tile on the confirm wash, the connector's name, one line of what it buys you, and exactly one control at the end of the row. The verb is the state: Add offers it, Authorize is the sign-in round trip, connected stops offering a verb at all and shows a confirm-blue On pill instead, and an expired grant says Reconnect with the reason in the amber where the description was.
Feather (light)
Offer
Authorize
Connected
Needs re-auth
Low flame (dark)
Offer
Authorize
Connected
Needs re-auth
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The logo tile | confirm glyph on the confirm wash | 5.78:1 | 4.92:1 |
| Name | ink on the card | 16.12:1 | 11.24:1 |
| What it buys you | muted on the card | 6.69:1 | 5.58:1 |
| Why it stopped working | the waiting amber on the card | 5.22:1 | 8.05:1 |
| Add · Authorize · Reconnect | ink on the quiet fill | 14.03:1 | 9.24:1 |
| On | confirm on confirm-soft | 5.78:1 | 4.92:1 |
Connected has no button, and that is the point. A working connector should not keep asking to be pressed; the only thing left to say is that it is on. The On pill is the same component as Saved and Connected below it, so one state reads identically wherever it appears.
ChoiceCard
The rule: the options are ONE group, separated by hairlines. Not three floating rows — a single bordered group at radius 12, each option a lettered key, a bold title line and an optional lighter description under it. The × in the header dismisses the question without answering it, which is a real answer and needs somewhere to go. Answered collapses the group to the option you picked, its key in the confirm wash and a confirm tick at the end of the row: the card stops asking.
Feather (light)
Open
Answered
Low flame (dark)
Open
Answered
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Question | ink on the card | 16.12:1 | 11.24:1 |
| The line under it | muted on the card | 6.69:1 | 5.58:1 |
| Option title | ink on the recessed panel | 14.71:1 | 13.26:1 |
| Option description | muted on the recessed panel | 6.11:1 | 6.59:1 |
| The key, unanswered | muted on the quiet fill | 5.82:1 | 4.59:1 |
| The key and tick, answered | confirm on confirm-soft | 5.78:1 | 4.92:1 |
| Dismiss | muted on the card (a drawn ×, never the letter) | 6.69:1 | 5.58:1 |
AgentScreen
The rule: it is a live view, not a picture of one — and the only question it answers is who has control. Some steps cannot be handed over as a form. A QR code on WhatsApp Web is the clearest case: there is no field to fill and no value to paste, so she shows you her screen and you scan it with your phone. That is what makes Open the screen on a CredentialHandoff card a destination rather than a label. The panel keeps 16:10 and radius 12 throughout; idle and working are thumbnails beside the thread, watching and handed over are the expanded forms, and the bar across the top is the part that changes.
Working carries a live dot that breathes at the ambient floor (--motion-ambient-min 4s, --motion-ease-ambient, static under prefers-reduced-motion) and names what she is doing. Watching says live and read-only out loud, because a screen you can see but not touch has to say so. Handed over changes the border and the bar to the waiting amber and adds the one control that gives it back.
Feather (light)
Idle · a thumbnail beside the thread
Working · the live mark, and what she is doing
Watching · expanded, live, read-only
Handed over · you are driving, she is waiting
Why it exists · the QR no form could ever carry
Low flame (dark)
Idle · a thumbnail beside the thread
Working · the live mark, and what she is doing
Watching · expanded, live, read-only
Handed over · you are driving, she is waiting
Why it exists · the QR no form could ever carry
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The screen and QR glyphs | muted on the panel (a non-text mark, so 3.0) | 6.11:1 | 6.59:1 |
| Caption | muted on the specimen ground | 6.40:1 | 6.27:1 |
| The live dot | coral-deep on the panel (3.0 non-text) | 4.75:1 | 8.00:1 |
| Full coral as that dot — why it is not used | asserted to stay under 3.0 in Feather | 2.55:1 must stay under 3 | 6.45:1 |
| The watching bar | ink on the quiet fill | 14.03:1 | 9.24:1 |
| The live dot ON that bar | coral-deep on the quiet fill (3.0 non-text) — the one place the live mark leaves the panel | 4.53:1 | 5.57:1 |
| The handed-over bar | the waiting amber on its own wash | 4.66:1 | 6.62:1 |
| The handed-over border | the waiting amber against the panel (3.0 non-text — it is the boundary that says control moved) | 4.76:1 | 9.50:1 |
| Hand back to Ada | onPrimary on primaryFill — the ink fill again, never coral | 16.12:1 | 12.61:1 |
Handed over is the waiting amber, and that is not decoration. Amber on this system means waiting on you, and a screen you are holding is exactly that: she is blocked until you give it back. It is the one state on this surface allowed to be louder than the accent, it carries a 2px border rather than the 1px hairline everything else uses (4.76:1 light, 9.50:1 dark), and it is the only place on the chat surface where a panel changes its edge. Control changing hands has to be unmistakable at a glance, or somebody types into a screen that is not listening.
Handing control back is a deliberate act, never a timeout. Hand back to Ada is a control the member presses; nothing reverts on a timer. A screen that silently takes itself back while somebody is mid-scan is worse than one that waits, because the member finds out by watching his own input go nowhere. The corollary: while the screen is handed over she does nothing, and the bar says so in her own words.
This panel is where Open the screen goes. The secondary action on a CredentialHandoff card carries the same screen glyph and lands here, which is why the two components share a glyph and why the handed-over state is drawn at all: an escape hatch that cannot show you what you are escaping to is a label.
The live dot is coral-deep, not coral. Full coral measures 2.55:1 on the Feather panel and misses the 3.0 a mark needs; coral-deep measures 4.75:1. This is the same swap the admin app icon makes for its tick, for the same reason — the coral family keeps the identity, the depth buys the contrast.
AuthComplete
The rule: a pill, a title, and one line of what happens next. This is the page a browser lands on at the end of an OAuth round trip, with nothing else on it, so it answers the only two questions anybody has there: did it work, and what do I do with this tab. Centred, and short enough to read without scrolling on a phone.
Feather (light)
Connected
Refused
Low flame (dark)
Connected
Refused
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Connected | confirm on confirm-soft — blue, never green | 5.78:1 | 4.92:1 |
| Not connected | the waiting amber on its own wash | 4.66:1 | 6.62:1 |
| Title | ink on the card | 16.12:1 | 11.24:1 |
| What happens next | muted on the card | 6.69:1 | 5.58:1 |
A refusal here is amber rather than the destructive red: nothing was destroyed and nothing was saved, and the next step is a fresh link rather than an apology. Red on this page would be the loudest thing in a flow whose worst outcome is "press it again".
Consuming it
Web: the --ch-* mapping printed at the top of this section, over the variables in dist/css/airmond.css. React Native: the same values are lightColors / darkColors in dist/ts/tokens.ts, which is where mb-airmond-app already reads ground, surface, ink, muted, coral, onCoral, confirm and confirmSoft — so a component here is a component there with no new token. The measured pairings above are asserted in tokens/tokens.json and re-run by build/contrast-test.mjs on every build, including the five this surface deliberately does not rely on.
Chat density
The chat at Grok's density is the standard since 2026-09-24. Cédric put app.airmond.com beside Grok's desktop app at 858 and 1283 on 2026-09-23 and approved its density, except for the emoji reactions; on 2026-09-24 he asked to keep it: “I do like the way you made it so that we can have more in one conversation now, so let's save that.” Every number below comes out of the density block in tokens/tokens.json, which records each one three times, for the web, the Mac and the native apps, and the apps mirror it.
Platform.OS, so a phone browser gets 14/20 as well; native does not move, because 14pt is too small at arm's length and nobody asked. The sidebar keeps its hero head on the desktop (2026-09-24: “on desktop, since we have space, we should still allow for a bit more room ... make the avatar larger again”): only its rows went compact. No emoji reactions: declined on 2026-09-23, so the hover rail stays Reply and More.The web, at 858
The window Cédric tested, drawn at true size: a 300 sidebar with the 108 portrait and three captioned 48pt discs over rows 32 tall at 14; a 44 header with a 24 portrait and her name at 14/600; the thread at 14/20 in bubbles with a 10 corner and 12/8 insets, 5 between two from one sender and 16 when the sender changes; and a 42 composer with 30 discs. The column is the pane less 16 each side, 526 here, so no bubble grows past min(640, 0.78 × 526) = 410. The draft card under her last message (no Send on it: Airmond never sends from the member's mailbox) keeps its own 14 corner: the bubble's corner moved to 10 and the cards' did not.
Feather (light)
AdaCan you move my Thursday flight? Anything before nine works.
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
The Lisbon hotel held the room but moved you to the street side. I have asked for the courtyard back.
Perfect. Leave the room as it is if they say no.
Will do. I also drafted the reply to Ákos about Thursday:
Low flame (dark)
AdaCan you move my Thursday flight? Anything before nine works.
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
The Lisbon hotel held the room but moved you to the street side. I have asked for the courtyard back.
Perfect. Leave the room as it is if they say no.
Will do. I also drafted the reply to Ákos about Thursday:
The two windows stack rather than sit side by side for the reason the thread's widest pair does: two 858px windows do not fit across this page, and a specimen clipped at the edge of a scroller is a specimen half-seen. There is no call mark in this header because the sidebar already offers Call; a window without the sidebar (under 720) draws it.
Native and web, the same exchange
One conversation drawn twice in the same 393px column: the native phone's density on the left of each pair, the web's on the right. The words are the same; the web fits more of them in the frame, which is the whole of what was approved. Native keeps ONE gap of 12 whoever is speaking (2026-09-14: “we should not combine messages that follow each other”); the web puts the grouping back at 5 / 16, which Cédric approved with the rest.
Feather (light)
Native · 17/26, corner 18, one 12 gap, 50 header, 48 composer
AdaCan you move my Thursday flight? Anything before nine works.
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
The Lisbon hotel held the room but moved you to the street side. I have asked for the courtyard back.
Perfect. Leave the room as it is if they say no.
Web · 14/20, corner 10, 5 / 16, 44 header, 42 composer
AdaCan you move my Thursday flight? Anything before nine works.
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
The Lisbon hotel held the room but moved you to the street side. I have asked for the courtyard back.
Perfect. Leave the room as it is if they say no.
Low flame (dark)
Native · 17/26, corner 18, one 12 gap, 50 header, 48 composer
AdaCan you move my Thursday flight? Anything before nine works.
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
The Lisbon hotel held the room but moved you to the street side. I have asked for the courtyard back.
Perfect. Leave the room as it is if they say no.
Web · 14/20, corner 10, 5 / 16, 44 header, 42 composer
AdaCan you move my Thursday flight? Anything before nine works.
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
The Lisbon hotel held the room but moved you to the street side. I have asked for the courtyard back.
Perfect. Leave the room as it is if they say no.
Every number, on every platform
Generated from the block, so the table cannot fall behind it. A bold web value is one the web draws differently from native. none means that platform has no such number, usually because the system draws that part (the Mac's titlebar, its split-view sidebar). guide marks a number the build checks and this page draws but tokens.ts does not export, because the app reads it from somewhere else and a second copy would be a second thing to keep true. The Mac column is recorded, not drawn: the Mac is SwiftUI and its numbers live in ThreadLayout.Mac, ComposerLayout.Mac and MacWindowStyle; they are here so the three shells can be read against each other in one place.
| Part | Token | Web | Mac | Native | What it sets |
|---|---|---|---|---|---|
The threadthreadWeb | bodySize | 14 | 14 | 17 | Message type. The thread has ONE size; hierarchy inside a bubble is weight and space. |
bodyLineHeight | 20 | 20 | 26 | Message line height. (The Mac sets 14pt with 3 of lineSpacing, which lands at 19.7.) | |
maxMeasure | 640 | 640 | 640 | The widest one bubble may be, before the share binds. | |
measureShare guide | 0.78 | 0.78 | 0.78 | A bubble's widest as a share of the thread column. | |
columnInset guide | 16 | 16 | 14 | The pane's edge to the thread column, both sides. Native here is the phone's thread.horizontalInset; the app's own thread.columnInset (16) is the stage value, which is this row's web column. | |
maxColumnWidth guide | none | 1000 | 900 | The widest the thread column gets. None on the web beside a sidebar: the column is the pane. | |
textBubbleHorizontalInset | 12 | 12 | 14 | Text to a bubble's left and right edge. | |
textBubbleVerticalInset | 8 | 9 | 9 | Text to a bubble's top and bottom edge. | |
bubbleCornerRadius | 10 | 14 | 18 | The bubble's corner, both speakers. Cards never read it. | |
cardCornerRadius guide | 14 | none | 14 | A card in the thread (a draft, a Slack message, a call, a connect or permission card). Cards keep 14 and never read the bubble's corner. | |
gapSameSender | 5 | 5 | 12 | Between two consecutive messages from the same sender. | |
gapNewSender | 16 | 16 | 12 | Where the sender changes. | |
The headerchatHeader / chatHeaderWeb | height guide | 44 | 40 | 50 | The band: paddingVertical x 2 + avatarSize, which the build checks for web and native. The Mac's is the system's compact titlebar, about 40. |
avatarSize | 24 | 24 | 34 | Her portrait. | |
avatarSpacing | 10 | 8 | 9 | Portrait to name. | |
paddingVertical | 10 | none | 8 | The band's own padding, the same above and below. | |
barMargin | 16 | none | 16 | The bar's horizontal margin, each side: the page gutter. | |
trailingActionWidth | 44 | none | 44 | One trailing action's tap target. | |
trailingClearance | 12 | none | 12 | The end of her name to the call mark, so a truncated name reads as truncated. | |
minimumNameWidth | 72 | none | 72 | The narrowest her name is squeezed to: about eight characters. | |
trailingActionMarginVertical | -10 | none | 0 | How far the call mark's target overhangs the band. Negative pulls it in, so the target fills the band rather than growing it. | |
nameSize | 14 | 14 | 17 | Her name's size. Native takes typography.bodyStrong, so its nameType is null. | |
nameLineHeight | 20 | none | 23 | Her name's line height. | |
nameWeight | 600 | 600 | 600 | Her name's weight: semibold everywhere. | |
The composercomposerBar / composerBarWeb | height | 42 | 42 | 48 | The bar at rest. |
control | 42 | 30 | 48 | One control's square box, which holds the disc. | |
disc | 30 | 30 | 36 | The circle drawn inside a control: height less 6 each side. | |
radius | 21 | 21 | 24 | The pill's corner: half the resting height, and a literal, so a grown field becomes a rounded box rather than an ellipse. | |
fieldRest | 34 | 30 | 40 | The text field's height with one line in it. | |
glyph | 16 | 14 | 20 | The + glyph. | |
glyphSmall | 15 | 14 | 18 | The mic and send glyphs, a step smaller. | |
textSize | 14 | 14 | 17 | The typed text and the placeholder, the same size as the messages it sends. Native takes typography.body, so its text is null. | |
textLineHeight | 20 | none | 23 | Their line height. | |
The sidebarrail / railWeb | width | 300 | 232 | 300 | Full rail width, padding included. |
paddingVertical | 20 | none | 20 | The rail's own padding, top and bottom. | |
paddingHorizontal | 12 | none | 12 | The rail's own padding, each side. | |
rowGap | 2 | none | 2 | Between two nav rows. | |
rowPaddingVertical | 6 | none | 10 | One nav row's padding, above and below its label. | |
rowPaddingHorizontal | 10 | none | 12 | One nav row's padding, each side. | |
glyphGap | 10 | none | 12 | Glyph column to label. | |
rowRadius | 8 | none | 10 | The active capsule's corner. | |
glyphSlot | 18 | none | 24 | The fixed glyph column, so every label starts on one x. | |
glyphSize | 16 | none | 20 | The glyph drawn in that column. | |
labelSize | 14 | 14 | 15 | Row label size. | |
labelLineHeight | 20 | none | 21 | Row label line height. | |
rowHeight guide | 32 | 32 | 41 | One row, derived: rowPaddingVertical x 2 + labelLineHeight. The build checks it. | |
headGap | 10 | none | 10 | Portrait to name in the head. | |
headPaddingTop | 6 | none | 6 | Above the head, under the rail's own padding. | |
headPaddingBottom | 18 | none | 18 | Under the head's actions, before the first row. | |
headPortrait | 108 | 40 | 108 | Her portrait in the head, the door to About. | |
headActionDisc guide | 48 | 24 | 48 | Call / Message / Record. 48 is layout.minTapTarget, which is what the app reads; Call is the one coral disc, the other two are surface. | |
headActionGlyph guide | 20 | 12 | 20 | The glyph inside one of those discs. | |
headActionGap guide | 12 | none | 12 | Between two discs (spacing.md). | |
headActionsSpaceAbove guide | 16 | none | 16 | Her name to the row of discs (spacing.lg). | |
headCaptionSize guide | 12 | none | 12 | The word under each disc, in ink. The Mac's discs carry no caption. | |
headCaptionLineHeight guide | 15 | none | 15 | Its line height. | |
headNameSize guide | 18 | 14 | 20 | Her name in the head: the heading step on each type ramp (typographyWide on the web). | |
headSubtitleSize guide | none | 11 | none | The Mac's one-line hotkey subtitle under her name. The hero head has none. | |
footPaddingVertical | 8 | none | 10 | The pinned foot (Settings), above and below. | |
footPaddingHorizontal | 10 | none | 12 | The same foot, each side. | |
The breakpointlayout.stageMinWindowWidth | stageMinWindowWidth | 720 | none | none | The browser width at which the sidebar appears and typographyWide takes over. |
density.thread The thread on each platform, measured against Grok (14/20 type, 12/9 insets, a corner of about 10, 5 to 6 within a sender and 16 between, about 460 wide at a narrow window and 640 at a wide one). Two web numbers moved again on 2026-09-24: the vertical inset 9 to 8, because 8 + 20 + 8 lands Grok's measured 34 px one-line bubble within two, and the bubble corner 14 to 10; the Mac has not taken either yet (9 and 14). 640 at 14pt is about 80 characters, what Grok runs at 1283. The web's bubble is min(maxMeasure, measureShare x the thread column), and the column is the pane less columnInset each side with no cap once the sidebar is there (720 and up); below 720 a browser draws the phone's 14 inset and 900 cap. Mac caps its column at 1000. The native column's numbers are checked against the thread block, so the two cannot disagree.
density.chatHeader The band above the thread: her portrait, her name, the call mark. The web is 10 + a 24 portrait + 10 = 44, and the call mark's 44pt target is pulled in by a -10 margin each way rather than growing the band to 64. The Mac uses the system's compact titlebar, which is about 40 (macOS offers 52 or about 40, and 40 is what Grok's Mac app uses). The phone keeps native's ~50pt navigation bar, 8 + 34 + 8.
density.composer The composer's one bar: +, the field and the trailing slot inside one pill. The web is the same construction one step down: 42 boxes holding 30 circles, so the 6 inset survives on every side. The Mac keeps its square control exactly as tall as the field (30), which is the relationship its bottom-aligned row depends on. Coral appears here only on the armed send disc.
density.rail The sidebar. On the web the HERO HEAD is back since 2026-09-24 (Cédric: "on desktop, since we have space, we should still allow for a bit more room ... make the avatar larger again"): the 108 portrait, her name, and Call / Message / Record as captioned 48pt discs, on native's 6 / 10 / 18 spacing, on a 300 rail at every window from 720. Only the ROWS stay compact: 32 tall at 14 (6 + 20 + 6) with a 16 glyph in an 18 column, where native's are 41 at 15. The Mac kept the 2026-09-23 compact head (a 40 portrait, name 14/600, an 11pt one-line subtitle, 24pt Call and Record discs) on a 232 sidebar. The native iPad keeps its rail exactly as it was.
density.layout Where a browser grows the sidebar. 720, was 1000 (Cédric, 2026-09-23): at his 858 window the app dropped to the phone layout, a bottom tab bar under a 626 pane, where Grok beside it kept its sidebar. At 720 the 300 rail leaves a 420 pane whose widest bubble (286 measured: the hover rail's reserved slot binds at 388 - 90 = 298, before the 0.78 share) is still wider than a phone's (262 at 393). ONE switch for the sidebar AND the wide type ramp, because a window with a sidebar is a desktop window. The native iPad keeps its own 768 rule; the Mac always has its sidebar.
The build checks the block against itself and against the thread. Every native thread number must equal the thread block's (the MessageRun gaps included, which is how the old 4 / 12 was caught: neither app drew it); a header must be its padding twice plus its portrait; a composer's disc must sit 6 inside its bar and its corner be half the bar; a sidebar row must be its padding twice plus its line. A value that breaks one of those fails npm run build, not a review.
The colours did not move
The density pass changed sizes and nothing else: every fill is an existing token, coral is still only the Call disc and the armed send. Two things are new enough to measure, because the type came down a step and the sidebar grew a head again.
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Message text at 14/20 | ink on her bubble, held to the same 7.0 the 17/26 thread was | 14.71:1 | 11.24:1 |
| The header's name at 14/600 | ink on the header band (surface) | 16.12:1 | 11.24:1 |
| The composer's 14px placeholder | muted on the field (groundAlt) | 6.11:1 | 6.59:1 |
| The hero head's name and captions | ink on the rail ground | 14.71:1 | 13.26:1 |
| A sidebar row at rest, 14px | muted on the rail ground | 6.11:1 | 6.59:1 |
| The active row | ink on the quiet capsule | 14.03:1 | 9.24:1 |
| Message and Record glyphs | ink on the surface disc (3.0, a glyph) | 16.12:1 | 11.24:1 |
| The Call glyph | onCoral on coral: ink, never white | 5.76:1 | 5.92:1 |
| The surface disc against the rail | not relied on: the glyph and the caption find it | 1.10:1 must stay under 3 | 1.18:1 must stay under 3 |
| The coral disc against the rail | identity, not a boundary: in Feather the glyph and the caption find it | 2.55:1 must stay under 3 | 6.45:1 |
| The header's bottom edge | border over the page: texture under the band, not a mark anyone has to find | 1.16:1 must stay under 3 | 1.54:1 must stay under 3 |
Every pairing above that the surface relies on is a claim in tokens.json, re-run by build/contrast-test.mjs; the two discs are recorded as must-fails so nobody later credits a plate with doing the work its glyph does. The header's edge measures 1.16:1 and is texture by the rule on the thread's marks: the band is told from the thread by its fill and its contents, and nothing is lost if the line is not seen.
The thread
The thirteen components the conversation itself is made of, under the cards: the bubble both speakers use, the structured answer she writes inside one, the long message of his that folds, the run of messages from one speaker, the rail that appears beside a message, the timestamp, the day break, the unread divider, the quiet line that records something happening, code, the draft she wrote for you to review and open in your own mail, the pill that says how many messages you have not read, and the action card that steps back once its work is finished. Every specimen is drawn in both schemes, and every distance in them is read out of one block in tokens/tokens.json.
border, which is toned for the page ground and measures 1.21:1 on a bubble; the quote's rule was full muted at 6.11:1. All three are --th-rule now: see the section below. There is no reaction button: the rail is reply and a three-dot menu, because Airmond is one member and one assistant, and a reaction to your own assistant has no reader. faint is unusable on this surface: it measures 2.81:1 on the bubble fill in Feather and 3.35:1 in Low flame, so every marker, timestamp, caption and footnote here is muted (6.11:1 and 5.58:1). The unread label is coral-deep, never coral: full coral on the thread ground measures 2.68:1, which fails the 4.5 text bar and the 3.0 non-text bar alike, against 4.97:1 and 7.61:1 for coral-deep. Every one of them is claimed in the token file and re-run by build/contrast-test.mjs.The geometry, and the one place it lives
Everything below is drawn from the thread block in tokens/tokens.json. That block is the single source, and it is new: until 2026-09-14 these numbers existed twice, once in the React Native app and once in the iOS package, with nothing joining them, and the two had already drifted far enough that settling an argument about a disc and a glyph meant reading the approved artifact's own CSS. Both apps now mirror this block, which is why it is printed here in full rather than described. Sizes are px, which the Mac reads as points.
| Group | Token | Value | What it governs |
|---|---|---|---|
| measure | maxBubble | 640px | The widest one bubble is allowed to be. Text stops growing here and wraps. |
maxColumn | 900px | The widest the thread’s own column is allowed to be, bubbles and centred lines alike. | |
| bubble | cornerRadius | 18px | The bubble’s corner, both speakers. |
borderWidth | 1px | Every hairline in the thread: the bubble edge, the rule under a section, the unread rule, the plate around code. | |
insetHorizontal | 14px | Text to the bubble’s left and right edge. Also the gap either side of the unread label, and half of it is the inline plate’s side inset. | |
insetVertical | 9px | Text to the bubble’s top and bottom edge. Also how far the rail and the timestamp sit off the row’s baseline, so they line up with the last line of text. | |
| body | size | 17px | The thread has ONE type size. Hierarchy inside a bubble is weight and space, never a second size. |
lineHeight | 26px | The line every block value is measured on top of. | |
| block | paragraphGap | 8px | Extra space between two paragraphs, and between any two blocks that are not a heading. |
listItemGap | 6px | Extra space between two items of one list. | |
headingSpaceAbove | 24px | Extra space above a section heading. | |
headingSpaceBelow | 12px | Extra space below a section heading. A horizontal rule takes the same value on both of its sides. | |
markerToText | 22px | A bullet’s left edge to its text, which is also where a wrapped line returns to. A blockquote is inset by the same amount. | |
hangingIndent | 28px | The same distance for a NUMBERED list, wider because a number is wider than a dot. | |
markerDiameter | 5px | The bullet itself, centred on the first line. | |
| run | gapWithinRun | 12px | Between two consecutive messages from one speaker, on the native phone. The same number as the next row since 2026-09-14: native has one gap. The web and the Mac tighten it (density.thread.gapSameSender). |
gapBetweenSpeakers | 12px | Between the last message of one speaker and the first of the next, on the native phone (density.thread.gapNewSender on the web and the Mac). | |
| rail | bubbleToFirstIcon | 12px | The bubble’s edge to the first icon’s tap target. The timestamp keeps the same distance from the last icon. |
iconPitch | 26px | Centre to centre between two rail icons. | |
iconSize | 15px | The drawn glyph inside that tap target. | |
tapTarget | 22px | The pointer hit box each glyph sits in. On touch the rail is not tapped at all, so this is not the 44pt touch minimum and is not trying to be. | |
revealMs | 120ms | How long the rail takes to appear on hover. Collapses to nothing under reduced motion. | |
| stamp | size | 12px | The timestamp, and the unread label. |
tone | muted | The timestamp’s tone, resolved from the palette rather than hard-coded. | |
| dayBreak | size | 12.5px | A centred line: the day break, and the activity line that copies it. |
spaceAbove | 22px | Above a centred line, and above the unread divider, which is the same furniture. | |
spaceBelow | 16px | Below both of them. | |
tone | muted | The centred line’s tone, resolved from the palette. | |
clock | 24h | 24h, everywhere a time is written, including the timestamp. | |
| unread | label | NEW | The word in the break of the rule. |
labelTone | coral-deep | That word’s tone, resolved from the palette. Coral-deep, never coral. | |
ruleTone | border | The hairline either side of it. The one mark in the thread allowed under 3.0, because its LABEL carries the reading at 4.97:1. | |
| draft | cornerRadius | 14px | The draft card’s corner. Not the bubble’s: a card is a different object and says so. |
headPadding | 16px | The head’s padding on every side. | |
headGap | 12px | The pen tile to the title block. | |
markSize | 44px | The tile the pen sits on, in the head. | |
markRadius | 10px | That tile’s corner. | |
markGlyph | 18px | The pen drawn on it. | |
captionGap | 2px | The title to the caption under it. | |
titleSize | 17px | “Draft reply”: the app’s subheading step. | |
titleLineHeight | 23px | Its line height. | |
captionSize | 13px | Who and what: “To … · Re: …”. It wraps rather than cuts, because the address is the fact a member checks before opening. | |
captionLineHeight | 18px | Its line height. | |
bandPaddingHorizontal | 16px | The band her text sits in, each side. | |
bandPaddingVertical | 12px | The same band, above and below. | |
bandGap | 12px | Her text to “Read all”; and in the replaced strip, the arrow to its words. | |
textSize | 15px | Her text: the app’s callout step. | |
textLineHeight | 22px | Its line height. | |
footPadding | 16px | The foot’s padding on every side. | |
footGap | 12px | Between the quiet control and the primary. | |
quietPadding | 8px | “Change it”: a word with this padding and no plate. | |
actionHeight | 38px | The one primary’s height. | |
actionInsetHorizontal | 16px | Its side padding. | |
actionRadius | 14px | Its corner. | |
previewLines | 6px | Lines of her text shown before “Read all”. | |
previewLinesReplaced | 3px | Lines a REPLACED card shows: the record is the lighter of the two. | |
previewChars | 360px | Characters shown before “Read all”, whatever the line count. The cut is made at a word, in the text, never by the renderer. | |
previewCrumb | 40px | A remainder this short is shown rather than hidden: a “Read all” that reveals one word is a control that lied about what it was hiding. | |
title | Draft reply | The card’s title. | |
changeLabel | Change it | The quiet control: puts this message in the composer’s reply quote. | |
openLabel | Open in | The primary, followed by the mailbox’s client: “Open in Gmail”, “Open in Outlook”. | |
openFallbackLabel | Open the draft | The primary when the client is unknown. | |
readAllLabel | Read all | Opens the rest of her text in place. | |
showLessLabel | Show less | Closes it again. | |
replacedLabel | Replaced by the draft below | The record a superseded card becomes, in place of its foot. | |
| newMessages | height | 28px | The pill’s height. |
cornerRadius | 14px | Its corner: exactly half the height, so it is a capsule. | |
edgeInset | 12px | How far it floats in from the edge it anchors to, top or bottom. | |
insetLeading | 9px | The arrow to the leading edge. | |
insetTrailing | 10px | The dismiss hit box to the trailing edge. | |
gap | 9px | Arrow to label, and label to dismiss. The same number both sides. | |
arrowSize | 14px | The drawn arrow, up at the top anchor and down at the bottom. | |
dismissHit | 16px | The ✕’s hit box. | |
dismissSize | 10px | The ✕ drawn inside it. | |
labelSize | 13px | The count and its word. | |
labelNumerals | tabular | Tabular, so the count does not jitter going from 9 to 10 under the member’s eye. | |
label | new messages | The words after the count. A token, like the unread divider’s label, because both apps draw them. | |
labelSingular | new message | The same words at a count of one. A pill reading “1 new messages” is a pill nobody read back. | |
| longMessage | capLines | 14px | How many lines of his body a folded message shows, counted in the body’s OWN line height (364px at 17/26, 280px at the web’s 14/20). Decided from the rendered height, never from a character count. |
minHiddenLines | 3px | The fewest lines a fold may hide. A message only just over the cap is drawn whole: a control that reveals one or two lines costs the space it saves. | |
fadeLines | 2.5px | How much of the clip the fade covers, in lines. Transparent at the top, the bubble’s own fill at the bottom. | |
controlGap | 4px | The clipped body to the control under it. | |
controlWeight | 600px | The control’s weight. Its size is the body’s own and its colour is the body’s ink. | |
moreLabel | Show more | The control while folded, followed by a chevron pointing down. | |
lessLabel | Show less | The control once opened, the same chevron turned 180 degrees. | |
| actionCard | cornerRadius | 14px | The card’s corner: the thread’s card radius, not the bubble’s. |
padding | 12px | The card’s padding on every side. | |
gap | 12px | Between the card’s rows: head, brief, transcript or report, foot. | |
markSize | 36px | The disc the kind’s glyph sits on (a phone on confirm-soft for a call). | |
markGlyph | 18px | The glyph drawn on it. | |
briefRuleWidth | 2px | The rule beside the brief, in the mark tone. | |
briefInset | 12px | The brief’s text off that rule. | |
insetRadius | 10px | The corner of the inset the transcript line or the report sits on. | |
insetPadding | 12px | That inset’s padding. | |
insetGap | 2px | The speaker’s name to the line under it. | |
pillInsetHorizontal | 8px | The status pill’s side padding. | |
pillInsetVertical | 2px | Its top and bottom padding. | |
pillDot | 7px | The dot in the pill. It breathes while the call is live and stops when it is not. | |
titleSize | 17px | The title, the thing the card is about (the recipient on a call): bodyStrong. | |
titleLineHeight | 23px | Its line height. | |
detailSize | 15px | Every explanatory line: the brief, the transcript line, the report. The callout step. | |
detailLineHeight | 22px | Their line height. | |
labelSize | 13px | The caption (the number), the speaker’s name and the pill’s word. | |
labelLineHeight | 18px | Their line height. | |
titleTone | ink | The title’s tone, live AND finished. It is what keeps a finished card scannable. | |
liveDetailTone | ink | The transcript line while the card is live: ink, because it is news. | |
detailTone | muted | Every explanatory line once the card’s work is finished. Must clear 4.5 on the card and on its inset in both schemes, and is claimed. |
thread.measure maxBubble caps one bubble; maxColumn caps the thread's own column. The app's layout.maxContentWidth is a DIFFERENT number read by 39 other screens and must not be conflated with either.
thread.block Spacing INSIDE a bubble holding a structured answer. Every value is the EXTRA space on top of the 26px line rather than a total, so a change to lineHeight carries through instead of being silently overridden.
thread.run THE NATIVE PHONE'S GAPS: ONE 12, NOT A PAIR. A run is consecutive messages from one speaker. Until 2026-09-24 this said 4 within a run and 12 between speakers, which neither app drew: the phone and the iPad collapsed the pair into one 12 on 2026-09-14 (Cédric: "we should not combine messages that follow each other"), and the Mac and the web both moved to 5 / 16 on 2026-09-23. The Mac agent found this block behind all three shells on 2026-09-23 (mb-airmond-ios #530). So this block now states what the phone draws, and the desktop pair lives in `density.thread.gapSameSender` / `gapNewSender`, where the build checks the native column against these two keys. A timestamp at rest belongs to the NEWEST message and to no other on every platform, whatever the gap: that is how a touch device shows the time without a hover to reveal it.
thread.rail The controls beside a message, revealed on hover on a pointer device and by long press on touch. Reply and a three-dot menu, and NO reaction button: decided 2026-09-14, because Airmond is one member and one assistant and a reaction to your own assistant has no reader.
thread.stamp tone is muted and NOT faint. faint measures 3.08:1 on this surface and 2.81:1 on the panel, so it fails the body-text bar and the non-text bar alike; the must-fail claims below are what say so if somebody reaches for it again.
thread.dayBreak Plain centred text carrying the day AND the time, with no pill and no rule around it. The time is the time of the FIRST message after the break, because the break introduces what follows rather than closing what came before.
thread.unread A hairline across the measure, broken for a centred label. Drawn only when at least one message sits ABOVE it: a rule at the very top of a thread labels the whole thread and says nothing.
thread.draft THE DRAFT SHE WROTE, shown in the thread: mb-airmond-app's DraftCard (src/features/drafts/draft-card.tsx, MAR-173 and MAR-174), mirrored here on 2026-09-24. ⛔ THERE IS NO SEND, AND THAT IS THE RULE, NOT A GAP: Airmond never sends from a member's mailbox (Cédric; the api's identity/never-send.ts, three layers including a load-time seal on the SMTP submission ports), so the card has no Send button and no Sent, Sending or Did-not-send state, and the library never draws one. It is review-and-edit: `Open in Gmail` (`Open in Outlook` for a Microsoft mailbox, `Open the draft` when the client is unknown) leaves the app for the draft in his own mail client, the one place it can go out from, and `Change it` puts this message in the composer's reply quote. Revisiting Send is Cédric's decision, not a pull request. THE HEAD: a pen on a `groundAlt` tile, the title `Draft reply`, and a caption naming who and what (`To akos@… · Re: Thursday in Lisbon`, never `Re: Re:`). HER TEXT sits in a band under a hairline, cut IN THE TEXT (not by the renderer) to `previewLines` lines or `previewChars` characters, whichever comes first, at a word, with a `Read all` in the working accent; a remainder under `previewCrumb` characters is shown rather than hidden. THE FOOT: a quiet `Change it` (muted text, no plate) and the ONE ink primary; no coral, no green, nothing faint. THE REPLACED RECORD (MAR-174): when she wrote a newer draft, this card keeps its head and her words (fewer lines, muted) and a `groundAlt` strip with a down arrow says `Replaced by the draft below` in place of the foot, both verbs gone; a used card stays, lighter than the one below it. THE COMPACT CARD: a draft with no stored text draws the head and the primary alone. The card's edge and its band hairlines are the `mark` tone per the rule above (the app still draws them in `border`; recorded, not copied).
thread.newMessages THE FLOATING PILL that says how many messages you have not read, centred in the thread column and `edgeInset` in from the edge it anchors to. ONE component, TWO anchors: unread BEHIND you gives a pill at the top with an up arrow that scrolls to the unread divider, and new messages arriving BELOW while you read history give a pill at the bottom with a down arrow. Same count either way, so the member never has to work out which of two components he is looking at. INK FILLED WITH GROUND TEXT: not confirm, which on this system means confirmed and succeeded and live and would be claiming something untrue about a message nobody has read; and not coral, which the thread has already spent on the composer's send disc. The ✕ dismisses the pill WITHOUT marking anything read: dismissing a reminder is not reading the messages, and a pill that quietly marked them read would destroy the one piece of state the member came back for. The label is TABULAR so it does not jitter as the count goes from 9 to 10 while he is looking at it.
thread.longMessage HIS LONG MESSAGE FOLDS (2026-09-26, mb-airmond-app #324, approved by Cédric: "Both look great."). Grok Bot's pattern: a text message OF THE MEMBER'S taller than `capLines` lines of body is clipped to exactly that many lines, its last `fadeLines` lines are covered by a gradient from transparent to THE BUBBLE'S OWN FILL (memberBubble, or a flavour's own member fill; no new colour, no grey, no hard line), and a `moreLabel` with a chevron sits bottom-left inside the bubble on the text's own left edge. One tap opens it in place and the control becomes `lessLabel` with the chevron turned 180 degrees; per message, in memory, never animated in height. ⛔ THE MEMBER'S OWN WORDS ONLY: never her answer (it is what he came to read, and folding it would fold a streamed answer under him the moment it landed), never a bubble with an attachment, a voice note, a call or a machine card, never a failed send (its retry wrapper is one accessibility element, so the control would be unreachable). ⛔ MEASURED IN RENDERED HEIGHT, NEVER IN CHARACTERS: the body's laid-out height against `capLines` x the body's own line height (364 at the phone's 17/26, 280 at the web's 14/20), and it folds only when at least `minHiddenLines` more lines would be hidden, because a control that reveals one or two lines costs the space it saves. The control is the body's INK at `controlWeight`, the body's own size, never muted and never onCoral (nothing quiet goes inside his bubble). On the web the clip is `overflow: clip`, not `hidden`, so find-in-page and a focused link cannot scroll it. The clipped text stays in the tree: a screen reader, copy and a reply quote act on the whole message.
thread.actionCard AN ACTION CARD IN THE THREAD, and what it does when its work is finished (2026-09-26, mb-airmond-app #319, variant B of three, from Grok Bot's dismissed action card; approved by Cédric: "Both look great."). CallCard is the first instance and the one drawn here: a neutral `surface` card inside the call's bubble, a phone on a `confirmSoft` disc, the recipient as the TITLE, the number as a caption, a status pill, the brief behind a rule, then the last transcript line (or the report, once it lands) on an inset, and a secondary `Follow call` / `View call`. THE RULE, for every action card: while it is live nothing changes; once its work is finished (a call ended or failed, or unavailable with its report in; generally approved, dismissed, done) its title stays `titleTone` and every explanatory LINE (brief, report, last transcript line, consequence text) takes `detailTone`. Labels (the speaker's name, the status pill) and controls keep their own tone. The finished card stops competing with the live messages while its title stays scannable. `detailTone` must clear the 4.5 text bar on EVERY surface it is drawn on, in both schemes: the card (`surface`) and its inset (`groundAlt` in Feather, `surfaceRaised` in Low flame). Claimed below; if one fails, the rule stops rather than ships. The card's edge, the brief's rule and the foot's hairline are the `mark` tone here per the 2026-09-14 rule (the app draws `border` / `borderStrong`; recorded, not copied).
The table is generated from the block, and the block is guarded in both directions. A key the token file loses throws at build time, and a key it gains that nothing here explains throws too, so this table cannot quietly fall behind the values the apps are mirroring. The notes above are the token file's own words, printed rather than paraphrased; where one of them quotes a number measured on a neighbouring surface (stamp's 3.08:1 is the chat card, which is not a thread fill), the measured table under each component below is the authority.
The specimen palette
Each specimen carries one scheme's palette on itself as the --th-* set, mapped once in site/thread.mjs. There is no hex in the section. Five of the tones the block names as strings (stamp.tone, dayBreak.tone, unread.labelTone, unread.ruleTone, mark.tone) are resolved through this table at build time rather than hard-coded, so changing one of those words in the token file actually changes what the page draws. The last two rows are derived rather than read: one ink at two strengths, as rgba, so they composite over whichever surface they are actually painted on.
| Variable | Token | What it draws |
|---|---|---|
--th-ground | rn.<scheme>.ground | The thread itself, behind every bubble, every centred line and the pill that floats over it. |
--th-surface | rn.<scheme>.surface | The one raised surface the thread has: the card a draft opens in. |
--th-ink | rn.<scheme>.ink | Ada’s words, the member’s words, a heading, an identifier, and the new-messages pill’s own fill. |
--th-muted | rn.<scheme>.muted | Every marker, timestamp, caption, quote and centred line, and the ink every separating mark is drawn from. Quiet text here is muted and never faint: see the corrections above. |
--th-confirm | rn.<scheme>.confirm | A link inside a bubble, and Read all on a draft. The working accent, never coral. |
--th-coral | rn.<scheme>.coral | Drawn once on this page, in the unread comparison, to show why the label is not coral. |
--th-border | rn.<scheme>.border | The bubble’s edge and the unread rule, and nothing else. It is the hairline token for the PAGE ground: on a bubble it measures 1.21:1, so no mark that has to be found may use it. See --th-rule. |
--th-alt | rn.<scheme>.groundAlt | The draft card’s pen tile, and the strip a replaced draft becomes. |
--th-primary-fill | rn.<scheme>.primaryFill | The one FILLED control the thread draws: Open in Gmail on a draft. Ink, not coral. There is no Send: Airmond never sends from a member’s mailbox. |
--th-on-primary | rn.<scheme>.onPrimary | The label on that fill. |
--th-confirm-soft | rn.<scheme>.confirmSoft | The call’s own bubble, which an action card sits inside, and the disc under a call card’s phone. Nothing quiet is ever drawn directly on it: muted there measures 4.05:1 in Low flame. |
--th-coral-deep | rn.<scheme>.coralDeep | The unread label, and nothing else in the thread. Coral-deep rather than coral: full coral misses the text bar AND the non-text bar on this ground. Exported in the app set on 2026-09-14; before that it existed only as a web token, so the app could not reach it and the label shipped as full coral. |
--th-bubble-her | <scheme>.muted | Ada’s bubble. The web token --muted is a BACKGROUND (#F1F5F8 / #1F2833) and has nothing to do with rn.<scheme>.muted, which is a foreground; the app set reaches the same two values as lightColors.groundAlt and darkColors.surface, and has no single name for the pair yet. That is a wart this table records rather than hides. |
--th-bubble-me | <scheme>.bubble-member | The member’s own bubble, blue-soft in both schemes and never coral. |
--th-edge | <scheme>.border | The specimen’s own outer edge, so a light specimen on a dark page still looks like a card. |
--th-rule | muted at thread.mark.ruleOpacity (0.7) | EVERY MARK IN THE THREAD THAT SEPARATES OR ENCLOSES: the rule under a section heading, the quote’s rule, the plate around code, and a draft card’s own edge. Was border for two of those and full muted for the third. |
--th-plate | muted at thread.mark.plateFillOpacity (0.14) | The tint under inline code and under a fenced block. A tint, not a boundary: the edge above is what draws the plate. |
--th-card-inset | rn.light.groundAlt / rn.dark.surfaceRaised | The inset inside an action card that holds the last transcript line or the report. The tightest surface the finished-card rule draws muted on. |
The marks that separate, and the measurement that was wrong
The rule, and it applies to every component this kit gains: a mark that separates or encloses must clear the 3.0 non-text bar on the surface it ACTUALLY sits on. Not on the page the specimen was measured against. The thread had three marks doing one job and had got two of them invisible and the third six times louder than the other two, and the cause is a single habit: the kit measures a tone in the context its specimens live in, which is a page, while the product draws the same tone inside a bubble that already has an edge.
border is the hairline for the page ground and it clears 3.0 there. Inside a bubble the same token composites to #DBE0E4 and #363E48 and measures 1.21:1 and 1.38:1 against the fill. So a thread mark can never use border, no matter what the page does. All three marks are --th-rule now: muted at 0.7, which is the lowest 0.05 step that clears 3.0 in both schemes (3.18:1 and 3.50:1; the step below it, 0.65, measures 2.87:1 in Feather and misses). The same pair already ships on the Mac, which is where the self-consistent version existed first.
| The mark | Was | Feather | Low flame | Is now | Feather | Low flame |
|---|---|---|---|---|---|---|
| The rule under a section heading | border | 1.21:1 must stay under 3 | 1.38:1 must stay under 3 | rule | 3.18:1 | 3.50:1 |
| The rule beside a quote | muted | 6.11:1 | 5.58:1 | rule | 3.18:1 | 3.50:1 |
| The plate around code, its edge | border | 1.21:1 must stay under 3 | 1.38:1 must stay under 3 | rule | 3.18:1 | 3.50:1 |
muted 0.5, which measures 2.16:1 here; we take 0.7 instead, because with a fill this quiet a 0.5 edge leaves the plate drawn by nothing measurable at all. The fill moved with it, from the quiet wash to the same ink at 0.14, so the plate and its edge now come off ONE ink rather than two unrelated tokens.The one mark allowed under 3.0, and why it is not an exception to the rule. The unread divider's rule measures 1.22:1 on the thread ground and stays exactly where it was. The rule above has a clause: unless the component draws something else at the body-text bar that carries the same meaning. The unread divider draws its label at 4.97:1, and that label is what tells you where you stopped reading. The section rule, the quote rule and the plate edge draw nothing else at all, which is why none of them may borrow the same argument. A hover mark is the other case: it appears and disappears under the pointer, and a change reads far below the ratio a static edge needs.
site/thread.mjs throws if a member row is given a mark, so this page cannot ship the combination it forbids, and the surface below is drawn so the reading is on screen rather than only in a claim.Feather (light)
What the rule is about: a mark on HER fill, where it clears
Swiss had nothing before 07:40.
Your car is booked for 05:50.
His bubble, carrying no mark at all, which is the rule
Move my Thursday flight if Swiss has anything earlier.
Low flame (dark)
What the rule is about: a mark on HER fill, where it clears
Swiss had nothing before 07:40.
Your car is booked for 05:50.
His bubble, carrying no mark at all, which is the rule
Move my Thursday flight if Swiss has anything earlier.
| The mark, by the fill it sits on | Feather | Low flame |
|---|---|---|
| On Ada’s bubble, where every mark in the thread is drawn | 3.18:1 | 3.50:1 |
| On the member’s bubble, the one surface it does not clear | 3.07:1 | 2.79:1 must stay under 3 |
| Quiet text on the member’s bubble, the rule this extends | 5.74:1 | 4.05:1 must stay under 4.5 |
Bubble
The rule: one anatomy, two fills, two sides. Hers is the neutral wash on the left, the member's is the blue-soft fill on the right, and both hug their text and stop at measure.maxBubble (640px). There is no avatar, no name and no tail: a thread with one member and one assistant never has to answer which of nine people said this, and every pixel spent saying so is a pixel not spent on the words. Neither fill separates from the thread ground by luminance, which is deliberate and is why the hairline and the side carry the reading.
Feather (light)
One line, hers
One line, the member’s
Multi-paragraph, 8px between
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
Low flame (dark)
One line, hers
One line, the member’s
Multi-paragraph, 8px between
Swiss had nothing before 07:40, so I took that one and released the 11:20.
Your car is booked for 05:50, which puts you at the gate with twenty minutes spare.
Feather (light)
At the cap (640px), hugging beside it, drawn at true width
Three things came in while you were flying. Windy wants its password again, so the weather line on tomorrow’s brief is a day old. The Lisbon hotel confirmed but moved you to a room facing the street, and I have asked for the courtyard side back. Nothing needs you tonight.
Low flame (dark)
At the cap (640px), hugging beside it, drawn at true width
Three things came in while you were flying. Windy wants its password again, so the weather line on tomorrow’s brief is a day old. The Lisbon hotel confirmed but moved you to a room facing the street, and I have asked for the courtyard side back. Nothing needs you tonight.
That last pair is drawn at measure.maxBubble, at true width, which is why it is the one pair on this page with its two schemes stacked rather than side by side: two 640px threads do not fit across this column, and capping the specimen to the column instead would have the container doing the work the token is supposed to do. Both schemes are still on screen together, which is what the rule is for.
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Ada’s words | ink on the bubble fill | 14.71:1 | 11.24:1 |
| The member’s words | ink on the member bubble | 13.82:1 | 8.14:1 |
| Ada’s bubble against the thread | a non-relationship by design: the hairline and the side carry it | 1.05:1 must stay under 3 | 1.12:1 must stay under 3 |
| The member’s bubble against the thread | the same, one step further from the ground | 1.11:1 must stay under 3 | 1.55:1 must stay under 3 |
| Quiet text inside HER bubble | muted on the bubble fill | 6.11:1 | 5.58:1 |
| Quiet text inside the MEMBER’S bubble | muted on the member bubble, which is why there is never any | 5.74:1 | 4.05:1 must stay under 4.5 |
faint, on either fill | the tone this surface does not use | 2.81:1 must stay under 4.5 | 3.35:1 must stay under 4.5 |
Nothing quiet ever goes inside the member's bubble. Muted on that fill measures 4.05:1 in Low flame, under the body bar, and the fix is not a new tone: his bubble holds his words and nothing else. Every caption the thread has (the timestamp, the day break, the activity line, the unread label) sits on the thread ground instead, where muted measures 6.40:1 and 6.27:1. The claim above is recorded as a must-fail so the rule survives somebody deciding a timestamp would look nice in there.
LongAnswer
The rule: a structured answer is one bubble, and the thread has one type size. Title, section heading, bullets, a numbered list, a link, a quote, a rule, inline code and a fenced block all live inside a single bubble, spaced by the block values and by nothing else. Hierarchy is weight and space: there is no second font size anywhere in the thread, because a bubble that starts growing headings turns a conversation into a document.
Every block value is the extra space on top of the 26px line rather than a total, so changing the line height carries through instead of being quietly overridden. A bullet's marker is 5px and its text starts 22px in; a numbered item gets 28px because a number is wider than a dot, and a wrapped line in both returns to the text edge rather than sliding under the marker.
Feather (light)
Thursday, the short version
The flight moved and the hotel held. Two things want you before Wednesday.
What I changed
- Zurich to Lisbon is now LX318, 07:40, seat 4A.
- The 11:20 is released, so nothing is double booked.
- The car comes at 05:50 instead of 09:15.
What I need from you
- Say yes to the courtyard room and I will confirm it tonight.
- Windy wants its password again before the weather line is current.
The airline’s own page has the new boarding time: swiss.com booking LX318.
Your booking has been changed. New departure 07:40, boarding from 07:05, gate A62.
The key I hold for the weather feed is FATHOM_API_KEY, and it never leaves the vault.
every weekday 07:00 brief: calendar, weather, flights
Low flame (dark)
Thursday, the short version
The flight moved and the hotel held. Two things want you before Wednesday.
What I changed
- Zurich to Lisbon is now LX318, 07:40, seat 4A.
- The 11:20 is released, so nothing is double booked.
- The car comes at 05:50 instead of 09:15.
What I need from you
- Say yes to the courtyard room and I will confirm it tonight.
- Windy wants its password again before the weather line is current.
The airline’s own page has the new boarding time: swiss.com booking LX318.
Your booking has been changed. New departure 07:40, boarding from 07:05, gate A62.
The key I hold for the weather feed is FATHOM_API_KEY, and it never leaves the vault.
every weekday 07:00 brief: calendar, weather, flights
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| Title, heading and body | ink on the bubble fill | 14.71:1 | 11.24:1 |
| Bullets and list numbers | muted on the bubble fill (a mark, so 3.0, and it clears the text bar anyway) | 6.11:1 | 5.58:1 |
| The quote’s words | muted on the bubble fill | 6.11:1 | 5.58:1 |
| The rule under a section, and the quote’s rule | rule on the bubble fill (a mark, so 3.0): the correction above | 3.18:1 | 3.50:1 |
border as either of those rules | the tone they used to be: the page’s hairline, invisible on a bubble | 1.21:1 must stay under 3 | 1.38:1 must stay under 3 |
| A link | confirm on the bubble fill, the working accent and never coral | 6.15:1 | 6.79:1 |
| Code, inline and fenced | ink on the tinted plate | 12.10:1 | 8.85:1 |
| The plate’s EDGE against the bubble | the same rule, because a fill this quiet cannot be what draws the plate | 3.18:1 | 3.50:1 |
| The plate’s FILL against the bubble | a tint, not a boundary, and claimed as one | 1.22:1 must stay under 3 | 1.27:1 must stay under 3 |
The rule under a section takes the heading's lower space on both sides. It does the same job a heading does, which is to separate, so it borrows the same number rather than introducing one of its own. The same applies to the quote: it is inset by block.markerToText, the distance a bullet already uses, so the bubble has one left edge for indented things instead of two. Both of them take the same tone as well as the same spacing, which they did not until 2026-09-14: the section rule was border at 1.21:1 and the quote's was full muted at 6.11:1, two marks doing one job five times apart.
MessageRun
The rule: the gap depends on the platform, and this block is the native phone's. On the phone and the iPad every message sits 12px from the one before it, whoever sent it (2026-09-14, Cédric: “we should not combine messages that follow each other”), so the side and the fill are what say who is speaking. On the web and the Mac the grouping is back, approved with the rest of chat density on 2026-09-23: 5px between two from one sender and 16px when the sender changes, drawn in that section at the web's own type. Until 2026-09-24 this block said 4 and 12, which no app drew; the build now checks it against the density block's native column.
Feather (light)
Three from her, then the turn · one 12px gap on native
Low flame (dark)
Three from her, then the turn · one 12px gap on native
The timestamp does not key off a run. A time at rest belongs to the NEWEST message in the thread, plus whichever message the member has opened the menu on, so a touch device shows when the conversation last moved without a time under every bubble, which would be a log rather than a conversation.
HoverRail
The rule: reply and a menu, and nothing else. No reaction button. Airmond is one member and one assistant: a reaction on your own assistant's message has no reader, and a reaction on your own message has no sender. What is left is the two things a member actually does with a message, so the rail is 15px glyphs in 22px hit boxes at a 26px pitch, starting 12px off the bubble.
At rest it is invisible and its space is still reserved, so a bubble never moves under the pointer. It fades in over 120ms and collapses to nothing under prefers-reduced-motion. Both icons are drawn SVG symbols: a text glyph renders as an empty box, which is the defect this whole component family inherited its icon rule from.
Feather (light)
At rest, the space reserved
Revealed on hover
The same on the member’s side, mirrored
Low flame (dark)
At rest, the space reserved
Revealed on hover
The same on the member’s side, mirrored
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The two glyphs | muted on the thread ground (a mark, so 3.0) | 6.40:1 | 6.27:1 |
faint as those glyphs | it misses even the 3.0 mark bar in Feather; in Low flame it clears that and still misses the 4.5 a word needs | 2.94:1 must stay under 3 | 3.76:1 must stay under 4.5 |
22px is a pointer hit box, not a touch target. On touch this rail is not tapped at all: it is reached by long press on the bubble, which is a target the size of the message. So the 22px box is held to what a mouse needs, and the 44pt touch minimum is answered by the bubble rather than by shipping a rail nobody can hit.
Timestamp
The rule: one home, two ways of reaching it. The time sits after the rail, 12px past it, at 12px in muted, and it is the same place in both cases. On a pointer device it is revealed with the rail on hover. On touch there is no hover, so it is shown at rest on the newest message and on the one whose menu is open, and on no other, which is how a thread gets a time without every bubble carrying one.
Feather (light)
Pointer, revealed on hover beside the rail
Touch, at rest on the newest message
Low flame (dark)
Pointer, revealed on hover beside the rail
Touch, at rest on the newest message
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The time | muted on the thread ground | 6.40:1 | 6.27:1 |
faint as the time | the original choice: under the 3.0 mark bar in Feather, under the 4.5 text bar in both | 2.94:1 must stay under 3 | 3.76:1 must stay under 4.5 |
24h everywhere, including here. The clock is a token rather than a locale guess, because the day break above a message and the time beside it have to agree, and they are drawn by two different components in two different apps.
DayBreak
The rule: plain centred text, and no furniture at all. No pill, no rule, no plate: 12.5px in muted, 22px above and 16px below. It carries the day and the time, and the time is the time of the first message after the break, because a break introduces what follows rather than closing what came before.
Four distances, one shape: today and yesterday by name, a weekday inside the last week, and a date beyond that. Nothing here ever says “2 days ago”: a relative phrase past yesterday makes the reader do arithmetic to find out whether they missed something.
Feather (light)
Low flame (dark)
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The line | muted on the thread ground | 6.40:1 | 6.27:1 |
faint as that line | under the 4.5 text bar in both schemes, and under the 3.0 non-text bar in Feather | 2.94:1 must stay under 3 | 3.76:1 must stay under 4.5 |
UnreadDivider
The rule: a hairline broken for a centred label, and it only appears when something sits above it. The label is NEW in coral-deep, inset from the rule by bubble.insetHorizontal either side, in the same vertical furniture a day break uses.
Feather (light)
Where it appears: read above, new below
Where it must not: the whole thread is new
Low flame (dark)
Where it appears: read above, new below
Where it must not: the whole thread is new
| Part | Colours | Feather | Low flame |
|---|---|---|---|
The NEW label | coral-deep on the thread ground, held to the text bar because it is a word a member reads | 4.97:1 | 7.61:1 |
| Full coral as that label | under the text bar AND the non-text bar in Feather, which is the whole reason for the swap | 2.68:1 must stay under 3 | 6.14:1 |
| The rule itself | the hairline, which cannot be what tells you and is not asked to be | 1.22:1 must stay under 3 | 1.36:1 must stay under 3 |
Coral-deep, in both schemes, because a role takes one token. Full coral clears the text bar on the dark ground (6.14:1) and fails it badly on the light one (2.68:1), so Feather alone forces the choice and Low flame follows it rather than running a second colour for the same word. This is the same substitution the AgentScreen's live dot and the admin icon's tick already make, for the same reason: the coral family keeps the identity, the depth buys the contrast.
The hairline is not load-bearing. It is one ink at one opacity, so it resolves to #E2E4E7 over the Feather ground (1.22:1) and #2F3540 over Low flame (1.36:1), which is a texture rather than a signal. The label is what carries the meaning, which is exactly why the label is held to the 4.5 body bar rather than the 3.0 a mark would get. Both numbers are claimed, so a future rule that tried to do the telling on its own would have to say so out loud.
ActivityLine
The rule: something that happened gets the day break's treatment, never a card. Same 12.5px, same muted, same space above and below. These are the thread's own events rather than messages, so they are not bubbles, they have no side, and they carry no control: a line that needs a button is a card and belongs with the cards above.
Feather (light)
Low flame (dark)
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The line | muted on the thread ground, the same pairing as the day break | 6.40:1 | 6.27:1 |
The wording is the component. “Routine created”, “Ada called you”, “3 messages with Cash”: a fact in the fewest words that still say who did it, with no verb tense a member has to decode and no exclamation anywhere.
InlineCode
The rule: monospace on a tinted plate with a 1px edge, and the edge is what draws the plate. The fill is the mark ink at 0.14 and measures 1.22:1 against the bubble: a tint, not a boundary. That is exactly why the edge cannot be the quiet one. It is the only measurable thing there, so it takes the same 0.7 every other enclosing mark in the thread takes. Inline the plate takes half a bubble inset either side and no vertical padding at all, so a sentence with three identifiers in it still sits on the 26px line. A fenced block takes the bubble's own insets and scrolls sideways rather than wrapping, because a wrapped command is a command somebody will mistype.
Feather (light)
Inside prose
The key I hold for the weather feed is FATHOM_API_KEY, and it never leaves the vault.
As a standalone identifier
AIRMOND_WEBHOOK_SECRET
Fenced
every weekday 07:00 brief: calendar, weather, flights
Low flame (dark)
Inside prose
The key I hold for the weather feed is FATHOM_API_KEY, and it never leaves the vault.
As a standalone identifier
AIRMOND_WEBHOOK_SECRET
Fenced
every weekday 07:00 brief: calendar, weather, flights
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The code | ink on the tinted plate | 12.10:1 | 8.85:1 |
| The plate’s EDGE against the bubble | rule, held to the same 3.0 as every other enclosing mark | 3.18:1 | 3.50:1 |
| The plate’s FILL against the bubble | a tint, and claimed as one rather than credited with drawing anything | 1.22:1 must stay under 3 | 1.27:1 must stay under 3 |
| The plate’s fill against the thread ground | the same, for a bubble that is only code | 1.22:1 must stay under 3 | 1.26:1 must stay under 3 |
border as that edge | what it was until 2026-09-14, on the surface it was actually drawn on | 1.21:1 must stay under 3 | 1.38:1 must stay under 3 |
The plate and its edge come off ONE ink now. Until 2026-09-14 the fill was the quiet wash and the edge was the page's hairline: two unrelated tokens, neither chosen against the surface the plate actually sits on, and the edge measuring 1.21:1. Both are muted now, at 0.14 and 0.7, so the plate cannot end up tinted one way and outlined another.
DraftCard
The rule: she drafts, the member sends, and the card is the draft as it sits in his own mailbox. This is the app's DraftCard (MAR-173 and MAR-174), state for state. The head is a pen on a quiet tile, Draft reply, and a caption naming who and what (“To … · Re: …”, never “Re: Re:”), which wraps rather than cuts because the address is the fact a member checks before opening. Her text sits in a band under a hairline, cut to 6 lines or 360 characters at a word, with Read all in the working accent; a remainder under 40 characters is shown rather than hidden. The foot is a quiet Change it, which puts this message in the composer's reply quote, and the ONE ink primary, Open in Gmail (or Outlook), which leaves for the draft in his mail client. It takes the full 640px, because a card is a document and a bubble is a sentence.
identity/never-send.ts, three layers including a seal on the SMTP submission ports). So this card has no Send button and no Sent, Sending or Did-not-send state, and this library draws none: the place a draft goes out from is his own mail client, which is where the primary leads. Revisiting Send is Cédric's decision, not a pull request. The primary is ink, never coral: the thread spends its one coral on the composer's send disc.Feather (light)
Live · Gmail, cut with Read all, at true 640px width
Ákos, Thursday works. I land at 09:20 and can be at your office by eleven, so the morning is yours if you want it. If the afternoon is easier I can move the return flight instead: the 17:55 still has seats and the car can wait. Either way, send me the address of the new office and I will have the driver go straight there from the airport rather than to…
Read allLow flame (dark)
Live · Gmail, cut with Read all, at true 640px width
Ákos, Thursday works. I land at 09:20 and can be at your office by eleven, so the morning is yours if you want it. If the afternoon is easier I can move the return flight instead: the 17:55 still has seats and the car can wait. Either way, send me the address of the new office and I will have the driver go straight there from the airport rather than to…
Read allThat pair is drawn at measure.maxBubble, at true width, so it stacks its two schemes for the same reason the bubble at the cap does: two 640px cards do not fit across this column, and a specimen clipped at the edge of a scroller is a specimen half-seen.
Feather (light)
Expanded · the whole text, and Show less
Ákos, Thursday works. I land at 09:20 and can be at your office by eleven, so the morning is yours if you want it. If the afternoon is easier I can move the return flight instead: the 17:55 still has seats and the car can wait. Either way, send me the address of the new office and I will have the driver go straight there from the airport rather than to the hotel first. Looking forward to it, Cédric
Show lessAn Outlook mailbox · Open in Outlook
Ákos, Thursday works. I land at 09:20 and will come straight to you, so eleven is safe. Send me the new office address and I will be there. Cédric
Compact · a draft with no stored text: the head and the door, no Change it
Low flame (dark)
Expanded · the whole text, and Show less
Ákos, Thursday works. I land at 09:20 and can be at your office by eleven, so the morning is yours if you want it. If the afternoon is easier I can move the return flight instead: the 17:55 still has seats and the car can wait. Either way, send me the address of the new office and I will have the driver go straight there from the airport rather than to the hotel first. Looking forward to it, Cédric
Show lessAn Outlook mailbox · Open in Outlook
Ákos, Thursday works. I land at 09:20 and will come straight to you, so eleven is safe. Send me the new office address and I will be there. Cédric
Compact · a draft with no stored text: the head and the door, no Change it
The replaced record (MAR-174). When the member asks for a change and she writes a new draft, the earlier card stays as the thread's record of what she wrote: its head and her words, 3 lines of them and muted, and in place of the foot a quiet strip, Replaced by the draft below, with an arrow pointing at the newer card. Both verbs go: Open would lead to the draft he has been told is the old one, and Change it would rewrite what is already superseded. It must read LIGHTER than the live card below it, never wear the quiet button's face.
Feather (light)
The record, then the newer draft under it
Ákos, Thursday works. I land at 09:20 and can be at your office by eleven, so the morning is yours if you want it. If the afternoon is easier I can move the return flight instead: the 17:55 still has seats and the car can wait…
Read allÁkos, Thursday works. I land at 09:20 and will come straight to you, so eleven is safe. Send me the new office address and I will be there. Cédric
Low flame (dark)
The record, then the newer draft under it
Ákos, Thursday works. I land at 09:20 and can be at your office by eleven, so the morning is yours if you want it. If the afternoon is easier I can move the return flight instead: the 17:55 still has seats and the car can wait…
Read allÁkos, Thursday works. I land at 09:20 and will come straight to you, so eleven is safe. Send me the new office address and I will be there. Cédric
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The card’s own edge, and the band hairlines | rule on the card, against the thread ground | 3.20:1 | 3.92:1 |
border as that edge | what it would have been without the rule above (the app still draws border; recorded, not copied) | 1.16:1 must stay under 3 | 1.54:1 must stay under 3 |
| The card against the thread ground | a non-relationship, which is why the edge is the edge | 1.05:1 must stay under 3 | 1.12:1 must stay under 3 |
| Title and her text | ink on the card | 16.12:1 | 11.24:1 |
| The caption, To · Re: | muted on the card | 6.69:1 | 5.58:1 |
| The pen | muted on the quiet tile (a glyph, so 3.0) | 6.11:1 | 6.59:1 |
Read all | confirm on the card: the working accent | 6.74:1 | 6.79:1 |
Change it | muted on the card: a word, no plate | 6.69:1 | 5.58:1 |
Open in Gmail, the one primary | on-primary on primary-fill. Ink, never coral | 16.12:1 | 12.61:1 |
| The record’s text | muted on the card: lighter than the live card below | 6.69:1 | 5.58:1 |
Replaced by the draft below | muted on the quiet strip | 6.11:1 | 6.59:1 |
| The strip against the card | found by its words and its arrow, never by its plate | 1.10:1 must stay under 3 | 1.18:1 must stay under 3 |
NewMessagesPill
The rule: one component, two anchors, and the arrow is the only difference. Unread behind you gives a pill at the top with an up arrow that scrolls to the unread divider; new messages arriving below while you read history give a pill at the bottom with a down arrow. The count is the same number either way, so the words are the same too. It floats 12px in from the edge it anchors to, centred in the thread column, and stands 28px tall at radius 14, which is exactly half its own height and therefore a capsule. It is content width rather than a fixed one: 9px leading, the arrow, 9px, the label, 9px, the ✕ on a 16px hit box, 10px trailing.
Ink filled with ground text. Not confirm, which on this system means confirmed and succeeded and live, and would be claiming something untrue about messages nobody has read. Not coral, which the thread has already spent on the composer's send disc. Ink is the only fill left that a floating object can carry without borrowing a meaning, and it clears everything it might float over by a wide margin.
newMessages.labelNumerals is tabular), so the count cannot jitter under the member's eye. Crossing from 9 to 10 costs exactly one digit width and never a reflow, and 10 to 11 costs nothing at all: the specimen below draws all three so the two same-width pills can be checked against each other rather than taken on trust.Feather (light)
Unread behind you · the top anchor, an up arrow to the unread divider
New arriving below · the bottom anchor, a down arrow to the newest
Tabular · one digit width from 9 to 10, nothing at all from 10 to 11, and the singular at 1
Low flame (dark)
Unread behind you · the top anchor, an up arrow to the unread divider
New arriving below · the bottom anchor, a down arrow to the newest
Tabular · one digit width from 9 to 10, nothing at all from 10 to 11, and the singular at 1
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The label and the two glyphs | the ground colour on the ink fill | 15.41:1 | 12.61:1 |
| The pill itself against the thread | a floating object, so the 3.0 mark bar | 15.41:1 | 12.61:1 |
| The same against a bubble scrolling under it | the other surface it floats over | 14.71:1 | 11.24:1 |
| Coral as that fill: why it is not used | the thread already spent its one coral, and the plate misses the mark bar in Feather anyway | 2.68:1 must stay under 3 | 6.14:1 |
There is no count on the tab bar to keep this in step with. The pill is the thread's own statement about the thread you are looking at, which is why it carries a number and not a dot: “you have not read nine of these” is a different sentence from “something happened here”, and only one of them is worth interrupting a read for.
LongMessage
The rule: his long message folds, hers never does. A text message of the member’s own that is taller than 14 lines of body is clipped to exactly 14 lines, the last 2.5 of them fade into the bubble’s own fill, and Show more with a chevron sits bottom-left inside the bubble, on the text’s own left edge. One tap opens it in place and the control becomes Show less, the same chevron turned over. It is Grok Bot’s pattern, asked for by Cédric on 2026-09-26 because a wall of his own text pushed the thread off the screen, and it shipped in the app as #324.
Feather (light)
Folded · 14 lines, the fade, Show more (try it)
Before Thursday, can you sort these:
move me to the 07:40 if Swiss has it
window seat, not the exit row
car at 05:50, not 06:15
tell Ákos I land at 09:20
ask him for the new office address
driver goes there first, not the hotel
courtyard room, not the street side
late checkout on Friday if they can
dinner Thursday for four, near him
nothing with a set menu
book it under my name
move the 14:00 with Hanna to Friday
keep the 17:00 with Josef
if the 17:55 home is full, take Saturday
put the Windy password back
so the morning brief has weather again
and remind me Wednesday about the charger
Thanks
Opened · the whole message, and Show less
Before Thursday, can you sort these:
move me to the 07:40 if Swiss has it
window seat, not the exit row
car at 05:50, not 06:15
tell Ákos I land at 09:20
ask him for the new office address
driver goes there first, not the hotel
courtyard room, not the street side
late checkout on Friday if they can
dinner Thursday for four, near him
nothing with a set menu
book it under my name
move the 14:00 with Hanna to Friday
keep the 17:00 with Josef
if the 17:55 home is full, take Saturday
put the Windy password back
so the morning brief has weather again
and remind me Wednesday about the charger
Thanks
16 lines · only 2 would hide, so it is drawn whole
Packing for Lisbon:
passport
the grey blazer
two white shirts
running kit
charger and the adapter
the Hanna notes
Ákos’s book back
sunglasses
the small umbrella
headphones
cash for the driver
reading glasses
the blue folder
toothbrush
that’s it
Low flame (dark)
Folded · 14 lines, the fade, Show more (try it)
Before Thursday, can you sort these:
move me to the 07:40 if Swiss has it
window seat, not the exit row
car at 05:50, not 06:15
tell Ákos I land at 09:20
ask him for the new office address
driver goes there first, not the hotel
courtyard room, not the street side
late checkout on Friday if they can
dinner Thursday for four, near him
nothing with a set menu
book it under my name
move the 14:00 with Hanna to Friday
keep the 17:00 with Josef
if the 17:55 home is full, take Saturday
put the Windy password back
so the morning brief has weather again
and remind me Wednesday about the charger
Thanks
Opened · the whole message, and Show less
Before Thursday, can you sort these:
move me to the 07:40 if Swiss has it
window seat, not the exit row
car at 05:50, not 06:15
tell Ákos I land at 09:20
ask him for the new office address
driver goes there first, not the hotel
courtyard room, not the street side
late checkout on Friday if they can
dinner Thursday for four, near him
nothing with a set menu
book it under my name
move the 14:00 with Hanna to Friday
keep the 17:00 with Josef
if the 17:55 home is full, take Saturday
put the Windy password back
so the morning brief has weather again
and remind me Wednesday about the charger
Thanks
16 lines · only 2 would hide, so it is drawn whole
Packing for Lisbon:
passport
the grey blazer
two white shirts
running kit
charger and the adapter
the Hanna notes
Ákos’s book back
sunglasses
the small umbrella
headphones
cash for the driver
reading glasses
the blue folder
toothbrush
that’s it
The fade is the bubble’s own fill, transparent at the top and opaque at the bottom over 65px: memberBubble, or a flavour’s own member fill wherever a flavour sets one. No new colour, no grey, no hard line: a fade in any other colour is a visible band across his words. The control is the body’s ink at 600, the body’s own size, never a quiet tone and never onCoral, because nothing quiet goes inside his bubble. It is a real button with aria-expanded and the clip it controls; opening it does not animate the height (instant, as on WhatsApp), only the chevron turns. The clipped text stays in the tree, so a screen reader, copy, a reply quote and the long-press menu all act on the whole message. On the web the clip is overflow: clip, not hidden: a hidden box is still a scroll container, and find-in-page or a focused link in the hidden lines would scroll the top of the message away under a fade that no longer lines up. The expansion is per message and in memory: it survives the list recycling a row, not a restart.
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| His words | ink on his bubble | 13.82:1 | 8.14:1 |
Show more / Show less and the chevron | the same ink at 600, on his bubble | 13.82:1 | 8.14:1 |
muted as that control: why it is ink | muted on his bubble, the reading that keeps every quiet thing out of it | 5.74:1 | 4.05:1 must stay under 4.5 |
A finished action card
The rule, for every action card in the thread: once its work is finished, the title stays in ink and every explanatory line steps back to muted. Finished means a call ended or failed, or went unavailable and its report has landed; for other cards, approved, dismissed or done. The explanatory lines are the brief, the report, the last transcript line and any consequence text. Labels keep their own tone (the speaker’s name stays confirm, the status pill says Ended in its own quiet pair), and so do the controls. While the card is live nothing changes: the transcript line is news and stays ink. The point is that a finished card stops competing with the live messages around it while its title stays scannable. It is Grok Bot’s dismissed “Action approved” card, picked by Cédric as variant B of three on 2026-09-26, and CallCard is its first instance (the app’s #319).
Feather (light)
Live · on the call
I’m calling the hotel now to ask about the cot.
Ask whether the cabin has a cot for Saturday, and hold it if it does.
Let me check the booking, one moment.
Finished · the title stays, the details step back
I’m calling the hotel now to ask about the cot.
Ask whether the cabin has a cot for Saturday, and hold it if it does.
She picked up on the second ring. The cabin has a cot and she is holding it for Saturday; no deposit needed.
Low flame (dark)
Live · on the call
I’m calling the hotel now to ask about the cot.
Ask whether the cabin has a cot for Saturday, and hold it if it does.
Let me check the booking, one moment.
Finished · the title stays, the details step back
I’m calling the hotel now to ask about the cot.
Ask whether the cabin has a cot for Saturday, and hold it if it does.
She picked up on the second ring. The cabin has a cot and she is holding it for Saturday; no deposit needed.
Stacked by scheme, so the live card and the finished one sit side by side in each scheme and can be compared line for line: the title is the same weight and tone in both, and the one line that changes is the one below the brief. The brief is muted in both states (it is the instruction she was given, not news). The card is drawn with the kit’s rule for its edge, its brief rule and its foot, where the app still draws border and borderStrong; recorded, not copied, the same as the draft card.
muted has to clear the 4.5 text bar on every surface it is drawn on, in both schemes, or the rule does not ship. Those surfaces are the card itself (surface) and the inset the report sits on (groundAlt in Feather, surfaceRaised in Low flame). All four clear, the tightest at 4.87:1, and all four are claims in tokens.json. The quiet lines stay ON the card, never out on the call’s own bubble, because muted on confirmSoft measures 4.05:1 in Low flame and fails; that is a must-fail claim too. A flavour that would take the finished card below 4.5 needs a token fix here before it shows call cards.| Part | State | Colours | Feather | Low flame |
|---|---|---|---|---|
| The title (the recipient) | live and finished | ink on the card | 16.12:1 | 11.24:1 |
| The last transcript line | live | ink on the inset | 14.71:1 | 9.80:1 |
| The brief | both | muted on the card | 6.69:1 | 5.58:1 |
| The report | finished | muted on the inset | 6.11:1 | 4.87:1 |
| The speaker’s name | live (a label keeps its tone) | confirm on the inset | 6.15:1 | 5.92:1 |
| The number | both | muted on the card | 6.69:1 | 5.58:1 |
| The pill, Live | live | confirm on confirm-soft | 5.78:1 | 4.92:1 |
| The pill, Ended | finished | muted on groundAlt | 6.11:1 | 6.59:1 |
muted out on the call’s bubble | never | muted on confirm-soft: why the quiet lines stay on the card | 5.74:1 | 4.05:1 must stay under 4.5 |
| The card’s edge against the call’s bubble | both | rule over the card, against confirm-soft (a mark, so 3.0) | 2.87:1 — fails 3 | 2.53:1 — fails 3 |
rule edge, composited over the white card and read against the call's confirm-soft bubble, measures 2.87:1 and 2.53:1, under the 3.0 the 2026-09-14 mark rule asks of it, because that rule was measured against the thread ground and this card sits on a bubble (the card's fill against the bubble is 1.17:1 and 1.38:1). The app draws border there, which is lower still. Fixing it is a mark-rule decision (a stronger edge, or a card that does not sit inside a tinted bubble), so it is shown failing, loudly, instead of being quietly left out of the table.Consuming it
Colours: the --th-* mapping printed above, over the variables in dist/css/airmond.css; the same values are lightColors / darkColors in dist/ts/tokens.ts, where the separating mark is the rule tone added on 2026-09-14. Geometry: the thread block in tokens/tokens.json, which both apps mirror and neither app may fork: the draft card and the pill are in it on the same terms as the other nine. Every pairing this surface RELIES ON is asserted in that file's claims array and re-run by build/contrast-test.mjs on every build, together with the ones it deliberately does not rely on, which are recorded as must-fails so a value drifting into being load-bearing would have to say so out loud. Not every cell above is a claim: a measured cell with no claim behind it is a reading, and the array is the shorter list of readings that are a contract.
Four controls a member could not find
Four fixes out of 84 captures of the Settings, Knowledge and Tasks screens: the card that had no edge, the secondary button that had no plate, the disabled recipe that halved the label and the fill in one step, and faint, which was carrying every line that says where a piece of information came from. All four are token-level, so they are ONE kit change and one app re-sync rather than eleven screen edits. Each is drawn before and after, side by side, in both schemes, with the measurement that decided it.
border clears 3.0 on a page ground and draws nothing on a card, which is how a hairline came back as the wrong token twice. The second is the half a state can quietly break: a control that reads at 15.41:1 enabled and 3.10:1 disabled has not been dimmed, it has been deleted.The specimen palette
Each specimen carries one scheme's palette on itself as the --co-* set, mapped once in site/controls.mjs from tokens/tokens.json. There is no hex anywhere in the section, including in the before states: border over a card, the groundAlt fill and the 0.5 dim are all recomputed on every build, so a retired recipe cannot come back through a number somebody typed. Every tone is read out of the app's own rn block, because that block is what mb-airmond-app mirrors and a tone the app cannot reach is a fix the app cannot ship.
| Variable | Token | What it draws |
|---|---|---|
--co-ground | rn.<scheme>.ground | The page behind every card on every screen, and the surface a control most often sits on. |
--co-panel | rn.<scheme>.groundAlt | The recessed panel, and the fill the secondary button wore until 2026-09-14. |
--co-surface | rn.<scheme>.surface | The card itself. |
--co-raised | rn.<scheme>.surfaceRaised | The one raised surface: the message bar’s field, which is lifted off the card in Low flame and flush with it in Feather. |
--co-quiet | rn.<scheme>.sidebarAccent | The quiet fill under a chip, an option key and a status pill. |
--co-member-bubble | rn.<scheme>.memberBubble | The member’s own bubble, which is where faint got its worst reading in the whole product. |
--co-ink | rn.<scheme>.ink | Titles, a secondary control’s label, every glyph on a quiet fill. |
--co-muted | rn.<scheme>.muted | Captions, and every provenance line, date and timestamp in the product since faint was retired from member-facing text. |
--co-faint | rn.<scheme>.faint | Drawn on this page ONLY to show what it measures. It carries no word anywhere in the product. |
--co-coral | rn.<scheme>.coral | Identity, and the one primary action a surface is allowed. |
--co-on-coral | rn.<scheme>.onCoral | The label on that coral. Ink, never white. |
--co-primary-fill | rn.<scheme>.primaryFill | The ink fill under the one FILLED control a card draws. |
--co-on-primary | rn.<scheme>.onPrimary | The label on that fill. |
--co-disabled-fill | rn.<scheme>.disabledFill | The plate under a disabled control, at FULL strength. New on 2026-09-14. |
--co-on-disabled | rn.<scheme>.onDisabled | The label on that plate, also at full strength, and held to the full body bar. |
--co-line | rn.<scheme>.border | The retired hairline, drawn on this page only to measure it. It is toned for a page ground and cannot draw a card. |
--co-rule | rn.<scheme>.rule | The tone every separating mark and every control outline is drawn in: the card’s hairline, the secondary’s outline, the outline a disabled control keeps. |
--co-coral-deep | <scheme>.coral-deep | The state label above a specimen when it is the state this section is arguing for. A caption on the page, outside the component. |
--co-edge | <scheme>.border | The specimen’s own outer edge, so a light specimen on a dark page still looks like a card. |
The icon set
Five symbols, drawn once into a <defs> block and referenced by <use>, each stroked in currentColor so it takes the colour of whatever it sits on. Nothing here is an emoji or a text character: the chevron on a card row and the clock on a timestamp are exactly the sizes at which a missing glyph renders as an empty box that nobody looks at twice.
Feather (light)
Low flame (dark)
1 · The card gets a hairline
The rule: a container the member has to find is a shape, and a shape needs an edge or a fill that measures. The card had neither. Its fill against the page ground measures 1.05:1 in Feather and 1.12:1 in Low flame, and the only other thing separating it was a shadow at 6%. That is a third of what the 3.0 non-text bar asks for, on every card on every screen.
The line that comes back is not the line that was removed. The one retired on 2026-09-02 was border, the hairline token for a page ground. Drawn on a card it composites to #E8E9EA and #363E48 and measures 1.16:1 and 1.54:1 against the ground it is meant to separate the card from: a wireframe that was also invisible, which is the worst of both. The line now is rule, the one tone this system draws every separating mark in, at layout.card.borderWidth: 1. It composites to #838E97 and #707C88 and measures 3.20:1 and 3.92:1.
Feather (light)
Before · the shadow alone
After · a 1px rule hairline
Low flame (dark)
Before · the shadow alone
After · a 1px rule hairline
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The card’s FILL against the ground | the whole of what separated a card until 2026-09-14 | 1.05:1 must stay under 3 | 1.12:1 must stay under 3 |
border as the edge | the line removed on 2026-09-02, measured on the surface it was actually drawn on | 1.16:1 must stay under 3 | 1.54:1 must stay under 3 |
rule as the edge | the line that came back: one tone, one strength, every surface | 3.20:1 | 3.92:1 |
| The title | ink on the card | 16.12:1 | 11.24:1 |
| The caption | muted on the card | 6.69:1 | 5.58:1 |
| The row’s chevron | muted on the card (a non-text mark, so 3.0) | 6.69:1 | 5.58:1 |
layout.card.borderWidth back to 0 and every card on every screen loses the line again, with no call-site change, because the width is a token the components read, not a style eleven screens repeat. If it reads heavy, the alternative is a stronger FILL rather than an edge, which is also one token (surface) and also no call-site change. What is not on the table is going back to a shape that measures 1.05:1 and calling the shadow an edge.Both specimens are the same size. The unruled card carries a transparent border of the same width, so the pair differs by the line and by nothing else. Drawn the other way, the after card would be 2px bigger than the before card and the comparison would be about two boxes rather than about one hairline.
2 · secondary stops being an invisible button
The rule: a button is a plate and a label, and if the plate carries nothing the label is carrying a button on its own. secondary was a groundAlt FILL at 24 call sites. It measures 1.05:1 against the page ground and 1.10:1 on a card in Feather, 1.05:1 and 1.18:1 in Low flame. The label read perfectly (ink on that fill measures 14.71:1) and the shape around it was not there, so the control looked like a line of bold text that happened to be tappable.
It is an OUTLINE now: no fill at all, a 1px rule outline, an ink label. The outline measures 3.24:1 on the ground and 3.34:1 on a card in Feather, 3.78:1 and 3.50:1 in Low flame. A secondary appears in both places, so it has to be findable in both, which is why the pairing is claimed twice rather than once.
Feather (light)
Before · a groundAlt fill, on the ground
After · a rule outline, on the ground
Low flame (dark)
Before · a groundAlt fill, on the ground
After · a rule outline, on the ground
Feather (light)
Before · the same fill on a card
After · the same outline on a card
Low flame (dark)
Before · the same fill on a card
After · the same outline on a card
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The OLD fill against the ground | groundAlt: the measurement that retired it | 1.05:1 must stay under 3 | 1.05:1 must stay under 3 |
| The OLD fill on a card | the same fill, one surface further in | 1.10:1 must stay under 3 | 1.18:1 must stay under 3 |
| The old label, on that fill | ink on groundAlt: the half that was always fine, and was carrying the whole control | 14.71:1 | 13.26:1 |
| The outline on the ground | rule, a non-text mark, so 3.0 | 3.24:1 | 3.78:1 |
| The outline on a card | the same tone composited over a card instead | 3.34:1 | 3.50:1 |
| The label on the ground | ink on the ground | 15.41:1 | 12.61:1 |
| The label on a card | ink on the card | 16.12:1 | 11.24:1 |
| The PRIMARY beside it | onPrimary on primaryFill: ink, never coral | 16.12:1 | 12.61:1 |
| The primary’s plate against the ground | what a filled control measures, for comparison | 15.41:1 | 12.61:1 |
The outline is the same tone and the same strength as the card's hairline, and that is the point. The card's edge and the secondary's outline composite to the same colour on the same surface, so a card holding a secondary button draws one mark at one weight instead of two marks a member has to tell apart. A filled secondary would also have worked arithmetically, and it was rejected: a fill deep enough to clear 3.0 against both the ground and a card is a fill deep enough to compete with the primary, and then the surface has two buttons that look equally pressable.
The primary did not move. It measures 15.41:1 against the ground in Feather and 12.61:1 in Low flame, which is twelve times what the old secondary managed and roughly four times what the new outline does. That gap is the hierarchy: one filled action, one outlined one, and the difference visible at a glance rather than only on inspection.
3 · The disabled recipe is a token pair, not an opacity
The rule: a disabled control should look unavailable, not absent. The old recipe was opacity: 0.5 on the whole group and nothing else, which halves the label and the fill together, so a control loses its words and its shape in one step. Worse, it takes the caption down with them, and the caption is the copy that says what enabling the control would cost. The member is left with a grey smear where the explanation was.
Every reading below is what that opacity actually produced, recomputed here rather than remembered. Two are worth saying out loud: a disabled SECONDARY's label resolves to #888E94 on a #F5F8FA plate and measures 3.10:1 in Feather, and a disabled CORAL primary's label resolves to #888E94 on a #FCB3AB plate and measures 1.92:1, a label all but gone into its own button.
| The control, dimmed the old way | What the dim resolves to | Feather | Low flame |
|---|---|---|---|
| A secondary, on the ground | ink on groundAlt, both dimmed over ground | 3.10:1 must stay under 4.5 | 4.25:1 must stay under 4.5 |
| The same secondary on a card | ink on groundAlt, both dimmed over surface | 3.04:1 must stay under 4.5 | 4.29:1 must stay under 4.5 |
| A quiet button | ink on sidebarAccent, both dimmed over ground | 3.02:1 must stay under 4.5 | 3.59:1 must stay under 4.5 |
| The ink primary | onPrimary on primaryFill, both dimmed over ground | 3.25:1 must stay under 4.5 | 4.16:1 must stay under 4.5 |
| A CORAL primary, the worst case | onCoral on coral, both dimmed over ground | 1.92:1 must stay under 4.5 | 2.45:1 must stay under 4.5 |
| The caption explaining the state | muted dimmed over ground, which does not dim with it | 2.20:1 must stay under 4.5 | 2.58:1 must stay under 4.5 |
The recipe now is a token pair at full strength: disabledFill under onDisabled, keeping the same rule outline an enabled secondary has. The label measures 5.82:1 in Feather and 4.59:1 in Low flame, which clears the full body bar rather than approaching it. The outline is not optional. The plate alone measures 1.10:1 and 1.37:1 against the ground, so the plate says unavailable and the outline is the only thing left saying this is still a control.
Feather (light)
Enabled, for comparison
Before · opacity 0.5 on the whole group
After · the pair at full strength, outline kept
Low flame (dark)
Enabled, for comparison
Before · opacity 0.5 on the whole group
After · the pair at full strength, outline kept
| Part | Colours | Feather | Low flame |
|---|---|---|---|
| The disabled label | onDisabled on disabledFill, both at full strength | 5.82:1 | 4.59:1 |
| The disabled PLATE against the ground | a non-relationship, and the reason the outline stays | 1.10:1 must stay under 3 | 1.37:1 must stay under 3 |
| The outline it keeps | rule over the disabled plate, against the ground (3.0 non-text) | 3.38:1 | 4.16:1 |
| The caption explaining the state | muted on the ground, at full strength | 6.40:1 | 6.27:1 |
| The same caption under the old recipe | the sentence the dim was quietly taking with it | 2.20:1 must stay under 4.5 | 2.58:1 must stay under 4.5 |
control.disabledOpacity is 1 on purpose. It is not a value anybody needs to multiply by; it is the retirement of opacity: 0.5 written down as a value an app reads rather than a rule somebody has to remember. A group that still dims itself is a group that has ignored the pair, and the token says so in the one place a component looks.The before specimen dims today's controls, and the before table measures the recipe as it actually shipped. Those are two different questions and both are worth answering. The specimen changes exactly one variable, the opacity, so what is on screen is what a dim does rather than what a dim and a retired fill do together. The table goes the other way and measures the real pairs the app drew, the groundAlt secondary fix 2 has since retired included, because the point of a before state is to record what shipped rather than a tidier version of it.
The coral primary is the case that made this a token pair rather than a smaller opacity. Dimming a coral fill and an ink label by the same factor moves them towards each other, not apart: the label lands on 1.92:1 in Feather. No single opacity fixes that, because the two tones start on opposite sides of the ground the group is dissolving into. A disabled control therefore stops being a dimmed version of its enabled self and becomes its own pair, which also means a disabled coral primary and a disabled secondary look like the same thing, which they are.
4 · faint is retired from member-facing text
The rule: the line that says where information came from is read, not skimmed, so it takes the body bar like any other sentence. faint measures 3.08:1 on a card in Feather and 3.35:1 in Low flame, and it never clears the 4.5 body bar on any ground the product has. Its worst reading, 2.43:1, is inside the member's own bubble. What it carried is provenance, dates and timestamps: from your calendar, added 4 September, updated 18:41. The dim was landing on exactly the copy a member reads to decide whether to trust the rest of the card.
Every one of those readings is muted now. One exception, and it is not a tone change: muted inside the member's own bubble measures 5.74:1 and 4.05:1, so it misses the body bar in Low flame and his bubble takes the same answer in both schemes. A line that used to sit in there moves out onto the thread ground instead, where muted measures 6.40:1 and 6.27:1. It is the same rule the thread's marks landed on for the same fill.
Feather (light)
Before · faint on a card
After · muted
Low flame (dark)
Before · faint on a card
After · muted
Feather (light)
Before · faint inside the member’s own bubble, the worst reading in the set
After · the line moves out of the bubble, in muted
Low flame (dark)
Before · faint inside the member’s own bubble, the worst reading in the set
After · the line moves out of the bubble, in muted
| Ground | What sits on it | faint Feather | faint Low flame | muted Feather | muted Low flame |
|---|---|---|---|---|---|
ground | The page behind every card, and the thread itself. | 2.94:1 must stay under 4.5 | 3.76:1 must stay under 4.5 | 6.40:1 | 6.27:1 |
groundAlt | The recessed panel: a grouped list, a card’s option rows. | 2.81:1 must stay under 4.5 | 3.95:1 must stay under 4.5 | 6.11:1 | 6.59:1 |
surface | A card. Every Settings, Knowledge and Tasks row sits on one. | 3.08:1 must stay under 4.5 | 3.35:1 must stay under 4.5 | 6.69:1 | 5.58:1 |
surfaceRaised | The message bar’s field, raised off the card in Low flame and flush with it in Feather. | 3.08:1 must stay under 4.5 | 2.92:1 must stay under 4.5 | 6.69:1 | 4.87:1 |
sidebarAccent | The quiet fill under a chip, an option key and a status pill. | 2.68:1 must stay under 4.5 | 2.75:1 must stay under 4.5 | 5.82:1 | 4.59:1 |
memberBubble | The member’s own bubble, and the worst reading in the set. The replacement does not go here: muted inside his bubble measures 5.74:1 and 4.05:1, so Low flame misses the body bar and one role takes one answer. A provenance line moves out to the ground, which is what the two muted cells on this row measure. | 2.64:1 must stay under 4.5 | 2.43:1 must stay under 4.5 | 6.40:1 | 6.27:1 |
Twelve readings, generated rather than typed. The rows come from an array of token keys, so the table measures whatever the token file currently holds: a change to faint that fixed one ground and broke another could not hide in a row somebody forgot to update. Every one of them is also a must-fail claim in tokens/tokens.json, which is what stops the tone quietly coming back for a timestamp.
faint is not deleted, and this is why it still has a token. It is legal as a non-text mark in Low flame on two of these grounds and illegal as a word on all twelve, which is precisely the trap: a tone that reads as fine in one scheme, in one role, and is wrong everywhere else. Keeping it named and claimed is cheaper than keeping the rule in somebody's head: the claims say where it may not go, and there is nothing left in the product that it does.
Consuming it
Colours: the --co-* mapping printed above, over the variables in dist/css/airmond.css; the same values are lightColors / darkColors in dist/ts/tokens.ts, where rule, disabledFill and onDisabled are the three keys this pass added. Chrome: layout.card carries the hairline's width and tone, and layout.control carries the secondary outline's width, the disabled outline's width and the disabledOpacity: 1 that retires the old recipe. That is the whole change. Nothing below the token file names a colour, so the four fixes reach eleven screens by re-syncing the kit, and rolling any one of them back is one value in one file. Every pairing this section RELIES ON is asserted in tokens/tokens.json and re-run by build/contrast-test.mjs on every build, and so are the before states it is arguing against, recorded as must-fails so a retired recipe cannot come back unannounced. Not every cell in the tables is a claim: a before state measured to make a point is a reading, and the array is the shorter list of readings that are a contract.
Landing-page recipes (2026-09-02)
What the airmond.com rebuild introduced and every other surface will want: the app, emails, decks, future landers. Each recipe is rendered from the tokens; the shipped implementation is cs-airmond-website/components/one.
The primary button, all six states
Coral --primary with ink type, 52px, radius 12, 17px semibold. Transform and opacity do the moving; colour and shadow change only on a state change. The nav pill is the same button in ink clothes (44px, radius 999) and is never coral.
| State | What changes | Measured |
|---|---|---|
| Rest | --primary #FF6B5A, ink #18222B, radius 12, 52px, padding 0 26px | ink on coral 5.76:1 |
| Hover (fine pointer only) | --coral-hover #FF7A6B, up 1px, shadow 0 8px 24px rgba(255,107,90,.35), 180ms --motion-ease-micro | ink on coral-hover 6.33:1 (white 2.55:1 fails) |
| Press | scale 0.98 in 90ms ease-out; the release rides 260ms --motion-ease-press-out (back.out 1.6) | |
| Focus | 3px --confirm #3E5D86 ring at 2px offset, on :focus-visible. The one universal ring: never coral on coral | ring on ground 6.44:1 |
| Loading | The label swaps to "Calling in a moment" with three dots in a grid cell that already had the room, so the button never changes width. aria-busy, cursor wait, NOT dimmed | |
| Disabled | opacity .6, no hover. A pre-hydration button is disabled but must not dim: a page at 60% for 300ms reads as broken |
The phone-first sign-up card
The conversion component. Country prefix, number and Call me in one card, 640 wide, radius 18, --shadow-float. At rest it is four rows: the title, the number row, the button and one muted line. A valid number reveals the recap block above the button (label, the two doors, the address field, the consent line) and the trust block below it (the three chips, the price line, the lock line). The button stays the last control in both states, so the reveal reads as "and this, before you press".
Get your first call
Ada calls within a couple of minutes. 7 days free, no card.
Get your first call
- Swiss company
- Data stored in the EU
- No card for 7 days
7 days free, no card, then $999 a month. She asks for the rest on the call.
- Prefix and field are 52px, radius 12, 1px
--border; the address field under the doors is 44px (still a full tap target). Focus:--primaryborder with a 3px coral wash ringrgba(255,107,90,.3). - The doors are 40px pills: Google draws its own; Microsoft is greyed at 55% with a
Soontag until its server side exists. A control that looks live and is not is worse than none. - The block never folds back once shown; the number becoming valid (250ms after the last keystroke) or a press of Call me with a valid number both open it. Both blocks grow in over 260ms and the window scrolls by exactly the overflow if the card ends under the fold.
- The trust chips are the same three everywhere: Swiss company, Data stored in the EU, No card for 7 days.
The closing band
The same card without its chrome on the ink ground (--foreground, radius --radius-band 24), the dark scheme's inputs (card #1F2833, border #2A3646, foreground #DCE0E5). Coral stays with the one primary action.
Ready when you are.
Leave your number. Ada calls within a couple of minutes. Seven days free, no card, then $999 a month.
Used for Ada's calls only. Stored in Europe, never sold.
The live chip
"Ada is on call": Feather at 92% (rgba(248,250,252,.92)), 32px, radius 999, 13px semibold ink, and an 8px --confirm dot that breathes (opacity .55 to 1 over 2.4s, sine). Use it wherever a live state is shown: a call, a recording, a meeting she is in. Over a photograph the 92% keeps the ink legible; on a plain ground it measures 15.41:1.

Task status pills, three tones only
| Tone | Colours | Measured |
|---|---|---|
| Waiting on you (amber) | --waiting on --waiting-soft | 4.66:1 light, 6.62:1 dark |
| Working (confirm blue) | --confirm on --confirm-soft | 5.78:1 light, 4.92:1 dark |
| Waiting on a third party (muted) | --muted-foreground on --muted | 6.11:1 light, 5.58:1 dark |
22px tall, radius 999, 11px semibold, padding 0 9px. On the app's list screens the same three tones apply at the app's label size.
The screen vignette
How marketing draws the product: a muted panel (--muted, radius 12, 12px padding) holding white rows (--card, 1px --border, radius 10, 10px 12px padding), inside a tile (--card, radius --radius-card 16, 20px padding) with the 11px uppercase label in confirm blue and the 19px tile title. Never paste a raw screenshot with its chrome; draw the screen in the tokens.
One list, hers to chase
She picks up, and she makes the calls
Trust chips and the flag pair
The chips are 22px, 12px medium, --muted-foreground on --muted, each with its 14px mark: the two flags at 2px corners and the card in the chip's own ink. The flag pair is the same two flags at 60px, always in this order, always with its fixed caption.
- Swiss company
- Data stored in the EU
- No card for 7 days
Swiss cross: #FF0000 field, white cross, radius 3 of 32. EU: twelve #FFCC00 stars on #003399, 3:2, square corners. Both are inline SVG (the exact paths are in cs-airmond-website/components/one/Icons.tsx), never bitmaps.
The footer
Four columns, Airmond / Guides / Trust / Members, with 12px uppercase headings at .08em and 14px muted links each carrying its own 24px tap target. The Guides column is generated from the landing-page registry so it cannot drift. Under the columns, the mark line and the AI-disclosure sentence, which every Airmond surface carries verbatim.