diff --git a/AGENTS.md b/AGENTS.md index 162b1d8..303480d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,21 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). **Key Features:** - Search over curated repository listing (`repositories.txt`) -- Breadcrumb trail under the menu (`components/breadcrumbs.jsx` + - `lib/breadcrumbs.js`): mirrors `/user/repo/branch` with linked ancestors; +- Dark mode, 100% CSS switching (`components/theme-toggle.jsx` + + `plans/css-only-dark-mode.md`): three radios (light/dark/system) on the + right side of the `.theme-bar` row below the header, rendered by + `app/layout.jsx` — the breadcrumb trail shares the row on report routes + (crumbs left, toggle right, vertically centered; the right-edge mirror + of the breadcrumbs' left-edge alignment) — palettes selected in + `app/globals.css` via `:root:has(#rf-theme-…:checked)` + + `prefers-color-scheme`; an inline pre-paint script in `app/layout.jsx` + only restores/persists the choice (CSP-hashed by the build). Guards: + `tests/unit/theme-css.test.js` (the two dark blocks must stay + byte-identical; all hex colors live in the palette blocks; dark palette + passes the same WCAG AA math as `tests/unit/css-a11y.test.js`) +- Breadcrumb trail on the theme bar row under the menu + (`components/breadcrumbs.jsx` + `lib/breadcrumbs.js`): mirrors + `/user/repo/branch` with linked ancestors; the repo crumb never self-links, and ReportView announces the resolved branch (`rf:branch-resolved` event) so the label matches the loaded report (`main` 404 → `master` fallback) @@ -130,6 +143,14 @@ coverage there when introducing new markup patterns. - **Environment config:** meta tags in `app/layout.jsx` (`submission-target`, `sentry-dsn`, `platform-base-url`) plus `NEXT_PUBLIC_HOSTING_ENVIRONMENT` / `NEXT_PUBLIC_BASE_PATH` at build time. +- **Theming:** see the dark-mode bullet above — the radios in + `components/theme-toggle.jsx` are the single source of truth; never add + JS-driven styling. All colors must be custom properties declared only in + the four palette blocks (`:root`, `html:root` mvp overrides, and the two + identical dark blocks) in `app/globals.css`; mvp.css light overrides must + stay on `html:root` (mvp loads after the bundle). Report-template + widgets that hardcode light colors are re-driven from `main#app …` + rules there (the mustache stays byte-identical). ## Report Tables (Enhanced) @@ -178,7 +199,7 @@ coverage there when introducing new markup patterns. ## Current Test Count -~543 unit/integration + ~202 E2E (~198 across three browsers + 4 basePath leg). +~584 unit/integration + ~229 E2E (~202 across three browsers + 4 basePath leg + 27 dark mode). WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and diff --git a/app/globals.css b/app/globals.css index 1d84ad2..69edd5f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,17 +1,43 @@ /* Main stylesheet: CSS custom properties, base layout, footer. */ +/* ---- Color palettes (dark mode: plans/css-only-dark-mode.md) ---- */ +/* The theme is selected purely in CSS: the three radios in + * components/theme-toggle.jsx are the single source of truth. + * - explicit "dark" radio -> :root:has(#rf-theme-dark:checked) below; + * - "system" radio + dark OS -> the prefers-color-scheme block below; + * - anything else -> this light :root palette. + * :has() blocks (1,2,0) beat mvp.css's later :root (0,1,0); the light mvp + * overrides therefore need html:root (0,1,1) to survive the load order. */ + :root { + color-scheme: light; --brand-color: #2a6f97; /* Darker accent: white text on it passes WCAG 2.2 AA contrast (1.4.3) and it serves as the visible focus indicator (2.4.11, >= 3:1 against the white page background). */ --brand-accent: #1f5273; + /* --brand-color is an accent *background* (white text sits on it, in both + palettes). Accent *text* (links, wordmark) needs its own role: #2a6f97 + as text fails AA on the dark page background. */ + --link-color: #2a6f97; + --on-accent-color: #ffffff; --text-color: #1f2d3d; --muted-color: #5c6b7a; --bg-color: #ffffff; --surface-color: #f5f8fa; + --card-bg: #ffffff; + --border-color: #dde4ea; + --input-border: #c6d2dc; --error-color: #c0392b; --success-color: #1e8449; + --error-bg: #fdf6f5; + --error-border: #f0c6c0; + --btn-hover-bg: #e8f0f6; + --btn-disabled-bg: #f3f4f6; + --btn-disabled-text: #6b7280; + --popup-border: #cccccc; + --toast-bg: #1f2933; + --toast-text: #ffffff; --menu-max-height: 140px; --menu-height: 48px; --radius: 6px; @@ -22,6 +48,106 @@ --width-content: min(3840px, 95vw); } +/* mvp.css re-declares its variables on :root AFTER this bundle loads, so + same-specificity overrides lose the cascade. html:root (0,1,1) wins over + mvp's :root (0,1,0) and themes every mvp-styled element (inputs, quotes, + table headers, zebra rows, scrollbars) via mvp's own variable plumbing. */ +html:root { + --color-bg: var(--bg-color); + --color-text: var(--text-color); + --color-bg-secondary: var(--surface-color); + --color-text-secondary: var(--muted-color); + --color-link: var(--link-color); + --color-secondary: var(--brand-accent); + --color-secondary-accent: rgba(42, 111, 151, 0.08); + --color-accent: rgba(42, 111, 151, 0.08); + --color-shadow: rgba(0, 0, 0, 0.12); + --color-table: var(--brand-color); + --color-scrollbar: var(--input-border); +} + +/* Dark palette. The two blocks below MUST stay identical: one applies the + explicit dark radio at any OS setting, the other applies the system radio + only when the OS prefers dark. tests/unit/theme-css.test.js guards the + duplication byte-for-byte. */ +:root:has(#rf-theme-dark:checked) { + color-scheme: dark; + --brand-color: #2a6f97; + /* Focus indicator / hover accent on the dark page: >= 3:1 against both + --bg-color (3.8:1) and --surface-color (3.3:1), and 4.8:1 against white, + so the light-mode guards keep passing under their last-occurrence + variable resolution (tests/unit/css-a11y.test.js). */ + --brand-accent: #3178a6; + --link-color: #6fb1d9; + --on-accent-color: #ffffff; + --text-color: #e6edf3; + --muted-color: #9fb0c0; + --bg-color: #10161d; + --surface-color: #1a2330; + --card-bg: #1a2330; + --border-color: #2c3a4d; + --input-border: #5b7396; + --error-color: #f0928c; + --success-color: #5bbd8b; + --error-bg: #2b1d1f; + --error-border: #7a4a48; + --btn-hover-bg: #243244; + --btn-disabled-bg: #232a35; + --btn-disabled-text: #8a97a8; + --popup-border: #3a4b61; + --toast-bg: #1f2933; + --toast-text: #ffffff; + --color-bg: var(--bg-color); + --color-text: var(--text-color); + --color-bg-secondary: var(--surface-color); + --color-text-secondary: var(--muted-color); + --color-link: var(--link-color); + --color-secondary: var(--link-color); + --color-secondary-accent: rgba(111, 177, 217, 0.12); + --color-accent: rgba(111, 177, 217, 0.1); + --color-shadow: rgba(0, 0, 0, 0.33); + --color-table: var(--link-color); + --color-scrollbar: #566d8f; +} + +@media (prefers-color-scheme: dark) { + :root:has(#rf-theme-system:checked) { + color-scheme: dark; + --brand-color: #2a6f97; + --brand-accent: #3178a6; + --link-color: #6fb1d9; + --on-accent-color: #ffffff; + --text-color: #e6edf3; + --muted-color: #9fb0c0; + --bg-color: #10161d; + --surface-color: #1a2330; + --card-bg: #1a2330; + --border-color: #2c3a4d; + --input-border: #5b7396; + --error-color: #f0928c; + --success-color: #5bbd8b; + --error-bg: #2b1d1f; + --error-border: #7a4a48; + --btn-hover-bg: #243244; + --btn-disabled-bg: #232a35; + --btn-disabled-text: #8a97a8; + --popup-border: #3a4b61; + --toast-bg: #1f2933; + --toast-text: #ffffff; + --color-bg: var(--bg-color); + --color-text: var(--text-color); + --color-bg-secondary: var(--surface-color); + --color-text-secondary: var(--muted-color); + --color-link: var(--link-color); + --color-secondary: var(--link-color); + --color-secondary-accent: rgba(111, 177, 217, 0.12); + --color-accent: rgba(111, 177, 217, 0.1); + --color-shadow: rgba(0, 0, 0, 0.33); + --color-table: var(--link-color); + --color-scrollbar: #566d8f; + } +} + body { margin: 0; color: var(--text-color); @@ -33,7 +159,7 @@ body { left: -9999px; top: 0; background: var(--brand-color); - color: #fff; + color: var(--on-accent-color); padding: 0.5rem 1rem; z-index: 100; } @@ -89,7 +215,7 @@ main section { } .hero h1 { - color: var(--brand-color); + color: var(--link-color); margin-bottom: 0.25rem; } @@ -108,7 +234,7 @@ main section { } a { - color: var(--brand-color); + color: var(--link-color); } code { @@ -133,7 +259,7 @@ pre { /* ---- Top navigation (max 140px, mobile hamburger) ---- */ #top-menu { background: var(--surface-color); - border-bottom: 1px solid #dde4ea; + border-bottom: 1px solid var(--border-color); /* mvp.css adds generous header padding; reset it so max-height bounds the whole rendered box (padding contributes outside max-height). */ padding: 0; @@ -157,7 +283,7 @@ pre { align-items: center; gap: 0.4rem; font-weight: bold; - color: var(--brand-color); + color: var(--link-color); text-decoration: none; white-space: nowrap; font-size: 0.9rem; @@ -176,14 +302,14 @@ pre { .menu-search input { width: 100%; padding: 0.35rem 0.6rem; - border: 1px solid #c6d2dc; + border: 1px solid var(--input-border); border-radius: var(--radius); } .menu-toggle { display: none; background: none; - border: 1px solid #c6d2dc; + border: 1px solid var(--input-border); border-radius: var(--radius); font-size: 1.1rem; padding: 0.25rem 0.6rem; @@ -221,25 +347,25 @@ pre { .menu-links a:hover, .menu-links a:focus-visible { background: var(--brand-color); - color: #fff; + color: var(--on-accent-color); } /* ---- Breadcrumbs (user/report trail under the top menu) ---- */ .breadcrumbs { - /* Same column as the top menu: #top-menu is capped by mvp.css at - --width-content, so reuse the variable — the two can never drift apart. - border-box (not mvp's default content-box) so the horizontal padding - insets crumbs exactly like .menu-bar's padding insets the brand; with - content-box, symmetric padding on a centered block never moves the - content edge. */ - max-width: var(--width-content); - box-sizing: border-box; + /* Lives on the .theme-bar row (rendered by app/layout.jsx), sharing the + line with the color theme toggle: the row owns the menu-column + alignment formula (max-width: var(--width-content) + 0.8rem horizontal + padding, see .theme-bar above), so the trail must add no box-model + offsets of its own — flex-grow fills the row's left side, keeping the + first crumb's glyph at the menu bar's left content edge while the + toggle pins to the right edge, both vertically centered. */ + flex: 1 1 auto; + min-width: 0; /* mvp.css gives bare nav a 7rem bottom margin, bold text and a spaced flex - row — reset all three so the trail hugs the menu like a sub-bar. */ - margin: 0 auto; + row — reset all three so the trail sits inline like a sub-bar. */ + margin: 0; font-weight: normal; display: block; - padding: 0 0.8rem; font-size: 0.85rem; } @@ -283,8 +409,8 @@ pre { margin: 0.15rem 0 0; padding: 0; list-style: none; - background: #fff; - border: 1px solid #c6d2dc; + background: var(--card-bg); + border: 1px solid var(--input-border); border-radius: var(--radius); box-shadow: var(--shadow); max-height: 16rem; @@ -299,7 +425,7 @@ pre { .search-results li.active, .search-results li:hover { background: var(--brand-color); - color: #fff; + color: var(--on-accent-color); } .hero-search { @@ -316,7 +442,7 @@ pre { width: 100%; box-sizing: border-box; padding: 0.35rem 0.6rem; - border: 1px solid #c6d2dc; + border: 1px solid var(--input-border); border-radius: var(--radius); } @@ -336,18 +462,18 @@ pre { .repo-card { display: block; padding: 0.85rem 1rem; - border: 1px solid #dde4ea; + border: 1px solid var(--border-color); border-radius: var(--radius); text-decoration: none; color: var(--text-color); - background: #fff; + background: var(--card-bg); box-shadow: var(--shadow); } .repo-card:hover, .repo-card:focus-visible { border-color: var(--brand-color); - color: var(--brand-color); + color: var(--link-color); } /* ---- Pagination ---- */ @@ -360,7 +486,7 @@ pre { .pagination .page { padding: 0.35rem 0.65rem; - border: 1px solid #c6d2dc; + border: 1px solid var(--input-border); border-radius: var(--radius); text-decoration: none; } @@ -368,7 +494,7 @@ pre { .pagination .page.current { background: var(--brand-color); border-color: var(--brand-color); - color: #fff; + color: var(--on-accent-color); } /* ---- User listing (/user) ---- */ @@ -412,7 +538,7 @@ pre { .cta { display: inline-block; background: var(--brand-color); - color: #fff; + color: var(--on-accent-color); padding: 0.6rem 1.2rem; border-radius: var(--radius); border: none; @@ -423,7 +549,7 @@ pre { .cta:hover { background: var(--brand-accent); - color: #fff; + color: var(--on-accent-color); } /* ---- Error pages ---- */ @@ -432,9 +558,9 @@ pre { margin: 3rem auto; text-align: center; padding: 2rem; - border: 1px solid #f0c6c0; + border: 1px solid var(--error-border); border-radius: var(--radius); - background: #fdf6f5; + background: var(--error-bg); } .error-page h1 { @@ -455,7 +581,7 @@ pre { .error-actions .retry { background: var(--brand-color); - color: #fff; + color: var(--on-accent-color); border: none; border-radius: var(--radius); padding: 0.5rem 1rem; @@ -533,8 +659,8 @@ pre { gap: 0.75rem; padding: 0.6rem 0.9rem; border-radius: var(--radius); - background: #1f2933; - color: #fff; + background: var(--toast-bg); + color: var(--toast-text); box-shadow: var(--shadow); pointer-events: auto; } @@ -546,7 +672,7 @@ pre { .rf-toast-dismiss { background: none; border: none; - color: #fff; + color: var(--toast-text); font-size: 1.1rem; line-height: 1; cursor: pointer; @@ -557,3 +683,121 @@ pre { outline: 2px solid var(--brand-color); outline-offset: 2px; } + +/* ---- Color theme toggle (three radio modes, pure-CSS switching) ---- */ +/* Row below the site header (rendered by app/layout.jsx): the breadcrumb + trail (when the route has one) on the left and the toggle on the right, + vertically centered on one line. The alignment formula .breadcrumbs used + to carry lives here now: --width-content column, 0.8rem horizontal + padding, border-box — keeping the first crumb's glyph at the menu bar's + left content edge and the toggle at its right content edge at every + viewport, the two edges mirroring each other. #top-menu is capped by + mvp.css at --width-content, so the row and the menu bar can never drift + apart. Crumb routes grow the trail (flex: 1 1 auto); crumb-less routes + leave only the toggle, pinned right by flex-end. */ +.theme-bar { + display: flex; + align-items: center; + justify-content: flex-end; + max-width: var(--width-content); + box-sizing: border-box; + margin: 0 auto; + padding: 0.4rem 0.8rem 0.45rem; +} + +/* The radios in components/theme-toggle.jsx are the theme's single source of + truth (see the palette blocks at the top of this file). Each input is + visually replaced by its icon pill (appearance: none, absolute, fills the + 28px label) but stays keyboard-focusable — the global :focus-visible rule + draws the indicator — and the checked pill doubles as the selected state, + so no JS is involved in switching. The control renders as a surface chip + so it stands out from the page background. */ +.theme-toggle { + display: flex; + align-items: center; + gap: 0.15rem; + flex: 0 0 auto; + padding: 0.15rem; + border: 1px solid var(--input-border); + border-radius: var(--radius); + background: var(--surface-color); +} + +.theme-option { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; /* 28px >= 24px minimum target size (SC 2.5.8) */ + height: 1.75rem; + border-radius: 5px; +} + +.theme-option:hover { + background: var(--btn-hover-bg); +} + +.theme-option-input { + appearance: none; + position: absolute; + inset: 0; + margin: 0; + border-radius: 5px; + cursor: pointer; +} + +.theme-option-input:checked { + background: var(--brand-color); +} + +.theme-option-icon { + /* Relative so the glyph paints above the checked input's pill. */ + position: relative; + width: 1.15rem; + height: 1.15rem; + color: var(--text-color); + pointer-events: none; +} + +.theme-option-input:checked + .theme-option-icon { + color: var(--on-accent-color); +} + +/* ---- Report template theme overrides ---- */ +/* The bundled mustache template (public/assets/refactor-first-report.mustache) + hardcodes light values for a few report widgets; it stays byte-identical, + so these main#app rules (1,1,1) beat its injected