Skip to content

Commit 9f690b1

Browse files
authored
design: record the owner walkthrough backlog and its three rulings (#176)
A page-by-page walkthrough of every rendered surface with the project owner, conducted against production screenshots. `docs/design-walkthrough.md` carries the findings as seven self-contained sessions so each is startable from a cold context; this commit is session 0, the cross-cutting part the other six depend on. Three rulings, recorded in DESIGN.md so a later sweep does not undo them: R1, hit targets. The 28px in-row grade is now scoped by the density reason rather than by the tag it lands in, so a `Disclosure as="row"` drawer takes the 36px standalone grade despite rendering a literal `<tr>`. This resolves a contradiction rather than reversing a decision: e2e/sync.spec.ts:1095 has been pinning the /admin/sync drawer to the standalone grade all along, and payouts/[id]/notes-form.tsx:90-95 reasons the same way for a panel field. Only /admin/accounts read the settled-decisions row as forbidding it. That row is marked AMENDED rather than deleted, per that file's own contract. R2, rare destructive controls do not hold permanent width in a scanning table. Records the existing reveal-on-arm constraint (#112) as a reason to move such controls rather than to progressively disclose them where they sit. R4, information may not live only in the assistive-tech channel. Two instances found, both the inverse of the usual defect: a working role="status" save confirmation marked .visually-hidden, and an affordance named in aria-label and absent from the visible summary. Copy: removes the four em dashes from rendered strings (the glyph stays as this app's null-value marker, per error.tsx:239), and drops the /payouts lede's first sentence, which restated its own table columns. The flat-pool note label changed shape, so its 12 e2e assertions move with it. The equivalent /account lede is deliberately left to session 3. e2e/account.spec.ts:1583 uses it as the narrow-capped sibling proving the manifest opts out of the cap, and finding 3.1 changes what that anchor should be; deleting it here would mean editing that test twice. Gates: typecheck, lint, format:check, 1290 unit tests across 83 files, 297 e2e, production build, and check-node-version all pass.
1 parent 7984c14 commit 9f690b1

9 files changed

Lines changed: 341 additions & 19 deletions

File tree

DESIGN.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,20 @@ configuration, the other two by overwriting the file.
291291
there and nowhere else. There are **two** sizes and no others: `quiet` is a colour
292292
grade, like `primary` and `default`, and does not carry a size of its own.
293293

294+
The `28px` grade is scoped by the *reason* for it, not by the tag it lands in:
295+
it applies to rows that each carry a control set and are read many at a time.
296+
A disclosure drawer is **not** in-row for this purpose even though
297+
`Disclosure as="row"` renders a literal second `<tr>`. One drawer is open at
298+
a time, it spans the full table width, and nothing is competing with it for
299+
vertical space, so the density argument that buys the `28px` grade does not
300+
apply and its controls take `36px`. This is not a new rule so much as the one
301+
already in force: `e2e/sync.spec.ts:1095` pins the `/admin/sync` drawer's
302+
Re-run control to the standalone grade and has been passing all along, and
303+
`payouts/[id]/notes-form.tsx:90-95` reasons the same way for a field in an
304+
operation's own panel. `/admin/accounts` is the surface that diverged.
305+
(Owner walkthrough 2026-08-07, ruling R1. Supersedes the flat two-grade
306+
reading recorded in `docs/settled-design-decisions.md`.)
307+
294308
`.shell__nav a` used to be a third, undocumented size at ~33.05px — `padding:
295309
var(--s-2) var(--s-3)` plus the label register's line box, with no floor
296310
and no ceiling. It could not simply shrink to 28px or grow to 36px by
@@ -309,3 +323,26 @@ configuration, the other two by overwriting the file.
309323
worth of controls competing for space — the same case that puts `.btn` at
310324
36px rather than 28px. `e2e/shell.spec.ts` pins both the 36px box and a
311325
minimum label-to-underline gap.
326+
327+
## Disclosure and parity
328+
329+
From the owner walkthrough of 2026-08-07. Both rules exist because a reviewer
330+
reading the code cannot see the failure; only someone using the app can.
331+
332+
- **Rare destructive controls do not hold permanent width in a scanning table.**
333+
PRODUCT.md principle 3 makes scanning the primary act, and a control that is
334+
pressed once a month should not cost a column on every row for the other
335+
thirty days. Move it behind per-row disclosure. Two constraints on how: the
336+
cost hint stays hidden-always rather than reveal-on-arm (`confirm-submit.tsx:89-98`
337+
records why — the widening disarms the control it revealed for), and the
338+
drawer that receives the control takes the `36px` grade above, not `28px`.
339+
(Ruling R2.)
340+
341+
- **Information may not live only in the assistive-tech channel.** Parity runs
342+
in both directions. The usual failure is a control with no accessible name;
343+
this codebase's failure is the inverse — a `role="status"` confirmation
344+
marked `.visually-hidden`, or an affordance named in `aria-label` and absent
345+
from the visible summary, so the sighted user is the one left guessing.
346+
`.visually-hidden` is for text that is *redundant* on screen (a heading level,
347+
a table caption already implied by layout), never for the only copy of a fact.
348+
If assistive tech is told something, the screen says it too. (Ruling R4.)

docs/design-walkthrough.md

Lines changed: 285 additions & 0 deletions
Large diffs are not rendered by default.

docs/settled-design-decisions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ undiscovered.
8080
| `--signal-warn` hue moved from 70 to 50 | At 70 it sat 18° from `--gold`/`--tier-member` at near-identical chroma/lightness (0.057 apart in OKLab) — not enough to tell a gold Member badge from an amber CRYO token two columns away | `DESIGN.md:58` |
8181
| Disabled controls use an explicit `--ink-faint` colour, not an opacity fade | An opacity fade moves with whatever ground it lands on: measured 3.24:1 on `--void` but 2.88:1 on a hovered admin row — under the WCAG 3:1 floor exactly when the pointer is on the row | `DESIGN.md:222-226` |
8282
| Hit targets: 36px standalone (`.btn`), 28px in-row (`.btn--micro`/`.btn--quiet`/`.row-toggle`) — exactly two sizes | Both clear the 24px WCAG 2.5.8 AA minimum; the admin tables carry a control set on every row and cannot reach 44px AAA without growing past a screenful, so density wins there and nowhere else | `DESIGN.md:227-232` |
83+
| **AMENDED 2026-08-07 (owner walkthrough, ruling R1)** — still exactly two sizes, but "in-row" is scoped by the density reason, not by the tag. A `Disclosure as="row"` drawer renders a `<tr>` and is nonetheless **not** in-row: one is open at a time, it spans the full width, nothing competes for the space. Drawer controls take 36px | Resolves a contradiction rather than reversing a decision: `e2e/sync.spec.ts:1095` has been pinning the `/admin/sync` drawer to the standalone grade all along, and `payouts/[id]/notes-form.tsx:90-95` reasons the same way for a panel field. Only `/admin/accounts` read the row above as forbidding it, having inherited 28px by structural accident | `DESIGN.md` "Hit targets", `e2e/sync.spec.ts:1095-1121`, `src/app/_components/note-form.tsx` |
8384

8485
## 3. Controls and confirmation (cross-cutting patterns)
8586

e2e/payouts.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ test("create, add a flat pool, paste a roster, finalize, mark paid", async ({
230230
// sit behind their own collapsed panel — open that first.
231231
await openFlatPoolPanel(page);
232232
await page.getByLabel("Total value (ISK)").fill("1000000");
233-
await page.getByLabel("Note (requiredwhy this number)").fill("sold privately");
233+
await page.getByLabel("Note (required): why this number").fill("sold privately");
234234
await page.getByRole("button", { name: "Add flat pool" }).click();
235235
// Scoped to the pool row: with one pool, "1,000,000.00 ISK" also appears in
236236
// the operation's "Total loot" summary, so a bare getByText is ambiguous.
@@ -329,7 +329,7 @@ test("a successful flat pool add clears the form", async ({ page, context }) =>
329329
await page.goto(`/payouts/${op.id}`);
330330
await openFlatPoolPanel(page);
331331
await page.getByLabel("Total value (ISK)").fill("1000000");
332-
await page.getByLabel("Note (requiredwhy this number)").fill("sold privately");
332+
await page.getByLabel("Note (required): why this number").fill("sold privately");
333333
await page.getByLabel("What was in it (optional)").fill("a stack of PLEX");
334334
await page.getByRole("button", { name: "Add flat pool" }).click();
335335

@@ -341,7 +341,7 @@ test("a successful flat pool add clears the form", async ({ page, context }) =>
341341
// banked — the exact hazard the docblock names: a second press with the
342342
// same numbers still visible would have created a second pool.
343343
await expect(page.getByLabel("Total value (ISK)")).toHaveValue("");
344-
await expect(page.getByLabel("Note (requiredwhy this number)")).toHaveValue("");
344+
await expect(page.getByLabel("Note (required): why this number")).toHaveValue("");
345345
await expect(page.getByLabel("What was in it (optional)")).toHaveValue("");
346346
});
347347

@@ -380,7 +380,7 @@ test("a rejected flat pool submission keeps what was typed in all three fields",
380380
await page.goto(`/payouts/${op.id}`);
381381
await openFlatPoolPanel(page);
382382
await page.getByLabel("Total value (ISK)").fill("1e5");
383-
await page.getByLabel("Note (requiredwhy this number)").fill("sold privately");
383+
await page.getByLabel("Note (required): why this number").fill("sold privately");
384384
await page.getByLabel("What was in it (optional)").fill("a stack of PLEX");
385385
await page.getByRole("button", { name: "Add flat pool" }).click();
386386

@@ -392,7 +392,7 @@ test("a rejected flat pool submission keeps what was typed in all three fields",
392392
// All three fields still hold exactly what was typed — this is the fix the
393393
// conversion to controlled state must not have traded away.
394394
await expect(page.getByLabel("Total value (ISK)")).toHaveValue("1e5");
395-
await expect(page.getByLabel("Note (requiredwhy this number)")).toHaveValue(
395+
await expect(page.getByLabel("Note (required): why this number")).toHaveValue(
396396
"sold privately",
397397
);
398398
await expect(page.getByLabel("What was in it (optional)")).toHaveValue(
@@ -423,7 +423,7 @@ test("pasting two alts of one account collapses them into one participant row",
423423

424424
await openFlatPoolPanel(page);
425425
await page.getByLabel("Total value (ISK)").fill("200");
426-
await page.getByLabel("Note (requiredwhy this number)").fill("flat test value");
426+
await page.getByLabel("Note (required): why this number").fill("flat test value");
427427
await page.getByRole("button", { name: "Add flat pool" }).click();
428428

429429
// The pasted fleet has two names, one main and one of its own alts.
@@ -805,7 +805,7 @@ test("setting shares, excluding, and removing a participant each recompute exact
805805

806806
await openFlatPoolPanel(page);
807807
await page.getByLabel("Total value (ISK)").fill("300");
808-
await page.getByLabel("Note (requiredwhy this number)").fill("even split test");
808+
await page.getByLabel("Note (required): why this number").fill("even split test");
809809
await page.getByRole("button", { name: "Add flat pool" }).click();
810810

811811
await page
@@ -1104,7 +1104,7 @@ test("finalizing hands focus to the operation heading", async ({ page, context }
11041104

11051105
await openFlatPoolPanel(page);
11061106
await page.getByLabel("Total value (ISK)").fill("1000000");
1107-
await page.getByLabel("Note (requiredwhy this number)").fill("sold privately");
1107+
await page.getByLabel("Note (required): why this number").fill("sold privately");
11081108
await page.getByRole("button", { name: "Add flat pool" }).click();
11091109
await page.getByLabel("Paste (names separated by /)").fill("Brain Tartare");
11101110
await page.getByRole("button", { name: "Set roster" }).click();
@@ -1396,7 +1396,7 @@ test("bad shares land on the page, not the error boundary", async ({ page, conte
13961396

13971397
await openFlatPoolPanel(page);
13981398
await page.getByLabel("Total value (ISK)").fill("100");
1399-
await page.getByLabel("Note (requiredwhy this number)").fill("flat");
1399+
await page.getByLabel("Note (required): why this number").fill("flat");
14001400
await page.getByRole("button", { name: "Add flat pool" }).click();
14011401
await page.getByLabel("Paste (names separated by /)").fill("Alice Pilot");
14021402
await page.getByRole("button", { name: "Set roster" }).click();
@@ -1882,7 +1882,7 @@ test("an admin deletes an operation, and the audit row outlives it", async ({
18821882

18831883
await openFlatPoolPanel(page);
18841884
await page.getByLabel("Total value (ISK)").fill("1000000");
1885-
await page.getByLabel("Note (requiredwhy this number)").fill("sold privately");
1885+
await page.getByLabel("Note (required): why this number").fill("sold privately");
18861886
await page.getByRole("button", { name: "Add flat pool" }).click();
18871887
await page
18881888
.getByLabel("Paste (names separated by /)")
@@ -1962,7 +1962,7 @@ test("deleting an operation with a paid participant is refused on the page", asy
19621962

19631963
await openFlatPoolPanel(page);
19641964
await page.getByLabel("Total value (ISK)").fill("1000000");
1965-
await page.getByLabel("Note (requiredwhy this number)").fill("sold privately");
1965+
await page.getByLabel("Note (required): why this number").fill("sold privately");
19661966
await page.getByRole("button", { name: "Add flat pool" }).click();
19671967
await page.getByLabel("Paste (names separated by /)").fill("Brain Tartare");
19681968
await page.getByRole("button", { name: "Set roster" }).click();
@@ -2183,7 +2183,7 @@ test("exactly one gold primary control renders in each draft state", async ({
21832183

21842184
await openFlatPoolPanel(page);
21852185
await page.getByLabel("Total value (ISK)").fill("100");
2186-
await page.getByLabel("Note (requiredwhy this number)").fill("flat test");
2186+
await page.getByLabel("Note (required): why this number").fill("flat test");
21872187
await page.getByRole("button", { name: "Add flat pool" }).click();
21882188

21892189
// Stage 2: loot exists, no roster yet — Set roster is the one gold control.

src/app/admin/accounts/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,8 +1096,8 @@ function AccountRow({
10961096
{r.tier !== "pending" && !r.tierLocked && (
10971097
<p className="dim drawer__note" id={tierLockNoteId(r.accountId)}>
10981098
Setting a tier here locks it: the membership job stops raising or lowering
1099-
it automatically including moving it to {tierLabel("alumni")} if this
1100-
member leaves the alliance until you press auto.
1099+
it automatically (including moving it to {tierLabel("alumni")} if this
1100+
member leaves the alliance) until you press auto.
11011101
</p>
11021102
)}
11031103
{r.tierChangedByName && (

src/app/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function describeScope(scope: string, contactLabel: string): string {
5353
case "esi-location.read_online.v1":
5454
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.";
5555
default:
56-
return "This deployment requests this scope, but authGD has no description for it — ask whoever runs it what it is for before granting.";
56+
return "This deployment requests this scope, but authGD has no description for it. Ask whoever runs it what it is for before granting.";
5757
}
5858
}
5959

src/app/payouts/[id]/appraise-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export function AppraiseForm({
182182
as="details"
183183
className="disc"
184184
summary="Add another paste"
185-
ariaLabel="Add another paste appraise more loot, or enter a flat value"
185+
ariaLabel="Add another paste: appraise more loot, or enter a flat value"
186186
>
187187
<div className="form-stack">
188188
{form}

src/app/payouts/[id]/flat-pool-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function FlatPoolForm({ operationId }: { operationId: string }) {
7979
/>
8080
</label>
8181
<label className="form-stack__field">
82-
Note (requiredwhy this number)
82+
Note (required): why this number
8383
<input
8484
className="field"
8585
name="notes"

src/app/payouts/page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ export default async function PayoutsPage({
9090
)}
9191
</div>
9292
<p className="page__lede">
93-
Every fight operation authGD has recorded: what it was worth, who was in it,
94-
and who has been paid. Your own share of each one is on{" "}
93+
Your own share of each operation is on{" "}
9594
<Link href="/account">your account</Link>.
9695
</p>
9796
</div>

0 commit comments

Comments
 (0)