Colour contrast
The room leans on 9–11px mono micro-copy for real information — the vote tally, the spectator list, seat labels, the histogram counts. This page records what those combinations actually measure, so the palette can be changed on numbers rather than on impression.
Nothing in the test suite can produce these figures. web/vite.config.ts sets
test.css: false, so no stylesheet loads under jsdom, and axe-core’s
color-contrast rule disables itself without a layout engine — it reports zero
violations against any palette, including a deliberately unreadable one. The
ratios below are computed by hand from the token values in web/src/tokens.css
using the WCAG 2.x relative-luminance formula, with color-mix(in oklab, …)
resolved through OKLab.
Scope is the four room surfaces plus the standup room: Table, Hand,
StoryQueue, ResultsPanel, StandupRoom. The space page, app shell and
landing page have not been audited.
Ratios
Section titled “Ratios”Threshold is WCAG AA: 4.5:1 for normal text, 3:1 where the text is genuinely large (18.66px bold or 24px regular and up). Almost all of this copy is small, so almost all of it is held to 4.5:1.
| Surface | Text | Foreground | Background | Size / weight | Threshold | Light | Dark |
|---|---|---|---|---|---|---|---|
| Table | seat “zzz” card label | --color-ink-faint |
--color-felt |
11px mono | 4.5:1 | 5.47 | 5.78 |
| Table | seat first name | --color-ink-soft |
--color-felt |
12px bold | 4.5:1 | 5.85 | 8.22 |
| Table | “· you” suffix | --color-ink-faint |
--color-felt |
12px | 4.5:1 | 5.47 | 5.78 |
| Table | SPECTATORS heading | --color-ink-faint |
--color-felt |
9px mono | 4.5:1 | 5.47 | 5.78 |
| Table | spectator name | --color-ink-soft |
--color-felt |
12px semibold | 4.5:1 | 5.85 | 8.22 |
| Table | vote tally | --color-ink-faint |
--color-felt |
11px mono | 4.5:1 | 5.47 | 5.78 |
| Table | revealed card face | --color-ink |
--color-surface |
24px mono | 3:1 | 15.25 | 13.20 |
| Hand | “YOUR HAND · deck” | --color-ink-faint |
--color-felt-deep |
11px mono | 4.5:1 | 4.75 | 6.12 |
| Hand | “pick a card” | --color-ink-faint |
--color-felt-deep |
10px mono | 4.5:1 | 4.75 | 6.12 |
| Hand | “picked N” | --color-go |
--color-felt-deep |
10px mono | 4.5:1 | 4.86 | 8.60 |
| Hand | SPECTATE button | --color-ink-soft |
--color-felt-deep |
10px mono | 4.5:1 | 5.08 | 8.71 |
| Hand | SPECTATING · REJOIN | --color-ink-soft |
--color-accent-soft |
10px mono | 4.5:1 | 5.58 | 6.02 |
| Hand | spectating rail copy | --color-ink-faint |
--color-felt-deep |
14px | 4.5:1 | 4.75 | 6.12 |
| Hand | card face | --color-ink |
--color-surface |
21.6–35.2px mono | 4.5:1 | 15.25 | 13.20 |
| Hand | card face, picked | --color-ink |
--color-accent-soft |
21.6–35.2px mono | 4.5:1 | 12.72 | 10.64 |
| Hand | card face, disabled | --color-ink at 70% |
--color-surface |
21.6–35.2px mono | 4.5:1 | 5.91 | 7.10 |
| StoryQueue | QUEUE heading | --color-ink-faint |
--color-surface |
10px mono | 4.5:1 | 6.25 | 5.25 |
| StoryQueue | Add / Clear links | --color-accent |
--color-surface |
12px bold | 4.5:1 | 8.20 | 6.24 |
| StoryQueue | empty-queue copy | --color-ink-faint |
--color-surface |
13px | 4.5:1 | 6.25 | 5.25 |
| StoryQueue | reorder arrows | --color-ink-faint |
--color-surface |
9px | 4.5:1 | 6.25 | 5.25 |
| StoryQueue | ad-hoc story label | --color-brass |
--color-surface |
10px mono | 4.5:1 | 6.49 | 7.87 |
| StoryQueue | “· current” marker | --color-accent |
--color-felt-deep |
10px mono | 4.5:1 | 6.23 | 7.28 |
| StoryQueue | story-ref chip | --color-ink |
--color-surface |
12.8px mono | 4.5:1 | 15.25 | 13.20 |
| StoryQueue | queue action chip | --color-ink-soft |
--color-surface |
12px bold | 4.5:1 | 6.69 | 7.47 |
| ResultsPanel | hero label | --color-ink-faint |
--color-felt-deep |
11px mono | 4.5:1 | 4.75 | 6.12 |
| ResultsPanel | hero number | --color-ink |
--color-felt-deep |
104px mono | 3:1 | 11.58 | 15.40 |
| ResultsPanel | hero sub-line | --color-ink-soft |
--color-felt-deep |
13px semibold | 4.5:1 | 5.08 | 8.71 |
| ResultsPanel | consensus pill | --color-go |
go 14% on --color-surface |
13px bold | 4.5:1 | 5.17 | 5.85 |
| ResultsPanel | histogram card face | --color-ink |
--color-surface |
18px mono | 4.5:1 | 15.25 | 13.20 |
| ResultsPanel | histogram mode card | --color-ink |
brass 14% on --color-surface |
18px mono | 4.5:1 | 12.31 | 10.35 |
| ResultsPanel | histogram count, mode | --color-brass |
--color-felt-deep |
11px semibold | 4.5:1 | 4.93 | 9.19 |
| ResultsPanel | histogram count, other | --color-ink-faint |
--color-felt-deep |
11px semibold | 4.5:1 | 4.75 | 6.12 |
| StandupRoom | participant link | --color-ink-soft |
--color-surface |
14px bold | 4.5:1 | 6.69 | 7.47 |
| StandupRoom | current speaker chip | --color-ink |
--color-accent-soft |
14px bold | 4.5:1 | 12.72 | 10.64 |
| StandupRoom | skipped speaker name | --color-ink-faint |
--color-felt |
14px bold | 4.5:1 | 5.47 | 5.78 |
| StandupRoom | turn timer, warning | --color-brass |
--color-surface |
30px mono | 3:1 | 6.49 | 7.87 |
| StandupRoom | field label | --color-ink-faint |
--color-surface |
12px bold | 4.5:1 | 6.25 | 5.25 |
| StandupRoom | empty-field em dash | --color-ink-faint |
--color-surface |
16px | 4.5:1 | 6.25 | 5.25 |
| StandupRoom | form field label | --color-ink-soft |
--color-surface |
12px bold | 4.5:1 | 6.69 | 7.47 |
| StandupRoom | save state | --color-ink-faint |
--color-surface |
12px | 4.5:1 | 6.25 | 5.25 |
| StandupRoom | save error | --color-stop |
--color-surface |
12px bold | 4.5:1 | 5.68 | 5.01 |
| StandupRoom | notes well body | --color-ink |
--color-felt-deep |
14px mono | 4.5:1 | 11.58 | 15.40 |
The row that stays on --color-felt-deep — the story the room is on — reuses
combinations already listed here: --color-ink-faint, --color-brass and
--color-ink on --color-felt-deep all appear above at their measured values.
The two rows that carry a de-emphasised state — the skipped speaker and the disabled card — were both measured through their old wrappers first, and both missed. They are listed above at what they measure now; the second pass below says what moved.
What the first pass found
Section titled “What the first pass found”Eight pairs missed AA. All of them in the light theme; the night watch was already clear throughout.
| Pair | Was | Now |
|---|---|---|
ink-faint on felt-deep — hand heading, hero label, histogram counts, rail copy |
4.27 | 4.75 |
go on felt-deep — “picked N” |
4.11 | 4.86 |
go on the consensus pill |
4.43 | 5.17 |
brass on felt-deep — “most picked” count |
4.47 | 4.93 |
ink-faint on felt, inside the spectator group |
2.80 | 5.47 |
ink-soft on felt, inside the spectator group |
3.11 | 5.85 |
The cause of the first four is one thing: --color-felt-deep is the darkest
light-theme ground, and three foregrounds that clear 4.5:1 on --color-surface
fall a little short of it. Lightening the well was the wrong direction — it
exists to read as recessed against --color-felt — so the three foregrounds
were darkened instead, by the smallest step that clears the threshold with a
little headroom:
--color-ink-faint#556472→#4F5D6A--color-brass#7A5A12→#725411--color-go#2A7060→#266454
Only the light definitions moved; the dark values are unchanged, because
nothing in the dark theme failed. The marketing site carries the same three
values independently in site/src/styles/parley.css and was moved with them.
The last two were not a palette problem. The spectator column in Table sat
inside an opacity-70 wrapper, which multiplies through to the text and cost
roughly 1.7:1 no matter what the tokens say. The rule and the smaller avatars
already set spectators apart, so the group opacity was dropped.
What the second pass found
Section titled “What the second pass found”Two more pairs, both the same multiply-through and neither a token problem.
| Pair | Was (light / dark) | Now (light / dark) |
|---|---|---|
skipped speaker in the StandupRoom order rail — ink on felt at opacity-40 |
2.38 / 3.31 | 5.47 / 5.78 |
disabled card in Hand, post-vote — ink on surface at opacity-45 |
2.77 / 3.77 | 5.91 / 7.10 |
The skipped seat followed the spectator column: the group opacity is gone, and
the de-emphasis moved onto the parts that are not text — the avatar dims, the
chip keeps no card background or shadow, and the name is struck through in
--color-ink-faint rather than dimmed --color-ink.
The disabled card could not do the same. The face is the card, so there is
nothing else to dim, and a card that stops looking spent after the vote reads
as still pickable. The wrapper stayed and its floor was raised to opacity-70,
the least dimming that clears 4.5:1 in both themes. It is still visibly faded
against a live hand, and it neither lifts on hover nor takes a cursor.
No token moved in this pass, so the dark definitions and the copies in
site/src/styles/parley.css are untouched.
A sweep of the remaining opacity-* utilities found no third case. The rest sit
on non-text marks — a card pip, an avatar — or on inactive controls, which WCAG
exempts, or clear the threshold anyway: the wordmark on the passcode page runs
--color-ink on --color-felt at opacity-80, which is 7.55 and 9.55.
What the utility sweep could not see
Section titled “What the utility sweep could not see”That sweep read opacity-* classes, and Avatar does not use one — it sets
opacity in an inline style, so the dimmed seat never appeared in the results.
It is the third case, and it is the one place the earlier note about avatars
being a non-text mark deserves the argument spelled out rather than assumed.
The chip is oklch(0.52 0.09 H) for H across the 185°–290° arc, with
#F4F8FB initials, dimmed when the seat is offline or spectating. Measured at
the worst hue in the arc, composited over --color-surface:
| Dim | Light | Dark |
|---|---|---|
0.55, as shipped through 0.4.4 |
2.23 | 3.05 |
0.7, from this change |
2.90 | 3.73 |
Neither clears 4.5:1, and no dim value would: the chip and its initials fade together, so the pair holds a near-constant ratio no matter how far it goes. Reaching AA would mean redesigning the dimmed state, not tuning it.
The initials are not held to the threshold, because every avatar carries the
name as text — in aria-label on the chip, and beside it in the roster, the
member card and the sheet. The glyphs are a redundant mark on a labelled
control, in the same class as the card pip. The floor moved to 0.7 for
legibility rather than compliance, which is also the least dimming that still
reads as “not here” against a live seat.
The doc records it so the next sweep does not have to rediscover that an inline style is invisible to a grep for utilities.
What the portraits changed, in v0.5.1
Section titled “What the portraits changed, in v0.5.1”Nothing in the numbers above. The hue arc still exists and the 0.7 dim floor
still applies, because both live on the chip rather than on what fills it: a
picked portrait replaces the initials inside the same oklch(0.52 0.09 H) disc
and inherits the same inline opacity, so the dimmed seat fades exactly as it
did.
Two smaller points hold with it. The xs chip — the 24px one inline in a line
of text — always renders initials, so nothing in the arc analysis is bypassed
there. And a portrait is alt="" and aria-hidden, which keeps the argument in
the section above intact: the name is still carried as text next to every chip,
so the mark is redundant either way.
Portrait legibility at the sizes we render
Section titled “Portrait legibility at the sizes we render”The numbers above are computed from tokens. Pre-rendered art is not, so the
portraits get their own check: every one of the thirty, drawn at the only two
sizes Parley uses them at — 46px in a seat (Table, StandupRoom) and 38px in
the picker preview — inside the identity-hue disc, on --color-surface, once
per theme.


Regenerate both sheets after any change to the art:
cd webnpm install --no-save @resvg/resvg-jsnode scripts/portrait-contact-sheet.mjs # writes site/src/assets/portrait-legibility-{light,dark}.pngnode scripts/portrait-contact-sheet.mjs --scale=3 # an oversampled copy for close inspection, not committedThe script reads --color-surface out of web/src/tokens.css for both themes
and stamps the value it used into the sheet, so a sheet shot against a stale
surface colour is visible on its face. resvg is not a dependency and not a
build step.
What the sheets show
Section titled “What the sheets show”Read at 1:1, all thirty portraits resolve into a distinct face at both sizes: hair shape and colour, skin tone, and the presence or absence of eyewear all survive 38px. None of them reads as mush, so none is being replaced. Three are the weakest reads and are accepted as-is, with the reason recorded rather than left to be rediscovered:
vikandzeke— both wear a dark visor across the eyes. At 38px the eyes themselves are gone and the visor is a single dark bar. They stay identifiable as the two with the bar, and are told apart by hair colour, but they are the pair most likely to be confused with each other.xiu— white hair against the light theme’s#F7F6F2surface. The silhouette’s top edge softens where it meets the page; the face is unaffected, and the disc keeps the shape closed on every hue in the arc.
bo, otto and noor were also checked specifically for dark hair
disappearing into the dark theme’s #162032: the disc sits behind the whole
portrait, so the hair reads against the chip, not against the page.
Nothing here is asserted by a test — legibility is a judgement, and a rendered
snapshot pinned in CI would be pinning the rasteriser, not the art. What CI does
hold is that the art itself has not changed underneath this page: every portrait
is pinned by a sha256 in web/src/assets/avatars/portraits.sha256, checked by
avatarIcons.test.tsx. Change a portrait and that test fails, which is the
prompt to re-shoot these sheets and re-read this section.