From d2d63eeaa6d543ec778fb4514bf7b984a65df52a Mon Sep 17 00:00:00 2001 From: x Date: Mon, 26 Oct 2026 14:57:13 -0400 Subject: [PATCH 1/2] feat(web): warm Neutral Dark palette with flat popups Neutral Dark moves from true-neutral grays to a warm family keyed off the sidebar #34322f, with the chat column a darker shade (#2c2a27) and every lift (rows, borders, composer, chips) re-derived in the same hue so the surface steps still read. Popups drop the shared dark frost for a flat #403e3b, and unchecked checkboxes become a flat white hairline. Glass and the other palettes are untouched. Co-Authored-By: Claude Opus 5.5 --- .fork/customizations.yaml | 29 +++-- apps/web/index.html | 4 +- .../forkNeutralDarkTheme.test.ts | 71 ++++++----- apps/web/src/custom/forkTheme.ts | 2 +- apps/web/src/theme.custom.css | 2 +- apps/web/src/theme.custom.palettes.css | 111 ++++++++++++------ 6 files changed, 142 insertions(+), 77 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 8314c9eb3664b..177cb40663356 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -2246,20 +2246,25 @@ - id: fork-neutral-dark-theme intent: > Appearance offers Neutral Dark alongside System, Light, Dark, and Westworld. - Neutral Dark is a true-neutral gray palette from Figma t3-fork node - 264:11816 — no cool undertone. Selecting it must not replace or alter - default Dark (#191919 / #1e1e1e) or Westworld (#26272c). + Neutral Dark is a warm-gray palette keyed off the sidebar #34322f (it began + as the true-neutral Figma t3-fork node 264:11816). Selecting it must not + replace or alter default Dark (#191919 / #1e1e1e) or Westworld (#26272c). Neutral Dark reuses the fork palette overlay in custom/forkTheme.ts (t3code:fork-theme = neutral-dark, upstream theme = dark, - data-fork-theme="neutral-dark"). Stage #1f1f1f, sidebar panel #2a2a2a - (slightly darker than Figma's #2e2e2e), composer vessel #323232 (a step - above the panel), prompt surface #3d3d3d (frosted), context chips #414141, - row hover/selected as opaque white-8% lifts (#383838 / #3a3a3a), borders - #3f3f3f. Accents stay monochrome (white primary/ring) with neutral muted + data-fork-theme="neutral-dark"). Sidebar panel #34322f, stage #2c2a27 (a + darker shade of the sidebar), composer vessel #3c3a37 (a step above the + panel), prompt surface #474542 (frosted), context chips #4b4946, row + hover/selected as opaque lifts (#42403d / #44423f), borders #494744. + Accents stay monochrome (white primary/ring) with neutral muted text. theme.custom.palettes.css uses the same two-block stage + v2 panel - pattern as Westworld. index.html pre-paints #1f1f1f so the load flash matches. + pattern as Westworld. index.html pre-paints #2c2a27 so the load flash matches. The sidebar stays opaque solid — wallpaper glass belongs to Neutral Darker. + Popups open flat on --popover #403e3b (a step above --card #383633) with no + backdrop-filter; the shared dark frost (fork-popup-surface) reads as glass, + which belongs to Glass. + An unchecked checkbox is a flat white-16% hairline with no fill, highlight + or shadow. tier: 2 files: - apps/web/src/custom/forkTheme.ts @@ -2275,9 +2280,9 @@ - id: fork-neutral-darker-theme intent: > Appearance offers Neutral Darker as a deeper sibling of Neutral Dark. - True-neutral grays only. Chat stage #1a1a1a (slightly darker than Neutral - Dark's #1f1f1f); sidebar #212121 (much darker than Neutral Dark's #2a2a2a, - still a step lighter than the stage). Stage --card/--popover match the + True-neutral grays only. Chat stage #1a1a1a (darker than Neutral Dark's + #2c2a27); sidebar #212121 (much darker than Neutral Dark's #34322f, still a + step lighter than the stage). Stage --card/--popover match the sidebar (#212121) so menus open at the same lift. Composer vessel sits a step above the sidebar at #252525 so the control row contrasts against the stage; prompt/chips lift from that vessel. Must not alter Neutral Dark, diff --git a/apps/web/index.html b/apps/web/index.html index eed949d3adf3c..ce41eeb54c916 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -29,7 +29,7 @@ const forkPaletteBackgrounds = { "cool-dark": "#26272c" }; /* fork:end fork-cool-dark-theme */ /* fork:begin fork-neutral-dark-theme — see .fork/customizations.yaml#fork-neutral-dark-theme */ - forkPaletteBackgrounds["neutral-dark"] = "#1f1f1f"; + forkPaletteBackgrounds["neutral-dark"] = "#2c2a27"; /* fork:end fork-neutral-dark-theme */ /* fork:begin fork-neutral-darker-theme — see .fork/customizations.yaml#fork-neutral-darker-theme */ forkPaletteBackgrounds["neutral-darker"] = "#1a1a1a"; @@ -489,7 +489,7 @@ /* fork:begin fork-neutral-dark-theme — see .fork/customizations.yaml#fork-neutral-dark-theme */ html.dark[data-fork-theme="neutral-dark"] body { - background: #1f1f1f; + background: #2c2a27; color: #e8e8e8; } /* fork:end fork-neutral-dark-theme */ diff --git a/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts b/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts index 71c772449dc50..58e135969854e 100644 --- a/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts +++ b/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts @@ -3,7 +3,7 @@ * Fork guard — see `.fork/README.md` §4b and * `.fork/customizations.yaml#fork-neutral-dark-theme`. * - * Neutral Dark is a selectable true-neutral dark palette. Losing the + * Neutral Dark is a selectable warm-gray dark palette. Losing the * preference wiring leaves the Appearance option as a dead label; losing the * CSS attribute selectors silently paints Neutral Dark with default Dark fills. */ @@ -95,12 +95,12 @@ describe("fork guard: fork-neutral-dark-theme", () => { const stage = ruleBodyFor(themeRules, NEUTRAL_STAGE); const panel = ruleBodyFor(themeRules, NEUTRAL_PANEL); expect(declarationHex(stage, "--background")).toBe(NEUTRAL_DARK_BACKGROUND); - expect(declarationHex(panel, "--background")).toBe("#2a2a2a"); - expect(panel).toContain("--sidebar: #2a2a2a"); - expect(panel).toContain("--sidebar-stage-fade: #2a2a2a"); - expect(stage).toContain("--fork-composer-vessel-bg: #323232"); - expect(stage).toContain("--fork-composer-bg: rgb(61 61 61 / 80%)"); - expect(stage).toContain("--fork-context-chip-bg: rgb(65 65 65)"); + expect(declarationHex(panel, "--background")).toBe("#34322f"); + expect(panel).toContain("--sidebar: #34322f"); + expect(panel).toContain("--sidebar-stage-fade: #34322f"); + expect(stage).toContain("--fork-composer-vessel-bg: #3c3a37"); + expect(stage).toContain("--fork-composer-bg: rgb(71 69 66 / 80%)"); + expect(stage).toContain("--fork-context-chip-bg: rgb(75 73 70)"); }); it("keeps Neutral Dark lighter than the default Dark stage and distinct from Westworld", () => { @@ -112,30 +112,33 @@ describe("fork guard: fork-neutral-dark-theme", () => { expect(neutralStage).toBe(NEUTRAL_DARK_BACKGROUND); }); - it("keeps Neutral Dark truly neutral (R = G = B on stage)", () => { - const [r, g, b] = parseHex( - declarationHex(ruleBodyFor(themeRules, NEUTRAL_STAGE), "--background"), - ); - expect(r).toBe(g); - expect(g).toBe(b); + it("paints the stage as a darker shade of the warm sidebar", () => { + const stage = parseHex(declarationHex(ruleBodyFor(themeRules, NEUTRAL_STAGE), "--background")); + const panel = parseHex(declarationHex(ruleBodyFor(themeRules, NEUTRAL_PANEL), "--background")); + const [r, g, b] = stage; + expect(r).toBeLessThan(panel[0]); + expect(g).toBeLessThan(panel[1]); + expect(b).toBeLessThan(panel[2]); + expect(r).toBeGreaterThan(g); + expect(g).toBeGreaterThan(b); }); it("states Neutral Dark row fills as opaque values", () => { const panel = ruleBodyFor(themeRules, NEUTRAL_PANEL); - expect(panel).toContain("--sidebar-row-hover: #383838"); - expect(panel).toContain("--sidebar-row-active: #3a3a3a"); - expect(panel).toContain("--sidebar-row-selected: #3a3a3a"); + expect(panel).toContain("--sidebar-row-hover: #42403d"); + expect(panel).toContain("--sidebar-row-active: #44423f"); + expect(panel).toContain("--sidebar-row-selected: #44423f"); expect(panel).not.toMatch( /--sidebar-row-(?:hover|active|selected):[^;]*(?:color-mix|--alpha)/u, ); }); - it("keeps context chips on an opaque match of the Figma chip fill", () => { + it("keeps context chips on an opaque fill", () => { const contextRules = cssRules(theme).filter((rule) => rule.body.includes("--fork-context-chip-bg:"), ); const neutralDark = contextRules.find((rule) => rule.selector === NEUTRAL_STAGE[0]); - expect(neutralDark?.body).toContain("--fork-context-chip-bg: rgb(65 65 65)"); + expect(neutralDark?.body).toContain("--fork-context-chip-bg: rgb(75 73 70)"); expect(neutralDark?.body).not.toMatch(/--fork-context-chip-bg:[^;]*\//u); }); @@ -150,21 +153,35 @@ describe("fork guard: fork-neutral-dark-theme", () => { expect(indexHtml).toContain('setAttribute("data-fork-theme", activeForkPalette)'); expect(indexHtml).toContain(`html.dark[${FORK_THEME_ATTRIBUTE}="neutral-dark"] body`); expect(indexHtml).toMatch( - /html\.dark\[data-fork-theme="neutral-dark"\] body\s*\{[^}]*background:\s*#1f1f1f/u, + /html\.dark\[data-fork-theme="neutral-dark"\] body\s*\{[^}]*background:\s*#2c2a27/u, ); expect(indexHtml).toContain("fork:begin fork-neutral-dark-theme"); }); + it("opens popups flat on --popover instead of the shared dark frost", () => { + const fill = themeRules.find( + (rule) => rule.selector === NEUTRAL_STAGE[0] && rule.body.includes("--fork-popup-fill"), + ); + expect(fill?.body).toContain("--fork-popup-fill: var(--popover)"); + const popup = themeRules.find( + (rule) => + NEUTRAL_STAGE.every((part) => rule.selector.includes(part)) && + rule.selector.includes(".dropdown-glass"), + ); + expect(popup?.body).toMatch(/(?:^|;)\s*backdrop-filter:\s*none/u); + }); + it("does not leak Neutral Dark fills into light mode", () => { const neutralHexes = [ - "#1f1f1f", - "#2a2a2a", - "#2e2e2e", - "#383838", - "#3a3a3a", - "#3d3d3d", - "#3f3f3f", - "#414141", + "#2c2a27", + "#34322f", + "#383633", + "#403e3b", + "#3c3a37", + "#42403d", + "#44423f", + "#494744", + "#4b4946", ]; const lightRules = cssRules(theme).filter( (rule) => rule.selector.includes(MARKER) && !rule.selector.includes(".dark"), diff --git a/apps/web/src/custom/forkTheme.ts b/apps/web/src/custom/forkTheme.ts index 89918896b75d5..11f36d56426eb 100644 --- a/apps/web/src/custom/forkTheme.ts +++ b/apps/web/src/custom/forkTheme.ts @@ -44,7 +44,7 @@ export const COOL_DARKER_BACKGROUND = "#141618"; export const NEUTRAL_DARK_THEME = "neutral-dark" as const; export const NEUTRAL_DARK_LABEL = "Neutral Dark"; /** Pre-paint / overscroll colour for Neutral Dark — matches stage `--background`. */ -export const NEUTRAL_DARK_BACKGROUND = "#1f1f1f"; +export const NEUTRAL_DARK_BACKGROUND = "#2c2a27"; export const NEUTRAL_DARKER_THEME = "neutral-darker" as const; export const NEUTRAL_DARKER_LABEL = "Neutral Darker"; diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 34670a72dd513..26bff2fd2548e 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -3267,7 +3267,7 @@ /* The user's sent bubble takes the same fill as the card above it. Upstream paints `bg-message` → --message-surface → --accent; on every fork palette - that is the secondary/hover step (cool #353a3d, neutral #383838), one rung + that is the secondary/hover step (cool #353a3d, neutral dark #42403d), one rung brighter than the changed-files card, so the two surfaces in the same column disagreed. diff --git a/apps/web/src/theme.custom.palettes.css b/apps/web/src/theme.custom.palettes.css index a33f36220cf36..9e956dbfd0cd1 100644 --- a/apps/web/src/theme.custom.palettes.css +++ b/apps/web/src/theme.custom.palettes.css @@ -251,74 +251,117 @@ /* --------------------------------------------------------------------------- Neutral Dark — see `.fork/customizations.yaml#fork-neutral-dark-theme`. - Figma t3-fork node 264:11816: true-neutral grays (no cool undertone). Stage - #1f1f1f, sidebar #2a2a2a (slightly darker than the drawn #2e2e2e), composer - vessel #323232, prompt surface #3d3d3d, context chips #414141. Same two-block + Warm grays keyed off the sidebar #34322f; the stage is a darker shade of it + (#2c2a27). Composer vessel #3c3a37 sits a step above the sidebar, then prompt + surface #474542 and context chips #4b4946. Same two-block pattern and data-fork-theme keying as Westworld so Dark / Westworld / Neutral Dark can coexist. */ :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] { - --background: #1f1f1f; - --app-chrome-background: #1f1f1f; + --background: #2c2a27; + --app-chrome-background: #2c2a27; --foreground: #e8e8e8; - --card: #2e2e2e; + --card: #383633; --card-foreground: #e8e8e8; - --popover: #2e2e2e; + --popover: #403e3b; --popover-foreground: #e8e8e8; --primary: #ffffff; - --primary-foreground: #1f1f1f; + --primary-foreground: #2c2a27; --ring: #ffffff; - --secondary: #383838; + --secondary: #42403d; --secondary-foreground: #e8e8e8; - --muted: #383838; + --muted: #42403d; --muted-foreground: #9a9a9a; - --accent: #383838; + --accent: #42403d; --accent-foreground: #e8e8e8; - --border: #3f3f3f; - --input: #3f3f3f; + --border: #494744; + --input: #494744; --surface-grain: none; - --fork-pill-border: #4a4a4a; + --fork-pill-border: #54524e; --fork-pill-fg: #e8e8e8; - --fork-pill-hover: #383838; + --fork-pill-hover: #42403d; /* A step above the sidebar panel: the control row reads as part of the composer rather than as more chrome. */ - --fork-composer-vessel-bg: #323232; - --fork-composer-bg: rgb(61 61 61 / 80%); + --fork-composer-vessel-bg: #3c3a37; + --fork-composer-bg: rgb(71 69 66 / 80%); --fork-composer-border: rgb(255 255 255 / 8%); - --fork-composer-border-focus: #525252; + --fork-composer-border-focus: #5c5a57; /* Opaque sibling of the Neutral Dark composer fill — see default-dark note. */ - --fork-context-chip-bg: rgb(65 65 65); - --fork-context-chip-bg-hover: #4a4a4a; + --fork-context-chip-bg: rgb(75 73 70); + --fork-context-chip-bg-hover: #54524e; } :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] [data-sidebar-version="v2"] { - --background: #2a2a2a; + --background: #34322f; --foreground: #e8e8e8; - --card: #2a2a2a; + --card: #34322f; --card-foreground: #e8e8e8; - --sidebar: #2a2a2a; + --sidebar: #34322f; --sidebar-foreground: #e8e8e8; - --border: #3f3f3f; - --sidebar-border: #3f3f3f; - --sidebar-stage-fade: #2a2a2a; - --muted: #383838; - --accent: #383838; + --border: #494744; + --sidebar-border: #494744; + --sidebar-stage-fade: #34322f; + --muted: #42403d; + --accent: #42403d; --accent-foreground: #e8e8e8; --muted-foreground: #9a9a9a; --sidebar-muted-foreground: #d6d6d6; --fork-sidebar-card-title-receded: #b0b0b0; - --sidebar-control-surface: #414141; - --sidebar-row-hover: #383838; - --sidebar-row-active: #3a3a3a; - --sidebar-row-selected: #3a3a3a; + --sidebar-control-surface: #4b4946; + --sidebar-row-hover: #42403d; + --sidebar-row-active: #44423f; + --sidebar-row-selected: #44423f; +} + +/* Popups open flat on the palette's own --popover rather than the shared dark + frost (theme.custom.css, fork-popup-surface): over a warm stage the blurred, + saturated backdrop read as glass, which belongs to Glass. Same arms as the + frost rule; the palette attribute outweighs it. --fork-popup-fill carries + the flat fill to the surfaces that borrow the popup material. */ +:root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] { + --fork-popup-fill: var(--popover); +} + +:root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] + :is( + :is( + [data-slot="menu-popup"], + [data-slot="menu-sub-content"], + [data-slot="popover-popup"], + [data-slot="tooltip-popup"], + [data-slot="dialog-popup"] + ).dropdown-glass, + [data-slot="select-popup"] > .dropdown-glass, + .dropdown-glass:has(> [data-slot="combobox-popup"]), + [data-thread-details-card].dropdown-glass + ) { + border-color: var(--border); + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* An unchecked checkbox is a flat white hairline. Upstream's dark recipe — an + --input/32 fill, a white top highlight on ::before and a drop shadow — read + as a raised bevel on the flat popups above. Checked and invalid boxes keep + upstream's look. */ +:root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] + [data-slot="checkbox"]:not([data-checked], [aria-invalid="true"]) { + border-color: rgb(255 255 255 / 16%); + background: transparent; + box-shadow: none; +} + +:root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] + [data-slot="checkbox"]:not([data-checked], [aria-invalid="true"])::before { + box-shadow: none; } /* --------------------------------------------------------------------------- Neutral Darker — see `.fork/customizations.yaml#fork-neutral-darker-theme`. Same true-neutral family as Neutral Dark, pushed down: chat stage #1a1a1a - (slightly darker than Neutral Dark's #1f1f1f), sidebar #212121 (much darker - than Neutral Dark's #2a2a2a, still a step lighter than the stage). Composer + (slightly darker than Neutral Dark's #2c2a27), sidebar #212121 (much darker + than Neutral Dark's #34322f, still a step lighter than the stage). Composer vessel sits a step above the sidebar (#252525) so the control row (mode / model / effort) contrasts against the stage; prompt/chips lift from that vessel. */ From 3e24dfc3257e55f01a0958dcbbbd29876c5f5506 Mon Sep 17 00:00:00 2001 From: x Date: Mon, 26 Oct 2026 15:10:21 -0400 Subject: [PATCH 2/2] refactor(web): Neutral Dark flattens popups through the frost's tokens The shared dark frost now reads its hairline and filter from --fork-popup-border / --fork-popup-filter beside --fork-popup-fill, so Neutral Dark goes flat with three declarations in its stage block instead of restating the frost rule's arm list. The checkbox rule keys on data-unchecked and says it is palette-wide, the guard pins it, and the Neutral Darker docs stop calling Neutral Dark true-neutral. Co-Authored-By: Claude Opus 5.5 --- .fork/customizations.yaml | 20 ++++---- .../forkLocalDictation.test.ts | 2 +- .../forkNeutralDarkTheme.test.ts | 34 ++++++++++--- .../__fork_guards__/forkPopupSurface.test.ts | 9 ++-- .../__fork_guards__/forkUsagePopover.test.ts | 2 +- apps/web/src/theme.custom.css | 17 ++++--- apps/web/src/theme.custom.palettes.css | 50 ++++++------------- 7 files changed, 69 insertions(+), 65 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 177cb40663356..1660a9c31c460 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -2260,11 +2260,11 @@ text. theme.custom.palettes.css uses the same two-block stage + v2 panel pattern as Westworld. index.html pre-paints #2c2a27 so the load flash matches. The sidebar stays opaque solid — wallpaper glass belongs to Neutral Darker. - Popups open flat on --popover #403e3b (a step above --card #383633) with no - backdrop-filter; the shared dark frost (fork-popup-surface) reads as glass, - which belongs to Glass. - An unchecked checkbox is a flat white-16% hairline with no fill, highlight - or shadow. + Popups open flat on --popover #403e3b (a step above --card #383633) by + overriding the shared frost's tokens (--fork-popup-fill / -border / + -filter); the frost (fork-popup-surface) reads as glass, which belongs to + Glass. Every unchecked checkbox in the palette is a flat white-16% hairline + with no fill, highlight or shadow. tier: 2 files: - apps/web/src/custom/forkTheme.ts @@ -2279,11 +2279,11 @@ - id: fork-neutral-darker-theme intent: > - Appearance offers Neutral Darker as a deeper sibling of Neutral Dark. - True-neutral grays only. Chat stage #1a1a1a (darker than Neutral Dark's - #2c2a27); sidebar #212121 (much darker than Neutral Dark's #34322f, still a - step lighter than the stage). Stage --card/--popover match the - sidebar (#212121) so menus open at the same lift. Composer vessel sits a + Appearance offers Neutral Darker as a true-neutral counterpart to the warm + Neutral Dark, pushed down. True-neutral grays only. Chat stage #1a1a1a + (well below Neutral Dark's #2c2a27); sidebar #212121 (much darker than + Neutral Dark's #34322f, still a step lighter than the stage). Stage + --card/--popover match the sidebar (#212121) so menus open at the same lift. Composer vessel sits a step above the sidebar at #252525 so the control row contrasts against the stage; prompt/chips lift from that vessel. Must not alter Neutral Dark, Westworld, or default Dark fills. diff --git a/apps/web/src/__fork_guards__/forkLocalDictation.test.ts b/apps/web/src/__fork_guards__/forkLocalDictation.test.ts index bcfc5b5df91e9..cb5ff0c1364d4 100644 --- a/apps/web/src/__fork_guards__/forkLocalDictation.test.ts +++ b/apps/web/src/__fork_guards__/forkLocalDictation.test.ts @@ -407,7 +407,7 @@ describe("fork local dictation", () => { const frost = cssRules(theme).find( (rule) => rule.selector.includes('[data-slot="tooltip-popup"]') && - /backdrop-filter: blur\(\d+px\)/u.test(rule.body), + rule.body.includes("backdrop-filter: var(--fork-popup-filter)"), ); expect(frost?.selector).toContain(".dropdown-glass"); expect(frost?.selector).not.toContain("data-fork-glass-tooltip"); diff --git a/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts b/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts index 58e135969854e..af0a93a149326 100644 --- a/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts +++ b/apps/web/src/__fork_guards__/forkNeutralDarkTheme.test.ts @@ -158,17 +158,33 @@ describe("fork guard: fork-neutral-dark-theme", () => { expect(indexHtml).toContain("fork:begin fork-neutral-dark-theme"); }); - it("opens popups flat on --popover instead of the shared dark frost", () => { - const fill = themeRules.find( - (rule) => rule.selector === NEUTRAL_STAGE[0] && rule.body.includes("--fork-popup-fill"), - ); - expect(fill?.body).toContain("--fork-popup-fill: var(--popover)"); - const popup = themeRules.find( + it("opens popups flat by overriding the shared frost's tokens", () => { + const stage = ruleBodyFor(themeRules, NEUTRAL_STAGE); + expect(stage).toContain("--fork-popup-fill: var(--popover)"); + expect(stage).toContain("--fork-popup-border: var(--border)"); + expect(stage).toContain("--fork-popup-filter: none"); + // Restating the frost rule's arms would let a new arm stay frosted here. + const restated = themeRules.filter( (rule) => - NEUTRAL_STAGE.every((part) => rule.selector.includes(part)) && + rule.selector.includes(`[${FORK_THEME_ATTRIBUTE}="${NEUTRAL_DARK_THEME}"]`) && rule.selector.includes(".dropdown-glass"), ); - expect(popup?.body).toMatch(/(?:^|;)\s*backdrop-filter:\s*none/u); + expect(restated).toHaveLength(0); + }); + + it("draws unchecked checkboxes as a flat hairline", () => { + const checkbox = ruleBodyFor(themeRules, [ + ...NEUTRAL_STAGE, + '[data-slot="checkbox"][data-unchecked]:not([aria-invalid="true"])', + ]); + expect(checkbox).toContain("border-color: rgb(255 255 255 / 16%)"); + expect(checkbox).toContain("background: transparent"); + expect(checkbox).toContain("box-shadow: none"); + const highlight = ruleBodyFor(themeRules, [ + ...NEUTRAL_STAGE, + '[data-slot="checkbox"][data-unchecked]:not([aria-invalid="true"])::before', + ]); + expect(highlight).toContain("box-shadow: none"); }); it("does not leak Neutral Dark fills into light mode", () => { @@ -182,6 +198,8 @@ describe("fork guard: fork-neutral-dark-theme", () => { "#44423f", "#494744", "#4b4946", + "#54524e", + "#5c5a57", ]; const lightRules = cssRules(theme).filter( (rule) => rule.selector.includes(MARKER) && !rule.selector.includes(".dark"), diff --git a/apps/web/src/__fork_guards__/forkPopupSurface.test.ts b/apps/web/src/__fork_guards__/forkPopupSurface.test.ts index a62dec04d4b84..2540f70f4c179 100644 --- a/apps/web/src/__fork_guards__/forkPopupSurface.test.ts +++ b/apps/web/src/__fork_guards__/forkPopupSurface.test.ts @@ -86,7 +86,7 @@ describe("fork guard: fork-popup-surface", () => { const frosts = cssRules(theme).filter( (candidate) => candidate.selector.includes(".dropdown-glass") && - /backdrop-filter: blur\(\d+px\)/u.test(candidate.body), + candidate.body.includes("backdrop-filter: var(--fork-popup-filter)"), ); const frost = frosts[0]; @@ -109,10 +109,11 @@ describe("fork guard: fork-popup-surface", () => { expect(flat(fill?.body ?? "")).toContain( `--fork-popup-fill: linear-gradient( ${WASH}, ${WASH} ), color-mix(in srgb, var(--popover) 45%, transparent);`, ); + expect(fill?.body).toContain(`--fork-popup-border: ${HAIRLINE};`); + expect(fill?.body).toContain("--fork-popup-filter: blur(28px) saturate(1.4);"); expect(frost?.body).toMatch(/background:\s*var\(--fork-popup-fill\);/u); - expect(frost?.body).toContain(`border-color: ${HAIRLINE};`); - expect(frost?.body).toContain("backdrop-filter: blur(28px) saturate(1.4);"); - expect(frost?.body).not.toContain("backdrop-filter: none"); + expect(frost?.body).toContain("border-color: var(--fork-popup-border);"); + expect(frost?.body).toContain("-webkit-backdrop-filter: var(--fork-popup-filter);"); // No fork rule paints the popups with the opaque composer-stack fill. // Only Glass may use the vessel token, where it is a 6% wash. for (const sheet of [theme, palettes]) { diff --git a/apps/web/src/__fork_guards__/forkUsagePopover.test.ts b/apps/web/src/__fork_guards__/forkUsagePopover.test.ts index 0b6b6e85520de..672668ec7c955 100644 --- a/apps/web/src/__fork_guards__/forkUsagePopover.test.ts +++ b/apps/web/src/__fork_guards__/forkUsagePopover.test.ts @@ -76,7 +76,7 @@ describe("fork guard: fork-usage-popover", () => { const frost = cssRules(theme).find( (rule) => rule.selector.includes('[data-slot="dialog-popup"]') && - /backdrop-filter: blur\(\d+px\)/u.test(rule.body), + rule.body.includes("backdrop-filter: var(--fork-popup-filter)"), ); expect(frost?.selector).toContain('[data-slot="popover-popup"]'); expect(frost?.selector).toContain(".dropdown-glass"); diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 26bff2fd2548e..534d821bec2f6 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -2855,9 +2855,12 @@ paints --popover with !important where backdrop-filter is unsupported, which outranks anything declared here. - The fill is named once, --fork-popup-fill, so a surface that should read - as the popup material without the frost (the Questions card above the - composer, fork-pending-user-input) wears the same recipe. */ + The fill, hairline and filter are named once (--fork-popup-fill, + --fork-popup-border, --fork-popup-filter), so a palette restyles every + arm by overriding the tokens rather than restating the selector, and a + surface that should read as the popup material without the frost (the + Questions card above the composer, fork-pending-user-input) wears the + same fill. */ :root[data-fork="noahhendrickson-t3code"].dark { --fork-popup-fill: linear-gradient( @@ -2865,6 +2868,8 @@ color-mix(in srgb, var(--contrast-foreground) 8%, transparent) ), color-mix(in srgb, var(--popover) 45%, transparent); + --fork-popup-border: color-mix(in srgb, var(--contrast-foreground) 12%, transparent); + --fork-popup-filter: blur(28px) saturate(1.4); } :root[data-fork="noahhendrickson-t3code"].dark @@ -2881,9 +2886,9 @@ [data-thread-details-card].dropdown-glass ) { background: var(--fork-popup-fill); - border-color: color-mix(in srgb, var(--contrast-foreground) 12%, transparent); - -webkit-backdrop-filter: blur(28px) saturate(1.4); - backdrop-filter: blur(28px) saturate(1.4); + border-color: var(--fork-popup-border); + -webkit-backdrop-filter: var(--fork-popup-filter); + backdrop-filter: var(--fork-popup-filter); } /* ── Thread details panel (fork-popup-surface) ────────────────────────────── diff --git a/apps/web/src/theme.custom.palettes.css b/apps/web/src/theme.custom.palettes.css index 9e956dbfd0cd1..44a540555417c 100644 --- a/apps/web/src/theme.custom.palettes.css +++ b/apps/web/src/theme.custom.palettes.css @@ -288,6 +288,12 @@ /* Opaque sibling of the Neutral Dark composer fill — see default-dark note. */ --fork-context-chip-bg: rgb(75 73 70); --fork-context-chip-bg-hover: #54524e; + /* Popups open flat rather than on the shared dark frost (fork-popup-surface): + over a warm stage the blurred, saturated backdrop read as glass, which + belongs to Glass. */ + --fork-popup-fill: var(--popover); + --fork-popup-border: var(--border); + --fork-popup-filter: none; } :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] @@ -313,55 +319,29 @@ --sidebar-row-selected: #44423f; } -/* Popups open flat on the palette's own --popover rather than the shared dark - frost (theme.custom.css, fork-popup-surface): over a warm stage the blurred, - saturated backdrop read as glass, which belongs to Glass. Same arms as the - frost rule; the palette attribute outweighs it. --fork-popup-fill carries - the flat fill to the surfaces that borrow the popup material. */ -:root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] { - --fork-popup-fill: var(--popover); -} - -:root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] - :is( - :is( - [data-slot="menu-popup"], - [data-slot="menu-sub-content"], - [data-slot="popover-popup"], - [data-slot="tooltip-popup"], - [data-slot="dialog-popup"] - ).dropdown-glass, - [data-slot="select-popup"] > .dropdown-glass, - .dropdown-glass:has(> [data-slot="combobox-popup"]), - [data-thread-details-card].dropdown-glass - ) { - border-color: var(--border); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - -/* An unchecked checkbox is a flat white hairline. Upstream's dark recipe — an - --input/32 fill, a white top highlight on ::before and a drop shadow — read - as a raised bevel on the flat popups above. Checked and invalid boxes keep +/* Every unchecked checkbox in the palette, popups and settings alike, is a + flat white hairline: upstream's dark recipe — an --input/32 fill, a white + top highlight on ::before and a drop shadow — reads as a raised bevel on + Neutral Dark's flat surfaces. Checked, indeterminate and invalid boxes keep upstream's look. */ :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] - [data-slot="checkbox"]:not([data-checked], [aria-invalid="true"]) { + [data-slot="checkbox"][data-unchecked]:not([aria-invalid="true"]) { border-color: rgb(255 255 255 / 16%); background: transparent; box-shadow: none; } :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] - [data-slot="checkbox"]:not([data-checked], [aria-invalid="true"])::before { + [data-slot="checkbox"][data-unchecked]:not([aria-invalid="true"])::before { box-shadow: none; } /* --------------------------------------------------------------------------- Neutral Darker — see `.fork/customizations.yaml#fork-neutral-darker-theme`. - Same true-neutral family as Neutral Dark, pushed down: chat stage #1a1a1a - (slightly darker than Neutral Dark's #2c2a27), sidebar #212121 (much darker - than Neutral Dark's #34322f, still a step lighter than the stage). Composer + A true-neutral counterpart to Neutral Dark, pushed down: chat stage #1a1a1a + (well below Neutral Dark's #2c2a27), sidebar #212121 (much darker than + Neutral Dark's #34322f, still a step lighter than the stage). Composer vessel sits a step above the sidebar (#252525) so the control row (mode / model / effort) contrasts against the stage; prompt/chips lift from that vessel. */