Skip to content

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.

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.

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.

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.

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.

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.

A contact sheet of thirty voxel-art portraits, each drawn twice — at 46 pixels and at 38 pixels — inside a coloured disc on the page surface, labelled with its id.A contact sheet of thirty voxel-art portraits, each drawn twice — at 46 pixels and at 38 pixels — inside a coloured disc on the page surface, labelled with its id.
One sheet per theme, rendered 1:1. The committed PNGs under site/src/assets are the artifact; view them at 100% rather than scaled in the page.

Regenerate both sheets after any change to the art:

Terminal window
cd web
npm install --no-save @resvg/resvg-js
node scripts/portrait-contact-sheet.mjs # writes site/src/assets/portrait-legibility-{light,dark}.png
node scripts/portrait-contact-sheet.mjs --scale=3 # an oversampled copy for close inspection, not committed

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

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:

  • vik and zeke — 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 #F7F6F2 surface. 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.