Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -323,6 +328,7 @@
shadows: []
watch:
- apps/web/src/index.css
- apps/web/index.html
verify:
- apps/web/src/__fork_guards__/forkSurfacePalette.test.ts

Expand Down
16 changes: 16 additions & 0 deletions .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 83 additions & 21 deletions apps/web/src/__fork_guards__/forkSurfacePalette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();
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", () => {
Expand Down Expand Up @@ -99,24 +163,22 @@ 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");
});

it("leaves light mode on upstream's 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"),
);
Expand Down
38 changes: 18 additions & 20 deletions apps/web/src/theme.custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
Loading