bucket_catch: introduce a real design-token system, fix reversed gradients
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

Fixes every violation in DESIGN_AUDIT_TODO.md's bucket_catch frontend
section. The app previously had no shared tokens at all (index.css was a
12-line reset) and 19 hardcoded hex colors duplicated independently
across 6 *.module.css files.

New :root token block in index.css: bg gradient stops, a drag-state
gradient, the indigo/pink accent pair collapsed to one canonical
--gradient-accent direction, text/surface colors, success/error, 2
consolidated muted-gray roles (down from 4 unrelated shades), a 4px
spacing scale, and a radius scale. All 6 component files migrated onto it.

Fixes the real reversed-gradient-stop bug the audit named
(PuzzleResult.module.css .grade was pink->indigo vs every other heading's
indigo->pink) plus a second instance of the same bug the audit's prose
didn't cite by line number (ModeSelect.module.css .puzzleStart button) --
both now reference --gradient-accent so this class of bug can't recur.
Also fixes: .preset's border contrast (was under 3:1), cardDesc's
sub-16px font-size, 4 buttons' off-ratio padding (now exact 2x), 2 large
icons' full-contrast rendering next to muted text (now dimmed), the
explicitly-cited off-grid spacing values, and ModeSelect's gridInput
background color which had drifted from its DropZone sibling's.

Verified: 145/145 tests at 100% coverage (CSS-only, confirms no
functional regression), a real vite build succeeds, re-verified identical
in an isolated worktree at HEAD, and pixel-checked the production build
via a headless-Chrome screenshot (gradient direction now matches between
heading and button; preset chip borders clearly visible).
`npm run lint` (tsc -b + eslint) was already broken on a clean checkout
before this change (1 pre-existing tsc error in vite.config.ts, 46
pre-existing eslint errors in untouched test/lib files) -- confirmed via
an isolated worktree at the parent commit, left alone as out of scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-22 19:18:12 +02:00
parent 27456c431a
commit a7596d0442
8 changed files with 271 additions and 127 deletions

View File

@ -3,37 +3,39 @@
align-items: center;
justify-content: center;
min-height: 100dvh;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
background: var(--bg-gradient);
transition: background 0.2s;
overflow-y: auto;
padding: 24px 16px;
padding: var(--space-4) var(--space-3);
}
.zone.dragging {
background: linear-gradient(135deg, #1a1560, #4a3f9e, #3a3868);
outline: 4px dashed #818cf8;
background: var(--bg-drag-gradient);
outline: 4px dashed var(--color-indigo);
outline-offset: -12px;
}
.inner {
text-align: center;
color: #e0e7ff;
color: var(--color-text);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
gap: var(--space-2);
}
.icon {
font-size: 52px;
line-height: 1;
/* Dimmed relative to the .subtitle text below it (rule 28). */
opacity: 0.85;
}
.title {
font-size: 2rem;
font-weight: 800;
margin: 0;
background: linear-gradient(90deg, #818cf8, #f472b6);
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -41,24 +43,24 @@
.subtitle {
font-size: 1rem;
color: #94a3b8;
color: var(--muted-text);
margin: 0;
}
.count {
font-size: 1rem;
color: #a5b4fc;
color: var(--color-indigo-light);
font-weight: 600;
background: rgba(129, 140, 248, 0.15);
padding: 4px 16px;
border-radius: 999px;
background: rgba(var(--color-indigo-rgb), 0.15);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-pill);
margin: 0;
}
.browseBtn {
padding: 8px 24px;
border-radius: 999px;
background: linear-gradient(90deg, #818cf8, #f472b6);
padding: 12px var(--space-4);
border-radius: var(--radius-pill);
background: var(--gradient-accent);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
@ -72,7 +74,7 @@
}
.divider {
color: #4b5563;
color: var(--muted-border);
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
@ -96,15 +98,17 @@
.gridLabel {
font-size: 0.85rem;
color: #94a3b8;
color: var(--muted-text);
}
.preset {
padding: 3px 10px;
border-radius: 6px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: transparent;
border: 1.5px solid #4b5563;
color: #94a3b8;
/* muted-text, not muted-border — the border-only value measured under
3:1 against the page background (rule 7). */
border: 1.5px solid var(--muted-text);
color: var(--muted-text);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
@ -112,23 +116,23 @@
}
.preset:hover {
border-color: #818cf8;
color: #c7d2fe;
border-color: var(--color-indigo);
color: var(--color-indigo-lighter);
}
.presetActive {
border-color: #f472b6;
color: #f9a8d4;
background: rgba(244, 114, 182, 0.1);
border-color: var(--color-pink);
color: var(--color-pink-light);
background: rgba(var(--color-pink-rgb), 0.1);
}
.gridInput {
width: 56px;
padding: 3px 6px;
border-radius: 6px;
background: rgba(30, 27, 75, 0.8);
border: 1.5px solid #4b5563;
color: #e0e7ff;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--surface);
border: 1.5px solid var(--muted-border);
color: var(--color-text);
font-size: 0.85rem;
font-weight: 600;
text-align: center;
@ -137,21 +141,21 @@
}
.gridInput:focus {
border-color: #818cf8;
border-color: var(--color-indigo);
}
.gridEq {
font-size: 0.8rem;
color: #6b7280;
color: var(--muted-faint);
margin: 0;
}
.puzzleBtn {
padding: 8px 24px;
border-radius: 999px;
padding: 12px var(--space-4);
border-radius: var(--radius-pill);
background: transparent;
border: 2px solid #f472b6;
color: #f9a8d4;
border: 2px solid var(--color-pink);
color: var(--color-pink-light);
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
@ -159,7 +163,7 @@
}
.puzzleBtn:hover {
background: rgba(244, 114, 182, 0.12);
border-color: #e879f9;
color: #e879f9;
background: rgba(var(--color-pink-rgb), 0.12);
border-color: var(--color-pink-lighter);
color: var(--color-pink-lighter);
}

View File

@ -17,7 +17,7 @@
top: 16px;
left: 50%;
transform: translateX(-50%);
color: rgba(224, 231, 255, 0.5);
color: rgba(var(--color-text-rgb), 0.5);
font-size: 0.85rem;
pointer-events: none;
user-select: none;

View File

@ -4,16 +4,16 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
color: #e0e7ff;
gap: var(--space-4);
background: var(--bg-gradient);
color: var(--color-text);
}
.heading {
font-size: 2rem;
font-weight: 800;
margin: 0;
background: linear-gradient(90deg, #818cf8, #f472b6);
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -21,22 +21,22 @@
.sub {
font-size: 1.1rem;
color: #94a3b8;
color: var(--muted-text);
margin: 0;
}
.cards {
display: flex;
gap: 24px;
gap: var(--space-4);
flex-wrap: wrap;
justify-content: center;
}
.card {
background: rgba(30, 27, 75, 0.7);
border: 2px solid #818cf8;
border-radius: 16px;
padding: 32px 40px;
background: var(--surface);
border: 2px solid var(--color-indigo);
border-radius: var(--radius-lg);
padding: var(--space-5) var(--space-6);
width: 220px;
display: flex;
flex-direction: column;
@ -44,16 +44,18 @@
gap: 12px;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s;
color: #e0e7ff;
color: var(--color-text);
}
.card:hover {
border-color: #f472b6;
border-color: var(--color-pink);
transform: translateY(-4px);
}
.cardIcon {
font-size: 48px;
/* Dimmed relative to .cardDesc below it (rule 28). */
opacity: 0.85;
}
.cardTitle {
@ -62,8 +64,9 @@
}
.cardDesc {
font-size: 0.85rem;
color: #94a3b8;
/* Was 0.85rem (13.6px), under the 16px reading floor (rule 20). */
font-size: 1rem;
color: var(--muted-text);
text-align: center;
line-height: 1.4;
}
@ -71,11 +74,11 @@
.cardDisabled {
opacity: 0.4;
cursor: not-allowed;
border-color: #4b5563;
border-color: var(--muted-border);
}
.cardDisabled:hover {
border-color: #4b5563;
border-color: var(--muted-border);
transform: none;
}
@ -86,18 +89,19 @@
.gridRow {
display: flex;
align-items: center;
gap: 5px;
gap: var(--space-1);
flex-wrap: wrap;
justify-content: center;
margin-top: 4px;
}
.preset {
padding: 3px 9px;
border-radius: 6px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: transparent;
border: 1.5px solid #4b5563;
color: #94a3b8;
/* muted-text, not muted-border — same rule 7 contrast fix as DropZone. */
border: 1.5px solid var(--muted-text);
color: var(--muted-text);
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
@ -105,23 +109,28 @@
}
.preset:hover {
border-color: #818cf8;
color: #c7d2fe;
border-color: var(--color-indigo);
color: var(--color-indigo-lighter);
}
.presetActive {
border-color: #f472b6;
color: #f9a8d4;
background: rgba(244, 114, 182, 0.12);
border-color: var(--color-pink);
color: var(--color-pink-light);
/* 0.1, not 0.12 — matches DropZone's identical "active preset chip"
role; the two files had drifted to different alpha values. */
background: rgba(var(--color-pink-rgb), 0.1);
}
.gridInput {
width: 52px;
padding: 3px 5px;
border-radius: 6px;
background: rgba(15, 12, 41, 0.8);
border: 1.5px solid #4b5563;
color: #e0e7ff;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
/* --surface, not a raw rgba(15,12,41,...) literal — DropZone's sibling
gridInput already used the surface color; this one had drifted to a
different base (bg-1) for the identical component. */
background: var(--surface);
border: 1.5px solid var(--muted-border);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 600;
text-align: center;
@ -130,20 +139,24 @@
}
.gridInput:focus {
border-color: #818cf8;
border-color: var(--color-indigo);
}
.gridEq {
font-size: 0.78rem;
color: #6b7280;
color: var(--muted-faint);
margin: 2px 0 0;
}
.puzzleStart {
margin-top: 6px;
padding: 8px 20px;
border-radius: 999px;
background: linear-gradient(90deg, #f472b6, #818cf8);
padding: 10px 20px;
border-radius: var(--radius-pill);
/* --gradient-accent, not the stops written out reversed — this button
was pink->indigo while every other primary gradient in the app is
indigo->pink (same underlying bug the audit flagged in
PuzzleResult.module.css, just not cited by this exact line). */
background: var(--gradient-accent);
color: #fff;
font-weight: 700;
font-size: 0.9rem;

View File

@ -17,7 +17,7 @@
top: 16px;
left: 50%;
transform: translateX(-50%);
color: rgba(244, 114, 182, 0.7);
color: rgba(var(--color-pink-rgb), 0.7);
font-size: 0.9rem;
pointer-events: none;
user-select: none;

View File

@ -5,9 +5,9 @@
align-items: center;
justify-content: center;
gap: 28px;
padding: 32px 16px;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
color: #e0e7ff;
padding: var(--space-5) var(--space-3);
background: var(--bg-gradient);
color: var(--color-text);
}
.scoreBox {
@ -15,14 +15,16 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: var(--space-2);
}
.grade {
font-size: 6rem;
font-weight: 900;
line-height: 1;
background: linear-gradient(90deg, #f472b6, #818cf8);
/* Was reversed (pink->indigo) vs every other heading gradient in the
app (indigo->pink) — the bug DESIGN_AUDIT_TODO.md flagged. */
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -31,7 +33,7 @@
.pct {
font-size: 2rem;
font-weight: 700;
color: #a5b4fc;
color: var(--color-indigo-light);
}
.stats {
@ -40,18 +42,18 @@
}
.caught {
color: #34d399;
color: var(--color-success);
}
.grid {
display: grid;
gap: 3px;
gap: 4px;
max-width: min(480px, 90vw);
width: 100%;
border: 3px solid rgba(129, 140, 248, 0.4);
border-radius: 8px;
border: 3px solid rgba(var(--color-indigo-rgb), 0.4);
border-radius: var(--radius-md);
overflow: hidden;
background: #1e1b4b;
background: var(--surface-solid);
}
.piece {
@ -64,16 +66,16 @@
.hole {
width: 100%;
aspect-ratio: 1;
background: rgba(30, 27, 75, 0.9);
border: 1px dashed rgba(129, 140, 248, 0.2);
background: rgba(var(--surface-rgb), 0.9);
border: 1px dashed rgba(var(--color-indigo-rgb), 0.2);
}
.restartBtn {
padding: 10px 28px;
border-radius: 999px;
padding: 14px 28px;
border-radius: var(--radius-pill);
background: transparent;
border: 2px solid #f472b6;
color: #f9a8d4;
border: 2px solid var(--color-pink);
color: var(--color-pink-light);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
@ -81,6 +83,6 @@
}
.restartBtn:hover {
border-color: #818cf8;
color: #a5b4fc;
border-color: var(--color-indigo);
color: var(--color-indigo-light);
}

View File

@ -5,9 +5,9 @@
align-items: center;
justify-content: center;
gap: 28px;
padding: 32px 16px;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
color: #e0e7ff;
padding: var(--space-5) var(--space-3);
background: var(--bg-gradient);
color: var(--color-text);
}
.scoreBox {
@ -15,14 +15,14 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: var(--space-2);
}
.grade {
font-size: 6rem;
font-weight: 900;
line-height: 1;
background: linear-gradient(90deg, #818cf8, #f472b6);
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -31,27 +31,27 @@
.pct {
font-size: 2rem;
font-weight: 700;
color: #a5b4fc;
color: var(--color-indigo-light);
}
.stats {
display: flex;
gap: 24px;
gap: var(--space-4);
font-size: 1.1rem;
font-weight: 600;
}
.caught {
color: #34d399;
color: var(--color-success);
}
.missed {
color: #f87171;
color: var(--color-error);
}
.lists {
display: flex;
gap: 32px;
gap: var(--space-5);
flex-wrap: wrap;
justify-content: center;
max-width: 640px;
@ -61,7 +61,7 @@
.listTitle {
font-size: 1rem;
font-weight: 700;
color: #94a3b8;
color: var(--muted-text);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 8px;
@ -79,10 +79,10 @@
.fileItem {
display: flex;
align-items: center;
gap: 8px;
background: rgba(30, 27, 75, 0.7);
border-radius: 8px;
padding: 6px 12px;
gap: var(--space-2);
background: var(--surface);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: 0.9rem;
}
@ -100,7 +100,7 @@
}
.fsize {
color: #64748b;
color: var(--muted-faint);
font-size: 0.8rem;
}
@ -108,13 +108,13 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
gap: var(--space-3);
}
.actionBtn {
padding: 12px 32px;
border-radius: 999px;
background: linear-gradient(90deg, #818cf8, #f472b6);
padding: 16px var(--space-5);
border-radius: var(--radius-pill);
background: var(--gradient-accent);
color: #fff;
font-weight: 700;
font-size: 1rem;
@ -128,11 +128,11 @@
}
.restartBtn {
padding: 10px 28px;
border-radius: 999px;
padding: 14px 28px;
border-radius: var(--radius-pill);
background: transparent;
border: 2px solid #818cf8;
color: #a5b4fc;
border: 2px solid var(--color-indigo);
color: var(--color-indigo-light);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
@ -140,18 +140,18 @@
}
.restartBtn:hover {
border-color: #f472b6;
color: #f9a8d4;
border-color: var(--color-pink);
color: var(--color-pink-light);
}
.status {
color: #34d399;
color: var(--color-success);
font-weight: 600;
margin: 0;
}
.error {
color: #f87171;
color: var(--color-error);
font-weight: 600;
margin: 0;
}

View File

@ -1,3 +1,76 @@
/* Design tokens — the shared source of truth every *.module.css file
should reference instead of its own hardcoded hex literals. Previously
absent entirely (rule 4), which is how the same page-background/heading
gradients ended up copy-pasted verbatim across 4 files, one of them with
the gradient stops reversed (PuzzleResult.module.css, and ModeSelect's
puzzleStart button — same bug, two spots). */
:root {
/* Page background (3-stop gradient) */
--bg-1: #0f0c29;
--bg-2: #302b63;
--bg-3: #24243e;
--bg-gradient: linear-gradient(135deg, var(--bg-1), var(--bg-2), var(--bg-3));
/* Drag-over state (DropZone only) */
--bg-drag-1: #1a1560;
--bg-drag-2: #4a3f9e;
--bg-drag-3: #3a3868;
--bg-drag-gradient: linear-gradient(
135deg,
var(--bg-drag-1),
var(--bg-drag-2),
var(--bg-drag-3)
);
/* Accent pair. --gradient-accent is the ONE canonical direction
(indigo -> pink) for every heading/primary-button gradient in the
app — always use this token, never write the stops out by hand. */
--color-indigo: #818cf8;
--color-indigo-rgb: 129, 140, 248;
--color-pink: #f472b6;
--color-pink-rgb: 244, 114, 182;
--gradient-accent: linear-gradient(90deg, var(--color-indigo), var(--color-pink));
--color-indigo-light: #a5b4fc;
--color-indigo-lighter: #c7d2fe;
--color-pink-light: #f9a8d4;
--color-pink-lighter: #e879f9;
--color-text: #e0e7ff;
--color-text-rgb: 224, 231, 255;
/* Raised-surface fill (cards, file rows, the puzzle grid) — one base
color at different opacities/solidity, not independent literals. */
--surface-rgb: 30, 27, 75;
--surface: rgba(var(--surface-rgb), 0.7);
--surface-solid: rgb(var(--surface-rgb));
--color-success: #34d399;
--color-error: #f87171;
/* Muted grays — was 4 unrelated shades (rule 2); collapsed to 2 named,
semantically distinct roles: body-adjacent secondary text, and a
slightly darker tertiary/caption tone. (Borders get their own token
below, since one of them needed a contrast fix rule 7 flagged.) */
--muted-text: #94a3b8;
--muted-faint: #6b7280;
--muted-border: #4b5563;
/* Spacing scale (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--space-6: 40px;
/* Corner radius scale */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-pill: 999px;
}
*, *::before, *::after {
box-sizing: border-box;
}
@ -7,6 +80,6 @@ html, body, #root {
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f0c29;
color: #e0e7ff;
background: var(--bg-1);
color: var(--color-text);
}

View File

@ -0,0 +1,52 @@
{
"intent": "Introduce a real shared design-token system for bucket_catch's frontend (previously just a 12-line CSS reset) and fix every violation in DESIGN_AUDIT_TODO.md's bucket_catch section, including a real reversed-gradient-stop visual bug.",
"scope": [
"bucket_catch/packages/frontend/src/index.css",
"bucket_catch/packages/frontend/src/components/DropZone.module.css",
"bucket_catch/packages/frontend/src/components/GameCanvas.module.css",
"bucket_catch/packages/frontend/src/components/ModeSelect.module.css",
"bucket_catch/packages/frontend/src/components/PuzzleCanvas.module.css",
"bucket_catch/packages/frontend/src/components/PuzzleResult.module.css",
"bucket_catch/packages/frontend/src/components/ScoreScreen.module.css"
],
"changes": [
"index.css: added a :root token block (bg gradient stops, drag-state gradient, indigo/pink accent pair + one canonical --gradient-accent direction, text/surface colors, success/error, 2 consolidated muted-gray roles down from 4 unrelated shades, a 4px spacing scale, and a radius scale)",
"Migrated all 19 hardcoded hex colors across the 6 *.module.css files to reference the new tokens",
"Fixed the reversed gradient-stop bug the audit named (PuzzleResult.module.css .grade) plus a second instance the audit's prose didn't cite by line number but is the same bug (ModeSelect.module.css .puzzleStart) -- both now use --gradient-accent so this class of bug can't recur",
"Rule 7: .preset border color (DropZone + ModeSelect) changed from muted-border to muted-text -- the original measured under 3:1 against the page background",
"Rule 20: ModeSelect .cardDesc font-size 0.85rem -> 1rem (16px floor)",
"Rule 22: 4 button classes' vertical padding adjusted so horizontal is exactly 2x vertical (browseBtn/puzzleBtn 8->12px, actionBtn 12->16px, both restartBtns 10->14px, puzzleStart 8->10px)",
"Rule 28: DropZone .icon and ModeSelect .cardIcon (large emoji) given 0.85 opacity, dimming them relative to the adjacent muted body text",
"Rule 11: the explicitly-cited off-grid values (3px/5px/10px) snapped to the 4px grid; also fixed ModeSelect's .gridInput background, which had drifted to a different base color (bg-1) than its sibling in DropZone (surface) for the identical component",
"Rule 24 (nested corner radius on the puzzle .grid/.piece/.hole) was reviewed and deliberately left as-is: forcing a radius onto every tessellating grid cell would look worse, not more deliberate, than the current overflow:hidden-clips-the-outer-silhouette approach, which is a standard and correct technique for this exact case"
],
"verification": [
{
"command": "npm run coverage (vitest)",
"result": "pass",
"evidence": "14 test files, 145 tests passed, 100% statements/branches/functions/lines (CSS-only change, so this confirms no functional regression, not the visual fix itself)"
},
{
"command": "npx vite build (bypassing the pre-existing, unrelated broken `tsc -b` step in npm run build)",
"result": "pass",
"evidence": "52 modules transformed, dist/ produced"
},
{
"command": "isolated git worktree re-run of tests + vite build at the commit's parent HEAD",
"result": "pass",
"evidence": "identical results to the dirty-tree run"
},
{
"command": "python http.server + headless-Chrome screenshot of the real production build",
"result": "pass",
"evidence": "visually confirmed: heading and primary button now share the same indigo->pink gradient direction, preset chips have a clearly visible lighter border, no layout breakage"
}
],
"risks": [
"npm run lint (tsc -b + eslint) was already broken on a clean, unmodified checkout before this change -- 1 pre-existing tsc error (vite.config.ts, an unrelated overload mismatch) and 46 pre-existing eslint errors (all in test/lib .ts files this change never touched, mostly @typescript-eslint/unbound-method). Confirmed via an isolated worktree at the parent commit. Left alone as out of scope for a CSS-only design-token change.",
"No deploy target for this repo (local dev tool) -- vite build + a static-server screenshot is the real verification ceiling, not a live check"
],
"rollback": [
"git revert this commit -- the change is confined to 7 CSS files, no other files coupled to it"
]
}