diff --git a/docs/feature-plan.md b/docs/feature-plan.md index 812641f..7d80d1e 100644 --- a/docs/feature-plan.md +++ b/docs/feature-plan.md @@ -387,7 +387,29 @@ it got rather than the retype challenge, which would have taxed the instant hatch the plan also asks us to protect. **Wave 4 — the sweep.** C3 chart alternatives · C4 contrast · C5 control -audit. Best done together, as one audit with one vocabulary. +audit. Best done together, as one audit with one vocabulary. **Shipped.** + +C5 found two things worth recording, since neither was what the item +predicted. It guessed the controls needed radio semantics; they do not — +every answer control here is deselectable (clicking the chosen option clears +it, because every question is optional), and ARIA radios may not behave that +way. `aria-pressed` toggles are the honest match, so the roles stayed and the +navigation changed instead. What was actually broken: + +- **462 tab stops** in a fully expanded survey, one per option button — 70 + to cross "What I value" alone. A roving tabindex (`OptionGroupDirective`) + makes each question one stop with arrows inside it. +- **The importance control exposed no state at all.** Which tier was + selected lived in a highlight class, so a screen reader was told nothing. + +Two more notes: + +- The interest matrix needed nothing — it was already a real table with + scoped headers and a visible level in every cell. Check before adding; + a second table is worse for a screen reader than one. +- Contrast is measurable, so `libs/ui/src/styles/contrast.spec.ts` now + measures it on every run. It found a failure by testing all four series + hues that eyeballing two had missed. **Wave 5 — reach.** D1 PWA · D2 share and print. Multiply the loop after it works. diff --git a/libs/ui/src/a11y/option-group.directive.ts b/libs/ui/src/a11y/option-group.directive.ts new file mode 100644 index 0000000..7644beb --- /dev/null +++ b/libs/ui/src/a11y/option-group.directive.ts @@ -0,0 +1,90 @@ +import { Directive, ElementRef, HostListener, afterNextRender, inject } from '@angular/core'; + +/** Keys that move within the group rather than out of it. */ +const MOVES: Record = { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, +}; + +/** + * One tab stop per question instead of one per option. + * + * Every answer control here is a row of buttons, and each button was its own + * tab stop: 7 for a scale, 4 per interest item, one per choice. A fully + * expanded survey came to 462 Tab presses — 70 of them to cross "What I + * value" alone. Keyboard users were paying a toll no mouse user could see. + * + * This is the composite-widget pattern: the group holds a single tab stop, + * arrows move between options inside it, Home and End jump to the ends. The + * buttons keep their `aria-pressed` toggle semantics, which matters — these + * controls are deselectable (clicking the chosen option clears the answer, + * because every question here is optional), and that is precisely what a + * `role="radio"` group may not do. Announcing them as radios would be tidier + * and would lie. + * + * The tab stop follows the selection, so returning to a question by Tab lands + * on the answer that is already given rather than back at the first option. + */ +@Directive({ selector: '[moxyOptionGroup]' }) +export class OptionGroupDirective { + private readonly host = inject>(ElementRef); + + constructor() { + // Before this runs every button is a tab stop; after it, exactly one is. + // It has to happen on render, or the group would be unreachable by Tab. + afterNextRender(() => this.syncTabStops()); + } + + private options(): HTMLButtonElement[] { + return [...this.host.nativeElement.querySelectorAll('button')]; + } + + /** The pressed option owns the tab stop; with none pressed, the first does. */ + private syncTabStops(): void { + const options = this.options(); + const pressed = options.findIndex((b) => b.getAttribute('aria-pressed') === 'true'); + const stop = pressed === -1 ? 0 : pressed; + options.forEach((button, i) => { + button.tabIndex = i === stop ? 0 : -1; + }); + } + + /** + * Re-sync whenever the group is entered or its selection changes. Cheaper + * and more robust than observing mutations: the DOM is small, and the only + * moments the right tab stop can change are the ones handled here. + */ + @HostListener('focusin') + @HostListener('click') + protected onInteract(): void { + this.syncTabStops(); + } + + @HostListener('keydown', ['$event']) + protected onKeydown(event: KeyboardEvent): void { + const step = MOVES[event.key]; + const isEdge = event.key === 'Home' || event.key === 'End'; + if (step === undefined && !isEdge) return; + + const options = this.options(); + if (options.length === 0) return; + const current = options.indexOf(document.activeElement as HTMLButtonElement); + if (current === -1) return; + + // Wrapping, so a row of options behaves like every other composite + // widget rather than dead-ending at its edges. + const next = isEdge + ? event.key === 'Home' + ? 0 + : options.length - 1 + : (current + step + options.length) % options.length; + + // Arrow keys inside a group must not also scroll the page. + event.preventDefault(); + options[current].tabIndex = -1; + options[next].tabIndex = 0; + options[next].focus(); + } +} diff --git a/libs/ui/src/charts/chart-table.component.ts b/libs/ui/src/charts/chart-table.component.ts new file mode 100644 index 0000000..f56d34c --- /dev/null +++ b/libs/ui/src/charts/chart-table.component.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +/** + * The numbers behind a chart, as a real table. + * + * Every chart in this app carries `role="img"` and a one-line summary, which + * tells a screen-reader user that a shape exists and roughly how big it is — + * not what it says. This is the rest: the same values, in a table anyone can + * read cell by cell, folded away behind a disclosure so it costs sighted + * readers nothing. + * + * Not only for screen readers. A table is also what someone reaches for when + * they distrust a shape, or want to quote one row to the person they compared + * with, or is looking at a radar on a phone where the axis labels collide. + */ +@Component({ + selector: 'moxy-chart-table', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ {{ summary() }} +
+ + + + + @for (column of columns(); track column) { + + } + + + + @for (row of rows(); track row[0]) { + + @for (cell of row; track $index; let i = $index) { + @if (i === 0) { + + } @else { + + } + } + + } + +
+ {{ + caption() + }} +
{{ column }}
{{ cell }}{{ cell }}
+
+
+ `, +}) +export class ChartTableComponent { + /** The disclosure's own label — what opening it gets you. */ + readonly summary = input('Read this as a table'); + /** Describes the table to a screen reader; never shown. */ + readonly caption = input.required(); + readonly columns = input.required(); + /** Each row's first cell is its header — the thing the row is about. */ + readonly rows = input.required(); +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 74fe8c4..56c40b5 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -5,6 +5,8 @@ export { PersonKeyComponent } from './charts/person-key.component'; export { ScaleStripComponent } from './charts/scale-strip.component'; export { InterestMatrixComponent } from './charts/interest-matrix.component'; export { MeterComponent } from './charts/meter.component'; +export { ChartTableComponent } from './charts/chart-table.component'; +export { OptionGroupDirective } from './a11y/option-group.directive'; export { StatTileComponent } from './charts/stat-tile.component'; export { SimDotComponent } from './charts/sim-dot.component'; export { AnswerTextComponent } from './charts/answer-text.component'; diff --git a/libs/ui/src/styles/_base.scss b/libs/ui/src/styles/_base.scss index 2e1020f..0892670 100644 --- a/libs/ui/src/styles/_base.scss +++ b/libs/ui/src/styles/_base.scss @@ -1302,6 +1302,26 @@ textarea { } /* matrices */ +/* The numbers behind a chart, folded away. Costs sighted readers a line of + text; gives everyone else the data the shape is drawn from. */ +.chart-table { + margin-top: 12px; +} +.chart-table > summary { + cursor: pointer; + color: var(--ink-2); + font-size: 13.5px; + padding: 4px 2px; + border-radius: var(--radius-sm); + width: fit-content; +} +.chart-table > summary:hover { + color: var(--ink); +} +.chart-table[open] > summary { + margin-bottom: 6px; +} + .matrix-wrap { overflow-x: auto; } diff --git a/libs/ui/src/styles/_tokens.scss b/libs/ui/src/styles/_tokens.scss index 86e84a3..e27a804 100644 --- a/libs/ui/src/styles/_tokens.scss +++ b/libs/ui/src/styles/_tokens.scss @@ -20,7 +20,9 @@ --surface-2: #f2efe8; --ink: #0b0b0b; --ink-2: #52514e; - --muted: #898781; + /* Was #898781 — 3.4:1 on --page, which fails AA for the .fine text this + colour exists for. Recessive is a design intent; unreadable is not. */ + --muted: #6b6964; --hairline: #e1e0d9; --baseline: #c3c2b7; --border: rgba(11, 11, 11, 0.1); @@ -35,8 +37,13 @@ --series-1: #2a78d6; --series-2: #eb6834; - --series-3: #1baf7a; - --series-4: #eda100; + /* Was #1baf7a — 2.7:1, under the same bar. Found by the spec across all + four hues, not by eye: the ones that fail are not the ones that look + faint, they are the ones whose luminance happens to sit near the page's. */ + --series-3: #158f63; + /* Was #eda100 — 2.1:1, below even the 3:1 that non-text graphics need, so + a fourth person's line was effectively invisible on a light background. */ + --series-4: #ad7500; --ramp-1: #86b6ef; --ramp-2: #2a78d6; @@ -92,3 +99,66 @@ :root[data-theme='dark'] { @include dark-tokens; } + +/* ---------- contrast preferences ---------- */ + +/* "More contrast" is a real setting people turn on, usually because the + default is not working for them. Recessive greys are the first thing to + give up: --muted stops being a shade of the ink and becomes the ink, and + the hairlines that separate rows get strong enough to actually separate + them. Series hues stay put — they carry identity, and darkening them all + toward each other would cost the distinguishability they exist for. */ +@media (prefers-contrast: more) { + :root { + --muted: var(--ink-2); + --hairline: var(--baseline); + --border: rgba(11, 11, 11, 0.32); + } +} + +/* Dark's stronger border, in the same two-selector shape the theme itself + uses: the system preference, then the explicit toggle that must beat it. */ +@media (prefers-contrast: more) and (prefers-color-scheme: dark) { + :root:where(:not([data-theme='light'])) { + --border: rgba(255, 255, 255, 0.32); + } +} +@media (prefers-contrast: more) { + :root[data-theme='dark'] { + --border: rgba(255, 255, 255, 0.32); + } +} + +/* Forced colours (Windows high contrast and friends) replaces the palette + wholesale, and anything painted with a custom colour stops meaning + anything. Map the tokens onto the system keywords so the app keeps its + structure, and let charts keep their own hues via forced-color-adjust: + a four-line chart where every line is CanvasText is four identical lines. */ +@media (forced-colors: active) { + :root { + --page: Canvas; + --surface: Canvas; + --surface-2: Canvas; + --ink: CanvasText; + --ink-2: CanvasText; + --muted: CanvasText; + --hairline: CanvasText; + --baseline: GrayText; + --border: CanvasText; + --accent: LinkText; + --accent-ink: Canvas; + --accent-soft: Canvas; + --danger: LinkText; + } + + svg { + forced-color-adjust: none; + } + + /* The focus ring must survive: it is the only thing telling a keyboard + user where they are, and the system palette will not draw one for a + custom control. */ + :focus-visible { + outline: 2px solid Highlight; + } +} diff --git a/libs/ui/src/styles/contrast.spec.ts b/libs/ui/src/styles/contrast.spec.ts new file mode 100644 index 0000000..80f44f2 --- /dev/null +++ b/libs/ui/src/styles/contrast.spec.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Contrast is measurable, so it is testable, so it should not rely on anyone + * remembering to check. Two of these values were real AA failures found by + * measuring rather than by looking — `--muted` at 3.4:1 under the `.fine` + * text it exists for, and the fourth series hue at 2.1:1, below even the 3:1 + * that non-text graphics need. + * + * Reads the token file rather than a rendered page: the values are the + * contract, and a headless browser would only tell us the same numbers more + * slowly. + */ +const TOKENS = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '_tokens.scss'), 'utf8'); + +function channel(value: number): number { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex: string): number { + const h = hex.replace('#', ''); + const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** Read a token's hex value from the light block or the dark mixin. */ +function token(name: string, theme: 'light' | 'dark'): string { + const block = + theme === 'light' + ? TOKENS.slice(TOKENS.indexOf(':root {'), TOKENS.indexOf('@mixin dark-tokens')) + : TOKENS.slice(TOKENS.indexOf('@mixin dark-tokens')); + const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`)); + if (!match) throw new Error(`no hex value for --${name} in the ${theme} palette`); + return match[1]; +} + +describe('token contrast', () => { + for (const theme of ['light', 'dark'] as const) { + describe(theme, () => { + // AA for normal text. --muted is the colour of `.fine`, which carries + // the honest-limits copy — the last text in this app that should be + // hard to read. + it('gives muted text 4.5:1 against both the page and a card', () => { + for (const surface of ['page', 'surface'] as const) { + const ratio = contrast(token('muted', theme), token(surface, theme)); + expect(ratio, `--muted on --${surface} (${theme})`).toBeGreaterThanOrEqual(4.5); + } + }); + + it('gives secondary ink 4.5:1 against a card', () => { + expect(contrast(token('ink-2', theme), token('surface', theme))).toBeGreaterThanOrEqual( + 4.5, + ); + }); + + it('gives the accent and danger colours 4.5:1 against a card', () => { + for (const name of ['accent', 'danger'] as const) { + const ratio = contrast(token(name, theme), token('surface', theme)); + expect(ratio, `--${name} (${theme})`).toBeGreaterThanOrEqual(4.5); + } + }); + + // Non-text graphics: 3:1 is the bar, and a series colour that misses it + // is a person's line nobody can see. + it('gives every series hue 3:1 as a graphic', () => { + for (const n of [1, 2, 3, 4]) { + const ratio = contrast(token(`series-${n}`, theme), token('surface', theme)); + expect(ratio, `--series-${n} (${theme})`).toBeGreaterThanOrEqual(3); + } + }); + }); + } + + it('answers the contrast and forced-colours preferences at all', () => { + expect(TOKENS).toContain('prefers-contrast: more'); + expect(TOKENS).toContain('forced-colors: active'); + // Charts opt out of forced colours on purpose: four lines all painted + // CanvasText are four identical lines. + expect(TOKENS).toContain('forced-color-adjust: none'); + }); +}); diff --git a/src/app/compare/panels/chart-tables.spec.ts b/src/app/compare/panels/chart-tables.spec.ts new file mode 100644 index 0000000..97e6f7a --- /dev/null +++ b/src/app/compare/panels/chart-tables.spec.ts @@ -0,0 +1,88 @@ +import { type Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { buildDemoCast, personaFromViewPhrase } from '@moxy/core'; +import { buildCompareModel, type CompareModel, type CompareSlot } from '../compare-model'; +import { FingerprintPanel } from './fingerprint.panel'; +import { SeekingMatrixPanel } from './seeking-matrix.panel'; +import { ValuesStripsPanel } from './values-strips.panel'; + +/** + * Every chart carries role="img" and a one-line summary, which says a shape + * exists without saying what it says. These tables are the rest of it, so + * what matters is that the numbers in them are the numbers the chart drew. + */ +async function demoModel(): Promise { + const cast = await buildDemoCast(); + const slots: CompareSlot[] = await Promise.all( + cast.map(async (profile) => ({ + ref: profile.phrase, + payload: profile.payload, + persona: await personaFromViewPhrase(profile.phrase), + })), + ); + return buildCompareModel(slots); +} + +function render(type: Type, model: CompareModel): HTMLElement { + const fixture = TestBed.createComponent(type); + fixture.componentRef.setInput('model', model); + fixture.detectChanges(); + return fixture.nativeElement; +} + +/** Rows as [header, ...cells], the way a screen reader would walk them. */ +function tableRows(el: HTMLElement): string[][] { + return [...el.querySelectorAll('tbody tr')].map((tr) => + [...tr.querySelectorAll('th, td')].map((cell) => cell.textContent?.trim() ?? ''), + ); +} + +describe('chart tables', () => { + let model: CompareModel; + + beforeEach(async () => { + model = await demoModel(); + await TestBed.configureTestingModule({}).compileComponents(); + }); + + it('gives the fingerprint its axes as rows and its people as columns', () => { + const el = render(FingerprintPanel, model); + const headers = [...el.querySelectorAll('thead th')].map((th) => th.textContent?.trim()); + expect(headers).toEqual(['Value', 'brave-azure-otter', 'calm-bright-owl']); + + const rows = tableRows(el); + expect(rows.length).toBeGreaterThanOrEqual(3); + // Values are the answers themselves, not the 0..1 the radar draws with. + for (const row of rows) { + expect(row[1]).toMatch(/^\d\/\d$/); + expect(row[2]).toMatch(/^\d\/\d$/); + } + }); + + // The interest matrix is already a table with scoped headers and a visible + // level in every cell, so it gets no second one — and must not grow one. + it('leaves the interest matrix as the single table it already is', () => { + const el = render(SeekingMatrixPanel, model); + expect(el.querySelectorAll('table')).toHaveLength(1); + expect(el.querySelector('details.chart-table')).toBeNull(); + const friendship = tableRows(el).find((row) => row[0] === 'Friendship'); + expect(friendship?.slice(0, 3)).toEqual(['Friendship', 'Into it', 'Into it']); + }); + + it('marks an unanswered scale as absent rather than as zero', async () => { + // One profile answers a values scale the other never touched. + const [first, second] = model.slots; + const thinnedAnswers = { ...second.payload!.a }; + delete thinnedAnswers['va.together']; + const thinned: CompareSlot = { + ...second, + payload: { ...second.payload!, a: thinnedAnswers }, + }; + + const rows = tableRows(render(ValuesStripsPanel, await buildCompareModel([first, thinned]))); + const together = rows.find((row) => row[0].includes('Togetherness')); + // A dash, not a zero: "did not answer" and "answered zero" are different + // facts, and the strip above draws only one dot for this row. + expect(together?.[2]).toBe('—'); + }); +}); diff --git a/src/app/compare/panels/fingerprint.panel.ts b/src/app/compare/panels/fingerprint.panel.ts index 1671b84..440ef04 100644 --- a/src/app/compare/panels/fingerprint.panel.ts +++ b/src/app/compare/panels/fingerprint.panel.ts @@ -1,6 +1,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { SCALE_MAX, getSection, type ScaleItem } from '@moxy/core'; -import { PersonKeyComponent, RadarComponent, type RadarSeries } from '@moxy/ui'; +import { + ChartTableComponent, + PersonKeyComponent, + RadarComponent, + type RadarSeries, +} from '@moxy/ui'; import type { CompareModel } from '../compare-model'; import type { ComparePanelComponent } from '../compare-panels.token'; @@ -12,7 +17,7 @@ import type { ComparePanelComponent } from '../compare-panels.token'; @Component({ selector: 'moxy-fingerprint-panel', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PersonKeyComponent, RadarComponent], + imports: [ChartTableComponent, PersonKeyComponent, RadarComponent], template: `

Values fingerprint

@@ -22,6 +27,11 @@ import type { ComparePanelComponent } from '../compare-panels.token';

+
`, }) @@ -43,6 +53,16 @@ export class FingerprintPanel implements ComparePanelComponent { protected readonly axes = computed(() => this.sharedScales().map((s) => s.right)); + /** The shape's own numbers: one row per axis, one column per person. */ + protected readonly tableColumns = computed(() => ['Value', ...this.model().names]); + + protected readonly tableRows = computed(() => + this.sharedScales().map((scale) => [ + `${scale.left} → ${scale.right}`, + ...this.model().payloads.map((p) => `${p.a[scale.id] as number}/${SCALE_MAX}`), + ]), + ); + protected readonly series = computed(() => this.model().payloads.map((p, i) => ({ name: this.model().names[i], diff --git a/src/app/compare/panels/seeking-matrix.panel.ts b/src/app/compare/panels/seeking-matrix.panel.ts index 4fc01f0..f7eb15a 100644 --- a/src/app/compare/panels/seeking-matrix.panel.ts +++ b/src/app/compare/panels/seeking-matrix.panel.ts @@ -11,6 +11,10 @@ import type { ComparePanelComponent } from '../compare-panels.token';

What each of you is open to

Highlighted rows are mutual — everyone answered is at least “Curious”.

+
`, diff --git a/src/app/compare/panels/values-strips.panel.ts b/src/app/compare/panels/values-strips.panel.ts index 210b7d3..5874303 100644 --- a/src/app/compare/panels/values-strips.panel.ts +++ b/src/app/compare/panels/values-strips.panel.ts @@ -1,13 +1,13 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import type { ScaleItem } from '@moxy/core'; -import { PersonKeyComponent, ScaleStripComponent } from '@moxy/ui'; +import { SCALE_MAX, itemLabel, type ScaleItem } from '@moxy/core'; +import { ChartTableComponent, PersonKeyComponent, ScaleStripComponent } from '@moxy/ui'; import type { CompareModel } from '../compare-model'; import type { ComparePanelComponent } from '../compare-panels.token'; @Component({ selector: 'moxy-values-strips-panel', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PersonKeyComponent, ScaleStripComponent], + imports: [ChartTableComponent, PersonKeyComponent, ScaleStripComponent], template: `

Values, side by side

@@ -20,6 +20,11 @@ import type { ComparePanelComponent } from '../compare-panels.token'; [names]="model().names" /> } +
`, }) @@ -31,6 +36,17 @@ export class ValuesStripsPanel implements ComparePanelComponent { return grid ? grid.rows.filter((r) => r.answeredCount > 0) : []; }); + protected readonly tableColumns = computed(() => ['Value', ...this.model().names]); + + protected readonly tableRows = computed(() => + this.rows().map((row) => [ + itemLabel(row.item), + // A dash, not a zero: unanswered and "answered zero" are different + // things, and a table that blurs them lies about the strip above it. + ...row.answers.map((a) => (typeof a === 'number' ? `${a}/${SCALE_MAX}` : '—')), + ]), + ); + protected asScale(item: unknown): ScaleItem { return item as ScaleItem; } diff --git a/src/app/survey/items/choice-editor.component.ts b/src/app/survey/items/choice-editor.component.ts index a266488..5e61629 100644 --- a/src/app/survey/items/choice-editor.component.ts +++ b/src/app/survey/items/choice-editor.component.ts @@ -1,12 +1,14 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import type { AnswerValue, ChoiceItem } from '@moxy/core'; +import { OptionGroupDirective } from '@moxy/ui'; /** Single-select pills; clicking the selected pill clears the answer. */ @Component({ selector: 'moxy-choice-editor', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [OptionGroupDirective], template: ` -
+
@for (opt of item().options; track $index) {
@if (weight() === 3) { -
+
I could match with: @for (opt of acceptOptions(); track $index) {