diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 2bf7078900c7..a4ce6fea85e9 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -297,16 +297,21 @@ - id: fork-surface-palette intent: > - The dark shell is not black. The workspace stage is #212121 and the sidebar - v2 panel is #1e1e1e, so the panel sits below the stage rather than lifting - off it — the inverse of upstream's "surfaces lift from the base" model, and - deliberate: the panel is chrome, the stage is the work. Borders are #2d2e2e, - row hover #262626, row selected and route-active #2a2a2a. + The dark shell is not black. The workspace stage is #191919 and the sidebar + v2 panel is #1e1e1e — the panel sits slightly above the stage so chrome + reads in front of the work surface. Borders are #2d2e2e, row hover #262626, + row selected and route-active #2a2a2a. + + The stage stays near upstream's pre-paint / overscroll colour (#161616 in + apps/web/index.html) so the load flash and rubber-band band do not seam + against the hydrated stage. Every value is flat opaque sRGB, not an alpha over the panel. Alpha compounds — a fill inside a hovered row inside a selected row lands on a colour nobody specified — and these are only correct against #1e1e1e - anyway, so freezing them is honest about that. + anyway, so freezing them is honest about that. Dark mode therefore clears + surface grain: upstream's noise would compound unspecified drift onto every + chrome surface this palette freezes. Two blocks are required and the second is not redundant: upstream re-declares --background, --card, --border and the whole --sidebar-* family @@ -323,6 +328,7 @@ shadows: [] watch: - apps/web/src/index.css + - apps/web/index.html verify: - apps/web/src/__fork_guards__/forkSurfacePalette.test.ts diff --git a/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md b/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md index e4f49a8cf396..052b03e108c2 100644 --- a/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md +++ b/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md @@ -82,6 +82,22 @@ Related deep-dives that predate this file and stay where they are: of the implementing PR caught it. That is why the guard test checks selection by walking the tree independently and demanding the selection match, rather than by spot-checks. +## fork-surface-palette + +- Stage `#191919` is a judgement call (not a Figma node): darken the work + surface under the `#1e1e1e` panel so chrome sits slightly in front of the + work. That inverts the earlier "panel below stage" hierarchy; the invariant + is now panel-above-stage, not the old inversion of upstream's lift-from-base + model. +- A side effect of the darker stage is closing the seam with upstream's + `#161616` pre-paint / overscroll colour in `apps/web/index.html` (theme-color + metas, `DARK_BACKGROUND`, and `html.dark body`). The guard pins that + proximity so a later stage tweak cannot reopen the flash without noticing. +- `--surface-grain: none` stays for the flat-opaque reason alone. Against + `#191919`, upstream's 0.035 noise would _increase_ panel/stage ΔL\* rather + than collapse it, so the old "grain erases the separation" argument no longer + holds and must not be restated. + ## fork-composer-shell - The wrap observer and its latch originally lived half-inline in `ChatComposer.tsx`, with the diff --git a/apps/web/src/__fork_guards__/forkSurfacePalette.test.ts b/apps/web/src/__fork_guards__/forkSurfacePalette.test.ts index a38300fbdebb..a02b2dd24513 100644 --- a/apps/web/src/__fork_guards__/forkSurfacePalette.test.ts +++ b/apps/web/src/__fork_guards__/forkSurfacePalette.test.ts @@ -8,12 +8,14 @@ * evaporates with a green checkmark. These tests turn that into a red one. * * The load-bearing invariant here is not "the hexes are these hexes" — it is - * that the override reaches the sidebar panel at all. Upstream re-declares - * `--background` / `--card` / `--border` and the whole `--sidebar-*` family on - * the panel element itself, so a fork rule written only against `:root` is - * inert on exactly the surface the design is about, and inert in a way that - * looks fine in a diff. Most of what follows is therefore about the doubled - * selector. + * that the override reaches the sidebar panel at all, and that the panel/stage + * relationship the intent names stays true. Upstream re-declares `--background` + * / `--card` / `--border` and the whole `--sidebar-*` family on the panel + * element itself, so a fork rule written only against `:root` is inert on + * exactly the surface the design is about, and inert in a way that looks fine + * in a diff. Most of what follows is therefore about the doubled selector — + * plus a derived ordering check so a value tweak that silently inverts the + * hierarchy cannot keep CI green. */ import * as NodeFS from "node:fs"; @@ -46,17 +48,79 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } +function declarationHex(block: string, prop: string): string { + const pattern = new RegExp(`${escapeRegExp(prop)}:\\s*(#[0-9a-f]{6})`, "iu"); + const match = pattern.exec(block); + expect(match, `no ${prop} hex in block`).not.toBeNull(); + return (match?.[1] ?? "").toLowerCase(); +} + +function parseHex(hex: string): readonly [number, number, number] { + const match = /^#([0-9a-f]{6})$/iu.exec(hex.trim()); + expect(match, `expected #rrggbb, got ${hex}`).not.toBeNull(); + const n = Number.parseInt(match?.[1] ?? "0", 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +} + +function srgbToLinear(channel: number): number { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(hex: string): number { + const [r, g, b] = parseHex(hex); + return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); +} + +/** CIE L* from relative luminance (D65 / sRGB). */ +function lStar(hex: string): number { + const y = relativeLuminance(hex); + return y > 0.008856 ? 116 * y ** (1 / 3) - 16 : 903.3 * y; +} + +function hexesIn(...blocks: readonly string[]): string[] { + const found = new Set(); + for (const block of blocks) { + for (const match of block.matchAll(/#[0-9a-f]{6}/giu)) { + found.add(match[0].toLowerCase()); + } + } + return [...found]; +} + const theme = readSibling("../theme.custom.css"); +const indexHtml = readSibling("../../index.html"); const STAGE = [`${MARKER}.dark`]; const PANEL = [`${MARKER}.dark`, '[data-sidebar-version="v2"]']; describe("fork guard: fork-surface-palette", () => { it("repaints the workspace stage off black", () => { - // Upstream's dark base is neutral-950. If this block goes, the sidebar - // stays #1e1e1e against a near-black stage and the panel reads as a hole - // rather than as chrome. - expect(blockFor(theme, STAGE)).toContain("--background: #212121"); + // Upstream's dark base is near-black / #161616 pre-paint. Losing this + // block drops the stage onto that base and leaves the #1e1e1e panel as the + // only lifted surface — chrome without a distinct work plane under it. + expect(blockFor(theme, STAGE)).toContain("--background: #191919"); + }); + + it("keeps the panel above the stage, as the intent names", () => { + // Intent: fork-surface-palette. Assert the ordering, not only the + // constants — a value tweak is fine, silently inverting the hierarchy is + // not. ΔL* > 2 is "perceptible on a large flat field" for this chrome. + const stage = declarationHex(blockFor(theme, STAGE), "--background"); + const panel = declarationHex(blockFor(theme, PANEL), "--background"); + expect(relativeLuminance(panel)).toBeGreaterThan(relativeLuminance(stage)); + expect(Math.abs(lStar(panel) - lStar(stage))).toBeGreaterThan(2); + }); + + it("keeps the stage near upstream's pre-paint colour", () => { + // apps/web/index.html hardcodes #161616 for dark theme-color / body / + // DARK_BACKGROUND. The stage must stay close enough that the load flash + // and overscroll band do not seam against the hydrated shell. + const prePaint = "#161616"; + expect(indexHtml).toContain(prePaint); + expect(indexHtml).toMatch(/DARK_BACKGROUND\s*=\s*"#161616"/u); + const stage = declarationHex(blockFor(theme, STAGE), "--background"); + expect(Math.abs(lStar(stage) - lStar(prePaint))).toBeLessThan(3); }); it("repaints the sidebar v2 panel through its own selector, not just :root", () => { @@ -99,12 +163,10 @@ describe("fork guard: fork-surface-palette", () => { expect(panel).toContain("--sidebar-control-surface: #303030"); }); - it("clears the grain that would erase the panel/stage separation", () => { - // Measured, not assumed: upstream's 0.035 noise lifts the sidebar panel - // from #1e1e1e to ~#212121 — the stage's colour — because the chat content - // paints over the workspace copy of the grain but nothing paints over the - // sidebar's. Losing this line silently collapses the three-level separation - // every other value in this block is built around. + it("clears the grain that would compound drift onto flat surfaces", () => { + // Kept for the flat-opaque reason alone. Upstream's 0.035 noise was + // calibrated against #000; on these surfaces any useful opacity leaves + // unspecified drift on every chrome fill this palette freezes. expect(blockFor(theme, STAGE)).toContain("--surface-grain: none"); }); @@ -112,11 +174,11 @@ describe("fork guard: fork-surface-palette", () => { // The design is dark-only. A fork surface value that escaped its `.dark` // scope would paint a #1e1e1e panel into the light theme. // - // Every marker-rooted block is checked, not only the one whose selector is - // exactly the bare marker: a hex leaked into - // `:root[marker] .some-descendant { }` is just as wrong and the narrower - // form missed it. - const surfaceHexes = ["#212121", "#1e1e1e", "#2d2e2e", "#2a2a2a", "#262626"]; + // Hexes are collected from the stage and panel blocks rather than + // hand-listed: editing one and forgetting the other left the safety net + // silently backing up a palette it no longer matched. + const surfaceHexes = hexesIn(blockFor(theme, STAGE), blockFor(theme, PANEL)); + expect(surfaceHexes.length).toBeGreaterThan(0); const lightRules = cssRules(theme).filter( (rule) => rule.selector.includes(MARKER) && !rule.selector.includes(".dark"), ); diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 56f52cb4f6a8..ebfaa27a7002 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -70,10 +70,13 @@ /* --------------------------------------------------------------------------- Surface palette (dark only) — see `.fork/customizations.yaml#fork-surface-palette`. - The shell moves off pure black. The workspace stage is #212121 and the - sidebar panel is #1e1e1e, so the panel sits *below* the stage rather than - lifting off it — the inverse of upstream's "surfaces lift from the base" - model, and deliberate: the panel is chrome, the stage is the work. + The shell moves off pure black. The workspace stage is #191919 and the + sidebar panel is #1e1e1e — chrome sits slightly in front of the work + surface (panel above stage). Stage #191919 is a judgement call that also + closes the seam with upstream's #161616 pre-paint / overscroll colour in + `apps/web/index.html`. Stage-scoped `--border: #2d2e2e` was kept: on the + darker stage it reads a touch stronger than against the old #212121, which + is fine for a hairline. Two blocks, not one, and the second is not redundant. Upstream re-declares --background / --card / --border and the whole --sidebar-* family *on the @@ -89,26 +92,21 @@ upstream's zinc hierarchy. NOTE: only the v2 panel is repainted. v1 keeps upstream's black panel so the - beta toggle stays a real A/B, which does mean a v1 sidebar sits against the - new #212121 stage. The stage is global chrome and has no sidebar-version - attribute to key off. */ + beta toggle stays a real A/B. Darkening the stage toward that black panel + narrows the v1 edge — accepted cost of sitting near the #161616 pre-paint. + The stage is global chrome and has no sidebar-version attribute to key off. */ :root[data-fork="noahhendrickson-t3code"].dark { - --background: #212121; + --background: #191919; --border: #2d2e2e; /* Upstream lays a 0.035-opacity white noise over every chrome surface (`@utility surface-grain`, applied to the sidebar shell and the workspace - inset). It was calibrated against a #000 shell, where a mean lift of ~+2.8 - is invisible. On these surfaces it is not: measured, it carries the sidebar - panel from #1e1e1e to ~#212121 — which is the stage's colour. The workspace - keeps its drawn value only because the chat content paints over the grain - opaquely, so the net effect is that the panel climbs to meet the stage and - the three-level separation this whole palette is built on disappears. - - Dropped rather than dialled down: the opacity that would keep the panel - within a level of spec is ~0.008, at which point there is no texture left - to preserve, only an unspecified amount of drift on every surface. Light - mode keeps upstream's grain — its surfaces are unchanged, so the original - calibration still holds. */ + inset). It was calibrated against a #000 shell. On these surfaces any + useful grain opacity compounds unspecified drift into fills this palette + freezes as flat opaque sRGB — the opacity that would keep drift within a + level of the panel is ~0.008, at which point there is no texture left to + preserve. Dropped rather than dialled down. Light mode keeps upstream's + grain — its surfaces are unchanged, so the original calibration still + holds. */ --surface-grain: none; }