diff --git a/picker/assets/hero-dark.jpg b/picker/assets/hero-dark.jpg new file mode 100644 index 000000000..1bfecfefa Binary files /dev/null and b/picker/assets/hero-dark.jpg differ diff --git a/picker/assets/hero-light.jpg b/picker/assets/hero-light.jpg deleted file mode 100644 index d3837d59e..000000000 Binary files a/picker/assets/hero-light.jpg and /dev/null differ diff --git a/picker/layouts/Picker.astro b/picker/layouts/Picker.astro index c7e6a01ad..39eff1c8f 100644 --- a/picker/layouts/Picker.astro +++ b/picker/layouts/Picker.astro @@ -13,7 +13,7 @@ const { --- - + diff --git a/picker/pages/index.astro b/picker/pages/index.astro index 87cfe6595..97a572aa0 100644 --- a/picker/pages/index.astro +++ b/picker/pages/index.astro @@ -11,7 +11,7 @@ const roles = [
- +
diff --git a/picker/scripts/color.js b/picker/scripts/color.js index aee366818..9fe95db3d 100644 --- a/picker/scripts/color.js +++ b/picker/scripts/color.js @@ -56,9 +56,24 @@ export function seedToRoles(seed) { }; } +// Relative luminance of the colors --pk-ink-dark and --pk-ink-light resolve +// to in styles/picker.css: oklch(14% 0.018 95) and oklch(99% 0.008 95). +const INK_DARK_LUMINANCE = 0.0027; +const INK_LIGHT_LUMINANCE = 0.9716; + +// The ink sits on a color the user picked, so it must not follow the picker's +// own theme: --ks-champagne and friends invert between light and dark and +// would blank the label on exactly the swatches that need it most. Comparing +// both ratios beats a fixed lightness threshold, which picks the losing ink +// for mid-tones sitting near the cutoff. export function contrastInk(hex) { const [red, green, blue] = parseHex(hex).map(linearize); - return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.22 - ? 'var(--ks-champagne)' - : 'var(--ks-lacquer-raised)'; + const swatch = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + const against = (ink) => { + const [hi, lo] = ink > swatch ? [ink, swatch] : [swatch, ink]; + return (hi + 0.05) / (lo + 0.05); + }; + return against(INK_DARK_LUMINANCE) >= against(INK_LIGHT_LUMINANCE) + ? 'var(--pk-ink-dark)' + : 'var(--pk-ink-light)'; } diff --git a/picker/styles/picker.css b/picker/styles/picker.css index c6acdd841..19d07db84 100644 --- a/picker/styles/picker.css +++ b/picker/styles/picker.css @@ -3,6 +3,17 @@ @import "./vendor/kinpaku-tokens.css"; @import "./vendor/kinpaku-kit.css"; +/* Ink for surfaces painted in a color the user chose, not in one of ours: + swatch labels and the wireframe artboards. Every --ks-* text token swaps + between the light and dark themes, which would invert these against + palettes that never changed. Values are the system's own ink and raised + paper, so neither is pure black or pure white. contrastInk() in + scripts/color.js mirrors their luminance; change both together. */ +:root { + --pk-ink-dark: oklch(14% 0.018 95); + --pk-ink-light: oklch(99% 0.008 95); +} + /* Scoped subset of the reset imported by site/styles/main.css through site/styles/tokens.css (lines 12-27). */ .picker-page, @@ -34,19 +45,6 @@ body.picker-page { text-rendering: optimizeLegibility; } -/* Copied from site/styles/light-mode.css lines 189-194. Light mode remaps - --ks-lacquer-deep to paper, so the primary button needs its dark ink value. */ -html.light .ks-button.ks-button-primary, -html.light .ks-button.ks-button-primary:hover, -html.light .ks-button.ks-button-primary:active { - color: oklch(14% 0.018 95); -} - -/* On paper, ghost buttons lift toward deep patina, not fill gold. */ -html.light .ks-button.ks-button-ghost:hover { - color: var(--ks-patina-deep); -} - .picker-shell { position: relative; min-height: 100vh; @@ -513,8 +511,8 @@ html.light .ks-button.ks-button-ghost:hover { .picker-icon-button:hover { background: var(--ks-graphite); - border-color: var(--ks-link-on-paper); - color: var(--ks-link-on-paper); + border-color: var(--ks-patina); + color: var(--ks-patina); } .picker-icon-button:focus-visible, @@ -789,7 +787,7 @@ html.light .ks-button.ks-button-ghost:hover { .picker-preview { --pv-primary: var(--ks-champagne); --pv-secondary: var(--ks-patina); - --pv-tertiary: var(--ks-link-on-paper); + --pv-tertiary: var(--ks-kinpaku); --pv-neutral: var(--ks-lacquer-raised); --pv-n-ink: var(--ks-champagne); --pv-p-ink: var(--ks-champagne); @@ -1156,7 +1154,7 @@ html.light .ks-button.ks-button-ghost:hover { } .picker-strategy-option:has(input:checked) .picker-strategy-title { - color: var(--ks-link-on-paper); + color: var(--ks-patina); } .picker-strategy-option:has(input:focus-visible) { @@ -1183,7 +1181,7 @@ html.light .ks-button.ks-button-ghost:hover { } .picker-strategy-choices .picker-strategy-option:has(input:checked) .picker-strategy-title { - color: var(--ks-link-on-paper); + color: var(--ks-patina); } .picker-strategy-choices .picker-strategy-copy { @@ -1209,7 +1207,7 @@ html.light .ks-button.ks-button-ghost:hover { .picker-strategy-preview { --pv-primary: var(--ks-champagne); --pv-secondary: var(--ks-patina); - --pv-tertiary: var(--ks-link-on-paper); + --pv-tertiary: var(--ks-kinpaku); --pv-neutral: var(--ks-lacquer-raised); --pv-n-ink: var(--ks-champagne); --pv-p-ink: var(--ks-champagne); @@ -1960,7 +1958,7 @@ html.light .ks-button.ks-button-ghost:hover { } .picker-type-options .picker-strategy-option:has(input:checked) .picker-type-sample-heading { - color: var(--ks-link-on-paper); + color: var(--ks-patina); } .picker-type-copy { @@ -2324,7 +2322,7 @@ html.light .ks-button.ks-button-ghost:hover { flex: 1 1 auto; min-width: 18ch; overflow-x: auto; - color: var(--ks-link-on-paper, var(--ks-champagne)); + color: var(--ks-patina); font-family: var(--ks-mono); font-size: var(--ks-type-body-size); letter-spacing: 0; @@ -2346,7 +2344,7 @@ html.light .ks-button.ks-button-ghost:hover { border: 1px solid var(--ks-rule); border-radius: 2px; background: transparent; - color: var(--ks-link-on-paper, var(--ks-champagne)); + color: var(--ks-patina); cursor: pointer; transition: background 180ms var(--ks-ease), diff --git a/scripts/build-picker.mjs b/scripts/build-picker.mjs index c1357e796..a47ea8aaf 100644 --- a/scripts/build-picker.mjs +++ b/scripts/build-picker.mjs @@ -12,8 +12,8 @@ const outputDir = path.join(root, 'skill/scripts/picker'); // private impeccable-site repo, so the picker carries its own copies). const faviconSource = path.join(root, 'picker/assets/favicon.svg'); const faviconOutput = path.join(outputDir, 'favicon.svg'); -const heroSource = path.join(root, 'picker/assets/hero-light.jpg'); -const heroOutput = path.join(outputDir, 'assets/hero-light.jpg'); +const heroSource = path.join(root, 'picker/assets/hero-dark.jpg'); +const heroOutput = path.join(outputDir, 'assets/hero-dark.jpg'); await rm(buildDir, { recursive: true, force: true }); execFileSync( diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs index 45f6ceed1..56e93d49d 100644 --- a/tests/picker-server.test.mjs +++ b/tests/picker-server.test.mjs @@ -198,7 +198,7 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a assert.match(pageHtml, /rel="icon" type="image\/svg\+xml" href="\.\/favicon\.svg"/); assert.match(pageHtml, /data-type-headline/); assert.doesNotMatch(pageHtml, />Made to last 0); @@ -299,8 +299,13 @@ test('picker color math round-trips sRGB and clips out-of-gamut OKLCH', async () assert.deepEqual(Object.keys(seedToRoles({ oklch: [0.62, 0.15, 210] })), [ 'primary', 'secondary', 'tertiary', 'neutral', ]); - assert.equal(contrastInk('#FFFFFF'), 'var(--ks-champagne)'); - assert.equal(contrastInk('#000000'), 'var(--ks-lacquer-raised)'); + // Swatch ink is theme-independent: it sits on a color the user picked, so + // it must not follow the picker's own light/dark tokens. + assert.equal(contrastInk('#FFFFFF'), 'var(--pk-ink-dark)'); + assert.equal(contrastInk('#000000'), 'var(--pk-ink-light)'); + // A mid-tone reads better against the dark ink, which a fixed lightness + // threshold gets backwards. + assert.equal(contrastInk('#8D7352'), 'var(--pk-ink-dark)'); }); test('rejects raw, encoded, and double-encoded path traversal', async (t) => {