feat(design): Fjord, a trial fifth scheme — Midnight's navy with a steel-blue accent and steel rules (E28)

This commit is contained in:
2026-09-25 15:39:49 +02:00
parent 62dbf35ea3
commit 905a9f2856
7 changed files with 94 additions and 5 deletions
+3 -1
View File
@@ -76,7 +76,8 @@ style guide.
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). Black and white are the media scrims and their text. Four schemes × dark (owner decision F16). Black and white are the media scrims and their text. Four schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. place with its own hue family. **Fjord** (Midnight's navy with a steel-blue accent) is a trial outside that rule: the owner
tries it app-wide, then it is folded into Midnight or removed. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it, labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
@@ -371,5 +372,6 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names | | 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down | | 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light | | 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide | | 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it | | 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#1d4a66" stop-opacity=".95"/><stop offset="100%" stop-color="#1d4a66" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#337599" stop-opacity=".5"/><stop offset="100%" stop-color="#337599" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2d57" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2d57" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#173c52" stop-opacity=".85"/><stop offset="100%" stop-color="#173c52" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#080d1a"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#a8c6dc" stop-opacity=".9"/><stop offset="100%" stop-color="#a8c6dc" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#5aa1cb" stop-opacity=".5"/><stop offset="100%" stop-color="#5aa1cb" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b9c6e0" stop-opacity=".85"/><stop offset="100%" stop-color="#b9c6e0" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#a9d0dc" stop-opacity=".8"/><stop offset="100%" stop-color="#a9d0dc" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#eaf0f6"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -1
View File
@@ -150,7 +150,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
return ( return (
<> <>
<Section title={t("settings.appearance.colorScheme")}> <Section title={t("settings.appearance.colorScheme")}>
<div className="grid grid-cols-4 gap-2"> {/* One row whatever the count: a fifth scheme wrapped alone onto a second line. */}
<div className="grid auto-cols-fr grid-flow-col gap-2">
{SCHEMES.map((s) => ( {SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}> <Tooltip key={s.id} hint={s.name}>
<Pressable <Pressable
+7 -2
View File
@@ -65,7 +65,7 @@ const pal = (
accentFg: string, accentFg: string,
): Pal => ({ bg, surface, border, fg, muted, accent, accentFg }); ): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
// A literal copy of index.css's scheme tokens, so the four tiles render side by side (the live CSS // A literal copy of index.css's scheme tokens, so the tiles render side by side (the live CSS
// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it. // switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it.
const SCHEMES: SchemeDef[] = [ const SCHEMES: SchemeDef[] = [
{ {
@@ -88,6 +88,11 @@ const SCHEMES: SchemeDef[] = [
dark: pal("#0b0e0b", "#111510", "#2a3226", "#d9ded6", "#8f9a8a", "#86a05a", "#0c1208"), dark: pal("#0b0e0b", "#111510", "#2a3226", "#d9ded6", "#8f9a8a", "#86a05a", "#0c1208"),
light: pal("#f4f5f0", "#ffffff", "#dfe3d8", "#161a14", "#5a6352", "#556b2f", "#ffffff"), light: pal("#f4f5f0", "#ffffff", "#dfe3d8", "#161a14", "#5a6352", "#556b2f", "#ffffff"),
}, },
{
name: "Fjord · steel (trial)",
dark: pal("#0d1225", "#121931", "#25506e", "#eef1f5", "#9fb0c4", "#5aa1cb", "#0d1225"),
light: pal("#f3f6fa", "#ffffff", "#c9d6e3", "#152232", "#4f6278", "#256b8f", "#ffffff"),
},
]; ];
function Metric({ ok, children }: { ok: boolean; children: ReactNode }) { function Metric({ ok, children }: { ok: boolean; children: ReactNode }) {
@@ -461,7 +466,7 @@ export function BatchB() {
id="b2" id="b2"
code="B2" code="B2"
title="The scheme set" title="The scheme set"
rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix moss. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light." rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix moss. YouTube and Starship are gone (a user on them lands on Midnight). Fjord is a trial outside the rule (Midnight's family, Playnite's steel accent): folded into Midnight or removed. Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
> >
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]"> <div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]">
{SCHEMES.flatMap((s) => {SCHEMES.flatMap((s) =>
+42
View File
@@ -356,6 +356,14 @@ html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
), ),
url("/backdrops/matrix.svg"); url("/backdrops/matrix.svg");
} }
html[data-backdrop="on"][data-theme="dark"][data-scheme="fjord"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/fjord.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body { html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body {
background-image: background-image:
linear-gradient( linear-gradient(
@@ -388,6 +396,14 @@ html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
), ),
url("/backdrops/light/matrix.svg"); url("/backdrops/light/matrix.svg");
} }
html[data-backdrop="on"][data-theme="light"][data-scheme="fjord"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/fjord.svg");
}
html[data-backdrop="on"][data-theme="dark"] body, html[data-backdrop="on"][data-theme="dark"] body,
html[data-backdrop="on"][data-theme="light"] body { html[data-backdrop="on"][data-theme="light"] body {
background-size: cover; background-size: cover;
@@ -984,6 +1000,32 @@ html[data-scheme="matrix"][data-theme="light"] {
--accent-fg: #ffffff; --accent-fg: #ffffff;
} }
/* Fjord — TRIAL, not yet a member of the set (docs/DESIGN.md decision log, B2 exception, 2026-09-25):
Midnight's navy with Playnite's calm — a muted steel-blue accent and steel rules instead of the vivid
periwinkle. It shares Midnight's hue family on purpose; the owner tries it app-wide, then it is folded
into Midnight or removed (normalizeTheme lands a stored "fjord" on the default either way). Playnite's
own accent (#337599) is 3.7:1 as text, so the accent is the same hue lifted to 6.5:1. */
html[data-scheme="fjord"][data-theme="dark"] {
--bg: #0d1225;
--surface: #121931;
--card: #161e38;
--border: #25506e;
--fg: #eef1f5;
--muted: #9fb0c4;
--accent: #5aa1cb;
--accent-fg: #0d1225;
}
html[data-scheme="fjord"][data-theme="light"] {
--bg: #f3f6fa;
--surface: #ffffff;
--card: #ffffff;
--border: #c9d6e3;
--fg: #152232;
--muted: #4f6278;
--accent: #256b8f;
--accent-fg: #ffffff;
}
/* ===== Semantic status colors (danger / warn / success) ===== /* ===== Semantic status colors (danger / warn / success) =====
Scheme-INDEPENDENT, theme-scoped: a status color is recognised by its conventional Scheme-INDEPENDENT, theme-scoped: a status color is recognised by its conventional
hue (red=danger), so it stays constant across schemes and only shifts by theme to hue (red=danger), so it stays constant across schemes and only shifts by theme to
+4 -1
View File
@@ -1,7 +1,7 @@
import { LS, readAccountMerged, writeAccount } from "./storage"; import { LS, readAccountMerged, writeAccount } from "./storage";
type Mode = "dark" | "light"; type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "matrix"; export type Scheme = "midnight" | "forest" | "slate" | "matrix" | "fjord";
// The scheme set (docs/DESIGN.md, decision B2): each earns its place with its own hue family, and its // The scheme set (docs/DESIGN.md, decision B2): each earns its place with its own hue family, and its
// accent stays ≥ 30° of OKLCH hue away from danger / warn / success. theme.contract.test.ts measures // accent stays ≥ 30° of OKLCH hue away from danger / warn / success. theme.contract.test.ts measures
@@ -11,6 +11,9 @@ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "forest", name: "Forest", swatch: "#22d3ee" }, { id: "forest", name: "Forest", swatch: "#22d3ee" },
{ id: "slate", name: "Slate", swatch: "#e879f9" }, { id: "slate", name: "Slate", swatch: "#e879f9" },
{ id: "matrix", name: "Matrix", swatch: "#86a05a" }, { id: "matrix", name: "Matrix", swatch: "#86a05a" },
// A TRIAL outside B2 (Midnight's hue family, DESIGN.md decision log 2026-09-25): folded into
// Midnight or removed once the owner has tried it; a stored "fjord" then lands on the default.
{ id: "fjord", name: "Fjord", swatch: "#5aa1cb" },
]; ];
export interface ThemePrefs { export interface ThemePrefs {