diff --git a/.gitignore b/.gitignore index 4fdd2f9f..891972d9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ playwright-report/ # anyone running a fork. Keep them local; put anything durable in README.md, # CONTRIBUTING.md, or docs/ops.md instead. docs/superpowers/ + +# Design-sweep screenshots: regenerable from docs/design-sweep-2/capture.spec.ts.txt +# and several megabytes of PNG. The prose reports beside them are tracked. +docs/design-sweep-2/shots/ diff --git a/AGENTS.md b/AGENTS.md index d9e5201c..2a9f9592 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,3 +45,30 @@ until it comes up green. The rationale is written next to the setting. `docs/e2e-flake-triage.md` has the full triage workflow and lists the tests currently exposed to the `useSubmitGuard` latch defect. +## A test written to prove a fix must be shown to fail without it + +A passing new test is not evidence that it tests anything. Assertions written +alongside a fix pass for two indistinguishable reasons — the fix works, or the +assertion cannot fail — and a green run reports both identically. This is not a +rare slip: six assertions written during one sweep of this repo turned out to be +vacuous, each caught only by this check and none by review. + +So, before a test that exists to prove a fix is committed: **take the fix out, +leave the test in, and watch it fail.** Then put the fix back and watch it pass. +If the failure message does not describe the defect you set out to fix, the test +is measuring something else. + +The instrument matters as much as the discipline. Reverting *everything* +uncommitted is not a control when the test is part of the same uncommitted +change — stashing removes the test alongside the fix, nothing runs, and the +green result is read as confirmation. Revert only the code under test, by hand, +and leave the assertion standing. + +The failures this catches are the quiet kind. `toBeVisible()` against a +`.visually-hidden` element passes either way, because a 1px clipped box still +has a bounding box. A row-count assertion on a table whose empty state is itself +one `` passes whether or not the filter ran. A colour assertion parsing +`getComputedStyle` output passes on garbage, because these tokens serialize as +`oklch(...)` and a naive parse reads lightness as red without erroring. None of +those announce themselves; all of them fail loudly the moment the fix is removed. + diff --git a/DESIGN.md b/DESIGN.md index 6b32aa8e..69c4241c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -58,7 +58,7 @@ lighter structure token carries a little more, up to `--rule-strong`. Do not "re a tint to `--void` for consistency with the rest of the ramp: the ramp *is* the rule. Every text token below was measured against all three grounds rather than asserted; -the worst case is `--ink-faint` at 4.63:1 on a hovered row, and nothing sits under +the worst case is `--ink-faint` at 4.61:1 on a hovered row, and nothing sits under 4.5:1. Two of these values exist *because* of that measurement rather than taste: `--signal-bad` is `0.66` because `0.64` measured 4.42 against `--hull-hi` (under the floor exactly when the pointer is on the row), and `--rule-strong` holds its lightness @@ -84,7 +84,7 @@ OKLCH is the right space to *tune* in and the wrong one to *judge* in. | `--hull` | `oklch(0.195 0.001 56)` | Panels, table headers, inset regions. | | `--hull-hi` | `oklch(0.245 0.002 54)` | Row hover, raised controls, pressed states. | | `--rule` | `oklch(0.33 0.004 55)` | Hairlines. The primary structural device. | -| `--rule-strong` | `oklch(0.56 0.008 58)` | Section boundaries, control borders. Lightness is set by WCAG 1.4.11: it measures 4.24 / 3.92 / 3.48 against `--void`, `--hull`, and `--hull-hi`, so a control's edge is identifiable on every ground it can sit on. | +| `--rule-strong` | `oklch(0.56 0.008 58)` | Section boundaries, control borders. Lightness is set by WCAG 1.4.11: it measures 4.23 / 3.90 / 3.47 against `--void`, `--hull`, and `--hull-hi`, so a control's edge is identifiable on every ground it can sit on. | ### Ink @@ -102,7 +102,7 @@ Gold is identity and emphasis. The three signals are semantic and never decorati |---|---|---| | `--gold` | `oklch(0.83 0.155 88)` | Brand mark, active nav, primary action, Member tier. | | `--gold-dim` | `oklch(0.72 0.14 84)` | Gold borders, hover on gold surfaces. | -| `--signal-ok` | `oklch(0.74 0.09 150)` | Reserved for where health is genuinely the subject (`.notice--ok`). **Not** the default `ok` status token, which takes `--ink-dim` — see "Status token" below. | +| `--signal-ok` | `oklch(0.74 0.09 150)` | **Declared, documented, and used by nothing.** It was meant for where health is genuinely the subject, and the class this row used to name as that place — `.notice--ok` — has never existed. Kept rather than deleted because the notice family (`--warn`, `--bad`) has an obvious gap where it would sit, and because `--tier-alumni` is the same value: deleting the token would leave the next `ok` notice reaching for the alumni tier's colour, which is worse than an unused declaration. **Not** the default `ok` status token, which takes `--ink-dim` — see "Status token" below. | | `--signal-warn` | `oklch(0.80 0.15 50)` | Needs re-auth, scope shortfall, cryo (admin table only — an admin scans for it; the member's own account page reads cryo in `--ink-dim` instead, since it's a pause the member asked for, not a fault). Hue 50 rather than the 70 this started at: at 70 the warn signal sat 18° from `--gold`/`--tier-member` at near-identical chroma and lightness — 0.057 apart in OKLab, not enough to tell a gold Member badge from an amber CRYO token two columns away in the same mono uppercase. 50 nearly doubles that to 0.104 while holding 0.146 from `--signal-bad`. Warn remains nearer to gold than to bad, deliberately: equalising the two means rotating to about hue 37, buying separation from identity by spending it on failure, and mistaking a warning for an error is the worse confusion. The bar is legibility against gold, not equidistance. | | `--signal-bad` | `oklch(0.66 0.19 26)` | Dead token, failed sync, destructive action. | @@ -128,8 +128,23 @@ a real tier's colour. - Colour is never the only carrier of meaning. - Saturated colour occupies well under 10% of any screen. The gold in particular is - rationed: one primary action per view, plus the mark. -- No gradients on text, ever. No decorative gradients at all. + rationed: one primary action per view, plus the mark, plus the active nav item + and the Member tier badge where those appear — the four uses the `--gold` row + above lists, not the two this line used to name. The short version selected for + the wrong repair: a reader auditing a screen against "one action plus the mark" + finds gold on the nav and on a tier badge and has to decide which of the two is + the mistake, when neither is. The focus ring is gold as well and is not part of + the ration at all — WCAG 2.4.7 says it appears wherever focus lands, and a + budget is not a thing an indicator can be held to. +- No gradients on text, ever, and none used as decoration. Two `linear-gradient`s + exist and both are load-bearing rather than ornamental: the scroller edge fades + (`.scroller-fade--start` / `--end`), which are the only rest-state cue that a + table has more to the side, and `.status__dot`'s neutral bar, which is a + gradient of one colour against itself — a flat fill, used because painting the + 0.15em bar into a constant-height box keeps every tone's geometry identical. + This line used to read "no decorative gradients at all", which is the intent + and reads as a ban; someone enforcing it literally would delete a scroll + affordance and misalign a status baseline. ## Typography @@ -235,8 +250,8 @@ you what it holds. below in a single measured column; admin tables are allowed to run wider. - **Nav membership is keyed to the viewer, not the section.** The bar offers every destination this viewer is *provably authorized* to reach — `Your account` always, - `Operations` when they can read payouts, `Members`/`Audit log`/`Sync` when they are an - admin — in one fixed order, broadest access first. Membership does not change as you + `Operations` when they can read payouts, `Members`/`Audit log`/`Sync`/`Access lists` + when they are an admin — in one fixed order, broadest access first. Membership does not change as you walk between sections, so no destination is ever two hops away from a viewer who is entitled to it in one. Note that `isAdmin` and `tier` are independent, so an admin is not automatically a payouts reader and `Operations` is never unconditional in the admin @@ -307,6 +322,14 @@ you what it holds. - Transitions are 140ms on colour and border, 220ms on transforms, both on `cubic-bezier(0.22, 1, 0.36, 1)` (ease-out-quint). No bounce, no elastic, no spring. + The 220ms half of that is a reservation rather than a description. `--dur-move` + has no consumers: all ten `transition` declarations in `globals.css` animate a + colour, a border or an opacity, and every one of them takes `--dur-color`. The + transforms that do exist are `@keyframes` — `seal-settle` at 620ms, + `btn-pulse` and `link-pending-pulse` at 900ms — and each names its own duration + rather than reading the scale, because a loop and a settle are not the same + kind of thing as a hover. Worth knowing before adding a transform transition + and finding the token has never been exercised. - Layout properties are never animated. Transform and opacity only. - The only entrance animation in the system is the login seal, and it is a single opacity-and-scale settle. @@ -346,7 +369,7 @@ configuration, the other two by overwriting the file. - Disabled controls keep `opacity: 1` and take an explicit `--ink-faint`, rather than fading. An opacity fade moves with whatever ground it lands on: at 65% the disabled text measured 3.24:1 on `--void` but 2.88:1 on a hovered admin table row, under the - 3:1 floor exactly when the pointer is on the row. The explicit colour is 4.85:1 on + 3:1 floor exactly when the pointer is on the row. The explicit colour is 4.61:1 on `--hull-hi` and does not move. **Do not "simplify" this back to an opacity.** - Hit targets: `36px` for standalone controls (`.btn`), `28px` for the in-row controls of the admin tables (`.btn--micro`, `.btn--quiet`, `.row-toggle`). Both clear the diff --git a/PRODUCT.md b/PRODUCT.md index ccb0aca1..7d88eceb 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -95,10 +95,19 @@ what is true. Never exclaims. Never apologizes twice. ordinary states, not failures. Reserve alarm colour for things the user can and should fix. 5. **Earn the artwork.** Faoble's illustrations are shown properly or not at all: - full quality, from an asset cut for the size it is drawn at, never a large file - scaled down or a fragment cropped out for decoration. The login ground is the - deliberate exception, where the lander is held far enough back to be texture - rather than picture. + full quality, from a master with pixels to spare, never a fragment cropped out + for decoration and never an asset stretched past the size it was cut for. The + login ground is the deliberate exception, where the lander is held far enough + back to be texture rather than picture. + + This principle used to say "never a large file scaled down", which the app has + never obeyed and should not: `hero-account.webp` is a 1120px master rendered at + 420px, or at 260px when `.closing--compact` applies on a one-character account. + Rendering a high-resolution master smaller is what protects the artwork — + oversampling is why it stays crisp on a 2x display. What degrades it is the + opposite direction, and that is what the rule is for. Stated as a ban on + downscaling, the principle condemned the correct implementation and left the + actual failure mode unnamed. ## Accessibility & Inclusion diff --git a/docs/design-sweep-2/BRIEF.md b/docs/design-sweep-2/BRIEF.md new file mode 100644 index 00000000..ceb88602 --- /dev/null +++ b/docs/design-sweep-2/BRIEF.md @@ -0,0 +1,352 @@ +# Design sweep 2 — dispatch brief + +Worktree: `.claude/worktrees/design-sweep-2026-08-10`. Base: `main` @ `a9777e7`. + +Screenshots for every surface are in `docs/design-sweep-2/shots/`, captured +2026-08-09 at 1440×900 (`.wide.png`) and 390×844 (`.narrow.png`), both +`fullPage`, against a seeded realistic fixture (34 payout operations, 62 audit +rows, 9 job types × 5 runs, a 16-participant roster, 12 members across four +tiers) plus the empty and error states. + +--- + +## Preamble — every reviewer gets this verbatim + +**Look at your surface's screenshot before you open a file.** Say what you see +before you explain it. Then read the target and everything it imports, and trace +at least one full interaction path. Do not review from structure alone — and do +not review from source alone, which is the same mistake wearing better clothes. +Both viewports. The narrow one is not an afterthought; several of these pages +are read on a phone at 1am. + +**You are read-only on source.** The single write you may make is your report, +at the path named in your block. Do not edit, format, or "fix in passing." + +### What this sweep is hunting + +Three failure patterns were found on `/account` (the reference surface, already +worked — see `12-account.*.png` and `src/app/account/page.tsx` for what "done" +looks like). Look for them specifically, and say plainly if your surface does +not have them: + +1. **Unshaped field.** Content occupying a fraction of a 78rem column with the + remainder empty, and the page running long instead of wide. +2. **Total enumeration.** A value repeated identically on every row of a table + when it is really one fact about the whole set. `crewNorms` in + `src/app/account/page.tsx` is the pattern for fixing it: measure deviation + against the set, state the shared fact once, keep both channels in parity. + `/admin/sync`'s "Cadence (UTC)" column header is the same fix in miniature — + the shared timezone is said once in the header, stripped from every row's + visible text, and restored per-row in a `visually-hidden` span so the + accessible name does not lose it. +3. **Repeated identical controls at uniform weight, where nothing directs the + eye.** + +Also: **an explanatory subtitle under an H1 is a smell.** A caption explaining +what a table means usually means the table needs work, not that it needs a +caption. + +### Report format — identical for everyone + +Findings worst-first. Each one: + +- **Severity** — Critical / Serious / Moderate / Minor. +- **Where** — a `file:line` *when the finding has one*. `**Where:** whole + surface` and `**Where:** across surfaces` are first-class values, not + fallbacks. See below. +- **Cost** — one sentence naming who is harmed and what it costs them. Concrete. + "A member checking a stale token at 1am scrolls past four screens of identical + rows to find the one that is red" is a cost. "Violates hierarchy" is not. +- **Fix** — concrete, and scoped to what you would actually change. +- **Principle** — the violated principle where one exists. + +Then: **what is genuinely good and should survive** (name it, so the fix pass +does not break it), and **what you could not evaluate** and why. + +### Four things about that format, each of which is load-bearing + +- **`file:line` is optional and I mean it.** The findings that cannot produce + one are the ones about what a screen *adds up to*: ten individually-defensible + decisions composing a page with no focal point, a table whose every row is + defensible and whose aggregate is noise. A format that demands a line number + selects against exactly those. If you have no location, give the observation + that supports the finding instead. +- **A missing principle citation is not a defect in the finding.** Do not drop a + finding because you cannot cite a rule for it, and do not manufacture a + criterion that half-fits — a fabricated criterion gets ranked as if it were + real. +- **State colour in rendered sRGB hex, never in the authoring space.** OKLCH is + the right space to tune in and the wrong one to judge in. At near-black, judge + the red-to-blue *ratio*, not the absolute difference — `#0c0a08` is a gap of 4 + and visibly brown. Convert before filing and quote the hex. +- **Counts and comparisons are claims.** "Sixteen branches", "the only client + component", "these two rules are identical" — check them before you state + them, and cut the ones that carry no decision. + +### Settled — constraints (closed, do not re-open) + +These are closed by something outside the design. Proposing to change them wastes +the finding. + +- **Dark only.** EVE's client is dark and its players expect dark. A light ground + is not on the table. Literal-paper and paper-panel-on-dark were both built and + rejected. +- **WCAG 2.2 AA** is the floor: 4.5:1 text, 3:1 for large text and UI + boundaries, 24px hit targets (2.5.8 AA), 320px reflow, 200% zoom, focus never + suppressed, colour never the only carrier. +- **`--void` is chroma 0** (`#0a0a0a`). Two warm grounds shipped and both read as + brown. Do not propose restoring a tint "for consistency with the ramp." +- **Do not change colour tokens.** Explicit instruction from the owner for this + sweep. You may report that a token is *used* wrongly; do not propose retuning + its value. +- **Two hit-target grades, 36px and 28px, and no third.** 28px is scoped by the + reason for it — rows that each carry a control set and are read many at a + time. A disclosure drawer is not in-row for this purpose and takes 36px. +- **`.st--ok` is `--ink-dim`, not green.** An `ok` that has to shout is an `ok` + competing with the one row that isn't. Do not propose restoring the green. +- **Disabled controls keep `opacity: 1` and take explicit `--ink-faint`.** An + opacity fade measured 2.88:1 on a hovered row. Do not simplify back to opacity. +- **Nav membership is keyed to the viewer, not the section**, derived once in + `src/app/_components/nav-items.ts`. The three boundary surfaces run the same + rule on the strongest membership the *path* alone proves. +- **One column origin.** The page box is `--measure-page` on every route; + narrow surfaces cap their *contents*, never the column. +- **Artwork carries empty `alt`**, including the seal, because it sits directly + above an `

` holding the same name. +- **Migrations are generated, never hand-written**, and an applied one is never + edited. + +### Settled — taste (decided, and open to challenge ONCE) + +These were closed by someone's judgement, not by an external constraint. They are +decided. If you think one is wrong, say so **in a clearly-marked contested +section at the end of your report**, with your reasoning. Do not spend the body +of the report re-litigating them. + +- Gold as the single emphasis colour, rationed to one primary action per view + plus the mark. +- `--signal-warn` at hue 50 (moved from 70 for separation from gold). +- The two-family split: Archivo for prose, IBM Plex Mono for all state. "Prose is + proportional, state is monospaced." +- The tight type ramp below `--t-h2` (1.07–1.09 between steps); size carries + little signal down there and face/weight/case/colour carry it instead. +- **No cards.** Structure is hairline rules and section headers. Exactly two + exceptions exist — the login panel and `/payouts/new`'s form panel — and a + third would mean the rule has stopped being true. +- Registration ticks on the login panel only. +- Near-zero radii (2px controls, 0 rules). +- Deadpan voice: terse, factual, never exclaims, joke lives in artwork and + microcopy and never in the controls. + +### Do NOT re-report — closed by the Aug-5 sweep + +Verified against `docs/design-sweep/SYNTHESIS.md` and `SECOND-PASS.md` this +session. If you find one of these still broken, that is a *regression* finding +and worth filing — but say explicitly that you are re-opening a closed item and +give the evidence. + +Re-auth stale state; dead worker reading healthy; rejected inline edit losing +input; `/admin/accounts` focus + tier disable; four silent `/account` actions; +`/payouts` "was I paid?"; alt-name audit search; the "Try again" control; +three `&&`-mounted `Notice`s; Discord unlink self-disarm; `/login`'s scope-list +`
` inversion; `/admin/accounts` search; 200% zoom portholes; pinned-column +focus ring; `/admin/sync` enqueue confirmations and the queued/last-run +separator; `/admin/audit`'s UUID recital and lost timestamp; `.dim` font-size and +the orphan `.dim-ink`; typographic drift; nav membership between sections +(resolved 2026-08-06 by the viewer-keyed rule); the `discord-roles` audit gap; +the `audit_log` action index; `workerHeartbeat`'s null conflation; both +add-forms' duplicate-submit hazard; `accountsConfirmation`'s loose signature. + +### Known-open — report only if you can add something + +These are already on the backlog. Do not spend a finding restating them; do file +if your surface shows a *consequence* the existing entry does not name. + +- The first "mark paid" freezes the operation and the warning renders after the + press. +- Members are shown live draft amounts the service refuses to pay. +- `/payouts`' future-date guard is client-only. +- Oversized images with no `sizes`/priority on `/account` and `/login` (the + 82 KB seal is the LCP element). +- `` prose length on `/account`. +- `.launch__foot`'s sixth type size; `.escalation`'s 1.00:1 ground; `Tone`'s + missing docblock; `RuleHead`'s dead `as="span"` default. +- Duplicate pagers on `/admin/audit`. + +### Domain vocabulary — these are correct, do not propose generic replacements + +*Corp*, *alt*, *fleet*, *cryo* (a pause the member asked for, not a fault), +*derole* (drops tier, keeps account — never a deletion), *tier* (Member / +Associate / Alumni / Pending), *operation* (one fight, one payout row), *ACL* +(the Wanderer map access list), *ESI* (EVE's API), *scope* (an OAuth grant), +*Flight log* / *manifest* / mission-patch furniture. "Center for Kids Who Can't +Fly Good" is the corp's real joke, told straight. + +--- + +## Surface blocks + +Every block gets the preamble above. Register determines which impeccable +reference you work from. + +### 1. `/login` — **register: brand** + +- Source: `src/app/login/page.tsx` +- Shots: `01-login.wide.png`, `01-login.narrow.png` +- The only unauthenticated surface, and the only one where the artwork is the + subject. It carries the seal (the LCP element), the login panel (one of the + two sanctioned card exceptions), the registration ticks (used here and nowhere + else), and the hero line art held back as texture. +- Judge it on: does it establish the flight-operations-at-night theme in one + screen, and does it read as authored rather than generated? A member's first + impression of the whole tool is this page. +- Report: `docs/design-sweep-2/reports/login-critique.md` / + `login-audit.md` + +### 2. `/payouts` — **register: product** + +- Source: `src/app/payouts/page.tsx`, `src/app/payouts/access.ts`, and the + co-located components in `src/app/payouts/` +- Shots: `04-payouts-full.*` (34 operations — the realistic case), and + `03-payouts-empty.*` (the empty state) +- Judge it on: principle 3, scanning is the primary act. And principle 2, state + before action — can a member answer "was I paid?" without pressing anything? +- This is a prime candidate for patterns 1 and 2. Check the column set against + what actually varies row to row. +- Report: `docs/design-sweep-2/reports/payouts-critique.md` / `payouts-audit.md` + +### 3. `/payouts/new` — **register: product** + +- Source: `src/app/payouts/new/page.tsx` (58 lines) and + `src/app/payouts/new/new-operation-form.tsx` +- Shots: `05-payouts-new.*` +- The second of the two sanctioned card exceptions (`.form-panel`). The page is + `page--narrow` with one short form on it — pattern 1 is the obvious risk, and + the panel exists precisely to answer it. Say whether it does. +- Operator-only: a non-operator is redirected out rather than handed a form that + would reject on submit. +- Report: `docs/design-sweep-2/reports/payouts-new-critique.md` / + `payouts-new-audit.md` + +### 4. `/payouts/[id]` — **register: product** + +- Source: `src/app/payouts/[id]/page.tsx` and every co-located component + (`lifecycle-submit.tsx`, `notes-form.tsx`, `flat-pool-form.tsx`, + `add-participant-form.tsx`, the appraise form) +- Shots: `06-payout-detail-draft.*` (a draft with a 16-participant roster and + both pool kinds), `07-payout-detail-finalized.*` +- The densest surface in the app and the one with the most controls. Pattern 3 is + the primary risk: count the pressable things and say what directs the eye. +- `ConfirmCost`'s `"visible"` case lives here (Finalize/Unlock sit alone outside + a table, so their permanent caption is wanted copy, not a fault appearing). + That is settled — do not propose hiding it. +- Report: `docs/design-sweep-2/reports/payout-detail-critique.md` / + `payout-detail-audit.md` + +### 5. `/admin/audit` — **register: product** + +- Source: `src/app/admin/audit/page.tsx`, `src/app/admin/audit/summarize.ts`, + `src/services/audit.ts`'s `queryAuditLog` +- Shots: `10-audit-full.*` (62 rows), `09-audit-empty.*` +- Judge it on the promise in PRODUCT.md: an admin can answer "why is this + person's role wrong?" in under a minute. Time that path. +- Pattern 2 is the standing risk on a log table — check what every row repeats. + The duplicate-pager item is already known; do not spend a finding on it. +- The empty state is also one ``, so do not assert a filter worked from a row + count alone. +- Report: `docs/design-sweep-2/reports/audit-critique.md` / `audit-audit.md` + +### 6. `/admin/sync` — **register: product** + +- Source: `src/app/admin/sync/page.tsx` (1143 lines — read all of it), + `src/app/admin/sync/actions.ts`, `src/app/admin/sync/view.ts`, + `src/core/schedules.ts` +- Shots: `11-admin-sync.*` +- This surface has already solved pattern 2 once ("Cadence (UTC)"), and folds + housekeeping behind a `Disclosure` keyed to whether the group needs attention. + Say what still enumerates. +- Pattern 3 is live at the bottom control row: `Sync now` (primary) / + `Recheck invalid affiliations` / `Refresh`, plus a per-job `Re-run` in every + drawer. +- The `Absent` component pairs an aria-hidden glyph with the words it stands + for — that is the R4 parity rule working. Do not break it. +- Report: `docs/design-sweep-2/reports/sync-critique.md` / `sync-audit.md` + +### 7. The boundaries — **register: product** + +One reviewer pair covers all three; they are small, related, and share a nav +derivation. + +- Source: `src/app/error.tsx` (317 lines, client component), + `src/app/not-found.tsx`, `src/app/payouts/[id]/not-found.tsx` +- Shots: `13-error-boundary.*`, `02-not-found-root.*`, `08-payout-not-found.*` +- A user meets these on their worst day with the app, and they are the surfaces + least likely to have been designed. That is the whole reason they are in scope. +- Settled and deliberate: no `global-error.tsx`; gold is *not* spent on "Try + again"; `error.tsx` hoists a `` because it beats the segment's static + metadata; all three are `page--narrow`. +- `error.tsx`'s escalation block is the one thing a user is asked to copy. Judge + whether it is copyable. +- Report: `docs/design-sweep-2/reports/boundaries-critique.md` / + `boundaries-audit.md` + +### 8. `/admin/access-lists` — **register: product** + +- Source: `src/app/admin/access-lists/page.tsx` +- Shots: `14-access-lists.*` +- **This surface appears in neither the owner's scope list nor the Aug-5 sweep.** + It has never been reviewed. Mark your report as covering an out-of-scope + addition so its findings can be separated cleanly if the owner does not want + them. +- Report: `docs/design-sweep-2/reports/access-lists-critique.md` / + `access-lists-audit.md` + +--- + +## The two surface-less reviewers + +### A. Whole-app + +Gets **every screenshot in `docs/design-sweep-2/shots/` and `PRODUCT.md`, and +nothing else.** No source. No file tree. No per-surface block. + +Questions, all of which have no location: + +- Does this read as authored or as generated? +- What is its composite character, in one paragraph? +- Which surfaces disagree with the others about what this product is? +- Could someone guess the palette and theme from the product category alone? +- Where does the eye go on each screen, and is that where it should go? + +**Exempt from `file:line` entirely.** Do not go looking for source you do not +have. Prose about the composite is the deliverable; a paper cut is not. + +Note the anti-references it is being measured against: Alliance Auth / Django +admin, neon sci-fi HUD, generic dark SaaS, cartoon-forward UI. + +Report: `docs/design-sweep-2/reports/whole-app.md` + +### B. Record contradiction + +Gets `DESIGN.md`, `PRODUCT.md`, and the code implementing them. One question: +**where does the code contradict its own written rule?** + +This is nearly mechanical and it is most productive in the codebases that look +best maintained. Two from the last sweep of this project: a status-token rule +saying "colour only when the state is actionable" while the CSS painted every +healthy state full-chroma green, and a predicate whose docblock argued the +opposite of what it did. + +Check specifically, and quote the rule text next to the code: + +- Every claim in DESIGN.md's tables that names a measured number (contrast + ratios, hit-target pixel sizes, type-scale steps, tracking tokens). +- The label register: is every selector in the `--- Label register ---` list + actually inheriting 600, and does anything outside it duplicate the style? +- "Saturated colour occupies well under 10% of any screen" and "one primary + action per view, plus the mark" — check per surface against the shots. +- The two-card exception, the two hit-target grades, the two type one-offs + (`0.625rem`, `0.5625rem`) that are supposed to be used exactly once each. +- PRODUCT.md's five design principles against what actually ships. + +Report: `docs/design-sweep-2/reports/record-contradiction.md` diff --git a/docs/design-sweep-2/SYNTHESIS.md b/docs/design-sweep-2/SYNTHESIS.md new file mode 100644 index 00000000..25d30877 --- /dev/null +++ b/docs/design-sweep-2/SYNTHESIS.md @@ -0,0 +1,700 @@ +# Design sweep 2 — synthesis and ranked backlog + +Sweep of 2026-08-10. Eight in-scope surfaces plus one reviewed unrequested. +Eighteen reports in `reports/`, 63 screenshots in `shots/` (30 from the sweep +proper, 33 from the two re-shoots that closed the capture gaps). + +**Nothing in `src/` has been modified.** This document is the Phase 3 output and +the Phase 4 gate: the owner chooses what gets worked before any edit. + +Ranked by **what the problem costs a user**, not by how many surfaces it recurs +on. Recurrence is recorded per item as a separate leverage note. + +--- + +## Rank + +### 1 — `/payouts/[id]` does not fit a phone, at the document level + +**Severity:** Critical (both reviewers, independently, with identical numbers) +**Cost:** The page where ISK is finalized and paid is the only surface in the app +whose narrow `fullPage` capture is wider than the viewport: **771px (draft)** and +**522px (finalized)** against 390px. A fleet member checking whether they were +paid, on a phone, scrolls the whole document sideways. It also fails SC 1.4.4 at +200% zoom on any display narrower than ~1600 physical px. + +Two independent causes, both one-line: + +- **(a)** `.btn-row--tight { white-space: nowrap }` — `globals.css:2974` — inherits + into Finalize's visible `ConfirmCost` sentence at `payouts/[id]/page.tsx:508` + → `lifecycle-submit.tsx:139-156`. A 137-character sentence renders as one + unbreakable **754px** line (measured x=120→x=874 at 1440). + Two fixes on the table: **remove `.btn-row--tight` from `page.tsx:508` + entirely** (the audit's — the row holds one button, Finalize and Unlock being + mutually exclusive by construction), or scope the `white-space` to direct + button children (the critique's). The first is cleaner and strictly local. +- **(b)** `.pool-items` is `display: grid` with an implicit `auto` track and no + `minmax(0, 1fr)` — `globals.css:4859` holding `page.tsx:682`. The item table + escapes its `Scroller`, which also kills the edge fades **and withdraws the + keyboard tab stop** at `scroller.tsx:95` (SC 2.1.1). + Fix: `.pool-items { grid-template-columns: minmax(0, 1fr) }`. + +**Leverage:** `globals.css:4583` already names `.pool-items` by name as one of +exactly two at-risk sites, and fixes only the other one (`.drawer__crew` got +`min-width: 0` at `:4577`). + +--- + +### 2 — Four of `/payouts/new`'s five error messages cannot be reached through the form + +**Severity:** Serious +**Where:** `new-operation-form.tsx:96` (`required`), `:111-112` (`max={today}` + +`required`), `:119` (`type="url"`), against `src/app/payouts/errors.ts:34-44` +**Cost:** Native constraint validation runs before the `submit` event, and React's +`<form action>` runs from that event — so `name_required`, `date_invalid`, +`url_invalid` and `url_scheme` are server backstops for scripted requests only. +An operator pasting `zkillboard.com/related/…` gets the browser's "Please enter a +URL.", which auto-dismisses and cannot be recalled; the app's own sentence +("Battle report links must start with http:// or https://") never renders. All +four end "Everything else you typed is still here." — a promise about a screen +nobody sees. SC 3.3.3. +**Fix:** `noValidate` on the `<form>`. Worth thirty seconds of manual confirmation +first; the two remedies differ. +**Leverage:** no e2e asserts any of the four, and cannot — `e2e/payouts.spec.ts` +and `e2e/submit-guard.spec.ts` reach `/payouts/new` six times and never a +rejection notice. Worth one grep for the same shape elsewhere when this is worked. + +--- + +### 3 — `/admin/audit`: ~20 seconds to a confident wrong answer + +**Severity:** Serious +**Where:** `admin/audit/page.tsx:425-434` +**Cost:** Actor is the first field and the first tab stop, and every tier change +is written with `system` or an admin as actor — so an admin looking up "what +happened to this member" filters by the member, gets nothing, and concludes +nothing happened. The actor/target nudge that would prevent this lives *inside* +the `rows.length === 0` branch, so it fires only on the harmless failure. +**Fix:** surface the nudge above the results, not inside the empty branch. + +--- + +### 4 — `/payouts`' narrow layout defeats its own pinned column + +**Severity:** Critical (critique's grade) +**Where:** budget comment `globals.css:1289-1366`; assertion +`e2e/payouts.spec.ts:3813-3818` +**Cost:** The pin measures **69px of a 286px region (24%)** at 390px, so the +column that exists to keep a payout identifiable while scrolling doesn't. +**Fix:** the answer is already in-repo — `.log--crew` (`globals.css:5450-5476`) +hits the identical wall, cites `.log--payouts`'s comment as precedent, and +reflows instead: `thead` hidden below 30rem, each `<td>` a labelled block. +Interim: `min-width: 11ch` (~88px of 286px = 31%). +**Sequencing hazard:** promoting the Name cell to `<th scope="row">` inherits +`.log th`'s `white-space: nowrap` (`globals.css:1142-1151`), which defeats +`overflow-wrap: anywhere` and blows the 60% ceiling. `.log--payouts tbody th` +must be scoped back to the `td` treatment **first**. +**Note:** `e2e/payouts.spec.ts:3813` asserts `cellWidth/regionWidth < 0.6` — a +ceiling with no floor, so it passes at 24%. + +--- + +### 5 — `/admin/sync`'s housekeeping summary has never fitted at 320px, in any state + +**Severity:** Serious +**Where:** `globals.css:4389` — `.strip__group-disc > summary` is absent from the +`@media (max-width: 46rem)` block that gives `.strip__disc > summary` +`flex-wrap: wrap` at `:4394` +**Cost:** `display: flex` (nowrap) holding one `<Status>` with `.st`'s +`white-space: nowrap` (`globals.css:2461`). Even the *healthy* string — `2 jobs · +nothing needs attention`, 31 characters, ~242px — is over the same 233px budget. +The faulted strings run 43 unbreakable characters and escape the panel. +**Fix:** add `.strip__group-disc > summary` to that media query. The base +stylesheet pairs the two selectors in five places; this is the one place the +pairing was dropped. **Do not truncate — the member names are the payload.** + +--- + +### 6 — A retry that *succeeds* drops focus to `<body>` and announces nothing + +**Severity:** Serious +**Where:** `error.tsx:295-306` +**Cost:** No page in `src/app/` renders a `FocusHeading`, so on a successful +`reset()` focus falls to `<body>` with no `<title>` change and no live region. A +keyboard or screen-reader user who recovers from an error is silently returned to +the top of the tab order with no confirmation anything happened. The **failed** +path is documented in detail at `error.tsx:280-294` and pinned by +`e2e/error-boundary.spec.ts:96-140`; the successful one is nowhere. SC 4.1.3 / +2.4.3. +**Fix:** move focus to `#main` before `reset()`, or add a `role="status"` line. + +--- + +### 7 — `.notice` reorders inline content, app-wide + +**Severity:** Serious (two reports, same mechanism, same coordinates) +**Where:** `globals.css:3429-3431` (`.notice` is `display: flex`), reaching +`error.tsx:197-214` +**Cost:** The digest branch's three children become flex items 2/3/4, so the +rendered reading order is *"…what you were **4292868890** . doing and quote +reference"*. Measured in Chromium: the `<code>` box lands at x=208 / x=278 / +x=500 while the notice starts at x=16 / x=16 / x=120 (320/390/1440). SC 1.3.2. +**Fix:** have `Notice` wrap `{children}` in a `<span>` at **`ui.tsx:321`** — this +fixes every `Notice` in the app carrying inline markup, not just this caller. +**Leverage:** `admin/accounts/page.tsx:298-300` and all four multi-part notices on +`/payouts/[id]` already work around it by hand. +**Note:** `e2e/error-boundary.spec.ts:239` was written to fix exactly this. The +DOM merge landed; the render undid it. The spec asserts `toContainText` and +`toBeVisible` on `code.mono`, both of which pass while the digest sits anywhere +on the page. + +--- + +### 8 — `/admin/access-lists`: "Check now" confirms success where it provably cannot act + +**Severity:** Serious — **out of scope, cleanly liftable** +**Where:** `admin/access-lists/page.tsx:153-160` against `src/jobs/access-lists.ts:59-62` +**Cost:** The form renders unconditionally, outside the `showsObservations(state)` +gate. In `grant-needed` — the state every new deployment opens on — the job +returns having read nothing, and the admin is told "Check queued at 09:41:22.418 +UTC. Reload this page once the worker has run." They reload to a byte-identical +page and cannot tell whether the worker is dead, the queue is stuck, or the +feature is unconfigured. +**Fix:** wrap the form in the same `showsObservations(state)` condition. + +--- + +### 9 — `/payouts/[id]` at 390px hides State and every row control + +**Severity:** Serious +**Where:** roster `page.tsx:952-998`; 253px of a 339px region spent on +Shares+Amount +**Cost:** A member cannot see paid/unpaid — the one thing the roster exists to +say — and rows are visibly ragged for a cause that is off-screen. +**Fix:** move STATE into the NAME cell below 40rem, per `globals.css:1315-1322`. + +--- + +### 10 — `/admin/audit` announces nothing when the page changes + +**Severity:** Serious +**Where:** `admin/audit/page.tsx` +**Cost:** Paging a 62-row table is completely silent to a screen reader. +**Fix:** `aria-live="polite"` around the count text. **Not** a focus move. +`e2e/admin.spec.ts:2469` is the existing shape for the regression test. + +Related, same surface: `<form method="get">` + `<Submit>` — `useFormStatus()` +never reports pending for a native GET, **and `useSubmitGuard`'s latch is set and +never cleared, surviving a bfcache restore**. + +--- + +### 11 — `/admin/access-lists`: `<main>` is not focusable, so the skip link does nothing + +**Severity:** Serious — **out of scope, one attribute** +**Where:** `admin/access-lists/page.tsx:133` +**Cost:** Eleven elements in `src/app` carry `id="main"`. Ten carry +`tabIndex={-1}`. This one does not, so the fragment target scrolls but does not +move focus and the next Tab resumes at the first nav link. Worst possible route +to lose it on: the link sits last in a nav of seven plus "Sign out", and three of +its four server actions redirect back to this same URL +(`actions.ts:34, 42, 56`). SC 2.4.1. +**Fix:** add `tabIndex={-1}`. + +--- + +### 12 — Pattern 2 (total enumeration), five instances + +**Severity:** Serious in aggregate +**Cost:** A reader scans a column looking for what differs and finds nothing +differing, so the column teaches them to stop reading it. + +| Surface | Where | The recital | +|---|---|---| +| `/payouts/[id]` | `page.tsx:993-996` → `payment-history.tsx:38-45` | six rows, each `2026-08-10 11:57:07 UTC paid 288,600,000.00 ISK by Fleet Commander` | +| `/payouts/[id]` | roster `page.tsx:952-998` | 48 cells, 45 saying the same three things — "`crewNorms` verbatim, on a wider table" | +| `/payouts` | `page.tsx` | ` ISK` ×34, `paid` ×34; and no set-level fact anywhere | +| `/admin/audit` | | the date stated 62 times; narrow is worse — `elapsedShort`'s hour granularity gives `41h ago` three times running, **in the pinned column** | +| `/admin/sync` | `page.tsx:334-341` | `19h ago` ×9, `OVERDUE` ×7, and the one fact appears nowhere | + +**Fix shape, uniform:** `crewNorms` — measure deviation against the set, state the +shared fact once in the head's aside, keep a `visually-hidden` per-row +restoration so both channels stay in parity. + +**One inversion, one refusal, worth keeping distinct:** +- `/admin/access-lists` is pattern 2 **inverted** — both siblings put two facts in + the head aside (`9 JOBS · CHECKED 11:57:14 UTC`, `13 MEMBERS · as of 11:57 UTC`) + and this page spends its aside on the label "add a list", on the surface where + "how current" matters most (every number is a worker read that may be hours old). +- `/login` has pattern 2 and `login-audit` **recommends leaving it** — the + `esi-`/`.v1` scope affixes, because "a consent screen is the one place a + truncated identifier is worse than a long one." + +**Parity already broken twice:** `/admin/sync`'s `Cadence (UTC)` loses its visible +channel below 46rem (`globals.css:4390-4392`) while `splitCadenceUtc` is not +media-queried; `/admin/audit` restored `(UTC)` per row and then lost sighted-channel +ordering to `elapsedShort`. + +--- + +### 13 — Pattern 1 (unshaped field), four instances + +**Severity:** Moderate–Serious +**Cost:** the page runs long instead of wide; the primary action lands below the +fold on surfaces with half their column empty. + +| Surface | Measured | Where | +|---|---|---| +| `/payouts/new` | panel 611px of a 1248px column, **637px (51%) empty for the whole 1,477px scroll**; CREATE OPERATION 412px below the fold at 1440, 577px at 390 | `globals.css:3222`, `new-operation-form.tsx:146` (`rows={10}`), `:158` (`rows={8}`) | +| `/login` | `width: min(30rem, 100%)` = 480px, so **960px (67%) empty ground** while the page overflows vertically by 406px | `page.tsx:32-54` | +| `/admin/access-lists` | content ends x≈700/y≈275 of 1440×900 — ~89% of the fold empty | `page.tsx:133` | +| boundaries | 476px of content in a 912px column | `page--narrow` vs `--measure` 68ch | + +**Constraints that must ride with the fix:** +- `/payouts/new`: the `Disclosure` fix **must** pass `defaultOpen={paste !== ""}` + or a rejected submit hides a 200-line paste behind a closed twisty; the fields + **must** stay controlled (`new-operation-form.tsx:34-41` — React DOM resets + uncontrolled fields when the action settles); the loot label **must not** + contain the word "name" (22 payouts specs match on strict mode); **do not delete + the panel to fix the whitespace** — it earns its card exception. +- `/login`: do not shrink the seal, do not re-tune `--t-display`, do not raise the + hero opacity (0.8% effective — the problem is placement), do not widen the panel + globally. It is the *disclosure* that is unshaped, not the panel, and `.launch` + carries `overflow: hidden` so it must keep growing. +- Boundaries: **the two 404s do not have this — do not let a fix pass inflate + them.** Cap `.page__head` at `--measure` instead of widening content. +- `/admin/access-lists`: `.page--wide` is used exactly once, here, and its own + comment says it "changes nothing about the box… It exists so the page states + which column it chose." Moving to `page--narrow` leaves `.page--wide` with no + callers. + +--- + +### 14 — Pattern 3 (uniform control weight), three real instances + +**Severity:** Moderate +- `/payouts/[id]`: **70 pressable things, 62 at the identical 28px quiet grade, + one gold.** Fix raises two `edit` chips to plain `.btn` and demotes per-row + `exclude`. **Neither adds a second gold.** +- `error.tsx:265-313`: both controls plain `.btn`, 8px apart. A member who has + just read "check whether it took effect before you send it again" is offered, + as the first and visually equal choice, the control that sends it again. + **Fix: differentiate downward** — `.btn--quiet` on Try again, or reverse the + order. Gold on "Try again" is settled and correct and is not being reopened. +- `/admin/sync`: `Refresh` is an `<a href>` with no pending state, drawn + identically to `Recheck invalid affiliations`, which enqueues a job. + +**Anti-fix, recorded:** `/payouts` has 52 amber tokens and four `DRAFT` rows that +are the only actionable state and the dimmest. **Do not "fix" this by making +drafts louder.** + +--- + +### 15 — Eight confirm controls on `/payouts/[id]` are silent when a press is refused + +**Severity:** Moderate +**Where:** `page.tsx:1063-1072`, `pay-flow.tsx:326-334`, `:353-359`, `:397-403`, +`pool-flow.tsx:149-155`, `lifecycle-submit.tsx:147-152`, `page.tsx:1171-1177`, +`page.tsx:1260-1267` +**Cost:** No `pendingLabel` on any of them, and **there is no `ConfirmGroup` +anywhere on this route**, so `useConfirmReport()` is null and a guard-refused +press produces nothing at all. +**`submit-guard.ts`'s justifying premise is false here:** seven actions in +`src/app/payouts/actions.ts:770-810`, `:836-843`, `:948+` end in +`revalidateOperation` and do not redirect. + +--- + +### 16 — `/login`'s six scopes are one weight, and they are three facts + +**Severity:** Serious +**Where:** `login/page.tsx:32-54` (`describeScope`) +**Cost:** Six items at identical weight, and the only one that *writes* is row +two of six. They collapse to three facts. +**Fix:** group under three sub-heads, `crewNorms`-style. +**Related on the same surface:** `emblem.webp` is 512×512 / **79.8 KB** drawn at +180px with no `fetchPriority`, while the **2,248-byte** SSO button carries the +page's one explicit hint — not "no priority", *negative* priority. And the error +notice has **0px** above it; the fix must be scoped +`.launch__panel > .notice { margin-top: var(--s-5) }`, **not** a change to +`.notice`, which is load-bearing on six other pages. + +--- + +### 17 — `.status-line__label` renders 400 where the register says 600 + +**Severity:** Serious (record-contradiction's grade) +**Where:** `globals.css:2404-2410`; markup `account/page.tsx:994`, `:1018`, `:1027` +**Cost:** It is register-shaped — mono, `--t-label`, uppercase, `--ink-faint` — +and is the only one of the fourteen register selectors that never declares +`font-weight`. On the *reference* surface, `12-account.wide.png` shows STANDINGS +and MAP at 600 in the rail (`.facts dt`) and 400 in the manifest, same words, +same size, same colour, ~430px apart. This is the exact collision the register +block was written to close. +**Fix:** add it to the register list at `globals.css:373-391` and delete four +duplicated properties. + +--- + +### 18 — Two disclosure controls change their accessible name every 30 seconds + +**Severity:** Moderate +**Where:** `admin/access-lists/page.tsx:217-231` (out of scope) **and +`admin/sync/page.tsx:435-449`** via `globals.css:4142` (in scope) +**Cost:** The `<summary>`'s name is computed from contents that include +`RelativeTime`, which re-renders on a shared 30s ticker. A screen-reader user who +opens a drawer and navigates back finds the control renamed; a voice user loses +the target mid-utterance. SC 4.1.2 / 3.2.4. +**Fix:** pass `ariaLabel={label}` — `Disclosure` already supports it and documents +the label-in-name constraint at `disclosure.tsx:40-43`. Both candidate strings +start with the visible text, so 2.5.3 holds. + +--- + +### 19 — Four page-level disclosures take the 28px in-row grade + +**Severity:** Moderate +**Where:** `.disc > summary { min-height: 1.75rem }` — `globals.css:3799-3807`; +call sites `payouts/[id]/page.tsx:135`, `:833`, `:1148`, `appraise-form.tsx:183` +**Cost:** The settled rule is explicit: *"A disclosure drawer is not in-row for +this purpose and takes 36px."* +**Fix:** `2.25rem` at `globals.css:3806`. Same class of defect, out of scope: +`StopWatching` inside the access-lists drawer (`page.tsx:256`), where +`globals.css:2827-2836` already documents the exact two-class buy-back for +`InlineEdit` and it was not extended. + +--- + +### 20 — Eight docblocks make claims the code contradicts + +**Severity:** Minor each, but they are how the *next* regression gets written. + +| Claim | Reality | +|---|---| +| `focus-heading.tsx:56-58` "No focus ring appears… programmatic focus does not match `:focus-visible`" | **Verified false in Chromium.** `.focus()` on `h1[tabindex="-1"]` returns `matches(":focus-visible") === true` and paints. The belief has already propagated to `confirm-notice.tsx:102`. Measured 120→1035px on a ~260px word. **Fix the comment first, then decide the ring — `width: fit-content` — do not suppress it.** | +| `confirm-submit.tsx:248-249` "No caller now keeps the same class in both states" | Three callers on `/payouts/[id]` do | +| `submit-guard.ts` "a redirecting form's navigation is the feedback" | Seven actions on that route revalidate without redirecting | +| `globals.css:2986-2991` `.btn-row--controls` "follows the data it operates on" | Applied above everything at `access-lists/page.tsx:139` | +| `globals.css:1521-1524` `--ink-faint` on `--hull` = 5.58:1 | Measured **5.18:1** (both pass) | +| `globals.css:2706` disabled pair 4.85:1; DESIGN.md:350 4.85, DESIGN.md:63-64 4.63 | Measured **4.61:1** | +| `globals.css:1031` 4.11/3.72/3.24 | Measured 4.23/3.90/3.47 | +| `nav-items.ts` `navFromPath` "serves three boundaries" | Only `error.tsx` calls it | +| `payouts/new/page.tsx:46-51` + `globals.css:3205+` credit the panel's ground | `--hull` on `--void` is **1.08:1**; the 1px `#787370` border does all the work | + +--- + +### 21 — Record-only corrections (no code change) + +- DESIGN.md:130-131 says gold is rationed to **two** uses; DESIGN.md:103 and + `globals.css:2925` say **four**. The app is consistent with four — **fix the + record, not the code.** +- `--signal-ok` has zero consumers and `.notice--ok` (DESIGN.md:105, + `globals.css:2505-2507`) does not exist. `--dur-move` also dead. +- DESIGN.md:132 "No decorative gradients at all" vs `.scroller-fade--start` + (`globals.css:1108-1124`), whose own docblock says "Decorative and inert". +- DESIGN.md:236-240's nav rule omits `Access lists` (5 listed, `navFor` returns 6). +- Raw `0.12em` should be `--track-label` at `globals.css:2654` and `:3700`. +- PRODUCT.md principle 5: `.closing--compact` at `min(260px, 100%)` is a 4.3× + downscale of a 1120px asset while `account/page.tsx:1382-1384` says otherwise. + +--- + +## Cross-cutting notes for whoever works this + +**A recurring shape: measure a defect, fix one instance, name the sibling, skip +it.** Six instances, every one with the fix already in-repo — +`globals.css:4583` (names `.pool-items`, fixes `.drawer__crew`); +`globals.css:1817-1819` (declines to share `/admin/accounts`' cap with +`/admin/audit` on a premise that measures identical, 426px both); +`globals.css:2827-2836` (`InlineEdit`'s 36px buy-back, not extended to the +access-lists drawer); `sync-audit` F1 (five paired selectors, pairing dropped in +the one media query that decides reflow); `login-audit` F8 +(`overflow-wrap: anywhere` one element away); and `removeWatch` +(`services/access-lists.ts:133-153`) which reads the list's name for the audit row +and withholds it from the operator, one return value away. + +**The e2e suite's assertions systematically cannot fail in the bug's direction.** +`payouts.spec.ts:3813` is a ceiling with no floor. `error-boundary.spec.ts:239` +asserts `toContainText`/`toBeVisible` on `code.mono`, both of which pass while the +digest sits anywhere on the page. Four `/payouts/new` error codes have no test +because none can exist. Plus the two already on record: `toBeVisible()` is vacuous +against `.visually-hidden`, and bare row-count assertions pass whether or not a +filter worked. **This belongs on the backlog as its own item.** + +**`white-space: nowrap` inherited into prose** is the mechanism behind two of the +three worst reflow findings — `.btn-row--tight` on `/payouts/[id]` and `.st` on +`/admin/sync`. + +**The narrow viewport is where the sweep's worst findings live.** Counter-examples +that measured clean: `/login` (`scrollWidth` 305 against 320), all three +boundaries (`scrollWidth` 320 and 200% zoom at 640px CSS), `/payouts/new` (320px +and 200% both hold). + +**`/payouts/[id]`'s roster borrows `.log--dense` from `/admin/accounts` and +inherits rules whose stated preconditions are false here** — `vertical-align: +middle` ("cells are never multi-line"), `scroll-margin-left: 10rem` ("scoped per +table"; measured pin is 169px). That class is now shared by a table it was never +measured against. + +**Two closed Aug-5 items legitimately re-opened, each naming why its premise +expired:** `SYNTHESIS.md:338-350`'s "they arrive through a navigation" died when +`appraise-form.tsx:111-115` moved to a same-route `router.replace` (six +`&&`-mounted `Notice`s on `/payouts/[id]`); and `/admin/audit`'s UUID recital — +the visible one was fixed, the AT one **doubled**, because `RawId`'s hidden span +*joins* `title` rather than replacing it. + +**Colour and the freeze.** The instruction was "do not change colour tokens" and +it is respected throughout. Three items are argued to sit *outside* it and the +owner should rule: +- `themeColor: "#080f1f"` (`src/app/layout.tsx:52`, found independently by two + reviewers) — a stale hard-coded literal from the retired navy palette, not a + token; paints Android Chrome / PWA chrome navy above a neutral-black page. +- `.st--ok` on `/payouts/[id]`'s roster — `paid` and `unpaid` render at the *same* + colour. Proposal is a token **assignment** change, not a value change. +- `.escalation`'s border is `--rule` `#373533` on `#0a0a0a` = **1.62:1**; swapping + to `--rule-strong` (4.23:1) is a token **use** change. + +**There is no missed AA anywhere in the palette.** `#787370` is `--rule-strong`, a +non-text boundary measured 4.24 / 4.23 / 3.94 / 3.47 / 4.23 / 3.90 / 5.64 by seven +reviewers against a 3:1 floor (SC 1.4.11). The text token `--ink-faint` `#90877e` +is 4.61:1 worst case. `sync-audit`'s verdict stands for the whole sweep: *"Nothing +on this surface is close to a floor. Do not touch the tokens."* + +**One whole-app finding the freeze forecloses, surfaced rather than dropped:** the +`#0a0a0a` ground is PRODUCT.md's anti-reference #3 verbatim, and `whole-app` ranks +it third by cost. Its thesis: *"The words are authored. The layout is generated."* +Composite: *"A well-written flight manual laid out by a build script."* Five of +fifteen surfaces are h1 + sentence + button, top-left. + +**A whole-app claim I disproved and dropped:** an app-wide kerning bug eating the +space after k-final words. No `word-spacing` declaration exists anywhere, and the +only negative letter-spacing is on headings (`-0.02em`, `-0.01em`, `-0.03em`). It +is a PNG text-extraction artifact. + +--- + +## What the sweep could not see + +Five capture gaps, **all now closed** — see the two sections below. +`capture.spec.ts.txt` and `capture-gaps-124.spec.ts.txt` are preserved so any of +it can be re-shot. + +1. ~~**A weighted payout split.** The fixture writes `amount` directly + (`capture.spec.ts.txt:210-222`) and never runs `recalculate`, so every share is + flat. `core/payout-split.ts:73` is correct — this is a fixture artifact, not a + bug — but item 12's roster fix needs re-checking against a real split.~~ + **Closed** — shot `29`. +2. ~~**`/admin/audit`'s Details column at real density** — the fixture seeds action + names absent from `PARTS`.~~ **Closed** — shot `30`. +3. ~~**`/login?error=…`** in its three tones.~~ **Closed** — shots `16`–`20`. +4. ~~**`/payouts/new`** with a populated `Notice`, and at 320px.~~ **Closed** — + shot `31`. +5. ~~**Six of `/admin/access-lists`' seven states.**~~ **Closed** — shots `21`–`28`. + +Also: the Next dev overlay reported **real console/hydration errors** — `1 Issue` +on shots `08` and `11`, `2 Issues` on `13`. Worth one click if a fix pass has the +dev server up. + +And: **the `/account` reference surface is not proven at narrow**, and item 17 +shows it breaks the one typographic rule DESIGN.md states in absolute terms. + +--- + +## Re-shoot: gaps 3 and 5 (shots `16`–`28`) + +One temporary spec, twelve cases, 26 PNGs at 1440x900 and 390x844. `git status` +before the boot was empty; after it, exactly the PNGs plus the spec — no +`tsconfig.json` or `AGENTS.md` rewrite this time. Spec deleted. + +### Gap 3 closes clean — no finding + +`/login?error=` renders as `loginErrorTone` argues it should, and the pixels are +the evidence the docblock could not be. + +- `oauth_failed`, `oauth_expired` → red-bordered notice, `!` glyph, between the + motto and the sign-in button. +- `oauth_denied`, `session_expired` → neutral box, `·` glyph, same slot. +- `?error=not-a-real-code` → **no box at all**, not an empty bordered region. + +That is PRODUCT.md principle 4 honoured: a cancelled sign-in and an expired +session are not painted as faults the user must fix. **Nothing to work here.** + +### Gap 5 produces four findings, three of them the brief's own patterns + +**A. Every fault state is an unshaped field** (pattern 1). Shots `22`–`26`. All +six non-`normal` states share one silhouette: lede, one gold button, a +full-width `WATCHED LISTS` rule (~1200px), and beneath it a ~545px notice, with +the lower ~500px of a 900px viewport void. The rule is the widest object on the +page and it labels the emptiest. + +**B. The empty watched-lists region renders during holder faults.** +`showsObservations` (`view.ts:160-162`) excludes only `grant-needed` and +`designate-needed`, so `scope-dropped`, both `holder-no-token` variants, +`holder-needs-reauth` and `catalog-empty` all print a section heading plus *"No +lists are being watched yet."* directly under a lede that already said why +nothing is happening. It restates the fault in weaker words and takes the fold +to do it. Cost: the admin's eye goes to the largest structure on the screen and +learns nothing. Fix: gate the region on the same predicate as the remedy — +if the monitor cannot read, the observation region has nothing to say. + +**C. Total enumeration in the missing-access table** (pattern 2), shot `28`. +"Null Harvest Inc" appears identically on 8 of 10 rows; the other two are `—`. +Four broad-grant lines all end in the identical clause *"plus an unknown number +of others"*. `crewNorms` is the fixing pattern: state the shared fact once +(*"all but two are in Null Harvest Inc"*), show only the deviation per row. +At 390px this table is two columns of which one is a constant. + +**D. `STOP WATCHING` is the quietest thing on the row** (pattern 3). It renders +at the same weight and colour as the `BROAD GRANTS (1)` rule labels above it, +bottom-left of each drawer, identical on every row — a caption, not a control, +and it is the only destructive action on the page. Nothing directs the eye, and +what little direction exists points away from it. + +### And one plain defect, not a design finding + +**The never-read row renders as `#4104` with no name** (shots `27`, `28`), even +though the catalog holds "Capital umbrella" and `catalog` is already in scope at +`page.tsx:67`. `getWatchedListViews` (`access-lists.ts:215`) takes `name` from +the snapshot alone, and a list watched but never read has no snapshot row. + +The service layer already contains the fix: `watchedListName` +(`access-lists.ts:88-103`) tries the catalog first and the snapshot second, with +the docblock *"a missing name must never cost the row"* — and it is wired only +to the two audit writes (`:127`, `:142`), never to the display path. The rule is +written, implemented, and not applied where a human reads it. + +--- + +## Sequencing constraints + +Violating any of these produces a red suite or an undone fix. + +- `/payouts`: scope `.log--payouts tbody th` off `.log th`'s nowrap **before** + promoting the Name cell. +- `/admin/sync`: the name track must be **capped** (`minmax(7rem, 13rem)`), never + reverted to `1fr`. +- `/payouts/[id]`: nothing may add a second gold; `AppraiseForm` + (`page.tsx:782-790`) must not be split back into two call sites — that + reintroduces a silent data-loss notice; item 12's roster fix needs the re-shoot + first. +- `/payouts/new`: `defaultOpen={paste !== ""}`; fields stay controlled; the loot + label must not contain "name". +- Boundaries: do not inflate the two 404s; decide the focus ring rather than + suppressing it; do not add `"use client"` to `ui.tsx` (it would make the + client-graph problem worse — measure first); price `.escalation`'s ground and + border together. +- `/admin/access-lists`: `monitorRemedy`'s two hrefs (`view.ts:135-151`) must not + be unified — the bare `/auth/eve/link` is what *drops* the ACL scope, so + offering it as the remedy sends the admin round the loop that caused the fault. + `rowHasDetail`'s asymmetry (`view.ts:232-236`) must not be made uniform. +- `/admin/audit`: do not widen the shared `.scroller--tall`. Do not let a fix pass + collapse `summarize.ts`'s `Part.keys`, or shorten `summarizeDetails`' line. + +--- + +## What is good and must survive a fix pass + +Recorded because fix passes delete things. + +- **`/payouts/[id]`'s focus-restoration architecture** — "the best work in this + codebase and none of the fixes above should touch it." `useOptimistic` rejected + for a money ledger, on the record. No arm timer, refused on 2.2.1 grounds. +- **`error.tsx`'s copy, all of it** — "That's a fault on this end, not something + you did." And `Notice live={false}` on the error boundary — easy to "fix" + backwards. +- **`/login`'s scope copy** — the best writing in the app. +- **`/admin/access-lists`' `monitorState` cascade** and `"plus an unknown number + of others"` — the difference between a true statement and a false one. +- **`/admin/audit`'s `summarizeDetails`** and its `Part` vocabulary; `(UTC)` said + once and restored per row — the model, not a target. +- **`/admin/sync`'s `overdue` exclusion from auto-open** (`view.ts:99-105`) and + `groupTone` refusing green. +- **`/payouts`' three-way empty state** (`page.tsx:380-407`) and `complete` vs + `shown` (`:114-120`). +- **`/payouts/new`'s controlled-input construction** and the operator redirect. +- **`.cell-link`'s explicit 28px floor with a written refusal of 2.5.8's inline + exception.** +- **`navFromPath`** producing three different bars from one rule — a fix pass must + not hand-edit any of those lists. + +--- + +## Scope note + +`/admin/access-lists` was reviewed unrequested. Both its reports carry an explicit +out-of-scope banner and every finding lifts out cleanly. Items **8**, **11**, and +parts of **13**, **18** and **19** are its. Two are strong enough to be worth +taking anyway: the inert skip link (one attribute, WCAG 2.4.1) and the fault +sentence rendered at boilerplate weight on a monitor page. + +--- + +## Re-shoot: gaps 1, 2 and 4 (shots `29`–`31`) + +`capture-gaps-124.spec.ts.txt`, three cases, seven PNGs at 1440x900, 390x844 and +one at 320x800. Deleted after the run; the tree was clean before and after +(`docs/design-sweep-2/shots/` is gitignored at `.gitignore:25`). + +Two fixture corrections did the work. Gap 1's split now goes through the real +`recalculate` from `src/services/payouts.ts:670` with shares varying 0.25 → 3.00 +and one participant excluded, instead of `amount` written by hand. Gap 2's rows +now use only action names declared in `PARTS` (`src/app/admin/audit/summarize.ts:253`), +so the Details column renders through the declarative path that ships rather than +the generic key=value fallback the old fixture forced it into. + +### Gap 1 closes clean on its stated question — no finding + +With a genuinely weighted split — 8 rows at par 1.00, 7 deviating — the roster's +deviation channel is doing what item 12 wanted it to. Enumeration is warranted +here; there is no shared fact being recited. **Nothing to work.** + +Three secondary observations, report-only, none of them defects: +- AMOUNT prints `219,652,173.91 ISK` identically on 8 of 15 rows. It is + mechanically derived from shares, and eliding the money each person is owed + would be a worse page than repeating it. +- `- UNPAID` + `COPY AMOUNT` + `MARK PAID` repeat on all 15 payable rows while the + header already says `0/15 paid` — pattern 3, but every one of those buttons acts + on a different person, so nothing here is a shared fact stated fifteen times. +- The excluded participant sorts first alphabetically, so the one person getting + nothing sits at the top of the roster. + +### Gap 4 produces one finding + +**F1 — the rejection `Notice` pushes the form 88px away, and only when it has +something to say.** Measured at the DOM: `#new-operation-error` bottom to +`.form-stack .rule-head` top is **0px** when the slot is empty and **88px** when +it is populated. The empty case was already handled — `.notice-slot` is +`position: absolute` (`globals.css:3551`) and `globals.css:3284` resets the +following `.rule-head`'s margin — but a populated notice carries class `.notice`, +which that selector does not match, so three spacings stack: `.notice` +`margin-bottom: var(--s-5)` 24px + `.form-stack` `gap: var(--s-4)` 16px + +`.rule-head` `margin-top: var(--s-7)` 48px. + +Cost: on a rejection — the one moment the operator most needs the error tied to +the form it is about — the error floats alone with the form pushed below it. +Worst at 320px, where the vertical space is scarcest. + +Fix: extend the existing reset at `globals.css:3283-3285` to `.form-stack > +.notice + .rule-head`, and add `.form-stack > .notice { margin-bottom: 0 }` so +the grid gap alone spaces it, matching every other sibling pair in the form. + +### Gap 2 produces three findings + +**F2 — the Details column prints raw character IDs.** `account.created`, +`account.main_changed` and `admin.main_changed` render `main → 90000002` / +`main 90000002`. The Target column, three cells to the left, resolves the same +class of referent to `Probe Kid` on the same row. `resolveAuditIdentities` +(`src/services/audit.ts:~300-415`) already builds `nameByCharacterId`, and already +has a `DETAIL_ACCOUNT_KEYS` mechanism for account uuids appearing inside +`details` — there is simply no character equivalent. Fix is a parallel +`DETAIL_CHARACTER_KEYS` plus a resolving part helper in `summarize.ts`. Real +scope: new key map, new part, widened fetch, tests. + +**F3 — `payout.deleted` clips mid-number.** Renders `deleted Tama gatecamp, +occurred 2026-07-30, roster 1…` at 1440px — four declared parts exceed the +column. Not data loss (the `+` disclosure recovers it), but truncating inside a +numeral reads as broken rather than deliberate. + +**F4 — at 390px the Details column is entirely off-screen** behind the `Scroller`, +and action names truncate mid-word (`account.main_chang`, `payout.item_repric`). +The column gap 2 existed to prove out is unreachable on a phone. diff --git a/docs/design-sweep-2/capture-gaps-124.spec.ts.txt b/docs/design-sweep-2/capture-gaps-124.spec.ts.txt new file mode 100644 index 00000000..00641039 --- /dev/null +++ b/docs/design-sweep-2/capture-gaps-124.spec.ts.txt @@ -0,0 +1,268 @@ +/** + * TEMPORARY — design-sweep re-shoot for capture gaps 1, 2 and 4. Preserved as + * `.txt` beside `capture.spec.ts.txt` so a later sweep can re-run it; copy to + * `e2e/` to use. Same contract as that file: not a test, just a renderer with + * assertions that fail loudly when a page did not draw what the shot is + * supposed to show. + */ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { + auditLog, + character, + lootPool, + payoutOperation, + payoutParticipant, +} from "../src/db/schema"; +import { recalculate } from "../src/services/payouts"; +import { resetDb, seedMember, sessionCookieFor, testDb } from "./helpers"; +import { WORKTREE_ROOT } from "./env"; + +const { db } = testDb(); + +const SHOTS = join(WORKTREE_ROOT, "docs", "design-sweep-2", "shots"); +mkdirSync(SHOTS, { recursive: true }); + +const WIDE = { width: 1440, height: 900 }; +const NARROW = { width: 390, height: 844 }; +const TINY = { width: 320, height: 800 }; + +async function shoot(page: Page, name: string) { + await page.setViewportSize(WIDE); + await page.waitForTimeout(250); + await page.screenshot({ path: join(SHOTS, `${name}.wide.png`), fullPage: true }); + await page.setViewportSize(NARROW); + await page.waitForTimeout(250); + await page.screenshot({ path: join(SHOTS, `${name}.narrow.png`), fullPage: true }); + await page.setViewportSize(WIDE); +} + +test.beforeEach(async () => { + await resetDb(db); +}); + +/* ---------------------------------------------------------------- gap 1 -- + A genuinely weighted split. The original fixture wrote `amount` by hand and + never ran `recalculate`, so every share came out flat and the roster's + deviation channel had nothing to show. Here the shares vary, one participant + is excluded, and the real service computes the money. */ + +const CREW: [name: string, shares: string, excluded: boolean][] = [ + ["Fleet Commander", "2", false], + ["Hurricane Main", "1", false], + ["Rifter Pilot", "1", false], + ["Logi Alt", "1.5", false], + ["Second Logi", "1.5", false], + ["Dictor Bunny", "1", false], + ["Falcon Andy", "0.5", false], + ["Sabre Dancer", "1", false], + ["Vexor Navy Guy", "1", false], + ["Probe Kid", "0.25", false], + ["Tackle Frigate", "0.5", false], + ["Booster Alt", "1", false], + ["Hauler Toon", "3", false], + ["Kikimora Kid", "1", false], + ["Cyno Alt", "1", false], + ["Bad Scout", "1", true], +]; + +test("payout detail — weighted split (gap 1)", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + const [op] = await db + .insert(payoutOperation) + .values({ + name: "Wormhole eviction — J155843", + occurredAt: new Date("2026-08-06"), + corpSharePct: "10.00", + createdBy: admin.id, + status: "finalized", + battleReportUrl: "https://br.evetools.org/br/68a1c0f2e4b0a1", + notes: + "Third night of the eviction. Logi and the hauler carry extra shares; " + + "the scout sat this one out.", + }) + .returning(); + + await db.insert(lootPool).values({ + operationId: op.id, + valuationSource: "appraised", + pricingMode: "sell_p05", + regionId: 10000002, + totalValue: "4210000000.00", + appraisedAt: new Date("2026-08-06T22:14:00Z"), + }); + + await db.insert(payoutParticipant).values( + CREW.map(([displayName, shares, excluded], i) => ({ + operationId: op.id, + displayName, + accountId: i === 0 ? admin.id : null, + shares, + excluded, + amount: "0.00", + })), + ); + + // The point of the shot: real money, from the real function. + await recalculate(db, op.id); + + await page.goto(`/payouts/${op.id}`); + await expect( + page.getByRole("heading", { level: 2, name: "Split / Roster" }), + ).toBeVisible(); + await shoot(page, "29-payout-weighted-split"); +}); + +/* ---------------------------------------------------------------- gap 2 -- + `/admin/audit`'s Details column at real density. The original fixture seeded + action names absent from `PARTS` in `summarize.ts`, so every row fell through + to the generic key=value fallback — which is exactly the rendering the column + was rebuilt to avoid, and so the shot proved nothing about the column that + ships. These are declared actions with the payload shapes their writers + actually emit. */ + +test("admin audit — declared actions (gap 2)", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + alts: ["Cyno Alt"], + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + const others = []; + for (const name of ["Rifter Pilot", "Bad Scout", "Logi Alt", "Probe Kid"]) { + others.push(await seedMember(db, { name, tier: "member" })); + } + const chars = await db.select().from(character); + const charId = String(chars[0].id); + + const ROWS: [action: string, details: Record<string, unknown>][] = [ + ["tier.changed", { from: "associate", to: "member", cause: "alliance affiliation" }], + [ + "tier.changed", + { from: "member", to: "alumni", cause: "left alliance", locked: true }, + ], + ["tier.approved", { to: "alumni", locked: false }], + ["status.changed", { from: "active", to: "cryo", self: true }], + ["status.note_changed", { had: true, has: true }], + ["account.created", { mainCharacterId: charId }], + ["account.main_changed", { mainCharacterId: charId }], + ["admin.main_changed", { mainCharacterId: charId }], + ["account.merged", { sourceAccountId: others[1].id, characterId: charId }], + ["character.unlinked", { name: "Cyno Alt", wasMain: false }], + ["character.reclaimed", { fromAccount: others[2].id }], + ["character.owner_mismatch", { detectedBy: "token-health" }], + ["token.invalidated", { reason: "refresh rejected by SSO" }], + ["token.verify_failed", { error: "jwt signature mismatch" }], + [ + "token.needs_reauth", + { missingScopes: ["esi-location.read_location.v1"], detectedBy: "token-health" }, + ], + ["discord.unlinked", { reason: "member left the guild" }], + [ + "discord.role_changed", + { + added: ["1284410981234567890"], + removed: [], + tier: "member", + cause: "tier.changed", + }, + ], + [ + "discord.role_sync_failed", + { + error: "Missing Permissions", + op: "add", + roleId: "1284410981234567890", + tier: "member", + }, + ], + ["discord.role_strip_failed", { error: "Unknown Member", roleId: "1284410981234567890" }], + ["access_list.watch_added", { name: "Alliance Standing", accessListId: "acl-7f21" }], + ["access_list.holder_designated", { characterId: charId }], + [ + "payout.item_repriced", + { + name: "Sleeper Data Library", + unitPrice: "12500000.00", + itemId: "i-1", + poolId: "p-1", + }, + ], + [ + "payout.deleted", + { + name: "Tama gatecamp", + occurredAt: "2026-07-30", + participantCount: 14, + totalValue: "820000000.00", + payableCount: 13, + }, + ], + ["payout.name_changed", { name: "Nullsec roam — Pure Blind" }], + ["payout.notes_changed", { had: false, has: true }], + ["wanderer.removed", { role: "viewer" }], + ["tier.unlocked", { tier: "member" }], + ]; + + await db.insert(auditLog).values( + Array.from({ length: 62 }, (_, i) => { + const [action, details] = ROWS[i % ROWS.length]; + return { + at: new Date(Date.UTC(2026, 7, 9, 22, 19, 24) - i * 1_700_000), + actor: i % 5 === 0 ? "system" : others[i % others.length].id, + action, + target: i % 7 === 0 ? charId : others[(i + 2) % others.length].id, + details, + }; + }), + ); + + await page.goto("/admin/audit"); + await expect(page.getByRole("heading", { name: "Audit log", level: 1 })).toBeVisible(); + await shoot(page, "30-audit-declared-actions"); +}); + +/* ---------------------------------------------------------------- gap 4 -- + `/payouts/new` with its rejection `Notice` actually holding text, and at + 320px. The form is `noValidate`, so an empty submit reaches the action and + comes back with `name_required` — the state no earlier shot captured. */ + +test("payouts new — populated notice and 320px (gap 4)", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + await page.goto("/payouts/new"); + await expect(page.getByRole("heading", { name: "New operation" })).toBeVisible(); + + // A realistic half-filled form, so the shot shows the notice sitting above + // content the operator still has rather than above an empty form. + await page + .getByLabel("Loot paste") + .fill("Sleeper Data Library\t42\nMelted Nanoribbons\t55\nNanite Repair Paste\t1200"); + await page.getByLabel("Roster paste").fill("Hurricane Main\nLogi Alt\nDictor Bunny"); + await page.getByRole("button", { name: "Create operation" }).click(); + + const notice = page.locator("#new-operation-error"); + await expect(notice).not.toBeEmpty(); + await shoot(page, "31-payouts-new-notice"); + + await page.setViewportSize(TINY); + await page.waitForTimeout(250); + await page.screenshot({ + path: join(SHOTS, "31-payouts-new-notice.tiny.png"), + fullPage: true, + }); +}); diff --git a/docs/design-sweep-2/capture.spec.ts.txt b/docs/design-sweep-2/capture.spec.ts.txt new file mode 100644 index 00000000..81f04590 --- /dev/null +++ b/docs/design-sweep-2/capture.spec.ts.txt @@ -0,0 +1,443 @@ +/** + * TEMPORARY — design-sweep Phase 0 capture pass. Delete after the shots land. + * + * Not a test: every step exists to render a surface at a realistic volume and + * write a PNG. Assertions are present only to fail loudly when a page did not + * render what the shot is supposed to show, so a blank screenshot cannot be + * mistaken for a finding about emptiness. + */ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { sql } from "drizzle-orm"; +import { + account, + auditLog, + character, + lootItem, + lootPool, + payoutOperation, + payoutParticipant, + payoutPayment, + syncRun, +} from "../src/db/schema"; +import { resetDb, seedMember, sessionCookieFor, testDb } from "./helpers"; +import { WORKTREE_ROOT } from "./env"; + +const { db } = testDb(); + +const SHOTS = join(WORKTREE_ROOT, "docs", "design-sweep-2", "shots"); +mkdirSync(SHOTS, { recursive: true }); + +const WIDE = { width: 1440, height: 900 }; +const NARROW = { width: 390, height: 844 }; + +async function shoot(page: Page, name: string) { + await page.setViewportSize(WIDE); + await page.waitForTimeout(250); + await page.screenshot({ path: join(SHOTS, `${name}.wide.png`), fullPage: true }); + await page.setViewportSize(NARROW); + await page.waitForTimeout(250); + await page.screenshot({ path: join(SHOTS, `${name}.narrow.png`), fullPage: true }); + await page.setViewportSize(WIDE); +} + +test.beforeEach(async () => { + await resetDb(db); +}); + +test("login", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await shoot(page, "01-login"); +}); + +test("not-found (root)", async ({ page }) => { + await page.goto("/no-such-page"); + await expect(page.getByRole("heading", { name: "Nothing at that address" })).toBeVisible(); + await shoot(page, "02-not-found-root"); +}); + +test("payouts list — empty and full", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + await page.goto("/payouts"); + await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible(); + await shoot(page, "03-payouts-empty"); + + const NAMES = [ + "Tama gatecamp", + "Nullsec roam — Pure Blind", + "Structure bash, J155843", + "Wormhole eviction", + "Faction warfare plex farm", + "Incursion HQ, Vanguard", + "Ratting fleet, Delve", + "Lowsec hunt", + ]; + const ops = await db + .insert(payoutOperation) + .values( + Array.from({ length: 34 }, (_, i) => ({ + name: `${NAMES[i % NAMES.length]} ${String(i + 1).padStart(2, "0")}`, + occurredAt: new Date(Date.UTC(2026, 7, 9) - i * 86_400_000), + corpSharePct: i % 3 === 0 ? "10.00" : "0", + status: (i % 4 === 0 ? "draft" : "finalized") as "draft" | "finalized", + createdBy: admin.id, + })), + ) + .returning(); + + // Every operation carries loot and a roster, so the list's Total/Paid/Yours + // columns render at the density a real corp sees rather than as em-dashes. + for (const [i, op] of ops.entries()) { + await db.insert(lootPool).values({ + operationId: op.id, + valuationSource: "flat", + totalValue: String((i + 1) * 412_000_000) + ".00", + notes: "seeded", + }); + const crew = ["Fleet Commander", "Rifter Pilot", "Bad Scout", "Logi Alt", "Cyno Alt"]; + await db.insert(payoutParticipant).values( + crew.map((displayName, j) => ({ + operationId: op.id, + displayName, + accountId: j === 0 ? admin.id : null, + shares: "1", + amount: String(((i + 1) * 412_000_000) / crew.length) + ".00", + })), + ); + } + + await page.goto("/payouts"); + await expect(page.getByRole("link", { name: ops[0].name })).toBeVisible(); + await shoot(page, "04-payouts-full"); +}); + +test("payouts/new", async ({ page, context }) => { + const op = await seedMember(db, { name: "Ops Lead", tier: "member" }); + await context.addCookies([await sessionCookieFor(db, op.id)]); + await page.goto("/payouts/new"); + await expect(page.getByRole("heading", { name: "New operation" })).toBeVisible(); + await shoot(page, "05-payouts-new"); +}); + +const ITEMS = [ + ["Sleeper Data Library", 42, "12500000.00", "triff"], + ["Ancient Coordinates Database", 18, "9200000.00", "triff"], + ["Neural Network Analyzer", 7, "31000000.00", "triff"], + ["Nanite Repair Paste", 1200, "12000.00", "triff"], + ["Republic Fleet Fusion M", 8000, "115.00", "triff"], + ["Zainou 'Gnome' Shield Management SM-703", 1, "48000000.00", "manual"], + ["Imperial Navy Multifrequency L", 400, "6200.00", "triff"], + ["Wrecked Drone Transceiver", 3, "0.00", "unresolved"], + ["Melted Nanoribbons", 55, "2100000.00", "triff"], + ["Sleeper Drone AI Nexus", 2, "0.00", "unresolved"], +] as const; + +const CREW = [ + "Fleet Commander", + "Rifter Pilot", + "Bad Scout", + "Logi Alt", + "Cyno Alt", + "Hurricane Main", + "Dictor Bunny", + "Falcon Andy", + "Sabre Dancer", + "Vexor Navy Guy", + "Probe Kid", + "Tackle Frigate", + "Booster Alt", + "Hauler Toon", + "Second Logi", + "Kikimora Kid", +]; + +async function seedRichOperation( + creatorId: string, + status: "draft" | "finalized", + paidCount: number, +) { + const [op] = await db + .insert(payoutOperation) + .values({ + name: "Wormhole eviction — J155843", + occurredAt: new Date("2026-08-06"), + corpSharePct: "10.00", + createdBy: creatorId, + status, + battleReportUrl: "https://br.evetools.org/br/68a1c0f2e4b0a1", + notes: + "Third night of the eviction. Rolled the static twice; the second hole " + + "landed us on top of their staging. Loot split includes the citadel drop.", + }) + .returning(); + + const [appraised] = await db + .insert(lootPool) + .values({ + operationId: op.id, + valuationSource: "appraised", + pricingMode: "sell_p05", + regionId: 10000002, + totalValue: "4210000000.00", + appraisedAt: new Date("2026-08-06T22:14:00Z"), + }) + .returning(); + await db.insert(lootPool).values({ + operationId: op.id, + valuationSource: "flat", + totalValue: "600000000.00", + notes: "Citadel rigs, priced by hand off contracts", + }); + await db.insert(lootItem).values( + ITEMS.map(([name, qty, unitPrice, priceSource]) => ({ + poolId: appraised.id, + name, + qty, + unitPrice, + totalValue: (Number(unitPrice) * qty).toFixed(2), + priceSource: priceSource as "triff" | "manual" | "unresolved", + })), + ); + + const each = (4_810_000_000 * 0.9) / (CREW.length - 1); + const rows = await db + .insert(payoutParticipant) + .values( + CREW.map((displayName, i) => ({ + operationId: op.id, + displayName, + accountId: i === 0 ? creatorId : null, + shares: i === 5 ? "2" : "1", + excluded: i === CREW.length - 1, + amount: i === CREW.length - 1 ? "0.00" : each.toFixed(2), + sourceCharacters: i === 3 ? ["Logi Alt", "Logi Alt II"] : [], + })), + ) + .returning(); + + for (const row of rows.slice(0, paidCount)) { + await db + .update(payoutParticipant) + .set({ paidAmount: row.amount }) + .where(sql`${payoutParticipant.id} = ${row.id}`); + await db.insert(payoutPayment).values({ + participantId: row.id, + kind: "paid", + amount: row.amount, + actor: creatorId, + }); + } + return op.id; +} + +test("payout detail — draft", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + const id = await seedRichOperation(admin.id, "draft", 0); + await page.goto(`/payouts/${id}`); + await expect(page.getByRole("heading", { level: 2, name: "Split / Roster" })).toBeVisible(); + await shoot(page, "06-payout-detail-draft"); +}); + +test("payout detail — finalized, part paid", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + const id = await seedRichOperation(admin.id, "finalized", 6); + await page.goto(`/payouts/${id}`); + await expect(page.getByRole("heading", { level: 2, name: "Split / Roster" })).toBeVisible(); + await shoot(page, "07-payout-detail-finalized"); +}); + +test("payout detail — not found", async ({ page, context }) => { + const member = await seedMember(db, { name: "Lost Reader", tier: "member" }); + await context.addCookies([await sessionCookieFor(db, member.id)]); + await page.goto("/payouts/11111111-2222-3333-4444-555555555555"); + await expect(page.getByRole("heading", { name: "No such operation" })).toBeVisible(); + await shoot(page, "08-payout-not-found"); +}); + +test("admin audit — empty and full", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + alts: ["Cyno Alt"], + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + await page.goto("/admin/audit"); + await expect(page.getByRole("heading", { name: "Audit log", level: 1 })).toBeVisible(); + await shoot(page, "09-audit-empty"); + + const others = []; + for (const name of ["Rifter Pilot", "Bad Scout", "Logi Alt", "Probe Kid"]) { + others.push(await seedMember(db, { name, tier: "member" })); + } + const [chars] = [await db.select().from(character)]; + const ACTIONS = [ + "tier.changed", + "status.changed", + "character.linked", + "discord.role.added", + "discord.role.removed", + "contact.sync_failed", + "token.invalidated", + "payout.finalized", + "payout.paid", + "acl.member.added", + ]; + await db.insert(auditLog).values( + Array.from({ length: 62 }, (_, i) => { + const actorAcc = i % 5 === 0 ? "system" : others[i % others.length].id; + const targetAcc = others[(i + 2) % others.length].id; + return { + at: new Date(Date.UTC(2026, 7, 9, 22, 19, 24) - i * 1_700_000), + actor: actorAcc, + action: ACTIONS[i % ACTIONS.length], + target: i % 7 === 0 ? String(chars[i % chars.length].id) : targetAcc, + details: + i % 3 === 0 + ? { from: "associate", to: "member", reason: "alliance affiliation" } + : { roleId: "1284410981234567890", characterId: chars[0].id }, + }; + }), + ); + + await page.goto("/admin/audit"); + await expect(page.getByRole("heading", { name: "Audit log", level: 1 })).toBeVisible(); + await shoot(page, "10-audit-full"); +}); + +test("admin sync", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + const JOBS = [ + "membership", + "contacts", + "wanderer", + "discord-roles", + "membership-recheck", + "access-lists", + "token-health", + "purge", + "location", + ]; + const rows = []; + for (const [j, jobType] of JOBS.entries()) { + for (let i = 0; i < 5; i++) { + const started = new Date(Date.UTC(2026, 7, 9, 21, 0, 0) - (i * 3600 + j * 60) * 1000); + const failed = j === 3 && i === 0; + rows.push({ + jobType, + startedAt: started, + finishedAt: new Date(started.getTime() + 4200), + status: (failed ? "failed" : i === 2 && j === 1 ? "partial" : "ok") as + | "ok" + | "partial" + | "failed", + errorSummary: failed ? "discord: 429 rate limited on guild 118… (2 of 41)" : null, + counts: failed + ? { attempted: 41, applied: 39, failed: 2 } + : { scanned: 128 + i, changed: i === 0 ? 0 : i * 3, skipped: 4 }, + }); + } + } + await db.insert(syncRun).values(rows); + + await page.goto("/admin/sync"); + await expect(page.getByRole("heading", { name: "Sync", level: 1 })).toBeVisible(); + await shoot(page, "11-admin-sync"); +}); + +test("account (reference surface)", async ({ page, context }) => { + const member = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + alts: ["Cyno Alt", "Logi Alt", "Probe Kid", "Hauler Toon", "Second Logi"], + }); + await context.addCookies([await sessionCookieFor(db, member.id)]); + await page.goto("/account"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await shoot(page, "12-account"); +}); + +test("error boundary", async ({ page, context }) => { + const member = await seedMember(db, { name: "Link Follower", tier: "member" }); + await context.addCookies([await sessionCookieFor(db, member.id)]); + await db.execute(sql`ALTER TABLE payout_operation RENAME TO payout_operation_probe`); + try { + await page.goto("/payouts"); + await expect(page.getByRole("heading", { name: "Something broke" })).toBeVisible(); + await shoot(page, "13-error-boundary"); + } finally { + await db.execute(sql`ALTER TABLE payout_operation_probe RENAME TO payout_operation`); + } +}); + +test("admin access-lists (uncovered surface)", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + await page.goto("/admin/access-lists"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await shoot(page, "14-access-lists"); +}); + +test("admin accounts (context for the reference)", async ({ page, context }) => { + const admin = await seedMember(db, { + name: "Fleet Commander", + tier: "member", + isAdmin: true, + }); + for (const [i, name] of [ + "Rifter Pilot", + "Bad Scout", + "Logi Alt", + "Probe Kid", + "Hauler Toon", + "Cyno Alt", + "Falcon Andy", + "Sabre Dancer", + "Dictor Bunny", + "Vexor Navy Guy", + "Tackle Frigate", + "Booster Alt", + ].entries()) { + await seedMember(db, { + name, + tier: (["member", "associate", "alumni", "pending"] as const)[i % 4], + status: i % 5 === 0 ? "cryo" : "active", + alts: i % 3 === 0 ? [`${name} II`] : [], + }); + } + await context.addCookies([await sessionCookieFor(db, admin.id)]); + await page.goto("/admin/accounts"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await shoot(page, "15-admin-accounts"); + await expect(db.select().from(account)).toBeTruthy(); +}); diff --git a/docs/design-sweep-2/reports/access-lists-audit.md b/docs/design-sweep-2/reports/access-lists-audit.md new file mode 100644 index 00000000..7c2280f6 --- /dev/null +++ b/docs/design-sweep-2/reports/access-lists-audit.md @@ -0,0 +1,355 @@ +# `/admin/access-lists` — audit + +**Out-of-scope addition.** This surface appears in neither the owner's scope list +for this sweep nor the Aug-5 sweep. Nothing has ever reviewed it. Every finding +below is new, and the standard checks were run as genuinely open questions +rather than as confirmations. Separate this file cleanly if the owner does not +want the surface in the round. + +Register: PRODUCT. Command: `$impeccable audit`. + +## What the screenshots show + +Both shots capture **state 1 of seven** — `grant-needed`, the state a fresh +install opens on. The fixture never granted the ACL scope, so the populated +monitor was never photographed. + +At 1440×900: an `<h1>`, a two-line sentence, and two buttons ("Grant access" in +gold, "Check now" outlined). The rendered content occupies roughly the top-left +580×180 of the fold. Below y≈272 the page is empty `--void` all the way to 900, +and right of x≈700 it is empty all the way to 1440. Nothing else is on screen. + +At 390×844: the same three elements, the nav taking 215px of the 844, content +ending at y≈420, and 420px of empty ground under it. + +The gold ration is respected in both — one primary, and it is the remedy. + +## Audit health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | Drawer control at the wrong hit grade; a disclosure name that mutates on a timer. Contrast, semantics, labelling and focus all clear. | +| 2 | Performance | 4 | One batched name lookup, five parallel reads, shared 30s ticker, no ESI on render. | +| 3 | Responsive | 3 | Wraps correctly at 320px; the column it wraps inside is unearned at every width. | +| 4 | Theming | 3 | Tokens used throughout; one `<select>` escapes the field vocabulary entirely. | +| 5 | Anti-patterns | 4 | No cards, no gradients, no hero metric, no glass. Reads as authored. | +| **Total** | | **17/20** | Good — the weak dimensions are narrow and fixable. | + +--- + +## Findings + +### 1. "Check now" is offered, and confirms success, in the two states where it provably cannot do anything + +- **Severity:** Serious +- **Where:** `src/app/admin/access-lists/page.tsx:153-160`, against + `src/jobs/access-lists.ts:59-62` +- **Cost:** The `checkNowAction` form is rendered unconditionally, outside the + `showsObservations(state)` gate that hides everything else. In + `grant-needed` — the state both screenshots show, and the state every new + deployment opens on — there is no holder, so the job returns + `{ status: "ok", counts: { noHolder: 1 } }` having read nothing. The admin + gets back `"Check queued at 09:41:22.418 UTC. Reload this page once the + worker has run."`, reloads, and sees a byte-identical page. They now cannot + tell whether the worker is dead, the queue is stuck, or the feature is simply + unconfigured, and the one sentence on the page ("nothing can be read") reads + like a fault report rather than a setup step, so it does not settle the + question either. +- **Fix:** Wrap the `checkNowAction` form in the same `showsObservations(state)` + condition the watched-list region already uses. In states 1 and 2 the remedy + button is the only honest control on the page, and removing the second one + also removes the choice a first-time admin has to make between two controls + where only one works. +- **Principle:** Product register — every control has a defined behaviour; + a control that reports success for a no-op is worse than an absent one. + Also PRODUCT.md's state-before-action ordering. + +### 2. `page--wide` is unearned in all seven states, and the first-run state is a 78rem column holding one sentence + +- **Severity:** Serious +- **Where:** `src/app/admin/access-lists/page.tsx:133`; whole surface +- **Cost:** This is the brief's pattern 1 in its purest form, and it is the + state every new install lands on. But it is not only the empty state: the + widest thing this page can ever render is `AccessListDetail`'s two-column + Character/Corporation table, inside a drawer, inside a list that declares no + `max-width` of its own (`globals.css:4625-4631`). At 1248px a watched row's + name sits at the left edge and its drift `Status` and age float somewhere in + the middle of an otherwise empty band, and the page runs long instead of + wide — which is exactly what `/account` was fixed for. The settled rule is + "narrow surfaces cap their contents, never the column"; `.acl-list` caps + nothing. +- **Fix:** Two separate changes. (a) Cap `.acl-list`'s inline size to something + its content earns — `--measure-crew` is the nearest existing token and the + row's three values do not want more — so the status and age read as a + right-hand column rather than as floating debris. (b) Give states 1 and 2 + something to occupy the field, or accept the narrow column for them: this is + a first-run screen and it currently teaches nothing. What the ACL scope is, + what the page will show once it has one, and which character should hold it + are all facts the admin needs and none are on screen. +- **Principle:** Pattern 1 (unshaped field). Product register — "empty states + that teach the interface, not 'nothing here'". + +### 3. "Stop watching" inside an open drawer is the 28px in-row grade; the settled rule gives a drawer control 36px + +- **Severity:** Serious +- **Where:** `src/app/admin/access-lists/page.tsx:304-318`, rendered at + `page.tsx:256` inside the `Disclosure`; `globals.css:2813-2819` +- **Cost:** `StopWatching` renders one component in two structurally different + positions with one class string. On a clean row it sits inline at the end of + the row and 28px is correct. Inside the drawer at `page.tsx:256` it is a + standalone control below the drawer's content — which the sweep's settled + constraints name explicitly: "A disclosure drawer is not in-row for this + purpose and takes 36px." An admin on a touch device, working through drifted + lists, gets an 8px-shorter target on exactly the rows that carry the most + content above the button. It clears WCAG 2.5.8's 24px floor, so this is the + project's own rule being broken, not the standard's. +- **Fix:** The codebase already contains this exact buy-back twice, and the + precedent is the fix: `.inline-edit--standalone .btn--quiet` + (`globals.css:2831-2840`) and `.manifest-panel__controls .btn--quiet` + (`globals.css:1603+`) both restore `min-height: 2.25rem` and `padding: var(--s-2) + var(--s-4)` for a quiet control that is standalone rather than in-row. Add a + third selector on `.acl-list__disc > div > .btn`. Two grades, no third — the + drawer button just needs to be in the right one. +- **Principle:** Settled constraint — two hit-target grades, 36px standalone / + 28px in-row, and no third. + +### 4. The drift summary is a sentence rendered in the 11px uppercase mono state register, and the codebase has already written down why that is wrong + +- **Severity:** Moderate +- **Where:** `src/app/admin/access-lists/view.ts:205-222`; + `src/app/globals.css:4693-4702` +- **Cost:** `rowSummary` produces strings like `1 missing access · 1 has access, + not a member` and hands them to `<Status>`, which is `--t-label` (11px), mono, + weight 600, uppercase, with `--track-value`. Uppercase removes word-shape + cues, and this is the smallest step on the ramp — so the one line on each row + that says what is actually wrong is the hardest line on the page to read, and + a row's drift is what an admin came here to scan. The CSS author already + noticed: the comment at `globals.css:4693-4698` says in as many words "this + page's drift summary is a sentence, not a token", then fixes only the + wrapping and leaves the register. +- **Fix:** `.st--lead`'s rule (`globals.css:2572+`) already states the remedy and + the reasoning, for the identical problem on `/account`: *"Uppercase and the + wide tracking come off deliberately: they are legible devices at 11px, where + they mark a value in a table, but at this size they turn a sentence into what + reads like a section heading. Sentence case keeps it a statement."* Apply + `text-transform: none; letter-spacing: 0` to `.acl-list__head .st` alongside + the `white-space` override already scoped there. Do not change the size — 11px + is fine once it is sentence case, and the tone dot and its wording both stay. +- **Principle:** The project's own `.st--lead` ruling. Also the two-family + split's actual content: state is monospaced, but this string is prose wearing + state's clothes. + +### 5. The disclosure control's accessible name changes every 30 seconds + +- **Severity:** Moderate +- **Where:** `src/app/admin/access-lists/page.tsx:217-231` (the `head` span + becomes `<summary>` at `page.tsx:249`), with `RelativeTime` at 224-229 +- **Cost:** `head` is passed as `Disclosure`'s `summary`, and no `ariaLabel` is + passed, so the `<summary>`'s accessible name is computed from its contents — + which include the `RelativeTime` element. That element re-renders on a shared + 30-second ticker (`_components/relative-time.tsx:36-60`). A screen-reader user + who opens a drawer, reads the detail, and navigates back to the control finds + it named something different from what they left; a user driving by voice + ("click Alpha ACL, four minutes ago") loses the target mid-sentence. The + visible `+`/`−` pseudo-element (`globals.css:4714-4722`) also enters the name + in Chromium and Firefox, so the name opens with a punctuation mark. +- **Fix:** Pass `ariaLabel={label}` — or `` `${label}, ${rowSummary(row)}` `` — to + the `Disclosure`, which already supports it and documents the label-in-name + constraint (`_components/disclosure.tsx:40-43`). Both start with the visible + text, so WCAG 2.5.3 holds. The age stays visible and stays out of the name, + which is the correct division: it is context, not identity. +- **Principle:** WCAG 4.1.2 (name changing without user action) and 3.2.4 + Consistent Identification. Note: `/admin/sync`'s `.strip__disc > summary` + carries `.ago` the same way (`globals.css:4142`, `admin/sync/page.tsx:435-449`) + — same defect, different surface, and that one was in scope and not filed. + +### 6. The add-to-watchlist form is the only place in the app where a `<select>` and its `<label>` fall outside the design system + +- **Severity:** Moderate +- **Where:** `src/app/admin/access-lists/page.tsx:170-180` +- **Cost:** Three defects from one omission, verified against the app's only + other `<select>` (`src/app/payouts/page.tsx:194-207`): + 1. The `<select>` carries no `className`. Everything else in the app uses + `.field` — `--void` ground, `--rule-strong` border, mono at `--t-data`, + `--gold-dim` on hover, `--gold` border on `:focus-visible`, 36px min-height. + This one gets the UA widget: proportional face, no gold focus border (the + global ring still applies, so this is a vocabulary break, not a focus + failure), and a UA height that sits under the 36px standalone grade. It + renders dark rather than light only because `layout.tsx:47` declares + `colorScheme: "dark"` — the contrast is fine, the consistency is not. + 2. The `<label>` carries no class, so it renders in the body face at body + size. Every other form label in the app is in the documented label + register (`.filters__label`, `.filter-form__label`, `.drawer__label`, + `.crew__label` — `globals.css:375-392`): mono, `--t-label`, 600, uppercase. + 3. `defaultValue=""` matches no rendered `<option>` — the catalog options are + the only ones present. The browser silently selects index 0, so the form + works, but the declaration says a placeholder was intended and none exists. + `/payouts` gets this right with an explicit `<option value="">any</option>`. +- **Fix:** `className="field"` on the select, `className="filter-form__label"` + on the label, and either add the empty option or drop the `defaultValue`. +- **Principle:** Product ban — inconsistent component vocabulary across screens. + "If the save button looks different in two places, one is wrong." + +### 7. The removal confirmation names a number the admin has never seen + +- **Severity:** Moderate +- **Where:** `src/app/admin/access-lists/actions.ts:79` +- **Cost:** `removeWatchAction` returns `` `Access list ${accessListId} removed + from the watchlist.` `` — the raw ESI id. The page went to real trouble two + files away to make sure a list is always called by its name: `page.tsx:216` + computes `label = c.name ?? \`#${c.accessListId}\`` specifically so the visible + name and the button's accessible name "can never disagree about what an + unnamed list is called", and `AccessListDetail`'s docblock states the rule + outright — "Names lead and ids are secondary throughout: the admin retypes + these in-game, where the id is not what the client accepts." The one sentence + confirming an irreversible-ish act is the one place that rule is dropped. An + admin removing "Home Fleet ACL" from a list of five reads "Access list + 99000891 removed" and cannot confirm from the sentence that the right row + went, because the row is already gone. Focus has just been moved onto that + sentence, so it is also what a screen-reader user hears. +- **Fix:** Carry the label on the submit button that already carries the id, or + have `removeWatch` return the removed row's name, and render + `` `${label} removed from the watchlist.` `` with the `#id` fallback the page + already computes. +- **Principle:** PRODUCT.md — the tool speaks in the operator's vocabulary. Same + class as the Aug-5 sweep's closed "UUID recital" item on `/admin/audit`; this + is a fresh instance on a surface that sweep never opened, not a re-report. + +### 8. The lede does two jobs in one slot, and in state 1 it is the explanatory subtitle the brief warns about + +- **Severity:** Minor +- **Where:** `src/app/admin/access-lists/view.ts:83-88` +- **Cost:** Six of the seven `monitorSentence` cases are live status about the + holder — "Kestrel Vane is the holder", "…and its authorization has gone + stale". The seventh opens "This page compares the alliance roster against the + in-game access lists", which is a caption explaining what the page means, + sitting under an `<h1>` that already says "Access lists". It occupies the + status slot, so a returning admin's eye goes to the position that normally + holds a state fact and finds a definition instead. It is also the sentence + most likely to be read once and never again, taking up the page's best line. +- **Fix:** Split the two registers. The status sentence stays in `.lede`; the + one-time explanation belongs with whatever fills the empty field in finding 2 + — a short "what this does / what you need" block that a configured install + never renders at all. +- **Principle:** The brief's own smell — an explanatory subtitle under an H1 + usually means the surface needs work, not a caption. + +### 9. `themeColor` is still the retired navy + +- **Severity:** Minor +- **Where:** `src/app/layout.tsx:47` — **across surfaces**, not this one +- **Cost:** `themeColor: "#080f1f"` is blue at nearly four times red. The + palette moved off the blue-slate axis deliberately and `--void` is + `#0a0a0a` at chroma 0, which the token comment defends at length. On Android + Chrome and as an installed PWA this paints the browser chrome navy directly + above a neutral-black page, so the one surface the design cannot restyle is + the one still wearing the old palette. Visible in the 390×844 context of every + narrow shot in this sweep, including both of mine. +- **Fix:** `themeColor: "#0a0a0a"`, matching `--void`. This is a stale value, not + a token retune, so it does not touch the "do not change colour tokens" + constraint. +- **Principle:** Record contradiction — the shipped chrome contradicts the + palette's own stated axis. Flagged here because I found it; it likely belongs + to reviewer B and should be de-duplicated rather than counted twice. + +--- + +## What is genuinely good and should survive + +- **`monitorState`'s cascade, and its ordering argument.** `view.ts:40-72` puts + the scope check before the token check because the plain re-auth link is what + *drops* the ACL scope — so offering it first would send the admin round the + loop that broke it. That is a real trap, correctly avoided, and the reasoning + is written where the next person will hit it. Seven states, each with exactly + one remedy, exhaustive over the union so a new state is a compile error rather + than a dead end. Do not collapse this into a `Record`. +- **`rowHasDetail`.** A clean row gets no disclosure control at all, rather than + a toggle that opens an empty box — and still gets its own inline "Stop + watching", so the list an admin most wants to remove is not the one that is + permanently unremovable. Both halves of that are right. +- **`rowTone` refusing `bad`.** Nothing on this page is a destructive act, so + the alarm colour is never spent. `never read` is `off`, not a failure. This is + the status-token rule working, in a place nobody was watching. +- **Honest staleness.** `observedAt` is the last *successful* read, never the + last attempt, and a failed row still dates the answer under it + (`page.tsx:222-229`, `view.ts:196-204`). `rowSummary` also refuses to print + drift counts beside a read failure, because those counts describe an older + read — that is a subtle correctness point about time, handled. +- **`allowEveryone` gets its own words.** Such a list has zero missing members + by construction, so "in sync" would have been true and a lie. It says "open to + everyone" instead. +- **Broad grants always carry "plus an unknown number of others."** The app + holds no corp roster, so the covered count is ours only, and the copy never + lets that be read as complete. +- **The single region-wide `ConfirmingForm`.** The docblock at `page.tsx:183-193` + and `271-303` reasons correctly that both halves of the confirm pair must + outlive a row's removal, and drops `pendingLabel` because a shared form's + pending state would name the wrong row. That is a genuinely hard-won shape; + do not "simplify" it back to per-row forms. +- **Contrast, measured.** Every colour pair on this surface clears WCAG 2.2 AA. + `--ink` `#ece7de` on `--void` `#0a0a0a` is 16.11:1; `--ink-dim` `#bab3a9` + 9.54:1; `--ink-faint` `#90877e` 5.64:1 on void and 4.63:1 on a hovered + `--hull-hi` `#21201f` row; `--signal-warn` `#ff9f5f` 9.75:1. `.acl-detail th` + at `--ink-faint` on `--hull` `#151514` is 5.21:1. Nothing here is close to the + floor. +- **Semantics and keyboard.** Heading order is clean (h1 → h2 → h3, no skips). + The detail table has `<thead>`/`<tbody>` and `scope="col"`. The summary's + focus ring is explicit and inset (`globals.css:4728-4731`), not suppressed. + Focus is deliberately moved to the confirmation after every action, by two + different mechanisms chosen per control, and neither announces twice. +- **Performance.** Five reads in one `Promise.all`, one batched + `lookupEntityNames` for every id every drawer will print rather than one per + row, no ESI call on render at all (and the docblock says why: a live fetch + would burn a refresh-token rotation per page load). No animation of layout + properties. `RelativeTime` shares one 30s ticker document-wide. +- **Anti-patterns: clean.** No cards, no gradient text, no glass, no hero + metric, no identical card grid, no modal. Hairlines and type carry the + structure. This does not read as generated. + +## What I could not evaluate, and why + +- **The populated monitor.** Both shots are state 1. Six of the seven states, + the watched list at realistic length, an open drawer, and the drift `Status` + in context were never photographed. Findings 2 and 4 reason about the + populated layout from the CSS and the content set and say so; they are + inferences, not observations, and a shot of a five-row watched list with one + drifted row open would confirm or kill both cheaply. +- **200% zoom and 320px reflow, as measured facts.** I did not run a browser — + this is a read-only review and starting the dev server was out of scope. The + structural evidence is good: `.acl-list__head` is `flex-wrap: wrap` with a + row-gap, `.btn-row` wraps, the detail table is inside a `Scroller`, and + `globals.css:4693-4698` records an actual 320px measurement (388px of mono + uppercase in a 288px box) being fixed. I have no reason to think either gate + fails and no measurement saying it passes. +- **Screen-reader behaviour of the two focus-move confirmations.** Both + `ConfirmNotice` and `ConfirmGroup` focus a `tabIndex={-1}` `<div>` carrying + `live={false}` text, relying on the focused element's name being read. That + pattern is shipped and reviewed on `/account`, `/admin/accounts` and + `/admin/sync`, so I did not re-open it — but I did not verify it with an AT + either, and finding 7's cost depends on it working. +- **Whether the owner wants this surface at all.** It is out of scope by the + brief's own statement. Finding 9 in particular is app-wide and probably + belongs to reviewer B. + +## Recommended actions + +1. **[P1] `$impeccable harden`** — gate `checkNowAction` on + `showsObservations`, and name the removed list rather than its id (findings + 1, 7). +2. **[P1] `$impeccable layout`** — cap `.acl-list`'s contents, and give states + 1-2 something that earns the column or a narrower one (finding 2). +3. **[P1] `$impeccable adapt`** — third `.btn--quiet` buy-back selector for the + drawer's standalone control (finding 3). +4. **[P2] `$impeccable typeset`** — de-case the drift summary, following + `.st--lead`'s written ruling (finding 4). +5. **[P2] `$impeccable audit`** — the mutating disclosure name, here and on + `/admin/sync` (finding 5). +6. **[P2] `$impeccable polish`** — `.field` and the label register on the add + form; `themeColor` (findings 6, 9). +7. **[P3] `$impeccable clarify`** — split the lede's two registers (finding 8). + +## Contested — settled taste I think is wrong + +Nothing. The two settled items this surface leans on hardest — `.st--ok` being +`--ink-dim` rather than green, and gold rationed to one primary action — are +both doing visible work here, and I would not change either. diff --git a/docs/design-sweep-2/reports/access-lists-critique.md b/docs/design-sweep-2/reports/access-lists-critique.md new file mode 100644 index 00000000..53dd809b --- /dev/null +++ b/docs/design-sweep-2/reports/access-lists-critique.md @@ -0,0 +1,492 @@ +# `/admin/access-lists` — critique + +> **OUT-OF-SCOPE ADDITION.** This surface appears in neither the owner's scope +> list for this sweep nor the Aug-5 sweep. It has never been reviewed. Every +> finding below can be lifted out cleanly if the owner does not want them. + +Register: product. Shots: `14-access-lists.wide.png`, `14-access-lists.narrow.png`. +Compared against `/account` (12), `/admin/sync` (11), `/admin/accounts` (15). + +--- + +## What the screenshot shows, before any explanation + +Wide (1440×900). An H1, "Access lists", in white. Two lines of dim body prose +under it. Two buttons, one gold, one dark. Then nothing. Content ends at roughly +y=275; the remaining 625px of the fold is `--void` `#0a0a0a`. Horizontally the +widest thing on the page is the prose, ending around x=700 of a 1440px window. +The page occupies about the top-left quarter of the viewport and stops. + +Narrow (390×844). Same three elements. The nav takes four rows and 220px, about +26% of the fold, before the H1 starts. Content ends around y=420 and the lower +half of the screen is empty. + +The two sentences read as one paragraph at one weight: "This page compares the +alliance roster against the in-game access lists. Nobody has granted the +access-list scope yet, so nothing can be read." The first sentence is +boilerplate about the page. The second is the only fact on the screen. They are +the same colour (`--ink-dim`, `#bab3a9`), the same size, the same face, and the +same paragraph. + +Two controls sit side by side: gold "GRANT ACCESS", dark "CHECK NOW". In this +state "Check now" cannot succeed: the sentence directly above it says nothing +can be read. + +--- + +## Findings, worst first + +### 1. The dark monitor announces itself in the same voice as its own boilerplate + +**Severity:** Serious + +**Where:** `src/app/admin/access-lists/view.ts:81-114`, rendered at +`src/app/admin/access-lists/page.tsx:137` + +The page's own design record states the goal exactly. `monitorSentence`'s +docblock (view.ts:74-80): *"a page that renders zero rows without saying why is +indistinguishable from a page saying everything is fine."* Six of the seven +`monitorState` branches return a fault sentence. The code says why. It then +renders that why as `.lede`, the class both siblings use for page description, +in `--ink-dim` `#bab3a9`, at body size, in the proportional face. + +Compare `/admin/sync` (shot 11) directly. That page has the same problem to +solve, a monitor that may be dark. It puts the description in `.page__lede` and +the two faults in bordered `Notice`s, one red, one amber, which are the second +and third things the eye reaches after the H1. `/admin/access-lists` has +`Notice` imported and in use inside the detail drawer (page.tsx:344) with a +`warn` tone, so the component and the tone were available and were not spent +here. + +The compound effect is worse than the flat weight alone. In `grant-needed`, the +only state captured, the description and the fault are welded into one string +(view.ts:84-87), so there is no separation to give weight to. In the other six +states the description silently disappears and only the fault is printed, which +means the standing explanation of what this page is exists for exactly one +viewer: the one who has never configured it. + +**Cost:** An admin who opens this page at a glance sees a paragraph of grey +prose where `/admin/sync` would have shown them a red box, and concludes the +monitor is fine. The states this most affects (`scope-dropped`, +`holder-needs-reauth`, `holder-no-token`) are, by view.ts's own docblock, *"the +ones most likely to be reached in production"* and they are the ones where the +monitor has silently stopped reading. The page is built to prevent exactly the +failure its own typography then permits. + +**Fix:** Split the two jobs. Keep one standing description as the lede across +all seven states, unchanged whatever the monitor is doing. Render +`monitorSentence`'s fault half through the `Notice` already imported here, at +`warn` for the four holder faults and untoned for `catalog-empty`/`normal`. +`grant-needed`'s string then loses its first sentence, because the lede is +saying it in every state instead of one. + +**Principle:** Visibility of system status (Nielsen 1). Colour is not the sole +carrier here in either direction, so the R4 parity rule is not at risk. + +--- + +### 2. The skip link does nothing on this route + +**Severity:** Serious + +**Where:** `src/app/admin/access-lists/page.tsx:133` + +``` +<main id="main" className="page page--wide"> +``` + +Eleven elements in `src/app` carry `id="main"`. Ten carry `tabIndex={-1}`. This +one does not. I checked all eleven. + +The shell renders `<a className="skip" href="#main">Skip to content</a>` +(`src/app/_components/ui.tsx:104-105`). A fragment target that is not focusable +scrolls the viewport but does not move focus, so the next Tab resumes from the +document position it already held, which is the first nav link. The skip link +is present, visible on focus, and inert. + +This surface is the worst one to lose it on. It sits last in a nav of seven +links plus "Sign out" (visible in every shot), and three of its four server +actions redirect back to this same URL (`actions.ts:34, 42, 56`), so the admin +lands at the top of that nav again after designating a holder, adding a list, or +queueing a check. + +**Cost:** A keyboard-only admin tabs through eight header links to reach the +page content, and does it again after every press that redirects. On +`/admin/sync` and `/admin/accounts`, the same admin presses the skip link once. + +**Fix:** Add `tabIndex={-1}` to the `<main>`. + +**Principle:** WCAG 2.4.1 Bypass Blocks. Also plain consistency: ten of eleven. + +--- + +### 3. Pattern 1, and the class that declares the width admits it changes nothing + +**Severity:** Moderate + +**Where:** `src/app/admin/access-lists/page.tsx:133`, +`src/app/globals.css:694-700` + +This is the sweep's unshaped field in its purest captured form. Content +occupying roughly the top-left quarter of a 1440×900 fold, the rest empty, and +no compositional acknowledgement that the remainder exists. + +The width class is worth reading in full. `globals.css:698-700` is +`.page--wide { max-width: var(--measure-page) }`, and its own comment says every +other admin route *"gets it by leaving `.page` unmodified, so this changes +nothing about the box. It exists so the page states which column it chose."* It +is used once, here, and only here. So the page's one width decision is a +no-op class asserting a choice that nothing enforces, on the surface where that +choice is most visibly wrong. + +The populated markup does not need 78rem either. A watched row is a name, a +status token and an age (page.tsx:217-231). The widest thing behind a drawer is +a two-column table of character and corporation names (page.tsx:355-374). None +of that is a `/admin/accounts` nine-column table. + +I can only make the strong claim about the state I have pixels for. In that +state the finding is unambiguous. + +**Cost:** An admin arriving at an unconfigured monitor gets a screen that is +mostly nothing, which reads as a page that failed to load rather than a page +with one thing to tell them. It is also the page's first impression, since +`grant-needed` is by construction the state every installation starts in. + +**Fix:** Move to `page--narrow`. It caps *contents* at 60rem and leaves the page +box on `--measure-page`, so the H1's left edge and every rule origin stay on the +one vertical the settled one-column-origin rule requires. Delete `.page--wide`, +which then has no callers. Separately, give this state something to fill the +column with: the grant flow is three steps (authorize a character, designate it, +wait for a read) and the page currently states none of them. + +**Principle:** Sweep pattern 1, unshaped field. + +--- + +### 4. The removal confirmation recites a number the code already resolved to a name + +**Severity:** Moderate + +**Where:** `src/app/admin/access-lists/actions.ts:80` + +``` +return { text: `Access list ${accessListId} removed from the watchlist.` }; +``` + +Three things make this more than a copy nit. + +First, the page states the opposite rule in its own docblock +(page.tsx:322-323): *"Names lead and ids are secondary throughout: the admin +retypes these in-game, where the id is not what the client accepts."* + +Second, the row went to real trouble to have one authoritative label. +page.tsx:216 computes `label = c.name ?? \`#${c.accessListId}\`` with the +comment *"so the two can never disagree about what an unnamed list is called"*, +and feeds it to both the visible name and the button's `aria-label`. The +confirmation is a third channel that disagrees with both. + +Third, and this is the part that makes it a clean fix: `removeWatch` +(`src/services/access-lists.ts:133-153`) **already reads the name.** It calls +`watchedListName` before the delete, specifically so the audit row carries it +(`details: { accessListId, name }`, line 152). It then returns `void`. The name +is fetched, spent on the auditor, and withheld from the person who pressed the +button, one return value away. + +The two redirect confirmations name nothing at all. `doneNotice` +(`view.ts:263-269`) returns "List added to the watchlist" and "Holder +designated", neither carrying which list or which character, on a page whose +`designateHolder` control exists precisely because the identity of the holder is +the fact that matters. + +**Cost:** The admin presses "Stop watching" on a row, the row vanishes, and the +only text left says "Access list 4192 removed from the watchlist." To undo a +mispress they must open the catalog select and match a bare number against a +list of names, which is the operation the page's own docblock says the id is bad +for. + +**Fix:** Change `removeWatch` to return the `name` it already reads (or null), +and have the action return `"<label> removed from the watchlist."` using the +same `name ?? #id` fallback the row uses. Note the shape constraint: this is one +shared `ConfirmingForm` for the whole region (page.tsx:194-195), so a per-row +hidden input is not available and the name has to come back from the server. +Same treatment for `doneNotice`'s `watch` and `holder` cases, which have the +redirect query string available to carry a name. + +**Principle:** Recognition rather than recall (Nielsen 6). Same species as the +Aug-5 "UUID recital" finding on `/admin/audit`, on a surface that never got that +pass. + +--- + +### 5. The add-list select has no unchosen state, so its button acts on an arbitrary list + +**Severity:** Moderate + +**Where:** `src/app/admin/access-lists/page.tsx:172-178` + +``` +<select id="add-list" name="accessListId" defaultValue=""> + {addable.map((c) => ( + <option key={c.accessListId} value={c.accessListId}>{c.name}</option> + ))} +</select> +``` + +No option carries `value=""`. `defaultValue=""` therefore matches nothing, and +the browser falls back to selecting the first option. The control renders +looking as though a choice has been made, and submitting without touching it +adds whichever list happens to sort first in the catalog. + +There is no confirmation step and, per finding 4, the resulting notice does not +name the list either, so the admin gets no signal that the wrong one was added. + +The blast radius is real but bounded: watching a list is reversible via "Stop +watching", and the worker reads rather than writes. This is why it is Moderate +and not Serious. + +**Cost:** An admin who tabs to "Add to watchlist" and presses Enter, or clicks +it while intending to open the select first, silently starts monitoring a list +they did not choose, and the page tells them only that "List added to the +watchlist." + +**Fix:** Add a disabled, selected placeholder as the first option +(`<option value="" disabled>Choose a list</option>`) so the submit is refused +until a real choice is made. `parseId` (`actions.ts:24-28`) already throws on a +non-positive value, so the server side is covered; this is about not reaching +it. Pair with naming the list in the confirmation. + +**Principle:** Error prevention (Nielsen 5). + +--- + +### 6. The drawer's "Stop watching" takes the 28px grade a drawer is not entitled to + +**Severity:** Moderate + +**Where:** `src/app/admin/access-lists/page.tsx:256`, styled by +`src/app/globals.css:2813-2819` + +`StopWatching` renders `className="btn btn--quiet"` and is used twice: in-row on +a clean list (page.tsx:243), and *inside the open `Disclosure`* on a drifted one +(page.tsx:256). `.btn--quiet` sets `min-height: 1.75rem`, the 28px grade. + +The settled constraint is explicit: *"28px is scoped by the reason for it, rows +that each carry a control set and are read many at a time. A disclosure drawer +is not in-row for this purpose and takes 36px."* The in-row use qualifies. The +drawer use does not. + +The codebase has already solved this exact case. `globals.css:2827-2836` +documents `InlineEdit` needing *"the 36px floor back without losing the quiet +colouring `.btn--quiet` still earns them"* for its page-level uses, and does it +with a two-class override. + +**Cost:** On a drifted list, the admin has just read a table of characters +missing access and a list of non-members. The one control that ends that +row's presence on the page is the smallest hit target on the screen, at the +bottom of an expanded drawer, and it is the control most likely to be pressed +with a trackpad after a long read. + +**Fix:** One rule mirroring the existing `InlineEdit` precedent: +`.acl-list__disc > div > .btn { min-height: 2.25rem; padding: var(--s-2) var(--s-4); }`. +The in-row instance is matched by `.acl-list__row > .btn` and is untouched. + +**Principle:** The settled two-grade hit-target rule; WCAG 2.5.8 is met either +way by spacing, so this is the system's own floor rather than the standard's. + +--- + +### 7. The section head is the one in the admin set with no count and no as-of, and spends its aside restating the form beneath it + +**Severity:** Moderate + +**Where:** `src/app/admin/access-lists/page.tsx:165-181`; across surfaces + +Both siblings put two facts in the aside of the head above their data. Shot 11: +`9 JOBS · CHECKED 11:57:14 UTC`. Shot 15: `13 MEMBERS · as of 11:57 UTC`. Both +answer "how much is here" and "how current is this" once, for the whole set, +which is the pattern-2 fix in its constructive form. + +This page's head is `<RuleHead as="h2" aside={... "add a list"}>Watched lists`. +`RuleHead`'s `aside` renders a `<span>` (`ui.tsx:216`), so "add a list" is a +label, not a control, and the control it labels (the select plus "Add to +watchlist") renders as the very next element at page.tsx:170-181. The aside is +spent announcing what is immediately visible below it, and the two facts the +siblings put there are absent. + +This is the surface where "how current" matters most. The page's own docblock +(page.tsx:44-49) is a paragraph on why nothing here is live: a render-time ESI +fetch would burn a token rotation, so every number is a worker read that may be +hours old. The rows carry `observedAt` individually, deliberately as the last +*successful* read (page.tsx:222-223). There is no set-level answer, so "is this +page stale" costs a scan of every row's `RelativeTime`. + +**Cost:** An admin checking whether the access-list monitor has been running has +to read every row's age and take the maximum by eye, on the one page whose +entire purpose is to report a background job's freshness. + +**Fix:** Put the count and the oldest observation in the aside, in the siblings' +shape: `4 LISTS · OLDEST READ 3h ago`, one string, computed once over `compared`. +Drop the "add a list" label; the labelled `<select>` beneath it already names +itself. + +**Principle:** Sweep pattern 2, inverted. The shared fact about the set is +missing rather than repeated. + +--- + +### 8. The control row heads the page, against both siblings and against the class's own documented contract + +**Severity:** Minor + +**Where:** `src/app/admin/access-lists/page.tsx:139` + +`.btn-row--controls`'s CSS comment (`globals.css:2986-2991`) defines the class: +*"A control row that follows the data it operates on rather than heading the +page."* This page applies it at line 139, above everything. + +`/admin/sync` uses the same two classes at page.tsx:1108, after the strip, with +its own comment (lines 1104-1107): *"State before action (PRODUCT.md principle +2): the strip answers 'what is true right now' before the gold button, which is +the most saturated thing on the page, gets to pull the eye."* + +In the captured `grant-needed` state this costs nothing, since there is no data +to precede. In `normal` and `catalog-empty` it inverts principle 2 on the page +whose single job is state: gold "Check now" (`#f1c035`, the most saturated thing +on the screen) is the first thing under the lede, and the watched lists are +below it. + +**Cost:** In the populated state, the eye lands on a button that enqueues a +background job before it lands on the answer that would say whether the job +needs enqueueing. + +**Fix:** Move the `btn-row--controls` block below the watched-list region, as on +`/admin/sync`. Keep it above in `grant-needed` and `designate-needed`, where +`showsObservations` is false and there is nothing for it to follow. + +**Principle:** PRODUCT.md principle 2, state before action. + +--- + +### 9. `.lede` duplicates `.page__lede` exactly, and the confirmation is wedged between the title and the sentence + +**Severity:** Minor + +**Where:** `src/app/admin/access-lists/page.tsx:134-137`, +`src/app/globals.css:757-761` and `768-772` + +The two rule bodies are identical, three declarations each: + +``` +.page__lede { max-width: var(--measure); color: var(--ink-dim); margin-top: var(--s-2); } +.lede { max-width: var(--measure); color: var(--ink-dim); margin-top: var(--s-2); } +``` + +`.lede`'s comment states why it exists: this page has no `.page__head` wrapper +around its H1, so the child selector cannot reach. And the reason it has no +wrapper is the ordering at page.tsx:134-137, which puts `ConfirmNotice` between +the H1 and the sentence. Both siblings wrap H1 and lede together and put the +confirmation after (`sync/page.tsx:230-282`, `accounts/page.tsx:252-294`). + +The ordering has a behavioural edge too: after any of the three redirecting +actions, the confirmation inserts itself above the sentence that explains what +the page is doing, pushing it down. + +**Cost:** Small for the reader. It is one duplicated rule and one page whose +masthead is assembled differently from every other, which is the kind of drift +that compounds when the next surface copies whichever one it happened to open. + +**Fix:** Wrap H1 and lede in `.page__head`, move `<ConfirmNotice>` below them, +switch to `page__lede`, delete `.lede`. + +**Principle:** Consistency and standards (Nielsen 4). + +--- + +## What is genuinely good and should survive + +**`monitorRemedy`'s two-href split** (view.ts:135-151) is the best decision on +the page and the least likely to survive a careless refactor. `scope-dropped` +and `grant-needed` get `/auth/eve/link?grant=access-lists`; the two token faults +get the bare `/auth/eve/link`. The docblock explains that the bare link is *what +drops the ACL scope in the first place*, so it must never be offered as the +remedy for a missing scope. Collapsing these to one link would send an admin +round the loop that caused the fault. Do not unify them. + +**`rowHasDetail`** (view.ts:232-236). A clean list gets no disclosure control at +all rather than a toggle that opens an empty box, and it still keeps its own +inline "Stop watching" so a clean or never-read list is not permanently +unremovable. A fix pass tempted to make rows uniform will break both halves of +this. Leave it. + +**`rowTone` refusing `bad`** (view.ts:188-193), with the rule stated: the alarm +colour is reserved for destructive acts, and nothing here is one, since every +row reports on a list only a human can change in-game. This is a page that +could easily have cried wolf and does not. + +**`rowSummary` preempting the drift counts on a failed read** (view.ts:205-209). +Those counts came from the last *successful* read; printing them beside a read +failure would date a stale number to now. Paired with `observedAt` being the +last successful read rather than the last attempt (page.tsx:222-223), the page +is honest about staleness in two places at once. + +**"plus an unknown number of others"** (page.tsx:405-407), on every broad grant, +because the app stores a corporation per character and holds no corp roster, so +the covered count is our members only. This is a clause a copy pass would delete +as wordy. It is the difference between a true statement and a false one. + +**The `Status` plus words pairing** on every row (page.tsx:220), and the +`aria-label` on `StopWatching` carrying the row identity that the visible words +cannot (page.tsx:314). Both are the parity rule working. + +**The region-level `ConfirmingForm`** (page.tsx:194-195) and the deliberate +absence of `pendingLabel` on `StopWatching` (page.tsx:290-302), each with the +failure it prevents written down. The absent `pendingLabel` in particular looks +like an oversight and is not. + +--- + +## What I could not evaluate + +**Six of seven states have no pixels.** The only shot is `grant-needed`. I have +no capture of `normal`, `catalog-empty`, or any of the three holder faults, and +no capture of a populated `.acl-list` at either width. Everything in findings 3, +6, 7 and 8 about the populated page is reasoned from markup and CSS, and I have +scoped each claim accordingly. Findings 1, 2, 4, 5 and 9 do not depend on it. + +**Whether the row's right-hand column reads as a column.** `.acl-list__head` +(globals.css:4671-4679) is a wrapping flex line with no grid tracks, and its +comment claims name / status / age *"read as a loose right-hand column across +rows rather than `.strip`'s hard-pinned tracks"* because there is no fourth +value competing. `/admin/sync`'s `.strip__head` uses an explicit grid. Whether +the looser treatment holds up across four rows of unequal name lengths is a +pixel question and I could not answer it. + +**The 320px reflow claim.** `globals.css:4699-4703` states the drift sentence +measures 388px of mono uppercase in a 288px content box and scopes +`white-space: normal` to this list to fix it. I did not re-measure, and I have +no narrow capture of a drifted row to check the resulting wrap for raggedness. + +**Interaction.** Read-only with no running app, so the confirm and pending +behaviour of the shared region form, the `Disclosure` open-state survival across +`removeWatchAction` (which deliberately does not redirect, `actions.ts:59-70`), +and focus placement after each press are all from source only. + +**Checked and not a finding, recorded so it is not re-raised:** `.acl-list`'s +`1px solid var(--rule-strong)` plus `--radius` (globals.css:4625-4631) is not a +third card. `.strip` carries the identical treatment (globals.css:4038-4045) +and so does `.scroller`; the border bounds a region of interactive rows rather +than decorating one, which is the stated reason for `--rule-strong` in both. The +two-card exception is intact. Contrast is also clean throughout: `.btn--quiet` +at `--ink-faint` `#90877e` on `--void` `#0a0a0a` is 5.64:1, rising to `--ink` +`#ece7de` on `--hull-hi` `#21201f` at 13.21:1 on hover; `.acl-detail th` at +`#90877e` on `--hull` `#151514` is 5.21:1. The drawer summary computes to about +49px tall (24px padding plus a 24.8px line box), comfortably over the 36px +grade, so finding 6 is about the button alone. + +--- + +## Contested + +Nothing. None of the settled taste items needed to be re-opened to file the +above, and finding 3's proposed `page--narrow` respects the one-column-origin +constraint rather than working around it. diff --git a/docs/design-sweep-2/reports/audit-audit.md b/docs/design-sweep-2/reports/audit-audit.md new file mode 100644 index 00000000..01fccc18 --- /dev/null +++ b/docs/design-sweep-2/reports/audit-audit.md @@ -0,0 +1,558 @@ +# `/admin/audit` — technical audit + +`$impeccable audit` · register: PRODUCT · design-sweep-2 · 2026-08-10 + +Read first: `10-audit-full.{wide,narrow}.png`, `09-audit-empty.{wide,narrow}.png`. +Then `src/app/admin/audit/page.tsx`, `summarize.ts`, `src/services/audit.ts`, +`src/app/_components/{scroller,submit,submit-guard,focus-heading,ui}.tsx`, and +the `.log--audit` / `.scroller--tall` / `.filter-form` / `.pager` blocks of +`src/app/globals.css`. + +## What the screenshots show, before any source + +**Wide (1440×1243, fullPage).** A left-aligned column: `Audit log`, a +one-sentence lede, a `FILTER` rule with three empty inputs and a `FILTER` +button, then a `62 ENTRIES · as of 11:57 UTC` rule, then one bordered box +holding the table. The box ends at y≈1148 with the last row flush against its +bottom edge and ~95px of empty ground below it — the box is a height-capped +scroll region, not the whole table, and no pager renders (62 < the 100-row page +size, so `hasOlder` is false). Five columns: a 19-character ISO stamp, Actor, +Action, Target, Details. **Every visible row's stamp begins `2026-08-08`** — 13 +of 13. Actor, Action and Target are underlined links; Target alternates between +a name and a truncated UUID (`6c4f2916-903b…`). Details is a `+`-prefixed +disclosure line, and six of the thirteen read the identical string +`+ roleId=1284410981234567890, characterId=90000006`. + +**Narrow (390×1500, clipped by the capture).** Nav wraps to three rows. The +filter stacks; `FILTER` sits beside the Target field rather than under it. The +`At` column swaps the ISO stamp for elapsed time and the table scrolls +sideways, cut mid-`TARGET`; Details is entirely off-screen. The At column reads +`42h, 41h, 41h, 41h, 40h, 40h, 39h, 39h, 38h, 38h, 37h, 37h, 36h` — **four +consecutive rows carry the same value.** + +**Empty (both).** One `<tr>` reading `Nothing has happened yet.`, centred, with +the header row above it and the count rule reading `NO ENTRIES`. The message is +on-screen and left-pinned at 390px, not scrolled away. + +The circular `N` badge at the left edge of three of the four shots is the +Next.js dev-tools indicator, not app UI. + +## Audit health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | A pager press is completely silent: no announcement, no focus move, and the count that would say what happened is ~300 tab stops back. | +| 2 | Performance | 3 | 100 rows each ship their payload twice (peek + collapsed `<pre>`); identity resolution itself is properly batched. | +| 3 | Responsive Design | 3 | Below 66rem the At column reads in whole hours, and four consecutive entries render as the same value. | +| 4 | Theming | 4 | Every colour on this surface is a token; no hard-coded value anywhere in the page or its rules. | +| 5 | Anti-Patterns | 4 | No cards, no gradient text, no glass, no hero metric. The one smell is the explanatory lede under the H1. | +| **Total** | | **17/20** | **Good — address accessibility and responsive.** | + +**Anti-patterns verdict: pass.** Nothing here reads as generated. Structure is +hairline rules and section headers; the two type families do the work the split +promises (Archivo for the lede, mono for every stamp, id and payload); the +`FILTER` button correctly refuses gold. A category-reflex check on "admin audit +log" would predict a dark blue-grey table with a green/red status column and a +row of icon buttons, and none of that is here. + +--- + +## Findings, worst first + +### 1. A page change on this table is completely silent + +**Severity:** Serious +**Where:** `src/app/admin/audit/page.tsx:200-231` (`Pager`), and +`src/app/_components/focus-heading.tsx:25-37` for the measurement that makes +this certain. + +The pager's next/previous controls are `<a href>`, which the App Router +intercepts as a *soft* navigation. `FocusHeading`'s docblock records the +measurement: "Nothing in the framework moves focus for us. The App Router does +call `focus()` on arrival, but it targets the first element of the changed +segment — which is the `<header>` — and a `<header>` is not focusable, so the +call is a no-op and focus stays wherever it fell." So focus stays on the +`Older →` link. Next's `AppRouterAnnouncer` portals `document.title` into an +assertive region, and this route's title is the constant `"Audit log"` on every +page — no change, no announcement. + +Net: a screen-reader admin presses `Older →`, hears nothing at all, and is +still standing on a link whose label has not changed. Nothing tells them the +press landed, which page they are on, or how many rows arrived. The one piece +of text that answers all three — `100+ older entries`, deliberately written to +distinguish page 1 from page 7 (`page.tsx:379-389`) — is above the table, i.e. +roughly 300 tab stops backward from where they are standing. + +I checked the obvious adjacent hazard and it is *not* present: `Pager`'s JSX has +two fixed child slots (`{hasLatest && …}`, `{hasOlder && …}`), so React's +positional reconciliation keeps `Older →` in slot 1 across the page-1 → page-2 +transition. The focused node does not silently become `← Latest`. Focus staying +put is in fact the right ergonomics for repeat paging; the defect is purely that +nothing is said. + +**Cost:** An admin using a screen reader to walk a 300-row log presses `Older →` +and cannot tell a successful page turn from a dead control, so they press again, +and again, skipping pages they never knew they had loaded — on the one surface +in the app whose job is to establish exactly what happened and in what order. + +**Fix:** Put the count line in a polite live region rather than moving focus. +`RuleHead` already renders the count and already carries the `id` + +`tabIndex={-1}` contract for a focus landing (`ui.tsx:204-209`), but focusing it +here would drag the keyboard user backward past the table on every page turn, +which is worse than the silence. A `aria-live="polite"` wrapper around the +count's text — announcing `100+ older entries` on arrival — costs nothing to +sighted use and answers all three questions. The precedent for the region is +`Notice` (`ui.tsx:299`); the precedent for announcing a soft transition is +`FocusHeading` itself. + +**Principle:** WCAG 2.2 AA 4.1.3 Status Messages. Also the product register's +"every interactive component has a loading and a feedback state." + +--- + +### 2. At 200% zoom the table's scroll region ends far below the fold + +**Severity:** Serious +**Where:** `src/app/globals.css:1792-1812` (`.scroller--tall`), and +`globals.css:1817-1830` for the fix that was scoped away from this table. + +`.scroller--tall` caps the region at `80svh`. The accounts table got a second, +narrower cap — `100svh - 29rem` — with a comment that states the reasoning +plainly: "80svh alone is a blind claim: it describes the region without +reference to what is above it… at 900px tall an 80svh region ended 246px below +the fold, so the sticky header the cap exists to enable had nothing on screen to +pin against." That fix is scoped to `:has(.log--dense)` because "/admin/audit's +region sits under far less chrome" — which is true at 100% zoom and stops being +true at 200%. + +Arithmetic, from the stylesheet rather than a browser (see *what I could not +evaluate*): 200% zoom on a 1440×900 screen gives a 720×450 CSS-px viewport. +`svh` halves with it, so the cap is 360px. Everything above the region is +expressed in `rem` and does not shrink: the header bar, `.page`'s top padding, +the H1, a two-line lede, the `FILTER` rule, the filter form, its hint line, its +`--s-5` bottom margin, and the count rule. 720px is also below the 66rem stop +and below the width at which `.filter-form`'s four flex cells fit on one line, +so the actions cell wraps and the form gains a row. That chrome lands around +380–400px of a 450px viewport, and a 360px region starting there ends roughly +300px past the fold. + +**Cost:** An admin who zooms to 200% — the population this route is most likely +to have, since reading UUID fragments and 19-digit snowflakes at 14px is the +whole task — must scroll the page to reach the table, then scroll inside a +region whose bottom edge they can never bring on screen, with a sticky header +that has almost no visible range to pin against. Two nested scrollbars for one +list. + +**Fix:** Give `.scroller--tall:has(.log--audit)` its own chrome-subtracting cap +in the same form the accounts table uses, measured against this page's chrome +(which is different — no standing pending notice, but a taller filter form once +it wraps). Do not widen the shared `.scroller--tall`; the comment's reason for +keeping it narrow is sound. `e2e/admin.spec.ts:2469` is the existing shape for +the regression test ("the accounts scroller does not floor to the same height at +every zoom level") and has no audit counterpart. + +**Principle:** WCAG 2.2 AA 1.4.4 Resize Text — content must remain usable at +200%; a region whose end is unreachable is the canonical failure. + +--- + +### 3. The Filter button's in-flight machinery cannot fire, and its guard can outlive the page + +**Severity:** Serious +**Where:** `src/app/admin/audit/page.tsx:456` (`<form method="get">`) → +`:527` (`<Submit>`), against +`src/app/_components/submit.tsx:79-80` and +`src/app/_components/submit-guard.ts:67-79`. + +`Submit` calls `useFormStatus()`, which reports status only for submissions +React itself manages — a form with a function `action`. This form is a native +`method="get"` with no `action` prop, so the browser owns the submit and +`pending` is never `true`. Two consequences: + +- **No feedback.** `aria-busy`, and the `pendingLabel` swap the docblock calls + "the whole of the in-flight signal", never engage. On a `force-dynamic` route + that runs up to three queries before it renders, the button is inert-looking + for the whole round trip. The page's own comment at `:26-31` notes that + `Submit` is a client component; it does not note that the component's reason + for existing is switched off here. +- **A latch with no release.** `useSubmitGuard` sets `inFlight.current = true` + synchronously on click and clears it only after observing `pending` go true + and then false (`submit-guard.ts:71-78`). With `pending` pinned false, the + latch is set and never cleared for the life of that document. Normally the + document is replaced by the navigation and nothing is lost. It is not + replaced when the document is restored from the back/forward cache: filter → + follow a row's actor link → press Back restores the *submitting* document with + its JS heap intact, latch still set, and every subsequent press of `Filter` + is silently swallowed. No `onRefused` is wired at this call site, so there is + no message either. + +**Cost:** An admin narrowing a log — filter, click a name to widen, press Back, +retype — finds the `Filter` button dead with no error and no visual change, and +the only recovery is a manual reload they have no reason to think of. + +**Fix:** Two independent halves. (a) Do not pass this form through `Submit` at +all; a plain `<button type="submit" className="btn">Filter</button>` is honest +about what a native GET form can signal, and drops a client-component boundary +the page does not need. (b) If the guard is wanted here, it needs a release +path that does not depend on `useFormStatus` — a `pageshow` listener clearing +the ref covers the bfcache case, which is the one that bites. + +**Principle:** Product bans — "every interactive component has default, hover, +focus, active, disabled, loading, error. Don't ship with half of these." Also +WCAG 2.2 AA 3.2.2 On Input, in the degenerate sense: the control accepts a press +and produces nothing. + +--- + +### 4. The date is stated 62 times, and the narrow rendering that saves the width loses the ordering + +**Severity:** Moderate +**Where:** `src/app/admin/audit/page.tsx:617-625`, with +`globals.css:5242-5260` (narrow column widths) and +`src/app/_components/format-ago.ts:8-15`. + +This is pattern 2, and it has an unusually clean fix because both viewports fail +for the same reason. + +Wide: every row renders `2026-08-08 17:31:04`. Thirteen of thirteen visible rows +share the date; a full 100-row page at this fixture's spacing spans about 47 +hours, so two or three distinct dates carry 100 renderings of a ten-character +string, in the pinned column — the one column that costs 12.25rem of a fixed +62rem table and paints over whatever the horizontal scroll has brought +alongside it. + +Narrow: the swap to elapsed time was measured and is right in principle — a +19ch stamp at 69% of a 286px region is indefensible. But `elapsedShort` rounds +to whole hours past 90 minutes, and this log's entries are ~28 minutes apart, so +the shot shows `41h ago` four times in a row and `37h ago` twice. On a phone the +At column can no longer order or distinguish adjacent entries, on the surface +whose entire premise (`page.tsx:444-447`) is "newest first". The exact instant +survives in a `visually-hidden` span, so a screen reader is fine and a sighted +phone reader is not — the two channels have fallen out of parity, which is the +same fault the `Cadence (UTC)` fix on `/admin/sync` exists to prevent. + +One change fixes both. `17:31:04` is 8 characters; `365d ago` — `elapsedShort`'s +widest output — is also 8. The narrow column already reserves 5rem for the +second, so it fits the first at no cost, and it restores second-level ordering. +The date then moves to where a repeated fact belongs: a group row, once per +date, in the manner `crewNorms` on `/account` states a shared fact once, or the +existing `.log--group` row on `/admin/sync`. + +**Cost:** An admin on a phone at 1am, asked why someone's role is wrong, reads +four entries that all claim to have happened 41 hours ago and cannot tell which +of them came first — so they cannot tell which change caused which. On desktop +the same admin scans past 100 copies of a date to find the one time that +matters. + +**Fix:** Render `HH:MM:SS` in the cell at every width. Emit a `.log--group`-style +date row whenever the date changes between consecutive entries, carrying the +date once. Keep the `visually-hidden` full-stamp span so the accessible name +stays absolute. The pinned column can then come down from 12.25rem, which buys +Details back real width at the 43rem and 34rem floors. + +**Principle:** The sweep's pattern 2 — a value repeated identically on every row +when it is one fact about the set. And "reflow is not permission to destroy +data", which `globals.css:5140` already states about the sync table's Started +column. + +--- + +### 5. The table is ordered by a key it does not show, and the lede promises the one it does + +**Severity:** Moderate +**Where:** `src/services/audit.ts:623` (`orderBy(desc(auditLog.id))`) against +`src/app/admin/audit/page.tsx:445` ("newest first"). + +The query orders by serial `id`; the column the reader sees is `at`. The +screenshot is a literal demonstration that they can disagree — the wide shot +runs `17:31:04` at the top down to `23:11:04` at the bottom, which is oldest +first, under a lede that says newest first. Here that is the fixture's doing +(`capture.spec.ts:305-320` back-dates `at` while inserting forward), and in +production insert order normally tracks event order, so this is not a live bug +today. + +It is still a real property worth naming, because the keyset pager inherits it: +`Older →` means `id < cursor`, not "earlier than". Any writer that computes a +historical `at` — a backfill, an import, a job reconstructing an event it +detected late — produces a log that is ordered by something invisible while +claiming to be ordered by the column on screen, and the pager walks a key the +admin cannot see. + +**Cost:** An admin reconstructing a sequence trusts the order of the rows, +because the page told them to. If `at` and `id` ever diverge they get a +confident wrong answer, and nothing on the page gives them a reason to doubt it. + +**Fix:** Cheapest honest option: order by `(at desc, id desc)` so the visible +column is the sort key and `id` is only the tiebreaker, and make the pager's +cursor match. If insertion order is deliberately the sort key, say so in the +column header rather than in a lede that names a different one. + +**Principle:** None cited — this is a correctness-of-claim finding, not a rule +violation. + +--- + +### 6. Actor and Target are trimmed; Action is not + +**Severity:** Moderate +**Where:** `src/app/admin/audit/page.tsx:267-272`, and its own comment at +`:261-266`. + +`actor` and `target` get `.trim()`, with a good reason given: these are typed or +pasted by hand and a trailing space off a copied UUID would fall through to "no +such name". `action` is deliberately left alone, and the comment says so — +"its semantics are out of scope for this branch." + +The scoping decision has outlived its branch. `action` is a prefix match built +into `like(escape(value) + '%')` (`services/audit.ts:604-608`), so a pasted +`"tier.changed "` becomes `LIKE 'tier.changed %'` and matches nothing. The +empty state then says `Nothing matches this filter.` — true, confidently +phrased, and wrong about the reason. Worse, the actor-column nudge that softens +the same state for `actor` (`page.tsx:425-434`) does not apply, so this failure +mode has no hint at all. + +**Cost:** An admin pastes an action name out of a Discord message — where +trailing whitespace is routine — and is told the log has nothing on it. They +conclude the event was never recorded. + +**Fix:** Trim `action` the same way, in the same expression. The prefix +semantics are unaffected: no action name in `ACTION_NAMESPACES` or `PARTS` +begins or ends with whitespace, so trimming can only turn a guaranteed-empty +query into a matching one. + +--- + +### 7. Each resolved name announces its UUID twice — re-opening a closed item with a consequence it did not name + +**Severity:** Moderate +**Where:** `src/app/admin/audit/page.tsx:76-78` (`RawId`), used at `:118` and +`:154`, in combination with the `title` on the same anchors (`:115`, `:153`). + +I am re-opening `/admin/audit`'s "UUID recital", listed as closed by the Aug-5 +sweep. The visible recital is genuinely fixed — the columns render names, and +`shortId` truncates what cannot be named. What the closed item does not name is +the assistive-tech channel, where the recital was not removed but doubled. + +Each resolved actor and target anchor carries both a `visually-hidden` span +`(id 6c4f2916-903b-…)` inside its content **and** `title={r.actor}` on the +element. The hidden span joins the accessible *name*; `title` supplies the +accessible *description*. NVDA and JAWS announce both, so a single link reads as +"Bad Scout, open paren, i-d, six-c-four-f-two-nine-one-six dash…, link, +six-c-four-f-two-nine-one-six dash…" — the same 36-character hex string spelled +out twice. On a full page that is up to 200 anchors, up to 400 spoken UUIDs. + +`RawId`'s docblock argues, correctly, that `title` alone is unreachable to +VoiceOver, TalkBack and touch, and that the raw id is real information rather +than a restatement of the name. Both halves of that argument are right; what +follows from them is that the hidden span should *replace* `title`, not join it. + +**Cost:** A screen-reader admin scanning the log by link, or tabbing the actor +column, hears each name buried between two recitals of a UUID they did not ask +for and cannot act on — on the page where finding the right person quickly is +the entire promise. + +**Fix:** Drop `title` from the two anchors that carry `RawId`, keeping the +hidden span. `title` stays where the visible text and the raw value are already +the same string (`system`, the reserved literals, the unresolved branch), which +is exactly the split `RawId`'s own docblock already draws. Consider whether the +id needs to be in the name at all rather than behind a per-row disclosure the +Details column already establishes. + +**Principle:** WCAG 2.2 AA 2.4.4 Link Purpose, in the "purpose should be +determinable *efficiently*" sense; and the register's density permission does +not extend to the audio channel. + +--- + +### 8. The scroll region and the table it holds announce nearly the same name + +**Severity:** Minor +**Where:** `src/app/admin/audit/page.tsx:563` (`label="Audit entries"`) and +`:565` (`<caption>Audit log entries</caption>`). + +`Scroller` emits `role="region" aria-label="Audit entries"`; the table inside it +carries a visually-hidden caption reading `Audit log entries`. Entering the +region announces both, four words apart, differing by one word. + +**Cost:** Two seconds of duplicate speech per entry into the table, and a +landmark list with an entry that says nothing the table does not. + +**Fix:** Make the region's label say what the *region* is, since that is the +thing with a distinct job: `label="Audit entries, scrollable"` is wrong (state +belongs in the role), but `label="Audit log"` on the region with the caption +keeping `Audit log entries` at least separates them. Better: drop the caption +and let the region label carry the name, since `<caption>` here is buying +nothing the region does not already provide. + +--- + +### 9. The lede explains what the table means + +**Severity:** Minor +**Where:** `src/app/admin/audit/page.tsx:444-447`. + +"Every state change, append only, newest first. Nothing here can be edited or +removed." Four claims, three of which the table should be making itself. "Newest +first" is a sort order, which belongs on the column that carries it (and see +finding 5, where it is currently not even true of the screenshot). "Append only" +and "nothing can be edited or removed" are the same fact said twice. + +**Cost:** Every admin reads two lines of preamble on every visit to learn one +thing — that this log is immutable — that they learn permanently on the first +visit. + +**Fix:** Cut to the one durable claim: `Append only. Nothing here can be edited +or removed.` Put the ordering on the `At` column header, where a reader looks +when they want to know it. + +**Principle:** The sweep's own note — "an explanatory subtitle under an H1 is a +smell." And Copy: "no intros that repeat the title." + +--- + +### 10. The Action field's hint and its datalist offer different vocabularies + +**Severity:** Minor +**Where:** `src/app/admin/audit/page.tsx:499-506` against +`src/services/audit.ts:193-195`. + +The `<datalist>` offers eleven namespace *prefixes* (`tier.`, `token.`, +`discord.`, …). The hint under the field reads "what happened, like +tier.changed" — a complete action name, which is not one of the eleven options +and cannot be, since the list is derived from `NAMESPACE_TARGET_KIND`'s keys. +The field is a prefix match, so both work; the two channels simply describe +different things and a reader who opens the list after reading the hint finds +nothing resembling the example. + +**Cost:** A moment's confusion the first time, and a mild reason to distrust the +suggestions afterwards. + +**Fix:** Make the hint describe the prefix behaviour the field actually has — +`"a namespace like tier., or a full action"` — so the list and the sentence are +saying the same thing. + +--- + +### 11. Every row ships its payload twice + +**Severity:** Minor +**Where:** `src/app/_components/ui.tsx:352-371` (`Json`), used at +`src/app/admin/audit/page.tsx:676-687`. + +Each row renders a summary line *and* a `<pre>` holding +`JSON.stringify(value, null, 2)` — indented, so roughly 3–4× the compact form — +inside a collapsed `<details>`. All 100 of them are in the HTML on first paint +whether or not anyone opens one. + +This is a deliberate trade and mostly the right one: `<details>` needs no JS, +and the docblock's argument for keeping the full value reachable (role IDs and +trailing counters live past the truncation point) is sound. It is worth naming +only because the multiplier is per-row and the page size is 100. + +**Cost:** A slower first byte and a larger document on a `force-dynamic` route, +paid by every admin on every load for content almost none of them open. + +**Fix:** Pass `pretty={false}` here. The prop exists for exactly this, with a +measured precedent on `/admin/sync` ("the difference between a 227px row and a +~116px one"), and audit payloads are flat scalar objects in every declared +`PARTS` shape — the one-key-per-line indent buys nothing a compact string does +not already say. Not worth building lazy expansion for. + +--- + +## What is genuinely good and should survive + +- **Contrast, everywhere, with margin.** Rendered sRGB, computed from the + tokens: `--ink-faint` `#90877e` on `--void` `#0a0a0a` is **5.61:1**, and on + the hovered row's lighter ground **4.61:1** — both over the 4.5:1 floor at the + 12px and 11px sizes this page uses it at. `--ink-dim` `#bab3a9` on void is + **9.53:1** (body cells), `--ink` `#ece7de` **16.08:1**, `--gold` `#f1c035` + **11.63:1**, `--signal-warn` `#ff9f5f` **9.78:1** (the `_failed` action cells). + `--rule-strong` `#787370` — the field and header borders — is **4.23:1**, over + the 3:1 UI-boundary floor. Nothing on this surface is close to failing. +- **The two-channel discipline holds nearly everywhere.** The exact UTC instant + survives the narrow elapsed-time swap in a hidden span; `_failed` actions get + warn colour *and* already contain the word "failed"; `system` is distinguished + by mono + dim rather than colour alone. Finding 4 is the one place the two + channels have drifted, and it is a rounding artifact, not a design decision. +- **Hit targets are argued, not assumed.** `.cell-link` takes an explicit 28px + floor with a written refusal of WCAG 2.5.8's inline-target exception ("each is + the whole content of its `<td>`, not a link inside a sentence"), and + `.json > summary` took the same floor for the same reason. The 28px grade is + the sanctioned in-row one; `.btn` is 36px. Both correct, neither invented. +- **Focus is not obscured by either sticky edge.** `scroll-margin-top: 3rem` and + a per-table `scroll-margin-left` that tracks the pin's width through all three + breakpoints (12.25rem → 8rem → 5rem), with the reasoning for over-estimating + written down and the figures pinned in e2e. This is WCAG 2.4.11 done properly, + including the part about Chromium's programmatic focus scroll not reproducing + the bug. +- **The `Scroller` earns its tab stop conditionally**, granting one only while + there is something to scroll, with the pre-hydration default erring toward + reachable rather than toward tidy. That default is the right way round. +- **The empty state does real work.** Four distinct messages — unmatched name + (naming *which* field and what kinds of thing it could have matched), + past-the-end (with an exit link that keeps the filter), filtered-no-match + (with the actor/target asymmetry nudge, shown only when it can help), and + genuinely-empty — plus a count heading that says the same thing in the second + channel. `.log__empty-text` stays pinned to the scroller's left edge at 320px + so the message does not scroll out of view. +- **The middle-band breakpoint.** The 66rem stop exists because someone measured + a 2px-of-viewport / 399px-of-scroll cliff at 640px and fixed the band nobody + had looked at. Tablets are the correct place to have found that. +- **`summarize.ts`'s declared-keys design.** Tagging each renderer with the keys + it reads, so `+N more` means "nobody looked at this" rather than "deliberately + silent", is the difference between a summary that is incomplete and one that + lies about being complete. Do not let a fix pass collapse `Part.keys`. + +## What I could not evaluate + +- **The Details column, from the screenshots.** The capture fixture + (`capture.spec.ts:293-304`) seeds action names that are not in the app's + vocabulary — `discord.role.added`, `payout.paid`, `acl.member.added`, + `contact.sync_failed`, none of which are keys in `PARTS` — so every seeded row + falls through to the generic `key=value` fallback. The wall of identical + `+ roleId=…, characterId=…` in the wide shot is that artifact, not the + column's real behaviour. What the column looks like at 62 rows of *real* + actions is unknown and should be re-shot with names drawn from `PARTS` before + anyone judges it. I have deliberately filed nothing about Details density. +- **The pager itself.** 62 rows is under the 100-row page size, so no pager + renders in either shot. Findings 1 and 5 are reasoned from source; I have not + seen the control on screen at either viewport. (The known duplicate-pager item + is out of scope per the brief and I have not spent a finding on it.) +- **Anything measured in a browser.** No dev server or seeded database was + started for this pass, and there is no jsdom in this project, so every pixel + figure here is arithmetic over the stylesheet with its inputs stated — + specifically finding 2's 200%-zoom chrome estimate and finding 4's 8-character + column-width claim. Both should be confirmed with Playwright before the fix + is sized. Finding 3's `useFormStatus` behaviour follows from React's contract + (status is reported only for React-managed submissions) and finding 1's from + the repo's own recorded measurement, but neither was re-run here. +- **Real screen-reader output.** Finding 7's double-announcement follows from + the accessible name/description computation and NVDA/JAWS defaults; it was not + verified against a running screen reader, and VoiceOver's `title` handling + differs. + +## Contested — settled taste I think is worth one challenge + +Nothing. The settled-taste list holds up on this surface: gold is correctly +rationed (the `FILTER` button explicitly refuses it, with the reason written at +`page.tsx:525-526`), the two-family split does real work here, there are no +cards, and the near-zero radii are consistent. I have no challenge to file. + +## Recommended actions + +1. **[P1] `$impeccable harden`** — announce the soft page change (finding 1) and + fix the Filter button's dead in-flight state and bfcache latch (finding 3). +2. **[P1] `$impeccable adapt`** — give the audit scroller a chrome-subtracting + height cap so 200% zoom does not strand the region below the fold (finding 2). +3. **[P1] `$impeccable layout`** — move the date to a group row and render + `HH:MM:SS` at every width, then reclaim the pinned column's width for Details + (finding 4). +4. **[P2] `$impeccable harden`** — trim `action`, and order by the column the + page shows (findings 5, 6). +5. **[P2] `$impeccable clarify`** — drop the doubled `title`, separate the + region and caption names, cut the lede, align the Action hint with its + datalist (findings 7, 8, 9, 10). +6. **[P3] `$impeccable optimize`** — `pretty={false}` on the audit `Json` + (finding 11). +7. **[P2] `$impeccable polish`** — final pass once the above land. diff --git a/docs/design-sweep-2/reports/audit-critique.md b/docs/design-sweep-2/reports/audit-critique.md new file mode 100644 index 00000000..ffb0a745 --- /dev/null +++ b/docs/design-sweep-2/reports/audit-critique.md @@ -0,0 +1,340 @@ +# `/admin/audit` — critique + +Register: product. Shots read before source: `10-audit-full.wide.png`, +`10-audit-full.narrow.png`, `09-audit-empty.wide.png`, `09-audit-empty.narrow.png`. + +## What I saw before I opened anything + +**Wide, 62 rows.** A title, a one-line explanation under it, a three-field filter +row, a rule reading `62 ENTRIES · as of 11:57 UTC`, and a five-column table: +At (UTC), Actor, Action, Target, Details. Thirteen rows, then the table's bottom +border, then void to the bottom of the frame. The count says 62. Thirteen are +drawn. Every visible row begins `2026-08-08` — the same eleven characters, thirteen +times, in the widest-looking column on the page. Every Details cell begins with a +`+`. Six of the thirteen Details cells read the identical string +`roleId=1284410981234567890, characterId=90000006`. The Target column mixes +person names, truncated UUIDs and a bare `90000007`. One action, `contact.sync_failed`, +is orange; everything else is the same weight and the same ink. The timestamps +ascend down the page — 17:31 at the top, 23:11 at the bottom — under a subtitle +that says "newest first". + +**Narrow, 390px.** The At column becomes `42h ago` / `41h ago` / `41h ago` / `41h ago` +/ `40h ago` — fourteen rows carrying seven distinct values, two and three at a time. +The table is cut off mid-Target. Details is not on screen at all. + +**Empty, wide.** The whole page occupies the top 520px of a 900px frame. A five-column +header row sits over one sentence, `Nothing has happened yet.`, and ~380px of void +below it. + +## Timing the promise + +PRODUCT.md's claim is that an admin answers *"why is this person's role wrong?"* in +under a minute. Traced against the 62-row shot: + +**The path when the fork is taken correctly.** Read the head (3s). Type the member's +name into **Target**, press Filter (10s). Scan the Action column for `tier.changed` +or `discord.role_changed` (10s). Read that row's Details peek — `Member → Alumni, +alliance affiliation` (5s). **≈30 seconds.** The design does deliver, and the +`summarizeDetails` line is why: it puts the transition *and* the cause on one line +instead of making the admin open a payload. That works. + +**The path most admins will take.** Actor is the first field, the first tab stop, and +"who did it" is the natural reading of "who changed this person's role". Type the name +there and you get that member's *self-service* history — the handful of things they did +to their own account — and none of the role changes, because every tier change and +every derole is written with `system` or an admin as actor and the member as target. +In the fixture that returns a non-empty set: `character.linked`, `token.invalidated`, +`payout.paid`. **≈20 seconds to a confident wrong answer**, with nothing on screen +saying the column was the wrong one. + +The page knows about this asymmetry. It is documented at length at `page.tsx:461-470` +and there is a nudge for it — but the nudge lives at `page.tsx:425-434`, inside the +`rows.length === 0` branch. It fires only on the harmless failure. The dangerous one, +a plausible partial result, is silent. + +--- + +## Findings + +### 1. The actor/target trap warns only when it does no damage + +**Severity:** Serious +**Where:** `src/app/admin/audit/page.tsx:416-435` (the nudge's branch), against +`page.tsx:471-485` (Actor is the first field) + +**Cost:** An admin investigating why Rifter Pilot lost their tier types the name into +the first field, gets six real rows back, sees no tier change among them, and concludes +the log has nothing — while the row explaining the derole sits one column over. The +one failure mode this page exists to prevent is answering the question wrongly while +looking right, and the guard against it is wired to the one case where the admin +already knows something is off. + +**Fix:** Hoist the asymmetry note out of the empty branch. When `actor` is set and +`target` is not, show it above the table on *every* result — including a full one: +"Showing what {name} did. Most entries about a member are on Target. [Search {name} +as a target]". The link and the copy already exist at `page.tsx:429-432`; the change is +the condition it hangs off. Cheaper alternative if that reads as too loud on the +correct path: on a name that resolves in *both* columns, say so in the rule aside +next to the count. + +**Principle:** Error prevention over error recovery — the recovery only exists for the +error that recovers itself. + +--- + +### 2. Eight of sixty-two rows at first paint, and no cue that the rest exist + +**Severity:** Serious +**Where:** `src/app/globals.css:1792-1815` (`.scroller--tall` at 80svh), +`src/app/admin/audit/page.tsx:563` (`<Scroller label="Audit entries" tall>`) + +The table region starts at y≈426 in the wide shot. `.scroller--tall` caps it at +80svh — 720px at a 900px viewport — so it ends at y≈1140, **240px below the fold**. +At 53px per row plus a 42px head, that is 8 rows visible on a 1440×900 screen while +the rule above says 62. The remaining 54 are inside a nested scrollbar on a page that +itself has only 343px of scroll, all of it dead: scrolling the page reveals the +region's bottom border and padding, not rows. + +There is no affordance for the vertical overflow. `.scroller-fade--start` / +`--end` are the only fades (`globals.css:1108-1123`), both horizontal, and +`scroller.tsx`'s `atStart`/`atEnd` read `scrollLeft` only — so a region that +overflows vertically and fits horizontally hides both. On top of that, +`globals.css:1132-1134` suppresses the start fade entirely for `.log--sticky-col`, +which this table carries. The one remaining cue points sideways. + +**Cost:** An admin scrolls the page looking for older entries, the page bottoms out +after a third of a screen, and they conclude they are at the end of the log. On macOS +and iOS, where overlay scrollbars draw nothing at rest, there is no pixel on screen +that says otherwise. + +**Fix:** `globals.css:1842-1845` already solves this for `/admin/accounts` — +`max-height: min(80svh, max(18rem, 100svh - 29rem))`, so the region ends *inside* the +viewport. The comment at `globals.css:1817-1819` declines to share it with +`/admin/audit` because that page's "region sits under far less chrome". That premise +is measurably wrong: the accounts figure the comment quotes is 426px, and this page's +region top measures y≈426 in the wide shot. The two are the same number. Apply the same +subtraction (`.scroller--tall:has(.log--audit)`, or widen the existing selector) and +re-measure. Separately: give `Scroller` a vertical end-fade driven off `scrollTop`, +since it already measures `scrollHeight`/`clientHeight` at `scroller.tsx:52`. + +**Principle:** Visibility of system status — a count that says 62 and a viewport that +shows 8 disagree, and the interface picks neither. + +--- + +### 3. On a phone the answer is 424px to the right of where you are looking + +**Severity:** Serious +**Where:** `src/app/globals.css:5242-5275` (narrow column widths), and the Details cell +at `src/app/admin/audit/page.tsx:675-689` + +At 390px the four sized columns total 26.5rem = 424px against a ~358px region, with +column 1 pinned at 5rem. To reach Details — which is where the transition and the +cause live, i.e. the answer to "why" — you scroll the region right past At, Actor, +Action and Target, and it opens into 7.5rem (120px). `Member → Alumni, alliance +affiliation` does not survive 120px with `text-overflow: ellipsis` +(`globals.css:2124-2140`), so the phone reading of the answer column is a truncated +fragment plus a `+` to open the raw JSON payload. + +The narrow shot confirms it: Details is not merely small, it is entirely absent from +the initial view, and nothing on screen names it. + +**Cost:** The 1am phone check — the exact scenario this surface is read in — cannot +answer the question the page is for. The admin either gives up or opens 62 raw JSON +payloads one at a time. + +**Fix:** Below 40rem, stop treating this as a five-column table. Promote the Details +summary to a second line under the Actor/Action pair inside the row (a `colspan` line, +or a stacked cell), so the answer and the action it belongs to are read together +without any horizontal scroll. Target and the absolute stamp can stay behind the +scroll — they are identifiers, not answers. If a stacked layout is too large a change, +the narrower version is to reorder the narrow column set so Details follows Action. + +**Principle:** The primary content of a row should not be the part that requires the +most work to reach. + +--- + +### 4. Pattern 2 — every row restates the date, and on narrow the pinned column carries the least information on the page + +**Severity:** Moderate +**Where:** `src/app/admin/audit/page.tsx:35-37` (`stamp`), `page.tsx:617-624` (the At +cell), `src/app/_components/format-ago.ts` (`elapsedShort`) + +`stamp()` prints the full `YYYY-MM-DD HH:MM:SS` on every row with no reference to the +set around it. In the wide shot all thirteen rows read `2026-08-08` — eleven of nineteen +characters, 58% of the cell, identical top to bottom, in a 12.25rem column. The page +already fixed the sibling case: `(UTC)` is said once in the header +(`page.tsx:583-586`) and restored per-row in `visually-hidden` text. The date is the +same fact and did not get the same treatment. + +Narrow is the worse half. `elapsedShort` is hour-granular from 90 minutes to 48 hours +and day-granular beyond, so a page of rows from one afternoon collapses to a handful of +repeated values — the shot shows fourteen rows carrying seven, in pairs and triples +(`41h ago` three times running). That column is **pinned**: it is the one cell that never +scrolls away, holding 5rem of permanently-reserved width, and on a log more than two days +old every row in it says `3d ago`. It cannot even order two rows against each other. + +**Cost:** An admin scanning for "the change that happened around the time the Discord +role vanished" reads the same eleven characters sixty-two times on desktop, and on a +phone gives up 5rem of a 358px region to a column that answers nothing. + +**Fix:** `crewNorms` in `src/app/account/page.tsx` is the pattern. Compute the set's +date span once. If every row on the page falls on one date, put that date in the rule +aside beside the count ("62 entries · 2026-08-08 · as of 11:57 UTC") and render only +`HH:MM:SS` in the cells; where the page spans days, render the date only on the row +where it changes. Keep the full instant in the `visually-hidden` span that is already +there (`page.tsx:623`) so the accessible name loses nothing — the same parity rule +"Cadence (UTC)" uses on `/admin/sync`. On narrow, either give `formatAgo` a minute +tier past 90 minutes for same-day rows, or drop the elapsed form and render `HH:MM` +with the date said once above. + +**Principle:** State the shared fact once; spend the column on what varies. + +--- + +### 5. The answer is set in the dimmest ink on the page, in the last column, with nothing directing the eye to it + +**Severity:** Moderate +**Where:** `src/app/globals.css:2124-2140` (`.json__peek`), against +`src/app/admin/audit/summarize.ts` + +`.json__peek` — the line that renders `Member → Alumni, alliance affiliation`, the +sentence this whole page is built to deliver — takes `--ink-faint`, **`#90877e`**, at +`--t-detail` and weight 400. That is 5.61:1 on `--void` (`#0a0a0a`), so it clears AA +comfortably; this is not an accessibility failure. It is a hierarchy inversion. The +same page sets the Actor name at `--ink` (**`#ece7de`**, 16.08:1) and the timestamp, +the least varying value on the row, in full-strength mono. The answer is quieter than +the identifiers that lead to it. + +Read the wide shot without reading any words: nothing pulls. Five columns, one weight, +one ink, sixty-two rows. The single exception is `contact.sync_failed` in +`--signal-warn` (**`#ff9f5f`**, 9.78:1) — and that one exception proves the mechanism +works, because it is the only thing on the page the eye finds unaided. + +**Cost:** A scanning admin's eye lands on timestamps and names, which are the same on +every row, instead of on the six words that differ and matter. + +**Fix:** Do not add colour — the gold ration and the `warn` reservation are both +correct as they stand. Move one step of ink instead: `.json__peek` to `--ink-dim` +(`#bab3a9`), and let the At cell drop to `--ink-faint`. That is a two-token swap that +inverts the current reading order without introducing a third signal. Consider also +giving Details more of the fixed-layout budget at the expense of column 1 once finding +4 shortens the stamp. + +**Principle:** Visual weight should track information value. + +--- + +### 6. The lede explains the table and restates itself, and asserts an ordering the query does not guarantee + +**Severity:** Minor +**Where:** `src/app/admin/audit/page.tsx:444-447` + +> "Every state change, append only, newest first. Nothing here can be edited or removed." + +Two problems in nineteen words. First, "append only" and "nothing here can be edited or +removed" are the same statement twice — the second sentence is the first one's +definition. Second, "newest first" is asserted as a property of what is on screen, and +the query orders by `id` (`src/services/audit.ts:622`, `orderBy(desc(auditLog.id))`) +while the column the admin reads is `at`. Those agree for as long as rows are inserted +in clock order and no further. + +The wide shot is what disagreement looks like: timestamps ascending, 17:31 to 23:11, +under copy promising the opposite. **That specific instance is a fixture artifact** — +`capture.spec.ts.txt:311` seeds row *i* at `now - i * 1_700_000`, so serial id ascends +as `at` descends, and `id desc` yields `at` ascending. It is not evidence that the +shipped page misorders. It is evidence that the page has no way to notice when it does, +and that the copy would keep making the claim regardless. + +This is also the brief's explanatory-subtitle smell: a caption teaching the reader +what the table is. + +**Fix:** Cut to one clause — "Append only. Nothing here can be edited or removed." — +and let the ordering be shown rather than promised (finding 4's date grouping does +that). If the ordering claim is worth keeping, order the query by `at desc, id desc` +so the claim and the sort key are about the same column. + +**Principle:** Every word earns its place; do not promise in copy what the system does +not enforce. + +--- + +### 7. "Why is this role wrong?" needs two queries because the action filter is one prefix + +**Severity:** Minor +**Where:** `src/app/admin/audit/page.tsx:486-507` (the action field), +`src/services/audit.ts:603-607` (single `LIKE` prefix) + +Role state lives under two namespaces: `tier.*` (the app's own tier) and `discord.*` +(the role that actually appears in the client). The filter takes one prefix, and the +datalist offers exactly those namespaces as separate options — so the admin runs the +query twice and holds the two result sets in their head, or filters by target alone and +scans both out of a mixed list. + +**Cost:** The most common investigation on this page is the one the filter cannot +express in a single pass. + +**Fix:** Cheapest version: leave the field alone and put the two queries one press +apart — when a target filter is set and no action filter is, offer two links in the +rule aside, "tier changes" and "Discord roles", each setting `action` and keeping the +target. That reuses `filterHref` and costs no query semantics. + +--- + +## What is genuinely good and should survive + +- **`summarizeDetails` and its `Part` vocabulary** (`summarize.ts`). The line + `Member → Alumni, alliance affiliation` is the answer to the page's question in six + words, and the `+N more` accounting means the summary never quietly drops a key. + Making the summary *honest about being partial* rather than complete is the right + call and it is rare. Do not let any of the fixes above shorten this line. +- **`(UTC)` said once in the header, restored per row in `visually-hidden`** + (`page.tsx:583-586`, `617-624`). This is the exact fix finding 4 asks for, already + built and already correct. It is the model, not a target. +- **`--signal-warn` on `_failed` actions, with the word `failed` carrying the same + signal in text** (`isFailureAction`, `globals.css:4366-4368`). The only thing on the + page the eye finds unaided, and colour is not the only channel. +- **The count label's `older` qualifier and the past-the-end exit** + (`page.tsx:379-389`, `407-415`). "No older entries" plus a link back is a genuinely + well-handled dead end, and the fact that `hasCursor` is judged in exactly one place + so the heading and the pager cannot disagree is careful work. +- **Actor / Action / Target all clickable as filters, and the target name linking to + the *name* rather than to that row's raw id** (`TargetCell`'s docblock, + `page.tsx:136-141`). That one decision is what keeps a person's history from + splintering across a uuid, a character id and a snowflake. +- **The unconditionally-mounted ambiguity `Notice`** (`page.tsx:550-552`). Correct + live-region handling, and "actor X matches 2 accounts" is precisely the warning that + stops this page answering wrongly while looking right — which is why finding 1 asks + for the same treatment for the column asymmetry. + +## What I could not evaluate + +- **The pager, at any real page size.** The fixture is 62 rows against an + `AUDIT_PAGE_SIZE` of 100, so `hasOlder` is false and `hasLatest` is false — neither + pager renders in either shot. I could not see the duplicate-pager problem, the + `100+ entries` heading, or what 100 rows of tab stops feels like. The known-open + entry stands; I have nothing to add to it. +- **Interaction states.** Static shots only, no dev server. Hover, `:focus-visible` + rings on the ~250 links a full page carries, the `Scroller` end-fade actually + appearing, and whether opening a `<details>` mid-table shifts the rows below it are + all unverified. +- **The real action vocabulary at density.** The fixture seeds `discord.role.added`, + `acl.member.added`, `contact.sync_failed` and `payout.paid`, none of which exist in + `PARTS` or in `NAMESPACE_TARGET_KIND` — so most Details cells in the shot fall to the + generic `key=value` fallback and repeat `roleId=…, characterId=…` verbatim. That + repetition is a seed artifact and I have not counted it against the page. What the + Details column looks like across 100 rows of the *real* vocabulary, where most rows + hit a declared `Part`, I could not see. +- **Contrast of `.json__peek` against the actual row background.** I measured against + `--void` `#0a0a0a`; if rows paint on a lighter surface token the 5.61:1 figure moves + slightly. It does not move far enough to change the finding, which is about hierarchy + and not about the floor. + +## Contested — settled taste I think is wrong + +Nothing. Every settled-taste item this surface touches — the tight ramp below `--t-h2`, +the two-family split, no cards, gold rationed to one primary action (the Filter button +correctly declines it, `page.tsx:525-527`) — reads as right on this page, and the +`.st--ok`-style restraint that keeps `--signal-warn` reserved for the one row that +failed is why finding 5 asks for an ink swap rather than a colour. diff --git a/docs/design-sweep-2/reports/boundaries-audit.md b/docs/design-sweep-2/reports/boundaries-audit.md new file mode 100644 index 00000000..a4a56c16 --- /dev/null +++ b/docs/design-sweep-2/reports/boundaries-audit.md @@ -0,0 +1,412 @@ +# The boundaries — audit (a11y / performance / responsive) + +`$impeccable audit` · register: **PRODUCT** · surfaces: `src/app/error.tsx`, +`src/app/not-found.tsx`, `src/app/payouts/[id]/not-found.tsx` + +Shots read before source: `13-error-boundary.{wide,narrow}.png`, +`02-not-found-root.{wide,narrow}.png`, `08-payout-not-found.{wide,narrow}.png`. + +Setup note: `node .agents/skills/impeccable/scripts/load-context.mjs` does not +exist in this worktree (`MODULE_NOT_FOUND`). Context gate satisfied by reading +`PRODUCT.md`, `DESIGN.md` and `BRIEF.md` directly. Register taken from the block +assignment, per routing rule 1 in the skill. + +## What I see, before explaining it + +Three pages built from the same four parts: the shell bar, an `h1` wearing a +gold outline, a lede, and a button row. The two 404s are a heading, two lines of +prose and one gold button in the top eighth of the screen; below that, nothing — +640px of empty ground at 1440×900. The error boundary adds a red-bordered +instruction box and a `WHAT TO SEND` record, and still ends 340px above the fold. + +The first thing I noticed, before reading a line of source: on the error +boundary the reference number **is not in the sentence that asks for it**. The +box reads + +> `! Try again. If it keeps happening, tell an admin what you were 4292868890 .` +> ` doing and quote reference` + +The digest sits at the top right of the box with a stranded full stop beside it, +and the words "quote reference" end four lines lower with nothing after them. It +is the same at 390px (`13-error-boundary.narrow.png`) and I reproduced it at +320px. The second thing: on all three surfaces the gold focus ring around the +`h1` is the widest and loudest element on the page — a 915px box drawn around a +260px word, wider than any content the page holds. + +--- + +## Health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 2 | A successful retry drops focus to `<body>` with no announcement; the failing path is the only one handled | +| 2 | Performance | 3 | `ui.tsx` has no client boundary, so the whole module crosses into the client bundle via `error.tsx` | +| 3 | Responsive | 3 | No overflow at 320px or 200% zoom; but `.notice`'s flex row reorders content at every width | +| 4 | Theming | 4 | Every colour is a token; every pair I measured clears AA | +| 5 | Anti-patterns | 4 | No AI tells. Deadpan, ruled, authored | +| **Total** | | **16/20** | Good — accessibility is the weak dimension | + +**Anti-patterns verdict: pass.** No gradient text, no glass, no hero metric, no +card grid, no side stripes (`.notice`'s comment names the ban by hand). Nothing +here would make anyone say "AI made that." + +**Contrast: no failures found.** Measured in rendered sRGB, not OKLCH: + +| Pair | Rendered | Ratio | +|---|---|---| +| `--ink` on `--void` (lede, notice text) | `#ece7de` on `#0a0a0a` | 16.08 | +| `--ink` on the `notice--bad` ground | `#ece7de` on `#261313` | 14.39 | +| `--signal-bad` glyph/border on that ground | `#f05751` on `#261313` | 5.22 | +| `--ink-dim` on `--void` (escalation body) | `#bab3a9` on `#0a0a0a` | 9.53 | +| `--ink-faint` on `--void` (`RuleHead`) | `#90877e` on `#0a0a0a` | 5.61 | +| `--rule-strong` `.btn` border on `--void` | `#787370` on `#0a0a0a` | 4.23 | +| `--void` on `--gold` (`.btn--primary`) | `#0a0a0a` on `#f1c035` | 11.63 | + +The `notice--bad` ground is `color-mix(… 12%, transparent)` composited over +`--void`; it renders `#261313`, not the `#5c1f1d` a linear-space calculation +gives. Both buttons are `min-height: 2.25rem` = 36px, clearing 2.5.8. + +--- + +## Findings + +### 1. The reference is torn out of the sentence that asks for it — `.notice` is a flex row and the `<code>` becomes its own flex item + +- **Severity:** Serious +- **Where:** `src/app/globals.css:3429` (`.notice { display: flex; gap: var(--s-3) }`), rendering `src/app/error.tsx:197-214` +- **Observation:** `.notice` is `display: flex` with no `flex-wrap`. Per the + flexbox spec, contiguous runs of text become anonymous flex items, so + `Notice`'s children resolve to **three** items, not one paragraph: the text + before the `<code>`, the `<code class="mono">` element, and the "." after it. + Measured in Chromium against the real stylesheet at 320 / 390 / 1440: the + `<code>` box lands at x=208, x=278 and x=500 respectively while the notice + starts at x=16, x=16 and x=120 — the digest is laid out to the *right* of the + prose at every viewport, with a 12px `gap` on both sides and the orphaned full + stop after it. Two independent confirmations that this is live and not a probe + artifact: it is visible in `13-error-boundary.wide.png` and + `13-error-boundary.narrow.png`. +- **Cost:** A member on their worst day is told to "quote reference", reads to + the end of that sentence, and finds nothing there — the number is four lines + up and across the box, reading as a separate stray value. This is precisely + the failure the merge in `e2e/error-boundary.spec.ts:239` was written to fix + ("It used to be a separate line below the alert with no instruction attached + to it"). The DOM merge landed; the render undid it. The spec asserts + `toContainText` and `toBeVisible` on `code.mono`, both of which pass while the + digest sits anywhere on the page, so nothing caught it. +- **Fix:** `.notice` should not make its message a flex container. Move the + glyph and the message into a two-item flex row with the message in its own + block — e.g. keep `.notice` as the flex row, and have `Notice` wrap + `{children}` in a `<span>`/`<div>` so the whole message is one flex item and + inline content flows normally inside it. That is one element in `ui.tsx:321` + plus a selector in the CSS, and it fixes every `Notice` in the app that ever + carries inline markup, not just this one. +- **Principle:** WCAG 1.3.2 Meaningful Sequence (the visual order no longer + matches the reading order the DOM asserts). Also the brief's own rule that a + reference must live inside the instruction that names it. + +### 2. A retry that *succeeds* drops focus to `<body>` and announces nothing + +- **Severity:** Serious +- **Where:** `src/app/error.tsx:295-306` +- **Observation:** `error.tsx:280-294` measures and documents what a **failed** + retry does in detail — `reset()` remounts the boundary, `FocusHeading` re-runs, + the `h1` is re-announced — and `e2e/error-boundary.spec.ts:96-140` pins exactly + that path. The successful path is not addressed anywhere in the file, the + comment, or the spec. On success the boundary subtree is replaced by the real + segment; the button that was pressed unmounts; no page in `src/app/` renders a + `FocusHeading`, so focus falls to `<body>` and there is no `<title>` change to + announce (`error.tsx`'s hoisted title is removed, and the segment's own + metadata was already resolved). +- **Cost:** A screen-reader user presses "Try again", the retry works, and they + hear nothing at all. Their next Tab restarts at "Skip to content" on a page + they were never told they arrived at — the exact stranding + `focus-heading.tsx:14-18` was written to prevent, reached from the one + direction nobody checked. The failure case is handled and the success case is + not, which is the wrong way round: failure repeats, success is the state you + want the user to notice. +- **Fix:** The boundary cannot reach into the segment it hands off to, so the + honest options are (a) move focus to `#main` before calling `reset()` inside + the transition, so the landing page's own main region has focus rather than + `<body>`, or (b) render a `role="status"` line that says the retry succeeded + before the unmount. (a) is smaller and matches what the skip link would have + done. Either way, add the success path to `e2e/error-boundary.spec.ts` — the + helper already restores the table in `finally`, so a retry inside the restored + state is a two-line addition. +- **Principle:** WCAG 4.1.3 Status Messages; 2.4.3 Focus Order. + +### 3. The escalation record is missing the two fields that would let an admin act on it, and rewrites the one it has + +- **Severity:** Serious +- **Where:** `src/app/error.tsx:257-263`, `src/app/_components/utc-time.tsx:2` +- **Observation:** The block's own comment (`error.tsx:216-224`) states its + purpose precisely: `/admin/audit` "filters on actor, action, target and + before, and has no free-text search at all… What does narrow that log is the + route and a time." Three things work against that: + 1. **`seen` carries no date.** `utcHhmm` returns `HH:MM` only. An admin + handed `11:57 UTC` and filtering `/admin/audit` on `before` — a timestamp — + has to guess the day. A member who reports the fault the next morning, or + any incident that straddles 00:00 UTC, hands over an ambiguous value. + 2. **`page` drops the query string.** `usePathname()` returns the path without + search params. `/admin/audit` is entirely query-driven + (`admin/audit/page.tsx:246-259` reads `searchParams`), so a throw while + filtering records `page /admin/audit` and discards the actor/action/target/ + before combination that reproduces it — on the one page whose failures are + most likely to *be* about the query. + 3. **`seen` is rewritten by a failed retry.** `reset()` remounts (the file + measures this at line 282), so `seenAt` resets to `null`, prints `—`, and + refills with the *retry's* time. The first-occurrence time — the one the + admin would bracket the search on — is gone, silently, and the member has + no way to know the number changed. +- **Cost:** An admin receives a copied record that names a route without its + filter, a time without a date, and a time that may be minutes after the event. + The block exists to stop this escalation dead-ending, and in the three most + likely escalation shapes it still does. +- **Fix:** Emit an ISO-8601 UTC instant for `seen` rather than `HH:MM` (`page` + and `ref` are already machine-shaped; this is the only field that is not). + Append `useSearchParams()` to the `page` line when non-empty. Capture `seenAt` + once per *error identity* rather than per mount — a `useRef` keyed on + `error.digest` survives the remount `reset()` performs; if the digest is + absent, hold the first value and do not overwrite it. +- **Principle:** None cited — this is a correctness gap in the surface's stated + job, not a rule violation. + +### 4. The `h1` focus ring renders, the source says it does not, and it is the loudest thing on all three pages + +- **Severity:** Moderate +- **Where:** `src/app/_components/focus-heading.tsx:57-58` +- **Observation:** The comment states: *"No focus ring appears: the global ring + is `:focus-visible` (globals.css), which a programmatic focus on a non-input + element does not match."* That is false in Chromium. I verified it directly — + a programmatic `.focus()` on an `h1[tabindex="-1"]` returns + `matches(":focus-visible") === true` and paints the outline. All six + screenshots agree: a 2px gold ring with 2px offset around the `h1`, spanning + the full 912px `page--narrow` child cap (measured 120→1035 in + `13-error-boundary.wide.png`), on a heading whose text is ~260px wide. +- **Cost:** Two costs, and the second is why this is not just a stale comment. + Visually, the boldest, largest, gold element on every boundary is an outline + around something nobody can press, and it out-ranks both the red instruction + box and — on the two 404s — the actual gold primary button, so gold appears + twice on a page whose ration is one. Structurally, a future editor reading + that comment will conclude the ring is impossible and reason from there; the + comment is load-bearing and wrong. +- **Fix:** Correct the comment first — it is a factual claim about the browser + and it is wrong. Then decide deliberately: keeping the ring is defensible + (focus is where focus is), but it should be a ring the design chose, scoped to + the heading's text rather than to the 912px column. `h1:focus-visible { + outline-offset: 4px; width: fit-content }` on the `page__head` heading, or a + ring on `#main` instead. Do not suppress it. +- **Principle:** WCAG 2.4.7 Focus Visible (satisfied — do not "fix" by removing + it); DESIGN.md's gold ration is the part under strain. + +### 5. The one thing the page asks you to copy has no copy affordance and cannot be selected from the keyboard + +- **Severity:** Moderate +- **Where:** `src/app/error.tsx:257-263`, `src/app/globals.css:3359` +- **Observation:** `.escalation`'s comment explains the `<pre>` choice: *"the + point is that one drag selects all of it"* — a drag, which is a pointer + gesture. The `<pre>` is not focusable, does not scroll (it wraps), carries no + copy button, and `user-select: all` is deliberately declined. Chromium has no + caret-browsing mode, so a keyboard-only user has no mechanism to select this + text at all. +- **Cost:** A keyboard-only member, or anyone on a touch device where a precise + three-line drag inside a 286px box is fiddly, is told to send a record they + cannot pick up. The digest is at least also spoken in the notice sentence; the + route and the time exist only here. +- **Fix:** Add one `Copy` button beside the `RuleHead`'s `aside` slot — + `RuleHead` already takes an `aside` and it is empty here — writing the block's + text via `navigator.clipboard.writeText`, with a `role="status"` + confirmation. That serves pointer, keyboard and touch with one control and + costs nothing to the drag path that already works. +- **Principle:** WCAG 2.1.1 Keyboard (the operation the page instructs is + pointer-only). + +### 6. `aria-busy` on a `<button>` is not a busy mechanism + +- **Severity:** Moderate +- **Where:** `src/app/error.tsx:298` +- **Observation:** The decision not to set `disabled` is correct and well + argued (line 274-278: disabling moves focus to `<body>` mid-wait). But + `aria-busy` is defined for *regions whose contents are being updated*; on a + focused button it is not reliably announced by any major screen reader, and + ARIA's own guidance is that it should not be used to mark a control as + temporarily unavailable. The only real signal in flight is the label swapping + to "Trying…", which is an accessible-name change on the currently-focused + element — announced inconsistently and, on some combinations, not at all. + Nothing prevents a second press either, though `reset()` twice is harmless. +- **Cost:** A screen-reader user presses "Try again" against a slow round trip, + hears nothing, and presses again. The visual half of the state + (`globals.css .btn[aria-busy="true"]`) works; the announced half does not. +- **Fix:** Keep `aria-busy` for the styling hook it already drives, and add + `aria-disabled={retrying}` with an early return in the handler. `aria-disabled` + keeps focus exactly where the comment requires, is announced, and is the + attribute that actually means "not actionable right now". Optionally pair it + with a `role="status"` line so the wait itself is spoken. +- **Principle:** WCAG 4.1.2 Name, Role, Value. + +### 7. Two identical controls, and the safer one reads as the afterthought + +- **Severity:** Moderate +- **Where:** `src/app/error.tsx:265-313` — the `.btn-row` +- **Observation:** This is the brief's pattern 3 in miniature. Both controls are + plain `.btn`: same 36px height, same `--hull-hi` ground, same + `--rule-strong` border, same 11px mono uppercase, side by side, 8px apart. + Nothing directs the eye. Meanwhile the lede directly above warns that a + submitted action may already have taken effect — which makes "Try again" the + control with consequences and "Back to Operations" the one the warning points + toward. Reading order and left-first position both hand primacy to the riskier + one. +- **Cost:** A member who has just read "check whether it took effect before you + send it again" is offered, as the first and visually equal choice, the control + that sends it again. The page's own copy and its own layout disagree. +- **Fix:** Not gold on "Try again" — that is settled and correct, and I am not + reopening it. Differentiate downward instead: give "Try again" the existing + `.btn--quiet` grade so the escape reads as the default at rest, or reverse the + order so the escape comes first. Either is a one-token change and neither + spends the ration. +- **Principle:** PRODUCT.md's "state before action" — the page states a hazard + and then presents the hazardous control as the default. + +### 8. Pattern 1: 476px of content in a 912px column, and the page runs long instead of wide + +- **Severity:** Minor +- **Where:** whole surface (all three boundaries) +- **Observation:** `page--narrow` caps direct children at 912px + (`60rem - 2 * --s-5`). `.notice`, `.escalation` and `.page__lede` all set + their own tighter `--measure` (68ch), measured at 476px at 1440px wide. The + `h1` and `.page__head` take the full 912px, which is invisible until the focus + ring draws it (finding 4). Net: a 476px content ribbon inside a 912px column + inside a 1440px viewport, ending 340px above the fold on the error boundary + and 640px above it on the two 404s. +- **Cost:** Low, honestly — these are pages nobody should linger on, and a + narrow measure for prose is right. The cost is only that the ring exposes the + mismatch, which is why this ranks below finding 4 rather than beside it. +- **Fix:** Cap `.page__head` on these three surfaces at `--measure` too, so the + ring lands on a box the same width as the prose under it. Do not widen the + content to fill the column. +- **Principle:** None cited. + +### 9. The skip link is unreachable by forward Tab on all three boundaries + +- **Severity:** Minor +- **Where:** `src/app/_components/ui.tsx:104`, interacting with `src/app/_components/focus-heading.tsx:53-55` +- **Observation:** `FocusHeading` moves focus into `#main` on mount, past the + skip link, which is the very first focusable element. `focus-heading.tsx:43-48` + names this cost for hard navigations and accepts it. What the comment does not + say is that it is now permanent on these three surfaces: the skip link can + only be reached by Shift+Tab, backwards, which is not how anyone finds a skip + link. +- **Cost:** Near zero on the 404s, where `#main` holds one button and the skip + link would save two Tab presses. Filed because the mechanism is invisible: a + keyboard user who has learned that Tab-then-Enter reaches "Skip to content" + everywhere else in this app gets "Try again" here instead, on the one page + where pressing the wrong thing is warned about two lines above. +- **Fix:** No change recommended to the focus behaviour — it is the right trade. + Record the consequence in `focus-heading.tsx`'s comment beside the hard- + navigation cost it already states, so the next reader knows the skip link is + dead weight on these three routes and does not "fix" the focus move to revive + it. +- **Principle:** WCAG 2.4.1 Bypass Blocks is satisfied (the focus move bypasses + the block more directly than the link does). This is a consistency note, not + a violation. + +### 10. `error.tsx` pulls all of `ui.tsx` across the client boundary + +- **Severity:** Minor +- **Where:** `src/app/_components/ui.tsx:1` (no `"use client"`), imported by `src/app/error.tsx:13` +- **Observation:** `ui.tsx` is a 376-line shared module with no client + directive, exporting `SiteHeader`, `RuleHead`, `Status`, `Notice` and `Json`. + `error.tsx` is `"use client"` and imports three of them, so the module is + compiled into the client graph — including `Json` (the largest export, with + its `<details>` payload rendering) and `Status`, neither of which any boundary + uses. A root-level `error.tsx` wraps every route, so its chunk is part of + every page's client reference manifest. +- **Cost:** Some JS on every route for code no boundary renders. I am not + quoting a KB figure because I did not build — the mechanism is certain, the + magnitude is not. +- **Fix:** Measure first (`next build` and compare the shared chunk). If it is + material, split the three boundary-facing exports into their own module rather + than adding `"use client"` to `ui.tsx`, which would push `Json` and `Status` + into the client on every *server* page that uses them and make things worse. +- **Principle:** None cited. + +### 11. Adds to a known-open item: `.escalation`'s border is 1.62:1, so at zoom the block has no perceivable edge at all + +- **Severity:** Minor +- **Where:** `src/app/globals.css:3359-3372` +- **Observation:** I am not restating the known-open `1.00:1 ground` item. The + consequence it does not name: because the ground is identical to the page, the + block's *only* separation from the surrounding surface is its 1px `--rule` + border, and that measures **1.62:1** (`#373533` on `#0a0a0a`). One hairline at + 1.62:1 is the entire boundary of the region. +- **Cost:** At 200% zoom the border stays 1px in device terms while everything + around it doubles, and on a dimmed laptop screen at 1am — the stated reading + condition for this app — it is not visible. The member is told "copy this" and + cannot see where "this" starts or stops, so they copy three lines by eye and + may take the `RuleHead` or the button labels with them. +- **Fix:** Whichever way the known-open ground item is resolved, the border must + end up ≥3:1 against whatever ground it separates. Colour tokens are frozen for + this sweep, so the available move is swapping `--rule` for `--rule-strong` + (4.23:1 on `--void`) on this one rule — a token *use* change, not a retune. +- **Principle:** WCAG 1.4.11 Non-text Contrast (as the boundary of a region the + user is instructed to operate on). + +--- + +## What is genuinely good and should survive + +- **The three-way nav derivation.** `navFromPath` running `navFor` with weaker + evidence, rather than three literal arrays, is the strongest single piece of + architecture in these files. `02-not-found-root.wide.png` shows one nav item, + `08-payout-not-found.wide.png` shows two with OPERATIONS marked current, and + `13-error-boundary.wide.png` shows two — three different bars from one rule. + A fix pass must not hand-edit any of these lists. +- **`live={false}` on the notice.** The reasoning at `error.tsx:188-196` is + correct and subtle: `role="alert"` in the same commit as a focus move preempts + the heading announcement. Leave it off. +- **The lede.** "That's a fault on this end, not something you did. If you had + just submitted something, check whether it took effect before you send it + again." It assigns fault, refuses a claim it cannot support, and tells the + member the one thing that changes their next move. Do not touch this sentence. +- **The digest-absent branch** (`error.tsx:209-212`). A client-side throw with + no digest still gets an instruction that points somewhere, instead of the line + vanishing. +- **`—` for a pending `seen`** rather than omitting the row, so the button + underneath does not slide out from under the pointer. Finding 3 asks for the + value to be *stabler*, not for this to go. +- **Every colour is a token and every pair clears AA**, including the 5.22:1 + glyph most systems get wrong. Both hit targets are 36px. +- **No overflow at 320px** (`document.scrollWidth === 320`, measured) **or at + 200% zoom** (measured at 640px CSS width). `.escalation` wraps rather than + scrolling, `.btn-row` wraps, `.rule-head__label` takes `min-width: 0`. +- **Reduced motion.** Nothing on these three surfaces animates; the only + transition is a 140ms colour fade on `.btn`, and the global block at + `globals.css:294` collapses transition-duration as well as animation. +- **`payouts/[id]/not-found.tsx`'s hoisted `<title>`** and the measured + prefetch-staleness reasoning behind it. That comment is a model of the kind. + +## What I could not evaluate + +- **Real screen-reader behaviour.** Findings 2, 4 and 6 are reasoned from the + spec and from what Chromium exposes; I did not run NVDA, JAWS or VoiceOver. + Finding 6 in particular ("`aria-busy` is announced inconsistently") is a claim + about AT implementations, not one I measured here. +- **Firefox and WebKit.** The `:focus-visible`-on-programmatic-focus result in + finding 4 is Chromium, verified. Firefox is likely to differ, which would make + the ring browser-dependent — worth checking before designing around it either + way. +- **Bundle magnitude** (finding 10). No build was run; the mechanism is certain, + the size is not. +- **The dev-overlay badges** in the shots ("2 Issues" on the error boundary, + "1 Issue" on both 404s) are Next's dev indicator. I have no access to what + they report and did not run the app; if a fix pass has the dev server up, they + are worth one click. +- **`global-error.tsx`'s absence** is settled and reasoned in the file; I did + not attempt to falsify the claim that `RootLayout` has no request-time failure + path. + +## Contested — settled-taste items I think are wrong + +Nothing. Every settled item I touched (gold off "Try again", no `global-error`, +the tight ramp under `--t-h2`, `page--narrow` on all three, hairlines over +cards) survives contact with these three surfaces, and finding 7 is written to +respect the gold ration rather than to reopen it. diff --git a/docs/design-sweep-2/reports/boundaries-critique.md b/docs/design-sweep-2/reports/boundaries-critique.md new file mode 100644 index 00000000..b1f19992 --- /dev/null +++ b/docs/design-sweep-2/reports/boundaries-critique.md @@ -0,0 +1,347 @@ +# The boundaries — critique + +Register: PRODUCT. Surfaces: `src/app/error.tsx`, `src/app/not-found.tsx`, +`src/app/payouts/[id]/not-found.tsx`. Shots: `13-error-boundary.*`, +`02-not-found-root.*`, `08-payout-not-found.*`. + +## What I saw before I opened anything + +All three pages, wide and narrow, are framed by a thin gold rectangle around the +`<h1>` — a 2px gold line at a 2px offset, running the full content column. On +the two 404s that box is roughly 915px wide holding about 380px of text, so more +than half of the most emphatic shape on the page is empty. It is the first thing +the eye lands on, on every one of the six screenshots. + +On `13-error-boundary.wide.png` the red notice reads, line by line: + +> `! Try again. If it keeps happening, tell an admin what you were 4292868890 .` +> ` doing and quote reference` + +The reference number sits at the end of the *first* line, followed by an orphan +period, and the words that introduce it ("doing and quote reference") are on the +second. The narrow shot is worse: the digest is parked alone at the top-right of +the block with a gap of about 90px between it and the sentence, which then runs +four lines beneath it. I read the sentence twice before I understood it. + +Below that, a hairline labelled WHAT TO SEND runs to x≈1035, and the boxed +record it introduces stops at x≈665. The lede above stops at x≈712. Three +different right edges on six stacked blocks, with the longest line on the page +being a rule that heads a block 370px shorter than itself. + +Then two identical dark buttons, TRY AGAIN and BACK TO OPERATIONS, and 355px of +void below them. + +The two 404s are the same page twice, correctly: heading, two-line lede, one +solid gold button, and then roughly 625px of nothing. They agree with each other +completely. The error page is the one that does not look like the other two. + +--- + +## Findings + +### 1. Serious — the reference number is laid out outside the sentence that names it + +**Where:** `src/app/error.tsx:197-214`; mechanism at `src/app/globals.css:3429-3431`. + +`.notice` is `display: flex`. Every child of `Notice` becomes a flex item. The +digest branch passes three children into that row — the text run, a +`<code className="mono">`, and the trailing `"."` — so the sentence is item 2, +the digest is item 3, and the full stop is item 4. Item 2 is the only one that +wraps, so it becomes a two-line box and the digest is baseline-aligned beside its +*first* line. The rendered reading order is "…what you were **4292868890** . +doing and quote reference". + +This is not an obscure property of the component. `admin/accounts/page.tsx:298-300` +already reasons out loud about "`.notice`'s flex row" and what it does to a child +element, and all four multi-part notices on `/payouts/[id]` (e.g. +`payouts/[id]/page.tsx:358-363`) wrap their whole message in a single `<span>` so +it stays one flex item. `error.tsx` is the only caller in the app that puts an +inline element directly into the row, and it is the one page whose entire job is +to hand over a string correctly. + +Only the digest branch is affected; the no-digest fallback is pure text and +renders fine. The digest branch is the server-throw case, which is the common one. + +**Cost:** a member told to relay a ten-digit reference is shown that reference +detached from the words "quote reference", trailed by a stray period that reads +like part of the number. On a phone the two are 90px and one line apart. The +likeliest failure is the member quoting `4292868890.` or reading the sentence as +truncated and quoting nothing. + +**Fix:** wrap the notice body in a single `<span>`, exactly as +`payouts/[id]/page.tsx:359` does. One element, no CSS change, no change to the +copy. (If it is worth preventing structurally, `Notice` could wrap its own +children — but that would change layout at eleven other call sites and is a +bigger decision than this page needs.) + +**Principle:** the app's own established `Notice` convention; reading order must +match DOM order. + +### 2. Serious — an undesigned gold rectangle is the loudest element on all three boundaries, and the code states it does not exist + +**Where:** `src/app/_components/focus-heading.tsx:56-57`, rendered by +`src/app/globals.css:289-292`. Visible on all six shots. + +`FocusHeading` moves focus to the `h1` on mount. Its closing comment says: *"No +focus ring appears: the global ring is `:focus-visible`, which a programmatic +focus on a non-input element does not match."* The screenshots disagree. The box +is `2px solid var(--gold)` at `outline-offset: 2px` with a 1px radius — that is +`globals.css:289-292` character for character, and nothing else in the stylesheet +draws a gold outline on an `h1`. + +Evidence it is the ring and not something else: the box appears on exactly the +three pages that use `FocusHeading` and on no other shot in the set. +`03-payouts-empty.wide.png` renders the same `h1` element in the same +`.page__head` with no box. Chromium's programmatic-focus heuristic is what makes +`:focus-visible` match here; other engines may differ, which is precisely why +this cannot stay an accident. + +`confirm-notice.tsx:102` repeats the same claim, so the belief has already +propagated to a second component. + +**Cost:** on `/payouts/[id]` 404 there are now four gold things on a page with +three elements of content — the mark, the active OPERATIONS underline, this box, +and the primary button. Gold is the app's one emphasis ration +(`DESIGN.md:103`, `DESIGN.md:287` "one per view"), and the largest gold shape is +the one thing on the page that cannot be pressed. It outranks the button it is +supposed to be pointing at. On the error boundary it is the *only* gold on the +page, so the strongest emphasis the design system has is spent framing a +non-interactive heading and 535px of empty column. + +**Fix:** decide it, do not delete it. Suppressing the outline is off the table — +focus is never suppressed (`DESIGN.md:341`, PRODUCT.md Accessibility). The +cheapest honest version keeps the ring and stops it outlining the void: give the +focused heading a `width: fit-content` (or an inline-block inner span) so the box +hugs the words. Then correct both comments to say the ring appears and is +intended. If the team decides it should *not* appear, the fix is a +`:focus:not(:focus-visible)`-shaped opt-out on this one element, made explicitly +rather than assumed. + +**Principle:** `DESIGN.md:341` scopes the global ring to interactive controls; +gold rationed to one primary action plus the mark. + +### 3. Moderate — the escalation record is not copyable in the situation it exists for + +**Where:** `src/app/error.tsx:256-263`. + +The comment at `error.tsx:225-226` claims the block is *"selectable as a unit, so +'copy this' is one gesture rather than three."* That holds on a desktop, where a +triple-click takes the whole `<pre>`. It does not hold on the narrow viewport, +where selecting three lines of a `<pre>` is a long-press followed by dragging two +handles, and where the member escalating to an admin over Discord actually is. + +This app already owns the answer. `payouts/[id]/copy-amount-button.tsx` is a +fully-worked copy control — secure-context guard, `role="status"` feedback, +WCAG 2.5.3-safe naming — built, per its own docblock, because *"what actually +goes wrong today is transcribing a twelve-digit ISK figure by hand."* A ten-digit +digest a member is explicitly instructed to relay is the same problem with worse +stakes, and it is the one place in the app that does not get the control. + +**Cost:** the member types the digest by hand from a phone, or screenshots the +page and sends a picture an admin cannot paste into a filter. + +**Fix:** put a copy control on the `RuleHead`'s `aside` slot (which exists for +exactly this) writing the whole three-line block. Reuse the +`CopyAmountButton` shape rather than writing a second clipboard path. + +**Principle:** consistent affordances across the surface (product register). + +### 4. Moderate — the record omits the two things that would let an admin act on it + +**Where:** `src/app/error.tsx:131` and `src/app/error.tsx:259`. + +The block's own comment (`error.tsx:216-223`) argues correctly that a digest +alone matches none of `/admin/audit`'s columns and that what narrows the log is +*a route and a time*. It then ships neither at the fidelity that claim needs. + +- **`seen` has no date.** `utcHhmm` (`_components/utc-time.ts:2-4`) returns + `HH:MM` and nothing else. A member who hits this at 23:50 and messages an admin + the next morning has handed over "11:57 UTC" with no day attached, and + `/admin/audit`'s `before` filter is a point in time. The row that exists to + bracket a search cannot bracket one. +- **`page` drops the query string.** `usePathname()` returns the path only. On + `/admin/audit` — the surface whose entire state lives in its search params — + the record reports `/admin/audit` for a failure that happened on + `?actor=…&page=7`, discarding the exact state needed to reproduce it. + +**Cost:** the admin receives a well-formatted record and still cannot narrow the +log with it, which is the dead end this block was built to remove. + +**Fix:** render the full UTC date-time in `seen` (`YYYY-MM-DD HH:MM UTC`), and +build `page` from `usePathname()` plus `useSearchParams()`. Both are additive and +neither changes the block's height behaviour. + +### 5. Moderate — the escalation material is presented before the retry it is the fallback for + +**Where:** `src/app/error.tsx:197-313` (block order). + +The page reads: *try again* (notice) → **what to send an admin** → the Try again +button. The member is handed the escalation kit before they have been given the +control that might make escalation unnecessary. In the wide shot that is 253px +and a section heading between the instruction "Try again" and the button labelled +"Try again". + +The stated reason the block sits above the buttons (`error.tsx:236-243`) is that a +late-arriving `seen` value must not move the buttons under the pointer. Moving the +block *below* the button row dissolves that concern entirely rather than +mitigating it — nothing sits under the block to be shifted — so the reorder is +strictly better on the axis the current order was chosen for. + +**Cost:** every member who hits a transient fault reads a wall of escalation +procedure before finding out they could just press a button. + +**Fix:** order it `h1` → lede → notice → `.btn-row` → `RuleHead` + escalation. +That is also the ladder in its true sequence: what happened, what you can do, what +to send if that did not work. + +**Principle:** progressive disclosure; PRODUCT.md principle 2 (state before +action) is satisfied either way, but the *actions* should precede the escalation +apparatus. + +### 6. Moderate — the three boundaries disagree about how an escape link is drawn + +**Where:** across surfaces. `not-found.tsx:61`, `payouts/[id]/not-found.tsx:104`, +`error.tsx:310-312`. + +All three pages carry the same control: a plain `<a href>` doing a hard +navigation to a section index, as the way out of a dead end. Two of them draw it +`btn btn--primary` (gold ground). The third draws it `btn`. + +To be explicit: this is **not** the closed "Try again" item. Try again is +correctly plain, and the argument at `error.tsx:266-273` for demoting it is +sound — pressing it re-sends something the lede just warned may already have +landed. But that argument is about the *retry*, and the demotion silently applied +to the neighbour as well. "Back to operations" carries none of the risk the +comment names; it is the identical control that is gold on both 404s. + +The comment's premise — *"a boundary has no action it can recommend"* — is also +the one premise the other two boundaries reject. Both 404s recommend leaving, in +gold, and are right to. + +**Cost:** on the page with the most content, the most decisions, and the highest +stress, nothing directs the eye; on the two emptiest pages, something shouts. The +emphasis is inversely proportional to how hard the moment is. A member who has +just been told the app broke has to read two identical dark rectangles to work out +which is the safe one. + +**Fix:** `btn--primary` on the "Back to …" anchor in `error.tsx`, leaving Try +again plain. That gives the error page one primary action (the ration), matches +its two siblings, and makes the safe exit the one the eye finds first. + +**Principle:** consistent component vocabulary across screens (product register); +one primary action per view. + +### 7. Minor — a six-block page with three right edges, headed by a rule that overruns everything under it + +**Where:** whole surface (`13-error-boundary.wide.png`). + +Measured off the wide shot: the `h1`'s box and the WHAT TO SEND hairline reach +x≈1035; the lede stops at x≈712; the notice and the escalation record stop at +x≈665. The column starts at x=120. + +Two mechanisms, both worth naming because the fix differs: + +- `.rule-head` (`globals.css:894-923`) has no measure cap and its `::after` + hairline is `flex: 1`, so it runs to the full `page--narrow` child width of + 57rem while `.notice` and `.escalation` cap at `--measure`. On every other + surface a `RuleHead` introduces a full-width table and the two edges agree. + Here it introduces a 68ch block and overruns it by roughly 370px, so the + strongest horizontal line on the page points at empty space. +- `--measure` is `68ch`, and `ch` resolves against each element's own font-size. + `.page__lede` is body size, `.notice` and `.escalation` are `--t-data` + (0.875rem). The same token therefore produces two different pixel widths, + which is where the 47px step between the lede and the notice comes from. + +This is the honest form of pattern 1 on this surface, and it is worth stating +plainly what it is *not*: none of the three pages "runs long instead of wide." +They are short pages that are honestly short, and padding them out would be +worse. The error page's problem is raggedness, not emptiness. + +**Cost:** low, but it is the difference between the page reading as composed and +reading as a stack of defaults — on the surface most likely to be screenshotted +and sent to someone. + +**Fix:** cap the escalation section's `RuleHead` to the same measure as the block +it heads (a modifier, not a change to `.rule-head`). The `ch` step is a +system-level observation; flag it, do not chase it here. + +### 8. Minor — addition to the known-open `.escalation` ground + +**Where:** `src/app/globals.css:3359-3372`. + +Not restating the 1.00:1 ground, which is on the backlog — adding the half that +entry does not name. The block's border is `--rule`, which renders `#373533` +against a `#0a0a0a` ground: **1.62:1**. So both channels that were meant to make +this read as an inset field are absent at once — the ground is identical to the +page and the boundary is a hairline near the floor of visibility. The comment's +intent (`globals.css:3355-3357`, *"inset into `--hull` like a field, so it reads +as a value the system produced"*) does not land; it reads as loose mono text with +a faint outline around it. Worth pricing the two together when the ground item is +worked, since fixing the ground alone may be enough and fixing the border alone +is not. + +(1.4.11 is not strictly engaged — a `<pre>` is content, not a UI component — so +this is a design-intent finding, not a conformance one.) + +--- + +## Pattern verdicts, stated plainly + +- **Pattern 1 (unshaped field):** present in a modified form on `error.tsx` only, + and it is raggedness rather than the /account shape. The two 404s do **not** + have it: they are three-element pages that end after three elements, which is + correct. Do not let a fix pass inflate them. +- **Pattern 2 (total enumeration):** absent. There is no repetition to collapse on + any of the three. The digest appears twice on `error.tsx`, once as the thing a + sentence points at and once as a field in a record, and the reasoning at + `error.tsx:226-230` for that is right. +- **Pattern 3 (repeated identical controls at uniform weight):** present in + miniature on `error.tsx` — exactly two controls, drawn identically, with nothing + directing the eye. Finding 6 is the fix. Absent on both 404s, which have one + control each. +- **Explanatory subtitle under an H1:** the ledes are not that. Each names the two + plausible causes and what to do, and none of them restates its heading. + +## What is genuinely good and should survive + +- **The copy, all of it.** "That's a fault on this end, not something you did" + is the best sentence in the app. The three headings — "Something broke", + "Nothing at that address", "No such operation" — are deadpan, parallel, and + none of them says 404 or "Oops". Both 404 ledes name the two real causes (typo, + truncated link) instead of apologising. A fix pass should touch none of it. +- **`navFromPath` as a shared rule rather than three literal arrays** + (`nav-items.ts:143-151`). The claim "the boundary is the same rule under weaker + evidence" is true of the code rather than asserted by a comment, and the three + surfaces are visibly consistent because of it. +- **The escalation block existing at all.** The reasoning that a digest alone + matches none of `/admin/audit`'s columns is correct and unusual; the block is + the right idea. Findings 3 and 4 are about it not going far enough, not about + it being wrong. +- **`Notice live={false}` on the error boundary.** Letting the focused `h1` + announce the arrival instead of an assertive region preempting it is the right + call and is easy to "fix" backwards. +- **The two 404s' restraint.** One heading, one lede, one exit. Nothing to add. + +## What I could not evaluate + +- **Whether `:focus-visible` matches in Firefox and Safari.** I confirmed the ring + renders in the shots' Chromium capture and confirmed the CSS that draws it, but + the cross-engine behaviour of programmatic focus on a `tabindex="-1"` heading is + browser-dependent and I did not run other engines. It affects the framing of + finding 2, not its substance: undesigned either way. +- **The no-digest branch (`error.tsx:209-212`) in a shot.** Only the digest branch + was captured. Its layout should be clean (pure text, one flex item), but I did + not see it rendered. +- **The `seen: —` first-paint window.** I could not observe how long the `—` + is on screen before the effect runs; the shots are post-mount. +- **Whether a member can actually reach an admin.** "Tell an admin" and "What to + send" never say *where*. The app has a Discord integration; whether the corp's + escalation channel is obvious enough to leave unnamed is a call I do not have + the context to make. Noting it rather than filing it. + +## Contested + +Nothing. None of the settled taste or constraint items needs challenging from +these three surfaces. Finding 6 touches the "Try again" area and finding 2 touches +gold rationing, and both are deliberately arguing *within* those settled +positions rather than against them. diff --git a/docs/design-sweep-2/reports/login-audit.md b/docs/design-sweep-2/reports/login-audit.md new file mode 100644 index 00000000..4930f080 --- /dev/null +++ b/docs/design-sweep-2/reports/login-audit.md @@ -0,0 +1,401 @@ +# `/login` — technical audit + +`$impeccable audit` · register: **brand** · surface block 1 + +Screenshots read before source: `docs/design-sweep-2/shots/01-login.wide.png`, +`01-login.narrow.png`. Measurements below were taken in a headless Chromium +against a static harness that loaded the real `globals.css` and the real +markup of `src/app/login/page.tsx` at 1440×900, 390×844 and 320×800, including +the error state, which has no screenshot in the set. The harness and its copied +stylesheet were deleted; `git status` is clean of them. + +## What the screenshots show, before any explanation + +A 480px panel centred in 1440px of near-black, with two thin gold corner ticks +biting the top-left and bottom-right of its border. A 180px navy-and-gold seal, +the corp name at 64px in a heavy grotesque, a mono uppercase motto, then the +EVE SSO button — the only bright object on the page, and the only thing you can +press. A hairline rule, then a paragraph and six scope identifiers with a +sentence each, running 700px down and off the fold. A mono footer line. Behind +all of it, hangers and a lander in line art at about 7% opacity, mostly outside +the panel. Narrow is the same page with the panel widened to the gutters and +the seal cut to 132px; nothing reflows, nothing is lost. + +It reads as authored. The seal is real artwork, the ticks are a print-shop +reference nobody generates by accident, and the scope copy is written by +someone who opened `src/jobs/contacts.ts` to check what `write_contacts` +actually does. The failure mode here is not slop. It is that the top third is +composed and the bottom two thirds are a document. + +## Audit health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | Every text token clears AA with margin; the six-item permission list has no accessible name | +| 2 | Performance | 2 | 128.5 KB of images on the only unauthenticated page, and the 79.8 KB LCP seal is out-prioritised by a 2.2 KB vendor mark below it | +| 3 | Responsive Design | 3 | 320px reflows cleanly; the surface has no rule that uses any width above 640px | +| 4 | Theming | 3 | Tokens throughout, but one label-register member never took its tracking or colour, and `themeColor` is still the retired navy | +| 5 | Anti-Patterns | 4 | No tells. The one card is a sanctioned exception and the artwork is not decoration | +| **Total** | | **15/20** | **Good — address performance, then the error-state spacing** | + +## Anti-patterns verdict — pass + +No gradient text, no `backdrop-filter` anywhere on this surface, no hero metric, +no card grid, no side-stripe accents, no bounce easing. The single motion is a +620ms opacity-and-scale settle on the seal, on an exponential ease, collapsed +correctly by the global `prefers-reduced-motion` block (`globals.css:295`) — +`seal-settle` fills `both`, runs to its 100% frame at 0.01ms and holds +`opacity: 1`, so the mark is visible either way. + +The one card is `.launch__panel`, one of the two exceptions the brief names. The +transparency on it is inert rather than glassy — see the last Minor. + +The category-reflex check passes at both altitudes. "EVE corp auth" does not +predict a warm near-neutral ground at `#0a0a0a` with a single gold, and +"spacecraft tool that is not neon HUD" does not predict mission-documentation +print furniture. The seal is doing the work a generated page would try to get +from a gradient. + +--- + +## Findings + +### 1. The LCP element is oversized and explicitly out-prioritised by a 2.2 KB image below it + +- **Severity** — Serious. +- **Where** — `src/app/login/page.tsx:108-114` (seal, no `fetchPriority`), + `src/app/login/page.tsx:173` (`fetchPriority="high"` on the SSO button), + `src/app/globals.css:3515-3522` (`.launch::before`, the hero background). +- **Cost** — Every visitor to the product's only unauthenticated page waits on + 128.5 KB of images before the page settles, and the browser has been told the + wrong one matters: a member on mobile data sees the vendor button resolve + first and the brand mark, which is the thing the page is *for*, arrive after + it. +- This re-opens nothing. The known-open entry names "oversized images with no + `sizes`/priority" and "the 82 KB seal is the LCP element". Three things it + does not name, all measured here: + - `emblem.webp` is 512×512 and 79.8 KB, drawn at 180px wide (2.84×) and at + 132px below 640px (3.88×). Not "no priority": **negative** priority, + relative to its own page. The 2,248-byte `eve-sso-login-white-large.png` + below it carries `fetchPriority="high"`, so the one explicit hint on the + page ranks the vendor mark above the LCP candidate. + - `hero.webp` is a third image nobody has counted: 1000×573, 46.5 KB, painted + at `opacity: 0.07`. It is a CSS background, so the preload scanner cannot + see it — it starts downloading only after `globals.css` parses, which is + exactly when the seal is competing for bandwidth. At 390px it is drawn at + 585px wide and the full 1000px file is still fetched. + - Page total: 79.8 + 46.5 + 2.2 = **128.5 KB**, all of it before a member can + decide anything. +- **Fix** — Three independent, none of which touches the encode quality + principle 5 protects: (a) ship `emblem.webp` at 360×360 for the 180px box, or + add a 1×/2× `srcset` — a 3.88× source for a 132px phone box is not "the + retina cut"; (b) move `fetchPriority="high"` from the SSO button to the seal, + or add it to the seal and drop it from the button, which is 2 KB and will + arrive regardless; (c) give `hero.webp` a smaller cut, or accept it and + `<link rel="preload" as="image">` it so it is not discovered last. The + cheapest single win is (b): one attribute moved. +- **Principle** — LCP; and the audit dimension "missing optimization: images + without lazy loading, unoptimized assets". +- **Suggested command** — `$impeccable optimize`. + +### 2. The error notice has no space above it and 56px below it + +- **Severity** — Moderate. +- **Where** — `src/app/globals.css:3429` (`.notice` sets `margin-bottom` and no + `margin-top`), rendered from `src/app/login/page.tsx:138`. +- **Cost** — A member whose sign-in just failed lands on a page where the motto + is jammed against the top border of a red box, close enough to read as being + *inside* it, while the button floats 56px below — so the first impression + after a failure is a page that looks broken, which is the worst possible + moment for that impression. +- Measured, not eyeballed. At 1440×900 with the `oauth_failed` message: + `.launch__motto` bottom 383, `.notice` top 383 — **0px** — and `.notice` + bottom 453 to `.launch__action` top 509 — **56px**. Confirmed at 390×844. + `.notice` works everywhere else because its siblings there supply the space + above it; on `/login` it is the immediate next sibling of `.launch__motto`, + which sets `margin-top` only (`globals.css:3589-3597`). +- **Fix** — One rule scoped to this surface, not a change to `.notice`, which is + load-bearing on six other pages: `.launch__panel > .notice { margin-top: + var(--s-5); }`. Reduce `.launch__action`'s `--s-6` top margin to `--s-5` in + the same breath if you want the notice to sit evenly between the two. +- **Principle** — Proximity: spacing should encode what belongs to what, and 0px + above / 56px below says the notice belongs to the motto. +- **Suggested command** — `$impeccable layout`. + +### 3. The six-item permission list has no accessible name + +- **Severity** — Moderate. +- **Where** — `src/app/login/page.tsx:191` (the `<p>`) and `:198` (the `<dl>`). +- **Cost** — A screen-reader user deciding whether to grant six ESI scopes hits + "definition list, 12 items" with nothing saying it is the scope list, and + because "Scopes requested" is a `<p>`, heading navigation on this page offers + exactly one destination — the corp name — so there is no way to jump to the + thing the page exists to disclose. +- This does not re-open the closed `<dl>` inversion item. The inversion is right + and the reasons in `globals.css:3629-3640` hold; the `<dt>`→`<p>` move was + also right. What was never added is the association between the two. +- **Fix** — `id="scopes-head"` on the `<p>`, `aria-labelledby="scopes-head"` on + the `<dl>`. Two attributes, no visual change, no restructuring. If heading + navigation is also wanted, `<h2 className="launch__scopes-head">` gets both + and keeps the register's type — the register styles by class, not by element, + and `.rule-head__label` and `.facts dt` already prove members can be different + elements. +- **Principle** — WCAG 2.2 AA, 1.3.1 Info and Relationships. +- **Suggested command** — `$impeccable harden`. + +### 4. Nothing on this surface uses any width above 640px + +- **Severity** — Moderate. +- **Where** — whole surface. The only width rule that touches it is + `src/app/globals.css:5089` (`@media (max-width: 40rem)`), whose login block at + `:5142-5149` shrinks the seal and the panel padding. There is no `min-width` + query anywhere in `globals.css` that names a `.launch*` selector. +- **Cost** — On a 1440px monitor the page renders as a 480px column and runs + 1399px tall, so a member reading the permissions scrolls a second screen while + 960px of ground sits empty either side; and from 641px to 3840px the rendered + layout is byte-identical, so a large display buys nothing. +- **This is the brief's pattern 1, and it is real here** — but it is the + *disclosure* that is unshaped, not the panel. The composed part (seal, name, + motto, notice slot, button) is 560px tall and correct at 480px wide; a + centred sign-in should be a narrow column. Everything below the hairline rule + at `.launch__disclosure` is a reference document wearing the sign-in + composition's width. +- **Fix** — Scoped to the disclosure only, and only above the fold-free widths: + at `min-width: 60rem`, let `.launch__scopes` run two columns + (`columns: 2; column-gap: var(--s-6)`), or widen `.launch__panel` for that + block alone. Do not widen the panel globally — the seal/title/button + composition is right at 480px and would go slack. Whatever is chosen, the + panel must keep growing rather than being pinned, because `.launch` carries + `overflow: hidden` (see Minor 8). +- **Principle** — Responsive design: a layout with no rule above its smallest + breakpoint is not responsive, it is narrow. +- **Suggested command** — `$impeccable adapt`. + +### 5. `themeColor` is still the retired navy + +- **Severity** — Minor. +- **Where** — `src/app/layout.tsx:52` — `themeColor: "#080f1f"`. +- **Cost** — A member opening the sign-in page on an Android phone gets a navy + browser bar above a neutral near-black page: `#080f1f` against `--void`'s + `#0a0a0a` is a 1.04:1 luminance match but a clear hue mismatch, blue at ~3.9× + red against a token that is exactly neutral. It reads as a seam between the + browser and the app, on the first screen anyone sees. +- The token comment at `globals.css:9-38` is explicit that the palette moved off + "the blue-slate axis this used to sit on". This value did not move with it. +- **Fix** — `themeColor: "#0a0a0a"`. This is not a colour-token change — the + constraint the brief closes — it is a hard-coded literal that should have been + `--void` and is now a stale copy of a colour the system deleted. +- **Principle** — Theming: hard-coded colours drift when tokens move. +- **Suggested command** — `$impeccable polish`. + +### 6. `.launch__scopes-head` joined the label register and took none of its per-component properties + +- **Severity** — Minor. +- **Where** — `src/app/globals.css:383` (register membership) and `:3625-3627` + (its own rule, which sets `margin-top` and nothing else). +- **Cost** — Three mono uppercase lines stack on this one page, and the middle + one is set differently from its neighbours for no reason anyone chose: measured + letter-spacing is 1.54px on `TEST MOTTO LINE`, **0 (`normal`)** on + `SCOPES REQUESTED`, 1.2px on `TEST FOOTER LINE`. It reads as a tighter, + brighter kind of label, and the page has exactly one kind. +- The register comment at `globals.css:317-320` states the contract: the shared + block carries family, size, weight and case, and "each component below keeps + only what is actually its own: its colour, its spacing, and its tracking + token." This member declares one of the three. Checked against siblings: + `.rule-head__label` (`:901`), `.strip__head` (`:4147`) and `.facts dt` (`:966`) + each carry both a tracking token and `color: var(--ink-faint)`. +- **Fix** — Add `letter-spacing: var(--track-furniture)` — it is a section label, + which is what that token is named for, and it matches `.launch__motto` 40px + above it. The colour is a separate call: inheriting `--ink` makes this the + brightest label in the system, which may be intentional emphasis for a section + head; if it is, say so in the rule, because right now it is indistinguishable + from the same omission. +- **Principle** — Consistency; the project's own written label register. +- **Suggested command** — `$impeccable typeset`. + +### 7. A CSS comment documents behaviour the code deliberately does not have, and "fixing" the code to match it would reintroduce a hazard + +- **Severity** — Minor (as shipped) — but the reason it is filed is that the + *next* edit is the dangerous one. +- **Where** — `src/app/globals.css:3653-3658` versus + `src/app/login/page.tsx:16-25`. +- The CSS says: "A scope the description map does not recognise (a fork's own + `EVE_SSO_SCOPES` addition) **renders no `dd` at all** rather than a + placeholder — the identifier is still shown, honestly, as the one thing known + about it." +- The code does the opposite, on purpose. `describeScope`'s `default` returns a + real sentence (`page.tsx:56`) and the JSX renders a `<dd>` unconditionally + (`:202`). The docblock above it argues at length why: "a `<dt>` with no `<dd>` + is invalid there, and worse than invalid on a consent screen, since AT groups + a term with the next definition it finds and would read an undescribed scope + as meaning whatever the scope BELOW it means." +- **Cost** — Nobody is harmed today. The harm is latent and specific: a + maintainer reading the stylesheet finds a documented behaviour, sees the code + disagree, and "corrects" the code — putting an unknown scope on a consent + screen under its neighbour's description, which is the exact failure the TSX + comment spent nine lines preventing. +- **Fix** — Delete the two clauses from the CSS comment and point at + `describeScope`'s docblock. The rest of that comment (`--ink-dim` parity with + `.launch__disclosure-note`) is correct and should stay. +- **Principle** — A comment that contradicts its code is worse than no comment. +- **Suggested command** — `$impeccable polish`. + +### 8. An unbroken config value in the disclosure clips off-screen with no way to scroll to it + +- **Severity** — Minor. +- **Where** — `src/app/login/page.tsx:180` (`<code>{label}</code>`) and `:36` + (the same `contactLabel` interpolated into the `write_contacts` description), + against `src/app/globals.css:3512` (`.launch { overflow: hidden }`). +- **Cost** — A fork whose `STANDINGS_LABEL` is one unbroken word longer than + about 29 characters gets a sign-in page where part of the sentence naming + what authGD will write to their characters is off the right edge and + unreachable — not scrollable, clipped. +- Measured at 320×800 with a 47-character unbroken label: the `<code>` box + extends to x=423 in a 320px viewport, the panel grows to x=440, and + `document.documentElement.scrollWidth` stays **305** because `.launch`'s + `overflow: hidden` swallows it. No scrollbar appears. With a hyphenated label + of the same length it wraps correctly, so this needs a no-separator value. +- The fix pattern is already on this page, one element away: + `.launch__scopes dt` carries `overflow-wrap: anywhere` + (`globals.css:3646`) for precisely this reason, on the other config-shaped + string. The prose sites never got it. +- **Fix** — `overflow-wrap: anywhere` on `.launch__disclosure-note code`, and on + `.launch__scopes dd` for the interpolated copy of the same value. +- **Principle** — WCAG 2.2 AA, 1.4.10 Reflow (no loss of content or + functionality at 320px). +- **Suggested command** — `$impeccable harden`. + +### 9. The panel's transparency is inert + +- **Severity** — Minor. +- **Where** — `src/app/globals.css:3527` — + `background: color-mix(in oklab, var(--hull) 88%, transparent)`. +- **Cost** — None to a user; the cost is to the next reader, who sees a + deliberate-looking 88% and assumes the hero shows through the panel. It does + not, measurably: the hero sits at `opacity: 0.07`, so the panel's 12% + transparency passes through 0.84% of the artwork's own contrast. Worked + through on the brightest plausible line-art pixel (`#3a3a3a` over `#0a0a0a`): + the panel renders 20.1/255 with the art behind it versus 19.7/255 over bare + void. A difference of 0.4 in 255. +- **Fix** — Either make it `background: var(--hull)` and say the panel is + opaque, or raise the hero's opacity behind the panel so the transparency + earns itself. The first is the smaller change and matches what ships today. +- **Principle** — A declaration that has no observable effect is a claim the + code cannot back. +- **Suggested command** — `$impeccable polish`. + +--- + +## The brief's three patterns, answered directly + +**Pattern 1 (unshaped field) — present, in the disclosure only.** Filed as +finding 4. The panel is not the problem; the 700px reference document inside it +is. + +**Pattern 2 (total enumeration) — present, and I recommend leaving it.** Every +one of the six `<dt>`s begins `esi-` and ends `.v1`. That is two facts about +the whole set, stated six times each, in the faintest ink, occupying about a +third of each identifier's width — structurally the same shape as +`/admin/sync`'s "Cadence (UTC)" fix. The `visually-hidden` restoration +technique would even preserve the accessible name. I am filing it as an +observation rather than a defect because the visible saving is six characters +per row on a 24-character remainder, and the cost is real: this string exists so +a technical reader can check it against `EVE_SSO_SCOPES` verbatim and so it can +be copied. A consent screen is the one place a truncated identifier is worse +than a long one. Naming it here so the next reviewer does not spend a finding +rediscovering it. + +**Pattern 3 (repeated identical controls) — absent.** The page has exactly one +focusable element. Tab once and you are on the sign-in link; there is nothing to +direct the eye away from, and the button is the brightest object on the page by +a wide margin. This is the surface most clearly free of that pattern. + +**"An explanatory subtitle under an H1 is a smell" — does not apply.** The +motto is brand, not explanation, and it is omitted entirely when unset +(`page.tsx:120`) rather than rendered empty. + +--- + +## What is genuinely good and should survive + +- **Contrast has margin everywhere, and it was clearly measured.** On the + panel's rendered ground of `#141413`: `--ink` `#ece7de` at 14.97:1, `--ink-dim` + `#bab3a9` at 8.87:1, `--ink-faint` `#90877e` at 5.23:1 (the scope identifiers, + the smallest text on the page at 12px), `--gold` `#f1c035` at 10.82:1, + `--signal-bad` `#f05751` at 5.43:1. `--rule-strong` `#787370` clears 1.4.11 at + 3.94:1 as the panel border. Nothing is close to a floor. +- **The keyboard path is one press.** One focusable element, a never-suppressed + 2px gold focus ring at `globals.css:289`, and the ring outlines the button + rather than a 414px strip — the `inline-block` fix at `:3673` with its + reasoning intact. Do not let a fix re-block that element. +- **320px is clean.** No horizontal overflow (`scrollWidth` 305 against a 320 + viewport), the SSO button scales because the global `img { max-width: 100%; + height: auto }` reset at `:198-212` pairs both axes, the seal drops to 132px, + the panel keeps 273px of width and the longest scope identifier fits at 239px. + The comment on that reset names this exact button as the case it was written + for; it is still working. +- **The scope copy.** Six sentences grounded in the actual call sites, including + the one that admits `write_contacts` deletes. This is the best writing in the + app and the reason the page reads as authored. +- **`alt=""` on the seal, `alt="Log in with EVE Online"` on the button.** The + right call on both, for the reasons given in place. +- **Reduced motion is handled correctly and the reasoning is written down.** +- **The registration ticks.** 14px, one hairline, `--gold-dim` at 0.75 opacity, + used once in the system. They cost nothing and they are the single clearest + signal that a person made this page. + +## What I could not evaluate + +- **Real LCP timing.** I measured bytes, intrinsic dimensions, priority hints + and discovery mechanism from source and from a static harness. I did not run a + throttled trace against the real app, so the finding-1 numbers are byte counts + and hint ordering, not a measured LCP delta. `$impeccable optimize` should + confirm with a trace before choosing between the three fixes. +- **Actual screen-reader output.** Finding 3 is derived from the markup, not + from a NVDA/VoiceOver session. +- **The error state as it really renders.** There is no error shot in the set; + finding 2 is measured against a harness reproducing the same markup and the + same stylesheet, at both viewports. +- **Long brand names.** With `BRAND_NAME` at the fixture's "Test Corp" the + title is one line. `--t-display` clamps at a 2.5rem floor, so at 320px it + stays 40px and stops scaling: "Zoo Landers Flygd" measures two lines and 90px + there, and a 32-character name measures four lines and 179px. Nothing + overflows horizontally in any of the three, so this is robustness rather than + a defect, and I could not tell which names are real deployments. +- **Whether the hero artwork is legible at 7% on a low-quality panel.** It is + faintly visible on this display; on a dim laptop it may be nothing at all, + which would make finding 1's 46.5 KB a pure loss rather than a proportionate + one. + +## Contested — one settled-taste item + +**`.launch__foot`'s hard-coded `0.12em` should be `--track-label`.** The brief +lists `.launch__foot`'s sixth type size (`0.625rem`) as known-open, and the +comment at `globals.css:3694-3703` defends that size well: it is the quietest +line on the page and deliberately below the register's floor. That argument +covers the *size*. It does not cover the tracking, which is written as a raw +`0.12em` sitting exactly on `--track-label`'s value. If the one-off size is +worth a paragraph of defence, the tracking that is not a one-off is worth a +token. This is a one-word change and I am flagging it once rather than arguing +it: fold it into whatever pass touches the known-open size item. + +--- + +## Recommended actions + +1. **[Serious] `$impeccable optimize`** — move `fetchPriority="high"` from the + 2.2 KB SSO button to the 79.8 KB seal, cut `emblem.webp` to a 360px source + or give it a `srcset`, and decide whether the 46.5 KB `hero.webp` earns its + place at 7% opacity. +2. **[Moderate] `$impeccable layout`** — give the login notice a top margin; + 0px above and 56px below is the state a failed sign-in lands in. +3. **[Moderate] `$impeccable harden`** — name the scope `<dl>` via + `aria-labelledby`, and add `overflow-wrap: anywhere` to the two prose sites + that interpolate the contact label. +4. **[Moderate] `$impeccable adapt`** — let the disclosure use the width above + 60rem; leave the sign-in composition at 480px. +5. **[Minor] `$impeccable typeset`** — give `.launch__scopes-head` its tracking + token, and decide its colour deliberately. +6. **[Minor] `$impeccable polish`** — `themeColor` to `#0a0a0a`, delete the + contradicted clauses from the `.launch__scopes dd` comment, and resolve the + inert panel transparency. diff --git a/docs/design-sweep-2/reports/login-critique.md b/docs/design-sweep-2/reports/login-critique.md new file mode 100644 index 00000000..3003cad6 --- /dev/null +++ b/docs/design-sweep-2/reports/login-critique.md @@ -0,0 +1,295 @@ +# `/login` — critique + +**Register:** brand. **Command:** `$impeccable critique`. +**Shots:** `01-login.wide.png` (1440×900 viewport, 1440×1306 page), +`01-login.narrow.png` (390×844 viewport, 390×1314 page). + +## What I see, before explaining it + +A 480px column standing in the middle of a 1440px black field. Inside it, top to +bottom: a navy-and-gold mission patch, the corp name set enormous, a tiny tracked +mono line, a light-grey vendor button, a hairline, a paragraph of grey prose, then +six mono identifiers each followed by two or three lines of grey prose, then a +hairline, then a very small mono line. Two faint slabs of line art sit out on the +black to the left and right of the column, at about a tenth of the ink of anything +else, and they do not join up — whatever they are a picture of is behind the panel. +There is a gold tick at the panel's top-left corner and another at its bottom-right. + +The page does not fit the screen at either size. Wide, 406px of it is below the +fold; narrow, 470px is. The lower half of both is the scope list. + +On the black at the far left there is a small dark circle with an "N" in it. That is +the Next.js dev indicator baked into the capture, not shipping UI. Ignore it. + +--- + +## Findings + +### 1. Six scopes at one weight, and the only one that writes is row two of six + +**Severity:** Serious +**Where:** `src/app/login/page.tsx:189-207`; `src/app/globals.css:3629-3665` + +Every scope renders as an identical unit: mono identifier at `--t-detail` / +`--ink-faint`, then a sentence at `--t-caption` / `--ink-dim`, `--s-2` between +pairs. Six of them, no grouping, no ordering signal, nothing pulled forward. + +The distinctions the list flattens are real and are already known to the file that +renders it. `describeScope`'s own comments carry them: `write_contacts` "also +*deletes*" contacts (`page.tsx:32-36`); `read_location` is +`LOCATION_SCOPE_REQUIRED`; `read_structures` and `read_online` are +`LOCATION_SCOPES_OPTIONAL[0]` and `[1]`, and refusing them degrades a line rather +than breaking a feature (`page.tsx:41-54`). Five of the six only read. Exactly one +changes something that persists on the member's character. + +They also collapse to three facts, not six: contacts (`read_contacts`, +`write_contacts`), where your characters are (`read_location`, `read_structures`, +`read_online`), and one convenience (`open_window`). The prose paragraph directly +above already states two of those three in the member's terms, so the list restates +them a second time in identifier order. + +**Cost:** A member granting a token at 1am reads six rows that look the same, and +cannot tell without parsing English which one lets authGD delete contacts off their +character. The realistic outcomes are granting without reading, or bouncing at the +one screen the product cannot afford to lose people on. + +**Fix:** Group the `<dl>` under three sub-heads in the label register — *Contacts*, +*Location*, *In-game window* — and say the shared fact once per group instead of +once per row. Then mark the deviation against the set the way `crewNorms` does: +state "read-only" once for the set, and let `write_contacts` carry the one visible +mark, because it is the only member that departs. Same for optional: `read_online` +and `read_structures` take one shared "optional — refusing degrades the location +line" and lose their individually-repeated hedges. Keep the raw identifiers (a +technical reader checks them against `EVE_SSO_SCOPES`) and keep the dt/dd +inversion. Net effect is six uniform rows becoming three groups with one thing +standing out, and roughly a third less height. + +Worth knowing while sizing this: CCP's own consent screen is the next thing the +visitor sees, and it enumerates the same six scopes in EVE's words. This page's job +is the plain-English *why*, not the inventory. Compressing the inventory loses +nothing the following screen does not restate. + +**Principle:** Sweep pattern 2 (total enumeration) and pattern 3 (repeated +identical controls at uniform weight). PRODUCT.md principle 3, scanning is the +primary act. + +--- + +### 2. A 480px column in a 1440px field, and the page still runs 1.45 screens + +**Severity:** Serious +**Where:** whole surface (`.launch__panel`, `src/app/globals.css:3524-3533`) + +`width: min(30rem, 100%)` puts the panel at 480px, so 960px of the 1440px field — +67% — is empty ground, while the content overflows vertically by 406px. The +disclosure block alone (rule at y≈495 through the last `dd` at y≈1190) is 695px, +53% of the page. Over half of the product's only brand surface is OAuth consent +copy running in a single narrow gutter with two thirds of the screen unused beside +it. + +This is sweep pattern 1 in its plainest form, and it is more costly here than on a +product surface, because a brand register is judged on composition. Underneath the +artwork, the shape is: centred mark, centred title, centred subtitle, centred +button, centred stack. `reference/brand.md` names that shape specifically — "don't +default to centering everything", "a centred-stack hero ... reads as template". The +page is rescued from reading as generated entirely by one asset. Cover the seal and +nothing in the layout says what this product is or who made it. + +**Cost:** A member's first impression of the whole tool is a template with a good +logo on it, and they have to scroll a 1440×900 desktop to finish reading a login +page. + +**Fix:** Spend the field. Break the composition asymmetric: hold the identity stack +(seal, name, motto, action) in its 30rem column to one side, and run the disclosure +in the space beside it rather than under it — at ≥66rem the panel can widen and the +`<dl>` can run two columns, which with finding 1's grouping brings the page inside +one screen. That also uncovers the artwork; see finding 3. The panel stays the +sanctioned card, the identity column keeps its cap, and nothing about the narrow +layout has to change. + +**Principle:** Sweep pattern 1. `reference/brand.md` — layout, asymmetry over +centred stacks; brand ban on "timid palettes and average layouts." + +--- + +### 3. The lander is occluded at desktop and absent on a phone + +**Severity:** Moderate +**Where:** `src/app/globals.css:3515-3522` (`.launch::before`), interacting with +`3524-3533` and the 40rem override at `5147-5149` + +`hero.webp` is 1000×573. At 1440 the background sizes to `min(1000px, 150%)` = +1000px, spanning x 220–1220, and the panel covers x 480–960. What is left visible +is two disconnected 260px slabs of the artwork's outer edges — which is what the +shot shows — with the lander itself, the recognisable part, behind the panel. The +panel's `color-mix(... 88%, transparent)` was presumably meant to let it read +through; at `opacity: 0.07` behind 12% transmission that is 0.8% effective, and the +capture confirms nothing is visible inside the panel. The translucency is a no-op. + +At 390 it is worse. `150%` = 585px wide, spanning x −97 to 487, and the panel +occupies x 16–374. Sixteen pixels of artwork survive on each edge, and those +sixteen are the image's empty margin. On a phone the login ground is plain +`--void`. + +PRODUCT.md principle 5 grants exactly one exception for held-back artwork and names +this surface as it: "the login ground is the deliberate exception, where the lander +is held far enough back to be texture rather than picture." Held back is not the +same as covered up. What ships is closer to the thing the principle's own sentence +forbids two lines earlier — a fragment. + +**Cost:** The one atmospheric move on the only page that gets to make one delivers +nothing on the viewport most members arrive from, and delivers two unreadable +offcuts on the other. + +**Fix:** Two edits, both small. Wide: once finding 2 moves the identity column off +centre, shift `background-position` so the lander's subject sits in the open field +rather than under the panel — it becomes a whole picture held at 0.07 instead of +two edges. Narrow: `150%` is the wrong sizing rule when the panel covers 92% of the +viewport. Either size the ground so a recognisable portion clears the panel top or +bottom, or drop `.launch::before` below 40rem and let the void be void, which is at +least honest. Do not raise the opacity to compensate; the problem is placement. + +**Principle:** PRODUCT.md principle 5, earn the artwork. + +--- + +### 4. The corp's joke is set at 11px in the faintest ink on the page + +**Severity:** Moderate +**Where:** `src/app/globals.css:3589-3599` (`.launch__motto`) + +`--t-label` (0.6875rem / 11px), `--ink-faint` (#90877e, 5.23:1 on the panel's +#141413), uppercase, `--track-furniture`. It sits directly under a 64px `--t-display` +heading, a 5.8× drop with nothing between. + +PRODUCT.md is unambiguous that the brand *is* the joke — "a NASA mission patch +reading *Center for Kids Who Can't Fly Good*" — and that the joke belongs in "the +artwork, the mission-patch furniture, and dry microcopy." `BRAND_MOTTO` is the only +line of microcopy on this page carrying it, and it is styled as metadata: same size +grade as a table column header, quieter ink than any prose on the surface, wedged +into the one gap in the type ramp. The deadpan is right; the volume is wrong. +Deadpan means say it flat, not say it small. + +**Cost:** The single line that gives the tool a personality reads as a caption on +the logo, and most visitors will not read it at all. + +**Fix:** Give the motto a real step — `--t-caption` or `--t-body` — and lift it to +`--ink-dim`. Keep the mono, the caps and the tracking; the instrument register is +what makes it land straight. It stays quieter than the H1 by a wide margin and +gains a floor. The existing `\n` handling means a two-line motto still works at the +larger size, so a fork's longer string is not a regression risk. + +**Principle:** PRODUCT.md brand personality; `reference/brand.md` scale — flat or +skipped steps read as uncommitted. + +--- + +### 5. The emphasis colour is unspent on the one page with a single action + +**Severity:** Moderate +**Where:** `src/app/globals.css:3673-3687` (`.launch__action`) + +Settled taste rations gold to one primary action per view plus the mark. This view +has exactly one action, and it carries no gold. The control is CCP's 270×45 +white-cut asset (#e8e8e8-ish) sitting in a `border: 1px solid transparent` box that +only takes `--gold` on `:hover`. So at rest the page's entire gold budget is two +14px corner ticks and whatever gold is inside the seal artwork, and the brightest +object on a brand surface is a vendor mark in a different type family with a +different corner radius from everything around it. + +The vendor asset itself is constrained — CCP publishes the button and re-cutting it +is not on the table, and `page.tsx:153-167` documents why the white cut beat the +black one. That is not what I am asking to change. + +**Cost:** The single thing the page wants pressed is the one element that does not +belong to the design system, and until a pointer touches it nothing on the surface +frames it as the product's own control. + +**Fix:** Change the resting border colour from `transparent` to `--gold-dim` +(#ce9c20, 7.37:1 on the panel, well clear of the 3:1 UI-boundary floor) and keep +`--gold` on hover. Geometry is untouched, so the "does not resize under the +pointer" reasoning in the existing comment still holds, and the entry control +becomes the one gold-outlined object on the page without altering the vendor mark +by a pixel. + +**Principle:** Settled taste — gold rationed to one primary action per view. + +--- + +### 6. The 44-word sentence carrying the core promise + +**Severity:** Minor +**Where:** `src/app/login/page.tsx:177-183` + +The disclosure note is three sentences, the middle one 44 words with a semicolon and +three trailing participles ("adding, updating and removing them at a set standing"). +The third sentence — "Leaving the alliance drops your tier, never your account, +characters, or Discord link" — is *derole, don't boot*, which PRODUCT.md calls the +core promise, and it arrives as the tail of a grey paragraph after the long one has +already spent the reader. + +**Cost:** The most reassuring sentence available on a consent screen is the one a +skimmer is least likely to reach. + +**Fix:** Split the middle sentence at the semicolon. Then lead the paragraph with +the promise rather than closing on it — what authGD will *not* take is a better +first line on a page asking for a token than what it will do. Content is right, and +the voice is right; this is ordering and sentence length only. + +**Principle:** PRODUCT.md voice — terse, factual, never apologises twice. + +--- + +## What is genuinely good and should survive + +- **The seal.** 180px, full quality, native 512 source, top of the stack, the only + saturated object on a near-black page. It carries the entire brand single-handed + and every finding above assumes it stays exactly as it is. Note that finding 2's + fix must not shrink it to make room. +- **`--t-display` at 4rem with `-0.03em`.** The corp's name is the largest thing on + the surface and the tracking is correct for a grotesque at that size. The comment + at `globals.css:3577-3583` shows this was a considered correction from 1.5rem; + do not let a widened panel tempt anyone into re-tuning it. +- **The registration ticks.** 14px, `--gold-dim`, two corners, used once in the + system. This is the detail that makes a visitor ask how the page was made rather + than which tool made it. Cheap and specific and exactly right. +- **`describeScope`'s copy, and its refusal to invent.** Descriptions grounded in + the real call sites, and a fallback that says only "authGD has no description for + it. Ask whoever runs it what it is for before granting." That is the deadpan voice + landing perfectly. Finding 1 regroups these sentences; it does not rewrite them. +- **The dt/dd inversion, with `dd` at `--ink-dim`.** Putting the plain sentence at + the same ink grade as the prose above it, so what a scope *buys* is never quieter + than its identifier, is the right call and survives the regrouping intact. +- **`.launch__action` shrink-wrapped to `inline-block`.** The focus ring outlines + the 270px control rather than a 414px strip, and the dead clickable margin is + gone. `:focus-visible` gives it a 2px `--gold` ring at `globals.css:289-293`, so + the keyboard state is solid — finding 5 is about the resting state only. +- **`Notice` mounted unconditionally in slot mode**, not behind `&&`, so the live + region exists before its text does. + +## What I could not evaluate + +- **Every error state.** There is no shot of `/login?error=…` in any of the three + tones. I could not judge the `bad`/`warn`/`info` treatments in place, and more + usefully I could not check what a mounted notice does to the fold: it inserts + between the motto and the action, and at 390×844 the action currently sits at + y≈347 with only ~500px of headroom. A two-line `bad` notice may push the primary + control close to the fold on a phone. Worth one capture. +- **The real deployment's brand strings.** The shots run the fixture — "TEST CORP", + "TEST MOTTO LINE", "TEST FOOTER LINE". Finding 4's argument holds for any string, + but the head's balance at the real motto's length is unjudged. +- **Hover and press.** Static captures only; `.launch__action:hover` and the seal's + 620ms `seal-settle` entrance were read from source, not seen. Reduced-motion is + handled globally at `globals.css:295-304` including `animation-duration`, so the + entrance collapses correctly — that much I did verify in source. +- **How `hero.webp` reads at 0.07 on a real panel in a dark room.** I judged + occlusion geometrically and from the capture, which is reliable for *where* the + artwork is; how much of it a member actually perceives at that opacity on their + own display is not something a PNG settles. + +## Contested + +Nothing. I have no quarrel with any settled-taste item as it applies here — the +panel earns its card exception, the ticks are the best detail on the page, and the +tight ramp is not what is wrong with the motto (finding 4 asks for a step that +already exists in the scale, not a new one). diff --git a/docs/design-sweep-2/reports/payout-detail-audit.md b/docs/design-sweep-2/reports/payout-detail-audit.md new file mode 100644 index 00000000..eec4c788 --- /dev/null +++ b/docs/design-sweep-2/reports/payout-detail-audit.md @@ -0,0 +1,683 @@ +# `/payouts/[id]` — audit + +`$impeccable audit` · register: **product** · read-only pass · re-run 2026-08-10. + +This is a re-run. It consolidates the first pass with a second, pixel-led pass +over the same four shots, so it supersedes rather than appends. Where the two +passes disagreed, the disagreement is resolved in the text and the resolution is +stated. Two things changed materially: **finding 1's mechanism is now settled** +(the first pass named the right rule; the second pass measured the consequence +and confirmed it against the CSS), and **four findings are new**, all of them +about classes this table borrows from `/admin/accounts` whose stated +preconditions are false here. + +Source read in full: `src/app/payouts/[id]/page.tsx` (1282 lines) and every +co-located component, plus `_components/confirm-submit.tsx`, `confirm-group.tsx`, +`submit.tsx`, `submit-guard.ts`, `scroller.tsx`, `ui.tsx`, and the relevant +`globals.css` blocks. Screenshots looked at before any file was opened. + +## What the shots show, before any explanation + +Both viewports are a single unbroken vertical run: a name, a date, a summary +line, then five ruled sections stacked one under another for 2900px (wide) / +4000px (narrow). The wide draft holds a 78rem column that the loot table and the +roster fill edge to edge, so **pattern 1 (unshaped field) does not apply here** — +this surface is the opposite problem. Every section is dense and every section is +the same density. One gold button (FINALIZE) sits a ninth of the way down; below +it, seventy controls at one uniform quiet weight. The eye lands on the gold and +then has nothing else to hold it. + +The narrow shots gave the first hard finding before a line of source was read. +`fullPage` capture writes the document's scroll width, so the file dimensions are +a measurement: + +``` +06-payout-detail-draft.narrow.png 771 x 4027 <-- +381px past the viewport +07-payout-detail-finalized.narrow.png 522 x 3944 <-- +132px +11-admin-sync.narrow.png 411 x 1686 +every other surface in the sweep 390 x ... +``` + +The site header bar in `06-payout-detail-draft.narrow.png` paints to x=390 and +stops; the document runs on to 771. **This page horizontally scrolls on a phone +and no other page in the app does.** Scanning the narrow draft for painted pixels +past x=390 returns exactly two bands — `y 767–777` reaching x=770, and +`y 1304–2225` reaching x=563 — which are findings 1 and 2, and nothing else. + +## Dimension scores + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | Focus management and live regions are unusually strong; reflow and in-flight feedback are not | +| 2 | Performance | 3 | ~70 client component instances on a read-first page; no images, no animation cost | +| 3 | Responsive | 1 | Document is 771px wide at a 390px viewport, from two independent sources | +| 4 | Theming | 4 | Every colour is a token; no hard-coded value anywhere in this route | +| 5 | Anti-patterns | 4 | No cards, no gradients, no glass, no hero metric. Reads as authored | +| **Total** | | **15/20** | Good — one dimension is dragging the rest down | + +--- + +## Findings + +### 1. The page scrolls horizontally at every viewport under about 800px, because Finalize's caption cannot wrap + +- **Severity:** Critical +- **Where:** `src/app/globals.css:2974-2977` (`.btn-row--tight { flex-wrap: + nowrap; white-space: nowrap }`), inherited into + `src/app/payouts/[id]/page.tsx:508` → + `src/app/payouts/[id]/lifecycle-submit.tsx:139-156`. + + `.btn-row--tight` sets `white-space: nowrap` for the reason its own comment + gives: *"Row actions sit in the narrowest column in the table, and the browser + will happily squeeze them into a vertical stack. Keep them on one line."* + `page.tsx:508` wraps Finalize/Unlock in it — but that control does not sit in a + table row, and inside it hangs `LifecycleSubmit`'s + `<ConfirmCost visibility="visible">`, a 137-character sentence. `white-space` + inherits, the span cannot break, and its max-content becomes the automatic + minimum size of every box between it and the page column. + + Measured, not asserted. At 1440 the caption runs x=120 → x=874: **754px on one + line.** At 390 the whole document is 771px wide and the only content reaching + that far is that same line — 16px of page padding plus 754 of unbreakable + sentence. Its tail, "and permanent after that.", is off-screen. The content + column is `viewport − 48px` at wide and `viewport − 32px` at narrow, so the + document overflows at any viewport below roughly 800px: 320px, 390px, a 768px + tablet, and **200% zoom on any display narrower than about 1600 physical px**. +- **Cost:** An operator finalizing from a phone, or anyone at 200% zoom, reads + "Closes the pools, roster and shares to editing. Reversible with Unlock until + the first payment is recorded, and permanent…" and the sentence walks off the + screen exactly where it was about to say the part that matters. The whole point + of `visibility="visible"` — settled, and not challenged here — is that this copy + reaches sighted operators; at these widths it does not. Every other section of + the page then inherits a 380px horizontal scroll it has no use for, so a + vertical scroll that catches any horizontal component drifts the entire ledger + sideways under the reader's thumb. +- **Fix:** Take `.btn-row--tight` off `page.tsx:508`. The lifecycle row holds one + button — Finalize and Unlock are mutually exclusive by construction + (`canFinalize` wants a draft, `canRelease` a finalized operation), so there is + nothing for `nowrap` to keep on one line and nothing for `flex-wrap: nowrap` to + hold either. A plain `.btn-row` is enough. If the class must stay, add + `white-space: normal` to `.confirm-cost` (`globals.css:961`), which is correct + for a prose caption in any context. +- **Principle:** WCAG 2.2 AA 1.4.10 Reflow and 1.4.4 Resize Text. Neither + exemption reaches a prose sentence. + +### 2. Each loot pool's item table escapes its own scroll region and widens the document instead of scrolling inside it + +- **Severity:** Critical +- **Where:** `src/app/globals.css:4859-4862` (`.pool-items { display: grid; gap }`) + holding `src/app/payouts/[id]/page.tsx:684` (`<Scroller label={"Pool N items"}>`). + + `.pool-items` is a grid; the `.scroller-frame` inside it is a grid item whose + `min-width` is `auto`, so its floor is the item table's min-content width and + the track grows past the container. The `Scroller` never gets to scroll: its + scrollport is as wide as its content. + + This is a known-at-authoring-time risk fixed at one of the two sites it applies + to. `globals.css:4583` says it in as many words: *"of the ten Scroller call + sites, only two sit in a grid or flex parent where the floor can bite at all — + this one and `.pool-items` on the payout detail page."* `.drawer__crew` got + `min-width: 0` (`globals.css:4607`). `.pool-items` did not. + + Measured: in `06-...narrow.png` the bands covering the Pool 2 item table + (y 1304–2225) reach x=563 against a content right edge of x=373 — 190px of + escape. In `07-...narrow.png` the Pool 1 table reaches x=521 and is what sets + that document's entire 522px width. +- **Cost:** Three compounding losses, and the third is the one nobody would guess. + (a) The page scrolls horizontally rather than the region, so panning to read a + line total drags the section headers and the roster with it. (b) The `Scroller`'s + edge fades never appear, because `scrollWidth === clientWidth` — the one + affordance that says "there is more this way" is silent precisely when there is + more. (c) `scroller.tsx:95` withdraws the region's tab stop when it measures no + scroll range, so a **keyboard operator loses the tab stop into the pool item + table entirely**, on the surface where the per-item `edit` price controls live. +- **Fix:** `.pool-items .scroller-frame { min-width: 0 }`, mirroring + `globals.css:4607` at the site that rule's own comment already names. + `.pool-items { grid-template-columns: minmax(0, 1fr) }` is the equivalent fix + one level up and either works. Add the assertion to whichever e2e 320px spec + covers this route; note that fixing this *changes* whether the region has a tab + stop, which is the correct direction but is a behaviour change worth pinning. +- **Principle:** WCAG 2.2 AA 1.4.10 Reflow, and 2.1.1 Keyboard for the lost stop. + +### 3. At 390px the roster spends 75% of its visible width on two columns that carry one fact, and hides the column that answers the question + +- **Severity:** Serious +- **Where:** `src/app/payouts/[id]/page.tsx:913-928` (column order: + Name / Shares / Amount / State / actions); visible in + `07-payout-detail-finalized.narrow.png`. + + Measured off that shot: the scroll region runs x=35→374, and the column rules + fall at Name 35–121 (86px), Shares 121–190 (69px), Amount 190–374 (184px). + **Shares + Amount = 253px of a 339px region.** STATE and the whole action cell — + `copy amount`, `mark paid`, `revert`, `exclude`, `remove` — begin past the end + fade. Unlike findings 1 and 2 this is a deliberate scroll inside a labelled + region, which the settled constraints permit; the defect is *which* columns were + left outside. + + The visual tell is in the crop: Bad Scout's row is roughly three times the + height of Booster Alt's and nothing on screen explains why. The cause is the + hidden STATE cell, where a paid row carries a three-line payment sentence + (finding 5). The reader gets the rhythm of the data without the data. +- **Cost:** A member opening a payout link on a phone at 1am — the session + PRODUCT.md describes — cannot answer "was I paid?" without discovering a + sideways scroll and panning past two columns, one of which says `1.00` on + fifteen of sixteen rows. An operator on a phone cannot reach `mark paid` at all + until they pan, and the region scrolls back for the next row. +- **Fix:** Move STATE ahead of SHARES. State is the only cell that varies for a + reason the reader came for; Shares is the *input* to Amount, which sits next to + it. `Name / State / Amount / Shares / actions` puts a phone's three visible + columns on identity, answer, and figure. If Shares must stay beside Amount it + reads fine as a suffix (`288,600,000.00 ISK ×2`) and gives the column back. + Below 40rem, rendering STATE inside the pinned NAME cell under the name is the + stronger version of the same move and is what `.log--payouts` + (`globals.css:1315-1322`) already reasoned toward for `/payouts` — "a pin hides + nothing from anyone", except here the pin is holding the wrong fact. Do **not** + promote NAME to `<th scope="row">` as part of this: `.log th` carries + `white-space: nowrap`, and this column already holds an unbounded + operator-typed name plus a parenthesised alt list. +- **Principle:** PRODUCT.md principle 2, "state before action", and principle 3. + WCAG 1.4.10 is satisfied by the region, so this is a priority defect rather + than a conformance one. +- **Fixture caveat, per the brief:** the payout fixture writes `amount` directly + and never runs `recalculate`, so AMOUNT is identical on every row in the shot + *including* Hurricane Main's 2.00-share row. Under a real weighted split Amount + varies and earns its place. This finding does not rest on that — it rests on + Shares (genuinely constant) preceding State (genuinely varying) at a viewport + where three columns fit. Finding 5's Amount half does rest on it, and says so. + +### 4. Nothing on this page shows an action is in flight, and a refused second press is completely silent + +- **Severity:** Serious +- **Where:** whole surface. Concretely: `page.tsx:1063-1072` (`exclude`), + `pay-flow.tsx:326-334` (`mark paid`), `:353-359` (`revert`), `:397-403` + (`remove`), `pool-flow.tsx:149-155` (`delete`), `lifecycle-submit.tsx:147-152` + (Finalize/Unlock), `page.tsx:1171-1177` (Replace roster), `page.tsx:1260-1267` + (Delete operation). + + Every one passes no `pendingLabel`. `Submit`'s own docblock + (`submit.tsx:14-19`) states the contract: the button is deliberately not + disabled, so *"`pendingLabel` and `aria-busy` are the whole of the in-flight + signal"*. With no `pendingLabel`, `aria-busy` is the whole of it — and + `aria-busy` renders nothing. Three controls on the page do it right (`open + info`, `saving…` in `InlineEdit`, `Pricing…` in `AppraiseForm`), which makes + the omission read as drift rather than a decision. + + The second half is specific to this route. `ConfirmSubmit` reports a + guard-refused press through `useConfirmReport()` (`confirm-submit.tsx:394`), + which resolves to `null` unless a `ConfirmGroup` is above it. **No + `ConfirmGroup` is rendered anywhere on `/payouts/[id]`** — the component is + rendered by `/admin/accounts`, `/admin/sync` and `/admin/access-lists` only; + this route mentions it in comments and never mounts it. So every refused press + here vanishes. + + `submit-guard.ts`'s docblock justifies that silence: *"a `ConfirmSubmit` in a + plain redirecting form still refuses silently"* — the navigation is the + feedback. **That premise is false on this page.** `markPaidAction`, + `revertPaymentAction`, `removeParticipantAction`, + `setParticipantExcludedAction`, `finalizeAction`, `unlockAction` and + `deletePoolAction` all end in `revalidateOperation` and do not redirect. The + page does not move. `deleteOperationAction` is the sole exception. +- **Cost:** An operator paying fifteen people presses `mark paid`, the round trip + takes a few hundred milliseconds against a database transaction, nothing on + screen changes, and they press again. The second press is correctly refused and + correctly invisible — so the button reads as dead on a money control. This is + the exact failure `NotesForm` opted into `onRefused` to fix + (`notes-form.tsx:69-81`), on the same page, for a lower-stakes control. +- **Fix:** Two independent changes. (a) Give the eight controls a `pendingLabel` + ("paying…", "reverting…", "removing…", "finalizing…"). Note + `confirm-submit.tsx:290-294`: `pendingLabel` is outside the ghost-label width + reservation, so a longer pending word reflows the button — reserve for it or + keep the words at or under the rest label's length. (b) Wrap the roster and the + lifecycle block in a `ConfirmGroup`, or extend `onRefused` to `ConfirmSubmit` + as an explicit prop, so the refusal lands somewhere. +- **Principle:** WCAG 2.2 AA 4.1.3 Status Messages for the refusal. + +### 5. The State column recites one identical payment sentence on every paid row + +- **Severity:** Serious +- **Where:** `src/app/payouts/[id]/page.tsx:993-996` → + `src/app/payouts/[id]/payment-history.tsx:36-46`. + + This is the sweep's pattern 2 in its most literal form on any surface here. In + `07-payout-detail-finalized.wide.png`, six rows each render: + + > `2026-08-10 11:57:07 UTC` paid `288,600,000.00 ISK` by Fleet Commander + + Same timestamp to the second, same actor, six times — and the amount repeats + the AMOUNT cell two columns to the left, verbatim, in the same row. + + `PaymentHistory` renders inline rather than behind a `Disclosure` when there is + exactly one payment, and the reasoning at `payment-history.tsx:47-52` is sound + for a single row read alone: a fold with nothing folded. It does not hold for + sixteen of them read as a column. +- **Cost:** The operator's job on this table is to find the rows that are *not* + paid. Six rows carry three lines each of text that says nothing distinguishing, + the unpaid rows carry one, and the eye sorts them by row height — an artefact, + not a design. On a sixteen-person roster this is four screens where one would + do, and at 390px it is the invisible cause of finding 3's ragged rows. +- **Fix:** `crewNorms` in `src/app/account/page.tsx` is the pattern the brief + names and it transfers directly. Derive the shared facts once — the pay run's + instant and the actor — and say them in the section header beside the existing + `6/15` aside: "paid 2026-08-10 11:57 UTC by Fleet Commander". Then each row's + STATE cell holds `PAID` alone, and only a row that *deviates* (a different + actor, a different instant, an amount that differs from that row's own AMOUNT + cell) spells its sentence out. Keep both channels in parity the way + `/admin/sync`'s "Cadence (UTC)" does: a `visually-hidden` span per row + restoring the full instant, so the audit-grade detail is not lost to a screen + reader. +- **Principle:** PRODUCT.md principle 3, "scanning is the primary act". +- **Fixture caveat:** the per-share amount is *not* safe to hoist into the header + as a shared fact — the fixture's uniform amounts are an artefact of `amount` + being written directly with no `recalculate`, and under a real weighted split + Hurricane Main's 2.00 shares would pay double. Hoist the instant and the actor; + leave the money per row. + +### 6. The roster borrows `.log--dense`, whose `vertical-align: middle` is documented as safe *because* its cells are never multi-line — and this table's State cell is three lines on every paid row + +- **Severity:** Moderate — **new in this re-run** +- **Where:** `src/app/payouts/[id]/page.tsx:913` (`log--dense` on the roster) + meeting `src/app/globals.css:1219` (`.log--dense td { vertical-align: middle }`). + That rule states its own precondition: *"Middle alignment is safe here — unlike + `.log--audit`, this table's cells are all short, single-purpose values, never + the multi-line blobs top alignment exists for."* The roster's State cell holds + `Status` **plus** `PaymentHistory` (`page.tsx:993`), which is three wrapped + lines on every paid row in the wide shot. +- **Cost:** Inside each paid row, Name / Shares / Amount are optically centred + against a `● PAID` token sitting at the top of its cell — so the column of + names loses its baseline against the column of states, in the one table where + reading across a row is the task. The ragged look survives even if finding 5 is + fixed, because a deviating row will still be multi-line. +- **Fix:** Give the roster its own density rule keyed to `.log--roster` — the + class already exists at `page.tsx:913` — carrying `.log--dense`'s padding and + `vertical-align: top`, and drop `log--dense` from the element. This is exactly + the call `.log--manifest` (`globals.css:1381`) and `.log--payouts` + (`globals.css:1289`) each already made, in comments that argue explicitly + against borrowing the accounts table's class for this reason. The roster is the + one table that borrowed it anyway. + +### 7. At 390px the loot table's first row is 130px tall and shows nothing, because an invisible prose column sets its height + +- **Severity:** Moderate — **new in this re-run** +- **Where:** `src/app/payouts/[id]/page.tsx:581` and `:605` — the `Notes` column, + rendered when `anyPoolNotes` (`:275`). In `06-payout-detail-draft.narrow.png` + the pool-1 row occupies y≈880–1010 with `1 ▲FLAT (MANUAL)` on the first line and + blank ground below it; the note that sets the height ("Citadel rigs, priced by + hand off contracts") is off-screen right along with the Value cell. +- **Cost:** A member scrolling the operation on a phone meets the loot summary as + a mostly-empty box whose largest feature is a gap, with nothing on screen to + explain it — the cause is a column they cannot see. A third of a phone screen + spent displaying two words. +- **Fix:** The notes are prose about one pool, not a fact that varies across a + column — `anyPoolNotes` at `:275` already concedes this ("almost never has + any"). Render the note under the source token in the Source cell, the same shape + the item rows already use for their `manual` / `unresolved` markers. That + removes the column, the conditional and the height it sets, at every viewport. +- **Principle:** the same argument `page.tsx:703-719` gives for deleting the + "Price source" column — a column spent on the rare row that has one. + +### 8. Seventy pressable things, sixty-two of them the same 28px quiet grade + +- **Severity:** Moderate +- **Where:** whole surface — the finding the brief asked for by name, and it has + no single line. + + Counted off `06-payout-detail-draft.wide.png` (16 participants, 2 pools, 10 + items), excluding the header nav: 4 standalone `edit` triggers (name, date, corp + share, battle report), FINALIZE, 2 pool `delete`, 10 per-item `edit`, 3 + disclosure summaries, 48 roster controls (16 × `edit` + `exclude` + `remove`), + Save, Delete. **Seventy.** Add three `Scroller` tab stops and the battle-report + link and a keyboard operator has 74 stops between the top of the page and the + Notes field. Sixty-two of the seventy are `.btn--quiet .btn--micro` — + transparent, borderless, `--ink-faint`, 28px. One is gold. +- **Cost:** An operator scanning the roster for the row they need to act on reads + a wall of identical grey verbs. A keyboard operator wanting the Notes field tabs + through all forty-eight, and a screen-reader user hears each named on the way. +- **Fix:** Fold `exclude` and `remove` behind a per-row disclosure the way + `/admin/accounts` folds its row controls into a drawer, leaving `edit` shares in + the row. That takes the roster from 48 controls to 32 and returns the row to one + action plus one disclosure. The `Disclosure` primitive, the arm scope and + `PayFlow`'s focus restoration all already handle the drawer shape. Whatever the + fix, do not remove a `RuleHead` heading or a `Scroller` label while tidying — + heading and landmark navigation are what make 70 stops survivable, and they are + the existing escape hatch. +- **Principle:** the sweep's pattern 3. Note `primaryStage` (`page.tsx:226-255`) + is good and is not what this finding is about — see "what must survive". + +### 9. `remove` never changes appearance when armed, and the shared component's docblock asserts the opposite + +- **Severity:** Moderate +- **Where:** `src/app/payouts/[id]/pay-flow.tsx:397-403` against `:353-359` and + `src/app/payouts/[id]/pool-flow.tsx:149-155`; the false claim is at + `src/app/_components/confirm-submit.tsx:249`. + + `RevertForm` and `DeletePoolForm` pass `armedClassName="btn btn--micro + btn--danger"`. `RemoveParticipantForm` passes none, so `ConfirmSubmit` falls + back to `armedClassName ?? className` and the armed control renders exactly as + it did at rest. `LifecycleSubmit` has no `armedClassName` parameter at all + (`lifecycle-submit.tsx:147-152`), so armed Finalize stays gold and armed Unlock + stays plain. `confirm-submit.tsx:249` states: *"No caller now keeps the same + class in both states."* Three callers on this page do. + + The only cue that `remove` is armed is the word changing from "remove" to + "confirm" — and the ghost-label width reservation + (`globals.css:2776-2790`) means the button does not even change size while it + does so. +- **Cost:** In a sixteen-row column of near-identical grey verbs, one word going + from six letters to seven is not a state change anyone notices. An operator who + armed row 9 and looked away comes back unable to see anything is armed, and + their next click on that row deletes a participant. `remove` is the one control + here with no undo path on the page. +- **Fix:** Give `RemoveParticipantForm` the same `armedClassName` its two + structural siblings use, and correct the docblock claim — or, if Finalize and + Unlock keeping their grade is deliberate, say that there instead of asserting + the universal. +- **Principle:** WCAG 2.2 AA 3.2.4 Consistent Identification for the sibling + mismatch. + +### 10. `.btn--danger-quiet` is pixel-identical to plain `.btn--quiet` until you hover or focus it + +- **Severity:** Moderate +- **Where:** `src/app/globals.css:2904-2920`. + + **Contrast is not the problem — it passes comfortably.** Converted to rendered + sRGB: `--ink-faint` is `#90877e`, and against `--void` `#0a0a0a`, `--hull` + `#151514` and `--hull-hi` `#21201f` it measures **5.61:1 / 5.18:1 / 4.61:1**. + All clear 4.5:1, including on a hovered row. The hover colour `--signal-bad` + `#f05751` measures 5.83 / 5.38 / 4.79. + + What is gated on hover is the *meaning*. `.btn--danger-quiet` sets only + `color: var(--ink-faint)` at rest, the identical value `.btn--quiet` already + takes (`globals.css:506-508`). So in the roster action cell, `exclude` (plain + quiet) and `remove` (danger-quiet) are the same colour, size, weight and + absence of border. Red arrives on `:hover` and `:focus-visible` and nowhere + else. +- **Cost:** On a touch device there is no hover, so the destructive grade is + invisible until the control is pressed — and by then arming has happened. A + member on a phone sees `exclude` and `remove` as one undifferentiated pair. +- **Fix:** Not a colour retune (out of scope, and the value is fine). Give + `.btn--danger-quiet` a rest-state carrier that survives no-hover: the + `--rule-strong` border it already takes in the accounts drawer variant + (`globals.css:1625`), applied to the roster case too, so a destructive control + at least reads as an edged control among borderless ones. +- **Principle:** none cleanly — WCAG 1.4.1 is not violated, since the labels + differ in text. This is a consistency finding without a conformance citation. + +### 11. Six `&&`-mounted `Notice`s, and the navigation that used to excuse them is gone + +- **Severity:** Moderate — **and this explicitly re-opens a closed item.** +- **Where:** `src/app/payouts/[id]/page.tsx:357`, `:387`, `:625`, `:650`, + `:1106`, `:1123`. Only `page.tsx:355` mounts unconditionally. + + Stating the closure honestly: `docs/design-sweep/SYNTHESIS.md:338-350` ruled on + this. It verified three `tone="bad"` sites (one of them this page's, since + fixed — line 355 is now correct) and explicitly set the block-level + conditionals aside as *"a real but different finding — those notices arrive + through a navigation."* + + **The new evidence is that the premise no longer holds.** Since that sweep, + `AppraiseForm` stopped redirecting: `appraise-form.tsx:111-115` pushes the + dropped payload with a same-route `router.replace(..., { scroll: false })`, + chosen precisely so the route tree is *not* replaced (its docblock argues this + at length — a redirect would collapse every `Disclosure`). + `addParticipantAction` and `setRosterAction` revalidate in place. So the + `?dropped=` notice at `:357` and the unresolved-items notice at `:625` now + appear inside a tree that never remounted, born holding their text — the exact + shape the primitive exists to prevent, arriving by the exact route the earlier + ruling said it did not. +- **Cost:** An operator pastes loot with two unresolved items. Sighted, they see + the orange warn box. Using a screen reader they hear nothing: the pool total is + short by whatever those items are worth and the tool did not say so. +- **Fix:** Mount all six unconditionally and move the condition inside, the way + `appraise-form.tsx:145-147` already does within this same route: + `<Notice tone="warn">{droppedReport ? <span>…</span> : null}</Notice>`. The + empty slot is out of flow and draws nothing, so spacing is unchanged. +- **Principle:** WCAG 2.2 AA 4.1.3 Status Messages. + +### 12. A successful "Add participant" announces nothing and moves no focus + +- **Severity:** Moderate +- **Where:** `src/app/payouts/[id]/add-participant-form.tsx:61-63`. + + Every other roster mutation here is meticulous about this. `PayFlow` announces + `"Removed X. N participants remain."` and lands focus on the next row + (`pay-flow.tsx:222-247`); `PoolFlow` does the same for a deleted pool; + `LifecycleSubmit` announces and focuses the H1. Add is the odd one out: on + success it clears the input and returns. Focus stays on the button, and the + only signal is a text field emptying — which is also what a rejected-then- + retyped field looks like mid-edit. +- **Cost:** A screen-reader operator building a roster one name at a time cannot + tell an add that landed from one that did not, on a form whose whole reason for + existing (`page.tsx:820-829`) is that it is the only way to add someone without + discarding share edits. +- **Fix:** A `role="status"` span in this form, written on `state.ok` with + `Added {name}. {n} participants.` — the successful `FormData` is in hand at + `:61`. The clear-then-set idiom from `pay-flow.tsx:109-113` handles two adds of + the same name. +- **Principle:** WCAG 2.2 AA 4.1.3 Status Messages. + +### 13. The roster takes the accounts table's `scroll-margin-left: 10rem` by class inheritance, and its pinned column measures 169px + +- **Severity:** Minor — **new in this re-run** +- **Where:** `src/app/globals.css:2006` — the selector is + `.log--dense.log--sticky-col …`, and its comment says *"Scoped per table rather + than set once on `.log--sticky-col`. **Both tables** share that class and pin a + first column of a different width (audit's is a fixed 12.25rem; the accounts + table's is auto-sized around a name)."* There are three such tables now; the + roster is the unmentioned one and it inherits the accounts figure through + `.log--dense`. Measured on `07-payout-detail-finalized.wide.png`: the table's + vertical rules fall at x=120 / 289 / 1319, so the pinned Name column is + **169px** — 9px past the 10rem the rule reserves. +- **Cost:** A keyboard operator at a mid-width window (wide enough that the + roster's ~1199px of columns overflows, narrow enough that Name is still near + its content width) Shift+Tabs from a row's `exclude` back to that row's shares + `edit` and lands with the control parked ~9px under the pinned column — focus + ring drawn, partly invisible. Small today; the gap grows with the data, because + this column holds `displayName` (operator-typed free text, uncapped for an + unresolved name) plus a comma list of source characters, where the accounts + table's pin is bounded by an EVE character name. +- **Fix:** Add a `.log--roster.log--sticky-col` branch beside the audit and + accounts ones with its own figure, over-estimating on purpose the way that + rule's comment already argues for. If finding 6's `log--dense` removal lands + first, this rule stops matching the roster at all and the branch becomes + required rather than corrective. +- **Principle:** WCAG 2.2 2.4.11 Focus Not Obscured (Minimum) — the near-miss the + rule exists to prevent. + +### 14. `log--sticky-head` is inert on the roster at the size the roster usually is + +- **Severity:** Minor — **new in this re-run** +- **Where:** `src/app/payouts/[id]/page.tsx:911` — + `tall={participants.length > ROSTER_TALL_THRESHOLD}` with the threshold at 20 + (`:117`) — meeting `:913`, which sets `log--sticky-head` unconditionally. Below + 21 participants the `Scroller` gets no `.scroller--tall` and so no height cap; + `globals.css:1787` states the consequence directly (*"scrollHeight equals + clientHeight and `top: 0` has nowhere to go"*). The 16-row fixture is in that + regime, so nothing in either shot is pinned. +- **Cost:** At 390px the 16-row roster is roughly 1,700px tall. A member scrolling + it passes the header row within one screen and reads the rest — the table they + must also pan sideways (finding 3) — with no column labels anywhere on screen. + The threshold was chosen against desktop height; it does not describe a phone. +- **Fix:** Make `tall` viewport-aware rather than count-only, or lower the + threshold to something that reflects a roster taller than a phone screen. The + threshold *idea* is right and the Aug-5 sweep was right to defend it — this is + about the constant, not the mechanism. Related and still open from that sweep: + the pool-items table (`page.tsx:685`) is the page's longest table and is the one + with no sticky head at all (Aug-5 audit finding 6, unfixed). + +### 15. The H1's accessible name includes a button label + +- **Severity:** Minor +- **Where:** `src/app/payouts/[id]/page.tsx:293-301`. While `canEdit`, the H1 + wraps an `InlineEdit`, which renders the value, an `edit` button and a + `role="status"` span as children of the heading. The computed name becomes + "Wormhole eviction — J155843 edit". When editing opens, the H1 contains a + `<form>`, an `<input>`, two buttons and a `role="alert"`. + `LifecycleSubmit` focuses `#operation-name` after finalize + (`lifecycle-submit.tsx:144`), so a screen reader lands here and reads the + trailing "edit" as part of the operation's name. +- **Cost:** Small and constant: every heading announcement on the page's most + important element carries a stray verb. +- **Fix:** Move the `InlineEdit` beside the `<h1>` rather than inside it, keeping + the plain name in the heading. The `id`/`tabIndex` contract + `lifecycle-submit.tsx` depends on stays on the `<h1>` either way. +- **Principle:** WCAG 2.2 AA 4.1.2 / 1.3.1. + +### 16. `ConfirmArmScope` hands out a fresh context value on every render + +- **Severity:** Minor +- **Where:** `src/app/_components/confirm-submit.tsx:49-60`. The provider's + `value` is an object literal containing two fresh closures, so every arm and + disarm invalidates it for every consumer. On this page the scope wraps the + entire content (`page.tsx:419-1278`), so a single arm re-renders roughly twenty + `ConfirmSubmit`s and three `ConfirmCost`s. `PayFlow` (`pay-flow.tsx:249-254`) + and `PoolFlow` (`pool-flow.tsx:111-114`) both memoize their context value and + both leave a comment explaining that context consumers are exempt from the + children-as-prop bailout. That reasoning applies here identically. +- **Cost:** Not user-visible today; the re-renders are cheap. Recorded because it + is the largest provider on the largest page and the two smaller providers beside + it took the opposite decision with a written rationale. +- **Fix:** `useMemo` on `[armed]`, with `arm`/`disarm` in `useCallback`. + +### 17. `.copy-result` reserves 5rem on every roster row, permanently + +- **Severity:** Minor +- **Where:** `src/app/globals.css:3043-3047`, rendered at + `copy-amount-button.tsx:88-90`. The reservation is right in principle — its + comment explains that "copied" appearing must not shove the neighbouring + controls — but it costs 80px of empty width in each of fifteen action cells, + always, and that width is part of what puts the action column past the narrow + viewport in finding 3. +- **Fix:** Reserve inside the button's own row rather than as a sibling, or render + the result absolutely positioned over the reserved gap so it occupies no inline + size at rest. + +### 18. `CopyAmountButton` sets both `role="status"` and `aria-live="polite"` + +- **Severity:** Minor +- **Where:** `src/app/payouts/[id]/copy-amount-button.tsx:88`. `role="status"` + already implies `aria-live="polite"`, and every other live region in this route + uses the role alone. Harmless drift — but on a finalized page there are fifteen + copies of it. + +--- + +## What is genuinely good and must survive + +**The focus-restoration architecture is the best work in this codebase and none +of the fixes above should touch it.** `PayFlow` and `PoolFlow` host their effects +*above* the lists whose rows unmount, drive them from server-rendered state +rather than an optimistic promise, compute the next focus target before the +removal so the pre-removal ordering is still available, and — the detail I would +not have thought of — resume the pay run from where the operator was rather than +from the first unpaid row, wrapping to the top only when nothing is left below, +and *saying so out loud* when it wraps (`pay-flow.tsx:185-208`). The Aug-5 audit's +finding 2 ("focus is destroyed on every control that removes itself") is +comprehensively closed. Do not fold the two split effects into one. + +**`useOptimistic` was rejected for a money ledger, on the record** +(`pay-flow.tsx:69-74`). The live region can only claim a payment on a render +where the server says it landed. Keep that. + +**One `ConfirmSubmit` component type in the mark-paid slot** +(`pay-flow.tsx:284-288`) rather than a ternary between component types. That is +the #146 fix and it is invisible in the diff that would undo it. + +**`.inline-edit--standalone .btn--quiet { min-height: 2.25rem }`** +(`globals.css:2838`) with `standalone={false}` passed only at the two genuine +in-row sites (`page.tsx:749`, `:967`). The 36/28 split is correct here and +correct for the stated reason, not by accident. + +**`ConfirmSubmit`'s ghost-label width reservation.** The `content: attr(...)` +approach (`confirm-submit.tsx:256-284`) is correct, and the reasoning for why a +hidden real element would not work — generated content is invisible to +Playwright's text engine where a `visibility: hidden` span is not — is worth +keeping written down. + +**No arm timer.** `confirm-submit.tsx:33-39` refuses a 4-second auto-disarm on +WCAG 2.2.1 grounds and covers abandonment with blur, Escape and pointer-leave +instead. Correct, and correctly argued. + +**Contrast across this route passes with margin.** Every token converted to sRGB +and checked against all three grounds. Worst case anywhere on this surface is +`--ink-faint` `#90877e` on `--hull-hi` `#21201f` at 4.61:1 — a hovered row's quiet +control, still over the 4.5 floor. `--rule-strong` `#787370` holds 3.47:1 against +`--hull-hi` for the scroll region's boundary, over the 3:1 that 1.4.11 asks. No +hard-coded colour appears anywhere in this route. + +**The one-gold-control-per-stage rule** (`page.tsx:226-255`) and the decision to +leave Unlock and the payment controls plain, with `primaryStage` surfaced in the +summary line as `· next: finalize`. Finding 8 is about the sixty-two controls +below it, not this. + +**`LifecycleAnnouncer` mounted outside the `showLifecycle` gate** +(`page.tsx:505`), and `#mark-paid-cost` rendered once and shared +(`page.tsx:1099`) rather than per row. + +**Per-row accessible names that name the subject** — `confirm revert payment for +{name}`, `copy amount for {name}`, `unit price for {item}`. With 48 roster +controls this is the difference between usable and not. + +**`Notice` at `page.tsx:355` is mounted unconditionally** — the fix from the last +sweep held. Finding 11 is about the other six, not a regression of this one. + +--- + +## What I could not evaluate + +- **The arm-then-press flow with an actual screen reader.** The live regions, the + `restName`/`confirmName` pairs and the `aria-describedby` wiring are + structurally right, but "does NVDA re-announce this" is not a claim source and + screenshots can support. Per project memory there is no jsdom here, so this + needs Playwright plus a real AT harness. +- **Hover, focus and armed states generally.** Every shot is a rest-state capture. + Findings 9 and 10 are read from CSS and from what the shots prove about the rest + state; no armed button was rendered. +- **200% zoom directly.** Finding 1's 1.4.4 consequence is derived from the + measured 754px caption against the content column rather than captured at zoom. + The arithmetic is stated so it can be checked. +- **Hydration and interaction cost.** The Aug-5 audit left this open and this pass + only narrows it: the fixture's draft ships ~70 `Submit`/`ConfirmSubmit` leaves, + 3 `ResizeObserver`s, and — once finalized — 15 `CopyAmountButton`s each with its + own `role="status"`, i.e. ~23 polite live regions in one document. All are empty + at rest so nothing announces spuriously, but whether that many degrades + NVDA/JAWS is a screen-reader measurement. No profile was run. +- **`open info`.** `access.canOpenInfo` is false in the fixture, so that control + never renders in either shot. It is the one control on the page that does carry + a `pendingLabel`, which is also why finding 4 does not cover it. +- **Anything requiring a weighted split.** The fixture writes `amount` directly + and never calls `recalculate`, so no screenshot exercises one. The two places + this bears on a conclusion (findings 3 and 5) are marked in place. + +## Resolved from the Aug-5 sweep, not re-filed + +- That audit's second "could not evaluate" — *"actual column widths at 320px, and + therefore whether the roster's pinned Name column can ever exceed the + scrollport"* — is answerable now. The pin is 169px at 1440 and ~86px at 390, + roughly a quarter of the region, so it never strands the other columns. + Separately: `.log--payouts td:first-child { overflow-wrap: anywhere }` + (`globals.css:1367`) is the guard that keeps a long token from doing it on the + sibling table, and **the roster has no equivalent rule** despite holding the + less bounded of the two name sets. Not filed as a finding — nothing in the shots + exhibits it — but it is the one-line prophylactic that belongs beside finding 13. +- Aug-5 findings 3 (`.disc` styling) and 7 (`PaymentHistory`'s summary target) are + fixed in the CSS read here (`globals.css:3799`, and `:3707`, which now carries + the 28px floor). Finding 2 (focus destruction) is fixed by `PayFlow`. Finding 4 + (the borrowed `scroller--tall` cap) is fixed by + `.scroller--tall:has(.log--roster)` at `globals.css:1857` — findings 6 and 13 + above are two *different* classes borrowed from the same table, not that item + re-opened. + +--- + +## Contested — settled taste, raised once + +**`.st--ok` on the roster.** Not re-opening the "no green" rule, which is right. +But `paid` (`.st--ok`, `--ink-dim`, filled dot) and `unpaid` (neutral, +`--ink-dim`, a 0.15em bar) render at the *same colour* in the one column whose +entire job is separating two states, on a table read fifteen rows at a time. The +words differ and the glyphs differ, so it conforms; it just does not scan. The +settled rule says an `ok` should not shout — it does not say `ok` and +`not-ok-yet` should be indistinguishable at a glance. `--ink-dim` for `paid` and +`--ink` for `unpaid` would separate them by lightness alone, spend no colour, and +put the weight on the row that still needs work rather than the row that does +not. Raised once; nothing in the body of this report depends on it. diff --git a/docs/design-sweep-2/reports/payout-detail-critique.md b/docs/design-sweep-2/reports/payout-detail-critique.md new file mode 100644 index 00000000..8913553b --- /dev/null +++ b/docs/design-sweep-2/reports/payout-detail-critique.md @@ -0,0 +1,504 @@ +# `/payouts/[id]` — critique + +Register: **product**. Reviewed from `06-payout-detail-draft.{wide,narrow}.png` and +`07-payout-detail-finalized.{wide,narrow}.png` first, then `page.tsx` (1283 lines) +and all thirteen co-located components, plus `_components/confirm-submit.tsx` and +`_components/confirm-group.tsx`. + +## What I saw before I opened anything + +**Draft, wide.** A title with a small `EDIT` chip trailing it, a date with another +one, a mono status line. Three label/value rows. One gold `FINALIZE` about a tenth +of the way down, with a permanent sentence under it. Then an orange-bordered +warning about two items priced at 0.00. Then a ten-row item table where every row +carries an `EDIT` chip wedged between the unit price and the line total. Then a +sixteen-row roster where the SHARES column reads `1.00` sixteen times over (one +`2.00`), the AMOUNT column reads `288,600,000.00 ISK` fifteen times over, the STATE +column reads `– UNPAID` fifteen times over, and every row ends in `EXCLUDE REMOVE`. +Then a notes box, then a delete button. My eye went to the gold button, then had +nowhere else to go for the remaining 2,500 pixels. + +**Finalized, wide.** Same page, minus the gold. The roster's STATE column now holds a +full sentence per paid row — `2026-08-10 11:57:07 UTC paid 288,600,000.00 ISK by +Fleet Commander` — and that sentence is character-for-character identical on all six +paid rows. Rows become three lines tall. Nine `MARK PAID` boxes and six borderless +`revert` words alternate down the right edge. + +**Both, narrow.** The page is broken. Not "cramped" — broken. Content runs off the +right edge of the viewport and the whole document scrolls sideways. The roster shows +NAME, SHARES and AMOUNT and nothing else; STATE and every action are past the edge. +Rows have unexplained vertical gaps. The loot pools table has a row that is a tall +empty box. + +--- + +## Findings + +### 1. Critical — the page does not fit a phone, and I mean the document, not a table + +**Where:** `src/app/globals.css:2974` (`.btn-row--tight`), reaching +`src/app/payouts/[id]/page.tsx:508` → `lifecycle-submit.tsx:153`; and +`src/app/globals.css:4859` (`.pool-items`), reaching `page.tsx:682`. + +Both narrow screenshots were captured at a 390px viewport at DPR 1. Their PNG +widths are **771px** (draft) and **522px** (finalized). Playwright's `fullPage` +capture is `document.scrollWidth`, so those numbers are the document's own width: the +draft page is 1.98× the viewport and the finalized page 1.34×. I scanned both images +for the rightmost non-`#0a0a0a` pixel and found content at x=770 and x=521 +respectively, so this is real paint, not a stray box. + +Two independent causes, both in grid tracks that size to their content's max-content +and have nothing clamping them: + +**(a) The Finalize cost sentence cannot wrap.** `LifecycleSubmit`'s form is a child of +`<div className="btn-row btn-row--tight">` (`page.tsx:508`), and +`.btn-row--tight` sets `white-space: nowrap` — written, per its own comment, to stop +*row actions in the narrowest column of a table* from stacking. That declaration is +inherited. Inside it now sits a 130-character sentence: "Closes the pools, roster and +shares to editing. Reversible with Unlock until the first payment is recorded, and +permanent after that." It renders as one unbreakable line in **both** screenshots — +that is how I first noticed something was wrong, since 130 characters cannot fit on +one line at 390px. Its max-content sizes `.lifecycle`'s auto track, and the track +overflows the page box. Content in the y-band 750–800px of the draft narrow shot +reaches x=770; that band is this caption. + +**(b) `.pool-items` sizes to its table.** `.pool-items` is `display: grid` with an +implicit `auto` column and no `minmax(0, 1fr)`. Its grid item is the `Scroller`'s +frame, whose max-content is the whole item table. The track takes that width and the +region bursts out of the page instead of scrolling. Rows y=1300–2250 of the draft +narrow shot — the Pool 2 item table — all end at x=563, past a viewport of 390. By +contrast the Loot **pools** `Scroller` sits directly under `.page` as a plain block +child and correctly caps at 382px, which is why one of the two tables on this page +scrolls properly and the other does not. + +**Cost:** an operator paying out a fleet from their phone at 1am cannot read the page +without dragging it sideways, and every vertical scroll that drifts a few pixels +horizontally loses their column. This is the surface most likely to be used away from +a desk, because the money moves in the game client and the ledger is a second screen. + +**Fix:** (a) drop `white-space: nowrap` from the caption's inheritance — either +scope `.btn-row--tight`'s `white-space` to its direct button children, or set +`white-space: normal` on `.confirm-cost`. The caption stays visible and permanent +(the settled `"visible"` decision is untouched); it just wraps. (b) give `.pool-items` +`grid-template-columns: minmax(0, 1fr)`. + +**Principle:** WCAG 2.2 SC 1.4.10 Reflow — no two-dimensional scrolling at 320px +equivalent. The brief names it in the floor. + +--- + +### 2. Serious — the Pool items `Scroller` is inert: no edge fades, no keyboard tab stop + +**Where:** `src/app/globals.css:4859`, and the comment at `src/app/globals.css:4568-4577` +that already describes this exact failure on a different surface. + +A direct consequence of 1(b), and worth its own entry because it costs something +different. `Scroller` withdraws its own tab stop when it measures no scroll range +(`scroller.tsx:47-55`, `setScrollable(scrollWidth > clientWidth + 1 || ...)`). Because +`.pool-items`' track grew to the table's full width, `scrollWidth === clientWidth` +inside the scroller — so it reports itself unscrollable, drops `tabIndex`, and drops +both edge fades. The overflow moved one box out and became the *page's* problem +instead. The codebase has already diagnosed precisely this, on `/admin/accounts`' +crew table: *"the Scroller it escaped through never overflowed (`scrollWidth === +clientWidth`), which cost it its edge fades and, because scroller.tsx withdraws the +stop from a region with no scroll range, its keyboard tab stop as well."* That comment +names `.pool-items` by name as the only other grid-parented Scroller in the app, then +fixes the drawer and leaves this one. + +**Cost:** a keyboard operator cannot reach the loot table's overflow at all, and gets +no fade telling them there is anything to the right. The fix for the other instance +was written, measured and documented; this one was named and skipped. + +**Fix:** same as 1(b). Verify afterwards that the region reports `scrollWidth > +clientWidth` at 390px, which is the assertion that distinguishes a fixed Scroller from +one that merely stopped overflowing the page. + +**Principle:** WCAG 2.1.1 Keyboard. + +--- + +### 3. Serious — the draft strips thousands separators from the one column where they matter + +**Where:** `src/app/payouts/[id]/page.tsx:734-753`. + +The unit-price cell is `canEdit ? <InlineEdit value={item.unitPrice}> : +fmtIsk(item.unitPrice)`. `InlineEdit` renders its raw `value` when no `displayValue` +is passed, and this call site passes none. So the two shots differ, on the same ten +rows: + +| draft | finalized | +|---|---| +| `9200000.00` | `9,200,000.00` | +| `31000000.00` | `31,000,000.00` | +| `12500000.00` | `12,500,000.00` | + +The LINE TOTAL cell immediately to the right of it is `fmtIsk`'d in both states, so a +single draft row reads `9200000.00` beside `165,600,000.00 ISK`. Corp share, three +sections up on the same page, already solves this: it passes `displayValue` to +`InlineEdit` and renders `10.00%` formatted while remaining editable +(`page.tsx:449-462`). + +**Cost:** an operator checking a mispriced item against a contract window is counting +digits in an eight-to-eleven digit unformatted number — exactly the transcription +error `CopyAmountButton` exists elsewhere on this page to eliminate — and they are +doing it *only* while the operation is still editable, which is the only window in +which they can act on what they find. + +**Fix:** pass `displayValue={fmtIsk(item.unitPrice)}` at `page.tsx:736`. The edit +field keeps the raw value; only the view mode changes. Same one-line shape as corp +share. + +**Principle:** consistent component vocabulary — the same value in the same column +must not render two ways depending on a permission the reader does not see. + +--- + +### 4. Serious — pattern 2: the roster is 48 cells, 45 of which say the same three things + +**Where:** `src/app/payouts/[id]/page.tsx:952-998`. + +Across the draft's sixteen rows: SHARES is `1.00` on fifteen and `2.00` on one. +AMOUNT is `288,600,000.00 ISK` on fifteen and `0.00 ISK` on one. STATE is `– UNPAID` +on fifteen and `excluded` on one. Three columns; three facts about the whole set; and +the deviation in each is a single row. The reader scans forty-five identical strings +to find three that differ, and the three that differ are the entire content of the +table. + +This is `crewNorms` verbatim, on a wider table. + +**Cost:** a member opening the operation to see what they are owed reads a column of +fifteen identical twelve-digit numbers to find their own, which is the same number as +everyone else's. An operator checking that the split is right cannot see at a glance +that it *is* right — "everyone is on one share" is the thing they want to confirm and +it is the one thing the table does not say. + +**Fix:** the pattern the brief names. State the norm once, in the section heading's +aside or a line under it — "15 participants, one share each, 288,600,000.00 ISK; 1 +excluded" — then render only deviation in the cells: leave SHARES blank where it is +1.00 and print `2.00` where it is not, leave AMOUNT blank where it matches the norm. +Keep both channels in parity: the accessible name for a blanked cell restores the +shared value in a `visually-hidden` span, the way `/admin/sync`'s "Cadence (UTC)" +column does. + +**Principle:** the sweep's pattern 2. + +--- + +### 5. Serious — the narrow roster hides the only column that varies, and keeps the two that do not + +**Where:** whole surface, narrow viewport. Visible in +`07-payout-detail-finalized.narrow.png` and `06-payout-detail-draft.narrow.png`. + +The roster table is `log--sticky-col` inside a `Scroller`, so NAME pins and the rest +scrolls. At 390px the visible columns are NAME, SHARES, AMOUNT. STATE — and `mark +paid`, `revert`, `copy amount`, `exclude`, `remove` — are all past the right edge. + +So the phone shows the two columns that are identical on fifteen of sixteen rows and +withholds the one that is not. "Was I paid?" and "who still needs paying?" both +require a horizontal drag, per row, on the page whose entire purpose is those two +questions. + +It gets worse in the finalized state. Row height is set by the tallest cell, and the +tallest cell is the off-screen STATE cell holding the three-line payment sentence +(see finding 6). The result in `07-payout-detail-finalized.narrow.png` is a column of +rows with wildly uneven heights and large blank gaps beside single-line content — the +visible half of the table is being laid out by content the reader cannot see. It reads +as a rendering fault. + +**Cost:** a member checking a payment on their phone drags sideways sixteen times, or +gives up. An operator working a payout from a phone cannot reach `mark paid` without +the same drag on every row. + +**Fix:** at narrow widths, reorder or reflow. The cheapest honest version: move STATE +to sit under NAME in the same cell below a breakpoint (the roster already has a +one-cell stack idiom in `.stack`), and let SHARES drop out entirely once finding 4 +turns it into deviation-only. The action cell should follow NAME, not trail the +numbers. + +**Principle:** the sweep's pattern 2 compounding with responsive column priority. + +--- + +### 6. Serious — the finalized STATE cell recites the same sentence six times + +**Where:** `src/app/payouts/[id]/page.tsx:993-996` → `payment-history.tsx:38-45`. + +Every paid row renders `2026-08-10 11:57:07 UTC paid 288,600,000.00 ISK by Fleet +Commander`. Across the six paid rows in `07-payout-detail-finalized.wide.png` the +timestamp is identical, the amount is identical, and the actor is identical. The +amount is *also* already printed in the AMOUNT cell of the same row, two columns +left. So each of those rows carries the same figure twice and the same +timestamp-plus-actor as its five neighbours, and pays three lines of height for it. + +The single-payment inline case was a deliberate fix (finding 1.6 of the 2026-08-07 +walkthrough: a drawer with one line in it is a fold with nothing folded). That +reasoning is right about the fold and silent about the recital. + +**Cost:** the roster triples in height at exactly the moment it becomes a worklist, +so the nine rows that still need paying are pushed apart by six rows of text that says +nothing new. On the narrow viewport this text is not even visible — it only sets row +heights (finding 5). + +**Fix:** same treatment as finding 4. A payment run is one event: say "all six paid +2026-08-10 11:57:07 UTC by Fleet Commander" once, above the table, and let the row +carry only the `paid` token. Print the full line per row only where a payment +deviates — different actor, different amount, or a revert in its history. That also +restores the disclosure's original purpose: a row with a *history* is interesting, a +row with a payment matching the batch is not. + +**Principle:** the sweep's pattern 2. + +--- + +### 7. Moderate — pattern 3: seventy controls, one of them emphasised, and it sits above the two sections holding sixty-four of them + +**Where:** whole surface, draft state. + +Counted from `06-payout-detail-draft.wide.png` and confirmed against the source: + +| control | count | +|---|---| +| `edit` (name, date, corp share, battle report, 10 unit prices, 16 shares) | 30 | +| `exclude` | 16 | +| `remove` | 16 | +| `delete` (pool) | 2 | +| disclosure summaries (add paste, add participant, replace roster) | 3 | +| `FINALIZE`, `SAVE`, `DELETE` | 3 | +| **total pressable in `<main>`** | **70** | + +Sixty-four of those seventy are the same `btn--quiet btn--micro` grade at +`--ink-faint` (`#90877e`, 5.61:1 on `#0a0a0a`). The one gold control is `FINALIZE`, at +y≈326 of a 2,902px page — about 11% down. Below it: 2,500 pixels in which nothing is +emphasised over anything else. + +I want to be fair about what is working here. `primaryStage` (`page.tsx:234-240`) is a +genuinely good mechanism — one gold control at a time, and *which* control it is +tracks how far along the operation is. The single-primary rule is not violated. The +problem is placement and reach: the one thing directing the eye is above the two +sections that hold 64 of the 70 controls, so it directs the eye at nothing the reader +still has to do. A first-time operator scrolling into the item table meets thirty +identical `EDIT` chips with no indication that two of them (the `unresolved` rows, +already flagged by a warning 400px up) are the ones that matter. + +**Cost:** an operator fixing a mispriced item scans ten identical chips to find the +two rows the warning notice named, having lost the notice off the top of the screen +by the time they get there. + +**Fix:** two things, neither of which adds a second gold. (i) Let the row carry the +emphasis the notice already computed: the two `unresolved` rows have a `Status +tone="warn"` badge but their `edit` chip is the same grade as the eight rows that are +fine. Raise those two chips to the plain `.btn` grade — a border where the others have +none — so the warning's "which rows" is answered in the table rather than in prose +above it. (ii) Demote the roster's per-row `exclude`, which is the least-used of the +three row actions and is currently at identical weight to `remove`. + +**Principle:** the sweep's pattern 3. + +--- + +### 8. Moderate — the finalized page never says what to do next + +**Where:** `src/app/payouts/[id]/page.tsx:250-255`. + +The lede reads `finalized · frozen · 4,810,000,000.00 ISK · 6/15 paid`, and stops. +`STAGE_LABEL` maps `none` to `null`, and its comment defends this: `none` covers both +"finalized, nothing left to promote" and "read-only viewer", and only the first has a +next step, so collapsing them means a viewer is never told to act on something they +cannot touch. + +That reasoning protects the viewer case correctly and takes the operator case down +with it. An operator looking at a finalized operation with nine unpaid rows has an +obvious next step — pay nine people — and the summary line is the one place that +answers "what do I do about it". The value it needs is already computed two lines +away: `access.isOperator && paidParticipants.length < owedParticipants.length`. + +**Cost:** the operator gets a page with no primary action and no stated next step at +the exact point the operation has the most work left in it. On a partly-paid +operation reopened days later, "6/15 paid" says where it stands but not that it is +theirs to finish. + +**Fix:** add a fifth stage — `pay` — computed as operator + finalized + unpaid rows +remaining, with `STAGE_LABEL.pay = "pay the roster"`. Leave the gold alone: nine +identical rows have no one row worth promoting, and `MARK PAID`'s bordered `.btn` +grade against `revert`'s borderless one is already doing that work well. + +**Principle:** Nielsen 1, visibility of system status — the page reports state and +withholds the consequence. + +--- + +### 9. Moderate — `copy amount` appears fifteen times and copies the same string fourteen times + +**Where:** `src/app/payouts/[id]/page.tsx:1004-1008`. + +Every non-excluded row in the finalized state carries a `copy amount` button. Fourteen +of the fifteen copy the identical string. The button is a good idea and it earns its +place — transcribing a twelve-digit ISK figure by hand is the failure it was built to +remove (`copy-amount-button.tsx:5-10`) — but at a flat split it is fifteen controls +doing one control's work, and it is one of the three things per row producing finding +7's uniformity. + +`CopyAmountButton`'s `id` is also `PayFlow`'s only stable focus target across +`mark paid`/`revert` (`pay-flow.tsx:179`, `:209`), so it cannot simply be removed from +rows — the focus flow depends on it existing per row. + +**Cost:** minor per press, real in aggregate: the operator's eye passes fifteen +identical control clusters to find the nine that still need action. + +**Fix:** keep the per-row button (focus depends on it) and add the shared fact where +finding 4 puts the norm — one `copy amount` beside "one share each, +288,600,000.00 ISK". Once the norm is stated, the per-row buttons on rows matching it +can drop to a quieter treatment than the rows that deviate. + +**Principle:** the sweep's pattern 2, applied to controls rather than to values. + +--- + +### 10. Moderate — an explanatory paragraph under the Split / Roster heading + +**Where:** `src/app/payouts/[id]/page.tsx:893-908`. + +The brief calls this smell out directly, so I will not pretend it is not one: two +sentences of prose sit between the `Split / Roster` heading and the table, explaining +what the table means. In the finalized state it reads *"A payment has been recorded, +so the loot pools, roster, shares and corp share are fixed permanently. Reverting a +payment does not reopen editing: it only corrects who was paid, so revert the wrong +one and pay the right person while still frozen."* + +I want to be careful here, because this paragraph is better than most things that +match this pattern. It was moved to this spot deliberately (2026-08-07 walkthrough, +findings 1.2 + 1.7) from the Operation section, where it explained a roster +consequence nowhere near the roster. The move was right. Both halves of the +`firstPayment`/`locked` pair render in the same spot at the same weight, which is a +careful piece of construction. And its content is genuinely not derivable from the +table. + +The residual problem is that it is 40 words of standing prose above a sixteen-row +table on a page that already carries a `frozen` status token, and it says the same +thing that token says plus one clause the token cannot ("revert does not reopen +editing"). Its second sentence is the load-bearing one; the first restates `frozen`. + +**Cost:** the paragraph is above the table, so on the narrow viewport it costs four +lines before the reader reaches the first row, and it is read once and skipped +thereafter — which means the one novel clause is skipped with it. + +**Fix:** cut the first sentence (the `frozen` token already carries it) and keep the +second. Better still, attach the surviving clause to `revert` as its `ConfirmCost`, +where it is read at the moment it becomes load-bearing rather than every visit. +`RevertForm` (`pay-flow.tsx:341-362`) is the only confirm control on this page with no +`describedBy` at all. + +**Principle:** the brief's "an explanatory subtitle under a heading usually means the +table needs work". + +--- + +### 11. Minor — "Pool 1" and "Pool 2" are numbers with no meaning to the operator + +**Where:** `src/app/payouts/[id]/page.tsx:683` and `:640`. + +Pool ordering is `asc(lootPool.id)` on a random uuid — deliberately, and the reasoning +in `payout-view.ts:366-376` is sound: neither pool kind carries a creation-order +column that ranks both, so the choice was stability over meaning, which is the right +trade for the bug it was fixing. I am not re-opening that. + +What is left is that the ordinal reaches the operator. The unresolved-items notice +says *"Pool 2: Sleeper Drone AI Nexus ×2, Wrecked Drone Transceiver ×3"*, and the +operator has to count table blocks downward to find which one that is. In the draft +shot the flat pool is #1 and the appraised is #2; in the finalized shot the same two +kinds are reversed. Those are two different operations, so it is not instability — but +it does demonstrate that the number carries nothing an operator could predict, and +"Pool 2" is not a name anyone would use out loud. + +**Cost:** small; one extra glance when following a warning to the rows it names. It +matters more as the pool count grows. + +**Fix:** label the block by what the pool *is*, not by its position — the Loot pools +table already renders `appraised · sell (5th percentile)` and `flat (manual)` as +status tokens. `Appraised pool` / `Flat pool` reads as itself, and the notice can name +the same thing. Keep the ordinal only as a disambiguator when two pools share a kind. + +--- + +### 12. Minor — the loot pools table spends a full-width scrolling table on two rows + +**Where:** `src/app/payouts/[id]/page.tsx:572-623`. + +Five columns (`#`, Source, Value, Notes, actions) for two rows, and the `#` column +exists only to restate the ordinal from finding 11. At 390px the region correctly +scrolls (it is the one Scroller on the page that works), and the column it cuts off is +Notes — the only cell whose content varies and is not repeated anywhere else on the +page. That produces the tall empty row visible at the top of +`06-payout-detail-draft.narrow.png`: row 1's height is set by an off-screen note. + +**Fix:** at two or three pools this is a definition list, not a table. `Appraised · +sell (5th percentile) — 4,210,000,000.00 ISK` and `Flat (manual) — +600,000,000.00 ISK, "Citadel rigs, priced by hand off contracts"` need no columns, +and the `delete` control can sit at the end of each line the way the roster's do. The +`#` column goes away with finding 11. + +--- + +## What is genuinely good and should survive + +- **`primaryStage`** (`page.tsx:234-240`). One gold control, and *which* control it is + tracks the operation's progress rather than being pinned to one spot. Findings 7 + and 8 ask for placement and a fifth stage; neither asks for a second gold. Do not + let a fix pass add one. +- **`MARK PAID` bordered against `revert` borderless** in the finalized roster. Nine + boxes and six words, and the boxes are the remaining work. This is the one place on + the page where uniform-weight controls were correctly differentiated, and it was + done with a border rather than a colour. It is the model for finding 7's fix. +- **The `unresolved` / `manual` per-item markers** (`page.tsx:720-731`), and the + decision that `triff` — the common case — gets no badge at all. The comment + explaining it is a correct statement of pattern 2 arrived at independently. +- **The item table not being hidden behind a per-pool disclosure** (`page.tsx:672-678`). + Burying the thing the section exists to show would have been the easy answer. +- **`PayFlow`'s resume-from-where-you-were focus logic** (`pay-flow.tsx:182-213`), + including the wrap-to-first-unpaid case announcing that it moved backwards. It reads + like someone actually paid a roster with a skipped pilot in it. +- **`NotesForm`'s "· saved" as a value comparison rather than a dirty flag** + (`notes-form.tsx:101-117`). It is correct in the case where the operator keeps + typing during the round trip, which is the case a boolean gets wrong. +- **The one-slot `AppraiseForm`** (`page.tsx:782-790`) — collapsed presentation as a + prop rather than a second call site, so the dropped-lines effect survives the first + paste. Any fix pass that splits this back into two call sites reintroduces a silent + data-loss notice. +- **Amount as `numeric(20,2)` strings and bigint cents throughout.** No floats touch + money on the read side either. + +## What I could not evaluate + +- **Weighted shares.** The fixture writes `amount` directly + (`docs/design-sweep-2/capture.spec.ts.txt:210-222`) rather than through + `recalculate`, and gives one participant `shares: "2"` while paying them the same + `each` as everyone else. So the screenshots show a 2.00-share row with a 1.00-share + amount. I confirmed this is a fixture artifact and **not** a product bug — + `core/payout-split.ts:73` computes `perShare * shares` correctly. But it means + **neither shot exercises a non-flat split**, so I cannot say how the AMOUNT column + reads when the figures actually differ row to row. That matters directly to finding + 4: my proposed norm-plus-deviation treatment is right for a flat split and needs + re-checking against a genuinely weighted one. Re-seed with `setParticipantShares` + before acting on finding 4. +- **The empty operation.** No shot of an operation with no pools and no roster, which + is `primaryStage: "appraise"` — the first thing an operator ever sees after creating + one, and the state where `hasFacts` is false and most of the page is absent. The + onboarding path is unreviewed. +- **The member (non-operator) view.** Every shot is an operator. `canEdit` false strips + 30 of the 70 controls, which would materially change finding 7's count and may make + findings 4 and 5 worse rather than better — a member's roster is three columns of + near-identical values and no actions at all. +- **Arm-state rendering.** No shot shows a `ConfirmSubmit` armed, so I could not judge + whether `confirm` reads as a distinct state in a row of `EXCLUDE REMOVE REMOVE` + at the micro grade, or whether `.btn--danger` on `mark paid` reads as alarm at 28px. +- **The 320px case.** Both narrow shots are 390px. Finding 1's overflow is already + 1.98× the viewport there; I did not measure 320px, where it will be worse and where + the reflow criterion is actually set. +- **Motion.** No transitions are visible in stills, and I did not run the app. + +## Contested + +Nothing. The two settled items that touch this surface — `ConfirmCost`'s `"visible"` +mode for Finalize/Unlock, and the one-gold-per-view ration — I think are both right, +and finding 1(a) is compatible with the first: the caption should stay permanently +visible and should also be allowed to wrap. diff --git a/docs/design-sweep-2/reports/payouts-audit.md b/docs/design-sweep-2/reports/payouts-audit.md new file mode 100644 index 00000000..a9a55e95 --- /dev/null +++ b/docs/design-sweep-2/reports/payouts-audit.md @@ -0,0 +1,412 @@ +# `/payouts` — audit + +`$impeccable audit`. Register: **PRODUCT**. Read-only pass over +`src/app/payouts/page.tsx`, `src/app/payouts/access.ts`, +`src/app/payouts/pending-link.tsx`, `src/app/_components/scroller.tsx`, +`src/app/_components/ui.tsx` (`Status`, `RuleHead`), and the table/scroller +rules in `src/app/globals.css`. Shots read first, both viewports. + +## What the screenshots show, before any source + +**Wide (1440×1851).** A 1200px table of 34 rows. Every row is the same +shape: an underlined name, an ISO date, one mono status token, a right-aligned +ISK figure, and two more mono tokens. The one saturated thing on the screen is +the gold `NEW OPERATION` button, top right — until you look at the last two +columns, where 22 of the 34 rows carry two amber tokens each. Roughly 640px of +the 1200px row is ink; the largest single void is ~214px between the end of +`FINALIZED` and the first digit of the total. The word "paid" appears twice in +every row, under two headers that already say Paid and Yours. + +**Narrow (390×5699).** The page is 5,699px tall — about fourteen phone +screens. The Name column is pinned at ~70px and the names are broken +mid-syllable to fit it: `Tama gatecamp 01` renders as `Tam / a / gate / cam / +p 01`, five lines, so the row is ~150px tall. The table scrolls sideways and +`TOTAL` is cut mid-number at the right edge; `PAID` and `YOURS` are off-screen +entirely at rest. The column headers scroll off the top after the second row +and never come back. + +**Empty (both).** Wide: content stops at y≈475 of a 900px viewport, and the +page's remaining 400px is void. Narrow: an empty ledger still renders a +six-column horizontally-scrolling region, and the empty message's last few +pixels sit under the end fade. + +## Audit health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | The row's identity cell is a `<td>`; the visual pin has no aural equivalent | +| 2 | Performance | 3 | 50 rows server-rendered per page, no client cost of note | +| 3 | Responsive | 2 | Names shredded to one syllable per line at 390px; headers scroll away on a 5,699px page | +| 4 | Theming | 4 | Full token discipline; no hard-coded colour on this surface | +| 5 | Anti-patterns | 3 | Amber is the majority state, not the exception | +| **Total** | | **15/20** | **Good — address responsive and the enumeration** | + +**Anti-patterns verdict: pass.** This does not read as generated. No cards, no +gradient text, no glassmorphism, no hero metric, no icon grid, one accent +colour rationed to one button. The one tell is not an AI tell but a +signal-discipline one: the alarm colour is spent on the majority case (finding +2). Nothing here would let someone say "AI made that." + +**No Critical findings.** I found no WCAG 2.2 AA failure on this surface. +Contrast measured in rendered sRGB, worst cases: `--ink-faint` `#90877e` +headers on `--hull` `#151514` = 5.21:1; `--ink-dim` `#bab3a9` cells on `--void` +`#0a0a0a` = 9.54:1; `--signal-warn` `#ff9f5f` = 9.75:1; `--gold` `#f1c035` on +`--void` = 11.65:1; the scroller frame `--rule-strong` `#787370` = 4.24:1 +against the 3:1 UI-boundary floor. All clear. + +--- + +## Findings, worst first + +### 1. Serious — the word "paid" is printed 68 times, and the fact a member came for is printed nowhere + +**Where:** `src/app/payouts/page.tsx:295-377` (the Paid and Yours cells), and +`:223-228` (the `RuleHead` aside that could hold the set fact). + +Every Paid cell reads `0/5 paid` under a column header that says **Paid**. +Every Yours cell reads `paid` / `unpaid` under a column header that says +**Yours**. On the 34-row shot that is 68 printings of a word the header has +already said — this is exactly the shape `/admin/sync` fixed once with +"Cadence (UTC)", and exactly what pattern 2 is looking for. Meanwhile the +question the page exists to answer at a glance — *am I owed anything?* — is +answered only by scanning 34 rows of one column, because the set-level fact is +never stated. The `RuleHead` aside says `34 total` and stops there. + +**Cost:** A member checking at 1am whether the corp owes them anything reads +thirty-four rows of a column, in a 5,699px page on a phone, to compute a number +the page already has in hand. + +**Fix:** Two moves, both already in the codebase. + +- Strip the repeated word from the visible cell (`0/5`, `—` for the Yours + column's own state) and restore it per-row in a `.visually-hidden` span so + the accessible name does not lose it. That is the `Cadence (UTC)` mechanism + verbatim, and this file already uses the same `aria-hidden` glyph + + `visually-hidden` words idiom four times (`:283-284`, `:298-299`, + `:353-355`, `:360-363`) — it is not a new pattern here. +- Put the deviation in the aside, the way `crewNorms` does on `/account`: + `34 total · 22 unpaid for you`. Compute it from `ops`, which is already in + hand, so it costs no query — and label it under the same honesty rule + `complete` / `quantity` already enforces at `:114-120`, since a paged result + can only claim what is on the page. + +**Principle:** Pattern 2 (total enumeration). PRODUCT.md principle 2, state +before action. + +--- + +### 2. Serious — amber is the majority state, so it has stopped meaning "this one" + +**Where:** `src/app/payouts/page.tsx:305-313` and `:342-347`. Also visible +without a location: it is the composite that fails, not either branch. + +The code already made this argument and stopped one branch short. `:314-323` +reasons, correctly, that a draft mid-payment is normal work and rendering it +amber "burned the alarm colour on nothing" — and sets it neutral. The finalized +branch kept the amber. On the supplied realistic fixture that produces 22 of 34 +rows carrying **two** `--signal-warn` tokens each (sampled from the shot at +`#ff9f5f`, confirming `Status tone="warn"` on both Paid and Yours) — 44 amber +tokens on one screen, against one gold button. The two are also redundant with +each other: on a finalized operation, `0/5 paid` amber and `unpaid` amber are +the same claim twice, side by side. + +**Cost:** An operator opening the ledger to find the operation that has stalled +sees two-thirds of it lit amber and has nothing to sort on; the one genuinely +old unpaid operation is indistinguishable from last week's. + +I am reporting this against the fixture the sweep supplied, and it is +fixture-dependent in one direction only: a corp that pays out promptly shows +fewer amber rows. It is not fixture-dependent in the direction that matters — +any backlog at all reproduces it, and a backlog is when someone opens this page. + +**Fix:** Pick one carrier, not two. Keep the amber on `Yours` (the viewer's own +stake, the reason the column was added) and let `Paid` state the fraction +neutrally — the fraction is already self-evidently short of the denominator. +Or, if the fault really is "finalized and still unpaid", make it a fault of +*age*, not of state: amber past some interval since `occurredAt`, neutral +before it. Either way the alarm colour lands on a minority of rows again. + +**Principle:** DESIGN.md's own "colour only when the state is actionable"; +"saturated colour occupies well under 10% of any screen". + +--- + +### 3. Serious — the only long table in the app whose column headers scroll away + +**Where:** `src/app/payouts/page.tsx:229` and `:239`. + +`<Scroller label="Operations log">` with `<table className="log log--payouts +log--sticky-col">`. The app's other three long tables all take the pair: +`admin/accounts/page.tsx:445-446`, `admin/audit/page.tsx:563-564`, and +`payouts/[id]/page.tsx:911-913` each use `Scroller tall` + +`log--sticky-head` + `log--sticky-col`. `/payouts` takes the pin and not the +sticky head, on a page whose size is 50 (`PAYOUTS_PAGE_SIZE`, +`src/services/payout-view.ts:49`). At 390px the header is gone after the second +row, and what remains is three near-identical mono tokens per row with nothing +naming which column is which. There is also no top pager: `.pager--top` exists +and `/admin/audit` renders one (`admin/audit/page.tsx:216`), so on the 50-row +case the only way to page is at the bottom of about 7,500px of table. + +**Cost:** A member on a phone panning right to read a figure has the row's +identity (the pin works) but not the column's, and has to scroll back to the +top of the page to find out whether the amber token they are looking at is +`Paid` or `Yours`. + +**Fix:** Adopt the same pair the other three long tables use — `tall` on the +`Scroller`, `log--sticky-head` on the table. State the tradeoff honestly when +doing it: `.scroller--tall` caps the region at 80svh, which puts the ledger in +a nested scroll box on a phone, and that is a real cost this page has so far +avoided. If it is judged too high, the alternative is to shorten the rows +(finding 4) so more of the table fits between header sightings — but the pin +without a sticky head is the inconsistent option, not a third position. + +**Principle:** Consistency with the app's own established mechanism for the same +problem. + +--- + +### 4. Serious — names are broken mid-syllable, and the page pays for it fourteen times over + +**Where:** `src/app/globals.css:1364-1366` (`.log--payouts td:first-child { +overflow-wrap: anywhere }`). + +`overflow-wrap: anywhere` — unlike `break-word` — participates in min-content +width, which is the point: it is what stops one unbroken 60-character +operation name from dragging the pin across the region, and `payouts.spec.ts` +holds the pinned column under 60% of the region at 320px and 390px. But there is +a ceiling with no floor. The table algorithm therefore shrinks Name to near its +minimum for *ordinary* names too, and `Tama gatecamp 01` renders as five lines +of two-to-four characters. Every row becomes ~150px tall and the 34-row page +becomes 5,699px. + +**Cost:** A member scanning for an operation they remember by name reads it one +syllable per line, and scrolls fourteen phone screens to reach the end of a +list that could be five. + +**Fix:** Add a floor the ceiling test still passes: `min-width: 11ch` on +`.log--payouts th:first-child, .log--payouts td:first-child`. 11ch is ~88px of +the measured 286px region at 320px — 31%, comfortably under the 60% the spec +asserts — and it holds ordinary names to two lines instead of five while +leaving `overflow-wrap: anywhere` in place for the pathological name it was +written for. The cost is ~20px more horizontal scroll for the columns that +already do not fit, which is scroll, not loss. + +**Principle:** Pattern 1 (the page runs long instead of wide). WCAG 1.4.10 +reflow is met either way; this is about what the reflow costs. + +--- + +### 5. Moderate — the pin has no aural equivalent: the row's identity cell is a `<td>` + +**Where:** `src/app/payouts/page.tsx:255-257`. + +The pinned column exists, per the CSS comment at `globals.css:1315-1322`, +because "panning right to read Total costs the reader the row's identity … +the one column every other cell is meaningless without." That argument is +exactly as true for a screen reader user moving cell to cell in table +navigation mode, and they get nothing: the Name cell is a `<td>`, so each cell +announces its column header and not its row. Column headers are correct +(`scope="col"` on all six); it is the row header that is missing. The table +also carries no accessible name of its own — the `Scroller`'s `role="region"` +label covers the region, not the table. + +**Cost:** A screen reader user reading down the Total column hears six ISK +figures and no operation names, which is the same defect the pin was built to +prevent, minus the pin. + +**Fix:** `<th scope="row">` on the Name cell. **This needs a CSS carve-out and +a naive change will break two other things:** `.log th` (globals.css:1142-1151) +sets `background: var(--hull)`, `color: var(--ink-faint)`, +`letter-spacing: var(--track-label)` and — the dangerous one — +`white-space: nowrap`, which would defeat `overflow-wrap: anywhere` on the +first column and blow the 60% pin ceiling `payouts.spec.ts` asserts. Scope the +body override (`.log--payouts tbody th`) back to the `td` treatment before +changing the element. Optionally add `<caption class="visually-hidden">` for the +table's own name — but note the known-open `<caption>` prose-length item on +`/account` and keep it to three words if so. + +**Principle:** WCAG 1.3.1 Info and Relationships (header association). + +--- + +### 6. Moderate — 214px of nothing in the middle of every row, and the class that fixes it is already in the stylesheet + +**Where:** whole surface, wide viewport. Measured from +`04-payouts-full.wide.png`: within a 1200px row, ink occupies roughly +x=120-333 (name), 464-541 (date), 615-662 (status), 876-1035 (total), +1050-1128 (paid), 1195-1252 (yours). The single largest gap is ~214px between +the status word and the first digit of the total. + +Every column is auto-sized and `width: 100%` distributes the slack across all +six, so Status — a column holding one of two fixed words — is stretched widest +of all. `.log__col--fit` exists for precisely this +(`globals.css:1284-1286`: "Shrink-to-content in an auto-layout table. Applied +to every column except the one that should absorb the leftover width") and is +used by `/account` and `/admin/accounts`. `/payouts` uses it on nothing. + +**Cost:** Reading a row means tracking a horizontal association across 214px of +empty ground; a mis-tracked row on a ledger is a member reading someone else's +total. + +**Fix:** A `<colgroup>` with `log__col--fit` on Date, Status, Total, Paid and +Yours, letting Name absorb the leftover — which is also the column that most +wants it, since it is the row's identity and its link target. This narrows the +table at wide and does nothing at narrow, where the min-content case governs. + +**Principle:** Pattern 1 (unshaped field). + +--- + +### 7. Moderate — the page's 34 primary controls are 22px targets, under this system's own floor + +**Where:** `src/app/payouts/page.tsx:256` — the row's `PendingLink`, an inline +anchor in a cell padded `--s-2` (8px) top and bottom around a `--t-data` +(14px × 1.55) line box. + +The target is the ~22px line box, not the 37px row. WCAG 2.5.8 is met through +the spacing exception — the row pitch measures ~39px at 1440px, so no two +targets' 24px circles intersect — so this is not a violation. It is a +violation of DESIGN.md's stated 28px in-row grade, and the codebase has already +made this exact argument once, for a *less* important control: +`globals.css:1153-1170` pads out `.log th a` because "a bare inline anchor here +is a 17px line box floating in a 41px header cell … the objection is DESIGN.md's +own floor, which says 28px is the smallest target this system has." The header +sort links got the fix; the 34 row links, which are the entire navigation of +this surface, did not. (`/admin/accounts` records the same 21.7px measurement +at `page.tsx:301`.) + +**Cost:** A member on a phone, thumb on a moving train, misses the name link and +hits dead cell; the press does nothing and there is no feedback saying why. + +**Fix:** The precedent's own mechanism — `display: inline-block; padding-block: +0.22rem; margin-block: -0.22rem` on `.log--payouts td:first-child a` — which +grows the target to ~29px without growing the row, so the pin measurements and +row-pitch figures elsewhere stay valid. + +**Principle:** DESIGN.md's two hit-target grades (28px in-row). + +--- + +### 8. Moderate — the lede is an explanatory subtitle under an H1, and it now under-sells the page + +**Where:** `src/app/payouts/page.tsx:162-165`. + +"Your own share of each operation is on your account." The brief names this +shape as a smell, and here it has additionally gone half-stale: the `Yours` +column was added precisely so a member can answer "was I paid?" without leaving +the page. The one thing `/account` still adds is the ISK **amount**, which the +`viewerState` docblock explains cannot be shown here. The sentence does not say +that; it says your share lives somewhere else, on a page that now shows your +share's state in its last column. + +**Cost:** A member reads a sentence telling them this page is not for them, +directly above a table with a column named after them. + +**Fix:** Name the thing the other page actually adds, or drop the line. "Amounts +owed to you are on your account." is one sentence shorter in meaning and +accurate. Dropping it entirely is also defensible — the `Yours` column and the +nav both point there. + +**Principle:** The brief's "an explanatory subtitle under an H1 is a smell"; +DESIGN.md's copy rule that every word earns its place. + +--- + +### 9. Minor — the empty state renders a six-column scroll region over nothing + +**Where:** `src/app/payouts/page.tsx:380-407`, and see +`03-payouts-empty.narrow.png`. + +An empty ledger still draws six column headers in a horizontally-scrolling +region at 390px, so the first thing a new corp sees is a scrollbar under a +table with no rows. Two smaller things travel with it: the message's last ~10px +sits under the 24px end fade (`.log__empty-text` caps at `100vw - 2×--s-5` = +342px inside a ~356px region, and the fade overlays 24px of it), and the +`RuleHead` aside vanishes at zero rows (`:115-120` returns `undefined`), so the +one case where a count is genuinely informative — "nothing matches this +filter" — is the case that shows no count. + +**Cost:** Small. A first-run operator meets a scrollbar before they meet a row; +a filtered-to-nothing reader loses the "0" that would confirm the filter ran. + +**Fix:** Render the `<thead>` only when `ops.length > 0`, and let the three +empty branches stand alone in the frame. Optionally emit `0 shown` in the aside +for the `noMatches` branch specifically, where it is a finding rather than an +absence. + +**Principle:** Empty states are a design surface, not a fallback. + +--- + +### 10. Minor — the page does not name the column it chose + +**Where:** `src/app/payouts/page.tsx:145` — `className="page"`. + +`.page--wide` exists (`globals.css:691-700`) with a comment explaining that it +is there "so the page states which column it chose instead of 'wide' reading as +whatever 'narrow' wasn't." One file in the app uses it +(`admin/access-lists/page.tsx:133`). `/payouts` is a wide page relying on the +bare default. Zero rendered difference; it is a legibility point for the next +reader. + +**Fix:** Add `page--wide`, or delete the class as a convention that did not take. +Either is better than one adopter. + +--- + +## What is genuinely good and should survive + +- **The `Scroller` contract.** `role="region"` + `aria-label` always, `tabIndex` + granted only while there is something to scroll, a `:focus-visible` gold + outline, and a `ResizeObserver` rather than a resize listener so a region + inside a collapsed disclosure recovers its stop. This is better than most + production scroll regions and none of it should be simplified. +- **The dash idiom.** `aria-hidden` em dash beside a `.visually-hidden` phrase, + with the reasoning (an `aria-label` on a bare span is silently dropped) + written down at the call site. Four instances, and the hidden text differs + per case because the claim differs — `not on this roster` versus `roster has + unresolved names`. Do not collapse these into one shared string. +- **Three distinct empty branches**, each with the right exit: past-end, + no-match, and genuinely-empty, and the genuinely-empty one is further split on + whether the reader can act on it. Most tables ship one. +- **`total` versus `shown`.** `:108-120` refuses to call a page count a total + unless the page provably is the whole list, and forces `shown` on any filtered + result. Keep this exactly as it is when adding the set fact from finding 1. +- **Shape parity on the status tokens.** Circle / triangle / square / hollow, so + the tone survives a colour-blind read, plus the word itself. Finding 2 is + about how *often* the amber fires, not about the token. +- **Input hygiene.** `one()`, `statusParam()` and the `q` trim each exist + because a specific past failure is named in the comment, and `filterParams` + is single-sourced so the two consumers cannot drift. +- **`PendingLink`.** Soft navigations on this page say they were pressed, with + the reduced-motion collapse leaving a visible mark rather than an invisible + one. +- **`.filter-form__actions .btn--quiet` already restores 36px.** I went looking + for a `clear` control at the in-row grade next to a 36px `Filter` and it is + not there — `globals.css:2880-2884` fixed it deliberately. Not a finding. + +## What I could not evaluate, and why + +- **A live 320px render and 200% zoom.** No running app in this worktree (the + surface needs a session and a database), so both are reasoned from the 390px + capture, the measurements the stylesheet records against `.log--payouts` + (736px of table against a 286px region at 320px), and the ratio assertions in + `e2e/payouts.spec.ts:3715-3820`. The 320px figures I quote in finding 4 are + derived from those, not measured by me. +- **Hover, focus-visible and in-flight states.** The shots are at-rest only. + The `PendingLink` mark, the row hover tint, the `Scroller` focus ring and the + `Submit` busy state are all read from source and none were seen rendered. +- **The `clear` control and both pager links.** Absent from both shots — 34 rows + is under the 50 page size, and no filter is applied — so their layout, + wrapping and spacing at either viewport are unverified. +- **Screen reader announcement order**, including whether the region label, + column headers and the `visually-hidden` phrases compose into a sensible row + reading. Asserted from markup, not heard. + +## Contested — settled items I think are worth one challenge + +None. Every settled item I brushed against on this surface (dark ground, the +`--void` chroma, gold rationed to one action, `.st--ok` at `--ink-dim`, the two +hit-target grades, one column origin, no cards) is right for this page, and two +of them — the `.st--ok` decision and the disabled-control rule — are load-bearing +for findings above rather than in tension with them. diff --git a/docs/design-sweep-2/reports/payouts-critique.md b/docs/design-sweep-2/reports/payouts-critique.md new file mode 100644 index 00000000..62590108 --- /dev/null +++ b/docs/design-sweep-2/reports/payouts-critique.md @@ -0,0 +1,332 @@ +# `/payouts` — critique + +Register: product. Shots read before source: `04-payouts-full.wide.png`, +`04-payouts-full.narrow.png`, `03-payouts-empty.wide.png`, +`03-payouts-empty.narrow.png`. + +## What I see, before explaining any of it + +**Wide, 34 operations.** A single 1200px-wide table under two rule-headed +sections. Left to right the row reads: an underlined operation name, an ISO +date, a small `draft`/`finalized` chip, a very long right-aligned ISK figure, +then two more chips. The eye lands on the gold **New operation** button, then +falls into the table and finds nothing to catch it — 34 rows of identical +height, identical weight, no grouping, no emphasis. Reading down the two +right-hand columns: **every one of the 34 rows says `0/5 PAID`, and every one of +the 34 rows says `UNPAID`.** Those two columns carry no row-to-row variation at +all in this fixture. The 26 finalized rows render both of those chips in warn +amber, so the screen holds roughly 52 amber tokens, and the only thing amber +distinguishes is finalized-from-draft, which the Status column already said in +words one column to the left. Every Total cell ends in the same three +characters, ` ISK`. There is a wide horizontal void in every row between where +`FINALIZED` ends (~x=697) and where the money starts (~x=880), so the eye +travels 1200px per row across mostly nothing. + +**Narrow, 390px, same 34 operations.** This is the one that stops you. The page +is 5699px tall. The table scrolls horizontally with Name pinned, and the pinned +Name column has been squeezed to roughly 40px, so *the pin renders the thing it +exists to preserve as an unreadable vertical ribbon*: "Structure bash, J155843 +03" comes down the left edge broken mid-word into three- and four-character +fragments, about eight lines deep, which is what makes each row ~150px tall. +At rest the reader sees ribbon, date, chip, and a truncated total (`412,`). +Paid and Yours are entirely off-screen. To answer "was I paid?" on a phone you +pan right on each of 34 rows, and panning is what the pin was added to survive. + +**Empty, wide.** Correct short sentence, one exit hint, and then 425px of +nothing below it in a 900px viewport. **Empty, narrow.** The zero-row table +*still* presents a horizontal scrollport: six column headers, the last one +clipped to `YOU`, and the message pinned under them. + +--- + +## Findings, worst first + +### 1. The narrow layout defeats its own pin, and the fix already exists in this repo + +**Severity:** Critical +**Where:** `src/app/globals.css:1289-1366` (the budget comment and +`.log--payouts td:first-child { overflow-wrap: anywhere }`), +`src/app/payouts/page.tsx:239`, `e2e/payouts.spec.ts:3808-3818` + +The budget comment is honest and its arithmetic holds: six columns are 736px +against a 286px region at 320px, every abbreviation available saves 200px, and +that still lands at 536px. Column-dropping was considered and rightly rejected +(it would collapse the `/payouts`-vs-`/account` distinction R3 draws). So the +table scrolls and Name is pinned. That reasoning was sound *at the time it was +written* — but it enumerates only two alternatives, abbreviate and drop, and a +third was invented in this same stylesheet afterwards and never came back here. +`.log--crew` (`src/app/globals.css:5450-5476`) hits the identical wall, cites +`.log--payouts`'s own comment as the precedent for refusing to drop columns, and +then **reflows**: `<thead>` hidden below 30rem, every `<td>` a labelled block +via a real `.crew__label` element rather than `::before` generated content, so +both channels keep every fact. Nothing is hidden from anyone, which is the +property the pin was chosen for, and the ribbon problem cannot arise because no +column is competing for width. + +The measured consequence of not doing that here: the comment itself records the +pin at **69px of a 286px region, 24%**, and `overflow-wrap: anywhere` — added +for the good reason that one 60-character operation name would otherwise widen +the column to 637px for every row — is what turns 69px into mid-word breakage. +The guardrail is one-sided by construction: `payouts.spec.ts:3813-3818` asserts +`cellWidth / regionWidth < 0.6`, a **ceiling and no floor**. There is no +assertion that a pinned name is legible, which is exactly the side that failed. + +**Cost:** A member checking on a phone at 1am reads a 5699px page on which every +operation's identity is a vertical stack of three-letter fragments, and the two +columns that answer "was I paid?" are off the right edge on all 34 rows. The +corp's transparency mechanism does not function on the device it is most often +opened on. + +**Fix:** Apply the `.log--crew` reflow to `.log--payouts` below the same +breakpoint — `<thead>` hidden, each `<td>` a block carrying a real label +element, `.log--sticky-col` and the Scroller dropped at that width since there +is nothing left to scroll. Rewrite the `payouts.spec.ts` pin block as a reflow +assertion (labels present, no horizontal overflow, `thead` hidden) rather than +loosening the 60% ceiling; keep the pin above the breakpoint where it earns its +keep. Row height rises per row but total page height falls hard, because the +current height is driven by the eight-line ribbon, not by content. + +**Principle:** Product register — "responsive behavior is structural (collapse +sidebar, responsive table, breakpoint-driven columns), not fluid typography." +And R4 parity, which the crew reflow was built to satisfy. + +--- + +### 2. Total enumeration: ` ISK` 34 times, `paid` 34 times under a header that says Paid + +**Severity:** Serious +**Where:** `src/app/payouts/page.tsx:245-249` (headers), `:288`, `:301-324` + +Two facts are repeated on every row and belong to the set, not the row: + +- **`ISK`.** Every operation in this system is denominated in ISK; there is no + second currency in the schema. 34 repetitions of a constant suffix, inside the + one column the reader is trying to compare figures down. +- **`paid`.** The cells read `0/5 paid` under a `<th scope="col">` that already + reads **Paid**. A screen reader traversing the column hears "Paid, 0 of 5 + paid" on every row; a sighted reader reads the word 34 times below its own + label. + +This is `/admin/sync`'s "Cadence (UTC)" fix in miniature, twice over — state the +shared fact in the header, strip it from every row's visible text, restore it +per-row in a `visually-hidden` span so no accessible name is lost. Roughly 34px +of row width comes back per row from `ISK` alone, which is real money against +the narrow budget in finding 1. + +**Cost:** The reader scanning for the largest operation counts comma groups +through 34 repetitions of a constant, and the accessible reading of the Paid +column stutters on every row. + +**Fix:** Header `Total (ISK)` with a per-row `<span class="visually-hidden"> +ISK</span>` beside the figure; header `Paid` with cells reading `0/5` and the +word carried in a `visually-hidden` span. Note the interaction with finding 1: +above the breakpoint the header is always on screen, and below it the reflow's +per-cell label carries the unit, so the fact survives at both widths. + +**Principle:** Sweep pattern 2 — "a value repeated identically on every row when +it is really one fact about the whole set." + +--- + +### 3. The page never states anything about the set it is showing + +**Severity:** Serious +**Where:** `src/app/payouts/page.tsx:162-165` (the lede), `:223-228` (the +quantity aside) + +The only aggregate this page produces is `34 total`, which is a row count and +tells the reader nothing they could act on. Everything else is per-row. The H1 +is followed instead by an explanatory subtitle — *"Your own share of each +operation is on your account"* — which is the smell the brief names: a caption +explaining what the table means, standing in for the table meaning something. + +Compare the reference surface. `/account` — a *personal* page, with less at +stake — leads with **"6 characters need attention"** and **"no characters on the +map"** on the H1's own line. The corp-wide ledger, whose stated job is +reconciliation, leads with a signpost to somewhere else. + +The shot makes the cost concrete: 34 rows, all `0/5 paid`, all `unpaid`, and no +line anywhere saying *"26 finalized operations, none of them paid out"*. That +sentence is derivable from data already in hand (`ops`, at zero query cost) and +would be the single most useful thing on the page. + +**Cost:** An operator opening this to find out what is outstanding must read 34 +rows and hold a running count, on a page whose whole purpose is to make that +count visible. A member wanting "is anything owed to me" gets a link to another +page instead of an answer. + +**Fix:** Replace the lede with a set-level reading on the H1 row, in the +`crewNorms` shape: measure deviation against the set, state it once. Something +in the register of *"26 finalized, none paid"* / *"all operations settled"*, +warn-toned only when the finalized-and-unpaid count is non-zero. Keep the +`your account` link, demoted — it is a signpost, not a lede. +I am not re-opening the closed "was I paid?" item; the Yours column stays. This +is additive, and it is what makes the column mostly unnecessary to read. + +**Principle:** Sweep pattern 2's remedy (`crewNorms`); "an explanatory subtitle +under an H1 is a smell." + +--- + +### 4. Fifty-two amber tokens on one screen, each individually correct + +**Severity:** Serious +**Where:** whole surface. The two decisions composing it are +`src/app/payouts/page.tsx:305-312` and `:342-347` + +Every rule here is defensible in isolation. A finalized operation with unpaid +rows genuinely is the stalled case (`page.tsx:305-312` argues it well). A draft +mid-payment is deliberately neutral, and that restraint is right. But 26 of the +34 rows in the realistic fixture are finalized-and-unpaid, and each contributes +**two** warn tokens — `--signal-warn`, rendered `#ff9f5f`, against `#0a0a0a` — +so the screen is a field of amber in which amber distinguishes nothing. The one +operation that actually needs chasing is indistinguishable from the twenty-five +that are simply awaiting a normal payout run. + +Worse, the amber is redundant with information already present: on this surface +a warn Paid chip and a warn Yours chip appear on exactly the rows whose Status +column says `finalized`. The alarm colour is currently a second, louder +rendering of the Status column. + +**Cost:** The operator who opens this to find the one stalled operation cannot +find it by looking, only by reading, which is the failure the colour was +introduced to prevent. Once every row is amber, no row is. + +**Fix:** Reserve warn for deviation from the set, not for membership in the +common case. With finding 3's aggregate in place, the shared state is stated +once at the top and rows can drop to `--ink-dim`; amber then marks only rows +that deviate from what the header just said — the same measure-against-the-set +logic `isNominal`/`crewNorms` runs on `/account`. If a stronger per-row signal +is wanted, age is the axis that actually varies (finalized longest ago and still +unpaid), not the binary that Status already shows. + +**Principle:** Sweep pattern 3 — repeated identical signals at uniform weight, +where nothing directs the eye. Also the settled `.st--ok` reasoning, applied to +warn: a state that has to shout on every row is competing with the one row that +isn't. + +--- + +### 5. Paid and Yours are two adjacent columns running one tone machine on correlated facts + +**Severity:** Moderate +**Where:** `src/app/payouts/page.tsx:295-377` + +The two cells duplicate each other's tone logic explicitly — the Yours docblock +says so: *"`paid`/`unpaid` reuse the neighbouring Paid column's own tone +logic."* They are the last two of six columns, which puts both of them off the +right edge at narrow (finding 1) and puts the viewer's own answer furthest from +the row's identity at wide. In the fixture they are also perfectly correlated: +`0/5 paid` + `unpaid` on all 34 rows, which is what a reader will usually see, +since a member is normally on the roster of the operations they are looking at. + +The column itself is settled and stays. Its **position** is not: "was I paid?" +is the member's question, and it is answered last, after four columns of +corp-wide bookkeeping. + +**Cost:** The member — the larger audience for this page, since any member reads +it and only operators act on it — travels the full width of the row to reach the +one cell addressed to them, and on a phone never reaches it at all. + +**Fix:** Move Yours immediately after Name (or, once finding 1 lands, put it +first in the reflowed block), so the viewer's own state sits next to the +operation's identity. Keep Paid where it is, as corp bookkeeping. This costs +nothing in width and makes the narrow layout answer the member's question +without any panning even before the reflow. + +**Principle:** Product register, "predictable grids... consistency IS an +affordance" cuts the other way here: the most-asked question should not be in +the column position reserved for the least-asked. + +--- + +### 6. The empty state presents a horizontal scrollport with nothing in it + +**Severity:** Minor +**Where:** `src/app/payouts/page.tsx:229-251`, visible in +`03-payouts-empty.narrow.png` + +With zero rows the table still renders six `<th>`s inside a `Scroller`, so at +390px the header row overflows (`YOURS` clipped to `YOU`) and the reader is +handed a scrollbar over a single sentence. The `.log__empty-text` sticky rule is +doing real work to keep the message on screen — work that only exists because +the empty table is being made to scroll. + +**Cost:** Small, but it is a first-run impression: the first thing a new corp +sees on this page is a broken-looking clipped header. + +**Fix:** Falls out of finding 1's reflow for free at narrow. Above the +breakpoint, consider suppressing `<thead>` when `ops.length === 0` — six column +promises over an empty set teach nothing. + +**Principle:** Product register — "empty states that teach the interface." + +--- + +### 7. Full-precision ISK across a 3-order-of-magnitude range + +**Severity:** Minor +**Where:** `src/app/payouts/page.tsx:288` (`fmtIsk`) + +The column spans `412,000,000.00` to `14,008,000,000.00` and asks the reader to +rank by counting comma groups. Right-aligned monospace helps and is the right +call; the ledger's need for exact figures is real and I am not proposing to +remove precision. Noted only because the budget comment already identified +`12.35B` as an available saving and rejected it on the narrow-width argument +alone — the *legibility* argument for it at wide was never weighed, and finding +1 changes the constraint that decided it. + +**Fix:** Optional and low confidence. If tried, abbreviate in the cell with the +exact figure in a `title`/`visually-hidden` companion, never abbreviate alone. + +--- + +## What is genuinely good and should survive + +- **The three-way empty state** (`page.tsx:380-407`). `pastEnd`, `noMatches` and + first-run are three genuinely different sentences with three different exits, + and the reasoning for splitting them — telling an operator their data is gone + — is exactly right. Most tables ship one string for all three. Do not collapse + these when reflowing. +- **`complete` vs `shown`** (`page.tsx:114-120`). Refusing to say "34 total" + unless the page provably *is* the whole list, and forcing `shown` under any + filter, is a level of honesty about counts that most products never reach, and + it costs no `COUNT(*)`. +- **The dash idiom**, applied identically in five places: `aria-hidden` em dash + plus `visually-hidden` words, never `aria-label` on a bare span. Both channels + in parity, one pattern, no drift. Keep it as the model when the reflow adds + per-cell labels. +- **Draft-mid-payment is neutral, not amber** (`page.tsx:314-324`). The + restraint is correct and is *not* the cause of finding 4 — the finalized + branch is. Do not "fix" finding 4 by making drafts louder. +- **Gold spent exactly once**, on New operation, and hidden rather than disabled + for non-operators. Filter is deliberately `.btn`, not `.btn--primary`. +- **The pager carries filters forward and offers `← Latest` off `cursor` rather + than `nextCursor`**, so the last page — the one that previously had no control + at all — keeps a way back. +- **`PendingLink`** on all three soft navigations, with the reasoning for + rejecting `loading.tsx` (it would blank the header) recorded. + +## What I could not evaluate, and why + +- **Interaction states.** Hover, focus, `:active`, the `.link-pending` mark, the + Scroller's keyboard focusability and its `tabIndex` flip — static fullPage + shots only, and I am read-only, so I did not run the app. +- **How much of finding 4 is fixture.** All 34 rows carry `0/5` and `unpaid`, so + I cannot tell from the shot what the real distribution of paid/unpaid looks + like in production. Finding 4's *mechanism* holds regardless (warn fires on + finalized-and-unpaid, which is the steady state between finalizing and paying + out), but its severity scales with how long operations sit in that window. + Findings 1, 2, 3 and 6 do not depend on the fixture at all. +- **320px and 200% zoom.** Shots are 1440 and 390 only. Finding 1's figures at + 320px are quoted from the stylesheet's own measurements, not remeasured by me. +- **Filter behaviour end to end.** I traced the code path (`statusParam`, `one`, + `filterParams`, the cursor drop) and it reads correct, but no filtered shot + exists, so I did not see `noMatches` or the `clear` control rendered. + +## Contested — settled-taste items I am challenging + +None. Nothing in the settled-taste list is doing damage on this surface, and the +"no cards" rule in particular is not threatened by the reflow in finding 1 — +labelled blocks inside `<tr>`/`<td>` keep the table's semantics and add no +panel, border box, or third card exception. diff --git a/docs/design-sweep-2/reports/payouts-new-audit.md b/docs/design-sweep-2/reports/payouts-new-audit.md new file mode 100644 index 00000000..c0438cc8 --- /dev/null +++ b/docs/design-sweep-2/reports/payouts-new-audit.md @@ -0,0 +1,326 @@ +# `/payouts/new` — audit + +`$impeccable audit`. Register: **product**. Read-only pass; no source was +modified. + +Screenshots read before source: `05-payouts-new.wide.png` (1440×1477) and +`05-payouts-new.narrow.png` (390×1546), both `fullPage`. + +## What the screenshots show, before any explanation + +**Wide.** A page stamp, an H1, a two-line lede, then a single bordered panel +starting at x=120 and ending at x=731 — 611px of a 1248px page column. Nothing +is to the right of it, for the panel's entire 1,130px height. Inside: three +short fields (Name, Date pre-filled `08/10/2026`, Battle report), a 230px-tall +textarea, a 190px-tall textarea, and one gold button. The capture is 1,477px +tall. On a 1440×900 window that is 1.64 screens for six inputs, and the gold +button — the only primary action on the surface — sits roughly 413px below the +fold, as does the entire ROSTER section. Half the page column is empty and the +page is scrolling. + +**Narrow.** The same stack, 1,546px tall on an 844px viewport. Fields fill the +column properly; the loot label wraps to two lines. Nothing overflows +horizontally. The dark circle overlapping the loot label at x≈38 is the Next +dev-overlay indicator, not app UI — I did not count it. + +Measured colours, converted from OKLCH to rendered sRGB rather than judged in +the authoring space: `--void` `#0a0a0a`, `--hull` `#151514`, `--rule-strong` +`#787370`, `--ink` `#ece7de`, `--ink-faint` `#90877e`, `--gold` `#f1c035`. + +## Audit health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | Rejection is announced twice and the field in error is never identified programmatically | +| 2 | Performance | 4 | Five controlled fields, one client leaf, no images, nothing to optimise | +| 3 | Responsive | 3 | 320px and 200% zoom both hold; the wide viewport is the one that fails, by running long instead of wide | +| 4 | Theming | 4 | Every value is a token; no hard-coded colour in either file or in `.form-panel`/`.field` | +| 5 | Anti-patterns | 4 | The panel is one of the two sanctioned card exceptions and earns it; no other tell | +| **Total** | | **18/20** | Excellent — but see finding 1, which the score band undersells | + +## Anti-patterns verdict — pass + +No AI tells. `.form-panel` is a card, and it is one of the two the system +sanctions by name; it carries no registration ticks (globals.css:3212-3221 +argues why, correctly). No gradient text, no glass, no hero metric, no card +grid, no side-stripe border — `.notice` is explicitly commented "Full border, +never a side stripe" (globals.css:3427). Structure is hairline rules and mono +section labels. This does not read as generated. + +--- + +## Findings + +### 1. The panel gives the form a ground; it does not stop the page running long instead of wide + +- **Severity:** Serious +- **Where:** whole surface. Anchored at `src/app/globals.css:3222-3228` + (`.form-panel { max-width: var(--measure) }`) and + `src/app/payouts/new/new-operation-form.tsx:126-162` (the two textareas + stacked in one column). +- **Cost:** An operator writing up a fight cannot see the button that creates it + without scrolling — at 1440×900 the "Create operation" button and the entire + ROSTER section are below the fold, while 637px of the page column (51% of it) + sits empty beside the form for the whole 1,477px scroll. They scroll down to + press, and if the submit is rejected the notice renders back up at the top, + out of view, which is the exact problem + `new-operation-form.tsx:73-75`'s focus effect exists to paper over. The + layout creates the distance that the focus move then has to travel. +- **Fix:** This is the brief's pattern 1 and the panel answers only half of it. + The panel does succeed at "a bare form on the page void read as a settings + row" — at 611px on `--hull` with a 3.90:1 border it reads as an object. What + it does not do is shape the field. Above ~64rem, lay the panel out in two + tracks rather than one: the three short OPERATION fields (Name, Date, Battle + report) in a left track, the LOOT and ROSTER pastes side by side in the + right, submit under both. That is the difference between 1,477px and roughly + one screen. Scope it to a `min-width` branch on `.form-stack` inside + `.form-panel` and lift the panel's own cap from `--measure` (a *reading* + measure, 68ch, and these are fields not prose) to the 57rem content cap + `.page--narrow > :where(*)` already grants it. The single-column stack stays + exactly as it is below the branch, so the narrow shot does not change. +- **Principle:** Sweep pattern 1 — content occupying a fraction of the column + with the remainder empty, and the page running long instead of wide. + +### 2. Four of the form's five error messages cannot be reached through the form + +- **Severity:** Serious +- **Where:** `src/app/payouts/new/new-operation-form.tsx:96` (`required`), `:111-112` + (`max={today}` + `required`), `:119` (`type="url"`), against + `src/app/payouts/errors.ts:34-44` +- **Cost:** An operator who pastes a zKillboard link as `zkillboard.com/related/…` + — no scheme, which is what you get from a copied breadcrumb or a typed-out + link — gets the browser's own bubble, "Please enter a URL.", which does not + say what is wrong. The app wrote the sentence that does say it ("Battle + report links must start with http:// or https://") and that sentence never + renders. The bubble also auto-dismisses and cannot be recalled, so a screen + reader user who moves focus away loses the message entirely, where the app's + own `Notice` is persistent and focusable. +- **Fix:** The mechanism: the browser fires `submit` only after interactive + constraint validation passes, and React's `<form action>` integration runs + from that event, so `required`, `type="url"` and `max` all intercept before + `createOperationAction` is ever called. `name_required`, `date_invalid`, + `url_invalid` and `url_scheme` are therefore server backstops for scripted + requests only; `appraisal_failed` is the sole code an operator can actually + see. Two coherent answers, pick one. Either accept that and stop maintaining + four messages that read as if they render (they end "Everything else you + typed is still here.", a promise about a screen nobody sees) — or put + `noValidate` on the `<form>` and let the server's own copy be the error UI + everywhere, which is the only way the http/https sentence reaches anyone. + I would take the second: this form already returns state rather than + redirecting precisely so rejections are answered in place, and native bubbles + are the one error channel on this surface that is neither styled, persistent, + nor written by anyone here. +- **Principle:** WCAG 3.3.3 Error Suggestion — the suggestion exists and is not + shown. Also: two error surfaces for one form, and the better one is dead code. +- **Supporting evidence:** no e2e spec asserts any of these four messages; + `e2e/payouts.spec.ts` and `e2e/submit-guard.spec.ts` reach `/payouts/new` six + times and never a rejection notice. Nothing tests them because nothing can. + (Verified from source and the HTML spec's validation ordering; I did not run + a browser — see "What I could not evaluate".) + +### 3. The roster and loot pastes are wide open to autocorrect and autocapitalisation + +- **Severity:** Serious +- **Where:** `src/app/payouts/new/new-operation-form.tsx:143-149` and `:155-161` + (both `<textarea>`s), and `:91-97` (the Name input) +- **Cost:** `<textarea>` defaults to `autocapitalize="sentences"` and + `autocorrect="on"`. An operator typing a roster on a phone at 1am gets EVE + character names silently rewritten to whatever dictionary word is nearest — + and `resolveRosterNames` matches exactly, so a corrected name does not fail + loudly, it comes back as an *unresolved* entry reported after the redirect on + `/payouts/[id]?unresolved=…`, by which point the operation exists and the + operator has to work out which of sixteen names the phone changed. The same + attributes squiggle every item name in a 300-line loot paste. Separately, + `name="name"` on a plain text input is the strongest autofill heuristic there + is: browsers offer the operator's own saved personal name as the operation + title. +- **Fix:** `spellCheck={false} autoCapitalize="none" autoCorrect="off"` on both + textareas — they hold machine paste, not prose — and `autoComplete="off"` on + the Name input. Five attributes, no layout change. +- **Principle:** Not a WCAG clause. Input hygiene: a field that accepts a + machine format must not be handed to a prose-correcting keyboard. + +### 4. The rejection is announced twice, against the primitive's own documented rule + +- **Severity:** Moderate +- **Where:** `src/app/payouts/new/new-operation-form.tsx:82-84` and `:73-75` +- **Cost:** `Notice tone="bad"` renders `role="alert"` (`ui.tsx:320`), so the + message is announced assertively the moment it commits; the effect then moves + focus to that same node, and a screen reader announces a newly-focused + element's text again. The operator hears the rejection twice, or hears the + focus announcement cut off the alert mid-sentence. On a form whose whole + rejection design is "say it once, clearly, where they are standing", that is + the one thing it does not do. +- **Fix:** Pass `live={false}` here. `Notice`'s own docblock (`ui.tsx:285-288`) + already states the rule — "a surface that already announces itself by moving + focus gets its heading preempted by an assertive region rendering in the same + commit" — and applies it to `error.tsx`, where the focus target is a + *different* node. Here the focus target *is* the live region, which is the + same collision at closer range. Focus movement alone carries the message to + both audiences: a focused `<p tabindex="-1">` is announced, and the sighted + operator is scrolled to it. Nothing else changes; the `id`/`tabIndex` + contract is untouched. +- **Principle:** WCAG 4.1.3 Status Messages, applied in the direction the + primitive already documents. + +### 5. The field in error is never identified programmatically + +- **Severity:** Moderate +- **Where:** `src/app/payouts/new/new-operation-form.tsx:89-124` (no + `aria-invalid` or `aria-describedby` on any input) +- **Cost:** After a rejection the operator is standing on the notice with no + machine-readable link from it to the field. For `name_required` the next tab + stop happens to be the right field, which is luck rather than design; for + `url_invalid` or `url_scheme` a screen reader user tabs past Name and Date + with nothing marking which of the three is wrong, and the fields themselves + give no non-visual signal that anything was refused. A sighted operator has + the same problem in reverse: the notice names the field in prose and no + field is highlighted. +- **Fix:** Derive the offending field from `state.code` — the mapping is + one-to-one (`name_required`→name, `date_invalid`→date, + `url_invalid`/`url_scheme`→battleReportUrl, `appraisal_failed`→lootPaste) — + and set `aria-invalid="true"` plus `aria-describedby={ERROR_NOTICE_ID}` on + that one input. Consider moving focus to the field rather than the notice + once `aria-describedby` points at it, which would collapse findings 4 and 5 + into one better behaviour: the field is announced, its description (the + notice text) is announced with it, once. +- **Principle:** WCAG 3.3.1 Error Identification. The text description is + present, so this is not a failure at A; the programmatic association is the + gap. + +### 6. Nothing on the form says the date is EVE time + +- **Severity:** Moderate +- **Where:** `src/app/payouts/new/page.tsx:27` and + `new-operation-form.tsx:106-113` +- **Cost:** The default is `new Date().toISOString().slice(0,10)` — today in + **UTC** — and the action parses the submitted `yyyy-mm-dd` as UTC midnight. + Both are right, and both are invisible. A US-Pacific operator writing up a + fight at 8pm local on the 9th sees `08/10/2026` pre-filled by a field labelled + only "Date (required)", concludes the form is wrong, and corrects it to the + 9th — dating the operation a day before the fight for everyone reconciling + against EVE time later. The source comments at page.tsx:25-26 and + new-operation-form.tsx:101-104 both explain this to the next developer and + neither explains it to the operator. +- **Fix:** Put it in the label: "Date (required, EVE time)". The label already + carries requiredness for the same reason (`new-operation-form.tsx:87-88`), so + this needs no new mechanism. A hint below the field is the alternative, and + `.form-stack__field` already supports one (globals.css:3199-3202) — but the + label is where an operator reading fast will see it. +- **Principle:** WCAG 3.3.2 Labels or Instructions. + +### 7. `max={today}` goes stale across EVE downtime, and there is no code for a date the server refuses + +- **Severity:** Minor +- **Where:** `src/app/payouts/new/new-operation-form.tsx:111`, + `src/app/payouts/actions.ts:166-180` (`parseYmd`), + `src/app/payouts/errors.ts:34-44` +- **Cost:** *Re-opening nothing — this adds to the known-open "future-date guard + is client-only" item, from the other direction.* Two consequences that entry + does not name. First, `today` is computed once at render (page.tsx:27), so a + composer left open across 00:00 UTC has a `max` of yesterday: the operator + picks the new day's date and the browser refuses with "Value must be + 2026-08-10 or earlier", with nothing on the page explaining why the correct + date is rejected. Corps that fight around downtime hit this. Second, + `NEW_OPERATION_ERRORS` reserves no code for a future date at all — so closing + the server-side gap is not a one-line check in `parseYmd`, it needs a new code + and new copy in the same change, which is worth knowing before the backlog + item is picked up. +- **Fix:** For the stale-`max` half: compute `max` in the client from + `new Date()` at submit time, or drop `max` and let the server refuse with a + real message once the guard exists. For the second half: add a + `date_future` entry to `NEW_OPERATION_ERRORS` when the server check lands. +- **Principle:** none cited — this is a correctness observation, not a rule + violation. + +--- + +## What is genuinely good and should survive + +- **The panel earns its exception.** `.form-panel` at 611px on `--hull` with a + `--rule-strong` border reads as an object rather than a settings row, and it + refuses registration ticks on the stated grounds. Finding 1 asks it to be + *shaped*, not removed. Do not delete the panel to fix the whitespace. +- **`--void` inset into `--hull` measures clean.** The lead asked; here are the + numbers. Field boundary `--rule-strong` `#787370` against the panel's `--hull` + `#151514` is **3.90:1**, over the 3:1 that WCAG 2.2 AA 1.4.11 asks of a UI + component boundary. `--ink` `#ece7de` in the field on `--void` is 16.08:1. The + fill difference alone (`--void` on `--hull`) is 1.08:1 and carries nothing — + the border does all the work, which is the right division. Section labels + `--ink-faint` `#90877e` on `--hull` measure 5.18:1 at 11px. The gold button's + `--void`-on-`--gold` label is 11.63:1. Nothing on this surface fails contrast. + (Note for whoever holds DESIGN.md: globals.css:1521-1524 states + `--ink-faint` on `--hull` as 5.58:1; I measure 5.18:1. Both pass, so it + changes no decision here, but the record-contradiction reviewer may want it.) +- **Hit targets are uniform and correct.** `.field` and `.btn` both carry + `min-height: 2.25rem` (36px), the standalone grade, against a 24px AA floor. + No control on this surface is in-row, so the 28px grade correctly does not + appear. +- **Keyboard traversal is exactly right.** Six stops, DOM order equals visual + order, no trap, no positive `tabindex`. The `Notice` carries `tabIndex={-1}` + so a landing place never becomes a stop on the way to the controls — the + paired `id`/`tabIndex` contract in `ui.tsx` is working as documented. + `:focus-visible` is a 2px gold outline at 2px offset (globals.css:289-292), + gold on `--hull` at 10.73:1, and the panel's 32px padding leaves room for the + ring on every field including the textareas. +- **Controlled fields, and the reason for them.** The docblock at + `new-operation-form.tsx:34-41` is right: React DOM resets *uncontrolled* + fields when an action settles, success or rejection alike. On a form whose + reason for existing is surviving a rejection with a 300-line paste intact, + `useState` on every field is not over-engineering. Do not "simplify" these to + `defaultValue`. +- **320px and 200% zoom both hold.** `.form-panel`'s narrow padding override + (globals.css:5151-5158) is present and its measurement is correct: at 320px + the fields get 224px rather than 192px. At 200% zoom on a 1280px screen the + effective 640px viewport hits the same `max-width: 40rem` branch and the + panel shrinks below its 68ch cap; no horizontal scroll either way, and every + size on the surface is in `rem` so text-only zoom scales cleanly too. +- **The operator gate is a redirect, not a disabled form.** `page.tsx:18-22` + and its comment. A non-operator never meets a form that would reject them. +- **The reserved `Notice` slot.** Mounted unconditionally and taken out of flow + when empty (globals.css:3459-3480), so the live region is registered before + its text arrives and costs no dead space. Finding 4 changes `live`, not this. + +## Patterns and systemic issues + +- **Em dashes in user-facing copy.** `NEW_OPERATION_ERRORS.appraisal_failed` + ("Nothing was created — adjust the paste…") uses one, as do most entries in + `OPERATION_ERRORS`. The shared design law bans them. This is app-wide rather + than a defect of this surface, and it belongs to whoever owns copy across all + of `errors.ts`; noting it here so it is counted once, not litigated per page. +- **The native-validation gap in finding 2 is not local to this form.** Any + other form in the app that pairs `required`/`type=`/`min`/`max` with a + hand-written server rejection message has the same dead copy. Worth one grep + when finding 2 is worked, rather than a second report. + +## What I could not evaluate, and why + +- **The rejection path in a live browser.** Finding 2's mechanism is reasoned + from the HTML spec's validation ordering plus React's use of the `submit` + event, and corroborated by the absence of any e2e that reaches those four + codes — but I did not run a browser to watch a bubble appear. I am read-only + on source, other agents are working this session, and this worktree's e2e run + shares one database. Worth thirty seconds of manual confirmation before the + fix is scoped; the two remedies differ. +- **Whether `OPERATION_ERRORS` ships to the client.** `errors.ts` exports both + maps from one module and the client component imports one of them; whether + tree-shaking drops the other needs a production build to answer. It is ~3.5KB + of strings either way, which is why I did not spend the build. +- **Screen-reader behaviour on the double announcement (finding 4).** The + collision is structural and the primitive documents the rule it breaks, but + the exact outcome — announced twice, or alert truncated by the focus + announcement — differs by AT and browser pairing, and I tested with neither. + The fix is the same in both cases. +- **Real appraisal latency.** `appraisal_failed` is the one rejection an + operator can actually reach, and how long they wait before it arrives depends + on triff.tools. `pendingLabel="Creating…"` plus `aria-busy` is the whole + in-flight signal, and `onRefused` is not wired here, so a second press during + a slow appraisal is silently refused. I could not time the round trip, so I + did not file it; if appraisal routinely runs past a couple of seconds it is + worth revisiting. + +## Contested — settled taste I would push back on, once + +Nothing. The two decisions this surface leans on hardest — the card exception +for `.form-panel` and no registration ticks on it — are both right, and finding +1 works with the panel rather than against it. diff --git a/docs/design-sweep-2/reports/payouts-new-critique.md b/docs/design-sweep-2/reports/payouts-new-critique.md new file mode 100644 index 00000000..5e7af90c --- /dev/null +++ b/docs/design-sweep-2/reports/payouts-new-critique.md @@ -0,0 +1,315 @@ +# `/payouts/new` — critique + +`$impeccable critique`, register: PRODUCT. Surface block 3. + +## What I see, before explaining it + +**Wide (1440×900 viewport, 1440×1477 fullPage).** A stamp, an H1, a two-line +lede, and then one outlined rectangle hanging on the left. The rectangle runs +from x=120 to x=731 and from y=261 to y=1380: 612 × 1119. Everything to its +right is empty ground, 709px of it, half the screen. Inside the rectangle, +three short fields at the top and then two large empty boxes, one 234px tall +and one 191px tall, with nothing in them. The one gold object on the page — a +164 × 36 button reading CREATE OPERATION — sits at y=1312, which is 412px below +the bottom of the viewport. On the screen an operator actually sees, this page +is a heading, three inputs, and the top 169px of an empty black box. + +**Narrow (390×844 viewport, 390×1546 fullPage).** The same panel, now 355 of +390px wide, so it is a thin outline hugging both edges. The two empty boxes are +*the same 234px and 191px* they were at desktop — a quarter of the phone +viewport each. The fold lands 27px inside the first one. The button is at +y=1421, 577px below the fold. Two labels wrap to two lines. + +At both viewports the largest thing on the page is an empty box, and at both +viewports the button that does the thing is off-screen. + +(A dark circular badge with a stylized "N" appears at the left edge of both +shots. That is the Next.js dev-overlay, not the design. Ignored throughout.) + +## Does `.form-panel` answer pattern 1? + +**Half.** Pattern 1 is two claims joined: *content occupying a fraction of the +column with the remainder empty*, **and** *the page running long instead of +wide*. The panel answers the first clause and does nothing about the second. + +The first clause is genuinely answered. `page.tsx:52`'s comment is right that a +bare `.form-stack` on the void would have read as a settings row; the panel +draws a boundary, so the form is an object rather than a leak, and there is no +ambiguity about where it starts and stops. + +The second clause is untouched, and by the numbers it is worse here than the +pattern usually is. The panel is 612px in a 1440px viewport (42.5%), leaving +709px (49.2%) of empty ground to its right, while the page runs 1477px — 1.64 +screens — and puts its only primary action below the fold. Drawing a border +around a column of content does not stop the content from being a column. The +panel converted "unshaped" into "shaped and still tall and narrow." Findings 1 +and 2 below are the two independent halves of fixing that, and neither one +fixes the other. + +--- + +## Findings + +### 1. The primary action is below the fold at both viewports, pushed there by the two fields the page's own lede calls optional + +- **Severity:** Serious +- **Where:** `src/app/payouts/new/new-operation-form.tsx:146` (`rows={10}`) and + `:158` (`rows={8}`); consequence is the whole surface. +- **Cost:** An operator writing up a fight at 1am on a phone fills in a name, + accepts today's date, and then has to scroll past 425px of boxes they were + explicitly told they can leave for later before they can find out where the + Create button is. The two required fields take 79px of a 1119px panel; the + two optional pastes take 425px, 38% of it. The page's own lede says "it opens + a draft you can fill in now or later", and the layout spends more than five + times as much vertical on "now" as on the fields that are actually required. + The copy and the shape disagree, and the shape is what people obey. +- **Fix:** Two mechanical changes, either of which alone gets Create above the + fold at 1440×900: + - Put Loot and Roster inside `Disclosure` + (`src/app/_components/disclosure.tsx:60`), summaries "Loot paste" and + "Roster paste". It is `<details>`-based, so the collapsed textareas stay in + the DOM and still submit — this does not break the paste-survives-rejection + property the whole component exists for. **Constraint:** pass + `defaultOpen={lootPaste !== ""}` / `defaultOpen={rosterPaste !== ""}`, or a + rejected submit hides a 200-line paste behind a closed twisty, which is + exactly the failure the file's docblock is written against. + - Or drop to `rows={4}` / `rows={3}` and let the textareas grow. `rows` is a + fixed count chosen once and applied at every viewport; 10 rows is 26% of a + 900px desktop and 28% of an 844px phone, which is not a size anybody chose + for the phone. +- **Principle:** Progressive disclosure — complexity revealed when needed. + Nielsen #8 (aesthetic and minimalist design): the page gives the most space to + the least-required thing. + +### 2. Half a screen of empty ground at wide, while the page runs 1.64 screens tall + +- **Severity:** Serious +- **Where:** whole surface. The rule is `src/app/globals.css:3222` + (`.form-panel { max-width: var(--measure) }`, 68ch ≈ 612px), inside a + `.page--narrow` content column that is 1200px wide at this viewport. +- **Cost:** The operator scrolls, twice, past a screen that is 49% blank. The + form has two natural groups — three short identifying fields, and two bulk + pastes — and at 1440px there is room to stand them side by side and end the + scroll entirely. Instead the panel takes a reading measure it does not need: + 68ch is a prose measure, and the widest thing in this panel is a 60-character + label, not a paragraph. +- **Fix:** Above ~64rem, make `.form-stack` inside `.form-panel` a two-column + grid: Operation (name / date / battle report) left, the two pastes right, + Create spanning below. The panel widens toward the page column it already + sits in and the page stops being taller than a screen. This is the fix that + finding 1's disclosure does *not* deliver — collapsing the pastes shortens the + page but leaves the 709px of void exactly where it is. +- **Principle:** Pattern 1, second clause. Also product-register layout: use the + structural axis the viewport actually gives you. + +### 3. The panel is carried by its border, not by the ground its own comment credits + +- **Severity:** Moderate +- **Where:** `src/app/globals.css:3222-3228` and the comment block above it + (from `:3205`); observable in the wide shot at any y inside the panel. +- **Cost:** `--hull` renders `#151514` and `--void` renders `#0a0a0a`. That is + 1.08:1 — no one sees it. The `page.tsx:46-51` and `globals.css` comments both + argue the panel is justified because "the ground change alone carries the + panel"; sampled from the shot, the only thing carrying the panel is the 1px + `#787370` border (3.90:1 against the fill). So one of the system's two + sanctioned card exceptions is, in fact, a hairline outline — the shape "No + cards" exists to prevent — and it is justified in the source by a mechanism + that is not operating. Worse, both textareas are filled with `--void` + `#0a0a0a`, the *page* ground: the single largest object inside the panel is a + 612 × 234 rectangle of the exact colour the panel is supposed to be + distinguishing itself from, so the panel reads as a frame around a hole. +- **Fix:** Not a token change (I am not proposing retuning `--hull`). Either + make the panel's separation honest — drop the border and let a real ground + step carry it, which means the ground step has to become visible, which it + cannot without a token change, so: **keep the border and correct the two + comments**, so a future reader does not remove the border believing the ground + is doing the work. And give the textareas a fill that is not the page ground — + `.form-panel .field { background: color-mix(...) }` is out of scope for a + no-token-change sweep, so at minimum stop describing the panel's ground as the + thing that carries it. +- **Principle:** Comments must describe the mechanism that is running. (See also + the record-contradiction reviewer's beat; I file it here because the + consequence is visual, not documentary.) + +### 4. The one everyday rejection has no recovery affordance, only instructions + +- **Severity:** Moderate +- **Where:** `src/app/payouts/errors.ts` (`appraisal_failed`) and + `new-operation-form.tsx:82-84`. +- **Cost:** `appraisal_failed` is the only rejection on this form that is not a + typo — it fires when triff.tools is down, which is not the operator's fault + and not something they can fix. Its copy says "leave it blank and price loot + later." To take that advice the operator must scroll ~470px down from the + notice they were just focused onto, select a 200-line paste, delete it, scroll + back down past the roster box, and press Create again — to do the thing the + page's own lede already told them was the normal way to work. At 1am during an + upstream outage, that is where someone gives up and creates the operation + tomorrow, or not at all. +- **Fix:** When `state.code === "appraisal_failed"`, render a second control + inside the `Notice`: "Create without loot", submitting the same form with the + loot paste omitted (a `name`/`value` on a second `Submit` — `submit.tsx:65-66` + already passes both through for exactly this shape of case, and the action can + branch on it before the `if (lootPaste)` block at `actions.ts:284`). The paste + stays in the textarea either way, so nothing is destroyed by taking the offer. +- **Principle:** Nielsen #9 (help users recover from errors) — recovery is an + action, not a sentence describing an action. + +### 5. Two of the three section headers name one field each, and that field's label repeats the word + +- **Severity:** Moderate +- **Where:** `new-operation-form.tsx:126` and `:152`. +- **Cost:** `LOOT` sits above a field labelled "Loot paste (…)". `ROSTER` sits + above a field labelled "Roster paste (…)". `.rule-head` costs 48px above and + 16px below (`globals.css:898`), so the two redundant headers spend ~166px of a + 1119px panel — a seventh of the panel's height, and a third of what pushes + Create off the screen in finding 1 — restating two words that are already the + first word of the label underneath. Meanwhile `OPERATION` heads three fields, + so the same furniture means "a group" in one place and "the next field" in two + others; a reader learns nothing from a header that fires at both scales. +- **Fix:** Drop all three `RuleHead`s. The H1 already says "New operation", so + the first is redundant with the page title; the other two are redundant with + the labels below them. The `.form-stack` gap already separates the fields, and + `globals.css:3194-3197`'s special-cased zero-margin branch — which exists only + to undo the first header's collision with the panel padding — goes away with + them. Panel loses ~230px. If the grouping is wanted at all, it belongs to + finding 2's two-column split, where the columns *are* the grouping. +- **Principle:** Every word earns its place; structure should not be spent + labelling a set of one. + +### 6. Requiredness is a trailing parenthetical on all five labels, at identical weight to the field name + +- **Severity:** Minor +- **Where:** `new-operation-form.tsx:90, 100, 116, 142, 154`. +- **Cost:** Five of five labels end in a parenthetical — "(required)", + "(required)", "(optional)", "(optional: one line per item, quantity before or + after)", "(optional: one per line, or separated by /)" — all in the same + proportional face, size, weight and colour as the field name itself. To answer + "what do I actually have to fill in" the operator reads five full strings, two + of which run to 60 characters and wrap to two lines at 390px. The one-word + distinction that matters is buried mid-sentence in the two longest labels. + This is the form's version of uniform weight where nothing directs the eye. +- **Fix:** Say it once. The two required fields are the first two; mark + requiredness structurally (a distinct treatment on the two, or a single line + above them) and drop "(optional)" from the other three entirely — an unmarked + field in a form whose required ones are marked is already understood as + optional. Keep the format hints; they are doing real work. **Do not** rephrase + the loot label without re-reading `new-operation-form.tsx:135-141`: the word + "name" cannot appear in it or `getByLabel("Name")` matches two fields and 22 + payouts specs fail on strict mode. +- **Principle:** Nielsen #6 (recognition rather than recall); differentiate what + differs. + +### 7. `max={today}` is baked at render, so a page left open across UTC midnight defaults to the wrong day + +- **Severity:** Minor — **and this re-opens a known-open item.** The backlog + entry reads "`/payouts`' future-date guard is client-only"; that entry is + about bypassability, and I am filing a different consequence of the same + guard, on a different route. +- **Where:** `src/app/payouts/new/page.tsx:27` (`today`), passed to + `new-operation-form.tsx:68` as both the `value` default and the `max`. +- **Cost:** `createOperationAction` does not check the date against today at all + — `parseYmd` (`actions.ts:166-180`) validates format and calendar rollover and + nothing else — so `max` is the entire guard, and it is a string frozen when the + page rendered. An operator who opens `/payouts/new` at 23:50 EVE and submits at + 00:10 files the operation under *yesterday*, silently, with no warning, because + the pre-filled default is stale too. `page.tsx:26` says this is "a record + operators reconcile against their own logs" — a day-off date is exactly the + error that costs someone an hour later. +- **Fix:** Validate the date server-side in `createOperationAction` (a + `date_future` code alongside the four in `NEW_OPERATION_ERRORS`), which closes + the bypass half of the known-open entry at the same time. The stale-default + half needs the client to re-derive `today` on mount and on `visibilitychange`, + or accept it and let the server rejection catch it. +- **Principle:** Nielsen #5 (error prevention). A client-side `max` is a hint, + not a guard, and a server-rendered one is a hint with an expiry date. + +### 8. The lede's first sentence explains the page to someone who cannot reach it + +- **Severity:** Minor +- **Where:** `src/app/payouts/new/page.tsx:41-42`. +- **Cost:** "One row per fight." defines *operation* for a reader who, by + `page.tsx:22`, is necessarily an operator — the only people who can load this + URL, and the people who least need the term defined. It is the explanatory- + subtitle smell in its mildest form. +- **Fix:** Cut the first sentence, keep the second. "Creating an operation pays + nobody: it opens a draft you can fill in now or later" is not explaining the + page, it is answering the anxiety at a commitment moment, and it is the best + line on the surface. Cutting the first sentence also drops the lede to one + line at 1440 and takes ~24px off the page. +- **Principle:** No intros that repeat the title; every word earns its place. + +--- + +## What is genuinely good and should survive + +- **The lede's second sentence.** "Creating an operation pays nobody: it opens a + draft you can fill in now or later." It answers the exact question an operator + has their hand on the mouse over — *am I committing to something?* — in the + deadpan register, with no exclamation and no reassurance theatre. Do not + soften it, do not move it into a tooltip, and do not delete it while acting on + finding 8. +- **The whole controlled-input / `useActionState` construction.** + `new-operation-form.tsx:34-41` documents why every field is `useState` rather + than `defaultValue`, and the reason is real: React DOM resets uncontrolled + fields when the action promise settles, so an uncontrolled version loses a + hundred-line paste on the one rejection this form exists to survive. Any fix + that touches these fields must keep them controlled. +- **The reserved `Notice` slot and the focus move.** Mounted unconditionally so + the live region exists before the text (`:79-84`), then focused on rejection + (`:73-75`) because the operator is otherwise standing at a button ~1000px + below where the message rendered. This is the correct handling and, notably, it + is a workaround for finding 1 — the page is only that tall because of the two + optional boxes. Fixing 1 makes this cheaper; it does not make it removable. +- **Redirecting the non-operator rather than showing them a form that will + reject.** `page.tsx:18-22`. Right call, well argued. +- **`Create operation` at 164px, not a full-measure gold bar.** + `globals.css:3176-3178` (`justify-self: start`) and its comment. Gold is + rationed correctly here: one primary action, one gold object, sized from its + label. The button is *misplaced* (finding 1), not mis-designed. +- **The date default.** Today, in UTC because UTC is EVE time, on a + `force-dynamic` page. The reasoning at `page.tsx:24-27` is right; finding 7 is + about its shelf life, not its choice. + +## Patterns 2 and 3 — plainly + +- **Pattern 2 (total enumeration): not present.** There is no table and no + repeated row on this surface. The nearest thing is the "(optional)" repetition + in finding 6, which is a labelling problem, not an enumeration one; I have not + dressed it up as pattern 2. +- **Pattern 3 (repeated identical controls at uniform weight): not present as + controls.** There is exactly one pressable thing on the page. The *sections* + are at uniform weight where two of them should not exist at all (finding 5), + and the *labels* are (finding 6), but neither is the control-density failure + pattern 3 names, and I have filed both under their own descriptions rather + than borrowing its authority. + +## What I could not evaluate + +- **Focus, hover and pending states.** The shots are static. `.field:hover` and + `:focus-visible` (`globals.css:3069-3081`), the `aria-busy` pulse, and the + "Creating…" label swap are all read from source only. In particular I could + not judge whether "Creating…" is *enough* feedback during a loot appraisal, + which is a network round trip to triff.tools plus ESI (`actions.ts:286-306`) + and is the slowest thing this button can do — a 200-line paste against a slow + upstream may sit on "Creating…" long enough to read as hung, and no shot of + that state exists. +- **The rejection layout.** No screenshot shows the `Notice` populated, so I + could not check how the filled notice affects the panel's top spacing or + whether the focus landing is visually obvious at the moment it happens. +- **The native date picker's rendered panel.** Chromium's own control, unstyled + by this system, and not in either shot. +- **Fold positions are inferred**, not measured in a live browser: both shots are + `fullPage`, so I took the fold from the capture viewports the brief states + (1440×900, 390×844) and the pixel geometry of the shots. The 412px and 577px + below-fold figures rest on that inference. +- **320px.** `globals.css:5156` gives `.form-panel` a narrower padding below + 40rem and the comment carries a measurement (224px of field width before the + fix). There is no 320px shot, so I could not check the result, only that the + rule exists. + +## Contested (settled-taste challenges) + +None. Nothing in this report asks to re-open a settled item. Finding 3 observes +that one of the two sanctioned card exceptions is not working by the mechanism +its own source comment claims — that is a statement about the comment and the +border, not a request to make it a third card or to stop being one. diff --git a/docs/design-sweep-2/reports/record-contradiction.md b/docs/design-sweep-2/reports/record-contradiction.md new file mode 100644 index 00000000..732c08dd --- /dev/null +++ b/docs/design-sweep-2/reports/record-contradiction.md @@ -0,0 +1,666 @@ +# Record contradiction — where the code disagrees with its own written rule + +Reviewer B. Inputs: `DESIGN.md`, `PRODUCT.md`, `src/app/globals.css` (all 5477 +lines), `src/app/_components/`, every `page.tsx` under `src/app/`, and the wide +shots for `/login`, `/payouts`, `/payouts/[id]`, `/admin/accounts`, +`/admin/audit`, `/admin/sync`, `/admin/access-lists`, `/account`, `error.tsx`. + +**Method note on the numbers.** Every OKLCH token was converted to sRGB and every +ratio recomputed from 8-bit hex. DESIGN.md's colour tables survive that check +almost exactly — see "What is genuinely good" — so the findings below are about +rules the *code* left behind, plus a small cluster of stale figures in +`globals.css` comments. Rendered hex for the tokens this report cites: + +| Token | Rendered sRGB | +|---|---| +| `--void` | `#0a0a0a` (R/B 1.00) | +| `--hull` | `#151514` | +| `--hull-hi` | `#21201f` | +| `--rule` | `#373533` | +| `--rule-strong` | `#787370` | +| `--ink` | `#ece7de` | +| `--ink-dim` | `#bab3a9` | +| `--ink-faint` | `#90877e` | +| `--gold` / `--tier-member` | `#f1c035` | +| `--signal-ok` / `--tier-alumni` | `#81bb8d` | +| `--signal-warn` | `#ff9f5f` | +| `--signal-bad` | `#f05751` | +| `--tier-associate` | `#52b0e2` | + +--- + +## 1. The label register has one undeclared member, and it renders the same word in two weights on one screen + +- **Severity:** Serious +- **Where:** `src/app/globals.css:2404-2410`; markup at + `src/app/account/page.tsx:994`, `:1018`, `:1027`. Visible in + `12-account.wide.png`. + +DESIGN.md, "The label register": + +> Small mono uppercase is the most reused type in the system, and it is **one** +> style, declared once in `globals.css` under `--- Label register ---` and +> applied by adding a selector to that list. Weight is `600` for every one of +> them; **a label that inherits `400` because its rule simply never said is a +> bug, not a variant.** + +The register list (`globals.css:373-391`) holds fourteen selectors. I checked all +fourteen: every one takes `font-weight: 600`, none is overridden to another +weight anywhere in the file, and all fourteen are live in markup. The three +documented non-members (`.btn`, `.tier`, `.st`) each declare their own explicit +`600` — including `.st` at `:2457`, so DESIGN.md's claim that the `.st` 400 bug +is fixed is true. The five documented deliberate exclusions (`.page__stamp`, +`.launch__motto`, `.btn-row__stamp`, `.worker`, `.push__next`) are all +value-or-furniture, all reach for `--track-value` or `--track-furniture`, and +all are argued in place. + +There is exactly one selector that is register-shaped, is not in the list, and is +not argued anywhere: + +```css +.status-line__label { + font-family: var(--font-mono), ui-monospace, monospace; + font-size: var(--t-label); + letter-spacing: var(--track-value); + text-transform: uppercase; + color: var(--ink-faint); +} +``` + +Mono, `--t-label`, uppercase, `--ink-faint` — the register's exact type, minus +the weight. Its rule simply never said, so it renders 400. + +What it holds is `token`, `standings`, `map` (`account/page.tsx:994-1029`) — a +fixed word naming a field, which is DESIGN.md's own definition of a label ("The +register is for **labels** — a fixed word naming a field"). It is not one of the +value cases: the value sits beside it in a separate `.st`. + +The consequence is on screen and is the precise failure the register block was +written to close. Its own docblock (`globals.css:311-315`) cites the original +bug as `.log th` (600) and `.strip__head` (400) rendering "labels at the same +size, the same tracking and the same colour about 40px apart, which reads as two +kinds of label rather than one applied twice." On `/account` the same collision +is rebuilt at larger scale and with *identical copy*: the crew manifest's +`STANDINGS` and `MAP` (this rule, 400) sit on the same viewport as the sync +rail's `STANDINGS` and `MAP` (`.facts dt`, register, 600) — same words, same +size, same colour, ~430px apart in `12-account.wide.png`. + +Secondary: `--track-value` is the wrong token by the register's own test — +"a component asking for the value tracking is telling you what it holds," and +this holds a field name. The register's default is `--track-label`. + +- **Cost:** A member at 1am scanning the manifest for what's wrong reads two + visual grades of field label on one screen and has to work out whether the + fainter ones mean something different. They don't. It is the one typographic + rule this system states in absolute terms, and the reference surface breaks it. +- **Fix:** Add `.status-line__label` to the register list at + `globals.css:373-386` and delete the four properties it duplicates, leaving it + its own `color` and (if a variance is wanted) its own tracking — but pick + `--track-label`, not `--track-value`. If it is genuinely meant to be excluded, + the exclusion has to be argued in the register docblock beside the other five, + because right now nothing records a decision here at all. +- **Principle:** DESIGN.md, "The label register." + +--- + +## 2. `--signal-ok` is a dead token, and both documents cite a `.notice--ok` that does not exist + +- **Severity:** Moderate +- **Where:** `DESIGN.md:105`; `src/app/globals.css:61`, `:2505-2507` + +DESIGN.md's Signal table: + +> `--signal-ok` | `oklch(0.74 0.09 150)` | Reserved for where health is genuinely +> the subject (`.notice--ok`). **Not** the default `ok` status token… + +`globals.css:2505` repeats it: + +> `--signal-ok` is not deleted — it is still right where health is genuinely the +> subject rather than the default (see `.notice--ok`) + +`.notice--ok` does not exist. `grep -rn "notice--ok" src/` returns nothing but +those two comments. And `var(--signal-ok)` appears zero times in the whole +stylesheet — I counted every `var(--…)` reference in `globals.css`; +`--signal-ok` and `--dur-move` are the only two declared tokens with no +consumer. + +So the token's stated reason to exist ("kept only for the few places health is +genuinely the subject", `globals.css:56-60`) is satisfied by no place at all. +The green still ships, but under a different name and a different meaning: +`--tier-alumni` is `oklch(0.74 0.09 150)`, byte-identical, so `#81bb8d` on screen +is always "Alumni", never "healthy." + +- **Cost:** The next person who needs a genuinely-health-is-the-subject surface + reads the record, goes looking for `.notice--ok` to copy, finds nothing, and + either invents a second convention or — the likelier failure — concludes the + green was removed and reaches for `.notice--warn`. It also costs a reviewer: + a token with a stated use site is not something you check. +- **Fix:** Decide which is true and make the record say it. Either build + `.notice--ok` (the notice family already has `--warn` and `--bad` at + `globals.css:3451-3457`, so it is one rule), or drop `--signal-ok` and rewrite + DESIGN.md's Signal row to say the green now lives only as `--tier-alumni`. + Do not change the token's value — that is settled for this sweep. +- **Principle:** none needed; this is the record describing something that is not + there. + +--- + +## 3. The gold ration is stated three different ways, and the app follows none of the sentence in DESIGN.md's Rules + +- **Severity:** Moderate +- **Where:** `DESIGN.md:103` vs `DESIGN.md:130-131`; `src/app/globals.css:2925`. + Evidence in `15-admin-accounts.wide.png`, `12-account.wide.png`. + +Three counts of the ration exist: + +| Source | Sanctioned standing uses | +|---|---| +| `DESIGN.md:130-131` (Rules) | "the gold is rationed: **one primary action per view, plus the mark**" — two | +| `DESIGN.md:103` (Signal table) | "Brand mark, active nav, primary action, Member tier" — four | +| `globals.css:2925` | "DESIGN.md rations gold to **four** sanctioned uses" — four | + +The four-use reading is the operative one and the shipped app is consistent with +it. I counted golds per surface against the wide shots: + +| Surface | Golds | +|---|---| +| `/login` | seal (artwork) + registration ticks (`--gold-dim`) — no nav, no primary | +| `/account` | mark + active nav underline + `TESTERS` tier badge (`--tier-member`) | +| `/payouts` | mark + nav underline + `NEW OPERATION` | +| `/payouts/[id]` | mark + nav underline + `FINALIZE` | +| `/admin/accounts` | mark + nav underline + `TESTERS` chip and every Member-tier badge in the table | +| `/admin/audit` | mark + nav underline | +| `/admin/sync` | mark + nav underline + `SYNC NOW` | +| `/admin/access-lists` | mark + nav underline + `GRANT ACCESS` | + +Every view has at most one primary action, which is the half of the Rules +sentence that holds. The half that does not is "plus the mark": three of the +eight surfaces spend gold on the active nav underline *and* on tier badges, and +`/admin/accounts` spends it on an unbounded number of badges — one per +Member-tier row plus the filter chip. That is not drift; it is what the Signal +table sanctions. But a reader who reaches the Rules bullet first — it is the +bullet, it reads as the rule — will file every gold tier badge as a violation, or +worse, "fix" one. + +The related area claim holds everywhere I could measure it: on the densest +saturated screen, `/admin/sync`, the two notice fills plus eight orange `OVERDUE` +tokens come to roughly 4% of a 1440×1317 viewport, and the fills are 12% tints +rather than saturated ground. Nothing in the shots approaches 10%. + +- **Cost:** A future contributor gets a contradictory answer depending on which + paragraph they read, and the ambiguity falls hardest on the one gold use that + scales with data volume (tier badges), which is exactly where a wrong ruling is + expensive. +- **Fix:** Rewrite `DESIGN.md:130-131` to match the Signal table it contradicts: + four standing uses — mark, active nav, one primary action per view, Member + tier — plus the two focus surfaces (the ring and the skip link) that + `DESIGN.md:341-345` already argues are not standing uses. Leave the code alone. +- **Principle:** DESIGN.md, "Rules" (colour). + +--- + +## 4. Four page-level disclosures take the 28px in-row grade on `/payouts/[id]` + +- **Severity:** Moderate +- **Where:** `src/app/globals.css:3799-3807`; call sites at + `src/app/payouts/[id]/page.tsx:135`, `:833`, `:1148`, and + `src/app/payouts/[id]/appraise-form.tsx:183`. Visible in + `06-payout-detail-draft.wide.png`. + +DESIGN.md, hit targets: + +> The `28px` grade is scoped by the *reason* for it, not by the tag it lands in: +> it applies to rows that each carry a control set and are read many at a time. +> A disclosure drawer is **not** in-row for this purpose even though +> `Disclosure as="row"` renders a literal second `<tr>`. One drawer is open at a +> time, it spans the full table width, and nothing is competing with it for +> vertical space, so the density argument that buys the `28px` grade does not +> apply and its controls take `36px`. + +`.disc > summary` sets `min-height: 1.75rem` — 28px. Its own docblock states, in +its first sentence, that it is *not* in a table: + +> Page-level disclosures that sit directly in document flow rather than nested +> inside a `.log` table — the payouts detail page's "Or enter a flat value", +> "Add another paste", "Replace roster from a paste" and "Add one participant". +> `.log summary` above never matched these: the selector needs a `.log`-classed +> ANCESTOR, and these summaries sit in a plain `.form-stack`. + +Nothing about these four is in-row. They are one-per-section controls in the page +flow of a long form, separated by hundreds of pixels in the shot. The density +argument DESIGN.md attaches to 28px ("rows that each carry a control set and are +read many at a time") does not reach any of them, by the record's own scoping — +the same argument that gave `/admin/sync`'s drawer Re-run, `/admin/accounts`' +drawer controls, and `.manifest-panel__controls .btn--quiet` the 36px grade. + +This is the only surviving instance I found. Every other 28px consumer is +genuinely in-row: `.btn--micro` (accounts table cells), `.cell-link` (audit +filter links), `.row-toggle` (accounts name column), `.log summary` and +`.json > summary` (table cells). + +- **Cost:** An operator building a payout on a phone — `06-payout-detail-draft` + runs 2900px tall at 1440 and far longer at 390 — taps a 28px target for + "Add one participant" sixteen times while every standalone control on the same + page is 36px. It is 4px over the AA floor rather than 12px, on the app's + densest and most-operated surface. +- **Fix:** Raise `.disc > summary` to `min-height: 2.25rem` at + `globals.css:3806`. Nothing else matches the selector — I checked all four call + sites — so this is a one-line change with no in-row collateral. Alternatively, + if the 28px is wanted, DESIGN.md's scoping paragraph has to grow a third case, + which by its own framing ("There are **two** sizes and no others") it should + not. +- **Principle:** DESIGN.md, "Focus and states" → hit targets, ruling R1. + +--- + +## 5. "No decorative gradients at all" versus a gradient whose own comment calls it decorative + +- **Severity:** Moderate +- **Where:** `DESIGN.md:132` vs `src/app/globals.css:1108-1124` + +DESIGN.md, Rules: + +> No gradients on text, ever. **No decorative gradients at all.** + +`globals.css`: + +```css +.scroller-fade--start { + left: 0; + background: linear-gradient( + to right, + color-mix(in oklab, var(--rule-strong) 75%, transparent), + transparent + ); +} +``` + +and its docblock, closing (`:1092-1093`): + +> **Decorative and inert** — `pointer-events: none` keeps it clear of scrolling +> and any hit-testing. + +This is a visible left-to-right colour ramp, on every scrollable table region in +the app (`/admin/accounts`, `/admin/audit`, `/admin/sync`, `/account`, +`/payouts`), described by its author in the same word the rule forbids. The +comment even reasons about its contrast (2.55:1, "deliberately under WCAG +1.4.11's 3:1… so it is styled to stay quiet rather than to pass as the +affordance") — i.e. it is explicitly reinforcement, not information, which is +the definition of decorative. + +I do not think the fade should go: a scroll affordance on a table that clips at +390px is worth having, and it is the quietest available device. The record is +what is wrong — it states an absolute that the system found a good reason to +break and then never came back to amend. + +(For completeness: the second `linear-gradient` in the file, `.st::before` at +`:2494`, is `linear-gradient(currentColor, currentColor)` — a single flat colour +used as a sized background box, with no visible ramp. That one is a geometry +trick, not a gradient in the sense the rule means, and its comment says so.) + +- **Cost:** Small but real: the absolute reads as load-bearing, so the next + person who wants a soft edge either files the fade as a bug or quietly copies + it, and the rule stops meaning anything either way. +- **Fix:** Amend `DESIGN.md:132` to name the exception — "no decorative + gradients, with one exception: the scroll-region edge fades, which are the + quietest available affordance for content clipped past a region edge and are + deliberately held under 3:1." Leave the CSS alone. +- **Principle:** DESIGN.md, "Rules" (colour). + +--- + +## 6. DESIGN.md gives two different contrast figures for the same disabled-text pair, and neither is the measured one + +- **Severity:** Moderate +- **Where:** `DESIGN.md:350` vs `DESIGN.md:63-64`; `src/app/globals.css:2706` + +`DESIGN.md:346-350`, on disabled controls: + +> Disabled controls keep `opacity: 1` and take an explicit `--ink-faint`… **The +> explicit colour is 4.85:1 on `--hull-hi`** and does not move. + +`DESIGN.md:60-64`, on the palette as a whole: + +> Every text token below was measured against all three grounds rather than +> asserted; **the worst case is `--ink-faint` at 4.63:1 on a hovered row** + +Those two sentences describe the same pair. `.btn:disabled` sets +`color: var(--ink-faint); background: transparent` (`globals.css:2712-2715`), and +"on a hovered row" is `--hull-hi`, so both are `--ink-faint` on `--hull-hi`. +Measured from rendered hex — `#90877e` on `#21201f` — the answer is **4.61:1**. +`globals.css:2706` carries the 4.85 as well. + +The behaviour is correct and the conclusion survives: 4.61 still clears text AA, +and the whole point of the rule (an opacity fade measured 2.88:1 on the same +ground) is unaffected. But the record states a number that is 0.24 high, right +next to a "do not simplify this back to an opacity" instruction whose force comes +from its measurements being trustworthy. + +- **Cost:** Someone retuning `--ink-faint` — or introducing a fourth, lighter + ground — checks their new value against 4.85 and believes they have 0.35 of + headroom over AA when they have 0.11. +- **Fix:** Replace 4.85 with 4.61 at `DESIGN.md:350` and `globals.css:2706`, and + update DESIGN.md's own worst case at `:64` from 4.63 to 4.61 while you are + there so the two agree. +- **Principle:** PRODUCT.md, "WCAG 2.2 AA." + +--- + +## 7. `--dur-move` is declared and documented and used nowhere + +- **Severity:** Minor +- **Where:** `DESIGN.md:308-309`; `src/app/globals.css:168` + +> Transitions are 140ms on colour and border, **220ms on transforms**, both on +> `cubic-bezier(0.22, 1, 0.36, 1)` (ease-out-quint). + +`--dur-move: 220ms` has zero consumers. Every one of the nine `transition` +declarations in the file uses `--dur-color`, and every one transitions +`color`, `background-color`, `border-color` or `opacity`. No transform is +transitioned anywhere in the app; the only transform in the stylesheet is inside +`@keyframes seal-settle` and `@keyframes btn-pulse`, which are animations, not +transitions, and set their own durations (620ms, 900ms). + +The Motion section is otherwise accurate: `--dur-color` is 140ms, `--ease` is +`cubic-bezier(0.22, 1, 0.36, 1)`, no layout property is animated anywhere, the +only entrance animation is `seal-settle` and it is opacity-and-scale, and the +`prefers-reduced-motion` block collapses everything to `0.01ms` globally at +`:295-304`. Only the transform grade is fictional. + +- **Cost:** Small. It costs the next person adding motion a wrong assumption + about what the house style already does, and it costs a reader of DESIGN.md a + belief that transforms are part of this system's vocabulary when nothing + transitions one. +- **Fix:** Either delete the token and the half-sentence, or keep both and say + the grade is reserved rather than in use. + +--- + +## 8. DESIGN.md's nav rule is missing a destination + +- **Severity:** Minor +- **Where:** `DESIGN.md:236-240` vs `src/app/_components/nav-items.ts:78-83`, + `:109-115`. Visible in every header shot. + +> The bar offers every destination this viewer is *provably authorized* to +> reach — `Your account` always, `Operations` when they can read payouts, +> `Members`/`Audit log`/`Sync` when they are an admin — in one fixed order, +> broadest access first. + +Five destinations. `navFor` returns six: `ACCESS_LISTS` (`/admin/access-lists`, +label "Access lists") is in the admin branch and renders in the bar on every +admin shot. `nav-items.ts`'s own docblock lists all six correctly. DESIGN.md is +the copy that is out of date, and `/admin/access-lists` appears nowhere else in +either record document either. + +Everything else about the nav rule verifies. `navFor` is the single derivation; +`isAdmin` and `canReadPayouts` are taken as independent bits so an admin does not +get `Operations` for free; the order is fixed and each role sees a strict prefix. + +- **Cost:** A maintainer auditing the bar against the record concludes an extra + link is a bug. Lower stakes than it looks, but this is the one rule the record + claims is derived in exactly one place, so an inaccurate statement of it is + worse here than elsewhere. +- **Fix:** Add `Access lists` to the DESIGN.md list, in the position `navFor` + puts it (last, with the other admin items). + +--- + +## 9. `navFromPath` serves one boundary, not the three its docblock names + +- **Severity:** Minor +- **Where:** `src/app/_components/nav-items.ts:59-62`, `:136-142` vs + `src/app/not-found.tsx:48` and `src/app/payouts/[id]/not-found.tsx:80` + +> `navFor` is the rule. `navFromPath` is the same rule run with weaker evidence, +> **for the three surfaces that cannot read a session at all (`error.tsx`, +> `not-found.tsx`, `payouts/[id]/not-found.tsx`)** and have only the URL to go +> on. It is written as calls to `navFor`, not as a second literal list, so that +> "the boundary is the same rule under weaker evidence" is a fact about the code +> rather than a claim in a comment. + +Only `error.tsx` calls `navFromPath` (three times, `:83`, `:91`, `:97`). The +other two named surfaces call `navFor` directly with the bits written out: + +``` +not-found.tsx:48 navFor({ canReadPayouts: false, isAdmin: false }) +payouts/[id]/not-found.tsx:80 navFor({ canReadPayouts: true, isAdmin: false }) +``` + +Those are exactly what `navFromPath` would return for their paths, so nothing is +wrong on screen today, and both files are honest — each says "the nav below is +`navFor({…})`" in its own docblock. The inaccuracy is in `nav-items.ts`, and it +inverts the reasoning it offers: the module argues that expressing the boundaries +as `navFromPath` calls makes the shared-rule claim a fact about the code, and +then two of the three boundaries hardcode that function's *output* instead. The +one change the argument was built to survive — altering what a path branch proves +— would reach `error.tsx` and silently miss the other two. + +- **Cost:** Latent. It costs a reviewer who takes the docblock at face value and + does not check, which is the point of the docblock. +- **Fix:** Either switch the two not-founds to `navFromPath(...)` (root passes + its own pathname; the payout one can pass `"/payouts"`), or correct the + docblock to say `navFromPath` exists for `error.tsx` and the other two apply + the same rule at their own call sites. + +--- + +## 10. Two raw tracking numbers at call sites + +- **Severity:** Minor +- **Where:** `src/app/globals.css:2654` (`.tier--lead`), `:3700` + (`.launch__foot`) + +> Tracking is the one property that legitimately varies, so it is **tokenised by +> the job the label is doing rather than left as a number at the call site**. + +Both of these write `letter-spacing: 0.12em` — the literal value of +`--track-label` — instead of the token. `.tier--lead` is the more interesting of +the two: it overrides `.tier`'s `--track-control` (0.1em) with the *form-and-table +label* tracking on a badge, which is a job change the token vocabulary would have +made visible and the raw number hides. `.launch__foot` is fine-print furniture +sitting beside `.launch__motto` and `.shell__wordmark span`, both of which take +`--track-furniture` (0.14em). + +The other five raw `letter-spacing` values in the file are all legitimate: the +three negative display trackings (`h1`, `h2`, `.launch__title`) match DESIGN.md's +scale table exactly, `.shell__wordmark b` is a positive wordmark tracking outside +the label vocabulary, and `.st--lead` zeroes tracking with an argued reason. + +- **Cost:** Two sites where the rule stops being enforceable by grep, on a + property the record specifically says was tokenised *because* "a new label got + whichever number its neighbour happened to carry." +- **Fix:** `var(--track-label)` at both, or `var(--track-furniture)` at + `.launch__foot` if the intent was to match its neighbours — that is a visual + change and should be measured, so the safe move is `--track-label` at both and + a note. + +--- + +## 11. A cluster of stale figures and stale prose in `globals.css` comments + +- **Severity:** Minor +- **Where:** `src/app/globals.css:1027-1031`, `:1088-1091`, `:588-594`, + `:1583`, `:3577-3578` + +None of these changes behaviour; each is a place where the file argues from a +number or a fact that is no longer true. Collected rather than filed separately +because the fix is the same edit pass. + +| Line | Claim | Measured / actual | +|---|---|---| +| `:1030-1031` | "`--rule` measures 1.76:1 on `--void`, 1.59:1 on `--hull` and 1.39:1 on `--hull-hi`" | 1.62 / 1.50 / 1.33 | +| `:1031` | "`--rule-strong` clears 3:1 on every ground (4.11 / 3.72 / 3.24)" | 4.23 / 3.90 / 3.47 — and this *contradicts DESIGN.md's own table*, which says 4.24 / 3.92 / 3.48 and is right | +| `:1089` | edge fade "mixed to 75% it measures 2.55:1" | ≈2.84:1 composited over `--void` | +| `:589-590` | nav states "clear 3:1 against the ground (8.67 / 10.21 / 12.67:1)" | on `--hull`: `--ink-dim` 8.80, `--gold` 10.73, `--ink` 14.84 | +| `:588` | "`--gold` vs `--ink-dim` … is only 1.18:1" | 1.22:1 | +| `:1583` | "every control inside `.drawer__controls` is `.btn--micro`" | every control in `.drawer__controls` is now plain `.btn` (`admin/accounts/page.tsx:942`, `:1006`, `:1032`, `:1091`, `:1099`, `:1151`, `:1219`) — that is ruling R1 having landed, and this sentence is what it left behind | +| `:3578` | "inside the system's own declared **1.25 minimum ratio**" | DESIGN.md now explicitly retracts that ratio: "'ratio 1.25 minimum between adjacent steps' is what this section used to claim and it has never been true of the shipped scale" | + +Every conclusion these comments draw still holds — `--rule-strong` does clear +3:1, the fade is under 3:1, the nav states do clear their grounds, `.btn--quiet` +was a third size. Only the figures and one tense are wrong. + +- **Cost:** These comments are the file's memory, and the file is unusually + dependent on them — 5477 lines with roughly half of them prose. A reader who + spot-checks one and finds it off by 0.13 loses confidence in the eighty that + are exact. +- **Fix:** Correct in place. The `:1031` one matters most because it disagrees + with a table in DESIGN.md rather than merely being imprecise. + +--- + +## 12. `.btn--quiet`'s base size is bought back four times + +- **Severity:** Minor +- **Where:** `src/app/globals.css:2813-2819` and the four override rules at + `:538`, `:1602`, `:2837`, `:2880-2884` + +> There are **two** sizes and no others: `quiet` is a **colour grade**, like +> `primary` and `default`, **not a third size**. + +`.btn--quiet` nonetheless sets `min-height: 1.75rem` as its base — the record +acknowledges this ("`.btn--quiet` does carry its own `min-height: 1.75rem`… but +that is not the last word wherever it lands") and names one override. There are +four, covering five selectors: + +- `.shell__signout .btn` (`:538`) — header sign-out +- `.manifest-panel__controls .btn--quiet` (`:1602`) — `/account` unlink +- `.inline-edit--standalone .btn--quiet` (`:2837`) — `/payouts/[id]` page-level edits +- `.filters .btn--quiet`, `.filter-form__actions .btn--quiet` (`:2880`) — both filter rows + +Each carries its own multi-paragraph justification, and each says roughly the +same thing: this quiet control is standalone, so it needs 36px back. The base +rule is right in one context — controls inside `.log` rows — and wrong in five, +plus the unfixed sixth in finding 4. A colour grade that has to have its size +corrected in five scoped rules is functioning as a size. + +- **Cost:** Not on screen — the overrides work. It costs the next person who adds + a quiet control outside a table row and does not know to add a sixth override, + which is exactly how `.disc > summary` ended up where it is. +- **Fix:** Out of scope for a report to specify, but the shape is: make 36px the + base and scope 28px to `.log tbody .btn--quiet` / `.btn--micro`, which inverts + five overrides into one. Worth a measurement pass before anyone does it. + +--- + +## PRODUCT.md's five principles against what ships + +1. **Play it straight.** Holds throughout. The joke is confined to the seal, the + `hero-account` illustration, and the motto line; no control, label or status + word is cute. `/admin/audit`'s copy — "Every state change, append only, + newest first. Nothing here can be edited or removed." — is the register + exactly. +2. **State before action.** Holds. `/account` opens with a verdict token and a + tier before any control; `/payouts` carries a `YOURS` column so "was I paid?" + is answerable without a click; `/admin/sync` states worker liveness above the + job table. +3. **Scanning is the primary act.** Holds structurally — the value/control split + (`globals.css:2443-2449`: every value carries a leading dot in its own hue, + every control is a neutral box and never carries one) is a genuinely good + scanning device and works in `15-admin-accounts.wide.png`. Per-surface + scanning failures are the surface reviewers' territory. +4. **Nothing reads as punishment.** Holds, and is enforced in code rather than + asserted: `payouts/page.tsx:314-324` deliberately renders a draft + mid-payment neutral rather than amber ("rendering them amber burned the alarm + colour on nothing"), cryo is amber only in the admin table and `--ink-dim` on + the member's own page, `.st--ok` is `--ink-dim`, and `.btn--danger-quiet` + exists precisely so an ordinary destructive choice is not permanently the + loudest thing on its page. Alumni is `#81bb8d`, not a warning colour. +5. **Earn the artwork.** Partly. The header mark is a purpose-cut 34px asset + rather than a scaled seal (`ui.tsx:117`), and the night-cut + `hero-account.webp` is argued at `DESIGN.md:330-337`. But `.closing img` draws + that 1120px asset at `min(420px, 100%)`, and `.closing--compact` at + `min(260px, 100%)` — a 4.3× downscale — while `account/page.tsx:1382-1384` + describes the compact case as "the same asset for a smaller frame rather than + cropping or downscaling it." At 2× DPR the 420px case is fine; the 260px case + is downscaling by any reading. This is adjacent to the known-open + "oversized images with no `sizes`/priority" item, so I am not filing it + separately — but the *prose* is a separate defect from the byte weight, and + the known-open entry does not cover it. + +Accessibility claims in PRODUCT.md that I could check all hold: no `outline: +none` anywhere in the stylesheet, the focus ring is `2px --gold` at `2px` offset +globally (`:289-293`) with two scoped `-2px`-offset variants for regions that +would clip it, the skip link takes `--gold` ground and `--void` text as +described (`:4963-4972`), `prefers-reduced-motion` collapses globally, +`.visually-hidden` is a proper clip-path implementation, and no text token +measures under 4.5:1 on any of the three grounds (worst case `--ink-faint` on +`--hull-hi` at 4.61:1). + +One thing I noticed in passing and am not filing as a record contradiction +because it belongs to that surface's own reviewer: `/admin/access-lists` +(`page.tsx:133`) is the only `<main>` in the app without `tabIndex={-1}`, so the +skip link's target is not focusable there. + +--- + +## What is genuinely good and should survive + +- **DESIGN.md's colour tables are honest.** I recomputed every measured ratio + from rendered sRGB and they land within 0.02: `--rule-strong` 4.23/3.90/3.47 + against the stated 4.24/3.92/3.48; `--signal-bad` 5.83/5.38/4.79 against + 5.83/5.39/4.79; `--signal-warn` 9.78/9.03/8.04 against 9.75/9.01/8.00; + `--void` renders `#0a0a0a` at a red-to-blue ratio of exactly 1.00. The one bad + figure (finding 6) is an outlier in an otherwise trustworthy table. Do not let + a fix pass "tidy" these. +- **The type scale's self-correction.** DESIGN.md's retracted 1.25 claim is + replaced by an accurate one: `--t-body`/`--t-data` is 1.071, and the four steps + from `--t-body` to `--t-label` are 1.071 / 1.077 / 1.083 / 1.091 — inside the + stated 1.07–1.09 band. `--t-h2` → `--t-body` is 1.467, matching the stated + 1.47. And the scale is genuinely closed: exactly three raw `font-size` values + exist in 5477 lines (`0.9em` on inline `code`, and the two documented one-offs), + each used once, each argued in place. +- **The two card exceptions hold, and the third was refused in writing.** + `.launch__panel` and `.form-panel` are the only two rules in the file pairing a + ground with a border on a content container, and `globals.css:3216-3221` + records the decision *not* to give `/payouts/new` registration ticks, quoting + the rule it would have broken. `.manifest-panel` has no ground of its own, + `.drawer` is a hairline, `.escalation` and `.json__full` are `--void` insets in + the Field idiom. +- **The label register itself.** Fourteen selectors, all at 600, none overridden, + all live, and the five deliberate non-members each argued from the same test + (`--track-value` is the tell). It is one undeclared selector away from being + exactly what the record claims. +- **`.shell__nav a` stayed fixed.** `globals.css:561-570` is + `display: inline-flex; align-items: center; min-height: 2.25rem` — the + documented idiom, not a padding-sized box, so the third undocumented ~33.05px + grade is gone and the underline offset is preserved. Pinned by + `e2e/shell.spec.ts`. +- **Ruling R1 landed where it was claimed.** Every control inside + `.drawer__controls` on `/admin/accounts` is plain `.btn` at 36px; the + `.manifest-panel__controls .btn--quiet` buy-back is present and does win on + both specificity and source order as DESIGN.md states. +- **`.st--ok` is `--ink-dim` and the argument is preserved beside it.** + +## What I could not evaluate + +- **Rendered weight and tracking.** Everything about type here is read from the + cascade, not from a browser. The `.status-line__label` finding is a cascade + fact (no `font-weight` in any matching rule) confirmed against a screenshot, + but I could not run `getComputedStyle`. +- **The `.tier--unknown` / `.tier--pending` tint measurements** + (`globals.css:2628-2632`: 4.56 / 3.94 / 5.67). These are `--ink-faint` and + `--ink-dim` over a 14% `color-mix` tint over a hovered row — three composites + deep, and `color-mix(in oklab, …, transparent)` composites differently than a + naive alpha blend. I could not reproduce them confidently enough to affirm or + challenge, so I left them alone. Everything around them checked out, which is + weak evidence they are fine. +- **Hover, focus and armed states.** All static shots. `.btn--danger-quiet`'s + row-hover red, `.field:focus-visible`'s gold border, `ConfirmSubmit`'s armed + swap and the `.scroller-fade` `data-visible` toggle are read from CSS only. +- **Saturated-colour area below ~4%.** I estimated from the shots rather than + sampling pixels. Nothing came near 10%, so the claim holds with room, but the + per-surface figures in finding 3 are estimates, not measurements. +- **Narrow-viewport register behaviour.** `.crew__label`'s stacked-block mode + below 29.9375rem is only reachable at 320px; the narrow shots are 390px, where + the real `<thead>` is still showing. + +## Contested — settled taste I think is worth one challenge + +None. Every settled item I touched (the `.st--ok` neutral, `--void` at chroma 0, +two card exceptions, registration ticks on login only, near-zero radii, the tight +type ramp, the two-family split) verified as shipped and I have no argument +against any of them. Finding 3 reads as a challenge to the gold ration but is +not: I am asking the record to state the ration the app already implements, not +to change the ration. diff --git a/docs/design-sweep-2/reports/sync-audit.md b/docs/design-sweep-2/reports/sync-audit.md new file mode 100644 index 00000000..2b92180b --- /dev/null +++ b/docs/design-sweep-2/reports/sync-audit.md @@ -0,0 +1,392 @@ +# `/admin/sync` — technical audit + +Register: **product**. Command: `$impeccable audit`. Source read in full: +`src/app/admin/sync/page.tsx` (1143 lines), `actions.ts`, `view.ts`, +`src/core/schedules.ts`, `src/core/run-health.ts`, +`src/app/_components/{disclosure,confirm-group,scroller,submit,relative-time,ui}.tsx`, +and the `.strip` / `.st` / `.notice` / label-register blocks in +`src/app/globals.css`. Shots read before source: +`docs/design-sweep-2/shots/11-admin-sync.{wide,narrow}.png`. + +## What the shots show + +**Wide (1440).** A 1200px column. H1 "Sync", then a three-line subtitle. Then a +red notice (mono, `worker · heartbeat check failed`) and an amber one +(proportional, `SYNC_MODE=dry-run`), both stopping at ~46% of the column width +while the strip below runs the full 1200. Then `9 JOBS · CHECKED 11:57:14 UTC` +with a hairline, then the strip: a column-label row, then four group labels +(SWEEP / MEMBER-FACING / ON-DEMAND / HOUSEKEEPING) over eight collapsed job rows +and one collapsed housekeeping summary line. Seven of the eight rows read +`▲ OVERDUE`; all eight read `19h ago`. Every row's name ends around x=247 and its +health token starts at x=627. The rightmost content on any row ends at x≈1030; +the strip's border is at x=1320. Bottom: `SYNC NOW` in gold, then +`RECHECK INVALID AFFILIATIONS`, then `REFRESH`. + +**Narrow (390).** The column-label row is gone and each job reflows to two lines +(name, then health/age/cadence). The housekeeping summary line does not reflow: +`+ ▲2 JOBS · TOKEN-HEALTH OVERDUE, PURGE OVERDUE` runs past the strip's right +border at x≈375 and is cut at the viewport edge. The first job row sits ~750px +down the page. + +## Audit health score + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 2 | Two R4 parity breaks in opposite directions on a surface otherwise unusually careful (F2, F3) | +| 2 | Performance | 3 | Nine drawers' subtrees render and hydrate whether opened or not — argued for, but it is the cost | +| 3 | Responsive Design | 2 | Housekeeping's health line overflows the viewport below ~26rem (F1); 48% of the strip empty at 1440 (F4) | +| 4 | Theming | 4 | Every colour a token; one hard-coded `1.5px` | +| 5 | Anti-Patterns | 4 | No cards, no gradients, no glass, no hero metric, no side stripes | +| **Total** | | **15/20** | **Good — address the two weak dimensions** | + +**Anti-patterns verdict: pass.** Nothing here reads as generated. Structure is +hairlines and typed labels; the one bordered box on the page is a list region, +not a card. The absent tells are the load-bearing ones: no green wall of healthy +dots, no icon-and-heading grid, no glassmorphism, no `border-left` accent, no +gradient anywhere. The strip's grouped-list construction is the opposite of a +template. + +--- + +## Findings, worst first + +### F1 — Housekeeping's collapsed health line overflows the viewport, and it is the only place either of those two jobs can report a fault + +- **Severity:** Serious +- **Where:** `src/app/globals.css:4389` (the `@media (max-width: 46rem)` block) + — `.strip__group-disc > summary` is absent from it, while + `.strip__disc > summary` at `:4394` gets `flex-wrap: wrap`. Compounded by + `.st { white-space: nowrap }` at `src/app/globals.css:2461`. +- **Evidence:** visible in `11-admin-sync.narrow.png` at 390px — the line + crosses the strip's border and is clipped by the viewport. At 320px the page + content box is 288px, the strip's inner box 286px, less the summary's two + `--s-4` paddings and the `+` marker plus its `--s-3` gap leaves ~233px. The + string is 44 characters at `--t-label` (11px) IBM Plex Mono with + `--track-value`, ~7.5px per character, so ~330px plus the status token's own + dot and gap. Roughly 106px of overflow that cannot wrap. The healthy string — + `2 jobs · nothing needs attention`, 31 characters, ~242px — is over the same + 233px budget, so this line has never fitted at 320px in any state. +- **Cost:** `token-health` and `purge` are reachable from no control anywhere in + the product, which is the stated reason this collapsed line exists at all. An + admin reading on a phone at 1am sees `2 JOBS · TOKEN-HEALTH OVERDU` and the + second job's state is off the right edge of the screen. +- **Fix:** add `.strip__group-disc > summary` to the 46rem block alongside its + sibling, and let the token wrap there — either `white-space: normal` scoped to + `.strip__group-disc .st`, or split `groupHealthSummary` (`view.ts:200`) so the + count stays inside the `Status` token and the flagged-job list becomes a + wrapping sibling beside it. The base stylesheet already pairs + `.strip__disc > summary` and `.strip__group-disc > summary` in five places + (`:4239`, `:4256`, `:4263`, `:4268`, `:4280`, `:4285`); this is the one place + the pairing was dropped, and it is the one that decides reflow. +- **Principle:** WCAG 2.2 1.4.10 Reflow. + +### F2 — Every "next HH:MM" is announced with no timezone; the only channel carrying UTC for those rows is `aria-hidden` + +- **Severity:** Serious +- **Where:** `src/app/admin/sync/page.tsx:543` and `:576-577`, with + `src/app/admin/sync/view.ts:252-260` and `:288-292`. +- **Mechanism:** `.strip__head` is `aria-hidden="true"` + (`page.tsx:381`), so `Cadence (UTC)` reaches sighted readers only. + `splitCadenceUtc` restores ` UTC` in a `visually-hidden` span exactly when the + cadence string ends in ` UTC`, which `formatCadence` writes only on its + fixed-hour branches. `nextRunFor` returns `null` on precisely those same + branches (`cadenceNamesTime`, `view.ts:277-280`). The two are mutually + exclusive by construction: **a row that renders a "next HH:MM" always has + `hiddenUtc === false`.** +- **Count, checked against `JOB_CRON`:** six of the nine seeded jobs render a + bare wall clock with no zone anywhere in the accessible name — `membership` + (`every 30m` / `next 12:00`), `contacts`, `wanderer`, `discord-roles`, + `location`, `access-lists`. The three that do get the restoration — + `membership-recheck` (`Sun 04:00 UTC`), `token-health`, `purge` — are the + three that render no next-run time and never needed it. The restoration works + correctly and covers only the rows that were never at risk. +- **Cost:** a screen-reader admin hears "wanderer, overdue, 19h ago, hourly :10, + next 12:10" and cannot tell whether 12:10 is their own clock. For a UK admin + in summer it is an hour out, and "is this job actually late" turns on it. +- **Fix:** give the next-run clause the same treatment the cadence got — + `next {utcHhmm(nextRun)}<span className="visually-hidden"> UTC</span>` at + `page.tsx:577`. One span. Alternatively drop `aria-hidden` from the cadence + header cell alone, but the per-row span is the shape this page already uses. +- **Principle:** DESIGN.md Ruling R4 (parity, standard direction); WCAG 1.3.1. + +### F3 — The queued marker's only copy of itself lives in the assistive-tech channel + +- **Severity:** Serious +- **Where:** `src/app/admin/sync/page.tsx:497-509`; + `src/app/globals.css:4117-4138`. +- **Mechanism:** the ring is `aria-hidden="true"`. The word "queued", and the + escalation "queued 18m ago" (`queuedMarkerText`, `view.ts:496`), exist only in + the adjacent `.visually-hidden` span. On screen the whole fact is a 0.5em + outlined circle with no label, no legend anywhere on the page, and no column + header naming it — the track it sits in is headed "Health". When + `queuedMarkerStuck` fires, meaning the dispatcher has been wedged 15 minutes or + more, the visible change is that the circle fills amber. No word appears + anywhere. `startDispatcher` swallows a dispatch failure into `console.error` + and reports it nowhere else, which the CSS comment at `:4128` states, so this + ring is the only surface that condition has. +- **Cost:** a sighted admin who pressed "Sync now" and came back to find nothing + landed sees a small ring beside "overdue" and has no way to learn what it + means. The sentence that would tell them is written, correct, and deliberately + withheld from them. +- **Fix:** R4's remedy is to say it on screen. The component's own comment is + right that the 7.5rem health track has no room for a second word, so the fix is + not a second word in that track — put the state where there is room: appended + to `.strip__cadence` ("queued 18m"), or as a second line under the health token + in the wrapped narrow layout, or promoted to the group line the way + housekeeping's own summary already works. The `Absent` precedent the comment + cites (`page.tsx:113`) does not transfer: `—` and `…` are conventional absence + marks in a cell whose column header names the missing value. This ring has no + header naming it and no convention behind it. +- **Principle:** DESIGN.md Ruling R4 (parity, inverse direction — the codebase's + own characteristic failure, named in DESIGN.md:461-467); WCAG 1.4.1. + +### F4 — 48% of the strip's width carries nothing, on every row, at every width above 46rem + +- **Severity:** Moderate +- **Where:** `src/app/globals.css:4076` (`grid-template-columns: 1ch + minmax(7rem, 28rem) 7.5rem 6.5rem 9rem 1fr`), with `.strip` at `:4038`. +- **Measured**, from the track list and confirmed against + `11-admin-sync.wide.png`: at 1440 the page content box is 1200px. From the + strip's left border the fixed tracks and gaps consume 905px, so the cadence + column ends at x≈1026 (the shot reads x≈1030) and the trailing `1fr` spacer + runs 260px to the border at x=1320 — 22% of the strip, empty inside a visible + border on every row. Inside that, the name track sits at its 28rem (448px) + ceiling for a longest job name of `membership-recheck`: 18 characters at + `--t-label` 11px mono with `--track-furniture`, ~147px. The shot shows the gap + directly — "MEMBERSHIP" ends at x≈247, "▲OVERDUE" starts at x≈627, ~380px of + unbroken near-black. Together ~570px of the strip's 1199px is unfilled. +- The CSS comment at `:4067-4075` documents finding this once already ("the three + value columns sat ~840px from the name they describe with nothing... filling + the gap") and fixing it by capping the name track at 28rem. That took 840px to + ~380px, not to zero. This is the sweep's pattern 1, half-resolved. +- **Cost:** an admin scanning at 1440 crosses ~380px of empty ground between a + job's name and its state, on a 62px row with no zebra and no leader. Scanning + the health column vertically works — that is what the grid buys and it works. + The horizontal read, "which job is the red one", is what costs a second look. +- **Fix:** the name track's ceiling is a guess where the content is a closed set + of nine known strings. Cap it near `max-content` — ~14rem covers + `membership-recheck` with room, and `.strip__name` already carries + `overflow-wrap: anywhere` for safety. Then cap `.strip` itself at its own + content measure rather than leaving a 260px empty `1fr` inside its border. + "Narrow surfaces cap their contents, never the column" sanctions this: `.strip` + is contents, not the column. +- **Principle:** none cited. The sweep's pattern 1. + +### F5 — The health column restates the page-level worker condition on every row, and this codebase has already written down why that is wrong + +- **Severity:** Moderate +- **Where:** whole surface. Observation: in `11-admin-sync.wide.png` seven of + eight visible rows read `▲ OVERDUE` and all eight read `19h ago`, directly + below a red notice that already says the worker's state is unknown. +- `view.ts:99-105` excludes `overdue` from auto-open and states the reason: "when + the worker dies, every row goes overdue at once, so opening on it would expand + all seven drawers together and destroy exactly the 'this one job needs you' + signal auto-open exists to create. A dead worker is a page-level condition and + it is the worker line above the strip that says so." That argument is applied + to the drawer and not to the token, so the token paints the page-level + condition nine times. +- **Cost:** an admin arriving because a Discord role never landed reads a column + of identical amber. The worst case is bounded — `rowHealth` returns `failing` + before it reaches the overdue branch (`run-health.ts:212`), so a job that + actually errored still reads red among the amber. What is lost is the middle + case: a job that succeeded and then silently stopped being scheduled is + indistinguishable from eight casualties of a dead worker, and that is the + failure `run-health.ts` was written to catch (its own docblock, lines 7-15). +- **Fix:** the `crewNorms` shape from `/account`. When `worker.fresh` is false, + `overdue` is not a per-row fact. State it once — in the worker line, or as a + note on each group — and let the rows show their last recorded outcome, so the + column carries deviation from the shared condition rather than the condition + itself. Both channels stay in parity, because it is the same sentence either + way. +- **Principle:** the sweep's pattern 2; consistent with `NEEDS_ATTENTION`'s own + stated reasoning. + +### F6 — On a phone the first job row is ~750px down, and a third of that is a subtitle restating the notice beneath it + +- **Severity:** Moderate +- **Where:** `src/app/admin/sync/page.tsx:233-251`. +- The lede's second half branches on worker state and, in the captured state, + reads "whether the worker picks it up is unknown right now — its heartbeat + could not be checked — see the line below". The red `Notice` roughly 40px + beneath it reads "worker · heartbeat check failed — unknown whether the worker + is running". One fact, twice, in two type families, and the second of the two + is the element designed to carry it. Read off `11-admin-sync.narrow.png`: nav + ends y≈215, lede ends y≈390, the two notices run to y≈600, section header + y≈655, first job row y≈750. At 844px of viewport that is one job row above the + fold. +- **Cost:** the admin who opened this at 1am on a phone because a role did not + land scrolls past a paragraph telling them nothing they will not read again 40 + px later, before they can see a single job. +- **Fix:** the branching half of the lede is a status readout wearing a subtitle. + Cut it. Keep "The jobs that keep tiers, roles and standings in step with the + game. The buttons enqueue work." and let the worker `Notice` — which already + branches on the same three heartbeat states, and which a screen reader reaches + as a `role="alert"` rather than as prose — be the only thing reporting worker + state. +- **Principle:** the brief's "an explanatory subtitle under an H1 is a smell", + in its live-state variant. + +### F7 — The bottom control row's widest label is its least consequential action, and one of the three is a link + +- **Severity:** Minor +- **Where:** `src/app/admin/sync/page.tsx:1108-1140`. +- Gold correctly takes the eye to "Sync now". Measured off the wide shot, + `SYNC NOW` is ~97px and `RECHECK INVALID AFFILIATIONS` ~260px — the secondary + action is 2.7× the primary's width, so size pulls against colour rather than + with it. `Refresh` (`:1137`) is an `<a>` styled identically to two `<button>`s + that enqueue work; assistive tech separates them by role, a sighted admin + cannot. +- Separately: nothing visible connects "Sync now" to the `SWEEP` group label + ~600px above it. The comment at `:1111-1115` says the group header is what now + answers "which jobs", and the header does exist — but no thread ties the button + to it, so the answer is only available to someone who already knows to look. +- **Cost:** an admin who cannot tell what "Sync now" touches either presses + per-job Re-run four times, or presses it and re-runs three jobs that were fine. +- **Fix:** name the scope on the control ("Sync sweep", or a `.btn-row__stamp` + beside it reading the four job names), and separate `Refresh` from the two + enqueues using the hairline vocabulary the strip already owns. +- **Principle:** none cited. The sweep's pattern 3, already partly answered by + the gold ration. + +### F8 — A refused second press is silent on all three enqueue controls + +- **Severity:** Minor +- **Where:** `src/app/admin/sync/page.tsx:1043`, `:1110`, `:1120`. +- `Submit` is deliberately not disabled while pending, and `useSubmitGuard` + refuses the second press. `onRefused` is opt-in (`submit.tsx:49-52`) and none + of this page's three `Submit`s pass it. `pendingLabel="Queueing…"` covers the + sighted case for as long as the round trip lasts; a press landing after the + label reverts and before the fresh render arrives is refused with no output at + all, in either channel. +- **Cost:** bounded. The window is short and the outcome — nothing enqueued twice + — is the desired one. Filed because the mechanism to say so exists and costs + one prop, and because "nothing happened, press again" is the trap the rest of + this page spends `ConfirmNotice` and `ConfirmGroup` on avoiding. +- **Fix:** pass `onRefused` on the Re-run `Submit`, reporting through the + drawer's existing `ConfirmGroup` via `useConfirmReport`. The two bottom + controls have no group and would need one, so they are the larger half. + +--- + +## A correction to the brief's premise + +The dispatch block asks me to "check the arm-then-press confirm flow on the +per-job Re-run controls with a keyboard." **There is no arm-then-press flow on +this page.** `page.tsx` imports `ConfirmNotice`, `ConfirmGroup`, +`ConfirmingForm`, `Disclosure` and `Submit` — not `ConfirmSubmit`, which is +where the two-stage arm/press behaviour lives and which this file never +references. The per-job Re-run is a single-press enqueue: one Enter on a +`.btn`, the action fires, `ConfirmGroup` moves focus to the confirmation +sentence below it. That flow is correct — the confirmation is a DOM sibling +after the form, so focus moves forward, and `ConfirmGroup`'s counter-based +effect re-fires on a repeat press even when the sentence is byte-identical +(`confirm-group.tsx:92-98`). + +Whether an immediate enqueue is the right call for this control is a design +question outside an audit's remit; the stakes are low (a queued sync job) and +the surrounding admin tables that do arm-then-press guard destructive or +tier-changing acts, which this is not. + +## Queued-status announcement: the answer + +Nothing announces it, and nothing updates it. The page is `force-dynamic` with +no polling — deliberately, per the comment at `page.tsx:1132-1135` ("an admin +reading an expanded failed row must not have the page move under them"). +`RelativeTime` ticks only its own age text, in a `<time>` that is not inside a +live region, so a job going queued → running → ok is invisible until a reload. +The copy is honest about this: `queuedNotice` ends every sentence with "reload +this page to see the run land." I agree with the trade. The gap it leaves is F3 +— the marker that *would* report a wedged dispatcher after a reload has no +visible form. + +--- + +## What is genuinely good and should survive + +- **Contrast, everywhere, measured.** Rendered sRGB: `--ink-faint` `#90877e` on + `--void` `#0a0a0a` is 5.64:1 and on a hovered row `#21201f` is 4.63:1; + `--signal-warn` `#ff9f5f` is 9.75:1 on void; `--signal-bad` `#f05751` is + 5.83:1. The one combination worth checking by hand — the red worker line + inside the red-tinted `.notice--bad`, whose ground composites to `#261313` — + measures 5.22:1. The strip's own boundary, `--rule-strong` `#787370`, is + 4.24:1, clearing 1.4.11's 3:1 as a control boundary. Nothing on this surface + is close to a floor. Do not touch the tokens. +- **`Absent`** (`page.tsx:113`) and the `.only-narrow` `started … UTC` + restorations (`page.tsx:690`, `:851`). The brief asks that these survive; they + should, and they are the correct model for fixing F2. +- **`Scroller`'s conditional `tabIndex`** (`scroller.tsx:95`) — a tab stop only + while there is scroll range, with a `ResizeObserver` so a drawer opening + restores it. That is the right answer to a problem most codebases get wrong in + one direction or the other, and it is pinned by an e2e case. +- **`RelativeTime`'s single shared 30s ticker** for up to 42 instances, with + per-subscriber `try`/`catch` so one bad instant cannot freeze every timestamp + registered after it. +- **Re-run at the full 36px grade.** `page.tsx:1043` uses `className="btn"` + (`min-height: 2.25rem`, `globals.css:2675`), not `.btn--micro`'s 28px. The + brief's requirement is met, and the reason is written down at the call site. +- **`syncJobAction` returning through `useActionState`** instead of redirecting, + so pressing Re-run does not collapse the drawer it was pressed in — a defect + caught twice on two pages and fixed once, in a shared component. +- **`role="list"` per group with `aria-labelledby`** pointing at a *visible* + heading. The trade (losing the flat "list, 9 items" count) is real, was + weighed, and was resolved in the direction that keeps the grouping actionable + for everyone. +- **Focus rings inset at `outline-offset: -2px`** on the summaries + (`globals.css:4285`) so the strip's own border cannot clip them. +- **`Notice`'s empty-slot pattern** — the live region is registered before its + text arrives, which is the one shape that actually announces. +- **Reduced motion.** The global `prefers-reduced-motion` block + (`globals.css:295`) covers the page's only motion, a 140ms background-colour + transition on the summaries — which would be harmless either way. + +## What I could not evaluate, and why + +- **Every drawer is closed in both shots.** The run-history table, the counter + columns, the `Absent` glyphs in situ, `Json`'s Raw cell, the `.log--runs` + 44rem (704px) floor inside a `Scroller`, and the Re-run control itself were + read from source only and never seen rendered. At 320px that 704px table sits + in a ~254px scrollport — roughly 450px of horizontal scroll to read one run. + It is inside a labelled, keyboard-reachable `Scroller` with edge fades and the + ISO stamp drops to relative time below 40rem, so I believe it is handled, but I + am reporting a reading of the CSS, not of a picture. +- **No screen reader was run.** F2's claim is structural — there is no UTC token + anywhere in that subtree, in any engine — and does not depend on the name + computation. I considered and am *not* filing the `<h3>`-inside-`<summary>` + question: the WebKit behaviour that prunes headings applies to `<button>`, and + `<summary>` is not a `<button>`, so the concern does not survive checking. +- **The queued ring never appears in either shot** — no job had work queued when + the fixture was captured. F3's visible rendering is derived from + `globals.css:4117-4138`, not observed. +- **Zoom was not rendered.** By calculation, 200% at 1280 gives an effective + 640px, above the ~430px where F1 bites, so F1 is a narrow-phone and 320px + failure rather than a 200% one. At 400% — the 320 CSS px width 1.4.10 is + actually specified at — it bites hard. +- **`ConfirmGroup`'s focus move** on a Re-run press was traced through + `confirm-group.tsx` but not observed in a browser. + +## Contested — settled taste, challenged once + +The impeccable shared design laws ban em dashes in copy outright. This surface +has four in user-visible strings: two in the page lede (`page.tsx:247`), one in +`workerLine` (`page.tsx:180`), one in the dry-run notice (`page.tsx:319`). I am +not filing this as a finding, because "deadpan voice" is listed as settled and +em dashes appear in shipped copy across the whole app, so the house voice has +plainly chosen them. Raising it once: three of those four sit inside sentences +that are already doing two jobs, and in each case the clause the dash introduces +is the one I would cut rather than repunctuate — see F6, where deleting that +clause is the fix regardless of how it is punctuated. + +## Recommended actions + +1. **[P1] `$impeccable adapt`** — F1. Add `.strip__group-disc > summary` to the + 46rem block and let the housekeeping health sentence wrap; verify at 320px, + not at 390px. +2. **[P1] `$impeccable harden`** — F2 and F3. One `visually-hidden` span for the + next-run clause; a visible home for the queued state. +3. **[P2] `$impeccable layout`** — F4 and F6. Tighten the name track to its + content, cap `.strip` at its own measure, cut the lede's status half. +4. **[P2] `$impeccable clarify`** — F5 and F7. Say "overdue" once when it is a + page-level condition; name "Sync now"'s scope; separate `Refresh` from the + enqueues. +5. **[P3] `$impeccable polish`** — F8, and a re-run of this audit against the + drawer-open state, which nothing in this sweep has photographed. diff --git a/docs/design-sweep-2/reports/sync-critique.md b/docs/design-sweep-2/reports/sync-critique.md new file mode 100644 index 00000000..3b27ab9e --- /dev/null +++ b/docs/design-sweep-2/reports/sync-critique.md @@ -0,0 +1,457 @@ +# `/admin/sync` — critique + +Register: product. `$impeccable critique`. Read-only pass over +`11-admin-sync.wide.png`, `11-admin-sync.narrow.png`, +`src/app/admin/sync/{page.tsx,actions.ts,view.ts}`, `src/core/schedules.ts`, +`src/services/health.ts`, and the `--- Sync status strip ---` block of +`src/app/globals.css`. + +## What I see, before explaining it + +**Wide (1440×900, page runs 1310px).** A title, then three lines of grey prose, +then a red box, then a brown box, then a rule reading `9 JOBS · CHECKED +11:57:14 UTC`, then a bordered panel of nine rows in four labelled groups, then +three buttons of which the first is gold. + +The panel is 1200px wide and about half of it is empty. Job names sit at x=161 +and stop around x=246. The health tokens start at x=627. Between the name of a +job and the word telling you whether it is broken there are roughly 380px of +nothing — no rule, no zebra, no leader. Past the cadence column at x=1030 there +is another 290px of nothing before the panel's own right border at x=1320. + +The `Last run` column reads `19h ago`, nine times, identically. The `Health` +column reads `OVERDUE` seven times with the same amber triangle, `OK` once, and +the ninth is folded. The `Cadence (UTC)` column stacks two lines on six of the +seven visible rows, and the second line begins with the word `next` every time. + +The top third of the page is left-half-only. The prose wraps at ~600px; both +notice boxes stop at ~545px; the right 650px is void. + +The three buttons are at y≈1203 — below the 900px fold. + +**Narrow (390×844).** The panel becomes a stacked list. The column headers are +gone, and with them `Cadence (UTC)`, so the timezone is nowhere near the times. +The housekeeping line — `+ ▲2 JOBS · TOKEN-HEALTH OVERDUE, PURGE OVERDUE` — runs +straight off the right edge of the screenshot, past the panel border. + +--- + +## Findings + +### 1. Half of every row is empty, and the health token sits 466px from the name it describes + +**Severity:** Serious +**Where:** `src/app/globals.css:4076` (and the composite of the wide shot) + +The name track is `minmax(7rem, 28rem)`. At `--measure-page` (78rem) it resolves +to its 28rem maximum, 448px. The longest string it will ever hold is +`membership-recheck` — eighteen characters, measured at 154px in the wide shot, +because every job name is a literal key of `JOB_CRON` (`src/core/schedules.ts:10`) +and there are exactly nine of them. The track is spending 448px to hold 154px. +Add the trailing `1fr` spacer (~290px) and roughly 580px of a 1200px row — just +under half — is blank by construction, on every row, at every load. + +The comment above the line is right about what it fixed: an unbounded `1fr` was +worse, and it says so honestly (~840px). The direction is correct and the number +is not. 28rem was chosen to be smaller than "the viewport" rather than to be +larger than "the widest job name," and those are different sizes. + +**Cost:** The primary act on this page is finding the one row that is not `OK` +among nine. An admin does that by scanning the health column, which means nine +saccades across ~380px of empty ground away from the label that says which job +they are looking at — and then nine back, once they find the amber one, to read +its name. On the narrow viewport the same content stacks and reads fine, which +is the tell: the wide layout is doing less with more. + +**Fix:** Cap the name track at the content it actually holds — +`minmax(7rem, 13rem)` clears 154px with room — and let the trailing `1fr` absorb +the rest. The four values then read as one left-aligned block about 700px wide, +the health column lands ~50px from the name, and nothing about the fixed value +tracks, the grouping, or the accessible names changes. `.strip__name` already +carries `min-width: 0; overflow-wrap: anywhere`, so the `other` bucket's +arbitrary job type still wraps rather than overflowing. + +**Principle:** Pattern 1 (unshaped field), stated inside a table rather than at +page scale. Also *impeccable* product register: "predictable grids… consistency +IS an affordance" — a fixed grid whose largest track is mostly air is paying +grid's cost without buying its benefit. + +--- + +### 2. The one line carrying a folded fault overflows the panel on a phone + +**Severity:** Serious +**Where:** `src/app/globals.css:4239-4244` with `.st`'s `white-space: nowrap` at +`src/app/globals.css:2461` + +`.strip__group-disc > summary` is `display: flex` with default `nowrap`, holding +a single `<Status>`, and `.st` sets `white-space: nowrap`. Its text is +`groupHealthSummary` (`view.ts:200`), which names every flagged member: +`2 jobs · token-health overdue, purge overdue` — 43 characters of uppercase mono +that cannot break anywhere. In the 390px shot it runs past the panel's right +border to the edge of the capture. `.strip` sets no `overflow`, so the string +escapes rather than clipping. + +This is the worst place on the surface for that to happen. Housekeeping is +folded precisely because `token-health` and `purge` are reachable from no +control on this page and surface nowhere else in the product; `view.ts:176-198` +argues at length that the collapsed sentence has to say enough on its own, +because a shut `<details>` is out of the accessibility tree. The sentence is +load-bearing, and it is the sentence that runs off the screen. + +**Cost:** An admin checking on a phone why nobody's tokens refreshed reads +`2 JOBS · TOKEN-HEALTH OVER` and has to scroll the page sideways — or, at 320px, +may not get the horizontal scroll at all depending on the ancestor — to find out +which two jobs and in what state. The fold is a summary that does not fit in the +space a summary gets. + +**Fix:** Let this one summary wrap: `flex-wrap: wrap` on +`.strip__group-disc > summary`, and either `white-space: normal` scoped to +`.strip__group-disc .st` or move the sentence out of `Status` into a sibling +span so the token keeps its `nowrap` and the prose does not. The token's own dot +and word stay unbreakable; only the comma-joined member list wraps. Do not +truncate it — the member names are the payload. + +**Principle:** WCAG 2.2 1.4.10 Reflow (content must not require two-dimensional +scrolling at 320px), and the sweep's own rule that the narrow viewport is not an +afterthought. + +*(Adjacent, same block: `.strip__cadence` also carries `white-space: nowrap` +(`globals.css:4308`). `every 15m from :02` fits at 390px with ~40px to spare, so +it is not broken today, but it is the same unbreakable-string posture one +`formatCadence` branch away from the same failure.)* + +--- + +### 3. The `Cadence (UTC)` fix loses its visible channel below 46rem + +**Severity:** Serious +**Where:** `src/app/globals.css:4390-4392` against `src/app/admin/sync/view.ts:252` + +This is the surface's celebrated pattern-2 solve, and at 1440px it is exactly +right: the header states the shared timezone once, `splitCadenceUtc` strips +` UTC` from every row's visible text, and `page.tsx:543` restores it per row in a +`visually-hidden` span so the accessible name keeps it. Two channels, parity +held. + +Below 46rem, `.strip__head { display: none }` — correctly, the tracks it labels +no longer exist. But `splitCadenceUtc` is not media-queried. So on a phone the +visible text says `Sun 04:00` and `next 12:00` with the timezone deleted from +the only channel a sighted user has, while the hidden span still announces +`Sun 04:00 UTC` to a screen reader. The parity the fix was built to preserve is +now inverted: the fix removed the word from the visible channel on the strength +of a header that is no longer on screen. + +The nearest UTC on the narrow page is `CHECKED 11:57:14 UTC` in the section +rule, attached to a different fact five rows up. EVE runs on UTC and an +experienced admin will assume it, but the reason this fix was made at all was +that assuming it is not good enough. + +**Cost:** An admin on a phone at 1am reads `next 12:00`, checks a wall clock +showing 07:00 local, and cannot tell whether that job is five hours out or +already late. + +**Fix:** Restore the visible ` UTC` below the breakpoint where the header that +replaced it is hidden — either a `.strip__cadence` `::after` inside the same +`@media (max-width: 46rem)` block that hides the header, paired with dropping +the hidden span there so it is not said twice, or a narrow-only `UTC` label in +the row. Whichever way, the invariant to hold is: the word appears exactly once +per channel per row-set, at every width. + +**Principle:** Pattern 2's own rule — "keep both channels in parity." The fix is +correct; its scope stops at a breakpoint it does not know about. + +--- + +### 4. Nine rows say `19h ago`; nothing says what that adds up to + +**Severity:** Moderate +**Where:** whole surface (the wide and narrow shots, `Last run` and `Health` +columns) + +This is the answer to "what still enumerates." When the worker stops — the +condition that brings an admin to this page — every row's `Last run` collapses +to the same value and every scheduled row's `Health` collapses to the same word. +The shot shows `19h ago` nine times and `OVERDUE` seven times. Those eighteen +cells carry one fact between them: *nothing has run since about 16:57 +yesterday*, and that sentence appears nowhere on the page. + +The red notice above says the heartbeat check failed. That is a different fact — +it is about the check, not about the consequence. `queuedNotice` (`view.ts:398`) +already writes careful sentences distinguishing "the read failed" from "no +heartbeat recorded" from "last checked in Xh ago"; none of them says how long +the queue has actually been still, which is the number an admin needs to size +the backlog they are about to create by pressing `Sync now`. + +I am filing this as distinct from the closed dead-worker-reads-healthy item: the +health column is now honest (`OVERDUE`, amber), which is what that item fixed. +The finding here is that nine honest cells are not a summary. + +**Cost:** The admin arrives during an outage, reads three lines of prose and two +boxes, then reads nine rows to derive by eye a number the page already has. The +`crewNorms` treatment on `/account` is the shape: measure against the set, state +the shared fact once, let the rows carry deviation. + +**Fix:** When every scheduled row shares one `Last run` bucket, say it once in +the section rule that already exists — `9 jobs · nothing has run since 16:57 UTC +(19h) · checked 11:57:14 UTC` — and let the rows carry only what deviates. The +`RuleHead` aside at `page.tsx:334-341` is already the right slot, and `groups` +is already in hand there. The per-row `RelativeTime` stays for the rows that +differ. + +**Principle:** Pattern 2 (total enumeration). + +--- + +### 5. `next HH:MM` restates the cadence it sits under, on four of six rows + +**Severity:** Moderate +**Where:** `src/app/admin/sync/view.ts:288` (`nextRunFor`) + +`nextRunFor` suppresses the decoration only when `cadenceNamesTime` is true — +that is, when the cron's *hour* field is a fixed number. So `membership-recheck` +(`0 4 * * 0` → `Sun 04:00`) correctly gets no second line. But `contacts`, +`wanderer`, `discord-roles` and `access-lists` are `hourly :05`, `hourly :10`, +`hourly :15`, `hourly :25` — the cadence already names the minute — and each +carries `next 12:05`, `next 12:10`, `next 12:15`, `next 12:25` beneath it. The +only thing the second line adds is the current hour, which the reader's own +clock supplies. + +Two rows earn it: `every 30m` genuinely does not say whether the next tick is +`:00` or `:30`, and `every 15m from :02` needs arithmetic. Four of six do not. + +**Cost:** The word `next` is printed six times for two rows' worth of +information, and the second line doubles the height of the cadence cell on the +rows where it says nothing — which is what makes the strip a 640px-tall block +rather than a 400px one, and pushes the controls below the fold (finding 8). + +**Fix:** Widen the suppression: return `null` when the cron's minute field is a +fixed number *and* the hour is `*`, since `formatCadence` then prints +`hourly :NN` and the minute is already stated. The test belongs beside +`cadenceNamesTime` in `view.ts`, reading the raw expression the same way, and +the existing docblock's reasoning ("would either repeat that number or read as +'soon'") already covers this case — it just stops one branch short. + +**Principle:** Copy: "every word earns its place. No restated headings." + +--- + +### 6. Every row promises a run that is not going to happen + +**Severity:** Moderate +**Where:** `src/app/admin/sync/page.tsx:426` / `src/app/admin/sync/view.ts:288` + +`nextRunFor(g.jobType, renderedAt)` is computed from the cron table and the +clock. It has no knowledge of `worker.fresh`, which the same render already +holds (`page.tsx:163-167`). So in the shot — heartbeat unreadable, every row +overdue by 19 hours — six rows say `next 12:00`, `next 12:05`, `next 12:10`, +`next 12:15`, `next 12:02`, `next 12:25`. Nothing will fire at any of those +times. + +I flag this as adjacent to, not a re-opening of, the closed dead-worker item: +that one was about the `Health` column, which now correctly reads `OVERDUE`. +This is the cadence cell, three columns over, still making a forward-looking +promise the page has already contradicted two inches above. + +**Cost:** An admin who has just read `heartbeat check failed` scrolls down and +sees six scheduled times in the next 25 minutes. The reasonable read is "it will +catch up on its own shortly," and they close the tab. + +**Fix:** Suppress the `next` decoration when `worker.fresh` is false — thread +the flag into `nextRunFor`, or gate the JSX at `page.tsx:544`. The cadence itself +stays: `hourly :05` is a fact about configuration and remains true. `next 12:05` +is a prediction and is not. + +**Principle:** Nielsen 1, visibility of system status — the status shown must +not contradict the status shown. + +--- + +### 7. The lede states the fault, and the alarm 20px below states it again + +**Severity:** Moderate +**Where:** `src/app/admin/sync/page.tsx:244-250` against `page.tsx:288-294` + +Three lines of grey prose under the H1 end with "its heartbeat could not be +checked — see the line below," and the line below is a red box saying `worker · +heartbeat check failed — unknown whether the worker is running`. Same fact, +twice, adjacent, with a cross-reference between them that would be unnecessary +if either were deleted. + +The lede is doing two unrelated jobs at once: a standing explanation of what the +page is ("the jobs that keep tiers, roles and standings in step with the game") +and a live status report that changes on every load through a four-branch +ternary. The first belongs under the H1. The second is what the `Notice` +component exists for, and it is already there. + +The brief names this shape directly: an explanatory subtitle under an H1 is a +smell. + +**Cost:** The first thing an admin reads on an outage page is three lines of +prose whose payload is restated in an alarm they have not reached yet. Both +notices then push the strip down; the panel's first row starts at y≈500 on a +900px viewport. + +**Fix:** Cut the lede to the standing sentence and let the notice own the live +claim: *"The jobs that keep tiers, roles and standings in step with the game. +The buttons enqueue work; the worker picks it up."* Drop the four-branch ternary +at `244-250` and the "see the line below" pointer with it — the `Notice` is +already the next thing in document order, so there is nothing to point at. The +notice's own text (`workerLine`, `page.tsx:177-198`) already distinguishes the +four heartbeat states carefully and does not need a second copy upstream of it. + +**Principle:** The brief's own H1-subtitle rule; and Copy, "no intros that +repeat." + +--- + +### 8. The page's one gold control is below the fold on the viewport it was shot at + +**Severity:** Moderate +**Where:** `src/app/admin/sync/page.tsx:1108` (the composite of the wide shot) + +`btn-row--controls` renders at y≈1203 on a page that runs 1310px, shot at a +900px viewport. On first paint an admin sees the title, two notices, and the +strip — and no controls at all. `Sync now` is the only saturated element on the +page (`#f1c035`, 11.63:1 on `#0a0a0a`) and it is invisible until they scroll. + +The comment above it defends the position by PRODUCT.md principle 2, state +before action. That principle is right and is not what I am contesting: the +strip *should* be read first. But the principle is satisfied by the strip being +above the buttons, not by the buttons being 300px below the fold — and the two +notices (finding 7) and the redundant `next` lines (finding 5) are what pushed +them there. Fix either of those and the row lands near the fold on its own. + +**Cost:** An admin who came to press `Sync now` after fixing an ESI outage scrolls +a page they did not need to read to find the button they came for; an admin who +came to read state, scrolls past the end of the table and finds the controls by +accident. + +**Fix:** No new control and no sticky bar. Take the height back from findings 5 +and 7 — the redundant `next` lines are ~20px × 4 rows and the lede is ~72px — +which brings the row to roughly y≈1030. If more is wanted, the two notices could +sit side by side above 60rem rather than stacked (see finding 10). + +**Principle:** Nielsen 7, flexibility and efficiency of use. + +--- + +### 9. `Refresh` and `Recheck invalid affiliations` are the same control + +**Severity:** Minor +**Where:** `src/app/admin/sync/page.tsx:1119-1139` + +This is pattern 3's real instance here, and it is not between the gold button +and the rest — the gold does its job and the eye does go to `Sync now` first. +It is between the second and third controls, which are visually identical +(`.btn`, same 36px grade, same border, same casing) and are not remotely the +same kind of act. `Recheck invalid affiliations` writes an audit row and +enqueues a job that re-checks every invalid affiliation in the corp. `Refresh` +is an `<a href="/admin/sync">` that reloads the page and drops the query string. +One is idempotent navigation; the other is work. + +They also differ in feedback in a way the styling hides: both `Submit`s carry +`pendingLabel="Queueing…"`; the anchor has no pending state at all, so the +control that looks most like the other two is the one that gives no +acknowledgement when pressed. + +**Cost:** An admin who wants to re-read the page after a press is one target away +from queuing a corp-wide affiliation sweep, with nothing but a 27-character label +distinguishing them. + +**Fix:** Demote `Refresh` to the quiet grade the design system already has +(`.btn--quiet`, as used elsewhere for non-consequential controls), so the row +reads gold → default → quiet and the visual weight tracks the consequence. Do not +promote `Recheck` — the gold ration is one per view and it is spent. + +**Principle:** Pattern 3 (repeated identical controls at uniform weight). Also +Nielsen 5, error prevention. + +--- + +### 10. Two one-line state banners wear a prose measure + +**Severity:** Minor +**Where:** `src/app/globals.css:3440` (`.notice { max-width: var(--measure) }`) + +`--measure` is 68ch, the right cap for prose. Both notices here are single +sentences of machine state — one is a mono string. At 1440px they stop at ~545px +inside a 1200px column, and the resulting shape is a page whose entire top third +is left-aligned in the left half with 650px of void beside it. That is pattern 1 +at page scale, arriving through a component default rather than through a +decision anyone made about this page. + +**Cost:** Small on its own; it is mostly the reason findings 7 and 8 compound +into 500px of vertical before the first data row. + +**Fix:** Not a token change and not a `.notice` change — it is correct for the +prose notices elsewhere. Either let this page's two notices share a row above +60rem, or accept the measure and take the height back from finding 7 instead. +Listed last because it is the least of the three contributors to the same +problem. + +**Principle:** Pattern 1 (unshaped field). + +--- + +## What is genuinely good and should survive + +Name these before touching anything above. + +- **`overdue` is excluded from auto-open** (`view.ts:99-105`). When the worker + dies every row goes overdue at once, and opening on it would throw nine + drawers open and destroy the "this one job needs you" signal auto-open exists + to create. This is the single best judgement call on the surface, it is + correctly reasoned in place, and finding 4's fix must not disturb it. +- **`groupTone` refuses to go green on a group where nothing has succeeded** + (`view.ts:170-173`). A two-test cascade defaulting to `ok` would paint a fresh + deployment's never-run housekeeping green. It doesn't. +- **`windowRestatesGroup`** (`view.ts:221`) — pattern 2 solved a third time, at + drawer scale: the `last N runs` caption is suppressed exactly when the table's + own `N runs` cell already states it. This is the same instinct as + `Cadence (UTC)`, applied without being asked. +- **The `Cadence (UTC)` split itself** (`view.ts:252`, `page.tsx:543`). Finding 3 + is about its scope at one breakpoint, not about the fix. Do not undo it. +- **`Absent`** (`page.tsx:113`) — aria-hidden glyph paired with the words it + stands for. Untouched by everything above. +- **The `.strip__queued` ring escalating shape rather than adding a word** + (`globals.css:4117-4138`). A fixed 7.5rem track with no room for a second word, + answered by changing the mark that is already there. +- **The bounded name track is the right *idea*.** Finding 1 changes 28rem to + 13rem. It must not revert to `1fr` — the comment at `globals.css:4066-4076` + explains why that was worse, and it is correct. +- **`_everyGroupIsOrdered`** (`page.tsx:79`) — a type-level guarantee that no + `JobGroup` can be added without appearing in `GROUP_ORDER`, with an honest + comment about why proximity to `GROUP_LABEL` was not a guarantee. +- **The `checked HH:MM:SS UTC` stamp living in the section rule at the top of the + strip**, not at the bottom of the page. Finding 4's fix builds on this slot. + +## What I could not evaluate, and why + +- **Every interactive state.** The shots are static and I am read-only on + source, so hover, focus-visible, the `+`→`−` marker flip, an open drawer, a + pending `Queueing…` label, and `ConfirmNotice`'s focus move were read from CSS + and JSX only. +- **Five of the nine `RowHealth` states.** The fixture contains `overdue`, `fresh` + and (folded) two more overdue. `failing`, `degraded`, `inflight`, `stuck`, + `never` and `unknown` — and with them the auto-open behaviour, the `bad` tone, + the error-summary line and the `.strip__queued--stuck` escalation — I judged + from `view.ts` alone. The `other` bucket has no fixture at all. +- **320px reflow.** Finding 2's overflow is measured from the 390px shot plus + `.st { white-space: nowrap }`. I inferred that 320px is worse; I did not + render it. +- **The drawer's run tables.** Nothing is open in either shot, so `collapseRuns`, + the counter columns, `Raw`, the `44rem` table floor and the `40rem` + `.only-wide`/`.only-narrow` swap are source-only judgements. Finding 1 does not + touch them. +- **Whether the panel scrolls or the document does** when finding 2's string + overflows — `.strip` sets no `overflow`, but the ancestor chain at 390px I did + not trace. + +## Contested (settled taste) + +None. Nothing above requires re-opening a settled item. Finding 8 reads as a +challenge to "state before action" and is not one: the principle asks that the +strip be read before the gold button, which a control row at y≈1030 satisfies as +well as one at y≈1203. The finding is about the 500px of removable height in +front of it, not about the ordering. diff --git a/docs/design-sweep-2/reports/whole-app.md b/docs/design-sweep-2/reports/whole-app.md new file mode 100644 index 00000000..3fb68dca --- /dev/null +++ b/docs/design-sweep-2/reports/whole-app.md @@ -0,0 +1,214 @@ +# Whole-app composite review + +Input: all 30 screenshots in `docs/design-sweep-2/shots/` (15 surfaces × 1440×900 and 390×844, both fullPage) and `PRODUCT.md`. No source read. Colour values below are sampled from the rendered PNGs and are true sRGB hex. + +--- + +## 1. Does this read as authored, or as generated? + +**The words are authored. The layout is generated.** That split is the single most important thing in this sweep, and it is visible only across surfaces. + +### What is unmistakably authored + +The microcopy is written by someone who has had to explain this system to a human being more than once, and got tired of it: + +- Payout detail, on Finalize: *"Closes the pools, roster and shares to editing. Reversible with Unlock until the first payment is recorded, and permanent after that."* +- Payout detail, finalized: *"A payment has been recorded, so the loot pools, roster and shares are fixed permanently. Reverting a payment does not reopen editing: it only corrects who was paid, so revert the wrong one and pay the right person while still frozen."* +- New operation: *"One row per fight. Creating an operation pays nobody: it opens a draft you can fill in now or later."* +- Login, on a scope: *"Without it a docked character reads only as docked."* +- 404: *"Check it for a typo, or for a link that was truncated on its way here."* + +No generator writes the second one. That is a paragraph that exists because somebody unlocked the wrong operation and lost an hour. + +Two other authored moves: + +- **The error boundary has a `WHAT TO SEND` block** — `page / seen / ref` in monospace, above the buttons. That is not error-page furniture; it is somebody pre-writing the Discord message so the reporter does not have to. It anticipates the support conversation. +- **Section rules carry a readout.** `LOOT ————— 4,810,000,000.00 ISK`, `SPLIT / ROSTER ————— 6/15`, `9 JOBS ——— CHECKED 11:57:14 UTC`, `62 ENTRIES ——— as of 11:57 UTC`, `LOG ——— 34 total`. The divider label doubles as a value. That is a real instrument idiom, applied consistently across five unrelated surfaces. Somebody decided that. + +And the negative evidence is strong: across 30 screenshots there is not one rounded corner on a container, not one gradient, not one glass panel, not one glow, not one drop shadow, not one hero metric. That is discipline, and it is sustained. + +### What is unmistakably generated + +**Every one of the fifteen surfaces is the same skeleton.** 60px header, seal + wordmark hard left, nav hard right, `SIGN OUT` behind a divider; then h1, then one line of grey description, then an optional filter strip, then a table or a form. Fifteen for fifteen. The only surface that escapes is login — because login is the one page that was drawn rather than assembled. + +**Five of fifteen surfaces are an h1, a sentence, and a button in the top-left corner of a 1440×900 viewport** (`02-not-found`, `08-payout-not-found`, `13-error-boundary`, `14-access-lists`, and functionally `03-payouts-empty`). On `14-access-lists.wide` the content ends at x=700, y=270; roughly 89% of the viewport is unbroken `#0a0a0a`. Nothing was decided about those pages. They fell out of the template. + +**The narrow viewport was not designed at all.** It is the wide layout permitted to fall over, and I go into what that costs below. A designed 390px layout does not put a 6-line vertical word-stack in a name column. + +**A systemic type defect nobody has looked at.** Every word ending in `k` loses the space after it, on every surface, at both widths: "Check**it** for a typo", "That link**points** at an operation", "a link**that** was truncated", "check whether it took**effect**". That is a bad kerning pair in the body face, and it appears in the 404 and the crash page — the two moments a member is already annoyed. + +> **Cost:** A member who hits the crash page reads "check whether it tookeffect before you send it again" and now has two problems: the thing that broke, and a tool that visibly cannot set type. The voice PRODUCT.md is built on — *deadpan, precise* — is contradicted by the rendering in the exact place precision is load-bearing. It is one font fix that repairs the impression on all fifteen surfaces at once. + +### Verdict + +Nobody has *drawn* this application. Somebody has *written* it, carefully, and then let a default admin chrome carry the writing. The chrome is not offensively wrong — it is competent, it avoids the obvious traps — but it makes no argument. If you deleted the copy and the seal, nothing about the remaining pixels would tell you what this product is, who runs it, or that it has a joke at its centre. + +--- + +## 2. Composite character, in one paragraph + +A well-written flight manual laid out by a build script. The prose is dry, exact, and quietly funny in the way PRODUCT.md asks for — it states what is true, never exclaims, and explains the irreversible thing before you press it — but it is set on a uniform near-black ground in a uniform left-flush column with a uniform 60px bar on top, so every surface has the same posture regardless of whether it is a 34-row scanning table, a two-button empty state, or a login page. The result reads as *serious and unfinished* rather than *serious and instrumented*: the restraint is real (no cards, no glow, no gradients, no hero numbers) but it has not been converted into any positive quality — no rhythm, no density gradient, no sense that a scanning surface and a reading surface are different kinds of object. It is closest in feel to a well-maintained internal tool at a company with no designer: trustworthy, legible, and completely mute about itself, with a single beautiful hand-inked lander parked in the corner of one page like a photograph on a desk in an otherwise bare office. + +--- + +## 3. Which surfaces disagree about what this product is? + +**Login (`01`) disagrees with the other fourteen.** It is the only centred layout, the only framed one (a 480px panel with a hairline gold left edge), the only one on a warmer ground (`#131313` panel over `#0a0a0a` page), the only one with the seal at full size, the only one with a ~64px display h1, and the only one with artwork used as texture. It is genuinely designed. Then you sign in and land on a left-flush admin console that shares none of those properties. Two products in two clicks. + +> **Cost:** A first-time member's impression is set by login and immediately withdrawn. The corp's identity — the whole reason PRODUCT.md exists — is spent entirely on the one screen the member sees least often, and absent from the one they return to. + +**Account (`12`) disagrees with the admin surfaces about whether artwork exists.** It is the only post-login page with an illustration. On wide, the lander sits in a right rail. On narrow it is at the very bottom of a 2091px page, below the sync schedule, roughly 1,900px down — a member on a phone will essentially never see it. + +> **Cost:** Principle 5 says *earn the artwork*. On narrow the artwork is not earned, it is buried; the member gets the bare console and the corp's personality is delivered to nobody. + +**Payout detail (`06`/`07`) disagrees about control density.** Every other surface has between zero and three controls. Payout detail draft has ~30 inline `EDIT` buttons, plus `EXCLUDE`/`REMOVE` on all 16 roster rows, plus `DELETE` on both loot pools. It is the only surface where the dominant visual texture is buttons rather than data — and PRODUCT.md's principle 3 is *scanning is the primary act*. + +> **Cost:** An admin reconciling a 4.8b ISK split cannot scan the amounts column, because a bordered button sits inside every cell of the neighbouring column at the same size and weight as the numbers. The eye stops sixteen times instead of running down one column. + +**Members (`15`) disagrees with Operations (`03`/`04`) and Audit (`09`/`10`) about what a filter is.** Members uses a row of pill toggle buttons that apply instantly (`ALL / QUEUED / TESTERS / FRIENDS / VETERANS`, `ALL / CRYO / ACTIVE`). Operations and Audit use text inputs plus a select plus an explicit `FILTER` submit button. Same job, two entirely different interaction models, one nav click apart. + +> **Cost:** An admin who filters Members by clicking a pill, then moves to Audit and clicks in the ACTOR field expecting the same immediacy, types a name and waits for a result that never comes. They have to learn the tool twice — the one thing PRODUCT.md says neither role wants to do. + +**Audit (`10`) disagrees with itself across viewports.** Wide shows absolute timestamps (`2026-08-08 17:31:04`); narrow shows relative ones (`42h ago`). Same table, same data, different facts. + +> **Cost:** An admin answering "why is this person's role wrong?" needs the exact time to correlate with Discord and in-game logs. On a phone they cannot get it, and cannot tell that the wide view would have given it to them. + +**Access lists (`14`) disagrees about whether it is a page.** An h1, two sentences, two buttons, and then nothing — at both widths. It reads like a dialog that got routed. + +--- + +## 4. Could someone guess the palette and theme from "dark tool for a space game"? + +**Yes. Plainly, and with one detail that makes it worse than a guess.** + +The page ground is **`#0a0a0a`** — sampled, exact, and dominant: 70–88% of every non-login screenshot's pixels. PRODUCT.md's third anti-reference names *"rounded card grid on `#0a0a0a`"*. The document names a hex value as the thing to avoid, and that hex value is the application's ground colour on all fourteen post-login surfaces. + +The rest of the palette is what anyone would predict: + +| Role | Sampled | Predictable? | +|---|---|---| +| Page ground | `#0a0a0a` | The named anti-reference, verbatim | +| Raised surface | `#151514`, `#171616`, `#21201f` | Yes — three near-black greys | +| Rules | `#373533` | Yes | +| Muted / label text | `#787370` | Yes | +| Body text | `#ece7de` | Slightly warm — a small point of character | +| Accent / primary | `#f1c035` | Gold on near-black: the EVE-tool default | +| Tier blue | `#44abdc` | Yes | +| Alarm | `#f05751`, `#ca2a30` | Yes | +| Warning amber | `#f0965a`, `#d7915f` | Yes | + +Gold-on-black for an EVE tool is the same reflex as cyan-on-black; it dodges anti-reference #2 by choosing the *other* obvious hue, not by choosing. + +**Be fair about what it does escape.** It is not generic dark SaaS: no rounded cards, no violet-to-blue gradient, no glassmorphism, no hero metric. It avoids the *shapes* of anti-reference #3 completely while adopting its exact *ground colour*. But that lands it on anti-reference **#1** instead — "dense grey admin chrome, Django admin with a hat on." Look at `15-admin-accounts.wide` with the copy blurred: a bordered table of small-caps status chips and paired bordered action buttons per row, above a strip of toggle pills, under a thin fixed bar. That is Django admin in dark mode. The only missing ingredient is Bootstrap blue, and `#44abdc` is nearly supplying it. + +The one thing not guessable from the category is the seal and the lander. They are doing 100% of the identity work, on two of fifteen surfaces. + +> **Cost:** The corp is replacing Alliance Auth. A member who used the old tool and opens this one sees the same dark table chrome in a different accent hue and concludes the change was cosmetic. The project's stated success condition — *"replace an Alliance Auth install with something the corp actually uses"* — is being argued entirely by the copy, against the palette. + +**Also measured, and it undercuts a stated guarantee:** `#787370` on `#0a0a0a` is **4.23:1**; on the table-header ground `#151514` it is **3.90:1**. That colour is the field labels (`NAME`, `STATUS`, `ACTOR`), the section rule labels (`FILTER`, `LOG`, `SCOPES REQUESTED`), and every table column header, at ~11px uppercase. PRODUCT.md claims WCAG 2.2 AA — 4.5:1 for all text. These are small text and they miss. + +> **Cost:** An admin scanning at night with a monitor dimmed for a dark room cannot resolve which column is which without leaning in, on the surface they use most; and the accessibility promise in the product document is not true as rendered. + +--- + +## 5. Where does the eye go, and should it? + +| Surface | Eye lands on | Should it? | +|---|---|---| +| `01` login | The seal, then "Test Corp", then the EVE button | **Yes.** The best-resolved screen in the app. | +| `02` not-found | The gold-outlined h1 box, then a large gold button | Roughly. But the h1 is wrapped in a full-width gold-bordered box that reads as an alert container for a message that is not an alarm. | +| `03` payouts empty | `NEW OPERATION` (gold, top-right) | **Yes** — the only thing to do. | +| `04` payouts full | `NEW OPERATION` again, then the amount column | **No.** 34 rows exist; the brightest object is still the create button. The eye should go to the four `DRAFT` rows — the only actionable state — and they are the *dimmest* rows (hollow `○` marker, `#787370` text) while the 26 finished rows are brighter. Attention is inverted. | +| `05` new operation | The `Name` field | Yes. | +| `06` payout detail draft | The gold `FINALIZE` button — then it is dragged to the orange unresolved-pricing banner | **Nearly right, wrong order.** The banner says the total is short by two unpriced items; `FINALIZE` sits *above* it and is the loudest thing on a 2,900px page. | +| `07` finalized | Title, then the wall of `MARK PAID` buttons | Yes — that is the job. | +| `08` payout not-found | h1 box, button | Fine. | +| `09` audit empty | `FILTER` button — the only bordered control | Weak, but nothing else exists. | +| `10` audit full | The monospace action column (`status.changed`, `tier.changed`) | **Yes.** Best-tuned table in the app: three type registers (mono timestamps, linked names, mono actions) give the eye a real rail. | +| `11` sync | The red heartbeat banner | **Yes** — then it fails. Seven `▲ OVERDUE` markers are all identical weight, so the one `● OK` row is what actually stands out, which is backwards. | +| `12` account | `▲ 6 characters need attention` in amber beside the h1 | **Yes.** Then the eye has nowhere to go: all six rows are identical, so "which one" is unanswerable without reading all six. | +| `13` error boundary | h1 box, then the red reference banner | Yes. | +| `14` access lists | `GRANT ACCESS` | Yes. | +| `15` members | The red `0/1 HEALTHY` token cluster | **Yes, and it is the best hierarchy decision in the app** — the one genuinely wrong value is the only red on the row. This is what principle 3 asks for, and it exists in exactly one place. | + +The pattern: **where a column has variance, the design handles it well (`10`, `15`, `12` header). Where a column is constant, the design still gives it full weight, and the constant column then out-shouts the varying one.** + +--- + +## 6. Which screens run long and empty rather than wide and full? + +**The two payout-detail surfaces are the emblem.** `06-payout-detail-draft.wide` is 2,902px tall in a 1440px-wide viewport, and its content is confined to x=83–955 — an 872px column that leaves **485px of the viewport permanently unused**, down all 3.2 screens. `07` is the same: 2,769px tall, content x=86–955. The roster table has five columns and the loot table four; both would fit twice over in the space being refused. + +> **Cost:** An admin reconciling a payout scrolls three full screens to compare the loot total at the top against the roster amounts at the bottom, and cannot hold both in view — on a monitor with a third of its width empty the entire time. Every reconciliation is a scroll-and-remember exercise that a two-column layout would make a glance. + +Also long and empty: + +- `04-payouts-full.narrow` — **5,699px**, 6.7 folds, for 34 rows. +- `06`/`07` narrow — **4,027px** and **3,944px**. +- `12-account.narrow` — 2,091px, with the artwork at the very bottom. +- `02`, `08`, `13`, `14` wide — all four are ~90% empty ground with content pinned to the top-left corner. + +**And the exact inverse, which is worse:** `15-admin-accounts.wide` is only **1,022px tall** and shows **4 of 13 members**, the fourth clipped mid-row — the table body has its own capped scroll region. This is the primary admin scanning surface, on the widest viewport, and it is the one page that *should* be long. + +> **Cost:** An admin looking for the one member whose tier is wrong must scroll inside a ~420px window to see 13 rows, three or four at a time, losing the column headers' spatial anchor on every scroll. Principle 3 says optimise for the eye moving down a column; this is the one surface where the eye cannot move down the column at all. + +--- + +## 7. Which screens repeat the same value down every row? + +Ranked by wasted column-width: + +**`15-admin-accounts` — 6 of 10 columns constant.** Across the visible rows: `TIER CHANGED` = `—` (all), `LAST LOGIN` = `—` (all), `ADMIN` = `MEMBER` (all), `DISCORD` = `NONE` (all), `TOKENS` = the identical three-line `0/1 HEALTHY / main dead / 1 dead` (all), `ACTIONS` = `GRANT` + `SYNC NOW` (all). Only `NAME`, `TIER`, `CRYO`, and `MAP` carry information. + +> **Cost:** The admin's most-used table spends more than half its width on cells that never differ, which is exactly why only 4 rows fit — the constant columns are pushing the varying ones out of the visible region and the row count down. + +**`04-payouts-full` — 68 identical cells.** `PAID` reads `0/5 PAID` on all 34 rows; `YOURS` reads `UNPAID` on all 34. Together ~270px of a ~1,200px table. + +> **Cost:** A member opening Operations to find their own unpaid share sees `UNPAID` 34 times and learns nothing; the column that exists to answer their one question answers it identically for every row. + +**`06-payout-detail-draft` — 15 of 16 rows identical in three columns at once.** `SHARES` = `1.00` + `EDIT`, `AMOUNT` = `288,600,000.00 ISK`, `STATE` = `UNPAID`, plus `EXCLUDE` and `REMOVE` on every row. Only two rows differ (`Hurricane Main` 2.00, `Kikimora Kid` excluded) — and they are the hardest two to find. + +> **Cost:** An admin verifying a split needs to spot the non-default share. The two rows that differ are rendered exactly like the fourteen that do not. + +**`11-admin-sync` — `LAST RUN` = `19h ago` on all 8 rows; `HEALTH` = `▲ OVERDUE` on 7 of 8.** An entire column of one value, next to a column of one value with a single exception. + +**`12-account` crew manifest — all 6 rows byte-identical:** `NOT REPORTED` / `RE-AUTHORIZE` / `NOT YET RUN` / `OFF`. + +> **Cost:** The header says *6 characters need attention*. The manifest is six identical rows, so "which one" has no answer and the member must re-authorise all six or guess. This is the single most common member session PRODUCT.md describes — a stale token, minutes before a fleet — and the page cannot point at it. + +**`10-audit` — `DETAILS` = `+ roleId=1284410981234567890, characterId=90000006` on 5 of 13 rows**, each truncated with an ellipsis at the same character. Meanwhile the table leaves ~120px of viewport unused to its right. + +--- + +## Composite finding: narrow is not a viewport, it is a failure mode + +Six surfaces put their **most important column off-screen** at 390px, behind horizontal scroll inside the table region: + +| Surface | Hidden at 390px | +|---|---| +| `04` payouts | `TOTAL`, `PAID`, `YOURS` — and the `NAME` column collapses to a 6-line vertical letter-stack ("Tama gatecamp 01" becomes six stacked fragments) | +| `06` payout detail | `STATE` + `EXCLUDE`/`REMOVE` | +| `07` payout detail finalized | `STATE` and **every `MARK PAID` button** | +| `10` audit | `TARGET` and `DETAILS` | +| `12` account | Token status and **every `RE-AUTHORIZE` link** | +| `15` members | Everything from `TIER CHANGED` rightward — 7 of 10 columns | + +> **Cost:** PRODUCT.md's member is *"alt-tabbed out of a game, often late at night, often minutes before a fleet forms"* — which is to say, frequently on a phone. On `12-account.narrow`, the one control that fixes the one thing they came for is horizontally off-screen. On `07.narrow`, an FC paying out a fleet from their phone cannot see who has been paid or press `MARK PAID`. These are not degraded layouts; on those two screens the page's entire purpose is unreachable, and nothing on screen indicates that content exists to the right. + +`04-payouts-full.narrow` is the worst single frame in the sweep: the name column is roughly 40px wide, so operation names render as vertical stacks of one or two characters per line, and the table *still* scrolls horizontally. It is 5,699px tall. + +--- + +## Note, not a finding + +The Next.js dev-overlay badge (black `N` circle) appears in all 30 captures, and it reads **`1 Issue`** on `08-payout-not-found` and `11-admin-sync`, and **`2 Issues`** on `13-error-boundary`. Those are capture artefacts rather than design, but the counts indicate real console/hydration errors on three surfaces and are worth someone's attention outside this sweep. + +--- + +## What I would fix first, by cost + +1. **Narrow-viewport column hiding on `12-account` and `07-payout-detail`** — the only two places where a user's actual task becomes impossible rather than awkward. +2. **The constant-column problem on `15-members` and `12-account`** — collapse or suppress columns with no variance; it directly buys back row count on the primary scanning surface and makes "which one needs attention" answerable. +3. **`#0a0a0a`** — the ground colour is the anti-reference by name. Nothing else in this report changes the product's character as cheaply. +4. **The `k`-plus-space kerning defect** — one font fix, repairs the voice on all fifteen surfaces. +5. **`#787370` at 4.23:1** — small, and it makes a stated guarantee true. diff --git a/docs/design-sweep-2/skill-update.patch b/docs/design-sweep-2/skill-update.patch new file mode 100644 index 00000000..f66a7745 --- /dev/null +++ b/docs/design-sweep-2/skill-update.patch @@ -0,0 +1,27 @@ +--- .claude/skills/design-sweep/SKILL.md ++++ .claude/skills/design-sweep/SKILL.md +@@ -40,6 +40,22 @@ + config, not from memory or from the user's description. Pure redirects and API + routes have no rendered output — list them, then exclude them with a one-line + reason so the user can object if you guessed wrong. + ++**A surface list in the request is a starting point, not a boundary.** When the ++user names the surfaces — "review /login, /payouts, /admin/audit and the ++boundaries" — the enumeration above still runs, and anything it finds that the ++request omitted goes into the sweep. Say what you added and why in one line; the ++user can drop it. What must not happen is the reverse: banner-ing the extra ++surface out-of-scope and reviewing it at half attention, which produces findings ++nobody acts on and a backlog that is quietly wrong about its own coverage. ++ ++The omission is almost never a decision. A hand-written list is written from ++memory, and memory is exactly what this phase has just told you not to enumerate ++from — so treating it as authoritative reintroduces the failure the mechanical ++enumeration exists to prevent, one step later and with the user's signature on ++it. This project lost `/admin/access-lists` that way: deployed, in the router, ++simply not in the sentence. Eight findings sat behind an out-of-scope banner ++until the user said it was in scope. **`/admin/access-lists` is a real surface — ++include it.** ++ + Routing entry points are not the whole list. Error boundaries, not-found pages, + loading states, and empty states all render to users, and a search shaped around + the framework's page file quietly excludes them. They're worth more scrutiny than diff --git a/e2e/access-lists.spec.ts b/e2e/access-lists.spec.ts index 82087600..1dd593a8 100644 --- a/e2e/access-lists.spec.ts +++ b/e2e/access-lists.spec.ts @@ -21,6 +21,7 @@ import { accessListWatch, auditLog, character, + esiEntityName, outbox, } from "../src/db/schema"; import { ACCESS_LISTS_SCOPE } from "../src/lib/esi/client"; @@ -128,6 +129,11 @@ test("state 1: no holder and no scope asks for the grant, and shows no table", a // Nothing to be stale about, so no watched-list section at all — an empty // table here would read as "no drift". await expect(page.getByRole("heading", { name: "Watched lists" })).toHaveCount(0); + // And no "Check now" either. This is the state a fresh deployment opens on, + // and the job returns at its first branch with no holder to read as — so the + // button could only ever have answered with a timestamped confirmation that + // nothing observable had been arranged. + await expect(page.getByRole("button", { name: "Check now" })).toHaveCount(0); }); test("state 2: a granted character with no holder gets the designate button", async ({ @@ -170,6 +176,11 @@ test("state 3: a holder whose scope was dropped is offered the GRANTING link, no // The last successful observation still renders beneath the problem. await expect(page.getByRole("heading", { name: "Watched lists" })).toBeVisible(); await expect(page.locator(".acl-list__row")).toContainText("Fleet staging"); + // The table stays and the button does not, and the split is deliberate: a + // stale answer is worth showing, but a check cannot produce a new one — the + // job returns as soon as it reads the persisted scopes. The re-granting link + // above is the only action here that changes anything. + await expect(page.getByRole("button", { name: "Check now" })).toHaveCount(0); }); test("states 4 and 5: a stale authorization and a dead token are different sentences", async ({ @@ -398,6 +409,125 @@ test("Check now enqueues a read and audits nothing", async ({ page, context }) = expect(audits).toHaveLength(0); }); +/** + * The skip link has to move the caret, not just the viewport. A fragment link + * focuses its target only if the platform already considers that target + * focusable, and `<main>` is not — so without `tabIndex={-1}` the page scrolls + * to the content and focus stays back in the header, and the next Tab walks the + * admin through the nav they just asked to skip (SC 2.4.1). + * + * Asserted as focus, never as `toHaveAttribute("tabindex", "-1")`: the + * attribute is the mechanism and the caret is the requirement, and a `<main>` + * made focusable some other way would fail an attribute check while doing + * exactly the right thing. + */ +test("the skip link moves focus into the page, not just the scroll position", async ({ + page, + context, +}) => { + const { characterId } = await asAdmin(context); + await seedHolder(characterId); + await seedCatalog(characterId); + await page.goto("/admin/access-lists"); + + await page.keyboard.press("Tab"); + const skip = page.locator("a:focus"); + await expect(skip).toHaveAttribute("href", "#main"); + await skip.press("Enter"); + + const landed = await page.evaluate(() => document.activeElement?.id); + expect(landed, "focus stayed behind the skip link").toBe("main"); +}); + +/** + * The row toggle's accessible name must hold still. Its name is computed from + * its contents, and one of those is `RelativeTime` — a client component on a + * shared 30s ticker — so the control used to rename itself twice a minute with + * nothing about the row having changed: SC 4.1.2 for a screen reader that + * re-announces a control it sees renamed, and SC 3.2.4 for a voice user whose + * remembered phrase stops matching the page. + * + * The two reads are compared against each other rather than against a literal. + * A literal would pin today's wording and start passing for the wrong reason + * the day the summary gains a field. + */ +test("a watched row's toggle does not rename itself as its timestamp ages", async ({ + page, + context, +}) => { + const { characterId } = await asAdmin(context); + await seedHolder(characterId); + await seedCatalog(characterId); + // Entries the alliance roster does not contain, so the row has findings and + // therefore renders a disclosure at all — a clean row is a plain `<li>` with + // no toggle to name. + await seedWatched(characterId, { + entries: [ + { kind: "character", entityId: 9001 }, + { kind: "character", entityId: 9002 }, + ], + }); + + // Before `goto`: the clock has to be in place while the page's own scripts + // load or the ticker captures the real timers on the way past. + await page.clock.install(); + await page.goto("/admin/access-lists"); + + const summary = page.locator(".acl-list__disc > summary"); + const nameOf = () => summary.evaluate((el) => el.getAttribute("aria-label") ?? ""); + const before = await nameOf(); + // Non-empty, or the two reads below would agree vacuously. + expect(before).not.toBe(""); + // And it still leads with what a voice user would say, so 2.5.3 holds. + expect(before).toContain("Fleet staging"); + + // The visible "ago" moving is what proves the tick landed. Without this the + // test would pass on a page where nothing ticked at all, which is the + // failure it exists to rule out. + const stamp = summary.locator(".ago"); + const stampBefore = await stamp.innerText(); + await page.clock.fastForward("05:00"); + await expect(stamp).not.toHaveText(stampBefore); + + expect(await nameOf(), "the toggle renamed itself as the clock moved").toBe(before); +}); + +/** + * The two halves of one decision, asserted as a pair on purpose. Either half + * alone passes on a page that hard-codes the other: a page permanently narrow + * satisfies the first, a page permanently wide satisfies the second, and only + * the pair says the measure actually follows the content. + */ +test("a page with no table is narrow and says nothing about watched lists", async ({ + page, + context, +}) => { + // A holder whose token went stale, with nothing discovered and nothing + // watched. `showsObservations` is true here — there is a holder — but there + // is no observation to show, so the region would be a heading over a notice + // saying the heading has nothing under it. + const { characterId } = await asAdmin(context, { tokenStatus: "needs_reauth" }); + await seedHolder(characterId); + await page.goto("/admin/access-lists"); + + await expect(page.locator(".page__lede")).toContainText("gone stale"); + await expect(page.getByRole("heading", { name: "Watched lists" })).toHaveCount(0); + await expect(page.getByText("No lists are being watched yet.")).toHaveCount(0); + // One sentence and one link do not want a 78rem column. + await expect(page.locator("main#main")).toHaveClass(/page--narrow/); +}); + +test("a page with a table is wide", async ({ page, context }) => { + const { characterId } = await asAdmin(context); + await seedHolder(characterId); + await seedCatalog(characterId); + await seedWatched(characterId); + await page.goto("/admin/access-lists"); + + await expect(page.getByRole("heading", { name: "Watched lists" })).toBeVisible(); + await expect(page.locator("main#main")).toHaveClass(/page--wide/); +}); + /** * The never-read row is the ONLY row whose name cannot come from a snapshot, * because it has no snapshot — `seedWatched` always writes one, so this test @@ -425,6 +555,7 @@ test("a list added but never read is still named, not reduced to its id", async // separate from the positive one: a row can contain both, and "the name is // present" is not the same claim as "the id is not standing in for it". await expect(row).not.toContainText(`#${LIST_ID}`); + await expect(row).toContainText("not read yet"); // The confirmation names it too, which takes `removeWatch`'s return value // rather than the id the action already had in hand: the row said "Fleet @@ -436,6 +567,72 @@ test("a list added but never read is still named, not reduced to its id", async ); }); +/** Two members of one corporation, neither on the list. */ +async function seedMissingPair(corporationIds: [number, number]) { + const a = await seedMember(db, { name: "Rane Solette", tier: "member" }); + const b = await seedMember(db, { name: "Ivo Tarn", tier: "member" }); + await db + .update(character) + .set({ corporationId: corporationIds[0] }) + .where(eq(character.accountId, a.id)); + await db + .update(character) + .set({ corporationId: corporationIds[1] }) + .where(eq(character.accountId, b.id)); +} + +test("one corporation behind every missing member is stated once, not per row", async ({ + page, + context, +}) => { + const CORP = 98_000_777; + const { characterId } = await asAdmin(context); + await seedHolder(characterId); + await seedCatalog(characterId); + await seedMissingPair([CORP, CORP]); + await db + .insert(esiEntityName) + .values({ id: CORP, kind: "corporation", name: "Static Vector" }); + // Only the admin is on the list, so the pair above is exactly `missingAccess`. + await seedWatched(characterId, { + entries: [{ kind: "character", entityId: characterId }], + }); + await page.goto("/admin/access-lists"); + + await page.locator(".acl-list__row summary").click(); + const detail = page.locator(".acl-detail"); + await expect(detail.locator(".acl-detail__norm")).toHaveText( + "All of them are in Static Vector.", + ); + // Said once. Printed per row it appeared twice and told the two rows apart + // not at all, which is the whole finding. + await expect(detail.getByText("Static Vector")).toHaveCount(1); + // And the column that carried it is gone with it. + await expect(detail.getByRole("columnheader", { name: "Corporation" })).toHaveCount(0); + // The names are what the admin retypes in-game, so they survive intact. + await expect(detail).toContainText("Rane Solette"); + await expect(detail).toContainText("Ivo Tarn"); +}); + +test("missing members from different corporations keep the column that tells them apart", async ({ + page, + context, +}) => { + const { characterId } = await asAdmin(context); + await seedHolder(characterId); + await seedCatalog(characterId); + await seedMissingPair([98_000_777, 98_000_888]); + await seedWatched(characterId, { + entries: [{ kind: "character", entityId: characterId }], + }); + await page.goto("/admin/access-lists"); + + await page.locator(".acl-list__row summary").click(); + const detail = page.locator(".acl-detail"); + await expect(detail.getByRole("columnheader", { name: "Corporation" })).toBeVisible(); + await expect(detail.locator(".acl-detail__norm")).toHaveCount(0); +}); + /** * The dropdown's default is a real option rather than an absent one, and the * select is `required`. Without the placeholder, `defaultValue=""` matched @@ -481,6 +678,32 @@ test("the add control cannot submit a list the admin never chose", async ({ expect(await db.select().from(accessListWatch)).toHaveLength(0); }); +test("the only control in a watched row does not read as another caption", async ({ + page, + context, +}) => { + const { characterId } = await asAdmin(context); + await seedHolder(characterId); + await seedCatalog(characterId); + // A clean row, so the button sits directly in the `<li>` rather than inside + // a closed drawer — the shape the new rule is scoped by descendant selector + // to cover in both cases, and the one that is visible without a press. + await seedWatched(characterId, { + entries: [{ kind: "character", entityId: characterId }], + }); + await page.goto("/admin/access-lists"); + + // `.btn--quiet` sets `border-color: transparent`, which computes to + // `rgba(0, 0, 0, 0)` — the value this asserts against rather than any + // particular colour, so it cannot be satisfied by an outline of the wrong + // grade and does not pin a token. Without the outline the row's one + // pressable thing sits at the weight of the labels around it. + const outline = await page + .getByRole("button", { name: "Stop watching" }) + .evaluate((el) => getComputedStyle(el).borderTopColor); + expect(outline).not.toBe("rgba(0, 0, 0, 0)"); +}); + /** * The two stale-page presses. Neither is reachable by clicking alone — the * `<select>` never offers a watched list, and a removed row takes its button diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts index 547d0fcc..c88c4900 100644 --- a/e2e/account.spec.ts +++ b/e2e/account.spec.ts @@ -1128,6 +1128,78 @@ test("a stalled chip's accessible name also carries the standings fact", async ( expect(label).toMatch(/map/i); }); +// The register block in globals.css is the one typographic rule DESIGN.md +// states in absolute terms: a label that is mono, `--t-label` and uppercase +// belongs to it, and every member renders at 600. `.status-line__label` was +// register-shaped and not a member — the only one of the set that never +// declared a weight, so it fell to 400. +// +// It shows up on this page because both channels render the same two words. +// The rail's `.facts dt` says STANDINGS and MAP; the manifest's status stack +// says STANDINGS and MAP; same size, same colour, same family, ~430px apart +// and two different weights. A reader has no way to know the difference is +// meaningless, so the page invents a hierarchy it does not have. +// +// Asserted as parity between the two rather than against the literal "600", +// because parity is the requirement and the constant is just today's value of +// it. If the register moves, this should follow it, not fail. +// +// Both are read from the live page rather than from the stylesheet, so this +// also covers the way the bug arrived: the properties were all present and +// individually correct, and the weight was simply never declared, which no +// source-level check of the rule's contents would notice. +test("the manifest's status labels carry the same register weight as the rail's", async ({ + page, + context, +}) => { + const acc = await seedNominalCrew(); + // Mounts the exception-only STATUS column; without it the manifest has no + // `.status-line__label` in the DOM and this test passes on an empty locator. + await faultContacts(acc.id, ["Alt Pilot Two"], "needs_reauth"); + await context.addCookies([await sessionCookieFor(db, acc.id)]); + await page.goto("/account"); + + const weights = await page.evaluate(() => { + const read = (el: Element | null | undefined) => { + if (!el) return null; + const cs = getComputedStyle(el); + return { + weight: cs.fontWeight, + size: cs.fontSize, + family: cs.fontFamily, + transform: cs.textTransform, + text: el.textContent?.trim().toLowerCase(), + }; + }; + const byText = (sel: string, want: string) => + [...document.querySelectorAll(sel)].find( + (el) => el.textContent?.trim().toLowerCase() === want, + ); + // The colliding pair by name, not by position: both channels render the + // word "standings", and that is the comparison the finding is about. + return { + rail: read(byText(".facts dt", "standings")), + manifest: read( + byText("[aria-label='Your characters'] .status-line__label", "standings"), + ), + }; + }); + + // Anti-vacuity: a null on either side would make every comparison below + // compare undefined to undefined and pass. + expect(weights.rail, "no STANDINGS in the rail").not.toBeNull(); + expect(weights.manifest, "no STANDINGS label in the manifest").not.toBeNull(); + expect(weights.rail!.text).toBe("standings"); + expect(weights.manifest!.text).toBe("standings"); + + expect(weights.manifest!.weight).toBe(weights.rail!.weight); + // The rest of the register, so a future edit cannot restore parity by + // dropping the rail to 400 and calling the collision closed. + expect(weights.manifest!.size).toBe(weights.rail!.size); + expect(weights.manifest!.family).toBe(weights.rail!.family); + expect(weights.manifest!.transform).toBe(weights.rail!.transform); +}); + // Scoped to the Scroller's own region, as location.spec.ts's `manifest` // helper does: the payouts table on this same page (page.tsx renders it after // the manifest, once seeded) shares `table tbody tr`, so an unscoped selector @@ -3219,3 +3291,36 @@ test("a character who has never reported a location says so, one line", async ({ const heights = await rowHeights(page, `${MANIFEST} tbody tr:not(.drawer-row)`); expect(heights[1]).toBeLessThanOrEqual(65); }); + +/** + * The closing artwork is the heaviest asset on the page, and `next/image` + * decides what to fetch from `sizes` alone — a missing one means "assume this + * spans the viewport", which on the widest layouts hands the browser a + * candidate several times the width the frame actually draws at. + * + * Asserted as the width the browser *chose* out of the srcset rather than as + * the `sizes` attribute itself, because the attribute is the input and the + * fetch is the cost. Measured at a wide viewport on purpose: that is where the + * two behaviours separate. A `100vw` assumption at 1440 selects a candidate at + * least 1440 wide; the frame draws at 420 CSS px, so anything at or under 640 + * is a candidate chosen for the frame and not for the window. + */ +test("the closing artwork is fetched for its frame, not for the viewport", async ({ + page, + context, +}) => { + const acc = await seedNominalCrew(); + await context.addCookies([await sessionCookieFor(db, acc.id)]); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/account"); + + const art = page.locator(".closing img"); + await expect(art).toBeVisible(); + const chosen = await art.evaluate((el) => { + const src = (el as HTMLImageElement).currentSrc; + const w = new URL(src, location.href).searchParams.get("w"); + return w === null ? null : Number(w); + }); + expect(chosen).not.toBeNull(); + expect(chosen).toBeLessThanOrEqual(640); +}); diff --git a/e2e/audit.spec.ts b/e2e/audit.spec.ts index 90e5f23b..040986ff 100644 --- a/e2e/audit.spec.ts +++ b/e2e/audit.spec.ts @@ -622,6 +622,66 @@ test("a paged view offers a way back to the newest entries", async ({ await expect(page.locator("tbody tr")).toHaveCount(AUDIT_PAGE_SIZE); }); +/** + * Every control on this page that changes the result set is a document load — + * the filter is a `<form method="get">` and both pagers are plain `<a href>` — + * so a screen reader's entire response to "Filter" or "Older entries" is to + * announce the new document by its title. With a constant "Audit log" that + * announcement was byte-identical whether the press had done something or + * nothing, and the only text distinguishing page one from page seven was an + * `<h2>` the admin then had to go and find. + * + * Note what cannot fix this: `aria-live`. A live region announces *mutations* + * to a region that was already present, and a region arriving with its + * document is not a mutation — so the obvious remedy would test green under + * any assertion that checks the attribute exists, and announce nothing. + * + * Deliberately coarse. The exact filter values belong to the `<h2>` and the + * chips; a title reciting them is read out in full, ahead of the thing the + * admin actually asked for, on every single load. + */ +test("the page title says which slice of the log this is", async ({ page, context }) => { + const admin = await seedMember(db, { name: "Boss", tier: "member", isAdmin: true }); + await db.insert(auditLog).values( + Array.from({ length: AUDIT_PAGE_SIZE + 5 }, () => ({ + actor: admin.id, + action: "tier.changed", + target: admin.id, + details: { to: "alumni" }, + })), + ); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + await page.goto("/admin/audit"); + await expect(page).toHaveTitle("Audit log · Test Corp"); + + // Paging: the announcement now differs from the one before it. + await page.getByRole("link", { name: "Older entries" }).first().click(); + await expect(page).toHaveTitle("Audit log — older · Test Corp"); + + // Filtering, from the newest page, is its own distinct announcement. + await page.goto("/admin/audit?actor=Boss"); + await expect(page).toHaveTitle("Audit log — filtered · Test Corp"); + + // Both at once, and in that order. + await page.getByRole("link", { name: "Older entries" }).first().click(); + await expect(page).toHaveTitle("Audit log — filtered, older · Test Corp"); + + // A cursor the page itself discards must not make the title claim a page the + // admin is not on — the same rule the `<h2>` follows for `?before=abc`. + await page.goto("/admin/audit?before=abc"); + await expect(page).toHaveTitle("Audit log · Test Corp"); + + // A repeated param resolves last-wins in the body, so the title has to read + // the same end of the array. Written both ways round because only one of + // them fails when the title picks the first value: with the junk cursor + // LAST, both readings agree that nothing is paged. + await page.goto("/admin/audit?before=abc&before=99999999"); + await expect(page).toHaveTitle("Audit log — older · Test Corp"); + await page.goto("/admin/audit?before=99999999&before=abc"); + await expect(page).toHaveTitle("Audit log · Test Corp"); +}); + test("a filtered paged view keeps its filter on the way back", async ({ page, context, @@ -900,8 +960,16 @@ test("the action is a filter link like actor and target", async ({ page, context * tier change, derole and token event puts them in `target` with `system` or an * admin acting. Filtering the wrong column returns "no results", which is true * and says nothing about the log actually being silent on that person. + * + * The nudge sits above the results rather than inside the empty state, and the + * second half of this test is why: an actor filter that returns the member's + * own self-service entries is the dangerous outcome, because it reads as a + * complete history and is not one. An empty result at least announces itself. */ -test("an empty actor filter points at the target column", async ({ page, context }) => { +test("an actor filter points at the target column, full or empty", async ({ + page, + context, +}) => { const admin = await seedMember(db, { name: "Boss", tier: "member", isAdmin: true }); const member = await seedMember(db, { name: "Zed", tier: "alumni" }); await db.insert(auditLog).values([ @@ -919,17 +987,49 @@ test("an empty actor filter points at the target column", async ({ page, context await expect(page.getByLabel("Actor", { exact: true })).toBeVisible(); await expect(page.locator("#filter-actor-hint")).toHaveText("who did it"); await expect(page.locator("#filter-target-hint")).toHaveText("who it happened to"); + // Nothing to re-point when no filter is set. + await expect(page.getByText("target of an entry, not the actor")).toHaveCount(0); await page.goto("/admin/audit?actor=Zed"); - const empty = page.locator(".log__empty"); - await expect(empty).toContainText("Nothing matches this filter."); - await expect(empty).toContainText("target of an entry, not the actor"); + const nudge = page.locator("p.page__lede", { + hasText: "target of an entry, not the actor", + }); + await expect(nudge).toBeVisible(); + await expect(page.locator(".log__empty")).toContainText("Nothing matches this filter."); - const retry = empty.getByRole("link"); + // Now give Zed something they did themselves, so the actor filter returns a + // row. The filter now looks answered, which is exactly when the sentence has + // to still be on screen. + await db.insert(auditLog).values([ + { + actor: member.id, + action: "character.linked", + target: member.id, + details: {}, + }, + ]); + await page.goto("/admin/audit?actor=Zed"); + // Which row, not how many: a count alone passes on any single row, and the + // point of this half is specifically that the actor filter returns Zed's own + // self-service entry and NOT the tier change done to them. + const rows = page.locator("tbody tr"); + await expect(rows).toHaveCount(1); + await expect(rows).toContainText("character.linked"); + await expect(rows).not.toContainText("tier.changed"); + await expect(page.locator(".log__empty")).toHaveCount(0); + await expect(nudge).toBeVisible(); + + const retry = nudge.getByRole("link"); await expect(retry).toHaveAttribute("href", "/admin/audit?target=Zed"); await retry.click(); - await expect(page.locator("tbody tr")).toHaveCount(1); - await expect(page.locator(".log__empty")).toHaveCount(0); + // The target column returns both: the one Zed did to themselves and the one + // the system did to them. The second is the entry the actor filter hid, and + // recovering it is the whole reason the nudge exists. + await expect(rows).toHaveCount(2); + await expect(rows.first()).toContainText("character.linked"); + await expect(rows.last()).toContainText("tier.changed"); + // Both columns crossed: there is nothing left to suggest. + await expect(page.getByText("target of an entry, not the actor")).toHaveCount(0); }); test("linking the system actor does not un-dim it", async ({ page, context }) => { @@ -969,6 +1069,13 @@ test("linking the system actor does not un-dim it", async ({ page, context }) => /* --- Pinned edges --------------------------------------------------------- */ +/** + * The wide cell's own text: a bare clock when the page's rows all fall on one + * calendar day and the date has been hoisted above the table, or the full + * stamp when they span more than one and every row has to carry its own. + */ +const INSTANT = /^(\d{4}-\d{2}-\d{2} )?\d{2}:\d{2}:\d{2}$/; + /** Enough entries that the table overflows the capped scroll region. */ async function seedDenseLog() { const admin = await seedMember(db, { name: "Boss", tier: "member", isAdmin: true }); @@ -1005,10 +1112,17 @@ for (const width of [320, 390]) { // Two renderings of the instant, one shown per width. The exact stamp is // 19ch of a 286px region and the pinned column is where it lands, so below // 40rem it reads as elapsed time instead. + // + // `INSTANT` and not the full stamp: when every row on the page falls on one + // calendar day the date is hoisted out of the column and stated once above + // the table, so the wide cell is a bare clock. `seedDenseLog` writes all 40 + // rows at once, so that is the branch these take — the hoist itself is + // pinned by its own test below, and here the point is only which of the two + // renderings is shown. const cell = page.locator("tbody tr:first-child td:first-child"); const exact = cell.locator("span.only-wide"); const relative = cell.locator("span.only-narrow time"); - await expect(exact).toHaveText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + await expect(exact).toHaveText(INSTANT); await expect(relative).toHaveText(/^\d+[smhd] ago$/); if (narrow) { await expect(exact).toBeHidden(); @@ -1112,7 +1226,7 @@ for (const width of [768, 1025, 1056, 1057, 1280]) { const exact = cell.locator("span.only-wide"); const relative = cell.locator("span.only-narrow time"); // Both renderings are in the markup at every width; only one is shown. - await expect(exact).toHaveText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + await expect(exact).toHaveText(INSTANT); await expect(relative).toHaveText(/^\d+[smhd] ago$/); await expect(narrow ? relative : exact).toBeVisible(); await expect(narrow ? exact : relative).toBeHidden(); @@ -1141,6 +1255,70 @@ for (const width of [768, 1025, 1056, 1057, 1280]) { }); } +/** + * Pattern 2 of the design sweep: a value repeated identically on every row when + * it is one fact about the whole set. Both branches, because the interesting + * failure is not the hoist — it is the hoist firing on a page whose rows do NOT + * agree, which would delete a date the admin needs and state a false one above. + */ +test("the shared calendar day is stated once, and only when every row agrees", async ({ + page, + context, +}) => { + const admin = await seedMember(db, { name: "Boss", tier: "member", isAdmin: true }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + + // Two rows, one day. Explicit instants rather than seedDenseLog's defaults: + // a test about which day the rows fall on cannot let the clock decide, and + // "now" straddles midnight once a day. + await db.insert(auditLog).values([ + { + actor: "system", + action: "tier.changed", + target: "char:1", + at: new Date("2026-03-04T09:15:00Z"), + details: {}, + }, + { + actor: "system", + action: "tier.changed", + target: "char:2", + at: new Date("2026-03-04T21:40:30Z"), + details: {}, + }, + ]); + + await page.goto("/admin/audit"); + await page.waitForSelector(".scroller tbody tr"); + await expect(page.getByText("All 2 entries on 2026-03-04 (UTC).")).toBeVisible(); + // Said once above the table and dropped from both channels in the rows — + // `.only-wide` is a display toggle, so this span is what AT reads at this + // width too. R4: neither channel keeps what the other lost. + const wide = page.locator("tbody tr td:first-child span.only-wide"); + await expect(wide.first()).toHaveText("21:40:30"); + await expect(wide.nth(1)).toHaveText("09:15:00"); + + // One more row, one day earlier. Now nothing is shared, the line goes away, + // and every row carries its own date again. It sorts to the top because the + // log is keyset-ordered by id — insertion order — not by `at`; a backdated + // entry lands where it was written, which is what makes the multi-day case + // reachable at all. + await db.insert(auditLog).values([ + { + actor: "system", + action: "tier.changed", + target: "char:3", + at: new Date("2026-03-03T11:00:00Z"), + details: {}, + }, + ]); + await page.reload(); + await page.waitForSelector(".scroller tbody tr"); + await expect(page.getByText(/^All \d+ entries on /)).toHaveCount(0); + await expect(wide.first()).toHaveText("2026-03-03 11:00:00"); + await expect(wide.nth(1)).toHaveText("2026-03-04 21:40:30"); +}); + /** * The exact instant is what an audit log is for, and the narrow rendering * cannot be allowed to cost it. `title` would not do: VoiceOver and TalkBack do diff --git a/e2e/error-boundary.spec.ts b/e2e/error-boundary.spec.ts index dc88d015..bbc1ec82 100644 --- a/e2e/error-boundary.spec.ts +++ b/e2e/error-boundary.spec.ts @@ -139,6 +139,69 @@ test("a retry that fails again re-announces instead of looking like a dead click }); }); +/* + * The other outcome, and the one that had no announcement at all: the retry + * works. `reset()` unmounts the boundary, so the button holding focus goes with + * it, the URL never changes, and nothing is left mounted to run a focus effect + * — focus fell to `<body>` and a screen-reader user heard silence on the single + * press that succeeded. + * + * Note the shape of the setup: the table is restored BEFORE the click, by + * letting `breakPayoutsList` exit while the broken page is still on screen. + * That is what makes the retry deterministically succeed, and it is the only + * difference from the failing-retry test above. + * + * Writing it that way is also what exposed the larger defect this test now + * guards. With the table repaired and the press made, the boundary came back + * anyway: `reset()` alone re-runs the segment from the client router cache, + * which is still holding the payload that threw, so for a server-side failure + * the button could not recover anything at all. The fix pairs it with + * `router.refresh()` (see error.tsx). So the first assertion below — that the + * page is actually back — is the load-bearing one; the focus assertions ride + * on top of a retry that now works. + */ +test("a retry that succeeds hands focus to the recovered page", async ({ + page, + context, +}) => { + const member = await seedMember(db, { + name: "Retry Winner", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, member.id)]); + + await breakPayoutsList(async () => { + await page.goto(BROKEN_ROUTE); + await expect(page.getByRole("heading", { name: "Something broke" })).toBeVisible(); + }); + + // The table is back; this press repairs the page. + const retry = page.getByRole("button", { name: /Try again|Trying/ }); + await retry.focus(); + await retry.click(); + + await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Something broke" })).toHaveCount(0); + + // Focus is on the recovered page's own main — the skip link's target, and + // where a member skipping to content would have landed. Asserting the + // boundary marker is absent as well as the tag: both mains carry + // `id="main"`, and `data-error-boundary` is the attribute the fix uses to + // tell them apart, so a regression that focuses the wrong one still fails + // here rather than passing on the tag alone. + await expect + .poll( + async () => + page.evaluate(() => ({ + tag: document.activeElement?.tagName, + boundary: document.activeElement?.hasAttribute("data-error-boundary"), + })), + { timeout: 10_000 }, + ) + .toEqual({ tag: "MAIN", boundary: false }); +}); + test("the boundary keeps an admin inside the admin section", async ({ page, context, @@ -195,6 +258,25 @@ test("the boundary keeps an admin inside the admin section", async ({ // And the escape goes back to the section, not to /account. await expect(page.getByRole("link", { name: "Back to Members" })).toBeVisible(); + + // The escape comes first. Both controls are the plain grade and sit 8px + // apart, so position is the only thing separating them — and the lede + // above has just told this member their action may already have taken + // effect and to check before sending it again. Offering the re-send as + // the first and visually equal choice contradicts the sentence directly + // above it. + // + // Asserted on DOM order rather than on x-coordinates: `.btn-row` wraps at + // narrow widths, so the leftmost control is not stably the first one, and + // the tab order this also fixes follows the DOM either way. + const escapeFirst = await page.evaluate(() => { + const row = document.querySelector(".btn-row")!; + const back = row.querySelector("a.btn")!; + const retry = row.querySelector("button.btn")!; + // Node.DOCUMENT_POSITION_FOLLOWING — retry comes after back. + return (back.compareDocumentPosition(retry) & 4) !== 0; + }); + expect(escapeFirst, "Try again sits before the escape route").toBe(true); } finally { await db.execute(sql`ALTER TABLE sync_run_probe RENAME TO sync_run`); } @@ -260,6 +342,34 @@ test("the reference is inside the instruction that asks for it", async ({ await expect(digest).toBeVisible(); expect((await digest.innerText()).trim()).not.toBe(""); + // And it sits *in* the sentence, not beside it. `.notice` is `display: + // flex` so the glyph in `::before` can hold its own column, which makes + // every top-level child of the notice a flex item laid out in the row box + // — the `<code>` became item 2 and the text runs around it items 1 and 3, + // each separated by the container's `gap: var(--s-3)`. The DOM order was + // right and the reading was not: "quote reference 4292868890 ." with the + // digest floated off the words that name it and the period stranded past + // it. `toContainText` cannot see that, because the text is all present and + // in order in the DOM; only the painted boxes disagree. So measure them. + // + // The trailing period, ranged rather than located — it is a bare text node + // with no element to address. Two pixels of slack for subpixel layout; the + // gap this guards against is `--s-3`, an order of magnitude wider. + const stranded = await alert.evaluate((el) => { + const code = el.querySelector("code")!; + const tail = code.nextSibling!; + const range = document.createRange(); + range.selectNodeContents(tail); + return { + codeRight: code.getBoundingClientRect().right, + tailLeft: range.getBoundingClientRect().left, + }; + }); + expect( + stranded.tailLeft - stranded.codeRight, + "the period after the digest is stranded across a flex gap", + ).toBeLessThan(2); + // Only the value is monospaced. The prose around it, "reference" included, // stays proportional — DESIGN.md's split, which the old `dim mono` line ran // backwards by setting a code face on an English word. diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index ab7d05e6..4ec77601 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -8,24 +8,35 @@ import { expect, test } from "@playwright/test"; * deployed set (see there) — no session, no seed, needed to see it. */ -/** In `EVE_SSO_SCOPES` order — the page renders `cfg.eveSso.scopes` as given. */ +/** + * In render order, which is `SCOPE_GROUPS`' order and NOT `EVE_SSO_SCOPES`'. + * The page groups the scopes by what they let authGD do — see `SCOPE_GROUPS` + * in `login/page.tsx` for why config order is deliberately not preserved. + */ const SCOPES = [ "esi-characters.read_contacts.v1", "esi-characters.write_contacts.v1", - "esi-ui.open_window.v1", "esi-location.read_location.v1", "esi-universe.read_structures.v1", "esi-location.read_online.v1", + "esi-ui.open_window.v1", ]; /** One distinctive fragment of each scope's own sentence, in the same order. */ const DESCRIPTIONS = [ - "Reads the contacts", - "Adds, updates, and removes contacts", - "open a character's info window", - "which solar system", - "name of the structure", + "Reads what is already there", + "Adds, updates and removes contacts", + "solar system", + "structure's name", "logged in right now", + "info window", +]; + +/** Each group's head, in render order. */ +const GROUP_HEADS = [ + "the only thing authGD writes", + "Where your characters are", + "Your running EVE client", ]; test("each requested scope shows a plain-English description, not just the identifier", async ({ @@ -36,6 +47,17 @@ test("each requested scope shows a plain-English description, not just the ident const heading = page.locator(".launch__scopes-head"); await expect(heading).toHaveText("Scopes requested"); + // The shared fact per group, said once above the rows it covers. This is the + // half of the fix that the per-row assertions below cannot see: the + // descriptions were trimmed on the assumption that the group head carries + // what they dropped, so a head that stopped rendering would leave six + // sentences that no longer say what they are for. + const heads = page.locator(".launch__scope-group"); + await expect(heads).toHaveCount(GROUP_HEADS.length); + for (const [i, head] of GROUP_HEADS.entries()) { + await expect(heads.nth(i)).toContainText(head); + } + const rows = page.locator(".launch__scopes dt"); await expect(rows).toHaveCount(SCOPES.length); for (const [i, scope] of SCOPES.entries()) { @@ -78,6 +100,12 @@ test("the description outranks the identifier in the reading order that matters: .evaluate((el) => { return getComputedStyle(el).color; }); + const headColor = await page + .locator(".launch__scope-group") + .first() + .evaluate((el) => { + return getComputedStyle(el).color; + }); const luminance = (rgb: string) => { const [r, g, b] = rgb.match(/[\d.]+/g)!.map(Number); @@ -87,6 +115,11 @@ test("the description outranks the identifier in the reading order that matters: // --ink-dim (the description) renders lighter than --ink-faint (the // identifier) in this system's dark theme — see DESIGN.md's colour table. expect(luminance(ddColor)).toBeGreaterThan(luminance(dtColor)); + // And the group head — --ink, the shared fact, the answer to "does this + // change my stuff" — outranks both. It is deliberately not a member of the + // uppercase label register, so weight and colour are the only thing marking + // it as the line to read first; this is the assertion that keeps that true. + expect(luminance(headColor)).toBeGreaterThan(luminance(ddColor)); }); test("the sign-in control renders before the scope disclosure in document order", async ({ @@ -111,7 +144,9 @@ test("the sign-in control renders before the scope disclosure in document order" // is not a case of "press the button to see it": it asserts the paragraph // and the scope list are both attached and visible, just later in the page. await expect(page.locator(".launch__disclosure-note")).toBeVisible(); - await expect(page.locator(".launch__scopes")).toBeVisible(); + // `.first()`: the disclosure renders one <dl> per scope group now, so this + // class matches three elements rather than one. + await expect(page.locator(".launch__scopes").first()).toBeVisible(); }); test("the sign-in control is reachable without scrolling on a short viewport", async ({ @@ -132,7 +167,7 @@ test("the scope list stays readable at a narrow width", async ({ page }) => { await page.setViewportSize({ width: 320, height: 700 }); await page.goto("/login"); - const list = page.locator(".launch__scopes"); + const list = page.locator(".launch__scopes").first(); await expect(list).toBeVisible(); // No horizontal scroll: the panel's own overflow-wrap: anywhere on dt is diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts index 1df303b9..b6e2d2e3 100644 --- a/e2e/not-found.spec.ts +++ b/e2e/not-found.spec.ts @@ -216,3 +216,54 @@ test("the payouts 404 stays behind the payouts guard", async ({ page, context }) await page.goto("/payouts/00000000-0000-4000-8000-000000000000"); await expect(page).toHaveURL(/\/account/); }); + +/** + * `FocusHeading`'s own comment claimed for a long time that focusing the + * heading draws no ring, because `:focus-visible` supposedly cannot match a + * programmatic focus. It does match, in Chromium, and the ring is wanted — it + * is the sighted half of the announcement. What was wrong was that the ring + * spanned the full column around a short heading. This pins both halves so + * neither the ring nor the `fit-content` that sizes it gets "cleaned up" on the + * strength of the comment that used to be there. + */ +test("the boundary heading takes a focus ring, and it is the width of the heading", async ({ + page, +}) => { + await page.goto("/no-such-page-at-all"); + + const h1 = page.getByRole("heading", { level: 1 }); + // Anti-vacuity: every measurement below reads `document.querySelector("h1")` + // and would silently measure `null` if the boundary stopped rendering one. + await expect(h1).toHaveCount(1); + await expect(h1).toHaveText("Nothing at that address"); + + const seen = await page.evaluate(() => { + const el = document.querySelector("h1")!; + const range = document.createRange(); + range.selectNodeContents(el); + return { + focused: document.activeElement === el, + matchesFocusVisible: el.matches(":focus-visible"), + outlineWidth: getComputedStyle(el).outlineWidth, + outlineStyle: getComputedStyle(el).outlineStyle, + boxWidth: Math.round(el.getBoundingClientRect().width), + textWidth: Math.round(range.getBoundingClientRect().width), + }; + }); + + // The ring exists. Asserting `:focus-visible` matches rather than only that + // an outline is set, because the false claim was specifically about the + // selector, and a future reader deleting the rule would want to see that + // exact statement contradicted. + expect(seen.focused).toBe(true); + expect(seen.matchesFocusVisible).toBe(true); + expect(seen.outlineStyle).toBe("solid"); + expect(seen.outlineWidth).toBe("2px"); + + // And it hugs the heading. Before `h1[tabindex="-1"] { width: fit-content }` + // this was 912 against 377. An exact equality would be brittle across font + // metrics, so this asserts the property that matters: the box is the text + // plus rounding, not the column. + expect(seen.textWidth).toBeGreaterThan(0); + expect(seen.boxWidth).toBeLessThanOrEqual(seen.textWidth + 2); +}); diff --git a/e2e/payouts.spec.ts b/e2e/payouts.spec.ts index 3e5779c5..2049c146 100644 --- a/e2e/payouts.spec.ts +++ b/e2e/payouts.spec.ts @@ -920,16 +920,77 @@ test("an empty name is refused with a specific message", async ({ page, context await context.addCookies([await sessionCookieFor(db, operator.id)]); await page.goto("/payouts/new"); await page.getByLabel("Date").fill("2026-08-01"); - // The Name field is `required`, so the browser's own validation blocks a - // truly empty submit before it ever reaches the server — reaching this - // rejection at all means going around the client guard, same as the date - // and shares/price tests above. + // Kept as a belt-and-braces removal rather than relied on: the form carries + // `noValidate` now, so an empty submit reaches the server on its own and the + // attribute is inert. It used to be the only way in — native validation ran + // ahead of the `submit` event React's `<form action>` fires from, so this + // message was a scripted-request backstop. The test below + // ("...through the form, with no client guard removed") is the one that pins + // the new route; this one keeps covering the server check itself. await page.getByLabel("Name").evaluate((el) => el.removeAttribute("required")); await page.getByRole("button", { name: "Create operation" }).click(); await expect(page.locator("p.notice--bad")).toContainText("needs a name"); await expect(page.getByText("Something broke")).toHaveCount(0); }); +/* + * The rejection has to stay attached to the form it is about. + * + * `.form-stack` reserves the notice slot so the live region exists before the + * message does, and `.notice-slot` is out of flow so the empty reservation + * costs nothing — that part was already right, and already measured at 0px. + * But a populated `Notice` renders `.notice`, a different class, so the reset + * that flattened `.rule-head`'s 48px top margin stopped matching at exactly the + * moment there was something to say. Three spacings then stacked — the + * notice's own 24px bottom margin, the grid's 16px gap, and the header's 48px + * — and the measured distance went 0px empty, 88px populated. On the one + * screen where an operator has just been refused, the error floated alone with + * the form pushed a third of a phone screen below it. + * + * Asserted as a distance rather than as a computed style, because the defect + * was the sum and no single declaration was wrong on its own. The expected + * value is `.form-stack`'s own row gap: after the fix the notice is spaced from + * the header exactly the way every other pair of siblings in the form is. + */ +test("a rejection notice sits against the form, not a screen above it", async ({ + page, + context, +}) => { + const operator = await seedMember(db, { + name: "FC Spacing", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, operator.id)]); + await page.goto("/payouts/new"); + + const notice = page.locator("#new-operation-error"); + const head = page.locator(".form-stack .rule-head").first(); + + // The empty case, so a regression that "fixes" the populated gap by + // reintroducing the reserved slot's own spacing fails here instead. + const gapEmpty = await gapBetween(notice, head); + expect(gapEmpty).toBe(0); + + await page.getByRole("button", { name: "Create operation" }).click(); + await expect(notice).toContainText("needs a name"); + + const gapFull = await gapBetween(notice, head); + const rowGap = await head.evaluate( + (el) => parseFloat(getComputedStyle(el.parentElement!).rowGap) || 0, + ); + expect(rowGap).toBeGreaterThan(0); + expect(Math.abs(gapFull - rowGap)).toBeLessThanOrEqual(1); +}); + +/** Vertical distance from the bottom of `above` to the top of `below`. */ +async function gapBetween(above: Locator, below: Locator): Promise<number> { + const a = await above.boundingBox(); + const b = await below.boundingBox(); + if (!a || !b) throw new Error("both elements must be laid out to be measured"); + return b.y - (a.y + a.height); +} + test("an invalid date is refused with a specific message", async ({ page, context }) => { const operator = await seedMember(db, { name: "FC Codes", @@ -939,8 +1000,9 @@ test("an invalid date is refused with a specific message", async ({ page, contex await context.addCookies([await sessionCookieFor(db, operator.id)]); await page.goto("/payouts/new"); await page.getByLabel("Name").fill("Bad date roam"); - // type="date" stops free text in the browser, so reaching the server check - // at all means going around the client guard. + // `type="date"` still refuses to hold free text whatever the form says about + // validation, so this one genuinely does need the bypass — `noValidate` + // switches off constraint *reporting*, not the input's own value sanitising. await bypassClientGuard(page.getByLabel("Date"), "not-a-date"); await page.getByRole("button", { name: "Create operation" }).click(); await expect(page.locator("p.notice--bad")).toContainText("real calendar date"); @@ -974,6 +1036,91 @@ test("a date that does not exist is refused rather than rolled forward", async ( await expect(page.getByText("Something broke")).toHaveCount(0); }); +/* + * The point of `noValidate`, stated as a test: an operator typing a plausible + * wrong thing gets the app's own sentence, in the page, where it stays. + * + * Every other rejection test on this form removes an attribute or writes the + * value past the input first. This one touches nothing — it fills the field the + * way a person does and presses the button. Before `noValidate` it could not + * have passed: the browser blocked the submit with a transient "Please enter a + * URL." bubble that never named the scheme, and the sentence asserted below was + * unreachable through the form at all. + * + * Measured, not assumed: a scheme-less paste lands on `url_invalid`, NOT + * `url_scheme`. `zkillboard.com/related/…` fails `new URL()` outright, so it + * never reaches the scheme check — `url_scheme` fires only for something that + * already parses and is the wrong kind, like `javascript:`. That makes + * `url_invalid` the message an operator actually meets, which is why its copy + * (errors.ts) names the missing `https://` rather than stopping at "not a URL", + * and why this asserts the remedy half specifically instead of just checking + * that some notice appeared. + */ +test("a bare-hostname battle report is refused through the form, with no client guard removed", async ({ + page, + context, +}) => { + const operator = await seedMember(db, { + name: "FC NoValidate", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, operator.id)]); + await page.goto("/payouts/new"); + await page.getByLabel("Name").fill("Scheme-less roam"); + await page.getByLabel("Date").fill("2026-08-01"); + await page + .getByLabel("Battle report (optional)") + .fill("zkillboard.com/related/30000142/"); + await page.getByRole("button", { name: "Create operation" }).click(); + + await expect(page).toHaveURL(/\/payouts\/new$/); + await expect(page.locator("p.notice--bad")).toContainText( + "needs an https:// on the front", + ); + // The other half of what the message promises, and the reason the composer + // returns state instead of redirecting: nothing the operator typed is gone. + await expect(page.getByLabel("Name")).toHaveValue("Scheme-less roam"); + await expect(page.getByLabel("Battle report (optional)")).toHaveValue( + "zkillboard.com/related/30000142/", + ); + await expect(page.getByText("Something broke")).toHaveCount(0); +}); + +/* + * `max={today}` was enforced by the browser and nothing else. Switching off + * native validation would have turned that attribute into decoration and let a + * future-dated operation through, so `createOperationAction` now checks it — + * this is the test that says so, and the one that fails if either half is + * removed without the other. + * + * The date is built from the run's own clock rather than hard-coded, so this + * does not quietly stop testing anything when a fixed "future" date becomes + * the past. Two days out, not one: the action compares against UTC midnight of + * the instant it runs, so a run that fills the form at 23:59 and submits after + * the boundary would be posting today's date and get it accepted. + */ +test("an operation cannot be dated into the future", async ({ page, context }) => { + const operator = await seedMember(db, { + name: "FC Tomorrow", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, operator.id)]); + const future = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString().slice(0, 10); + + await page.goto("/payouts/new"); + await page.getByLabel("Name").fill("Tomorrow's roam"); + await page.getByLabel("Date").fill(future); + await page.getByRole("button", { name: "Create operation" }).click(); + + await expect(page).toHaveURL(/\/payouts\/new$/); + await expect(page.locator("p.notice--bad")).toContainText( + "cannot be dated in the future", + ); + await expect(page.getByText("Something broke")).toHaveCount(0); +}); + test("a battle report link is stored, and a bad scheme is refused without losing the rest of the form", async ({ page, context, @@ -1032,6 +1179,84 @@ test("a battle report link is stored, and a bad scheme is refused without losing expect(created.battleReportUrl).toBe("https://zkillboard.com/related/1/"); }); +/** + * This page renders 70 pressable things and 62 of them are the identical 28px + * quiet chip, against a single gold button. At that ratio nothing directs the + * eye: an operator opening a payout scans a field of interchangeable `edit` + * marks with no way to tell which one changes the record's identity from which + * one changes a line item's unit price. + * + * Two triggers are raised out of the quiet grade — the operation's name and its + * date, which *are* the record's identity and are the only two above the fold. + * The raise is deliberately not extended to the other three standalone uses + * (corp share, notes, battle report): promoting five of 62 does not create a + * focal point, it creates a second uniform tier. + * + * So the assertion runs in both directions. Checking only that name and date + * are prominent would pass just as well if every trigger on the page had been + * raised, which is the failure this scoping exists to avoid. + */ +test("the two identity triggers are raised, and the other standalone ones are not", async ({ + page, + context, +}) => { + const operator = await seedMember(db, { + name: "FC Grade", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, operator.id)]); + + await page.goto("/payouts/new"); + await page.getByLabel("Name").fill("Grade check roam"); + await page.getByLabel("Date").fill("2026-08-01"); + await page.getByRole("button", { name: "Create operation" }).click(); + await expect(page.getByRole("heading", { name: "Grade check roam" })).toBeVisible(); + + const raised = ["edit operation name", "edit operation date"]; + // Battle report only. The other two unraised standalone triggers are corp + // share, which needs a loot pool before its row renders at all, and notes — + // neither is on a freshly created operation, and seeding a pool here to + // reach them would test the fixture rather than the grade. One counterweight + // is enough to catch the failure this direction exists for: a raise applied + // to every trigger instead of the two scoped ones. + const notRaised = ["edit battle report URL"]; + + for (const name of raised) { + const trigger = page.getByRole("button", { name }); + // Anti-vacuity: a renamed label would make every class assertion below run + // against an empty locator and pass. + await expect(trigger, `${name} is missing`).toHaveCount(1); + await expect(trigger).not.toHaveClass(/btn--quiet/); + await expect(trigger).not.toHaveClass(/btn--micro/); + await expect(trigger).toHaveClass(/\bbtn\b/); + } + + for (const name of notRaised) { + const trigger = page.getByRole("button", { name }); + await expect(trigger, `${name} is missing`).toHaveCount(1); + await expect(trigger).toHaveClass(/btn--quiet/); + } + + // The raise costs no layout: bare `.btn` already carries the same + // `min-height: 2.25rem` that `.inline-edit--standalone .btn--quiet` buys + // back for the quiet ones, so this changes fill and border only. Measured + // rather than asserted from the stylesheet, because that equality is the + // whole reason the change was affordable. + const heights = await page.evaluate(() => { + const by = (label: string) => document.querySelector(`button[aria-label="${label}"]`); + const h = (el: Element | null) => + el ? Math.round(el.getBoundingClientRect().height) : null; + return { + name: h(by("edit operation name")), + report: h(by("edit battle report URL")), + }; + }); + expect(heights.name).not.toBeNull(); + expect(heights.report).not.toBeNull(); + expect(heights.name).toBe(heights.report); +}); + /* * The other half of the same rule, on the other entry point. Both the create * form and this inline edit go through `battleReportUrlProblem` @@ -2666,6 +2891,54 @@ test("the notes Save control sits at the page's standalone hit-target grade", as expect(Math.round(saveBox!.height)).toBe(Math.round(finalizeBox!.height)); }); +/** + * The same ruling, one control type over. R1 scopes the 28px grade by the + * reason for it — rows carrying a control set, read many at a time — and says + * so outright: "A disclosure drawer is not in-row for this purpose and takes + * 36px." These four summaries are page-level sections of a draft operation, + * not table rows, and they were taking the in-row ration. + * + * Every `.disc` on the page rather than a named one: the class has exactly + * four call sites, all here, and a per-summary assertion would pass while a + * fifth was added at the wrong grade. Compared against `Finalize` for the same + * reason the test above does it — the number has to mean "this page's own + * standalone grade", not a constant restated. + */ +test("the page-level disclosures sit at the standalone hit-target grade", async ({ + page, + context, +}) => { + const operator = await seedMember(db, { + name: "Disc FC", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, operator.id)]); + + await page.goto("/payouts/new"); + await page.getByLabel("Name").fill("Disclosure roam"); + await page.getByLabel("Date").fill("2026-08-01"); + await page.getByRole("button", { name: "Create operation" }).click(); + await expect(page.getByRole("heading", { name: "Disclosure roam" })).toBeVisible(); + + const finalizeBox = await page.getByRole("button", { name: "Finalize" }).boundingBox(); + expect(Math.round(finalizeBox!.height)).toBe(36); + + const summaries = page.locator("details.disc > summary"); + const count = await summaries.count(); + // A draft with no pools and no participants still renders more than one, so + // a zero here would mean the locator stopped matching rather than that the + // grade holds. + expect(count).toBeGreaterThan(1); + for (let i = 0; i < count; i++) { + const box = await summaries.nth(i).boundingBox(); + expect( + Math.round(box!.height), + await summaries.nth(i).innerText(), + ).toBeGreaterThanOrEqual(Math.round(finalizeBox!.height)); + } +}); + /** * Seeds a *draft* operation — pools, participants, or both. `seedFinalizedRoster` * above cannot stand in for this: `canEdit` is @@ -3712,23 +3985,146 @@ test("a filter matching nothing renders its own empty state, not 'no operations' }); /** - * The pinned Name column, at the widths where the table is forced to scroll. + * Seeds three finalized operations, one of them named with an unbroken + * 60-character token, each carrying the 12-digit total the column budget in + * globals.css was measured against. Operation names are operator-typed `text` + * with no cap in schema.ts, unlike the EVE-bounded character names the accounts + * table pins, so that token is the one input that could grow the Name column + * until it covers the region it is meant to anchor. A short name and a small + * total would understate how far the row runs and make every width below pass + * for the wrong reason. + */ +async function seedWidePayoutList(viewerId: string) { + for (const [i, name] of [ + "Aaa First Operation Name", + "B".repeat(60), + "Ccc Third Operation", + ].entries()) { + const [op] = await db + .insert(payoutOperation) + .values({ + name, + occurredAt: new Date(`2026-08-0${5 - i}`), + corpSharePct: "0", + status: "finalized", + }) + .returning(); + await db.insert(lootPool).values({ + operationId: op.id, + valuationSource: "flat", + totalValue: "123456789012.00", + notes: "seeded", + }); + await db.insert(payoutParticipant).values({ + operationId: op.id, + accountId: viewerId, + displayName: "Pin Viewer", + shares: "1", + amount: "123456789012.00", + paidAmount: "123456789012.00", + }); + } +} + +/** + * The pinned Name column, at a width where the table is still a table and is + * still forced to scroll. + * + * Six columns need 736px worst case (the budget is measured out in globals.css + * beside `.log--payouts`), so scrolling is not the defect — losing the row's + * identity while panning to Total is. Under ruling R3 /payouts is a corp-wide + * ledger, which makes "which operation is this figure for?" the question every + * other cell depends on, so these are correctness tests in the same sense as + * the accounts table's pin tests, not cosmetic ones. + * + * 560px, not the 320px and 390px this asserted at previously. Below 30rem the + * row now reflows to labelled blocks and there is no horizontal scroll left to + * pin anything out of, so `maxScrollLeft > 0` cannot hold there — the guard on + * the first assertion is doing its job, not reporting a regression. The reflow + * is covered by its own test below. 560px is the narrowest round width above + * the 30rem breakpoint where the 736px budget still exceeds the region, so the + * pin is under real load rather than nominally present. + */ +test("payouts at 560px: the operation name stays put while Total is reached", async ({ + page, + context, +}) => { + const viewer = await seedMember(db, { + name: "Pin Viewer", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, viewer.id)]); + await seedWidePayoutList(viewer.id); + + await page.setViewportSize({ width: 560, height: 720 }); + await page.goto("/payouts"); + await page.waitForSelector(".scroller tbody tr"); + + const pinned = await pinGeometry( + page, + ".scroller", + "tbody tr:first-child td:first-child", + "right", + ); + // Vacuous unless there was something to scroll past in the first place. + expect(pinned.maxScrollLeft).toBeGreaterThan(0); + expect(pinned.scrolledLeft).toBeGreaterThanOrEqual( + pinned.maxScrollLeft - pinned.gutterWidth, + ); + // Fully on screen at the far right, not merely intersecting by a sliver — + // the distinction `toBeInViewport` cannot make (see geometry.ts). + expect(pinned.overlapX).toBeCloseTo(pinned.cellWidth, 0); + expect(pinned.overlapY).toBeGreaterThan(0); + expect(pinned.text).toContain("Aaa First Operation Name"); + + // The corner cell rides with the column it heads: a NAME column left under + // a heading that reads TOTAL is worse than no heading at all. + const corner = await pinGeometry(page, ".scroller", "thead th:first-child", "right"); + expect(corner.overlapX, "the Name heading stays over the pinned column").toBeCloseTo( + corner.cellWidth, + 0, + ); + expect(corner.text).toContain("Name"); + + // The pin has to leave most of the region for the columns it exists to let + // you reach. The same 60% ceiling the account manifest is held to — and + // the reason `overflow-wrap: anywhere` is on this column, since the + // 60-character row below would otherwise set its width. + const longRow = await pinGeometry( + page, + ".scroller", + "tbody tr:nth-child(2) td:first-child", + "right", + ); + expect(longRow.text).toContain("B"); + expect( + longRow.cellWidth / longRow.regionWidth, + "an unbroken 60-character name does not turn the pin into the page", + ).toBeLessThan(0.6); +}); + +/** + * Below 30rem the row stops being a row. The pin was the previous answer at + * these widths and it defeated itself: measured at 69px of a 286px region — + * 24% — which is a column too narrow to read an operation name out of and + * still the only thing kept while panning across five more. The 60% ceiling + * above is a ceiling with no floor, so 24% passed it exactly as 55% would. * - * Six columns need 736px worst case against a 286px region at 320px (the - * budget is measured out in globals.css beside `.log--payouts`), so scrolling - * is not the defect — losing the row's identity while panning to Total is. - * Under ruling R3 /payouts is a corp-wide ledger, which makes "which operation - * is this figure for?" the question every other cell depends on, so these are - * correctness tests in the same sense as the accounts table's pin tests, not - * cosmetic ones. + * The three assertions are the three ways the reflow can be wrong, and none of + * them can pass while the bug they describe is present: * - * A deliberately unbroken 60-character name is seeded because operation names - * are operator-typed `text` with no cap in schema.ts, unlike the EVE-bounded - * character names the accounts table pins. That token is the one input that - * could grow the pin until it covers the region it is meant to anchor. + * - it did not happen at all (cells still laid out in a row, table still + * scrolling sideways); + * - it happened and dropped facts, which would collapse the /payouts-vs- + * /account distinction ruling R3 draws; + * - it happened and left the columns unnamed, which is ruling R4's + * both-channels requirement — the `<thead>` is `display: none` down here, + * so a `.payouts__label` in each cell is the only thing naming them, in + * the visual channel and the accessibility tree alike. */ for (const width of [320, 390]) { - test(`payouts at ${width}px: the operation name stays put while Total is reached`, async ({ + test(`payouts at ${width}px: each row reflows to labelled blocks, nothing dropped`, async ({ page, context, }) => { @@ -3738,83 +4134,119 @@ for (const width of [320, 390]) { status: "active", }); await context.addCookies([await sessionCookieFor(db, viewer.id)]); + await seedWidePayoutList(viewer.id); - for (const [i, name] of [ - "Aaa First Operation Name", - "B".repeat(60), - "Ccc Third Operation", - ].entries()) { - const [op] = await db - .insert(payoutOperation) - .values({ - name, - occurredAt: new Date(`2026-08-0${5 - i}`), - corpSharePct: "0", - status: "finalized", - }) - .returning(); - await db.insert(lootPool).values({ - operationId: op.id, - valuationSource: "flat", - // A 12-digit total is the worst case the column budget was measured - // against; a short one would understate how far the row scrolls. - totalValue: "123456789012.00", - notes: "seeded", - }); - await db.insert(payoutParticipant).values({ - operationId: op.id, - accountId: viewer.id, - displayName: "Pin Viewer", - shares: "1", - amount: "123456789012.00", - paidAmount: "123456789012.00", - }); - } - - await page.setViewportSize({ width, height: 720 }); + await page.setViewportSize({ width, height: 900 }); await page.goto("/payouts"); await page.waitForSelector(".scroller tbody tr"); - const pinned = await pinGeometry( - page, - ".scroller", - "tbody tr:first-child td:first-child", - "right", - ); - // Vacuous unless there was something to scroll past in the first place. - expect(pinned.maxScrollLeft).toBeGreaterThan(0); - expect(pinned.scrolledLeft).toBeGreaterThanOrEqual( - pinned.maxScrollLeft - pinned.gutterWidth, - ); - // Fully on screen at the far right, not merely intersecting by a sliver — - // the distinction `toBeInViewport` cannot make (see geometry.ts). - expect(pinned.overlapX).toBeCloseTo(pinned.cellWidth, 0); - expect(pinned.overlapY).toBeGreaterThan(0); - expect(pinned.text).toContain("Aaa First Operation Name"); - - // The corner cell rides with the column it heads: a NAME column left under - // a heading that reads TOTAL is worse than no heading at all. - const corner = await pinGeometry(page, ".scroller", "thead th:first-child", "right"); - expect(corner.overlapX, "the Name heading stays over the pinned column").toBeCloseTo( - corner.cellWidth, - 0, - ); - expect(corner.text).toContain("Name"); - - // The pin has to leave most of the region for the columns it exists to let - // you reach. The same 60% ceiling the account manifest is held to — and - // the reason `overflow-wrap: anywhere` is on this column, since the - // 60-character row below would otherwise set its width. - const longRow = await pinGeometry( - page, - ".scroller", - "tbody tr:nth-child(2) td:first-child", - "right", + // The 60-character row: the widest thing this table can be asked to hold. + const row = page.locator(".scroller tbody tr").nth(1); + + const geometry = await row.evaluate((tr) => { + const scroller = tr.closest(".scroller") as HTMLElement; + const cells = Array.from(tr.querySelectorAll("td")); + return { + maxScrollLeft: scroller.scrollWidth - scroller.clientWidth, + // Distinct `top` values means the cells stack. One shared `top` is + // the unreflowed table, and is what this asserts against. + distinctTops: new Set( + cells.map((td) => Math.round(td.getBoundingClientRect().top)), + ).size, + cellCount: cells.length, + headVisible: getComputedStyle(tr.closest("table")!.querySelector("thead")!) + .display, + firstCellPosition: getComputedStyle(cells[0]).position, + }; + }); + + expect(geometry.cellCount, "no column is dropped on a phone").toBe(6); + expect(geometry.distinctTops, "the six cells stack instead of running across").toBe( + 6, ); - expect(longRow.text).toContain("B"); - expect( - longRow.cellWidth / longRow.regionWidth, - "an unbroken 60-character name does not turn the pin into the page", - ).toBeLessThan(0.6); + expect(geometry.headVisible).toBe("none"); + // `.log--sticky-col` is still on the element; its `position: sticky` has to + // lose to the reflow or the first cell pins against a table that no longer + // scrolls. + expect(geometry.firstCellPosition).toBe("static"); + expect(geometry.maxScrollLeft, "nothing left to pan sideways").toBe(0); + + // Both channels, per R4: the label is a real element, so it is in the + // accessibility tree as well as on screen. Checked on the Total cell, + // whose figure is the one the reader panned right for under the old + // layout and the one most likely to end up unlabelled. + const totalCell = row.locator("td").nth(3); + await expect(totalCell.locator(".payouts__label")).toBeVisible(); + await expect(totalCell).toContainText("Total"); + await expect(totalCell).toContainText("123,456,789,012"); }); } + +/** + * The roster at 390px. Measured before the fix: 253px of a 339px region spent + * on Shares and Amount, leaving State and every row control off the right + * edge. State is the cell answering the question a member opened the page to + * ask, so the column a phone dropped was the most important one on the table. + * + * The assertion is positional rather than a visibility check, because the + * fix's whole content is where the cells land: State has to sit on the same + * line as the name it describes, and above the numbers rather than after them. + * A `toBeVisible()` here would have passed before the fix too — the cell was + * rendered and scrollable-to, just unreachable without panning. + */ +test("the roster at 390px puts state beside the name, with nothing scrolled off", async ({ + page, + context, +}) => { + const operator = await seedMember(db, { + name: "Narrow FC", + tier: "member", + status: "active", + }); + await context.addCookies([await sessionCookieFor(db, operator.id)]); + const opId = await seedFinalizedRoster(db, operator.id, ["Ada Narrow", "Bo Narrow"]); + + await page.setViewportSize({ width: 390, height: 900 }); + await page.goto(`/payouts/${opId}`); + await page.waitForSelector(".log--roster tbody tr"); + + const box = await page + .locator(".log--roster tbody tr") + .first() + .evaluate((tr) => { + const cells = Array.from(tr.querySelectorAll("td")).map((td) => { + const r = td.getBoundingClientRect(); + return { top: Math.round(r.top), right: Math.round(r.right) }; + }); + const scroller = tr.closest(".scroller") as HTMLElement; + return { + name: cells[0], + shares: cells[1], + state: cells[3], + actions: cells[4], + regionRight: Math.round(scroller.getBoundingClientRect().right), + maxScrollLeft: scroller.scrollWidth - scroller.clientWidth, + }; + }); + + // State shares the name's line: same question, same answer. + expect(box.state.top, "state sits beside the name it describes").toBe(box.name.top); + // And the numbers have moved below both. + expect(box.shares.top).toBeGreaterThan(box.name.top); + expect(box.actions.top).toBeGreaterThan(box.shares.top); + + // Nothing is off the right edge any more — the defect, stated directly. + expect(box.state.right).toBeLessThanOrEqual(box.regionRight); + expect(box.actions.right).toBeLessThanOrEqual(box.regionRight); + // Measured at 390px: the table lays out at exactly the scroller's 356px client + // width and every cell sits inside it, but the scroller still reports 357px of + // scroll range. That last pixel is a scroller-level rounding artifact, not a + // column parked off-screen, and `Scroller` already discounts it — the + // `scrollWidth > clientWidth + 1` test at `scroller.tsx:53` leaves the region + // at `tabIndex={-1}` with no edge fades. Assert against the same threshold the + // component uses rather than a stricter one it was never held to. + expect( + box.maxScrollLeft, + "no sideways pan left to lose a column behind", + ).toBeLessThanOrEqual(1); +}); diff --git a/e2e/sync.spec.ts b/e2e/sync.spec.ts index bf93183e..fd425664 100644 --- a/e2e/sync.spec.ts +++ b/e2e/sync.spec.ts @@ -283,6 +283,49 @@ test("a fixed-hour cadence keeps UTC in its accessible name, and an interval one * whitespace, so this distinguishes one space from none — the whole question — * but not one from two. */ +/** + * The same defect as `/admin/access-lists`, on the denser of the two surfaces. + * A `<summary>`'s accessible name is computed from its contents, and one of + * these contents is `RelativeTime` — a client component on a shared 30s ticker + * — so every job row's toggle renamed itself twice a minute with nothing about + * the job having changed: SC 4.1.2 for a screen reader that re-announces a + * control it sees renamed, SC 3.2.4 for a voice user whose remembered phrase + * stops matching the page. + * + * Stability only. What the name *contains* is pinned by the three + * `toHaveAccessibleName` cases above, which is the half that catches the + * pre-built label drifting away from the visible content — the standing risk + * of fixing this with `aria-label`, since the label replaces the computed name + * outright and anything not restated leaves the assistive channel (R4). + */ +test("a job row's toggle does not rename itself as its timestamp ages", async ({ + page, + context, +}) => { + await asAdmin(context); + await seedRuns(); + // Before `goto`: the clock has to be in place while the page's scripts load, + // or the ticker captures the real timers on the way past. + await page.clock.install(); + await page.goto("/admin/sync"); + + const summary = summaryFor(page, "membership"); + const nameOf = () => summary.evaluate((el) => el.getAttribute("aria-label") ?? ""); + const before = await nameOf(); + // Non-empty, or the two reads would agree vacuously. + expect(before).not.toBe(""); + expect(before).toContain("membership"); + + // The visible "ago" moving is what proves the tick landed; without it this + // would pass on a page where nothing ticked at all. + const stamp = summary.locator(".ago"); + const stampBefore = await stamp.innerText(); + await page.clock.fastForward("05:00"); + await expect(stamp).not.toHaveText(stampBefore); + + expect(await nameOf(), "the toggle renamed itself as the clock moved").toBe(before); +}); + test("an interval row's cadence and its next-run time stay separate words", async ({ page, context, @@ -435,6 +478,89 @@ test("housekeeping's collapsed line states health when nothing needs attention", await expect(summary.locator(".st--ok")).toHaveCount(1); }); +/** + * The collapsed line is the only place the flagged member names appear while + * the group is shut, and it is a sentence rendered as a `Status` — so it + * inherited `.st`'s `white-space: nowrap`, which is written for a one-word + * token in a table cell. At 320px that held ~41 unbreakable characters on one + * line and pushed them out of the panel. + * + * Both jobs faulted, because that is the longest the sentence gets and the + * state in which it matters most: an admin who cannot read past "token-health + * fai…" has to open the group to learn what the line exists to tell them. + * Wrapping, never truncating — the names are the payload. + */ +test("housekeeping's collapsed line wraps inside the panel at 320px", async ({ + page, + context, +}) => { + await asAdmin(context); + await db.insert(syncRun).values([ + { + jobType: "token-health", + startedAt: ago(2 * MIN), + finishedAt: ago(2 * MIN - 300), + status: "failed", + errorSummary: "token refresh failed for 3 accounts", + counts: null, + }, + { + jobType: "purge", + startedAt: ago(3 * MIN), + finishedAt: ago(3 * MIN - 300), + status: "failed", + errorSummary: "purge could not acquire its lock", + counts: null, + }, + ]); + await page.setViewportSize({ width: 320, height: 720 }); + await page.goto("/admin/sync"); + + const summary = page.locator(".strip__group-disc > summary"); + // Whole sentence, both names, nothing elided. + await expect(summary).toHaveText("2 jobs · token-health failed, purge failed"); + + // The measurement, not the rule: the sentence's own painted box stays inside + // the strip that contains it. Measured on the `.st`, not on the `<summary>` + // — an overflowing flex item spills past its container's edge without + // growing it, so the summary's own right edge sits at the panel boundary + // whether the line fits or not, and asserting on it passes vacuously. + const fit = await summary.locator(".st").evaluate((el) => ({ + right: el.getBoundingClientRect().right, + limit: el.closest(".strip")!.getBoundingClientRect().right, + })); + expect(fit.right, "collapsed line overflows the strip at 320px").toBeLessThanOrEqual( + Math.ceil(fit.limit), + ); + + // And it wrapped to get there rather than being narrow enough all along. + // Measured against the rule it overrides: put `.st`'s own `white-space: + // nowrap` back on the element and the line demands more width than it was + // given. That comparison is what makes this a test of the media-query rule + // rather than of the seed happening to be short — it fails if the rule is + // dropped, and it also fails if the rule never did anything. + const width = await summary.locator(".st").evaluate((el) => { + const wrapped = el.getBoundingClientRect().width; + const prev = el.style.whiteSpace; + el.style.whiteSpace = "nowrap"; + const nowrap = el.getBoundingClientRect().width; + el.style.whiteSpace = prev; + return { wrapped, nowrap }; + }); + expect(width.nowrap, "the line fits at 320px even unwrapped").toBeGreaterThan( + width.wrapped, + ); + + // The page itself still does not scroll sideways (WCAG 1.4.10). + const doc = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })); + expect(doc.scroll, "page-level horizontal scroll at 320px").toBeLessThanOrEqual( + doc.client, + ); +}); + /** * A job JOB_CRON schedules but that has no rows at all. Before this it was an * absent row, and an absent row is the hardest thing on a page for an eye to @@ -833,6 +959,46 @@ test("a wedged run reads stuck, with its elapsed time, and opens", async ({ /* --- Controls ------------------------------------------------------------ */ +// Refresh changes nothing on the server. `Recheck invalid affiliations` puts a +// job on the queue. They were both plain `.btn`, 8px apart, in the same control +// row at the foot of the page — identical weight, identical box, and nothing +// but the label to tell an admin that one of them is free and the other is not. +// +// The grade axis was unavailable: `.btn--quiet` carries `min-height: 1.75rem`, +// and DESIGN.md R1 scopes that 28px grade by the reason for it, to rows that +// each carry a control set and are read many at a time. A single control is not +// that. So this is fixed by adjacency instead — Refresh moved to the strip's +// section header, beside the "checked … UTC" stamp, which is the thing it +// actually replaces. +// +// Asserted structurally rather than by coordinates: the header wraps at narrow +// widths and the stamp is `--ink-faint` mono, so a geometric assertion would +// either be brittle or pass on a control that had merely drifted near. +test("Refresh sits with the stamp it replaces, not with the controls that queue work", async ({ + page, + context, +}) => { + await asAdmin(context); + await seedRuns(); + await page.goto("/admin/sync"); + + const aside = page.locator(".rule-head__aside"); + // Anti-vacuity: every containment check below would pass against an empty + // locator, and `.rule-head__aside` only exists if `RuleHead` got an `aside`. + await expect(aside).toHaveCount(1); + await expect(aside.locator(".btn-row__stamp")).toContainText("checked"); + await expect(aside.getByRole("link", { name: "Refresh" })).toBeVisible(); + + // The other half of the finding: it is no longer a peer of the two controls + // that enqueue. Both of those stay, so this is not asserting on an empty row. + const controls = page.locator(".btn-row--controls"); + await expect(controls.getByRole("button", { name: "Sync now" })).toBeVisible(); + await expect( + controls.getByRole("button", { name: "Recheck invalid affiliations" }), + ).toBeVisible(); + await expect(controls.getByRole("link", { name: "Refresh" })).toHaveCount(0); +}); + test("the fan-out reports back, moves focus to the confirmation, and Refresh clears the flag", async ({ page, context, diff --git a/src/app/_components/confirm-notice.tsx b/src/app/_components/confirm-notice.tsx index c4a28c96..e3388e7a 100644 --- a/src/app/_components/confirm-notice.tsx +++ b/src/app/_components/confirm-notice.tsx @@ -99,9 +99,16 @@ export function ConfirmNotice({ }, [at, text]); return ( - // No focus ring: same reasoning as `FocusHeading` — the global ring is - // `:focus-visible`, which a programmatic focus on a non-input element - // does not match. + // A focus ring appears, and is meant to. This used to say "no focus ring: + // same reasoning as `FocusHeading`" — and that reasoning was wrong in both + // places (Chromium does match `:focus-visible` on a programmatic focus of a + // `tabindex="-1"` element; measured). The ring is the sighted counterpart + // of the announcement this div exists to make. + // + // No `fit-content` here, unlike `FocusHeading`. This wraps a `Notice`, + // which is a full-width panel, so a full-width ring is the right size — it + // is drawn around the thing it is pointing at rather than around a column + // of mostly nothing. <div ref={ref} tabIndex={-1}> <Notice live={false}>{text}</Notice> </div> diff --git a/src/app/_components/confirm-submit.tsx b/src/app/_components/confirm-submit.tsx index 89a57348..287a1e53 100644 --- a/src/app/_components/confirm-submit.tsx +++ b/src/app/_components/confirm-submit.tsx @@ -246,7 +246,16 @@ export function ConfirmCost({ * `.btn--danger` red only on confirm. REVOKE was the one exception until the * design pass that followed #193 — it held full `--danger` at rest, which put * four saturated buttons in a four-row admin table and spent the alarm colour - * on a recoverable action. No caller now keeps the same class in both states. + * on a recoverable action. No caller that reaches for `.btn--danger` now holds + * it at rest — which is the claim that matters, and is narrower than the one + * this sentence used to make ("no caller now keeps the same class in both + * states"). That reading is false and always was: `armedClassName` is + * optional, and the callers that omit it keep one class by construction — + * `RemoveParticipantForm` (pay-flow.tsx), `LifecycleSubmit` + * (lifecycle-submit.tsx), and `MarkPaidForm` whenever `arm` is false, plus + * grant-admin and the tier buttons on /admin/accounts. Those are all + * non-destructive or already-quiet controls with nothing to upgrade *to*, so + * their staying put is the design working, not an exception to it. * * Width is reserved for the wider of the two labels so the swap never * changes the button's own size and reflows the row it sits in — the same @@ -288,10 +297,14 @@ export function ConfirmCost({ * every character has the same advance and letter-spacing gap, so the longer * string is always the wider one, and comparing lengths to choose it is a * safe shortcut — unlike using the length to *compute* a width, which is what - * broke before. `pendingLabel` is a `ReactNode`, not a string, so it can't - * feed `attr()` and isn't part of this reservation; none of the twelve call - * sites in `src/app/` pass it today, so nothing regresses, but a future - * caller that does would want its own fix here. + * broke before. `pendingLabel` is a `ReactNode`, so it can only join that + * comparison when a caller passes a plain string — which the payout page's + * eight controls now do, and which is why the reservation reads all three + * candidates rather than the two it started with. A caller passing an element + * gets the old two-way reservation and is responsible for its own width; that + * is a narrower gap than the one that existed when nothing passed a pending + * label at all, and it is the honest limit of a technique that has to feed + * `attr()` a string. * * `confirm` defaults to true but can be set false to make this render as a * plain one-click submit instead — no arm step, no live region text, ever. @@ -428,7 +441,16 @@ export function ConfirmSubmit({ // See the block comment above: the longer string, in a true monospace // face, is always the wider one, so `.length` is a safe way to choose // which label the ghost renders — it is not used to compute a width. - const ghostLabel = label.length >= confirmLabel.length ? label : confirmLabel; + // `pendingLabel` joins the comparison only when it is a string, because + // `attr()` has nothing to do with an element; a caller passing a node is + // reserving for two labels out of three and owns the consequence. + const ghostLabel = [ + label, + confirmLabel, + typeof pendingLabel === "string" ? pendingLabel : "", + ].reduce((widest, candidate) => + candidate.length > widest.length ? candidate : widest, + ); return ( <> diff --git a/src/app/_components/focus-heading.tsx b/src/app/_components/focus-heading.tsx index 124d6654..4e99a7ad 100644 --- a/src/app/_components/focus-heading.tsx +++ b/src/app/_components/focus-heading.tsx @@ -54,8 +54,29 @@ export function FocusHeading({ children }: { children: ReactNode }) { ref.current?.focus(); }, []); - // No focus ring appears: the global ring is `:focus-visible` (globals.css), - // which a programmatic focus on a non-input element does not match. + // A focus ring DOES appear. This comment used to claim the opposite — that + // the global `:focus-visible` ring (globals.css:288-293) cannot match a + // programmatic focus on a non-input element — and that is simply not how + // Chromium's heuristic works: measured here, `h1.matches(":focus-visible")` + // is `true` immediately after the effect above runs, and a 2px solid gold + // outline is painted. The belief had also been copied into + // `confirm-notice.tsx`, so both are corrected. + // + // The ring is kept, not suppressed. It is the only visible confirmation that + // focus moved, and on a soft navigation into a boundary that move is the + // whole point of this component — a sighted keyboard user gets the same + // information the screen-reader user gets from the heading announcing itself. + // Removing it would leave focus somewhere with nothing marking it. + // + // What was wrong was its *size*. An `h1` is a block, so the ring spanned the + // full column: measured at 912px around 377px of text, a page-wide gold band + // that reads as decoration rather than as "focus is here". + // `h1[tabindex="-1"] { width: fit-content }` (globals.css) hugs the text + // instead. Applied unconditionally rather than on `:focus` so nothing shifts + // at the moment focus lands, and it costs no wrapping: `fit-content` is + // `min(max-content, max(min-content, available))`, so at any width narrow + // enough for the heading to wrap it still resolves to the available width and + // `text-wrap: balance` behaves exactly as before. return ( <h1 ref={ref} tabIndex={-1}> {children} diff --git a/src/app/_components/nav-items.ts b/src/app/_components/nav-items.ts index 21f19fc8..bd04a2e9 100644 --- a/src/app/_components/nav-items.ts +++ b/src/app/_components/nav-items.ts @@ -57,9 +57,17 @@ import type { NavItem } from "./ui"; * thing the page's own H1 does and collides with nothing else in the bar. * * `navFor` is the rule. `navFromPath` is the same rule run with weaker - * evidence, for the three surfaces that cannot read a session at all - * (`error.tsx`, `not-found.tsx`, `payouts/[id]/not-found.tsx`) and have only - * the URL to go on. It is written as calls to `navFor`, not as a second + * evidence, for a surface that cannot read a session at all and has only the + * URL to go on. That is `error.tsx`, and only `error.tsx` — this used to say + * "the three surfaces", naming both `not-found.tsx` files alongside it, and + * neither of them calls this function: each passes a hardcoded `navFor(...)` + * literal instead (not-found.tsx:48, payouts/[id]/not-found.tsx:80), because a + * 404 knows its own route statically in a way a shared error boundary does not. + * The count mattered enough to correct: a reader trusting "three" would go + * looking for two call sites that have never existed, and anyone changing the + * rule here would believe they were changing three surfaces' behaviour. + * + * It is written as calls to `navFor`, not as a second * literal list, so that "the boundary is the same rule under weaker evidence" * is a fact about the code rather than a claim in a comment: * diff --git a/src/app/_components/submit-guard.ts b/src/app/_components/submit-guard.ts index 7c7d08a5..7ea8979e 100644 --- a/src/app/_components/submit-guard.ts +++ b/src/app/_components/submit-guard.ts @@ -7,10 +7,18 @@ import { useEffect, useRef, type MouseEvent } from "react"; * flight. * * `disabled` used to do this job, and it did it by destroying focus: disabling - * the element the member just pressed moves focus to `<body>`, and because - * every one of these actions ends in a server-action `redirect()` — a client - * navigation with no document load — there is nothing afterwards that puts it - * back. `error.tsx:275-278` already refuses `disabled` for exactly this reason. + * the element the member just pressed moves focus to `<body>`, and nothing + * afterwards puts it back. This used to say the reason was that "every one of + * these actions ends in a server-action `redirect()`" — a client navigation + * with no document load. Most do, but not all: `syncJobAction` + * (admin/sync/actions.ts:67) revalidates and returns, and it is driven by a + * `<Submit>` like the rest. The narrower claim is the true one, and it covers + * both shapes — neither a client-side `redirect()` nor a `revalidatePath()` is + * a focus restoration. A revalidated button re-renders in place with focus + * still on `<body>`, which is the same dead end by a different route. Stating + * it as "they all redirect" invited exactly the wrong repair: adding a + * redirect to the actions that lack one would not have helped. + * `error.tsx:275-278` already refuses `disabled` for this reason. * So the button keeps `aria-busy`, keeps focus, and stops a second submit here * instead. * diff --git a/src/app/_components/submit.tsx b/src/app/_components/submit.tsx index 1129db4a..aa5f75c7 100644 --- a/src/app/_components/submit.tsx +++ b/src/app/_components/submit.tsx @@ -20,6 +20,21 @@ import { useSubmitGuard } from "./submit-guard"; * prop is still honoured for the other meaning of the word: a control the call * site knows is unavailable before anyone presses it. * + * Not for a `<form method="get">`, and the mismatch runs both ways. Nothing it + * offers can arrive: a native GET submit is a document navigation, not a + * server action, so `useFormStatus()` reports `pending` false for the whole + * life of the document and the button's `aria-busy` is a promise fixed at + * "false". And the guard's latch, taken synchronously on the first click, is + * released only by watching `pending` go true and then false again — a + * transition that never happens here. In the ordinary path the document is + * replaced before that matters, but a document that outlives its own + * navigation (a stop press, a back into the bfcache) comes back with the latch + * still set and the button permanently refusing every press, with no visible + * trace. The three GET filter forms — `/admin/audit`, `/payouts`, + * `/admin/accounts` — use a plain `<button type="submit">` instead. They lose + * nothing: re-running a filter is idempotent, which is the whole reason the + * guard has no work to do there. + * * `className` defaults to `"btn"` rather than staying free-form or moving to * a closed `grade` union: real call sites stack a colour grade, a size * modifier and an occasional layout utility together in one string diff --git a/src/app/_components/ui.tsx b/src/app/_components/ui.tsx index 284ba73a..10cb22fe 100644 --- a/src/app/_components/ui.tsx +++ b/src/app/_components/ui.tsx @@ -325,7 +325,29 @@ export function Notice({ id={id} tabIndex={id ? -1 : undefined} > - {children} + {/* One flex item, always. `.notice` is `display: flex` so that the glyph + in `::before` can sit in its own column and the text can wrap beside + it rather than under it — but that makes every top-level child its own + flex item, and flex items are laid out in the row box independently of + the inline content they contain. A caller writing + `Quote {" "} <code>{digest}</code> {" "} when you report this` got the + `<code>` promoted to item 2 and the two text runs to items 3 and 4, so + the rendered order read "Quote 4292868890 . when you report this" with + the phrase broken around a number that belongs at the end. Visual order + diverging from DOM order is SC 1.3.2, and it is invisible in the source + — the JSX is in the right order and the browser is not. + + Wrapping here rather than at the call sites fixes every `Notice` in the + app carrying inline markup at once. Several callers had already worked + around it by hand with a `<span>` of their own; those now nest one span + in another, which costs nothing and is not worth unpicking one by one. + + The empty-slot branch shares this `<p>`, so it renders an empty span. + That is inert — `.notice-slot` is not a flex container and an empty + inline box draws nothing — and the slot's whole job is to be a live + region registered before its text arrives, which the span does not + change. */} + <span>{children}</span> </p> ); } diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index f51b5015..baa0bdfa 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -308,6 +308,11 @@ export default async function AccountPage({ // to say, not each row's. See `crewNorms`. const norms = crewNorms(view.characters); + // The closing artwork's frame, decided here because both the class and the + // `sizes` hint below have to agree about it — a `sizes` that outran the CSS + // would quietly undo the whole point of narrowing the frame. + const closingCompact = view.characters.length <= 1; + return ( <> <SiteHeader items={nav} current="/account" {...brandProps()} /> @@ -1376,21 +1381,41 @@ export default async function AccountPage({ </> )} - {/* The closing beat. Decorative, so alt is empty; drawn from a - 1120px asset cut for exactly this, never a scaled-down master. - A single-character account has little content above it, and the - full-size artwork dwarfed it; `.closing--compact` asks the same - asset for a smaller frame rather than cropping or downscaling it, - same technique the full size already uses, just a smaller target. + {/* The closing beat. Decorative, so alt is empty. A 1120px master + drawn at 420, or at 260 when `.closing--compact` applies: a + single-character account has little content above this, and at + full size the artwork dwarfed it. Oversampling is deliberate and + is what keeps it crisp at 2x — this comment used to say the asset + was "cut for exactly this, never a scaled-down master" and that + the compact variant asked for "a smaller frame rather than + downscaling", which described the mechanism backwards. It is a + downscale in both sizes, and that is the good direction. + PRODUCT.md's fifth principle now says so; it used to ban this. Moved into the rail by this pass (composition finding 3): the mission-patch identity used to be a mark in the header and an illustration parked at the very bottom of a long page: putting it beside the manifest instead makes it structural to the page's shape rather than a footer no one scrolls to. */} - <p - className={`closing${view.characters.length <= 1 ? " closing--compact" : ""}`} - > - <Image src="/brand/hero-account.webp" alt="" width={1120} height={711} /> + <p className={`closing${closingCompact ? " closing--compact" : ""}`}> + {/* `sizes` is what makes the oversampling above a choice rather + than an accident. Without it `next/image` assumes the image + spans the viewport and hands a phone the 1120px master for a + 260px frame — the largest asset on the page, fetched at four + times the width it is drawn at. The two values track + `.closing img` and `.closing--compact img` in globals.css; + the `100vw` branches cover the widths where the CSS `100%` + clamp, not the pixel cap, is the one deciding. */} + <Image + src="/brand/hero-account.webp" + alt="" + width={1120} + height={711} + sizes={ + closingCompact + ? "(max-width: 320px) 100vw, 260px" + : "(max-width: 480px) 100vw, 420px" + } + /> </p> </div> </div> diff --git a/src/app/admin/access-lists/page.tsx b/src/app/admin/access-lists/page.tsx index 29ce29bd..7bdb9d0d 100644 --- a/src/app/admin/access-lists/page.tsx +++ b/src/app/admin/access-lists/page.tsx @@ -38,6 +38,7 @@ import { rowHasDetail, rowSummary, rowTone, + sharedCorporation, showsObservations, type WatchedRow, } from "./view"; @@ -130,8 +131,35 @@ export default async function AdminAccessListsPage({ // client's first tick agree on what "now" meant when the page was built. const now = Date.now(); + // Whether the watched-lists region has anything to put in the page. Narrower + // than `showsObservations`, deliberately: that predicate asks "is there a + // stale answer worth showing", and its answer is yes for the three holder + // faults — correct when rows exist, and an empty promise when they do not. + // With no rows and nothing addable, the region is a heading over a notice + // saying the heading has nothing under it. + const showsRegion = + showsObservations(state) && (compared.length > 0 || addable.length > 0); + return ( - <main id="main" className="page page--wide"> + // `tabIndex={-1}` so the skip link lands here rather than merely scrolling + // here. A fragment link moves focus only to elements the platform already + // considers focusable, and `<main>` is not one — without it the viewport + // jumps and the caret stays in the header, so the next Tab walks back + // through the nav the member just skipped (SC 2.4.1). Ten of the app's + // eleven `id="main"` elements carry it; this was the one that didn't, + // because it landed after the sweep that added the rest. + // `page--wide` only once there is a table to be wide for. The fault states + // put one sentence and one link on the page, and a 78rem column turns that + // into a ribbon across an otherwise empty field — the measure the prose + // needs is the one `.page--narrow` sets. It caps CONTENTS rather than the + // page box, so the `h1`'s left edge and every rule's origin stay on the + // same vertical as the other admin routes either way; only the line length + // changes. + <main + id="main" + tabIndex={-1} + className={`page ${showsRegion ? "page--wide" : "page--narrow"}`} + > <div className="page__head"> <h1>Access lists</h1> <p className="page__lede">{monitorSentence(state)}</p> @@ -153,83 +181,111 @@ export default async function AdminAccessListsPage({ </Submit> </form> )} - <form action={checkNowAction}> - <Submit - className={remedy.kind === "check-now" ? "btn btn--primary" : "btn"} - pendingLabel="Queueing…" - > - Check now - </Submit> - </form> + {/* + Only where a check can actually read something. The button used to + render in every state, including the one a fresh deployment opens on: + no holder, so `runAccessListsJob` returns at its first branch having + read nothing (src/jobs/access-lists.ts:59-62) — and the admin was told + "Check queued at 09:41:22.418 UTC. Reload this page once the worker + has run." They reload to a byte-identical page, with no way to tell a + dead worker from a stuck queue from a feature that was never + configured. The confirmation was true about the enqueue and false + about everything the admin cared about. + + Gated on the remedy rather than on `showsObservations(state)`, which + is the wider predicate this page already uses for the table below. + The two differ on the three holder-fault states, and the job cannot + read in those either: a dropped scope returns at branch 2 + (`access-lists.ts:80-83`) and both token faults return `failed` + without a read (`:101-121`). `showsObservations` asks "is there a + stale answer worth showing" — true there, which is why the table + stays. This asks "can a check change anything", and the honest answer + in all three is no; the link beside it is the action that helps. + + Since the form now renders only when `remedy.kind === "check-now"`, + the class is unconditionally primary — it is the state's one remedy, + not a secondary sitting next to a link. + */} + {remedy.kind === "check-now" && ( + <form action={checkNowAction}> + <Submit className="btn btn--primary" pendingLabel="Queueing…"> + Check now + </Submit> + </form> + )} </div> {showsObservations(state) && ( <> - <RuleHead as="h2">Watched lists</RuleHead> + {showsRegion && ( + <> + <RuleHead as="h2">Watched lists</RuleHead> - {/* Its own class rather than `.btn-row`: same four declarations - today, but the two answer to different things — `.btn-row` is - the shape of a row of buttons, and a change to it should not - have to reason about a label bound to a control. No `aside` on - the `RuleHead` above either — `aside` is a metadata slot (every - other user in `src/app` carries a fact: an ISK total, a - "checked … UTC" stamp, a filter summary — never an action name), - so the affordance's name was rendering as inert prose at the - rule's trailing edge rather than owning a control. + {/* Its own class rather than `.btn-row`: same four declarations + today, but the two answer to different things — `.btn-row` is + the shape of a row of buttons, and a change to it should not + have to reason about a label bound to a control. No `aside` on + the `RuleHead` above either — `aside` is a metadata slot (every + other user in `src/app` carries a fact: an ISK total, a + "checked … UTC" stamp, a filter summary — never an action name), + so the affordance's name was rendering as inert prose at the + rule's trailing edge rather than owning a control. - Not `.btn--primary`: this form renders in four of the five - states `showsObservations` admits (every one but `catalog-empty`, - which by definition has nothing addable), and in the three - dark-monitor ones the gold is already spent on `monitorRemedy`'s - link — re-granting a dropped scope outranks adding a list when - nothing is being read at all. Rather than paint it gold in - `normal` and plain elsewhere, which would make the emphasis a - function of an unrelated fault, it stays plain everywhere and - earns its place by sitting directly under the section it adds - to. */} - {addable.length > 0 && ( - <form action={addWatchAction} className="acl-add"> - <label className="acl-add__label" htmlFor="add-list"> - Catalog - </label> - <select - id="add-list" - name="accessListId" - className="field" - defaultValue="" - required - > - {/* A real option for the default, not an absent one: every - option below comes from `addable`, so with no placeholder - the browser's ask-for-reset step selected the first list in - the catalog and an untouched submit added a list the admin - never chose, which the redirect then confirmed as a - deliberate act. + Not `.btn--primary`: this form renders in four of the five + states `showsObservations` admits (every one but `catalog-empty`, + which by definition has nothing addable), and in the three + dark-monitor ones the gold is already spent on `monitorRemedy`'s + link — re-granting a dropped scope outranks adding a list when + nothing is being read at all. Rather than paint it gold in + `normal` and plain elsewhere, which would make the emphasis a + function of an unrelated fault, it stays plain everywhere and + earns its place by sitting directly under the section it adds + to. */} + {addable.length > 0 && ( + <form action={addWatchAction} className="acl-add"> + <label className="acl-add__label" htmlFor="add-list"> + Catalog + </label> + <select + id="add-list" + name="accessListId" + className="field" + defaultValue="" + required + > + {/* A real option for the default, not an absent one: every + option below comes from `addable`, so with no placeholder + the browser's ask-for-reset step selected the first list in + the catalog and an untouched submit added a list the admin + never chose, which the redirect then confirmed as a + deliberate act. - `required` is what makes the placeholder a guard rather - than a label. `disabled` alone only stops the option being - re-chosen; it does NOT stop the form submitting, and a - disabled selected option contributes no entry at all — so - an untouched submit sent no `accessListId`, `parseId` threw - `invalid_id` on the resulting `null`, and an ordinary - mis-click landed on the "Something broke" boundary - (measured: the form's entry list held only `$ACTION_ID_…`). - With `required` the browser refuses the submit and points - at the field, and `parseId` goes back to being what its own - docblock says it is — the backstop for a hand-crafted POST, - not the only thing between a mis-click and the error - page. */} - <option value="" disabled> - Choose a list… - </option> - {addable.map((c) => ( - <option key={c.accessListId} value={c.accessListId}> - {c.name} - </option> - ))} - </select> - <Submit pendingLabel="Adding…">Add to watchlist</Submit> - </form> + `required` is what makes the placeholder a guard rather + than a label. `disabled` alone only stops the option being + re-chosen; it does NOT stop the form submitting, and a + disabled selected option contributes no entry at all — so + an untouched submit sent no `accessListId`, `parseId` threw + `invalid_id` on the resulting `null`, and an ordinary + mis-click landed on the "Something broke" boundary + (measured: the form's entry list held only `$ACTION_ID_…`). + With `required` the browser refuses the submit and points + at the field, and `parseId` goes back to being what its own + docblock says it is — the backstop for a hand-crafted POST, + not the only thing between a mis-click and the error + page. */} + <option value="" disabled> + Choose a list… + </option> + {addable.map((c) => ( + <option key={c.accessListId} value={c.accessListId}> + {c.name} + </option> + ))} + </select> + <Submit pendingLabel="Adding…">Add to watchlist</Submit> + </form> + )} + </> )} {/* One `ConfirmingForm` for the whole region, not one per row: both @@ -242,11 +298,20 @@ export default async function AdminAccessListsPage({ survive `compared` shrinking to zero. Each row's "Stop watching" is a plain submit button carrying its own `accessListId`, not a form of its own — the button's job ends at the press, and - submitting a shared form by name/value is ordinary HTML. */} + submitting a shared form by name/value is ordinary HTML. + + Which is also why this pair sits OUTSIDE the `showsRegion` gate + above while everything visible sits inside it. Removing the last + watched list can empty `compared` and `addable` in the same + commit — a list dropped from the catalog is watched but not + addable — so gating the pair on `showsRegion` would unmount the + reporter on exactly the press it exists to report. Empty, it + renders a `<form>` with nothing in it and costs the page + nothing. */} <ConfirmGroup> <ConfirmingForm action={removeWatchAction}> {compared.length === 0 ? ( - <Notice>No lists are being watched yet.</Notice> + showsRegion && <Notice>No lists are being watched yet.</Notice> ) : ( <ul className="acl-list"> {compared.map((c) => { @@ -298,7 +363,35 @@ export default async function AdminAccessListsPage({ } return ( <li key={c.accessListId} className="acl-list__row"> - <Disclosure summary={head} className="acl-list__disc"> + <Disclosure + summary={head} + className="acl-list__disc" + // A pre-built name, because the computed one would not + // hold still. The summary's accessible name is derived + // from its contents, and one of those contents is + // `RelativeTime` — a client component on a shared 30s + // ticker. So the control renamed itself twice a minute + // ("Alliance ACL 3 to add, 2 minutes ago" → "… 3 + // minutes ago") without any state having changed. That + // is SC 4.1.2 in the announcement channel, where a + // screen reader re-reads a control whose name it + // notices has changed, and SC 3.2.4 for a voice user, + // whose "click Alliance ACL 3 to add 2 minutes ago" + // stops matching whatever the page now calls it. + // + // Starts with the visible name, so 2.5.3 label-in-name + // still matches on the word a voice user would say, + // and restates the row's status after it — an + // `aria-label` on a summary replaces the computed name + // outright, so anything stable that the contents were + // contributing has to be put back by hand or it leaves + // the assistive channel entirely (R4). Everything here + // is server-computed and holds still for the life of + // the render. The one thing deliberately not restated + // is the timestamp, which is the whole reason this + // prop is here. + ariaLabel={`${label} ${rowSummary(row)} — findings and controls`} + > <AccessListDetail detail={c.detail} readStatus={c.readStatus} @@ -324,10 +417,13 @@ export default async function AdminAccessListsPage({ * Names lead and ids are secondary throughout: the admin retypes these in-game, * where the id is not what the client accepts. * - * Broad grants always carry the "plus an unknown number of others" clause. We - * store a corporation per character and hold no corp or alliance roster, so the - * covered-member count is OUR members only — the page must never imply a - * corp-granted list is fully accounted for. + * Broad grants carry the "plus an unknown number of others" clause once, above + * the list rather than on every line. We store a corporation per character and + * hold no corp or alliance roster, so the covered-member count is OUR members + * only — the page must never imply a corp-granted list is fully accounted for. + * Stating it per line said the identical sentence up to N times to make one + * point about how the count is computed, which is a property of the counting + * and not of any particular grant. */ export function AccessListDetail({ detail, @@ -340,6 +436,9 @@ export function AccessListDetail({ comparison: AccessListComparison; names: Map<number, string>; }) { + // See `sharedCorporation`. Null means the rows genuinely differ, and only + // then is a Corporation column earning its width. + const missingCorp = sharedCorporation(comparison.missingAccess); return ( <div className="acl-detail"> {readStatus !== null && readStatus !== "ok" && ( @@ -353,28 +452,41 @@ export function AccessListDetail({ {comparison.missingAccess.length > 0 && ( <> <RuleHead as="h3">Missing access ({comparison.missingAccess.length})</RuleHead> - <Scroller label="Members missing access"> - <table> - <thead> - <tr> - <th scope="col">Character</th> - <th scope="col">Corporation</th> - </tr> - </thead> - <tbody> - {comparison.missingAccess.map((m) => ( - <tr key={m.characterId}> - <td>{m.name}</td> - <td> - {m.corporationId === null - ? "—" - : (names.get(m.corporationId) ?? `#${m.corporationId}`)} - </td> + {missingCorp === null ? ( + <Scroller label="Members missing access"> + <table> + <thead> + <tr> + <th scope="col">Character</th> + <th scope="col">Corporation</th> </tr> + </thead> + <tbody> + {comparison.missingAccess.map((m) => ( + <tr key={m.characterId}> + <td>{m.name}</td> + <td> + {m.corporationId === null + ? "—" + : (names.get(m.corporationId) ?? `#${m.corporationId}`)} + </td> + </tr> + ))} + </tbody> + </table> + </Scroller> + ) : ( + <> + <p className="acl-detail__norm"> + All of them are in {names.get(missingCorp) ?? `#${missingCorp}`}. + </p> + <ul className="acl-detail__names"> + {comparison.missingAccess.map((m) => ( + <li key={m.characterId}>{m.name}</li> ))} - </tbody> - </table> - </Scroller> + </ul> + </> + )} </> )} @@ -394,6 +506,10 @@ export function AccessListDetail({ {comparison.broadGrants.length > 0 && ( <> <RuleHead as="h3">Broad grants ({comparison.broadGrants.length})</RuleHead> + <p className="table-note"> + Counts are our members only — each grant covers those, plus an unknown number + of others. + </p> <ul className="acl-detail__names"> {comparison.broadGrants.map((g) => ( <li key={`${g.kind}:${g.entityId ?? "all"}`}> @@ -405,7 +521,7 @@ export function AccessListDetail({ : (names.get(g.entityId) ?? `#${g.entityId}`) }`} {" — covers "} - {g.coveredMembers} of our members, plus an unknown number of others + {g.coveredMembers} of our members </li> ))} </ul> diff --git a/src/app/admin/access-lists/view.ts b/src/app/admin/access-lists/view.ts index d59a4544..e1b210ed 100644 --- a/src/app/admin/access-lists/view.ts +++ b/src/app/admin/access-lists/view.ts @@ -177,6 +177,30 @@ export function showsObservations(state: MonitorState): boolean { return state.kind !== "grant-needed" && state.kind !== "designate-needed"; } +/** + * The one corporation shared by every member missing access, or null when they + * do not share one. + * + * Same shape and same argument as `crewNorms` in `src/app/account/page.tsx`: + * the Corporation column exists to tell rows apart, and on the common case — + * one corp's members left off an alliance list — it tells them apart not at + * all while charging every row for the repetition. Measure the norm against + * the set, state it once above the list, and let the rows carry only what + * differs. + * + * A single unknown corporation (`corporationId === null`) defeats the norm + * rather than being folded into it. The sentence this feeds says "all of + * them", and it has to be true of all of them; a row we cannot place is + * exactly the row that sentence would be lying about. + */ +export function sharedCorporation( + rows: { corporationId: number | null }[], +): number | null { + const first = rows[0]?.corporationId ?? null; + if (first === null) return null; + return rows.every((r) => r.corporationId === first) ? first : null; +} + export type WatchedRow = { accessListId: number; name: string | null; diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx index f622598a..dcdb4533 100644 --- a/src/app/admin/accounts/page.tsx +++ b/src/app/admin/accounts/page.tsx @@ -383,8 +383,13 @@ export default async function AdminAccountsPage({ row, and the tier and status chips beside it (`:338` onward) are already bare `.btn`. `clear` additionally needs the `.filters .btn--quiet` buy-back in globals.css — dropping - `--micro` alone leaves it at `.btn--quiet`'s own 1.75rem. */} - <Submit className="btn">Find</Submit> + `--micro` alone leaves it at `.btn--quiet`'s own 1.75rem. + + A plain button rather than `<Submit>`, because this form is + `method="get"` — see `submit.tsx`. */} + <button type="submit" className="btn"> + Find + </button> {q && ( <a className="btn btn--quiet" href={qs({ q: undefined })}> clear diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx index d5ead1bf..93778413 100644 --- a/src/app/admin/audit/page.tsx +++ b/src/app/admin/audit/page.tsx @@ -11,7 +11,6 @@ import { } from "@/services/audit"; import type { FilterResolution, ResolvedAuditRow } from "@/services/audit"; import { RuleHead, Json, Notice, Scroller } from "@/app/_components/ui"; -import { Submit } from "@/app/_components/submit"; import { formatAgo } from "@/app/_components/format-ago"; import { renderedAt } from "@/app/_components/utc-time"; import { summarizeDetails, isFailureAction } from "@/app/admin/audit/summarize"; @@ -19,15 +18,58 @@ import { tierLabel } from "@/app/_components/labels"; export const dynamic = "force-dynamic"; -export const metadata: Metadata = { - title: "Audit log", -}; +/** + * The title states which page of the log this is, because on this surface the + * title is the announcement. Every control that changes the result set here is + * a document load — the filter is a `<form method="get">` and both pagers are + * plain `<a href>` — so a screen reader's response to "Filter" or "Older" is + * to announce the new document by its title and stop. With a constant "Audit + * log" that announcement is byte-identical whether the press did something or + * nothing, and the only text that distinguishes page 1 from page 7 is an `<h2>` + * the admin now has to go and find. + * + * A live region cannot do this job, which is worth writing down because it is + * the obvious fix and it fails silently: `aria-live` announces *mutations* to a + * region that was already there, and a region that arrives with the document is + * never a mutation. It would test green under any assertion that checks the + * attribute is present. + * + * Deliberately coarse — filtered or not, paged or not. The exact filter values + * are the `<h2>` and the chips' job; a title reciting them would be read in + * full on every load, ahead of the thing the admin actually asked for. + */ +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ + actor?: string | string[]; + action?: string | string[]; + target?: string | string[]; + before?: string | string[]; + }>; +}): Promise<Metadata> { + const sp = await searchParams; + // The module's own `one`, not a local copy: a repeated param resolves + // last-wins here exactly as it does in the page body. A private helper that + // took the FIRST value disagreed with the body on `?before=a&before=b` -- + // the body paged on `b` while the title described `a` -- so the title + // described a page the admin was not on, which is the one thing the `paged` + // guard below exists to prevent. + const val = (v: string | string[] | undefined) => one(v)?.trim() ?? ""; + const filtered = val(sp.actor) !== "" || val(sp.action) !== "" || val(sp.target) !== ""; + // Same guard the page body applies to the cursor, for the same reason: a + // junk `before` is ignored there and must not make the title claim a page + // the admin is not on. + const paged = Number.isFinite(Number(val(sp.before))) && val(sp.before) !== ""; + const qualifier = [filtered && "filtered", paged && "older"].filter(Boolean).join(", "); + return { title: qualifier === "" ? "Audit log" : `Audit log — ${qualifier}` }; +} /** id linking the action filter's `list` attribute to its `<datalist>` — * see `add-participant-form.tsx`'s `CHARACTER_LIST_ID` for the precedent this * follows: an `<option>` per namespace, no children. The datalist itself needs * no client JS — the browser does the filtering — so it costs this page - * nothing to render it server-side. (The page is not JS-free: `Submit` is a + * nothing to render it server-side. (The page is not JS-free: `Scroller` is a * client component. The datalist just isn't why.) */ const ACTION_NAMESPACE_LIST_ID = "action-namespaces"; @@ -36,6 +78,39 @@ function stamp(d: Date): string { return d.toISOString().replace("T", " ").slice(0, 19); } +/** Just the clock, for rows whose calendar day is stated once above the table. */ +function clock(d: Date): string { + return d.toISOString().slice(11, 19); +} + +/** + * The calendar day every rendered row shares, or null when they span more than + * one. `crewNorms`' shape from `account/page.tsx`: measure the deviation + * against the set, state once what the whole set agrees on, and leave the rows + * carrying only what actually differs between them. + * + * Audit traffic arrives in bursts, and a full page is AUDIT_PAGE_SIZE rows, so + * the ordinary case is a column reading `2026-08-03 22:19:24`, `2026-08-03 + * 22:19:31`, `2026-08-03 22:19:31`, ... — eleven of nineteen characters + * restating a date, in the column that is pinned and therefore paints over + * whatever the horizontal scroll has brought alongside it. + * + * Two rows minimum: one row is not a set, and "All 1 entries on ..." states a + * norm over nothing while costing the reader the instant it replaced. + * + * Both channels lose exactly the same characters. `.only-wide` is a display + * toggle, so the trimmed span is what assistive tech reads at this width too, + * and the day is restored in the flow above the table rather than in a per-row + * `visually-hidden` — which would put the fact in one channel and not the + * other, which is the breach R4 exists to name. The narrow branch is untouched: + * it renders elapsed time and already carries its own full-instant restoration. + */ +function sharedDay(rows: ResolvedAuditRow[]): string | null { + if (rows.length < 2) return null; + const first = rows[0].at.toISOString().slice(0, 10); + return rows.every((r) => r.at.toISOString().slice(0, 10) === first) ? first : null; +} + /** Collapses a possibly-repeated query param to one value, last wins: a * duplicate arises in practice by appending `&actor=x` to a URL that already * has one, so the appended value is the intent. */ @@ -305,6 +380,7 @@ export default async function AdminAuditPage({ targetIds: idsOf(targetRes), beforeId: hasCursor ? beforeId : undefined, }); + const day = sharedDay(rows); // The active filters, cursor dropped. Shared by the pager (which then adds // its own `before`) and the past-the-end exit link (which must not), so the @@ -414,29 +490,38 @@ export default async function AdminAuditPage({ <a href={filterHrefBase}>Back to the latest entries</a> </> ) : filtered ? ( - // The actor/target asymmetry bites hardest here: a member appears as an - // actor only for what they did to their own account, so "no results" for - // an actor filter usually means the filter was pointed at the wrong - // column, not that the log is silent about that person. The nudge only - // appears when it can actually help -- when actor is set and target is - // not. - <> - Nothing matches this filter. - {params.actor && !params.target && ( - <> - {" "} - Members are usually the target of an entry, not the actor.{" "} - <a href={filterHref({ action: params.action }, "target", params.actor)}> - Search {params.actor} as a target - </a> - . - </> - )} - </> + "Nothing matches this filter." ) : ( "Nothing has happened yet." ); + // The actor/target asymmetry, said where it can still prevent the wrong + // answer rather than only where the wrong answer was harmless. + // + // A member reaches `actor` only for what they did to their own account; + // everything done TO them is written with `system` or an admin as actor and + // the member as `target`, and that is most of the log. This nudge used to + // live inside `emptyMessage`'s `filtered` branch, so it fired on the one + // outcome that already told the admin something was wrong -- zero rows -- + // and stayed silent on the outcome that does the damage: an actor filter + // that returns the member's four self-service entries, reads as a complete + // history, and is not one. Same sentence, hoisted to where both cases see + // it. + // + // Still gated on actor-set-and-target-unset, which is the only shape it can + // help: with both set the admin has already crossed the columns, and with + // neither there is nothing to re-point. + const actorNudge = + params.actor && !params.target ? ( + <p className="page__lede"> + Members are usually the target of an entry, not the actor.{" "} + <a href={filterHref({ action: params.action }, "target", params.actor)}> + Search {params.actor} as a target + </a> + . + </p> + ) : null; + return ( <main id="main" tabIndex={-1} className="page"> <div className="page__head"> @@ -523,8 +608,16 @@ export default async function AdminAuditPage({ <div className="filter-form__cell filter-form__cell--actions"> <div className="filter-form__actions"> {/* Filter is routine and reversible, not the page's primary act — - gold (btn--primary) is rationed for the one thing that is. */} - <Submit className="btn">Filter</Submit> + gold (btn--primary) is rationed for the one thing that is. + + A plain button rather than `<Submit>`, because this form is + `method="get"`: see `submit.tsx` for why that pairing is wrong + in both directions. Nothing about the rendered control changes + — `<Submit>` was passed the same `className` and its only other + output here was an `aria-busy` fixed at "false". */} + <button type="submit" className="btn"> + Filter + </button> {filtered && ( <a className="btn btn--quiet" href="/admin/audit"> clear @@ -550,6 +643,20 @@ export default async function AdminAuditPage({ <Notice tone="warn"> {ambiguityNotes.length > 0 ? ambiguityNotes.join(" · ") : null} </Notice> + {/* Under the count, above the rows: the sentence has to be readable + while the admin is looking at a result they believe, not only after + the page has already come up empty. Not a `Notice` — nothing here is + wrong, the filter is simply pointed at the column that answers a + different question, and a warn band would say otherwise. */} + {actorNudge} + {/* The day the whole page agrees on, said once so the pinned column does + not say it on every row. Same slot and same treatment as the actor + nudge above: a fact about the rows below, not a warning about them. */} + {day !== null && ( + <p className="page__lede"> + All {rows.length} entries on {day} (UTC). + </p> + )} {/* Also above the table. The bottom pager is roughly 300 tab stops past the top of a full page, so on a keyboard the only way to reach the next page was to traverse every link in every row. */} @@ -615,7 +722,9 @@ export default async function AdminAuditPage({ "as of HH:MM UTC", so the reading is dated in the same way the rest of the page is. */} <td className="mono nowrap"> - <span className="only-wide">{stamp(r.at)}</span> + <span className="only-wide"> + {day === null ? stamp(r.at) : clock(r.at)} + </span> <span className="only-narrow"> <time className="ago dim mono" dateTime={iso}> {formatAgo(iso, now)} diff --git a/src/app/admin/sync/page.tsx b/src/app/admin/sync/page.tsx index cd502ab9..eb7ccf55 100644 --- a/src/app/admin/sync/page.tsx +++ b/src/app/admin/sync/page.tsx @@ -334,7 +334,46 @@ export default async function AdminSyncPage({ <RuleHead as="h2" aside={ - <span className="btn-row__stamp">checked {utcHhmmss(renderedAt)} UTC</span> + <> + <span className="btn-row__stamp">checked {utcHhmmss(renderedAt)} UTC</span> + {/* Refresh lives beside the stamp it replaces, not in the control + row at the foot of the page. Down there it was an `<a href>` + with no pending state drawn identically to `Recheck invalid + affiliations`, which enqueues a job — two controls at the same + weight, 8px apart, one of which changes nothing and one of + which puts work on the queue. Nothing but the label separated + them. + + Differentiated by adjacency rather than by grade, because the + grade axis is closed here: `.btn--quiet` carries + `min-height: 1.75rem`, and DESIGN.md R1 scopes that 28px grade + by the reason for it — rows that each carry a control set and + are read many at a time. A single control in a section header + is not that. Gold is already spent on `Sync now` and a second + one would flatten the first. + + Adjacency is the stronger argument anyway: this control's + subject is the timestamp, not the queue. "Checked 14:02:11 UTC" + followed by the control that re-checks reads as one statement, + and an admin who wants a newer number now finds the control + while looking at the stale one instead of scrolling past seven + rows and however many open drawers. + + A plain anchor, not a router link: this page is the only thing + on screen that can answer "did the run land", and a soft + navigation to the URL you are already on is exactly the case a + client router is entitled to serve from its own cache. It drops + `?queued=` and `?at=` on the way, which is still worth having + now that the notice stamps itself: the canonical URL is the one + an admin leaves open, and it should not carry a press from an + hour ago at all. + + No polling behind it: an admin reading an expanded failed row + must not have the page move under them. */} + <a className="btn" href="/admin/sync"> + Refresh + </a> + </> } > {groups.length} job{groups.length === 1 ? "" : "s"} @@ -424,6 +463,37 @@ export default async function AdminSyncPage({ cadence ?? "on demand", ); const nextRun = nextRunFor(g.jobType, renderedAt); + // A pre-built accessible name for the toggle, because the + // computed one would not hold still. `<summary>`'s name comes + // from its contents, and one of those contents is + // `RelativeTime` — a client component on a shared 30s ticker — + // so this control renamed itself twice a minute with no state + // having changed. A screen reader re-announces a control whose + // name it sees change (SC 4.1.2), and a voice user's "click + // discord.sweep healthy 2 minutes ago" stops matching the page + // a minute later (SC 3.2.4). + // + // Every other piece is restated here rather than left to the + // contents, because `aria-label` replaces the computed name + // outright: anything not repeated leaves the assistive channel + // entirely, which is the R4 parity failure inverted. All of it + // is safe to freeze — `renderedAt` is one server-side instant + // for the whole render, so the queued age and the next-run time + // are as fixed as the cadence is. Only the "ago" is dropped, + // which is the entire reason this exists; the drawer under it + // carries every run's own timestamp. + // + // Assembled to match what the contents computed to, separator + // for separator: `queuedMarkerText` supplies its own leading + // comma, ` UTC` is the span `splitCadenceUtc` strips off the + // visible text, and the space before "next" is the one the + // `<br>` was contributing. The three `toHaveAccessibleName` + // cases in `e2e/sync.spec.ts` pin those joins. + const summaryName = + `${g.jobType} ${healthLabel(health)}` + + `${g.queued ? queuedMarkerText(g.queuedSince, renderedAt) : ""}` + + ` ${cadenceVisible}${hiddenUtc ? " UTC" : ""}` + + `${nextRun ? ` next ${utcHhmm(nextRun)}` : ""}`; const cols = countColumns(g.jobType, g.runs); const span = cols.length || 1; // Shared between the runs table below and the window @@ -434,6 +504,7 @@ export default async function AdminSyncPage({ <li key={g.jobType} className="strip__job"> <Disclosure className="strip__disc" + ariaLabel={summaryName} defaultOpen={needsAttention(health)} summary={ <> @@ -1121,22 +1192,10 @@ export default async function AdminSyncPage({ Recheck invalid affiliations </Submit> </form> - {/* A plain anchor, not a router link: this page is the only thing on - screen that can answer "did the run land", and a soft navigation to - the URL you are already on is exactly the case a client router is - entitled to serve from its own cache. It drops `?queued=` and - `?at=` on the way, which is still worth having even now that the - notice stamps itself: the canonical URL is the one an admin leaves - open, and it should not carry a press from an hour ago at all. - - No polling behind it: an admin reading an expanded failed row must - not have the page move under them. The notice copy names the - browser reload rather than this control, because the notice renders - at the top of the page and this sits below seven rows and however - many open drawers. */} - <a className="btn" href="/admin/sync"> - Refresh - </a> + {/* Refresh used to sit here as a third peer. It moved up beside the + "checked …" stamp in the strip's section header — see the docblock + there. What is left in this row is exactly the two controls that + put work on the queue, which is what the row is for. */} </div> </main> ); diff --git a/src/app/admin/sync/view.ts b/src/app/admin/sync/view.ts index 1c8c97df..e3ffca07 100644 --- a/src/app/admin/sync/view.ts +++ b/src/app/admin/sync/view.ts @@ -350,12 +350,19 @@ export function queuedStamp(at: string | undefined): string | null { * counter, not on this string, so a second press of "Re-run wanderer" * producing the identical sentence still moves focus again. * - * "reload this page", not "use Refresh": Refresh is the last control below - * seven job rows and however many open drawers, while this text renders at the - * top of the page, and nothing in the copy said which direction to go. The - * browser reload is what an admin reaches for anyway, and naming it costs the - * page nothing — the anchor's `?queued=`-dropping behaviour still matters, but - * only for the canonical URL, which a reload of *this* URL is not. + * "reload this page", not "use Refresh". The original reason was distance — + * Refresh sat at the foot of the page below seven job rows and however many + * open drawers, while this text renders at the top — and that reason is gone: + * Refresh now sits beside the "checked …" stamp in the strip's section header, + * a few lines below this notice. + * + * The copy stays as it is on the surviving reason. A browser reload is what an + * admin reaches for regardless, it needs no lookup of what a named control + * does, and it is the one gesture guaranteed to work whether or not the + * anchor rendered. Naming the control instead would trade a universal + * instruction for a page-specific one and buy nothing: the anchor's + * `?queued=`-dropping behaviour still matters, but only for the canonical URL, + * which a reload of *this* URL is not. */ export function queuedNotice( queued: string | undefined, diff --git a/src/app/error.tsx b/src/app/error.tsx index 0c82f071..cb963230 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState, useTransition } from "react"; -import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState, useTransition } from "react"; +import { usePathname, useRouter } from "next/navigation"; import { FocusHeading } from "@/app/_components/focus-heading"; import { utcHhmm } from "@/app/_components/utc-time"; import { @@ -111,6 +111,7 @@ export default function Error({ reset: () => void; }) { const pathname = usePathname(); + const router = useRouter(); const section = sectionFor(pathname); // A client component cannot read config; the root layout's provider carries // the values down. `useBrand()` falls back to the generic defaults rather @@ -119,6 +120,61 @@ export default function Error({ const brand = useBrand(); const [retrying, startRetry] = useTransition(); + /** + * A retry that WORKS was, until now, the one outcome this page did not + * announce. `reset()` unmounts this boundary and the recovered page renders + * in its place — so the button holding focus disappears, focus falls to + * `<body>`, the URL does not change, and no boundary is left to run an + * effect. A screen-reader user pressed a button and heard nothing, on the one + * press that actually succeeded. The failing path was already covered (the + * remount re-runs `FocusHeading`; e2e/error-boundary.spec.ts pins it), which + * is what made the gap easy to miss: the louder half was the one handled. + * + * Nothing inside this component can act after it unmounts, so the handoff is + * observed rather than scheduled — no rAF count, no timeout guess about when + * React commits. Watch for THIS main leaving the document, then focus + * whatever now answers to `#main`, which is the skip link's own target and so + * exactly where a member skipping to content would have landed. + * + * `data-error-boundary` is what keeps this off the failing path. A failed + * retry also removes this main and mounts another one carrying the same + * `id="main"`, and focusing that would race `FocusHeading` for the h1 and + * make the existing announcement nondeterministic. The attribute makes the + * two cases distinguishable at the moment of the swap: marked means the + * boundary came back and its own focus effect owns the announcement. + */ + const mainRef = useRef<HTMLElement>(null); + function retry() { + const leaving = mainRef.current; + if (leaving) { + const observer = new MutationObserver(() => { + if (leaving.isConnected) return; + observer.disconnect(); + const arrived = document.getElementById("main"); + if (arrived && !arrived.hasAttribute("data-error-boundary")) arrived.focus(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + // Bounded so a retry that never resolves cannot leave an observer running + // over the rest of the session. Ten seconds is far longer than the swap + // and far shorter than a member's patience. + window.setTimeout(() => observer.disconnect(), 10_000); + } + startRetry(() => { + // `router.refresh()` BEFORE `reset()`, and the button does not work + // without it. Measured, on `/payouts` with its list query broken and then + // repaired before the press: `reset()` alone re-rendered the boundary and + // the boundary came back, every time. It re-runs the segment from the + // client router cache, and that cache is still holding the payload that + // failed — so for a server-side throw, which this boundary's own doc + // names as the case it exists for, "Try again" could never have recovered + // anything. It was reliably a no-op dressed as the page's only action. + // `refresh()` is what discards that cache entry and refetches; `reset()` + // then tears down the boundary so the refetched segment can render. + router.refresh(); + reset(); + }); + } + // Set after mount rather than during render. This boundary renders on the // server for a server-side throw, and a clock read in the render body would // be a hydration mismatch on every one of those. `null` until the effect @@ -159,7 +215,15 @@ export default function Error({ brandTagline={brand.tagline} brandMarkUrl={brand.markUrl} /> - <main id="main" tabIndex={-1} className="page page--narrow"> + <main + id="main" + tabIndex={-1} + className="page page--narrow" + ref={mainRef} + // Read at swap time by `retry()` above, to tell "the boundary came + // back" from "the page recovered" when both mains share an id. + data-error-boundary="" + > <div className="page__head"> {/* Same mechanism and the same reason as the two 404 boundaries: the subtree is swapped in place with no document load, the control @@ -263,6 +327,34 @@ export default function Error({ </pre> <div className="btn-row"> + {/* The escape route first, and that ordering is the whole of this + change. Both controls are the plain grade and 8px apart, so + nothing but position separates them — and the one that sat first, + in the position a reader takes as the offered answer, was the one + the lede directly above had just warned about. "Your action may + already have taken effect; check before sending it again", and + then, as the first and visually equal choice, the control that + sends it again. + + Differentiated downward rather than upward: gold is the page's one + emphasis ration and a boundary has no action it can recommend, so + spending it on either control would be a claim this page cannot + make. The obvious downward move — `.btn--quiet` on Try again — is + wrong for a different reason: that class carries + `min-height: 1.75rem`, and DESIGN.md R1 scopes the 28px grade by + the reason for it, to rows that carry a control set and are read + many at a time. Two buttons read once are not that. Order is the + one axis here that is free. + + This also puts the safe control first in the tab order, which is + the same argument in the keyboard channel. + + Stays an `<a href>`, not a `<Link>`: a full document load is the + one escape guaranteed to work from a client tree that has already + thrown once, and it is the arrival the browser announces itself. */} + <a className="btn" href={section.back.href}> + Back to {section.back.label} + </a> {/* Plain grade, not `btn--primary`. The lede directly above warns that a submitted action may have taken effect and to check before sending it again — and pressing this is that second send. Gold is @@ -292,24 +384,9 @@ export default function Error({ is the thing that was missing. The visual half is the busy state in flight; it is brief against a local failure and grows with the round trip, which is the right way round. */} - <button - type="button" - className="btn" - aria-busy={retrying} - onClick={() => - startRetry(() => { - reset(); - }) - } - > + <button type="button" className="btn" aria-busy={retrying} onClick={retry}> {retrying ? "Trying…" : "Try again"} </button> - {/* Stays an `<a href>`, not a `<Link>`: a full document load is the - one escape guaranteed to work from a client tree that has already - thrown once, and it is the arrival the browser announces itself. */} - <a className="btn" href={section.back.href}> - Back to {section.back.label} - </a> </div> </main> </> diff --git a/src/app/globals.css b/src/app/globals.css index eeaba5a8..17d1fe74 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -15,7 +15,16 @@ feedback this palette answers. Warm ground also lets the seal's navy ring separate from the header bar by hue, which it could not do on navy. Measured, not asserted: every text token below clears 4.5:1 on all three - grounds, worst case 4.63 (--ink-faint on a hovered row). + grounds, worst case 4.61 (--ink-faint on a hovered row). + + Every ratio quoted in this file was re-measured on 2026-08-10 by painting + each token to a 1x1 canvas and reading the pixel back. That indirection is + the point: `getComputedStyle` hands these back as `oklch(...)`, still in + their authoring space, so a probe that parses the string reads L, a and b + as if they were r, g and b — which is a probe that reports `--ink`, a + near-white, as a dark maroon, and does it without failing. Rasterizing is + the only reading here that is actually sRGB. Several of the numbers below + were wrong before that pass; none of the arguments they support were. THE TINT SCALES WITH LIGHTNESS, and that is the whole rule here. It took three passes to get right, so the failed ones are worth recording. @@ -40,7 +49,7 @@ --hull: oklch(0.195 0.001 56); --hull-hi: oklch(0.245 0.002 54); --rule: oklch(0.33 0.004 55); - /* Lightness fixed by WCAG 1.4.11: 4.24 / 3.92 / 3.48 against void, hull and + /* Lightness fixed by WCAG 1.4.11: 4.23 / 3.90 / 3.47 against void, hull and hull-hi, so a control's edge is identifiable on every ground it can sit on. */ --rule-strong: oklch(0.56 0.008 58); @@ -165,6 +174,14 @@ --ease: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-quint */ --dur-color: 140ms; + /* No consumers. All ten `transition` declarations in this file animate a + colour, a border or an opacity, and every one takes --dur-color. The + transforms that exist are @keyframes (seal-settle 620ms, btn-pulse and + link-pending-pulse 900ms) and each names its own duration, since a settle + and a loop are not paced like a hover. Kept as the declared answer for the + first transform *transition*, so it picks from the scale rather than + inventing a number — but it is a reservation, not a description of the + shipped UI, and DESIGN.md's motion section says the same. */ --dur-move: 220ms; } @@ -292,6 +309,22 @@ a:hover { border-radius: 1px; } +/* The three boundary headings (`FocusHeading` — error.tsx and both + not-found.tsx) take focus on mount, which draws the ring above. An `h1` is a + block, so that ring spanned the whole column: measured at 912px around 377px + of text, which reads as a page-wide gold band rather than as a focus + indicator. This is the only selector that matches those three, since + `FocusHeading` is the app's only producer of a `tabindex="-1"` heading. + + Unconditional rather than `:focus`-scoped, so nothing shifts at the moment + focus lands. It costs no wrapping: `fit-content` resolves to + `min(max-content, max(min-content, available))`, so at any width narrow + enough for the heading to wrap it is still the available width, and + `text-wrap: balance` is unaffected. */ +h1[tabindex="-1"] { + width: fit-content; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -369,10 +402,18 @@ a:hover { and these per-cell spans become the only thing naming the columns. Same job, so the same register — it carries only its own `letter-spacing` and `color` down there, the way every other member carries its own. It stays - `display: none` above the breakpoint because this list sets no `display`. */ + `display: none` above the breakpoint because this list sets no `display`. + + `.payouts__label` is the same idiom on `/payouts`, and it is a sibling class + rather than a reuse of `.crew__label` for the reason `.log--manifest` gives + for keeping its own copy of `.log--dense`'s padding: the two tables reflow at + the same breakpoint today, and a shared class quietly welds that together. + The register entry is the part worth sharing, and it is shared here. */ .rule-head__label, .log th, .crew__label, +.payouts__label, +.roster__label, .strip__name, .strip__head, .strip__group, @@ -384,6 +425,7 @@ a:hover { .launch__scopes-head, .shell__register, .acl-detail th, +.status-line__label, .shell__nav a { font-family: var(--font-mono), ui-monospace, monospace; font-size: var(--t-label); @@ -1017,8 +1059,11 @@ h3.rule-head__label { /* --rule-strong, not --rule. This border is the only thing telling a reader where the scrollable region begins and ends, which makes it a control boundary under WCAG 1.4.11 rather than decoration — and --rule measures - 1.76:1 on --void, 1.59:1 on --hull and 1.39:1 on --hull-hi. --rule-strong - clears 3:1 on every ground the frame can sit on (4.11 / 3.72 / 3.24). */ + 1.62:1 on --void, 1.50:1 on --hull and 1.33:1 on --hull-hi. --rule-strong + clears 3:1 on every ground the frame can sit on (4.23 / 3.90 / 3.47 — + these used to read 4.11 / 3.72 / 3.24 here while the token's own + definition claimed a third set, 4.24 / 3.92 / 3.48; all three sites now + quote the same measurement). */ border: 1px solid var(--rule-strong); border-radius: var(--radius); } @@ -1309,7 +1354,13 @@ h3.rule-head__label { Dropping columns below a breakpoint was the alternative and was rejected: it hides Total, Paid and Status on a phone, turning the ledger into a personal list and collapsing the very /payouts-vs-/account distinction R3 - draws. A pin hides nothing from anyone. */ + draws. A pin hides nothing from anyone. + + All of the above now describes the table from 30rem up only. Below that the + pin was measured at 24% of the region and the row reflows to labelled blocks + instead — see the `@media (max-width: 29.9375rem)` block at the foot of this + file, beside `.log--crew`'s. The budget figures here are unchanged and still + the reason a phone cannot have this as a table; what changed is the answer. */ .log--payouts th, .log--payouts td { padding-top: var(--s-2); @@ -1342,7 +1393,9 @@ h3.rule-head__label { name widens the column to 637px for EVERY row, since a table column is as wide as its widest cell: the pin then exceeds the region it anchors and `payouts.spec.ts` fails on the spot. That test holds the ratio under 60% at - both 320px and 390px, so the ceiling is asserted rather than assumed. + 560px, where the pin still runs. It used to assert at 320px and 390px, and + was moved when those widths stopped having a pin to measure — a `<td>` laid + out as a block has no scroll range and `maxScrollLeft > 0` cannot hold. No `scroll-margin-left` companion rule, unlike `.log--audit` and `.log--dense` above. That rule exists for a control in a non-first column @@ -1360,6 +1413,25 @@ h3.rule-head__label { padding-right: var(--s-5); } +/* The per-cell label the narrow media query on `.log--payouts` turns on, off + up here where the real `<thead>` already names the columns. Same + construction and same reasoning as `.crew__label`. The media block that + pairs with this one is not here but at the foot of the file, beside + `.log--crew`'s: `.log--sticky-col`'s `position: sticky` rule sits at equal + specificity further down, so an override written next to this rule would + lose the cascade and the first cell would still try to pin. */ +.payouts__label { + display: none; +} + +/* Same idiom again on the /payouts/[id] roster, which reflows at 40rem rather + than 30rem — the roster carries a row of controls the payouts list does not, + and runs out of width sooner. Only the Shares cell gets one; the block that + turns this on says why. */ +.roster__label { + display: none; +} + /* Ten columns of state per row: the standard cell padding pushes the accounts table past the container and clips the last action. Tighten it rather than drop a column, since every column here answers a real admin question. */ @@ -1394,8 +1466,8 @@ h3.rule-head__label { deliberately not `--hull-hi`: raising this row's ground to `--hull-hi` would change what `--ink-faint` sits on, and the `tr.drawer-row--actions > td` comment below records that pairing was chosen to clear WCAG 4.5:1 - (`--ink-faint` on `--hull` measures 5.58:1; on `--hull-hi` it drops to - 4.85:1). A layout change binding a row to its panel is not licence to + (`--ink-faint` on `--hull` measures 5.18:1; on `--hull-hi` it drops to + 4.61:1). A layout change binding a row to its panel is not licence to revisit that. Specificity, not luck: `.log tbody tr:not(:has(.log__empty)):hover` (below) @@ -1510,7 +1582,7 @@ h3.rule-head__label { background lives on its `<td>` and on the separate data row above, neither of which that selector matches. `--ink-faint` (`.manifest-panel__controls .btn--quiet`'s rest ink, and the status-line - labels this panel can carry) measures 5.58:1 against `--hull`, clear of + labels this panel can carry) measures 5.18:1 against `--hull`, clear of WCAG's 4.5:1 text floor — the same pairing `.log th` already uses for its own `--hull` background. Do not raise the shared ground to `--hull-hi` to make an open row "pop" more: that would change what `--ink-faint` sits on @@ -2018,7 +2090,7 @@ h3.rule-head__label { /* The pinned edge has to read as an edge once content is sliding under it, and a hairline is what this system draws edges with. --rule-strong rather than --rule for the same reason as .scroller: this line is the boundary of the - frozen region, so it carries meaning, and --rule's 1.39:1 against the hovered + frozen region, so it carries meaning, and --rule's 1.33:1 against the hovered row it most often sits on is not a line most people can see at 1px. */ .log--sticky-col th:first-child, .log--sticky-col tr:not(.drawer-row) > td:first-child:not(.log__empty) { @@ -2391,11 +2463,11 @@ h3.rule-head__label { display: contents; } +/* Register member, so the three shared properties come from the list above. + What stays here is what every other member also keeps for itself: its own + `letter-spacing` and `color`. */ .status-line__label { - font-family: var(--font-mono), ui-monospace, monospace; - font-size: var(--t-label); letter-spacing: var(--track-value); - text-transform: uppercase; color: var(--ink-faint); } @@ -2492,9 +2564,15 @@ h3.rule-head__label { A screen of green dots reporting that nothing needs doing is the generic-dashboard signature, and it spends the eye's attention on exactly the rows that have no claim on it. - --signal-ok is not deleted — it is still right where health is genuinely - the subject rather than the default (see .notice--ok) — but the default - `ok` token is now as quiet as what it reports. */ + --signal-ok is not deleted, but do not read that as "it is in use + somewhere else". It has no consumers at all, and the `.notice--ok` this + comment used to cite as the somewhere-else has never existed — an editor + who went looking for it to copy would find two comments naming it and no + rule. The token stays because the notice family has a visible gap where an + ok variant would go, and because --tier-alumni carries the same value: with + the token gone, the next ok notice reaches for a tier colour instead. + What matters here is only that the default `ok` token is now as quiet as + what it reports. */ .st--ok { color: var(--ink-dim); } @@ -2641,7 +2719,7 @@ h3.rule-head__label { .tier--lead { font-size: var(--t-data); padding: 0.3em 0.65em; - letter-spacing: 0.12em; + letter-spacing: var(--track-label); } /* Replaces a literal 🔒 emoji, which rendered in the vendor's own colour @@ -2693,7 +2771,7 @@ h3.rule-head__label { row to --hull-hi; the same 65% opacity measures 2.88:1 there, under the 3:1 DESIGN.md promises, right when an admin's pointer is on the row. An opacity fade moves with whatever ground it sits on, so set the - disabled colour explicitly instead: --ink-faint on --hull-hi is 4.85:1, + disabled colour explicitly instead: --ink-faint on --hull-hi is 4.61:1, clearing text AA rather than only the 3:1 floor, and it no longer depends on which ground the control happens to be resting on. Higher specificity than .btn--primary/.btn--danger (one class each), so @@ -2974,9 +3052,20 @@ h3.rule-head__label { justify-content: flex-end; } -/* A control row that follows the data it operates on rather than heading the - page. It carries the top margin the page__head would otherwise have given - it. */ +/* A control row that carries the top margin a `page__head` would otherwise + have given it. + + This used to describe its position as well — "follows the data it operates + on rather than heading the page" — and that is true of only one of its two + users. On /admin/sync it does sit below the job strip (page.tsx:1179), and + two docblocks over there reason from that placement. On + /admin/access-lists (page.tsx:146) it is the first thing under the H1 and + lede, above the data entirely, because the control it holds is a remedy for + a list that may not have rendered at all. + + The margin is the rule; the placement is the call site's. Stated the other + way round, the next person to reach for this class on a third page inherits + a positional claim the class does not enforce. */ .btn-row--controls { margin-top: var(--s-6); } @@ -3173,19 +3262,36 @@ h3.rule-head__label { /payouts/new that is `.form-panel`'s own 48px of top padding, so the panel opened with 96px of empty ground above the word OPERATION. - Two branches because the two forms that open with a header disagree about + Three branches because the two forms that open with a header disagree about what comes first in the DOM. `appraise-form.tsx` puts its `RuleHead` first outright, so `:first-child` catches it. `new-operation-form.tsx` puts the reserved `Notice` ahead of the header — that slot is out of flow, so the header is still the first thing anyone sees, but `:first-child` cannot know - that and would miss it. Only the always-mounted reserved slot needs the - second branch; a `Notice` rendered behind `&&` (as appraise-form's is) - isn't in the DOM to be skipped over in the first place. */ + that and would miss it. + + The third branch is the same slot once it has something to say. A populated + `Notice` renders `.notice`, not `.notice-slot`, so the branch above stops + matching at exactly the moment the form is being rejected: measured, the + header sat 0px below the slot while empty and 88px below it once populated. + Which is backwards — the one state where the error and the form it is about + need to read as one thing is the state that pushed them apart. */ .form-stack > .rule-head:first-child, -.form-stack > .notice-slot + .rule-head { +.form-stack > .notice-slot + .rule-head, +.form-stack > .notice + .rule-head { margin-top: 0; } +/* `.notice`'s own bottom margin is written for flow layout, where it is the + only thing separating the notice from what follows. Inside `.form-stack` the + grid's row gap already does that job, so the two add: 24px of margin on top + of 16px of gap put the notice 40px from the next field where every other + sibling pair in the form sits at 16px. All four of these forms mount their + `Notice` unconditionally, so all four had it — the header case above was just + the loudest, because `.rule-head`'s 48px stacked on as well. */ +.form-stack > .notice { + margin-bottom: 0; +} + /* One field: its label, its control, and optionally a hint below (the corp-share and appraise-location hints, both outside their `<label>` so they don't get folded into its accessible name). Tighter gap than the form's, so @@ -3600,6 +3706,21 @@ h3.rule-head__label { text-align: left; } +/* `.notice` carries `margin-bottom` and no `margin-top`, which is right + everywhere it follows a `.page__head` — and wrong here, where it follows the + emblem/title/motto stack directly and sat flush against the motto with 0px + between them. A returning member bounced back by an expired session met the + reason for it welded to the brand mark. + + Scoped to the child of `.launch__panel` rather than fixed on `.notice` + itself: that class is load-bearing on six other surfaces, all of which sit + below a head that already provides the space. `.notice-slot` is a different + class, so the empty reservation this page mounts unconditionally is + untouched and still draws nothing. */ +.launch__panel > .notice { + margin-top: var(--s-5); +} + /* No font-size override: this is prose, and DESIGN.md sets prose at --t-body. Matches .page__lede, which distinguishes secondary copy by colour alone. */ .launch__disclosure-note { @@ -3620,6 +3741,27 @@ h3.rule-head__label { margin-top: var(--s-2); } +/* The shared fact for the list below it: what every scope in this group has in + common, said once, so the rows can carry only what differs. See + `SCOPE_GROUPS` in `login/page.tsx`. + + Deliberately NOT a member of the label register above, though it sits + between two things that are (`.launch__scopes-head`, and `.launch__scopes + dt`'s mono treatment). The register is for fixed field names — a word or + two, uppercased, naming a slot. These are sentences, and sentences in forced + caps at `--track-furniture` are the thing this codebase keeps taking out. + Weight and colour are what separate it instead: `--ink`, the only full-value + ink in this block, against the `--ink-dim` of the descriptions and the + `--ink-faint` of the identifiers. That ordering is the point — the reader + deciding whether to grant should hit "the only thing authGD writes" before + any identifier, and it is now the brightest text in the disclosure. */ +.launch__scope-group { + margin-top: var(--s-5); + font-weight: 600; + color: var(--ink); + font-size: var(--t-caption); +} + /* Inverted from the original structure: dt is now the raw scope identifier (what a technical reader checks against EVE_SSO_SCOPES), dd is the sentence it buys (what everyone else needs to decide). The old order — one dt @@ -3644,8 +3786,15 @@ h3.rule-head__label { same grade as `.launch__disclosure-note` above it, so the sentence a member actually needs is not quieter than the identifier it explains. A scope the description map does not recognise (a fork's own EVE_SSO_SCOPES addition) - renders no dd at all rather than a placeholder — the identifier is still - shown, honestly, as the one thing known about it. */ + still gets a dd — `describeScope`'s default branch (login/page.tsx) says + that this deployment asks for the scope, that authGD has no description for + it, and to ask whoever runs it. This comment used to claim it "renders no dd + at all rather than a placeholder", which is both false and backwards about + which behaviour is honest: an empty row leaves a reader to guess whether the + scope is undescribed or harmless, while the default sentence tells them what + is actually known — that nobody here can vouch for it. The correction + matters because login/page.tsx points readers at this comment by name, so + the false claim was signposted rather than buried. */ .launch__scopes dd { margin-top: var(--s-1); font-size: var(--t-caption); @@ -3687,7 +3836,7 @@ h3.rule-head__label { border-top: 1px solid var(--rule); font-family: var(--font-mono), ui-monospace, monospace; font-size: 0.625rem; - letter-spacing: 0.12em; + letter-spacing: var(--track-label); text-transform: uppercase; color: var(--ink-faint); } @@ -3793,7 +3942,18 @@ h3.rule-head__label { display: flex; align-items: center; gap: var(--s-2); - min-height: 1.75rem; + /* R1's standalone grade, not the in-row one. The 28px grade is scoped by the + reason for it — rows carrying a control set, read many at a time — and + DESIGN.md states the consequence outright: "A disclosure drawer is not + in-row for this purpose and takes 36px." All four `.disc` call sites are + page-level sections of `/payouts/[id]` (`page.tsx:135`, `:844`, `:1165`, + `appraise-form.tsx:183`); nothing wearing this class sits in a table row. + + `PaymentHistory`'s "payments (N)" toggle is unaffected and should be: + `Disclosure` passes `className` through with no default, that call site + passes none, and it is styled by `.log summary` above — where it is + genuinely in-row and the 28px grade is the right one. */ + min-height: 2.25rem; } .disc > summary::-webkit-details-marker { @@ -4029,7 +4189,7 @@ h3.rule-head__label { margin: var(--s-6) 0 0; /* The strip's outer edge, like .scroller's, bounds a region of interactive rows rather than decorating one — so --rule-strong, which clears 3:1 on - --void, --hull and --hull-hi, instead of --rule's 1.39-1.76:1. */ + --void, --hull and --hull-hi, instead of --rule's 1.33-1.62:1. */ border: 1px solid var(--rule-strong); border-radius: var(--radius); } @@ -4403,6 +4563,33 @@ h3.rule-head__label { .strip__name { flex: 1 1 100%; } + + /* The housekeeping group's collapsed line is a sentence wearing a status + token's clothes, and `.st`'s `white-space: nowrap` is written for the + token: one uppercase word in a table cell, which must not break. Here the + same rule holds `2 jobs · discord.sweep overdue, wanderer.sweep overdue` + on one line in a region ~233px wide at 320, so the group's own summary — + the only place those member names appear while the group is collapsed — + leaves the panel. Not truncated: the names are the payload, and a group + that cannot say which of its jobs is overdue is a group there is no + reason to collapse. + + Scoped to this summary rather than relaxed on `.st`, which is correct + everywhere else it lands. + + `align-items: baseline` on the summary, replacing the base rule's + `center`: once the sentence occupies three lines, centring puts the `+` + marker halfway down the block instead of beside the line it opens. The + absolute-gutter treatment `.strip__disc > summary` takes above is not + needed here — that one exists because `.strip__name` claims a 100% + basis and strands the marker on a line of its own; nothing here does. */ + .strip__group-disc > summary { + align-items: baseline; + } + + .strip__group-disc > summary .st { + white-space: normal; + } } /* The row drawer. Set tier / cryo / note come first since the admin opened @@ -4573,7 +4760,10 @@ h3.rule-head__label { all — this one and `.pool-items` on the payout detail page. The rest hang off `.page` or a plain `<div>`, where `min-width: auto` and `0` are the same number, so a global rule would be inert on eight surfaces and load-bearing on - a ninth nobody measured. + a ninth nobody measured. (`.pool-items` was named here and left unfixed for + exactly one release; it now carries the same floor as + `grid-template-columns: minmax(0, 1fr)`, which bounds the track rather than + the item and reaches the Scroller in one rule instead of two.) Both floors are kept but are no longer reproducibly load-bearing, and that is measured, not assumed: finding 4.4 (`.log--crew` below) stops the crew @@ -4791,6 +4981,40 @@ h3.rule-head__label { gap: var(--s-2); } +/* The one fact every "missing access" row shared, lifted out of the table the + way `crewNorms` lifts the account manifest's — see `.crew-fact` above and + `sharedCorporation` in access-lists/view.ts. When the whole set sits in one + corporation, the Corporation column told the rows apart not at all and + charged each of them for the repetition; this says it once instead. + + Full `--ink`, deliberately not `.table-note`'s caption grade. This is the + data that column used to carry, and the detail panel's own rule is that + names lead because the admin retypes them in-game — a string you have to + read character by character does not belong at the de-emphasised weight + reserved for standing explanations. The broad-grants note directly below it + IS such an explanation, and does use `.table-note`. */ +.acl-detail__norm { + margin: 0 0 var(--s-3); + color: var(--ink); +} + +/* "Stop watching" is the only control inside a watched row, and `.btn--quiet` + strips the border that says so: at --ink-faint with no outline it lands on + exactly the weight of `.acl-list__head`'s labels and the rule heads around + it, so the one thing on the row a press can change reads as another caption. + Keep `.btn--quiet`'s restraint — no fill, no gold at rest, the 28px in-row + grade — and put back the outline, which is what separates a control from a + label here without spending a second gold or a colour token. + + Descendant rather than child: the button sits directly in the `<li>` on a + clean row and inside `Disclosure`'s drawer on a row that has one, and it + has to read the same in both. Two classes, so `.btn--quiet:hover` (a class + plus a pseudo-class plus `:not(:disabled)`) still escalates over it. */ +.acl-list__row .btn--quiet { + border-color: var(--rule); + color: var(--ink-dim); +} + /* --- Utility ------------------------------------------------------------ */ /* A `.field` plus a micro `Submit`, meant to sit inline with the prose around @@ -4869,6 +5093,13 @@ h3.rule-head__label { gap, grid's default stretch. */ .pool-items { display: grid; + /* The implicit track is `auto`, which sizes to the item table's intrinsic + width and lets the `.scroller` inside escape the grid entirely — taking + the edge fades and the region's keyboard tab stop with it, and widening + the document past the viewport. `.drawer__crew` above is the other of the + two at-risk Scroller parents the comment at `.scroller-frame` names; it + got its floor and this one did not. */ + grid-template-columns: minmax(0, 1fr); gap: var(--s-2); } @@ -4941,8 +5172,10 @@ h3.rule-head__label { color: var(--ink-faint); } -/* The closing beat: the lander, drawn at the size it was cut for. Nothing - below it, so the page ends on the artwork rather than on a control. */ +/* The closing beat: the lander, drawn well under its master's 1120px so it has + pixels to spare at 2x. Nothing below it, so the page ends on the artwork + rather than on a control. (This used to say "drawn at the size it was cut + for", which it is not and should not be — see PRODUCT.md's fifth principle.) */ .closing { margin: var(--s-8) 0 0; } @@ -5486,3 +5719,154 @@ h3.rule-head__label { color: var(--ink-faint); } } + +/* `.log--payouts` reflows at the same breakpoint, for the reason the block + above spends its docblock establishing — the argument for hiding the + `<thead>` rather than dropping columns, and for a real element over + `content: attr(data-label)`, is there and is not repeated here. What is + specific to this table is why it needed the treatment at all. + + The pin defeats itself down here. `.log--payouts td:first-child`'s comment + records the pinned Name column at 69px of a 286px region at 320px — 24% — + which is a column too narrow to read an operation name out of and still the + only thing the reader keeps while panning right across five more. The 60% + ceiling `payouts.spec.ts` asserts is a ceiling with no floor, so 24% passed + it exactly as 55% would have. And `.log--crew`'s own comment cites this + table as the precedent for reflowing; the precedent had not actually done + it. This is that debt paid back. + + Nothing is dropped, which is the property that kept the pin through two + earlier passes: all six columns still render, so the corp-wide-ledger + distinction ruling R3 draws between `/payouts` and `/account` survives. The + row runs down instead of across, which is the one axis a phone has spare. */ +@media (max-width: 29.9375rem) { + .log--payouts thead { + display: none; + } + + .log--payouts tbody tr { + display: grid; + gap: var(--s-2); + padding: var(--s-3) 0; + border-top: 1px solid var(--rule); + } + + .log--payouts tbody tr:first-child { + border-top: none; + } + + /* The second selector is `.log--sticky-col`'s own, repeated verbatim so it + matches at equal specificity and wins on source order — this file's + sticky-column rules sit ~3500 lines above and would otherwise keep + pinning a cell that is now block-level, against a table with no scroll + range left to pin it out of. `border-right` is the frozen region's edge + and there is no frozen region here. */ + .log--payouts td, + .log--payouts tbody tr:not(.drawer-row) > td:first-child:not(.log__empty) { + display: block; + position: static; + padding: 0; + border-top: none; + border-right: none; + background-color: transparent; + } + + .payouts__label { + display: block; + letter-spacing: var(--track-label); + color: var(--ink-faint); + } +} + +/* The roster on /payouts/[id], below 40rem. Measured at 390px: 253px of a + 339px region on Shares and Amount, which pushes State and every row control + off the right edge. A member's whole reason to open this page is "was I + paid?", and State is the cell that answers it — so the roster's most + important column is the one a phone never shows, while two numeric columns + the reader mostly scans keep the room. + + Reflowed rather than pinned, same as `.log--payouts` above, and rather than + dropped, same as everything else in this file. What differs is the shape: + this table's five cells do not want to be a flat stack, because Name and + State are one question and answer and Shares and Amount are the arithmetic + behind it. So the row becomes a small grid with the pairs on their own + lines, and the action cell spans the foot where a row of buttons has the + full width it was designed for. + + The areas are assigned by `nth-child` against DOM order (Name, Shares, + Amount, State, actions) — the DOM order is the reading order the desktop + table has and the accessibility tree keeps, and only the visual placement + changes. `grid-template-areas` is what makes State climb to line 1 without + moving a node, which is the whole point: R4 wants both channels populated, + not a second copy of the state written into the name cell. */ +@media (max-width: 40rem) { + .log--roster thead { + display: none; + } + + .log--roster tbody tr { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "name state" + "shares amount" + "act act"; + gap: var(--s-2) var(--s-3); + padding: var(--s-3) 0; + border-top: 1px solid var(--rule); + } + + .log--roster tbody tr:first-child { + border-top: none; + } + + /* The second selector is `.log--sticky-col`'s own, repeated verbatim so it + matches at equal specificity and wins on source order — see the identical + note on `.log--payouts` above. */ + .log--roster td, + .log--roster tbody tr:not(.drawer-row) > td:first-child:not(.log__empty) { + display: block; + position: static; + padding: 0; + border-top: none; + border-right: none; + background-color: transparent; + } + + .log--roster td:nth-child(1) { + grid-area: name; + } + + /* `.num`'s right alignment is for reading a column of figures down a shared + vertical, and there is no column here — Shares now starts the second line, + so it reads from the left like the name above it. Amount keeps its right + edge, which is the row's one remaining vertical. */ + .log--roster td:nth-child(2) { + grid-area: shares; + text-align: left; + } + + .log--roster td:nth-child(3) { + grid-area: amount; + } + + /* State sits on the first line beside the name it describes, and stops + being right-aligned there — it is an answer, not a figure. */ + .log--roster td:nth-child(4) { + grid-area: state; + text-align: right; + } + + .log--roster td:nth-child(5) { + grid-area: act; + } + + /* Shares keeps its own label because a bare number beside a currency figure + is ambiguous once the `<thead>` naming them is gone. Amount does not: it + carries the word ISK, and State and the action row are self-describing. */ + .roster__label { + display: block; + letter-spacing: var(--track-label); + color: var(--ink-faint); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3ae52ab7..5aba4582 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -49,7 +49,19 @@ export function generateMetadata(): Metadata { } export const viewport = { - themeColor: "#080f1f", + // Must equal the rendered value of `--void` (globals.css), which is what + // `body` paints. This is a hardcoded duplicate of that token — the viewport + // meta cannot read a custom property — so it has to be updated by hand + // whenever the ground moves, and it was not: it still held `#080f1f`, a + // navy from a palette the app no longer uses, while `--void` is + // `oklch(0.145 0 58)`, chroma 0. On mobile that painted a blue browser + // chrome directly above a neutral page. + // + // `oklch(0.145 0 58)` is achromatic, so all three channels are equal: + // 0.145³ = 0.00304862 in linear light, which is below the 0.0031308 sRGB + // gamma knee, so the linear segment applies — 12.92 × 0.00304862 × 255 = + // 10.04, i.e. 0x0a. + themeColor: "#0a0a0a", colorScheme: "dark" as const, }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d6d87510..91812136 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -23,40 +23,104 @@ import { LOGIN_ERRORS, loginErrorTone, lookupErrorMessage } from "@/lib/error-re * means. On the one page whose job is to say what is being granted, an * unknown scope wearing its neighbour's description is the failure worth * spending a line of copy to avoid. + * + * Each sentence now says only what distinguishes its scope from its + * neighbours; whatever the whole group has in common is stated once, in the + * group's own head (`SCOPE_GROUPS`). That is `crewNorms`' shape from + * `account/page.tsx`, and the reason is the same: six sentences that each + * re-established "so authGD can show your location" taught the reader that + * these lines do not differ, on the one screen where the differences are the + * entire point. */ function describeScope(scope: string, contactLabel: string): string { switch (scope) { // src/jobs/contacts.ts:14 — CONTACT_SCOPES[0]. getAllContacts() only. case "esi-characters.read_contacts.v1": - return "Reads the contacts already on your characters, to check what's there before changing anything."; + return "Reads what is already there, before anything is changed."; // src/jobs/contacts.ts:15 — CONTACT_SCOPES[1]. The writes themselves are // esi.addContacts / editContacts / deleteContacts in that same job, driven // by `diffContacts`' return value, gated to the configured label only. case "esi-characters.write_contacts.v1": - return `Adds, updates, and removes contacts under the "${contactLabel}" label on your characters, to keep them matching your standing.`; + return `Adds, updates and removes contacts under the "${contactLabel}" label, to keep them matching your standing. Nothing outside that label is touched.`; // src/lib/esi/client.ts:18,332 — OPEN_WINDOW_SCOPE, used from // payouts/actions.ts:861 to open a character's info window in the client. case "esi-ui.open_window.v1": - return "Lets authGD open a character's info window in your EVE client from the payouts page."; + return "Opens a character's info window in your running client, from the payouts page."; // src/jobs/location.ts — LOCATION_SCOPE_REQUIRED, the only scope // `canReadLocation` gates on; `runLocationJob` reads esi.getLocation with // it. No line number: that file is added later in this same plan. case "esi-location.read_location.v1": - return "Reads which solar system your character is in, and the station or structure it is docked in, so authGD can show that on your own account page and to admins."; + return "The solar system, and the station or structure the character is docked in."; // src/jobs/location.ts — LOCATION_SCOPES_OPTIONAL[0]. esi.getStructureName, // resolved with the docked character's own token. Missing or refused, the // line still renders — just without a name on the structure. case "esi-universe.read_structures.v1": - return "Looks up the name of the structure your character is docked in, so the location names a place instead of a number. Without it a docked character reads only as docked."; + return "The structure's name, so a docked character reads as a place instead of a number. Without it, docked is all it says."; // src/jobs/location.ts — LOCATION_SCOPES_OPTIONAL[1]. esi.getOnline, used // only to choose between a current reading and a "last seen" one. case "esi-location.read_online.v1": - return "Checks whether your character is logged in right now, so a location left behind by a character who has since logged off is shown as where they were last seen rather than as where they are."; + return "Whether the character is logged in right now, so a reading left behind by one who has since logged off is shown as where they were last seen, not where they are."; default: return "This deployment requests this scope, but authGD has no description for it. Ask whoever runs it what it is for before granting."; } } +/** + * The six scopes are three facts, and the flat list said so nowhere: six rows + * at one identical weight, of which exactly one — `write_contacts` — writes + * anything at all, sitting second of six with nothing marking it out. + * + * So the shared fact per group is stated once, in the group's head, and the + * `<dd>`s below carry only what differs. The head is also where the reader + * gets the answer they actually came for ("does this thing change my stuff"), + * ahead of any identifier. + * + * Grouping deliberately reorders relative to `EVE_SSO_SCOPES`. Config order + * is an ops concern and carries no meaning for the person deciding; a scope + * this list does not know about still renders, last, under a head that admits + * the deployment added it. Groups with nothing in them do not render at all, + * so a fork requesting only contact scopes gets one group rather than three + * heads over two lists and an empty one. + */ +const SCOPE_GROUPS: { head: string; scopes: string[] }[] = [ + { + head: "Contacts on your characters — the only thing authGD writes.", + scopes: ["esi-characters.read_contacts.v1", "esi-characters.write_contacts.v1"], + }, + { + head: "Where your characters are, shown on your own account page and to admins.", + scopes: [ + "esi-location.read_location.v1", + "esi-universe.read_structures.v1", + "esi-location.read_online.v1", + ], + }, + { + head: "Your running EVE client.", + scopes: ["esi-ui.open_window.v1"], + }, +]; + +/** `SCOPE_GROUPS` intersected with what this deployment actually requests, + * plus a trailing group for anything the list above does not place. */ +function groupScopes(requested: string[]): { head: string; scopes: string[] }[] { + const placed = new Set<string>(); + const groups = SCOPE_GROUPS.map(({ head, scopes }) => { + const present = scopes.filter((s) => requested.includes(s)); + present.forEach((s) => placed.add(s)); + return { head, scopes: present }; + }).filter((g) => g.scopes.length > 0); + + const rest = requested.filter((s) => !placed.has(s)); + if (rest.length > 0) { + groups.push({ + head: "Also requested by this deployment, and not described here.", + scopes: rest, + }); + } + return groups; +} + // `await searchParams` below already forces dynamic rendering, so the // getConfig() call cannot run at build time today. Declared anyway, matching // every other getConfig() caller under src/app/: the ordering is load-bearing @@ -95,6 +159,20 @@ export default async function LoginPage({ the brand twice before reaching anything actionable — on the one page an unauthenticated visitor can get to. The emblem adds nothing the heading does not already say, which is what `alt=""` is for. */} + {/* `fetchPriority="low"`, stated rather than left implied. This mark is + 79.8 KB of decorative WebP with `alt=""`, and the control below it — + the entry point the whole page exists for — is 2,248 bytes carrying + an explicit `high`. Left unset, the emblem contends for the same + connection as that control on a first visit over a bad link, and the + page's one actionable thing waits behind its own background. Saying + `low` costs the emblem nothing a visitor can perceive (it is drawn + at 180px, below the fold of nothing, and announces nothing to AT) + and takes it out of the race. + + This block sits above the disable directive, not between it and the + `<img>`. "Next line" means the next line, and a comment is a line — + inserting anything there retargets the directive at the comment and + silently unguards the element. Keep the directive adjacent. */} {/* eslint-disable-next-line @next/next/no-img-element -- what next/image would add here is a re-encode at its default quality 75, and PRODUCT.md principle 5 asks for this artwork at "full quality" or not @@ -111,6 +189,7 @@ export default async function LoginPage({ alt="" width={180} height={180} + fetchPriority="low" /> <h1 className="launch__title">{brand.name}</h1> {/* Omitted entirely when unset rather than rendered empty: an empty @@ -189,20 +268,32 @@ export default async function LoginPage({ {scopes.length > 0 && ( <> <p className="launch__scopes-head">Scopes requested</p> - {/* dt is the raw identifier, dd is the one sentence it buys — + {/* One <dl> per group rather than one for the lot: the group's + head is a statement about every row under it, and a <p> + interleaved inside a single <dl> is neither a <dt> nor a + <dd> and has no defined relationship to either. Separate + lists keep each head adjacent to exactly the rows it + describes, in both channels. + + dt is the raw identifier, dd is the one sentence it buys — see describeScope above and the CSS comment on .launch__scopes for why this runs the opposite way from a plain "list of identifiers under one heading". A <Fragment> per scope keeps each dt/dd pair a real boundary: still one row per scope, never a joined string. */} - <dl className="launch__scopes"> - {scopes.map((scope) => ( - <Fragment key={scope}> - <dt>{scope}</dt> - <dd>{describeScope(scope, label)}</dd> - </Fragment> - ))} - </dl> + {groupScopes(scopes).map((group) => ( + <Fragment key={group.head}> + <p className="launch__scope-group">{group.head}</p> + <dl className="launch__scopes"> + {group.scopes.map((scope) => ( + <Fragment key={scope}> + <dt>{scope}</dt> + <dd>{describeScope(scope, label)}</dd> + </Fragment> + ))} + </dl> + </Fragment> + ))} </> )} </div> diff --git a/src/app/payouts/[id]/inline-edit.tsx b/src/app/payouts/[id]/inline-edit.tsx index 225e1349..2b9f6997 100644 --- a/src/app/payouts/[id]/inline-edit.tsx +++ b/src/app/payouts/[id]/inline-edit.tsx @@ -66,6 +66,7 @@ export function InlineEdit({ step, rows, standalone = true, + prominentTrigger = false, }: { /** The server action, already bound to whatever ids it needs * (`setItemPriceAction.bind(null, operationId, item.id)`). */ @@ -104,6 +105,28 @@ export function InlineEdit({ * 28px `.btn--quiet` floor back to 36px without giving up the quiet * colouring. */ standalone?: boolean; + /** Raises the *trigger* out of the quiet grade to a plain `.btn`, filled and + * bordered. Defaults to false, and only the two page-head uses set it. + * + * This page has 70 pressable things and 62 of them are the identical 28px + * quiet chip, with a single gold button. At that ratio nothing directs the + * eye: an operator opening a payout scans a field of interchangeable + * `edit` marks and has no way to tell which of them changes the record's + * identity from which changes one line item's unit price. + * + * Scoped to the operation's name and date because those two *are* the + * record's identity — everything else edits a field of it — and because + * they are the only two above the fold. Deliberately not extended to all + * five `standalone` uses: promoting five of 62 does not create a focal + * point, it creates a second uniform tier, which is the same defect one + * notch up. + * + * Costs no CSS and no layout. Bare `.btn` already carries + * `min-height: 2.25rem` — the identical box `.inline-edit--standalone + * .btn--quiet` buys back — so this changes fill, border and nothing else. + * It also adds no second gold: `.btn--primary` stays the page's one + * emphasis ration, spent on Finalize. */ + prominentTrigger?: boolean; }) { const [editing, setEditing] = useState(false); const [announcement, setAnnouncement] = useState(""); @@ -173,7 +196,7 @@ export function InlineEdit({ <button type="button" ref={triggerRef} - className="btn btn--quiet btn--micro" + className={prominentTrigger ? "btn" : "btn btn--quiet btn--micro"} onClick={() => { setShowError(false); setEditing(true); diff --git a/src/app/payouts/[id]/lifecycle-submit.tsx b/src/app/payouts/[id]/lifecycle-submit.tsx index a672bb81..6f2c55e9 100644 --- a/src/app/payouts/[id]/lifecycle-submit.tsx +++ b/src/app/payouts/[id]/lifecycle-submit.tsx @@ -112,6 +112,7 @@ export function LifecycleAnnouncer({ children }: { children: ReactNode }) { export function LifecycleSubmit({ action, label, + pendingLabel, confirmName, costId, cost, @@ -122,6 +123,13 @@ export function LifecycleSubmit({ * (`finalizeAction.bind(null, operation.id)`). */ action: (formData: FormData) => Promise<void>; label: string; + /** What the button says while the press is in flight, e.g. "Finalizing…". + * Required rather than optional: this control's action does not redirect, + * so without it the button is inert-looking for the whole round trip and + * the second press an operator makes is refused in silence — there is no + * `ConfirmGroup` on this route for `ConfirmSubmit` to report a refusal to. + * A new lifecycle control should have to answer this, not inherit a gap. */ + pendingLabel: string; confirmName: string; costId: string; cost: ReactNode; @@ -147,6 +155,7 @@ export function LifecycleSubmit({ <ConfirmSubmit className={className} label={label} + pendingLabel={pendingLabel} confirmName={confirmName} describedBy={costId} /> diff --git a/src/app/payouts/[id]/page.tsx b/src/app/payouts/[id]/page.tsx index 4647637a..f159f902 100644 --- a/src/app/payouts/[id]/page.tsx +++ b/src/app/payouts/[id]/page.tsx @@ -297,6 +297,7 @@ export default async function PayoutOperationPage({ fieldName="name" value={operation.name} label="operation name" + prominentTrigger /> </h1> ) : ( @@ -317,6 +318,7 @@ export default async function PayoutOperationPage({ type="date" mono max={today} + prominentTrigger /> ) : ( <span className="mono">{fmtDate(operation.occurredAt)}</span> @@ -505,12 +507,22 @@ export default async function PayoutOperationPage({ <LifecycleAnnouncer> {showLifecycle && ( <div className="lifecycle"> - <div className="btn-row btn-row--tight"> + {/* Plain `.btn-row`, deliberately. `--tight` exists to stop a + table's row actions stacking inside a narrow column, and it + buys that with `white-space: nowrap` — which inherits into + the cost sentence each `LifecycleSubmit` carries, rendering + a 137-character explanation as one unbreakable 754px line + and pushing the whole document past a phone's width. There + are no row actions to keep on one line here: `canFinalize` + wants `draft` and `canRelease` wants `finalized`, so this + row holds exactly one button by construction. */} + <div className="btn-row"> {canFinalize && ( <LifecycleSubmit action={finalizeAction.bind(null, operation.id)} className={primaryStage === "finalize" ? "btn btn--primary" : "btn"} label="Finalize" + pendingLabel="Finalizing…" confirmName="confirm finalize" costId="finalize-cost" announcement="Operation finalized." @@ -537,6 +549,7 @@ export default async function PayoutOperationPage({ action={unlockAction.bind(null, operation.id)} className="btn" label="Unlock" + pendingLabel="Unlocking…" confirmName="confirm unlock" costId="unlock-cost" announcement="Operation unlocked." @@ -950,6 +963,12 @@ export default async function PayoutOperationPage({ )} </td> <td className="num"> + {/* Hidden above 40rem, where the `<thead>` names + this column. Below it the row reflows to a + grid and the `<thead>` is gone, and a bare + number sitting beside a currency figure is + ambiguous without it. */} + <span className="roster__label">Shares</span> {canEdit ? ( <InlineEdit action={setParticipantSharesAction.bind( @@ -1174,6 +1193,7 @@ export default async function PayoutOperationPage({ label="Replace roster" confirmName="confirm replace roster" describedBy="replace-roster-cost" + pendingLabel="Replacing…" /> <ConfirmCost id="replace-roster-cost"> Replaces all {participants.length} participant @@ -1264,6 +1284,7 @@ export default async function PayoutOperationPage({ restName="Delete operation" confirmName="confirm delete operation" describedBy="delete-operation-cost" + pendingLabel="Deleting…" /> </form> </div> diff --git a/src/app/payouts/[id]/pay-flow.tsx b/src/app/payouts/[id]/pay-flow.tsx index 0f394c2e..36b72b30 100644 --- a/src/app/payouts/[id]/pay-flow.tsx +++ b/src/app/payouts/[id]/pay-flow.tsx @@ -331,6 +331,15 @@ export function MarkPaidForm({ confirmName={`confirm mark paid ${displayName}`} describedBy={arm ? describedBy : undefined} confirm={arm} + // The in-flight state, said on the control rather than nowhere. These + // actions end in `revalidateOperation` and do not redirect, so a press + // moves nothing on screen until the server answers — and + // `useSubmitGuard` silently refuses every press in that window. With no + // `ConfirmGroup` on this route there is no channel for the refusal + // (`useConfirmReport()` is null here), so the label is the whole of the + // feedback: it changes on the press that worked, which is what stops + // the operator making the press that gets swallowed. + pendingLabel="paying…" /> </form> ); @@ -356,6 +365,7 @@ export function RevertForm({ label="revert" restName={`revert payment for ${displayName}`} confirmName={`confirm revert payment for ${displayName}`} + pendingLabel="reverting…" /> </form> ); @@ -399,6 +409,7 @@ export function RemoveParticipantForm({ label="remove" restName={`remove ${displayName}`} confirmName={`confirm remove ${displayName}`} + pendingLabel="removing…" /> </form> ); diff --git a/src/app/payouts/[id]/payment-history.tsx b/src/app/payouts/[id]/payment-history.tsx index 4e5c5c28..fa6a3a40 100644 --- a/src/app/payouts/[id]/payment-history.tsx +++ b/src/app/payouts/[id]/payment-history.tsx @@ -8,6 +8,58 @@ function fmtAt(d: Date): string { return `${d.toISOString().replace("T", " ").slice(0, 19)} UTC`; } +/** Just the clock part, for rows whose calendar day is stated once above them. */ +function fmtTime(d: Date): string { + return `${d.toISOString().slice(11, 19)} UTC`; +} + +/** + * What every event in this history agrees on, so the rows do not have to keep + * saying it. `crewNorms`' shape from `account/page.tsx`, and the same argument: + * a real payout gets paid out in one sitting, by one operator, so six rows read + * + * 2026-08-10 11:57:07 UTC paid 288,600,000.00 ISK by Fleet Commander + * + * six times with only the seconds and the amount differing — and those are the + * two things an operator is actually scanning for. The repeated words are not + * merely redundant, they are the majority of each line, and they push the + * varying part off to where it has to be found by reading. + * + * A field is a norm only when every event agrees; one reverted row among five + * paid ones puts `kind` back on every row rather than dropping it from four. + * Null means "varies", which is the same thing the rows already handle. + * + * Both channels lose exactly the same words on exactly the same rows — the + * shared line renders in the flow, not in a `visually-hidden`, and the rows + * hide nothing from either. That is what R4 asks for; a per-row assistive-only + * restoration would put the fact in one channel and not the other, which is the + * breach R4 exists to name. + */ +type PaymentNorms = { day: string | null; kind: string | null; actor: string | null }; + +function paymentNorms(payments: PayoutPaymentView[]): PaymentNorms { + const uniform = (pick: (p: PayoutPaymentView) => string): string | null => { + const first = pick(payments[0]); + return payments.every((p) => pick(p) === first) ? first : null; + }; + return { + day: uniform((p) => p.at.toISOString().slice(0, 10)), + kind: uniform((p) => p.kind), + actor: uniform((p) => p.actorName ?? "unknown"), + }; +} + +/** The norms as one sentence, or null when nothing is shared and every row is + * already carrying its own. */ +function normsSentence(count: number, norms: PaymentNorms): string | null { + const { day, kind, actor } = norms; + if (day === null && kind === null && actor === null) return null; + const head = kind === null ? `All ${count} events` : `All ${count} ${kind}`; + const by = actor === null ? "" : ` by ${actor}`; + const on = day === null ? "" : ` on ${day}`; + return `${head}${by}${on}.`; +} + /** * One participant's payment history: who did what, and when. * @@ -32,31 +84,49 @@ export function PaymentHistory({ participantName: string; }) { if (payments.length === 0) return null; + // Owner walkthrough 2026-08-07, finding 1.6: a `Disclosure` collapsed behind + // "payments (1)" makes the operator open a drawer to read the one line it + // would have shown anyway — a fold with nothing folded. `payments (3)` + // asserted at e2e/payouts.spec.ts:1572 still holds: two or more payments is + // still a history worth collapsing, so the drawer stays there and only the + // single-payment case renders inline. + // + // That single row is also the one with nowhere to hoist a shared fact to, so + // it keeps the full instant, the kind and the actor. Norms need a set to be + // norms of. + const single = payments.length === 1; + const norms: PaymentNorms = single + ? { day: null, kind: null, actor: null } + : paymentNorms(payments); + const shared = single ? null : normsSentence(payments.length, norms); // `.stack` is a grid, which blockifies the items so no markers render. const list = ( <ul className="stack"> {payments.map((ev) => ( <li key={ev.id}> - <span className="mono nowrap">{fmtAt(ev.at)}</span> {ev.kind}{" "} - <span className="mono nowrap">{fmtIsk(ev.amount)} ISK</span> by{" "} - {ev.actorName ?? "unknown"} + <span className="mono nowrap"> + {norms.day === null ? fmtAt(ev.at) : fmtTime(ev.at)} + </span>{" "} + {norms.kind === null ? `${ev.kind} ` : null} + <span className="mono nowrap">{fmtIsk(ev.amount)} ISK</span> + {norms.actor === null ? ` by ${ev.actorName ?? "unknown"}` : null} </li> ))} </ul> ); - // Owner walkthrough 2026-08-07, finding 1.6: a `Disclosure` collapsed behind - // "payments (1)" makes the operator open a drawer to read the one line it - // would have shown anyway — a fold with nothing folded. `payments (3)` - // asserted at e2e/payouts.spec.ts:1572 still holds: two or more payments is - // still a history worth collapsing, so the drawer stays there and only the - // single-payment case renders inline. - if (payments.length === 1) return list; + if (single) return list; + const body = ( + <> + {shared === null ? null : <p className="page__lede">{shared}</p>} + {list} + </> + ); return ( <Disclosure summary={`payments (${payments.length})`} ariaLabel={`payments (${payments.length}) for ${participantName}`} > - {list} + {body} </Disclosure> ); } diff --git a/src/app/payouts/[id]/pool-flow.tsx b/src/app/payouts/[id]/pool-flow.tsx index 96c8e5ea..6fa782f6 100644 --- a/src/app/payouts/[id]/pool-flow.tsx +++ b/src/app/payouts/[id]/pool-flow.tsx @@ -152,6 +152,7 @@ export function DeletePoolForm({ label="delete" restName={`delete pool ${poolNumber}`} confirmName={`confirm delete pool ${poolNumber}`} + pendingLabel="deleting…" /> </form> ); diff --git a/src/app/payouts/actions.ts b/src/app/payouts/actions.ts index 0c4f97e6..248e52a7 100644 --- a/src/app/payouts/actions.ts +++ b/src/app/payouts/actions.ts @@ -257,6 +257,27 @@ export async function createOperationAction( if (!name) return { ok: false, code: "name_required" }; const occurredAt = parseYmd(field(formData, "occurredAt")); if (occurredAt === null) return { ok: false, code: "date_invalid" }; + // `max={today}` on the form declares this rule; until now the browser was the + // only thing enforcing it, so a hand-built request could always date an + // operation into the future. That became load-bearing the moment this form + // took `noValidate` (see new-operation-form.tsx): with native validation off, + // the attribute stops being enforcement at all and this check is the rule. + // + // Compared against the same UTC-midnight boundary `parseYmd` produces, not + // against `now`: both sides are then EVE-day granular, so an operation + // recorded during today's downtime is not rejected for being "ahead" of an + // instant a few hours later in the same day. + // + // `/payouts/[id]`'s own date field is deliberately NOT changed to match. It + // still runs native validation, so its `max={today}` still holds for anyone + // using the page; it carries the same scripted-request gap this closes here, + // pre-existing and unchanged, and closing it there means a new code on + // `OPERATION_ERRORS` for a path this task did not touch. + const todayUtc = new Date(); + todayUtc.setUTCHours(0, 0, 0, 0); + if (occurredAt.getTime() > todayUtc.getTime()) { + return { ok: false, code: "date_future" }; + } // Checked before any network call, alongside name and date, so a bad scheme // never triggers an appraisal only to be thrown away. diff --git a/src/app/payouts/errors.ts b/src/app/payouts/errors.ts index c632cd9b..ec00f5a3 100644 --- a/src/app/payouts/errors.ts +++ b/src/app/payouts/errors.ts @@ -35,10 +35,18 @@ export const NEW_OPERATION_ERRORS = { name_required: "An operation needs a name. Everything else you typed is still here.", date_invalid: "Date must be a real calendar date. Everything else you typed is still here.", + date_future: + "An operation cannot be dated in the future. Everything else you typed is still here.", appraisal_failed: "Could not price that loot paste right now (triff.tools did not answer). Nothing was created — adjust the paste and try again, or leave it blank and price loot later.", + // Names the remedy, because this is the code a pasted zkillboard link + // actually lands on: `zkillboard.com/related/…` with no scheme fails + // `new URL()` outright, so it is `url_invalid` that an operator meets, not + // `url_scheme` — which only fires for something like `javascript:`, already + // a URL and merely the wrong kind. Saying "not a URL" and stopping is true + // and useless; the missing piece is always the `https://` on the front. url_invalid: - "That battle report is not a URL. Everything else you typed is still here.", + "That battle report is not a URL — it usually needs an https:// on the front. Everything else you typed is still here.", url_scheme: "Battle report links must start with http:// or https://. Everything else you typed is still here.", } as const; diff --git a/src/app/payouts/new/new-operation-form.tsx b/src/app/payouts/new/new-operation-form.tsx index 08bb00e7..729a1da1 100644 --- a/src/app/payouts/new/new-operation-form.tsx +++ b/src/app/payouts/new/new-operation-form.tsx @@ -74,8 +74,32 @@ export function NewOperationForm({ today }: { today: string }) { if (state && !state.ok) document.getElementById(ERROR_NOTICE_ID)?.focus(); }, [state]); + /* `noValidate` below, so this form's own rejection messages are the ones an + operator actually reads. Native constraint validation runs BEFORE the + `submit` event and React's `<form action>` runs FROM it, so with validation + on, `name_required`, `date_invalid`, `date_future`, `url_invalid` and + `url_scheme` were server backstops for scripted requests only — every one + of them ends "Everything else you typed is still here", a promise about a + screen nobody could reach. + + What the browser showed instead was a transient bubble ("Please enter a + URL.") that auto-dismisses, cannot be recalled, and never says the thing an + operator pasting a zkillboard link needs to hear — that the link wants an + http:// or https:// on the front. The replacement is the reserved `Notice` + slot below: persistent, re-readable, and a live region, which is what + SC 3.3.1/3.3.3 are asking for. + + The promise those messages make is already true and does not depend on this + change: every field here is controlled React state, so a rejected submit + re-renders with the pastes still in their textareas. + + The one thing native validation was really enforcing is `max={today}` — + nothing server-side rejected a future date. `createOperationAction` now + does; see the comment there. Do not remove `noValidate` without also + deciding what happens to that check, and do not remove that check while + this attribute is here. */ return ( - <form action={formAction} className="form-stack" data-navigates> + <form action={formAction} className="form-stack" data-navigates noValidate> {/* Mounted unconditionally, not behind `&&`: the reserved slot registers the live region before the text arrives, so AT announces a change to it rather than a region born holding its own message. */} @@ -140,10 +164,19 @@ export function NewOperationForm({ today }: { today: string }) { strict-mode violation that took out 22 payouts specs. Any wording added here has to stay clear of every other label on the form. */} Loot paste (optional: one line per item, quantity before or after) + {/* rows={6}, and the same six as Roster below. These are two sibling + optional pastes with the same job, and 10-then-8 reserved 18 rows of + empty box between the operator and "Create operation" while saying, + by the size difference alone, that loot matters more than roster. + Neither field is read at rest — both are pasted into, and a textarea + scrolls once it overflows — so the reserved height buys nothing the + scrollbar does not, and spends it on the page length of the app's + primary creation flow. Equal heights also let the two read as the + pair they are. */} <textarea className="field" name="lootPaste" - rows={10} + rows={6} value={lootPaste} onChange={(e) => setLootPaste(e.target.value)} /> @@ -155,7 +188,7 @@ export function NewOperationForm({ today }: { today: string }) { <textarea className="field" name="rosterPaste" - rows={8} + rows={6} value={rosterPaste} onChange={(e) => setRosterPaste(e.target.value)} /> diff --git a/src/app/payouts/new/page.tsx b/src/app/payouts/new/page.tsx index 4cb1e2e1..4e8dde75 100644 --- a/src/app/payouts/new/page.tsx +++ b/src/app/payouts/new/page.tsx @@ -48,7 +48,18 @@ export default async function NewPayoutPage() { DESIGN.md names for an inset region, and a `--void` field inset into it is the contrast the field spec already assumes. The H1 and lede stay on the page ground; only the form itself is boxed, and it - wears no registration ticks (those are the login panel's alone). */} + wears no registration ticks (those are the login panel's alone). + + The ground is not what makes it read as boxed, though, and this + comment used to imply it was. `--hull` on `--void` measures 1.08:1 — + a difference an eye can find only by looking for it. The visible + edge is `.form-panel`'s `1px solid var(--rule-strong)` + (globals.css), at 4.23:1 on the page ground. That is worth stating + because the two are load-bearing in opposite directions: the ground + is deliberately almost nothing (it sets the field contrast without + competing with the form's own content), and the border is the whole + boundary. Dropping the border to "flatten" the panel does not soften + the box, it deletes it. */} <div className="form-panel"> <NewOperationForm today={today} /> </div> diff --git a/src/app/payouts/page.tsx b/src/app/payouts/page.tsx index e5f122f5..39008ac5 100644 --- a/src/app/payouts/page.tsx +++ b/src/app/payouts/page.tsx @@ -9,7 +9,6 @@ import { } from "@/services/payout-view"; import { navFor } from "@/app/_components/nav-items"; import { RuleHead, Scroller, SiteHeader, Status } from "@/app/_components/ui"; -import { Submit } from "@/app/_components/submit"; import { brandProps } from "@/app/_components/brand-server"; import { fmtIsk } from "@/app/_components/format-isk"; import { PendingLink } from "./pending-link"; @@ -209,8 +208,13 @@ export default async function PayoutsPage({ <div className="filter-form__cell filter-form__cell--actions"> <div className="filter-form__actions"> {/* Filter is routine and reversible, not the page's primary act - — gold (btn--primary) is rationed for New operation. */} - <Submit className="btn">Filter</Submit> + — gold (btn--primary) is rationed for New operation. + + A plain button rather than `<Submit>`, because this form is + `method="get"` — see `submit.tsx`. */} + <button type="submit" className="btn"> + Filter + </button> {filtered && ( <Link className="btn btn--quiet" href="/payouts"> clear @@ -252,11 +256,29 @@ export default async function PayoutsPage({ <tbody> {ops.map((op) => ( <tr key={op.id}> + {/* Each cell leads with a `.payouts__label`, hidden above + 30rem where the real `<thead>` is naming the columns and + shown below it where the row has reflowed to blocks and + the `<thead>` is gone. Same construction as + `.crew__label` on the accounts drawer, and the reasoning + — why a real element rather than `content: attr()`, and + why the label is in the DOM at every width rather than + swapped in — is in that rule's docblock in globals.css. + These stay `<td>`, not `<th scope="row">`: `.log th` + carries `white-space: nowrap`, which would inherit into + the Name cell and defeat the `overflow-wrap: anywhere` + that keeps a 60-character operation name from setting the + column's width. */} <td> + <span className="payouts__label">Name</span> <PendingLink href={`/payouts/${op.id}`}>{op.name}</PendingLink> </td> - <td className="mono nowrap">{fmtDate(op.occurredAt)}</td> + <td className="mono nowrap"> + <span className="payouts__label">Date</span> + {fmtDate(op.occurredAt)} + </td> <td> + <span className="payouts__label">Status</span> {op.status === "finalized" ? ( <Status tone="ok">finalized</Status> ) : ( @@ -278,6 +300,7 @@ export default async function PayoutsPage({ unexplained punctuation mark. `.visually-hidden` is the pattern `<Tier>` already uses for exactly this. */} <td className="mono nowrap num"> + <span className="payouts__label">Total</span> {op.status === "draft" && Number(op.totalValue) === 0 ? ( <span className="dim"> <span aria-hidden="true">—</span> @@ -293,6 +316,7 @@ export default async function PayoutsPage({ done it, and carries the glyph and the word so the hue is never the only signal. */} <td> + <span className="payouts__label">Paid</span> {op.participantCount === 0 ? ( <span className="dim mono"> <span aria-hidden="true">—</span> @@ -338,6 +362,7 @@ export default async function PayoutsPage({ Their hidden text differs because the claim differs, and only one of the two is provable — see `ViewerPayoutState`. */} <td> + <span className="payouts__label">Yours</span> {op.viewerState === "paid" && <Status tone="ok">paid</Status>} {op.viewerState === "unpaid" && (op.status === "finalized" ? ( diff --git a/tests/payment-history.test.ts b/tests/payment-history.test.ts index 59600d6d..2c33558a 100644 --- a/tests/payment-history.test.ts +++ b/tests/payment-history.test.ts @@ -38,7 +38,12 @@ describe("PaymentHistory", () => { actorName: "Second FC", }), ]); - expect(html).toContain("2026-08-01 12:34:56 UTC"); + // Two events, differing in kind and actor, sharing a calendar day. Only the + // day is a norm, so it is stated once and the rows keep the clock — which + // is the part that differs between them. + expect(html).toContain("All 2 events on 2026-08-01."); + expect(html).toContain("12:34:56 UTC"); + expect(html).not.toContain("2026-08-01 12:34:56 UTC"); // fmtIsk groups this display value with commas — the raw "450000.00" // form no longer appears in the rendered markup. expect(html).toContain("450,000.00 ISK"); @@ -47,6 +52,43 @@ describe("PaymentHistory", () => { expect(html).toContain("Second FC"); }); + // The shape a real payout actually produces: one operator settles the roster + // in one sitting, so kind, actor and day are all constant and the only thing + // that varies between rows is the clock and the amount. Those two are what + // the operator is scanning for, and they were previously buried mid-sentence + // behind three words the row shared with every other row. + it("states what every event agrees on once, and drops it from the rows", () => { + const html = render([ + payment(), + payment({ + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + at: new Date("2026-08-01T12:40:00Z"), + amount: "900000.00", + }), + ]); + expect(html).toContain("All 2 paid by FC Prime on 2026-08-01."); + // Said once, in the shared line, and nowhere else. Both channels lose the + // same words on the same rows — nothing is restored for assistive tech that + // is not also on screen (R4). + expect(html.match(/FC Prime/g)).toHaveLength(1); + expect(html.match(/paid/g)).toHaveLength(1); + expect(html).toContain("12:34:56 UTC"); + expect(html).toContain("12:40:00 UTC"); + expect(html).toContain("450,000.00 ISK"); + expect(html).toContain("900,000.00 ISK"); + }); + + // A single event has no head to hoist a shared fact into — `payments (1)` + // renders the list inline with no disclosure at all — so it keeps the full + // instant, the kind and the actor. Norms need a set to be norms of. + it("keeps the whole sentence when there is only one event", () => { + const html = render([payment()]); + expect(html).toContain("2026-08-01 12:34:56 UTC"); + expect(html).toContain("paid"); + expect(html).toContain("by FC Prime"); + expect(html).not.toContain("All 1"); + }); + // The null case, which is reachable in production: the actor's account was // deleted (the FK is `on delete set null`), or it has no main character. The // event still renders, and it must not print "null" or leave a gap where a