[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

Ship one palette and one language behaviour, and delete the switching

I have decided both questions this branch was carrying, so the code
should carry one answer and not three. The palette is "instrument",
cool steel greys with a deep cyan indicator, and the language handling
is the banner: it offers the other language and nothing ever navigates
on its own.

Gone with them: the floating prototype switcher, the blueprint and
graphite palettes, the `data-accent` attribute and the before-paint
script in Head.astro that stamped it, the `phonometry:accent` and
`phonometry:lang-mode` storage keys, the redirect and off language
modes with their per-session guard, and the bar-height custom property
that only existed so the switcher could sit clear of the bar.
src/styles/theme-directions.css becomes src/styles/theme.css, with the
winning direction promoted to the plain :root and
:root[data-theme='light'] rules.

The language feature keeps a kill switch, but as a build-time constant
rather than a control: ENABLED in LangSuggest.astro renders no markup
and no script when it is false. Nothing on the page and nothing in
storage can flip it.

Everything else about the behaviour is unchanged and still checked: an
explicit choice always wins, the picker and a dismissal both record
one, crawlers are skipped and the English-only API subtree renders no
bar at all. check-contrast.mjs now resolves the two themes of the one
palette (25 pairs, 0 below threshold) and check-lang-suggest.mjs runs
the nine scenarios that survive without modes.

José M. Requena Plens (Jul 26, 2026, 3:57 AM +0200) 4ade17da 741ed416

+227 -709
+4 -4
site/astro.config.mjs
··· 255 255 MarkdownContent: './src/components/MarkdownContent.astro', 256 256 }, 257 257 customCss: [ 258 - // Experimental colour directions. Unlayered, so its `:root` blocks 259 - // win over Starlight's `@layer starlight.base` defaults; loaded first 260 - // so the later sheets can still reference the tokens it defines. 261 - './src/styles/theme-directions.css', 258 + // The site palette. Unlayered, so its `:root` blocks win over 259 + // Starlight's `@layer starlight.base` defaults; loaded first so the 260 + // later sheets can still reference the tokens it defines. 261 + './src/styles/theme.css', 262 262 './src/styles/katex.css', 263 263 './src/styles/theme-images.css', 264 264 './src/styles/theme-tables.css',
+12 -16
site/scripts/check-contrast.mjs
··· 1 - // Contrast audit for the experimental colour directions. 1 + // Contrast audit for the site palette. 2 2 // 3 - // Parses src/styles/theme-directions.css, resolves the six palettes 4 - // (3 directions x light/dark) and reports the WCAG 2.1 contrast ratio of 5 - // every pair the site actually renders, plus the seam between the page 6 - // ground and the opaque plate every committed figure carries. 3 + // Parses src/styles/theme.css, resolves the light and the dark theme and 4 + // reports the WCAG 2.1 contrast ratio of every pair the site actually 5 + // renders, plus the seam between the page ground and the opaque plate every 6 + // committed figure carries. 7 7 // 8 8 // Pairs are either enforced (text 4.5:1, meaningful non-text 3:1, per WCAG 9 9 // 1.4.3 / 1.4.11) or informational (purely decorative hairlines, and the ··· 13 13 // Run: node scripts/check-contrast.mjs [--json] 14 14 import { readFileSync } from 'node:fs'; 15 15 16 - const css = readFileSync(new URL('../src/styles/theme-directions.css', import.meta.url), 'utf8'); 16 + const css = readFileSync(new URL('../src/styles/theme.css', import.meta.url), 'utf8'); 17 17 18 18 /** All `--token: #hex;` declarations of the rule whose selector list is exactly `selector`. */ 19 19 function block(selector) { ··· 47 47 return palette; 48 48 } 49 49 50 - const instrumentDark = block(":root, :root[data-accent='instrument']"); 51 - const instrumentLight = block(":root[data-theme='light'], :root[data-accent='instrument'][data-theme='light']"); 50 + // The dark theme is the root scope; the light theme overrides it, so it is 51 + // resolved on top of the dark one exactly as the cascade does it. 52 + const dark = block(':root'); 53 + const light = block(":root[data-theme='light']"); 52 54 const PALETTES = { 53 - 'instrument / dark': resolve({ ...instrumentDark }), 54 - 'instrument / light': resolve({ ...instrumentDark, ...instrumentLight }), 55 + 'instrument / dark': resolve({ ...dark }), 56 + 'instrument / light': resolve({ ...dark, ...light }), 55 57 }; 56 - for (const name of ['blueprint', 'graphite']) { 57 - const dark = block(`:root[data-accent='${name}']`); 58 - const light = block(`:root[data-accent='${name}'][data-theme='light']`); 59 - PALETTES[`${name} / dark`] = resolve({ ...dark }); 60 - PALETTES[`${name} / light`] = resolve({ ...dark, ...light }); 61 - } 62 58 63 59 for (const [name, p] of Object.entries(PALETTES)) { 64 60 if (!p['--sl-color-black']) throw new Error(`palette "${name}" did not parse`);
+10 -2
site/scripts/check-home-headings.mjs
··· 23 23 // 24 24 // Usage: 25 25 // node scripts/check-home-headings.mjs [--base http://localhost:4322] 26 - // [--shots <dir>] 26 + // [--shots <dir>] [filter...] 27 + // 28 + // A filter is matched against "<locale> <case>", so `--shots dir 390 1440` 29 + // writes crops for those two widths only. Filters never narrow the audit 30 + // itself: every case is always measured. 27 31 import { readdirSync } from 'node:fs'; 28 32 import { mkdir } from 'node:fs/promises'; 29 33 import { createRequire } from 'node:module'; ··· 44 48 }; 45 49 const BASE = arg('--base', 'http://localhost:4322'); 46 50 const SHOTS = arg('--shots', null); 51 + const values = new Set([BASE, SHOTS].filter(Boolean)); 52 + const FILTERS = argv.filter((a) => !a.startsWith('--') && !values.has(a)); 47 53 48 54 // Width sweep, plus the two zoom levels and the enlarged-text cases. A zoom 49 55 // level is a viewport: 200 % zoom of a 1440x900 window is a 720x450 CSS ··· 181 187 ); 182 188 for (const p of problems) console.log(` ${p}`); 183 189 184 - if (SHOTS) { 190 + const wanted = 191 + SHOTS && (!FILTERS.length || FILTERS.some((f) => `${c.lang} ${c.tag}`.includes(f))); 192 + if (wanted) { 185 193 const clip = await page.evaluate(() => { 186 194 const b = document.querySelector('.home .block'); 187 195 if (!b) return null;
+10 -26
site/scripts/check-lang-suggest.mjs
··· 2 2 // 3 3 // Runs the scenarios that decide whether the feature is safe: a first visit 4 4 // with a Spanish browser, a visitor who deliberately opened an English URL 5 - // after choosing Spanish, an explicit ?lang=, a dismissal, a crawler, the 6 - // English-only API subtree, and both redirect guards (once per session, never 7 - // after a stored choice). 5 + // after choosing Spanish, an explicit ?lang=, a dismissal, a crawler and the 6 + // English-only API subtree. The bar offers and never navigates, so every 7 + // scenario also asserts that the URL is the one that was asked for. 8 8 // 9 9 // Usage: node scripts/check-lang-suggest.mjs [--base http://localhost:4322] 10 10 import { readdirSync } from 'node:fs'; ··· 37 37 * Load `path` with a fake language list, an optional pre-seeded localStorage 38 38 * and an optional user agent, and report what the page decided. 39 39 */ 40 - async function visit(path, { languages = ['es-ES', 'es'], seed = {}, ua, mode } = {}) { 40 + async function visit(path, { languages = ['es-ES', 'es'], seed = {}, ua } = {}) { 41 41 // Every scenario gets its own browser context, otherwise localStorage 42 42 // written by one of them leaks into the next and the results are garbage. 43 43 const context = await browser.createBrowserContext(); 44 44 const page = await context.newPage(); 45 45 if (ua) await page.setUserAgent(ua); 46 46 await page.evaluateOnNewDocument( 47 - (langs, seeded, m) => { 47 + (langs, seeded) => { 48 48 Object.defineProperty(navigator, 'languages', { get: () => langs }); 49 49 Object.defineProperty(navigator, 'language', { get: () => langs[0] }); 50 50 try { 51 51 for (const [k, v] of Object.entries(seeded)) localStorage.setItem(k, v); 52 - if (m) localStorage.setItem('phonometry:lang-mode', m); 53 52 } catch {} 54 53 }, 55 54 languages, 56 55 seed, 57 - mode, 58 56 ); 59 57 await page.goto(`${BASE}/phonometry${path}`, { waitUntil: 'networkidle0', timeout: 60000 }); 60 58 await new Promise((r) => setTimeout(r, 400)); ··· 131 129 { url: '/phonometry/reference/api/levels/levels/', banner: 'absent', stored: null }, 132 130 ); 133 131 134 - // 9. Redirect mode, first visit with nothing stored: one hop to /es/. 132 + // 9. Nothing ever navigates on its own: the same first visit that a redirect 133 + // would have moved stays exactly where it was asked to be. 135 134 expect( 136 - 'redirect: first visit hops once', 137 - await visit('/guides/levels/', { mode: 'redirect' }), 138 - { url: '/phonometry/es/guides/levels/', banner: false, stored: null }, 139 - ); 140 - 141 - // 10. Redirect mode with a stored choice: degrades to the banner, no hop. 142 - expect( 143 - 'redirect: a stored choice never navigates', 144 - await visit('/guides/levels/', { mode: 'redirect', seed: { 'phonometry:lang': 'es' } }), 145 - { url: '/phonometry/guides/levels/', banner: true, stored: 'es' }, 146 - ); 147 - 148 - // 11. Redirect mode on the page it would redirect to: nothing to do, and the 149 - // session guard means a second load cannot bounce back. 150 - expect( 151 - 'redirect: no loop on the target page', 152 - await visit('/es/guides/levels/', { mode: 'redirect' }), 153 - { url: '/phonometry/es/guides/levels/', banner: false, stored: null }, 135 + 'nothing navigates: the ES-preferring first visit stays on the EN url', 136 + await visit('/getting-started/'), 137 + { url: '/phonometry/getting-started/', banner: true, stored: null }, 154 138 ); 155 139 156 140 await browser.close();
+31 -60
site/scripts/redesign-shots.mjs
··· 1 - // Screenshot matrix for the experimental redesign. 1 + // Screenshot matrix for the redesign. 2 2 // 3 3 // Drives its own headless Chrome so the captures are reproducible and do not 4 4 // depend on an interactive browser session: every shot states its viewport, 5 - // locale, colour direction and theme up front. 5 + // locale and theme up front. 6 6 // 7 7 // Puppeteer is not a declared dependency of the site; it is already in the 8 8 // tree because pa11y-ci pulls it in, so the script resolves it out of the 9 - // pnpm store rather than adding a heavyweight devDependency for a throwaway 10 - // branch. 9 + // pnpm store rather than adding a heavyweight devDependency for a branch that 10 + // is only here to be looked at. 11 11 // 12 12 // Usage: node scripts/redesign-shots.mjs [--base http://localhost:4322] [filter...] 13 13 import { readdirSync } from 'node:fs'; ··· 34 34 const DESKTOP = { width: 1440, height: 900 }; 35 35 const PHONE = { width: 390, height: 844 }; 36 36 37 - /** name, path, viewport, accent, theme, and how far down the page to sit. */ 37 + /** name, path, viewport, theme, and how far down the page to sit. */ 38 38 const SHOTS = [ 39 - // Colour directions on a figure-heavy guide. 40 - ['10-guide-instrument-dark', '/guides/filter-banks/', DESKTOP, 'instrument', 'dark', 900], 41 - ['11-guide-instrument-light', '/guides/filter-banks/', DESKTOP, 'instrument', 'light', 900], 42 - ['12-guide-blueprint-dark', '/guides/filter-banks/', DESKTOP, 'blueprint', 'dark', 900], 43 - ['13-guide-blueprint-light', '/guides/filter-banks/', DESKTOP, 'blueprint', 'light', 900], 44 - ['14-guide-graphite-dark', '/guides/filter-banks/', DESKTOP, 'graphite', 'dark', 900], 45 - ['15-guide-graphite-light', '/guides/filter-banks/', DESKTOP, 'graphite', 'light', 900], 46 - // A transparent matplotlib plot rather than a hand-drawn diagram: this is 47 - // the case where the page ground shows through the figure. 48 - ['16-plot-instrument-dark', '/getting-started/', DESKTOP, 'instrument', 'dark', 1750], 49 - ['17-plot-blueprint-dark', '/getting-started/', DESKTOP, 'blueprint', 'dark', 1750], 50 - ['18-plot-graphite-dark', '/getting-started/', DESKTOP, 'graphite', 'dark', 1750], 51 - ['19-plot-instrument-light', '/getting-started/', DESKTOP, 'instrument', 'light', 1750], 39 + // The palette on a figure-heavy guide: the hand-authored diagram case, 40 + // whose plate is #0d1117. 41 + ['10-guide-dark', '/guides/filter-banks/', DESKTOP, 'dark', 900], 42 + ['11-guide-light', '/guides/filter-banks/', DESKTOP, 'light', 900], 43 + // A matplotlib plot rather than a hand-drawn diagram: the plate is black. 44 + ['16-plot-dark', '/getting-started/', DESKTOP, 'dark', 1750], 45 + ['19-plot-light', '/getting-started/', DESKTOP, 'light', 1750], 52 46 // Front page, both locales, both themes, full page. 53 - ['20-home-instrument-dark', '/', DESKTOP, 'instrument', 'dark', 0, true], 54 - ['21-home-instrument-light', '/', DESKTOP, 'instrument', 'light', 0, true], 55 - ['22-home-blueprint-dark', '/', DESKTOP, 'blueprint', 'dark', 0, true], 56 - ['23-home-graphite-light', '/', DESKTOP, 'graphite', 'light', 0, true], 57 - ['24-home-es-instrument-light', '/es/', DESKTOP, 'instrument', 'light', 0, true], 58 - ['25-home-es-blueprint-dark', '/es/', DESKTOP, 'blueprint', 'dark', 0, true], 47 + ['20-home-dark', '/', DESKTOP, 'dark', 0, true], 48 + ['21-home-light', '/', DESKTOP, 'light', 0, true], 49 + ['24-home-es-light', '/es/', DESKTOP, 'light', 0, true], 50 + ['25-home-es-dark', '/es/', DESKTOP, 'dark', 0, true], 59 51 // Narrow widths. 60 - ['30-home-phone-instrument-dark', '/', PHONE, 'instrument', 'dark', 0, true], 61 - ['31-home-phone-graphite-light', '/', PHONE, 'graphite', 'light', 0, true], 62 - ['32-guide-phone-instrument-dark', '/guides/filter-banks/', PHONE, 'instrument', 'dark', 700], 63 - ['33-header-phone-light', '/getting-started/', PHONE, 'instrument', 'light', 0], 64 - ['34-header-phone-dark', '/es/getting-started/', PHONE, 'blueprint', 'dark', 0], 52 + ['30-home-phone-dark', '/', PHONE, 'dark', 0, true], 53 + ['31-home-phone-light', '/', PHONE, 'light', 0, true], 54 + ['32-guide-phone-dark', '/guides/filter-banks/', PHONE, 'dark', 700], 55 + ['33-header-phone-light', '/getting-started/', PHONE, 'light', 0], 56 + ['34-header-phone-dark', '/es/getting-started/', PHONE, 'dark', 0], 65 57 ]; 66 58 67 59 /** 68 - * Shots that need something more than a viewport and a palette: a faked 69 - * Accept-Language list, or a click before the capture. 60 + * Shots that need something more than a viewport and a theme: a faked 61 + * Accept-Language list. 70 62 */ 71 63 const EXTRA = [ 72 64 { 73 65 name: '40-lang-banner-on-en-page', 74 66 path: '/guides/levels/', 75 67 viewport: DESKTOP, 76 - accent: 'instrument', 77 68 theme: 'light', 78 69 languages: ['es-ES', 'es'], 79 70 }, ··· 81 72 name: '41-lang-banner-on-es-page-phone', 82 73 path: '/es/guides/levels/', 83 74 viewport: PHONE, 84 - accent: 'blueprint', 85 75 theme: 'dark', 86 76 languages: ['en-US', 'en'], 87 - }, 88 - { 89 - name: '42-prototype-switcher-open', 90 - path: '/', 91 - viewport: DESKTOP, 92 - accent: 'graphite', 93 - theme: 'dark', 94 - click: '[data-accent-toggle]', 95 77 }, 96 78 ]; 97 79 ··· 102 84 args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--font-render-hinting=none'], 103 85 }); 104 86 105 - for (const [name, path, viewport, accent, theme, scrollY = 0, fullPage = false] of SHOTS) { 87 + for (const [name, path, viewport, theme, scrollY = 0, fullPage = false] of SHOTS) { 106 88 if (filters.length && !filters.some((f) => name.includes(f))) continue; 107 89 const page = await browser.newPage(); 108 90 await page.setViewport({ ...viewport, deviceScaleFactor: 1 }); 109 - // Seed the preferences before the document runs its before-paint script. 110 - await page.evaluateOnNewDocument( 111 - (a, t) => { 112 - try { 113 - localStorage.setItem('phonometry:accent', a); 114 - localStorage.setItem('starlight-theme', t); 115 - } catch {} 116 - }, 117 - accent, 118 - theme, 119 - ); 91 + // Seed the theme before the document runs its before-paint script. 92 + await page.evaluateOnNewDocument((t) => { 93 + try { 94 + localStorage.setItem('starlight-theme', t); 95 + } catch {} 96 + }, theme); 120 97 await page.goto(`${BASE}/phonometry${path}`, { waitUntil: 'networkidle0', timeout: 90000 }); 121 98 await page.evaluate((t) => { 122 99 document.documentElement.dataset.theme = t; ··· 153 130 const page = await context.newPage(); 154 131 await page.setViewport({ ...shot.viewport, deviceScaleFactor: 1 }); 155 132 await page.evaluateOnNewDocument( 156 - (a, t, langs) => { 133 + (t, langs) => { 157 134 try { 158 - localStorage.setItem('phonometry:accent', a); 159 135 localStorage.setItem('starlight-theme', t); 160 136 } catch {} 161 137 if (langs) { ··· 163 139 Object.defineProperty(navigator, 'language', { get: () => langs[0] }); 164 140 } 165 141 }, 166 - shot.accent, 167 142 shot.theme, 168 143 shot.languages ?? null, 169 144 ); ··· 172 147 document.documentElement.dataset.theme = t; 173 148 }, shot.theme); 174 149 await page.addStyleTag({ content: 'astro-dev-toolbar{display:none !important}' }); 175 - if (shot.click) { 176 - await page.click(shot.click); 177 - await new Promise((r) => setTimeout(r, 300)); 178 - } 179 150 await new Promise((r) => setTimeout(r, 500)); 180 151 await page.screenshot({ path: join(OUT, `${shot.name}.png`) }); 181 152 await context.close();
+3 -6
site/src/components/Footer.astro
··· 3 3 // human-visible maintainer block that corroborates the Person node in the 4 4 // site-wide JSON-LD @graph (GEO: entity corroboration). 5 5 import Default from '@astrojs/starlight/components/Footer.astro'; 6 - // Experimental: the floating prototype switcher (colour direction, language 7 - // detection mode) and the first-visit language suggestion. Both are mounted 8 - // here because the footer is the one custom component rendered on every page, 9 - // splash included; both position themselves fixed. 10 - import PrototypeSwitcher from './PrototypeSwitcher.astro'; 6 + // The first-visit language suggestion. Mounted here because the footer is the 7 + // one custom component rendered on every page, splash included; it positions 8 + // itself fixed to the bottom edge. 11 9 import LangSuggest from './LangSuggest.astro'; 12 10 13 11 const path = Astro.url.pathname; ··· 26 24 · <a href="https://pypi.org/project/phonometry/">PyPI</a> 27 25 </div> 28 26 <LangSuggest /> 29 - <PrototypeSwitcher /> 30 27 31 28 <style> 32 29 .maintainer {
-20
site/src/components/Head.astro
··· 88 88 --- 89 89 90 90 <Default><slot /></Default> 91 - {/* 92 - Experimental colour directions: stamp the stored `data-accent` on <html> 93 - before first paint, so switching a direction never flashes the default one. 94 - Runs inline in <head> with no dependencies; a missing or unknown value falls 95 - back to `instrument`, which is also the unconditional `:root` block in 96 - src/styles/theme-directions.css, so the site is correct with JS disabled. 97 - */} 98 - <script is:inline> 99 - (() => { 100 - var allowed = ['instrument', 'blueprint', 'graphite']; 101 - var stored; 102 - try { 103 - stored = localStorage.getItem('phonometry:accent'); 104 - } catch (e) { 105 - stored = null; 106 - } 107 - document.documentElement.dataset.accent = 108 - allowed.indexOf(stored) !== -1 ? stored : 'instrument'; 109 - })(); 110 - </script> 111 91 {techArticle && ( 112 92 <script type="application/ld+json" set:html={JSON.stringify(techArticle)} /> 113 93 )}
+28 -54
site/src/components/LangSuggest.astro
··· 4 4 * 5 5 * The site is a static export on GitHub Pages: there is no server, no 6 6 * middleware and no Vary: Accept-Language, so anything that reacts to the 7 - * visitor's language has to happen in the browser. Two behaviours are 8 - * implemented here and one of them is chosen at runtime: 7 + * visitor's language has to happen in the browser. What it does is offer, 8 + * never decide: a quiet, dismissible one-line bar pinned to the bottom edge 9 + * that names the other language and links to the counterpart page. The page 10 + * never navigates on its own, in any circumstance. 9 11 * 10 - * banner (default) a quiet, dismissible one-line bar offering the other 11 - * language. Nothing navigates on its own. 12 - * redirect a single `location.replace` to the counterpart page, and only 13 - * on a genuine first visit with no stored preference. 14 - * off nothing at all. 12 + * That is deliberate. English is the root locale here, so an English URL is a 13 + * real canonical address: it is what the README, PyPI, Zenodo and every 14 + * English search result point at, and a visitor who followed one of those 15 + * made a choice about the page and not just about a language. A client-side 16 + * redirect would override them, and it would be worst exactly where the 17 + * content is thinnest, in the English-only API reference. The bar costs one 18 + * click, tells the reader that the other language exists, and ends forever on 19 + * one dismissal. 15 20 * 16 - * The mode is `DEFAULT_MODE` below, overridable at runtime with 17 - * `localStorage['phonometry:lang-mode']` (the prototype switcher writes it), 18 - * so it can be turned off or flipped without a rebuild. 19 - * 20 - * Rules that hold in every mode: 21 + * Rules: 21 22 * 22 23 * - An explicit locale in the URL always wins. `?lang=en` or `?lang=es` 23 24 * is recorded as the visitor's choice and suppresses any suggestion. 24 25 * - Using the language picker records the choice, so a manual decision 25 - * beats the browser's Accept-Language list from then on. 26 - * - Once a choice is stored, the page NEVER navigates on its own again. 27 - * Someone who deliberately opens an English URL after choosing Spanish 28 - * stays on the English URL and is at most offered a link. This is what 29 - * keeps the feature from trapping anyone. 30 - * - Redirection is guarded three ways: only when nothing is stored, only 31 - * once per session (sessionStorage), and only when the target path 32 - * differs from the current one, so it cannot loop. 26 + * beats the browser's Accept-Language list from then on. Dismissing the 27 + * bar is a decision too, and records the current page's language. 28 + * - Nothing is inferred from a language the visitor does not have: if 29 + * neither `es` nor `en` is in navigator.languages, nothing happens. 33 30 * - Known crawler user agents are skipped, and nothing here touches the 34 31 * canonical or hreflang links, which is what search engines actually 35 32 * use to pick a locale. ··· 42 39 * language the reader is assumed to understand. 43 40 */ 44 41 45 - /** 'banner' | 'redirect' | 'off' */ 46 - const DEFAULT_MODE = 'banner'; 42 + /** 43 + * Kill switch, build time. Set it to false and the site ships with no 44 + * language handling at all: no markup, no script, nothing stored. It is a 45 + * constant on purpose, not a control: there is nothing on the page, and 46 + * nothing in storage, that can turn the feature on or off behind my back. 47 + */ 48 + const ENABLED = true; 47 49 48 50 const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); 49 51 const pathname = Astro.url.pathname; ··· 80 82 --- 81 83 82 84 { 83 - !isApiReference && ( 85 + ENABLED && !isApiReference && ( 84 86 <div 85 87 id="lang-suggest" 86 88 class="lang-suggest" ··· 89 91 data-current={current} 90 92 data-other={other} 91 93 data-target={counterpart} 92 - data-default-mode={DEFAULT_MODE} 93 94 hidden 94 95 > 95 96 <p lang={other}>{copy.text}</p> ··· 103 104 ) 104 105 } 105 106 107 + {ENABLED && ( 106 108 <script is:inline> 107 109 (() => { 108 110 var LANG_KEY = 'phonometry:lang'; 109 111 var DISMISS_KEY = 'phonometry:lang-dismissed'; 110 - var MODE_KEY = 'phonometry:lang-mode'; 111 - var SESSION_KEY = 'phonometry:lang-redirected'; 112 112 var BOTS = 113 113 /bot|crawl|spider|slurp|mediapartners|facebookexternalhit|embedly|quora link preview|whatsapp|telegram|preview|scanner|lighthouse|pagespeed/i; 114 114 ··· 152 152 153 153 var current = banner.dataset.current; 154 154 var other = banner.dataset.other; 155 - var target = banner.dataset.target; 156 - var mode = get(localStorage, MODE_KEY) || banner.dataset.defaultMode; 157 155 158 156 var dismiss = banner.querySelector('[data-lang-dismiss]'); 159 157 if (dismiss) { ··· 172 170 }); 173 171 } 174 172 175 - if (mode === 'off') return; 176 173 if (BOTS.test(navigator.userAgent || '')) return; 177 174 178 175 // An explicit ?lang= in the URL is a decision, not a hint. ··· 182 179 if (explicit === current) return; 183 180 } 184 181 185 - var stored = get(localStorage, LANG_KEY); 186 - var preferred = stored; 182 + var preferred = get(localStorage, LANG_KEY); 187 183 if (!preferred) { 188 184 var list = navigator.languages && navigator.languages.length 189 185 ? navigator.languages ··· 200 196 // No opinion, or the page is already in the preferred language. 201 197 if (!preferred || preferred === current) return; 202 198 203 - if ( 204 - mode === 'redirect' && 205 - !stored && // never navigate away once a choice exists 206 - !get(sessionStorage, SESSION_KEY) && 207 - target && 208 - target !== location.pathname 209 - ) { 210 - set(sessionStorage, SESSION_KEY, '1'); 211 - location.replace(target + location.search + location.hash); 212 - return; 213 - } 214 - 215 199 if (get(localStorage, DISMISS_KEY)) return; 216 200 banner.hidden = false; 217 - // Publish the bar's height so anything else pinned to the bottom edge 218 - // (the prototype switcher) can sit clear of it at any width. 219 - var publish = function () { 220 - document.documentElement.style.setProperty( 221 - '--ph-lang-bar-height', 222 - (banner.hidden ? 0 : banner.offsetHeight) + 'px', 223 - ); 224 - }; 225 - publish(); 226 - if (typeof ResizeObserver === 'function') new ResizeObserver(publish).observe(banner); 227 - if (dismiss) dismiss.addEventListener('click', publish); 228 201 })(); 229 202 </script> 203 + )} 230 204 231 205 <style> 232 206 .lang-suggest {
-243
site/src/components/PrototypeSwitcher.astro
··· 1 - --- 2 - /** 3 - * Experimental switcher for the two things this branch wants a decision on. 4 - * 5 - * Colour: three complete palettes live in src/styles/theme-directions.css, 6 - * keyed on a `data-accent` attribute on <html>. The attribute is stamped 7 - * before first paint by the inline script in Head.astro (so there is no 8 - * flash) and read back from localStorage under `phonometry:accent`; this 9 - * control only writes it. 10 - * 11 - * Language: the mode used by src/components/LangSuggest.astro, stored under 12 - * `phonometry:lang-mode`. Changing it reloads the page, because the decision 13 - * is taken once at load. 14 - * 15 - * Neither is meant to ship. Once a direction and a mode are picked, the 16 - * winning colour block becomes the unconditional `:root` rule, the mode 17 - * becomes the component's `DEFAULT_MODE`, and this component goes away. 18 - * 19 - * Rendered from Footer.astro so it exists on every page, splash included, and 20 - * positioned fixed. The buttons are real <button>s in labelled groups with 21 - * `aria-pressed`, so the control is reachable and announced; the panel starts 22 - * collapsed to stay out of the reading column. 23 - */ 24 - const DIRECTIONS = [ 25 - { id: 'instrument', label: 'Instrument' }, 26 - { id: 'blueprint', label: 'Blueprint' }, 27 - { id: 'graphite', label: 'Graphite' }, 28 - ] as const; 29 - 30 - const LANG_MODES = [ 31 - { id: 'banner', label: 'Banner' }, 32 - { id: 'redirect', label: 'Redirect' }, 33 - { id: 'off', label: 'Off' }, 34 - ] as const; 35 - --- 36 - 37 - <div class="accent-switcher" id="accent-switcher"> 38 - <button 39 - type="button" 40 - class="accent-toggle" 41 - aria-expanded="false" 42 - aria-controls="accent-options" 43 - data-accent-toggle 44 - > 45 - <span class="accent-toggle-dot" aria-hidden="true"></span> 46 - <span class="accent-toggle-label">Prototype</span> 47 - </button> 48 - <div class="accent-options" id="accent-options" hidden> 49 - <div class="accent-group" role="group" aria-label="Colour direction"> 50 - <span class="accent-group-label" aria-hidden="true">Colour</span> 51 - { 52 - DIRECTIONS.map((d) => ( 53 - <button type="button" class="accent-option" data-accent-set={d.id} aria-pressed="false"> 54 - <span class={`accent-swatch accent-swatch-${d.id}`} aria-hidden="true" /> 55 - {d.label} 56 - </button> 57 - )) 58 - } 59 - </div> 60 - <div class="accent-group" role="group" aria-label="Language detection mode"> 61 - <span class="accent-group-label" aria-hidden="true">Language</span> 62 - { 63 - LANG_MODES.map((m) => ( 64 - <button type="button" class="accent-option" data-lang-mode={m.id} aria-pressed="false"> 65 - {m.label} 66 - </button> 67 - )) 68 - } 69 - </div> 70 - </div> 71 - </div> 72 - 73 - <script is:inline> 74 - (() => { 75 - const root = document.documentElement; 76 - const KEY = 'phonometry:accent'; 77 - const panel = document.getElementById('accent-switcher'); 78 - if (!panel) return; 79 - const options = panel.querySelector('#accent-options'); 80 - const toggle = panel.querySelector('[data-accent-toggle]'); 81 - 82 - const LANG_MODE_KEY = 'phonometry:lang-mode'; 83 - const read = (key, fallback) => { 84 - try { 85 - return localStorage.getItem(key) || fallback; 86 - } catch { 87 - return fallback; 88 - } 89 - }; 90 - 91 - const sync = () => { 92 - const active = root.dataset.accent || 'instrument'; 93 - for (const btn of panel.querySelectorAll('[data-accent-set]')) { 94 - btn.setAttribute('aria-pressed', String(btn.dataset.accentSet === active)); 95 - } 96 - const banner = document.getElementById('lang-suggest'); 97 - const mode = read(LANG_MODE_KEY, banner?.dataset.defaultMode || 'banner'); 98 - for (const btn of panel.querySelectorAll('[data-lang-mode]')) { 99 - btn.setAttribute('aria-pressed', String(btn.dataset.langMode === mode)); 100 - } 101 - }; 102 - 103 - for (const btn of panel.querySelectorAll('[data-lang-mode]')) { 104 - btn.addEventListener('click', () => { 105 - try { 106 - localStorage.setItem(LANG_MODE_KEY, btn.dataset.langMode); 107 - // The suggestion is decided once at load, and the redirect 108 - // guard is per session, so both have to be reset to see the 109 - // new mode behave as it would on a first visit. 110 - localStorage.removeItem('phonometry:lang-dismissed'); 111 - sessionStorage.removeItem('phonometry:lang-redirected'); 112 - } catch { 113 - /* private mode */ 114 - } 115 - location.reload(); 116 - }); 117 - } 118 - 119 - for (const btn of panel.querySelectorAll('[data-accent-set]')) { 120 - btn.addEventListener('click', () => { 121 - root.dataset.accent = btn.dataset.accentSet; 122 - try { 123 - localStorage.setItem(KEY, btn.dataset.accentSet); 124 - } catch { 125 - /* private mode: the choice just does not survive the tab */ 126 - } 127 - sync(); 128 - }); 129 - } 130 - 131 - toggle?.addEventListener('click', () => { 132 - const open = toggle.getAttribute('aria-expanded') === 'true'; 133 - toggle.setAttribute('aria-expanded', String(!open)); 134 - if (options) options.hidden = open; 135 - }); 136 - 137 - sync(); 138 - })(); 139 - </script> 140 - 141 - <style> 142 - .accent-switcher { 143 - position: fixed; 144 - inset-block-end: 0.75rem; 145 - inset-inline-end: 0.75rem; 146 - z-index: 50; 147 - display: flex; 148 - flex-direction: column; 149 - align-items: flex-end; 150 - gap: 0.375rem; 151 - font-family: var(--__sl-font-mono); 152 - font-size: var(--sl-text-2xs); 153 - line-height: 1; 154 - } 155 - 156 - .accent-toggle, 157 - .accent-option { 158 - display: flex; 159 - align-items: center; 160 - gap: 0.5rem; 161 - padding: 0.4rem 0.6rem; 162 - border: 1px solid var(--ph-border); 163 - border-radius: var(--ph-radius, 4px); 164 - background: var(--ph-surface-raised, var(--sl-color-bg)); 165 - color: var(--sl-color-gray-2); 166 - cursor: pointer; 167 - text-transform: uppercase; 168 - letter-spacing: 0.06em; 169 - box-shadow: var(--sl-shadow-sm); 170 - } 171 - 172 - .accent-toggle:hover, 173 - .accent-option:hover { 174 - border-color: var(--ph-border-strong); 175 - color: var(--sl-color-white); 176 - } 177 - 178 - .accent-toggle-dot { 179 - width: 0.55rem; 180 - height: 0.55rem; 181 - border-radius: 50%; 182 - background: var(--ph-mark); 183 - } 184 - 185 - .accent-options { 186 - display: flex; 187 - flex-direction: column; 188 - align-items: stretch; 189 - gap: 0.6rem; 190 - } 191 - .accent-group { 192 - display: flex; 193 - flex-direction: column; 194 - align-items: stretch; 195 - gap: 0.25rem; 196 - } 197 - .accent-group-label { 198 - color: var(--sl-color-gray-3); 199 - text-transform: uppercase; 200 - letter-spacing: 0.1em; 201 - padding-inline-start: 0.1rem; 202 - } 203 - /* `display: flex` would otherwise beat the user-agent [hidden] rule. */ 204 - .accent-options[hidden] { 205 - display: none; 206 - } 207 - 208 - .accent-option[aria-pressed='true'] { 209 - border-color: var(--ph-mark); 210 - color: var(--sl-color-white); 211 - } 212 - 213 - .accent-swatch { 214 - width: 0.75rem; 215 - height: 0.75rem; 216 - border-radius: 2px; 217 - border: 1px solid var(--ph-border-strong); 218 - } 219 - /* Fixed swatch colours: each one previews its own direction, so they 220 - cannot follow the active palette's tokens. */ 221 - .accent-swatch-instrument { 222 - background: linear-gradient(135deg, #0a6f8c 50%, #35b8d8 50%); 223 - } 224 - .accent-swatch-blueprint { 225 - background: linear-gradient(135deg, #23479a 50%, #78a2f0 50%); 226 - } 227 - .accent-swatch-graphite { 228 - background: linear-gradient(135deg, #8a4b06 50%, #e0a33e 50%); 229 - } 230 - 231 - /* The language suggestion is a full-width bar pinned to the same edge and 232 - is rendered just before this component, so lift the panel clear of it 233 - while it is showing. */ 234 - :global(#lang-suggest:not([hidden])) ~ .accent-switcher { 235 - inset-block-end: calc(var(--ph-lang-bar-height, 3rem) + 0.75rem); 236 - } 237 - 238 - @media print { 239 - .accent-switcher { 240 - display: none; 241 - } 242 - } 243 - </style>
-278
site/src/styles/theme-directions.css
··· 1 - /* ------------------------------------------------------------------ * 2 - * Colour directions (experimental) 3 - * 4 - * Three complete accent/surface palettes for the site, selected with a 5 - * `data-accent` attribute on <html> and persisted in localStorage by the 6 - * inline script in src/components/Head.astro. Every direction covers the 7 - * light and the dark theme, so the matrix is 3 x 2. 8 - * 9 - * instrument cool steel greys, deep cyan accent (default) 10 - * blueprint drafting blue-greys, prussian accent 11 - * graphite warm neutral greys, amber accent 12 - * 13 - * The palettes are constrained by the committed figures, which are NOT 14 - * touched by any of this. Every one of them is an opaque plate, so the only 15 - * thing the page ground can do is make the plate edge disappear: 16 - * 17 - * - Light figures carry an opaque #ffffff rectangle, so the light page 18 - * ground has to stay white. Every direction tints its surfaces (nav, 19 - * sidebar, cards, code) instead, never the page ground, otherwise all 20 - * 1220 figures would sit in a visible white box. 21 - * - Dark matplotlib figures (404 of 506) emit their figure patch as a 22 - * <path> with no fill at all, which SVG paints opaque BLACK. They are 23 - * not transparent, whatever the intent was. 24 - * - The remaining 102 dark figures are the hand-authored diagrams, whose 25 - * plate is #0d1117. 26 - * So the dark grounds sit between #000000 and #0d1117 in lightness, 27 - * which is the only place where both plate edges stay under ~1.1:1. 28 - * - No direction uses a hue near #1f77b4 or #d62728 for its accent: the 29 - * chrome must never read as part of a plot. 30 - * 31 - * Ratios are recorded in SITE-REDESIGN-NOTES.md and are checked by 32 - * scripts/check-contrast.mjs, which parses this file. 33 - * ------------------------------------------------------------------ */ 34 - 35 - /* -------------------------------------------------------------- * 36 - * instrument (default) - dark theme is the Starlight root scope 37 - * -------------------------------------------------------------- */ 38 - :root, 39 - :root[data-accent='instrument'] { 40 - color-scheme: dark; 41 - 42 - --sl-color-white: #ffffff; 43 - --sl-color-gray-1: #e6ebef; 44 - --sl-color-gray-2: #c2ccd3; 45 - --sl-color-gray-3: #8c979f; 46 - --sl-color-gray-4: #58626a; 47 - --sl-color-gray-5: #363f46; 48 - --sl-color-gray-6: #1a2126; 49 - --sl-color-black: #0d1114; 50 - 51 - --sl-color-accent-low: #10394a; 52 - --sl-color-accent: #35b8d8; 53 - --sl-color-accent-high: #b3e6f4; 54 - 55 - --sl-color-bg: var(--sl-color-black); 56 - --sl-color-bg-nav: #141b20; 57 - --sl-color-bg-sidebar: #141b20; 58 - --sl-color-bg-inline-code: var(--sl-color-gray-5); 59 - --sl-color-hairline-light: var(--sl-color-gray-5); 60 - --sl-color-hairline: #1e262c; 61 - --sl-color-hairline-shade: var(--sl-color-black); 62 - 63 - /* Landing-page and component tokens, one set per direction/theme. */ 64 - --ph-surface: #141b20; 65 - --ph-surface-raised: #192228; 66 - --ph-border: #2c353c; 67 - --ph-border-strong: #3c474f; 68 - --ph-accent-soft: #0d323f; 69 - --ph-accent-ink: #b3e6f4; 70 - --ph-mark: #35b8d8; 71 - --ph-grid: rgba(53, 184, 216, 0.14); 72 - --ph-swatch: #35b8d8; 73 - } 74 - 75 - :root[data-theme='light'], 76 - :root[data-accent='instrument'][data-theme='light'] { 77 - color-scheme: light; 78 - 79 - --sl-color-white: #12181c; 80 - --sl-color-gray-1: #1c242a; 81 - --sl-color-gray-2: #333e46; 82 - --sl-color-gray-3: #55616a; 83 - --sl-color-gray-4: #7d8991; 84 - --sl-color-gray-5: #c3ccd2; 85 - --sl-color-gray-6: #e7edf1; 86 - --sl-color-gray-7: #f3f7f9; 87 - --sl-color-black: #ffffff; 88 - 89 - --sl-color-accent-low: #d8eef6; 90 - --sl-color-accent: #0a6f8c; 91 - --sl-color-accent-high: #064a5e; 92 - 93 - --sl-color-bg: var(--sl-color-black); 94 - --sl-color-bg-nav: #f3f7f9; 95 - --sl-color-bg-sidebar: var(--sl-color-bg); 96 - --sl-color-bg-inline-code: var(--sl-color-gray-6); 97 - --sl-color-hairline-light: var(--sl-color-gray-6); 98 - --sl-color-hairline-shade: var(--sl-color-gray-6); 99 - 100 - --ph-surface: #f4f8fa; 101 - --ph-surface-raised: #ffffff; 102 - --ph-border: #dbe4e9; 103 - --ph-border-strong: #b9c6ce; 104 - --ph-accent-soft: #e4f2f7; 105 - --ph-accent-ink: #064a5e; 106 - --ph-mark: #0a6f8c; 107 - --ph-grid: rgba(10, 111, 140, 0.12); 108 - --ph-swatch: #0a6f8c; 109 - } 110 - 111 - /* -------------------------------------------------------------- * 112 - * blueprint - drafting blue-greys, prussian accent 113 - * -------------------------------------------------------------- */ 114 - :root[data-accent='blueprint'] { 115 - color-scheme: dark; 116 - 117 - --sl-color-white: #ffffff; 118 - --sl-color-gray-1: #e5e9f2; 119 - --sl-color-gray-2: #bfc7da; 120 - --sl-color-gray-3: #8891a8; 121 - --sl-color-gray-4: #555e75; 122 - --sl-color-gray-5: #333c52; 123 - --sl-color-gray-6: #171d2b; 124 - --sl-color-black: #0b0f1a; 125 - 126 - --sl-color-accent-low: #1b2b52; 127 - --sl-color-accent: #78a2f0; 128 - --sl-color-accent-high: #cbdcfb; 129 - 130 - --sl-color-bg: var(--sl-color-black); 131 - --sl-color-bg-nav: #121827; 132 - --sl-color-bg-sidebar: #121827; 133 - --sl-color-bg-inline-code: var(--sl-color-gray-5); 134 - --sl-color-hairline-light: var(--sl-color-gray-5); 135 - --sl-color-hairline: #1c2334; 136 - --sl-color-hairline-shade: var(--sl-color-black); 137 - 138 - --ph-surface: #121827; 139 - --ph-surface-raised: #171e30; 140 - --ph-border: #2a3348; 141 - --ph-border-strong: #3c4763; 142 - --ph-accent-soft: #17233f; 143 - --ph-accent-ink: #cbdcfb; 144 - --ph-mark: #78a2f0; 145 - --ph-grid: rgba(120, 162, 240, 0.14); 146 - --ph-swatch: #78a2f0; 147 - } 148 - 149 - :root[data-accent='blueprint'][data-theme='light'] { 150 - color-scheme: light; 151 - 152 - --sl-color-white: #141a28; 153 - --sl-color-gray-1: #1d2434; 154 - --sl-color-gray-2: #333c52; 155 - --sl-color-gray-3: #545e77; 156 - --sl-color-gray-4: #7d8699; 157 - --sl-color-gray-5: #c2c9d7; 158 - --sl-color-gray-6: #e5e9f2; 159 - --sl-color-gray-7: #f2f5fa; 160 - --sl-color-black: #ffffff; 161 - 162 - --sl-color-accent-low: #dde5f7; 163 - --sl-color-accent: #23479a; 164 - --sl-color-accent-high: #16305f; 165 - 166 - --sl-color-bg: var(--sl-color-black); 167 - --sl-color-bg-nav: #f2f5fa; 168 - --sl-color-bg-sidebar: var(--sl-color-bg); 169 - --sl-color-bg-inline-code: var(--sl-color-gray-6); 170 - --sl-color-hairline-light: var(--sl-color-gray-6); 171 - --sl-color-hairline-shade: var(--sl-color-gray-6); 172 - 173 - --ph-surface: #f2f5fa; 174 - --ph-surface-raised: #ffffff; 175 - --ph-border: #dae0ed; 176 - --ph-border-strong: #b6bfd3; 177 - --ph-accent-soft: #e6ecfa; 178 - --ph-accent-ink: #16305f; 179 - --ph-mark: #23479a; 180 - --ph-grid: rgba(35, 71, 154, 0.12); 181 - --ph-swatch: #23479a; 182 - } 183 - 184 - /* -------------------------------------------------------------- * 185 - * graphite - warm neutral greys, amber accent 186 - * -------------------------------------------------------------- */ 187 - :root[data-accent='graphite'] { 188 - color-scheme: dark; 189 - 190 - --sl-color-white: #ffffff; 191 - --sl-color-gray-1: #edeae5; 192 - --sl-color-gray-2: #cbc6bd; 193 - --sl-color-gray-3: #969086; 194 - --sl-color-gray-4: #605b53; 195 - --sl-color-gray-5: #3c3833; 196 - --sl-color-gray-6: #201d18; 197 - --sl-color-black: #100e0a; 198 - 199 - --sl-color-accent-low: #46320c; 200 - --sl-color-accent: #e0a33e; 201 - --sl-color-accent-high: #f7dcab; 202 - 203 - --sl-color-bg: var(--sl-color-black); 204 - --sl-color-bg-nav: #191611; 205 - --sl-color-bg-sidebar: #191611; 206 - --sl-color-bg-inline-code: var(--sl-color-gray-5); 207 - --sl-color-hairline-light: var(--sl-color-gray-5); 208 - --sl-color-hairline: #241f19; 209 - --sl-color-hairline-shade: var(--sl-color-black); 210 - 211 - --ph-surface: #191611; 212 - --ph-surface-raised: #1f1c16; 213 - --ph-border: #35302a; 214 - --ph-border-strong: #47413a; 215 - --ph-accent-soft: #3a2a0b; 216 - --ph-accent-ink: #f7dcab; 217 - --ph-mark: #e0a33e; 218 - --ph-grid: rgba(224, 163, 62, 0.14); 219 - --ph-swatch: #e0a33e; 220 - } 221 - 222 - :root[data-accent='graphite'][data-theme='light'] { 223 - color-scheme: light; 224 - 225 - --sl-color-white: #17150f; 226 - --sl-color-gray-1: #221f19; 227 - --sl-color-gray-2: #3c3830; 228 - --sl-color-gray-3: #5d584e; 229 - --sl-color-gray-4: #868075; 230 - --sl-color-gray-5: #ccc6bc; 231 - --sl-color-gray-6: #ece8e1; 232 - --sl-color-gray-7: #f7f5f1; 233 - --sl-color-black: #ffffff; 234 - 235 - --sl-color-accent-low: #f6e6c8; 236 - --sl-color-accent: #8a4b06; 237 - --sl-color-accent-high: #5c3104; 238 - 239 - --sl-color-bg: var(--sl-color-black); 240 - --sl-color-bg-nav: #f7f5f1; 241 - --sl-color-bg-sidebar: var(--sl-color-bg); 242 - --sl-color-bg-inline-code: var(--sl-color-gray-6); 243 - --sl-color-hairline-light: var(--sl-color-gray-6); 244 - --sl-color-hairline-shade: var(--sl-color-gray-6); 245 - 246 - --ph-surface: #f8f6f2; 247 - --ph-surface-raised: #ffffff; 248 - --ph-border: #e4dfd6; 249 - --ph-border-strong: #c4bcaf; 250 - --ph-accent-soft: #f8eeda; 251 - --ph-accent-ink: #5c3104; 252 - --ph-mark: #8a4b06; 253 - --ph-grid: rgba(138, 75, 6, 0.12); 254 - --ph-swatch: #8a4b06; 255 - } 256 - 257 - /* ------------------------------------------------------------------ * 258 - * Shared identity: the parts that are the same in every direction. 259 - * 260 - * The library measures things, so the chrome borrows from measurement 261 - * hardware and from print: hairlines instead of shadows, square-ish 262 - * corners, and a monospaced treatment wherever a standard designation 263 - * or a number appears. 264 - * ------------------------------------------------------------------ */ 265 - :root { 266 - --ph-radius: 4px; 267 - --ph-mono: var(--__sl-font-mono); 268 - } 269 - 270 - /* Cards and asides: hairline + flat ground rather than a drop shadow. */ 271 - .sl-markdown-content .starlight-aside { 272 - border-radius: var(--ph-radius); 273 - } 274 - 275 - /* The site title reads as an instrument label, not a wordmark. */ 276 - .site-title { 277 - letter-spacing: -0.01em; 278 - }
+129
site/src/styles/theme.css
··· 1 + /* ------------------------------------------------------------------ * 2 + * Site palette: "instrument". 3 + * 4 + * Cool steel greys and one saturated deep-cyan indicator, the way the front 5 + * panel of a measuring instrument is put together: neutral metal, a single 6 + * colour that means something. It covers the light and the dark theme, and 7 + * it is the only palette the site has; two other directions (a drafting 8 + * blueprint blue and a warm amber graphite) were prototyped alongside it and 9 + * are recoverable from the branch history. 10 + * 11 + * The palette is constrained by the committed figures, which are NOT touched 12 + * by any of it. Every one of them is an opaque plate, so the only thing the 13 + * page ground can do is make the plate edge disappear: 14 + * 15 + * - Light figures carry an opaque #ffffff rectangle, so the light page 16 + * ground has to stay white. The tint goes into the surfaces (nav, 17 + * sidebar, cards, code) instead, never into the page ground, otherwise 18 + * all 1220 figures would sit in a visible white box. 19 + * - Dark matplotlib figures (404 of 506) emit their figure patch as a 20 + * <path> with no fill at all, which SVG paints opaque BLACK. They are 21 + * not transparent, whatever the intent was. 22 + * - The remaining 102 dark figures are the hand-authored diagrams, whose 23 + * plate is #0d1117. 24 + * So the dark ground sits between #000000 and #0d1117 in lightness, 25 + * which is the only place where both plate edges stay under ~1.1:1. 26 + * - The accent is nowhere near #1f77b4 or #d62728: the chrome must never 27 + * read as part of a plot. 28 + * 29 + * Ratios are recorded in SITE-REDESIGN-NOTES.md and are checked by 30 + * scripts/check-contrast.mjs, which parses this file. 31 + * ------------------------------------------------------------------ */ 32 + 33 + /* -------------------------------------------------------------- * 34 + * Dark theme: the Starlight root scope. 35 + * -------------------------------------------------------------- */ 36 + :root { 37 + color-scheme: dark; 38 + 39 + --sl-color-white: #ffffff; 40 + --sl-color-gray-1: #e6ebef; 41 + --sl-color-gray-2: #c2ccd3; 42 + --sl-color-gray-3: #8c979f; 43 + --sl-color-gray-4: #58626a; 44 + --sl-color-gray-5: #363f46; 45 + --sl-color-gray-6: #1a2126; 46 + --sl-color-black: #0d1114; 47 + 48 + --sl-color-accent-low: #10394a; 49 + --sl-color-accent: #35b8d8; 50 + --sl-color-accent-high: #b3e6f4; 51 + 52 + --sl-color-bg: var(--sl-color-black); 53 + --sl-color-bg-nav: #141b20; 54 + --sl-color-bg-sidebar: #141b20; 55 + --sl-color-bg-inline-code: var(--sl-color-gray-5); 56 + --sl-color-hairline-light: var(--sl-color-gray-5); 57 + --sl-color-hairline: #1e262c; 58 + --sl-color-hairline-shade: var(--sl-color-black); 59 + 60 + /* Landing-page and component tokens, one set per theme. */ 61 + --ph-surface: #141b20; 62 + --ph-surface-raised: #192228; 63 + --ph-border: #2c353c; 64 + --ph-border-strong: #3c474f; 65 + --ph-accent-soft: #0d323f; 66 + --ph-accent-ink: #b3e6f4; 67 + --ph-mark: #35b8d8; 68 + --ph-grid: rgba(53, 184, 216, 0.14); 69 + } 70 + 71 + /* -------------------------------------------------------------- * 72 + * Light theme. 73 + * -------------------------------------------------------------- */ 74 + :root[data-theme='light'] { 75 + color-scheme: light; 76 + 77 + --sl-color-white: #12181c; 78 + --sl-color-gray-1: #1c242a; 79 + --sl-color-gray-2: #333e46; 80 + --sl-color-gray-3: #55616a; 81 + --sl-color-gray-4: #7d8991; 82 + --sl-color-gray-5: #c3ccd2; 83 + --sl-color-gray-6: #e7edf1; 84 + --sl-color-gray-7: #f3f7f9; 85 + --sl-color-black: #ffffff; 86 + 87 + --sl-color-accent-low: #d8eef6; 88 + --sl-color-accent: #0a6f8c; 89 + --sl-color-accent-high: #064a5e; 90 + 91 + --sl-color-bg: var(--sl-color-black); 92 + --sl-color-bg-nav: #f3f7f9; 93 + --sl-color-bg-sidebar: var(--sl-color-bg); 94 + --sl-color-bg-inline-code: var(--sl-color-gray-6); 95 + --sl-color-hairline-light: var(--sl-color-gray-6); 96 + --sl-color-hairline-shade: var(--sl-color-gray-6); 97 + 98 + --ph-surface: #f4f8fa; 99 + --ph-surface-raised: #ffffff; 100 + --ph-border: #dbe4e9; 101 + --ph-border-strong: #b9c6ce; 102 + --ph-accent-soft: #e4f2f7; 103 + --ph-accent-ink: #064a5e; 104 + --ph-mark: #0a6f8c; 105 + --ph-grid: rgba(10, 111, 140, 0.12); 106 + } 107 + 108 + /* ------------------------------------------------------------------ * 109 + * Shared identity. 110 + * 111 + * The library measures things, so the chrome borrows from measurement 112 + * hardware and from print: hairlines instead of shadows, square-ish 113 + * corners, and a monospaced treatment wherever a standard designation 114 + * or a number appears. 115 + * ------------------------------------------------------------------ */ 116 + :root { 117 + --ph-radius: 4px; 118 + --ph-mono: var(--__sl-font-mono); 119 + } 120 + 121 + /* Cards and asides: hairline + flat ground rather than a drop shadow. */ 122 + .sl-markdown-content .starlight-aside { 123 + border-radius: var(--ph-radius); 124 + } 125 + 126 + /* The site title reads as an instrument label, not a wordmark. */ 127 + .site-title { 128 + letter-spacing: -0.01em; 129 + }