Public: anyone can read this; the crew can change it.
The theme picker in the layout draws §5's field and button, and .theme-option, .theme-apply leave the markup #4738
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4700-the-theme-picker-in-the-layout-draws-5-s → main 1 commit fork
4 files
changed +23
−15
src/web/client.ts +3 −3
| @@ -1305,13 +1305,13 @@ export const shellJs = /* js */ ` | ||
| 1305 | 1305 | if (!m || !m.matches || !m.matches('details.menu[open]')) return; |
| 1306 | 1306 | closeMenus(m); |
| 1307 | 1307 | var first = m.querySelector('.menu-panel .menu-item, .menu-panel input'); |
| 1308 | if (first && !first.closest('.theme-option')) first.focus(); | |
| 1308 | if (first && !first.closest('.theme-form')) first.focus(); | |
| 1309 | 1309 | }, true); |
| 1310 | 1310 | doc.addEventListener('keydown', function (e) { |
| 1311 | 1311 | if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; |
| 1312 | 1312 | var panel = e.target && e.target.closest ? e.target.closest('.menu-panel') : null; |
| 1313 | 1313 | if (!panel) return; |
| 1314 | var items = Array.prototype.slice.call(panel.querySelectorAll('.menu-item, .theme-option input')); | |
| 1314 | var items = Array.prototype.slice.call(panel.querySelectorAll('.menu-item, .theme-form input')); | |
| 1315 | 1315 | var at = items.indexOf(doc.activeElement); |
| 1316 | 1316 | var next = items[(at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length]; |
| 1317 | 1317 | if (next) { next.focus(); e.preventDefault(); } |
| @@ -1320,7 +1320,7 @@ export const shellJs = /* js */ ` | ||
| 1320 | 1320 | // ---- theme: apply instantly, persist once the choice settles ---- |
| 1321 | 1321 | var themeForm = doc.querySelector('.theme-form'); |
| 1322 | 1322 | if (themeForm) { |
| 1323 | var apply = themeForm.querySelector('.theme-apply'); | |
| 1323 | var apply = themeForm.querySelector('button[type=submit]'); | |
| 1324 | 1324 | if (apply) apply.hidden = true; |
| 1325 | 1325 | var pending = 0; |
| 1326 | 1326 | themeForm.addEventListener('change', function () { |
src/web/layout.ts +9 −10
| @@ -121,7 +121,7 @@ export interface PageOptions { | ||
| 121 | 121 | /** The theme choice as a panel section: lives in a menu, never in the bar. */ |
| 122 | 122 | function themeForm(active: ThemeName | undefined): Html { |
| 123 | 123 | const option = (value: string, label: string, checked: boolean) => |
| 124 | html`<label class="theme-option"> | |
| 124 | html`<label class="check"> | |
| 125 | 125 | <input |
| 126 | 126 | type="radio" |
| 127 | 127 | name="theme" |
| @@ -130,17 +130,16 @@ function themeForm(active: ThemeName | undefined): Html { | ||
| 130 | 130 | /> |
| 131 | 131 | <span class="swatch" data-t="${value}"></span> |
| 132 | 132 | ${label} |
| 133 | <span class="tick" aria-hidden="true">${icon("check")}</span> | |
| 134 | 133 | </label>`; |
| 135 | 134 | return html`<form class="theme-form" method="post" action="/theme"> |
| 136 | <span class="menu-title">Theme</span> | |
| 137 | ${option("system", "System", active === undefined)} | |
| 138 | ${THEME_NAMES.map((name) => | |
| 139 | option(name, THEMES[name].label, active === name), | |
| 140 | )} | |
| 141 | <button class="btn theme-apply" data-size="sm" type="submit"> | |
| 142 | Apply | |
| 143 | </button> | |
| 135 | <fieldset class="field"> | |
| 136 | <legend>Theme</legend> | |
| 137 | ${option("system", "System", active === undefined)} | |
| 138 | ${THEME_NAMES.map((name) => | |
| 139 | option(name, THEMES[name].label, active === name), | |
| 140 | )} | |
| 141 | </fieldset> | |
| 142 | ${btn("Apply", { size: "sm" })} | |
| 144 | 143 | </form>`; |
| 145 | 144 | } |
| 146 | 145 |
test/flow-pipeline.test.ts +2 −2
| @@ -290,10 +290,10 @@ describe("/flow: the forge shows its own bottleneck", () => { | ||
| 290 | 290 | expect(panel).toContain('action="/theme"'); |
| 291 | 291 | }); |
| 292 | 292 | |
| 293 | test("the shell script wires the theme form by its new class and guards focus on theme options", () => { | |
| 293 | test("the shell script wires the theme form by its new class and guards focus on the theme form", () => { | |
| 294 | 294 | expect(shellJs).toContain("doc.querySelector('.theme-form')"); |
| 295 | 295 | expect(shellJs).not.toContain(".theme-menu form"); |
| 296 | expect(shellJs).toContain("first.closest('.theme-option')"); | |
| 296 | expect(shellJs).toContain("first.closest('.theme-form')"); | |
| 297 | 297 | }); |
| 298 | 298 | |
| 299 | 299 | const flowCommand = (user?: User) => { |
test/web.test.ts +9
| @@ -655,6 +655,15 @@ describe("themes", () => { | ||
| 655 | 655 | for (const name of THEME_NAMES) { |
| 656 | 656 | expect(text).toContain(`value="${name}"`); |
| 657 | 657 | } |
| 658 | // §5: each choice is a labelled radio, the button a plain btn; no bespoke classes. | |
| 659 | const form = text.slice(text.indexOf('action="/theme"')); | |
| 660 | const end = form.indexOf("</form>"); | |
| 661 | const panel = form.slice(0, end); | |
| 662 | expect(panel.match(/<label class="check">/g)?.length).toBe(THEME_NAMES.length + 1); | |
| 663 | expect(panel.match(/type="radio"/g)?.length).toBe(THEME_NAMES.length + 1); | |
| 664 | expect(panel).toMatch(/<button\s+type="submit"[\s\S]*class="btn"[\s\S]*Apply/); | |
| 665 | expect(text).not.toContain("theme-option"); | |
| 666 | expect(text).not.toContain("theme-apply"); | |
| 658 | 667 | // Critical paint before the stylesheet: system pair (light default + dark |
| 659 | 668 | // media), then a rule per theme so a toggle repaints without a reload. |
| 660 | 669 | const boot = text.match(/<style nonce="[^"]+">([^<]*)<\/style>/)?.[1]; |
new above
offline