Skip to content

Commit db4529e

Browse files
Making progress
1 parent a22175b commit db4529e

7 files changed

Lines changed: 91 additions & 61 deletions

File tree

‎AGENTS.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket).
1111
**Key Features:**
1212
- Search over curated repository listing (`repositories.txt`)
1313
- Dark mode, 100% CSS switching (`components/theme-toggle.jsx` +
14-
`plans/css-only-dark-mode.md`): three radios (light/dark/system) as the
15-
last `.menu-bar` child, palettes selected in `app/globals.css` via
16-
`:root:has(#rf-theme-…:checked)` + `prefers-color-scheme`; an inline
17-
pre-paint script in `app/layout.jsx` only restores/persists the choice
18-
(CSP-hashed by the build). Guards: `tests/unit/theme-css.test.js` (the
19-
two dark blocks must stay byte-identical; all hex colors live in the
20-
palette blocks; dark palette passes the same WCAG AA math as
21-
`tests/unit/css-a11y.test.js`)
14+
`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)` +
18+
`prefers-color-scheme`; an inline pre-paint script in `app/layout.jsx`
19+
only restores/persists the choice (CSP-hashed by the build). Guards:
20+
`tests/unit/theme-css.test.js` (the two dark blocks must stay
21+
byte-identical; all hex colors live in the palette blocks; dark palette
22+
passes the same WCAG AA math as `tests/unit/css-a11y.test.js`)
2223
- Breadcrumb trail under the menu (`components/breadcrumbs.jsx` +
2324
`lib/breadcrumbs.js`): mirrors `/user/repo/branch` with linked ancestors;
2425
the repo crumb never self-links, and ReportView announces the resolved

‎app/globals.css‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -609,12 +609,6 @@ pre {
609609
order: 2;
610610
}
611611

612-
/* Right of the hamburger at the bar's right edge (order 2 ties resolve
613-
by DOM order, and the hamburger's auto margin pushes both right). */
614-
.theme-toggle {
615-
order: 2;
616-
}
617-
618612
.menu-search {
619613
/* On mobile the search sits on its own row between toggle and links. */
620614
order: 3;
@@ -691,6 +685,21 @@ pre {
691685
}
692686

693687
/* ---- 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. */
694+
.theme-bar {
695+
display: flex;
696+
justify-content: flex-end;
697+
max-width: var(--width-content);
698+
box-sizing: border-box;
699+
margin: 0 auto;
700+
padding: 0.2rem 0.8rem 0.45rem;
701+
}
702+
694703
/* The radios in components/theme-toggle.jsx are the theme's single source of
695704
truth (see the palette blocks at the top of this file). Each input is
696705
visually replaced by its icon pill (appearance: none, absolute, fills the

‎components/site-header.jsx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import MenuSearch from './menu-search';
44
import ThemeToggle from './theme-toggle';
55
import { withBasePath } from '../lib/base-path';
66

7-
// Site header: brand logo, hamburger toggle, main navigation links, the
8-
// type-ahead menu search and the three-mode color theme toggle pinned to the
9-
// right side of the bar.
7+
// 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.
1010
export default function SiteHeader({ repositories = [] }) {
1111
return (
1212
<>
@@ -34,11 +34,16 @@ export default function SiteHeader({ repositories = [] }) {
3434
</li>
3535
</ul>
3636
<MenuSearch repositories={repositories} />
37-
{/* Last flex child: the toggle's right edge sits flush with the
38-
menu bar's content edge, mirroring the breadcrumbs' left-edge
39-
alignment (plans/css-only-dark-mode.md). */}
40-
<ThemeToggle />
4137
</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>
4247
</header>
4348
</>
4449
);

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

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
## Goals
44

5-
1. Three-mode theme control in the top menu bar — **light / dark / system** —
6-
with small images (icons) for each mode.
7-
2. The control's right edge aligns with the top menu's right content edge at
5+
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.
7+
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).
1010
3. All *theming* is pure CSS: custom properties + `prefers-color-scheme` +
@@ -18,8 +18,9 @@
1818
`components/theme-toggle.jsx` (a Server Component — no state, no handlers)
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
21-
icon pill (inline SVG: sun / moon / monitor). The toggle is the **last flex
22-
child of `.menu-bar`**, after `MenuSearch`.
21+
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.
2324

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

122123
### Alignment with the menu
123124

124-
The toggle is a normal flex child at the end of `.menu-bar` (which carries
125-
`padding: 0.3rem 0.8rem`), so its right edge is flush with the menu's content
126-
edge by construction — the same 0.8rem inset the breadcrumbs mirror on the
127-
left (`tests/unit/layout-styles.test.js` pins that invariant). Below 900px it
128-
keeps `order: 2` so it sits beside the hamburger at the right edge. No
129-
absolute positioning, no magic numbers.
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.
130134

131135
### Toggle UI / a11y
132136

@@ -188,10 +192,10 @@ mustache-rendered).
188192

189193
1. Write the failing unit/integration tests (above).
190194
2. `components/theme-toggle.jsx` + render it in `components/site-header.jsx`
191-
after `MenuSearch`.
195+
on a `.theme-bar` sub-row after `.menu-bar`.
192196
3. globals.css: split color roles, sweep hardcoded colors into variables, add
193197
the three cascade blocks + mvp variable overrides + dark overrides for the
194-
report template's light-only rules + toggle styles (+ mobile order).
198+
report template's light-only rules + `.theme-bar`/toggle styles.
195199
4. `app/layout.jsx`: inline persistence script after `<SiteHeader />` +
196200
`color-scheme` meta.
197201
5. Unit/integration green; `npx eslint` clean.
@@ -250,11 +254,11 @@ mustache-rendered).
250254
were pre-verified (dark bg/text ≈13:1, muted ≈8:1, links ≈7.7:1, focus
251255
indicator 3.8:1 on the page bg (3.3:1 on surfaces), brand-bg+white 5.5:1,
252256
error/success ≈8:1).
253-
10. **Alignment is structural, not geometric** — placing the toggle inside
254-
`.menu-bar` (like the search before it) guarantees right-edge alignment
255-
at every viewport with zero positioning code; the E2E test pins it with a
256-
bounding-box comparison. This mirrors (and is simpler than) the
257-
breadcrumbs' `--width-content` trick, which only aligns below ~1263px.
257+
10. **Alignment is structural, not geometric** — the `.theme-bar` sub-row
258+
reuses the breadcrumbs' `--width-content` column + 0.8rem padding formula,
259+
so the toggle's right edge is the menu bar's right content edge at every
260+
viewport with zero positioning code; the E2E test pins it with a
261+
bounding-box comparison, at desktop and mobile widths.
258262

259263
Verdict: the design is sound. The only genuine trade-offs — the persistence
260264
shim (flagged deviation) and the duplicated dark block (test-guarded) — are

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,29 +37,28 @@ test('the toggle right edge aligns with the top menu content edge', async ({ pag
3737
return {
3838
toggleRight: toggle.right,
3939
// The menu bar's right *content* edge (its 0.8rem padding insets the
40-
// search input exactly like the breadcrumb padding insets crumbs).
40+
// search input exactly like the sub-row padding insets the toggle).
4141
barContentRight: bar.right - parseFloat(style.paddingRight)
4242
};
4343
});
4444
expect(Math.abs(edges.toggleRight - edges.barContentRight)).toBeLessThanOrEqual(1);
4545
});
4646

47-
test('the toggle stays right-aligned on mobile next to the hamburger', async ({ page }) => {
47+
test('the toggle sits below the menu bar, right-aligned, at mobile widths', async ({ page }) => {
4848
test.skip(test.info().project.name !== 'chromium', 'geometry check runs once');
4949
await page.setViewportSize({ width: 375, height: 800 });
5050
await page.goto('/');
5151
const edges = await page.evaluate(() => {
5252
const bar = document.querySelector('.menu-bar').getBoundingClientRect();
5353
const toggle = document.querySelector('.theme-toggle').getBoundingClientRect();
54-
const hamburger = document.querySelector('.menu-toggle').getBoundingClientRect();
5554
return {
5655
toggleRight: toggle.right,
5756
barContentRight: bar.right - parseFloat(getComputedStyle(document.querySelector('.menu-bar')).paddingRight),
58-
hamburgerBeforeToggle: hamburger.right <= toggle.left
57+
belowBar: toggle.top >= bar.bottom
5958
};
6059
});
6160
expect(Math.abs(edges.toggleRight - edges.barContentRight)).toBeLessThanOrEqual(1);
62-
expect(edges.hamburgerBeforeToggle).toBe(true);
61+
expect(edges.belowBar).toBe(true);
6362
});
6463

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

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
// Dark mode toggle in the top menu: three radio modes (light / dark /
1+
// Dark mode toggle under the top menu: three radio modes (light / dark /
22
// system) rendered as icon pills, with the system preference preselected and
3-
// the whole control placed after the menu search box so its right edge can
4-
// align with the top menu's content edge (plans/css-only-dark-mode.md).
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).
55
import { describe, test, expect } from 'bun:test';
66
import { mock } from 'bun:test';
77

@@ -60,15 +60,20 @@ describe('ThemeToggle', () => {
6060
});
6161

6262
describe('SiteHeader placement', () => {
63-
test('the toggle is the last child of the menu bar, after the search box', () => {
63+
test('the toggle sits on a sub-row below the menu bar, inside the header', () => {
6464
render(_jsx(SiteHeader, { repositories: [] }));
65+
const header = document.getElementById('top-menu');
6566
const bar = document.querySelector('.menu-bar');
66-
const children = [...bar.children];
67-
const search = bar.querySelector('.menu-search');
68-
const toggle = bar.querySelector('.theme-toggle');
67+
const themeBar = header.querySelector('.theme-bar');
68+
const toggle = themeBar.querySelector('.theme-toggle');
69+
expect(themeBar).toBeTruthy();
6970
expect(toggle).toBeTruthy();
70-
expect(children[children.length - 1]).toBe(toggle);
71-
expect(children.indexOf(search)).toBeGreaterThan(0);
72-
expect(children.indexOf(toggle)).toBeGreaterThan(children.indexOf(search));
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);
7378
});
7479
});

‎tests/unit/theme-css.test.js‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ describe('dark mode cascade structure', () => {
206206
});
207207

208208
describe('toggle styles', () => {
209-
it('the toggle is a flex child of the menu bar with an icon pill per mode', () => {
209+
it('the toggle renders icon pills over appearance-none radios', () => {
210210
expect(rules.get('.theme-toggle')).toMatch(/display:\s*flex/);
211211
expect(rules.get('.theme-option-input')).toMatch(/appearance:\s*none/);
212212
expect(rules.get('.theme-option-input')).toMatch(/position:\s*absolute/);
@@ -224,12 +224,19 @@ describe('toggle styles', () => {
224224
expect(height).toBeGreaterThanOrEqual(1.5);
225225
});
226226

227-
it('the toggle stays right-aligned on mobile, next to the hamburger', () => {
228-
const mobileSlice = noComments.split('@media (max-width: 900px)')[1];
229-
expect(mobileSlice).toBeTruthy();
230-
const mobileToggle = mobileSlice.match(/\.theme-toggle\s*\{([^{}]*)\}/);
231-
expect(mobileToggle).toBeTruthy();
232-
expect(mobileToggle[1]).toMatch(/order:\s*2/);
227+
it('the sub-row under the menu bar mirrors the breadcrumb alignment formula', () => {
228+
// Same invariant layout-styles.test.js pins for .breadcrumbs: the row
229+
// shares #top-menu's --width-content column and the 0.8rem horizontal
230+
// padding insets the toggle to the menu bar's right *content* edge at
231+
// every viewport. Flex-end pins the control to that edge.
232+
const row = rules.get('.theme-bar');
233+
expect(row).toBeTruthy();
234+
expect(row).toMatch(/display:\s*flex/);
235+
expect(row).toMatch(/justify-content:\s*flex-end/);
236+
expect(row).toMatch(/max-width:\s*var\(--width-content\)/);
237+
expect(row).toMatch(/box-sizing:\s*border-box/);
238+
expect(row).toMatch(/margin:\s*0\s+auto/);
239+
expect(row).toMatch(/padding:\s*[^;]*0\.8rem[^;]*;/);
233240
});
234241
});
235242

0 commit comments

Comments
 (0)