Skip to content

Commit fb85732

Browse files
Getting closer
1 parent db4529e commit fb85732

7 files changed

Lines changed: 87 additions & 69 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket).
1212
- Search over curated repository listing (`repositories.txt`)
1313
- Dark mode, 100% CSS switching (`components/theme-toggle.jsx` +
1414
`plans/css-only-dark-mode.md`): three radios (light/dark/system) on a
15-
right-aligned `.theme-bar` sub-row under the menu bar (the right-edge
16-
mirror of the breadcrumbs' left-edge alignment), palettes selected in
17-
`app/globals.css` via `:root:has(#rf-theme-…:checked)` +
15+
right-aligned `.theme-bar` row below the header, rendered by
16+
`app/layout.jsx` between `<SiteHeader>` and the persistence script (the
17+
right-edge mirror of the breadcrumbs' left-edge alignment), palettes
18+
selected in `app/globals.css` via `:root:has(#rf-theme-…:checked)` +
1819
`prefers-color-scheme`; an inline pre-paint script in `app/layout.jsx`
1920
only restores/persists the choice (CSP-hashed by the build). Guards:
2021
`tests/unit/theme-css.test.js` (the two dark blocks must stay

‎app/globals.css‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -685,27 +685,28 @@ pre {
685685
}
686686

687687
/* ---- Color theme toggle (three radio modes, pure-CSS switching) ---- */
688-
/* Sub-row under the menu bar: the same alignment formula .breadcrumbs use
689-
(--width-content column, 0.8rem horizontal padding, border-box) keeps the
690-
toggle's right edge flush with the menu bar's right content edge at every
691-
viewport — the right-edge mirror of the breadcrumb trail's left-edge
692-
alignment. #top-menu is capped by mvp.css at --width-content, so the row
693-
and the menu bar can never drift apart. */
688+
/* Row below the site header (rendered by app/layout.jsx): the same alignment
689+
formula .breadcrumbs use (--width-content column, 0.8rem horizontal
690+
padding, border-box) keeps the toggle's right edge flush with the menu
691+
bar's right content edge at every viewport — the right-edge mirror of the
692+
breadcrumb trail's left-edge alignment. #top-menu is capped by mvp.css at
693+
--width-content, so the row and the menu bar can never drift apart. */
694694
.theme-bar {
695695
display: flex;
696696
justify-content: flex-end;
697697
max-width: var(--width-content);
698698
box-sizing: border-box;
699699
margin: 0 auto;
700-
padding: 0.2rem 0.8rem 0.45rem;
700+
padding: 0.4rem 0.8rem 0.45rem;
701701
}
702702

703703
/* The radios in components/theme-toggle.jsx are the theme's single source of
704704
truth (see the palette blocks at the top of this file). Each input is
705705
visually replaced by its icon pill (appearance: none, absolute, fills the
706706
28px label) but stays keyboard-focusable — the global :focus-visible rule
707707
draws the indicator — and the checked pill doubles as the selected state,
708-
so no JS is involved in switching. */
708+
so no JS is involved in switching. The control renders as a surface chip
709+
so it stands out from the page background. */
709710
.theme-toggle {
710711
display: flex;
711712
align-items: center;
@@ -714,7 +715,7 @@ pre {
714715
padding: 0.15rem;
715716
border: 1px solid var(--input-border);
716717
border-radius: var(--radius);
717-
background: var(--bg-color);
718+
background: var(--surface-color);
718719
}
719720

720721
.theme-option {
@@ -728,7 +729,7 @@ pre {
728729
}
729730

730731
.theme-option:hover {
731-
background: var(--surface-color);
732+
background: var(--btn-hover-bg);
732733
}
733734

734735
.theme-option-input {

‎app/layout.jsx‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SiteHeader from '../components/site-header';
33
import SiteFooter from '../components/site-footer';
44
import Breadcrumbs from '../components/breadcrumbs';
55
import SentryProvider from '../components/sentry-provider';
6+
import ThemeToggle from '../components/theme-toggle';
67
import { loadListedRepositories } from '../lib/repositories.js';
78
import './globals.css';
89

@@ -48,13 +49,25 @@ export default function RootLayout({ children }) {
4849
</head>
4950
<body>
5051
<SiteHeader repositories={repositories} />
52+
{/* Color theme toggle on a row below the header: .theme-bar reuses
53+
the breadcrumbs' alignment formula (--width-content column,
54+
0.8rem horizontal padding, border-box) so the toggle's right
55+
edge sits flush with the menu bar's right content edge,
56+
mirroring how the breadcrumb trail aligns with the bar's left
57+
edge (plans/css-only-dark-mode.md). */}
58+
<div className="theme-bar">
59+
<ThemeToggle />
60+
</div>
5161
{/* Theme persistence shim (plans/css-only-dark-mode.md): switching
5262
itself is pure CSS (radio + :has() in globals.css); this inline
5363
script only restores the saved choice into the radios before
54-
first paint and records changes. It sets the checked *property*
55-
(never the attribute), so React hydration never sees a mismatch.
56-
scripts/fix-csp-hashes.mjs hashes it into the CSP at build time;
57-
with JS disabled the toggle still works per page view. */}
64+
first paint and records changes. It must stay AFTER the
65+
.theme-bar markup (the radios have to be parsed before it can
66+
check them) and before any meaningful page content. It sets the
67+
checked *property* (never the attribute), so React hydration
68+
never sees a mismatch. scripts/fix-csp-hashes.mjs hashes it into
69+
the CSP at build time; with JS disabled the toggle still works
70+
per page view. */}
5871
<script
5972
dangerouslySetInnerHTML={{
6073
__html:

‎components/site-header.jsx‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import Link from 'next/link';
22
import MenuToggle from './menu-toggle';
33
import MenuSearch from './menu-search';
4-
import ThemeToggle from './theme-toggle';
54
import { withBasePath } from '../lib/base-path';
65

76
// Site header: brand logo, hamburger toggle, main navigation links and the
8-
// type-ahead menu search in the top bar, plus the three-mode color theme
9-
// toggle on a right-aligned sub-row below the bar.
7+
// type-ahead menu search. The color theme toggle lives below the header in
8+
// app/layout.jsx (.theme-bar row).
109
export default function SiteHeader({ repositories = [] }) {
1110
return (
1211
<>
@@ -35,15 +34,6 @@ export default function SiteHeader({ repositories = [] }) {
3534
</ul>
3635
<MenuSearch repositories={repositories} />
3736
</nav>
38-
{/* Theme sub-row below the menu bar: .theme-bar reuses the
39-
breadcrumbs' alignment formula (--width-content column, 0.8rem
40-
horizontal padding, border-box) so the toggle's right edge sits
41-
flush with the menu bar's right content edge, mirroring how the
42-
breadcrumb trail aligns with the bar's left edge
43-
(plans/css-only-dark-mode.md). */}
44-
<div className="theme-bar">
45-
<ThemeToggle />
46-
</div>
4737
</header>
4838
</>
4939
);

‎plans/css-only-dark-mode.md‎

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Goals
44

55
1. Three-mode theme control — **light / dark / system** — with small images
6-
(icons) for each mode, on a sub-row below the top menu bar.
6+
(icons) for each mode, on a row below the site header.
77
2. The control's right edge aligns with the menu bar's right content edge at
88
every viewport (mirroring how the breadcrumb trail aligns with the menu's
99
left edge).
@@ -19,8 +19,9 @@
1919
renders three radios named `rf-theme` (`rf-theme-light`, `rf-theme-dark`,
2020
`rf-theme-system`, with `system` `defaultChecked`), each wrapped in a labelled
2121
icon pill (inline SVG: sun / moon / monitor). The toggle sits on the
22-
**`.theme-bar` sub-row under `.menu-bar`**, inside `#top-menu`, right-aligned
23-
with the menu bar's content edge.
22+
**`.theme-bar` row below `#top-menu`**, rendered by `app/layout.jsx` right
23+
after `<SiteHeader />` (and before the persistence shim, which needs the
24+
radios parsed), right-aligned with the menu bar's content edge.
2425

2526
Why radios: "one of three modes" is exactly a radio group, the checked state
2627
is queryable from CSS (`:has(#id:checked)`), and switching requires zero JS.
@@ -122,15 +123,16 @@ practices" and "only CSS" conflict here, so the plan resolves it as:
122123

123124
### Alignment with the menu
124125

125-
The toggle lives on a `.theme-bar` sub-row inside `#top-menu`, right after
126-
`.menu-bar`, and pinned right via `justify-content: flex-end`. The row
127-
reuses the breadcrumbs' exact alignment formula — `max-width:
128-
var(--width-content)`, `box-sizing: border-box`, `margin: 0 auto`,
129-
`padding: 0.2rem 0.8rem 0.45rem` (see `tests/unit/layout-styles.test.js` for
130-
the breadcrumb invariant this mirrors) — so its right content edge is the
131-
menu bar's right content edge at every viewport, with no positioning tricks
132-
or magic numbers; the formula works identically at mobile widths, where the
133-
sub-row simply follows the wrapped menu bar.
126+
The toggle lives on a `.theme-bar` row rendered by `app/layout.jsx` right
127+
after `<SiteHeader />`, below the header block, pinned right via
128+
`justify-content: flex-end`. The row reuses the breadcrumbs' exact alignment
129+
formula — `max-width: var(--width-content)`, `box-sizing: border-box`,
130+
`margin: 0 auto`, `padding: 0.4rem 0.8rem 0.45rem` (see
131+
`tests/unit/layout-styles.test.js` for the breadcrumb invariant this mirrors)
132+
— so its right content edge is the menu bar's right content edge at every
133+
viewport, with no positioning tricks or magic numbers; the formula works
134+
identically at mobile widths, where the row simply follows the wrapped menu
135+
bar.
134136

135137
### Toggle UI / a11y
136138

@@ -191,8 +193,9 @@ mustache-rendered).
191193
## Implementation steps
192194

193195
1. Write the failing unit/integration tests (above).
194-
2. `components/theme-toggle.jsx` + render it in `components/site-header.jsx`
195-
on a `.theme-bar` sub-row after `.menu-bar`.
196+
2. `components/theme-toggle.jsx` + render it in `app/layout.jsx` on a
197+
`.theme-bar` row right after `<SiteHeader />`, before the persistence
198+
shim.
196199
3. globals.css: split color roles, sweep hardcoded colors into variables, add
197200
the three cascade blocks + mvp variable overrides + dark overrides for the
198201
report template's light-only rules + `.theme-bar`/toggle styles.
@@ -254,7 +257,7 @@ mustache-rendered).
254257
were pre-verified (dark bg/text ≈13:1, muted ≈8:1, links ≈7.7:1, focus
255258
indicator 3.8:1 on the page bg (3.3:1 on surfaces), brand-bg+white 5.5:1,
256259
error/success ≈8:1).
257-
10. **Alignment is structural, not geometric** — the `.theme-bar` sub-row
260+
10. **Alignment is structural, not geometric** — the `.theme-bar` row
258261
reuses the breadcrumbs' `--width-content` column + 0.8rem padding formula,
259262
so the toggle's right edge is the menu bar's right content edge at every
260263
viewport with zero positioning code; the E2E test pins it with a

‎tests/e2e/dark-mode.spec.js‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,38 @@ test('the toggle right edge aligns with the top menu content edge', async ({ pag
3131
await page.setViewportSize({ width: 1280, height: 900 });
3232
await page.goto('/');
3333
const edges = await page.evaluate(() => {
34+
const header = document.getElementById('top-menu').getBoundingClientRect();
3435
const bar = document.querySelector('.menu-bar').getBoundingClientRect();
3536
const toggle = document.querySelector('.theme-toggle').getBoundingClientRect();
3637
const style = getComputedStyle(document.querySelector('.menu-bar'));
3738
return {
3839
toggleRight: toggle.right,
3940
// The menu bar's right *content* edge (its 0.8rem padding insets the
40-
// search input exactly like the sub-row padding insets the toggle).
41-
barContentRight: bar.right - parseFloat(style.paddingRight)
41+
// search input exactly like the row padding insets the toggle).
42+
barContentRight: bar.right - parseFloat(style.paddingRight),
43+
belowHeader: toggle.top >= header.bottom
4244
};
4345
});
4446
expect(Math.abs(edges.toggleRight - edges.barContentRight)).toBeLessThanOrEqual(1);
47+
expect(edges.belowHeader).toBe(true);
4548
});
4649

47-
test('the toggle sits below the menu bar, right-aligned, at mobile widths', async ({ page }) => {
50+
test('the toggle sits below the header, right-aligned, at mobile widths', async ({ page }) => {
4851
test.skip(test.info().project.name !== 'chromium', 'geometry check runs once');
4952
await page.setViewportSize({ width: 375, height: 800 });
5053
await page.goto('/');
5154
const edges = await page.evaluate(() => {
55+
const header = document.getElementById('top-menu').getBoundingClientRect();
5256
const bar = document.querySelector('.menu-bar').getBoundingClientRect();
5357
const toggle = document.querySelector('.theme-toggle').getBoundingClientRect();
5458
return {
5559
toggleRight: toggle.right,
5660
barContentRight: bar.right - parseFloat(getComputedStyle(document.querySelector('.menu-bar')).paddingRight),
57-
belowBar: toggle.top >= bar.bottom
61+
belowHeader: toggle.top >= header.bottom
5862
};
5963
});
6064
expect(Math.abs(edges.toggleRight - edges.barContentRight)).toBeLessThanOrEqual(1);
61-
expect(edges.belowBar).toBe(true);
65+
expect(edges.belowHeader).toBe(true);
6266
});
6367

6468
test('light OS preference renders light by default', async ({ page }) => {

‎tests/integration/theme-toggle.test.jsx‎

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
// Dark mode toggle under the top menu: three radio modes (light / dark /
1+
// Dark mode toggle below the site header: three radio modes (light / dark /
22
// system) rendered as icon pills, with the system preference preselected and
3-
// the whole control on a sub-row below the menu bar whose right edge aligns
4-
// with the menu's content edge (plans/css-only-dark-mode.md).
3+
// the whole control on a right-aligned .theme-bar row under the header whose
4+
// right edge aligns with the menu bar's content edge
5+
// (plans/css-only-dark-mode.md).
56
import { describe, test, expect } from 'bun:test';
67
import { mock } from 'bun:test';
78

@@ -14,8 +15,9 @@ mock.module('next/navigation', () => sharedNextNavigationMock({
1415

1516
import { render, installRtlDom, within } from './rtl';
1617
import { jsx as _jsx } from 'react/jsx-runtime';
18+
import { renderToStaticMarkup } from 'react-dom/server';
1719
import ThemeToggle from '../../components/theme-toggle';
18-
import SiteHeader from '../../components/site-header';
20+
import RootLayout from '../../app/layout';
1921

2022
installRtlDom();
2123

@@ -59,21 +61,25 @@ describe('ThemeToggle', () => {
5961
});
6062
});
6163

62-
describe('SiteHeader placement', () => {
63-
test('the toggle sits on a sub-row below the menu bar, inside the header', () => {
64-
render(_jsx(SiteHeader, { repositories: [] }));
65-
const header = document.getElementById('top-menu');
66-
const bar = document.querySelector('.menu-bar');
67-
const themeBar = header.querySelector('.theme-bar');
68-
const toggle = themeBar.querySelector('.theme-toggle');
69-
expect(themeBar).toBeTruthy();
70-
expect(toggle).toBeTruthy();
71-
// The sub-row comes after the menu bar and holds only the toggle.
72-
const children = [...header.children];
73-
expect(children[children.length - 1]).toBe(themeBar);
74-
expect(children.indexOf(themeBar)).toBeGreaterThan(children.indexOf(bar));
75-
expect(themeBar.children.length).toBe(1);
76-
expect(themeBar.contains(toggle)).toBe(true);
77-
expect(bar.contains(toggle)).toBe(false);
64+
describe('Root layout placement', () => {
65+
test('the toggle row sits below the header, before main, holding the radios', () => {
66+
const html = renderToStaticMarkup(_jsx(RootLayout, { children: null }));
67+
const headerStart = html.indexOf('<header');
68+
const headerEnd = html.indexOf('</header>');
69+
const themeBar = html.indexOf('class="theme-bar"');
70+
const main = html.indexOf('<main');
71+
expect(themeBar).toBeGreaterThan(headerEnd);
72+
expect(themeBar).toBeLessThan(main);
73+
// The header itself no longer contains the toggle row.
74+
expect(html.slice(headerStart, headerEnd)).not.toContain('theme-bar');
75+
// The row holds all three radios (and nothing but the toggle).
76+
const rowHtml = html.slice(themeBar, main);
77+
expect(rowHtml).toContain('rf-theme-light');
78+
expect(rowHtml).toContain('rf-theme-dark');
79+
expect(rowHtml).toContain('rf-theme-system');
80+
// The persistence bootstrap must come after the radios to restore the
81+
// saved choice pre-paint.
82+
const shim = html.indexOf("localStorage.getItem('rf-theme')");
83+
expect(shim).toBeGreaterThan(themeBar);
7884
});
7985
});

0 commit comments

Comments
 (0)