From 9d9a9bd28eec1afc895a4a80b44b527d33d1605a Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 1 Aug 2026 09:09:13 -0400 Subject: [PATCH 1/4] feat(web): Cool Dark theme, closed folder marks, and composer polish Add Cool Dark as a selectable appearance option, show FolderClosed for collapsed project groups, keep Search/scope chrome at 14px, and open the workspace menu above the branch pill so glass does not overlap the chip row. Co-authored-by: Cursor --- .fork/customizations.yaml | 87 +++-- apps/web/index.html | 32 +- .../__fork_guards__/forkComposerShell.test.ts | 27 +- .../__fork_guards__/forkCoolDarkTheme.test.ts | 183 +++++++++ .../__fork_guards__/forkSidebarChrome.test.ts | 9 + .../sidebarV2ProjectGrouping.test.ts | 3 + .../BranchToolbarEnvModeSelector.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 22 +- apps/web/src/custom/SidebarV2ChromeRows.tsx | 4 +- .../custom/SidebarV2ProjectGroupHeader.tsx | 26 +- apps/web/src/custom/forkTheme.ts | 42 +++ apps/web/src/overrides/hooks/useTheme.ts | 346 ++++++++++++++++++ apps/web/src/theme.custom.css | 110 +++++- 13 files changed, 832 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts create mode 100644 apps/web/src/custom/forkTheme.ts create mode 100644 apps/web/src/overrides/hooks/useTheme.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 2657b07ce208..61ab00f86511 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -582,16 +582,17 @@ presentational li and the label is the heading. Groups collapse from the header row. Hovering anywhere on the row swaps the - folder mark for a chevron (FolderOpen at rest — Figma 113:3718 Frame 48); - clicking the mark or the label toggles. Collapse is an absolutely- + folder mark for a chevron; clicking the mark or the label toggles. At rest + the mark is FolderOpen when expanded (Figma 113:3718 Frame 48) and + FolderClosed when collapsed (Figma 151:6742). Collapse is an absolutely- positioned hit layer behind the row; the new-thread plus paints above it (z-10) so its clicks start a thread instead of toggling — a flex-1 collapse sibling was eating them. Row-hover still swaps the mark when the - pointer is over the plus. When collapsed the chevron stays (rotated to - point at the label). Collapsed keys persist in localStorage (device-local, - like the grouping switch). A collapsed section hides its cards from both - paint and keyboard order, with the open route thread kept visible — the - same exception the snoozed shelf makes for a deep-linked row — so collapse + pointer is over the plus; the chevron rotates to point at the label while + collapsed. Collapsed keys persist in localStorage (device-local, like the + grouping switch). A collapsed section hides its cards from both paint and + keyboard order, with the open route thread kept visible — the same + exception the snoozed shelf makes for a deep-linked row — so collapse cannot bury the thread you are viewing. The header has no own horizontal pad — the list's 8px is the edge. The @@ -809,14 +810,49 @@ verify: - apps/web/src/__fork_guards__/forkSurfacePalette.test.ts +- id: fork-cool-dark-theme + intent: > + Appearance offers Cool Dark as a fourth theme preference alongside System, + Light, and Dark. Cool Dark is a lighter, cool-gray dark palette (barely + cool, not blue-slate) that coexists with the default fork Dark — selecting + it must not replace or alter Dark's #191919 / #1e1e1e fills. + + Persistence reuses the client-only t3code:theme localStorage key with the + value cool-dark. Applying Cool Dark still toggles the document .dark class + (so Tailwind and upstream dark tokens apply) and stamps + data-fork-theme="cool-dark" on :root; other preferences clear that + attribute. Desktop nativeTheme only accepts light|dark|system, so Cool Dark + maps to dark before bridge.setTheme. + + The palette lives in theme.custom.css under + :root[data-fork].dark[data-fork-theme="cool-dark"] (stage) and the same + selector plus [data-sidebar-version="v2"] (panel) — the doubled selector is + required for the same upstream re-declaration reason as fork-surface-palette. + Stage is #1c1e20, panel #232628 (panel above stage), with cool-gray borders, + row fills, composer, pills, and context chips. index.html pre-paints Cool + Dark so the load flash matches the stage. + tier: 2 + files: + - apps/web/src/custom/forkTheme.ts + - apps/web/src/theme.custom.css + - apps/web/src/overrides/hooks/useTheme.ts + shadows: + - apps/web/src/hooks/useTheme.ts + watch: + - apps/web/src/components/settings/SettingsPanels.tsx + - apps/web/index.html + verify: + - apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts + - id: fork-sidebar-type-size intent: > - Sidebar V2 chrome body type is 0.8125rem (13px at the default root). The - panel remaps Tailwind's --text-xs and --text-sm (and their --line-height - companions) to 0.8125rem / 1rem so Search, scope, project headers, and - slim shelves share one size without rewriting each className. Card titles - and the repo/branch line opt out with literal rem sizes (0.875rem / 0.75rem - — see sidebar-v2-card-rows) so the remap cannot flatten them. Rem, not px: + Sidebar V2 body type is 0.8125rem (13px at the default root) for project + headers and slim shelves. The panel remaps Tailwind's --text-xs and + --text-sm (and their --line-height companions) to 0.8125rem / 1rem so those + share one size without rewriting each className. Search and All projects + opt out at 0.875rem (14px) via CHROME_CONTROL; card titles and the + repo/branch line opt out with literal rem sizes (0.875rem / 0.75rem — see + sidebar-v2-card-rows) so the remap cannot flatten them. Rem, not px: a px pin would freeze the sidebar while h-4 and the rest of the app still honour the browser font-size setting. Line-height stays 1rem so the card's h-4 rows scale with the type. Literal captions (text-[11px], text-[10px] @@ -888,7 +924,8 @@ Chrome rows use ps-2 pe-3 with an inner px-1 control so their leading icon also sits at 12px — matching card status and the group folder glyph. Search - and All projects controls are 28px tall (h-7), not Figma's 24. + and All projects controls are 28px tall (h-7), not Figma's 24, at 0.875rem + (14px) type — literal so the panel's 13px text-xs remap cannot shrink them. These rows' own trailing inset is then 4px, and that 4px belongs to sidebar-v2-row-action-hit-area, which owns the trailing glyph column's @@ -1200,10 +1237,13 @@ Which controls sit where is the main design contract. BranchToolbar's worktree and branch chips sit above the surface as filled chips sharing one selected-surface fill with no hairline, 4px apart, on the Auto chip's - container at the drawn 4px inline padding (24px tall, 4px radius) at 12px - type matching the control row's ghost pills (Noey's finish over the drawn - 6px-radius, 10px-type chips), and the workspace chip's trigger caret is - dropped. Only the prompt and primary + container at 4px/6px inline padding (24px tall, 6px radius — extra 2px on + the end so the trailing chevron clears the curve; branch combobox takes + 6px start, workspace select 0 for its roomier folder glyph) at 12px type + matching the control row's ghost pills, and the workspace chip's trigger + caret is dropped. The workspace select opens above the chip row + (side top, no align-item-with-trigger) so its glass menu clears the + neighboring branch pill instead of overlapping it. Only the prompt and primary action sit inside the base row. Runtime and interaction modes sit below-left; context usage, model, and effort sit below-right in a 24px control row of 20px, 4px-radius controls (Noey's radius over the drawn @@ -1227,10 +1267,10 @@ The prompt surface keeps its own translucent fill, inset hairline, focus-border recolour, backdrop blur, and explicit drag-over outline. BranchToolbar's stitched-strip spacing is flattened in the named context - row. Send and stop remain 24px square; dark builds without stage-channel - art use a flat white send button with a black icon, while Dev and Nightly - retain their channel fill. Existing project components supply all icons and - controls; no Figma-exported assets are committed. + row. Send and stop remain 24px square at 4px radius; dark builds without + stage-channel art use a flat white send button with a black icon, while + Dev and Nightly retain their channel fill. Existing project components + supply all icons and controls; no Figma-exported assets are committed. Type follows the design system's dark tokens: the prompt is body/md — 14px on a 23px line at >=40rem with a full muted-foreground placeholder (16px @@ -1263,6 +1303,9 @@ - apps/web/src/components/chat/TraitsPicker.tsx # Passes the composer-only label separator through to TraitsPicker. - apps/web/src/components/chat/composerProviderState.tsx + # Workspace select opens above the context chips (side top, no + # align-item-with-trigger) so the glass menu clears the branch pill. + - apps/web/src/components/BranchToolbarEnvModeSelector.tsx # The glass-shell pseudo-elements and .chat-composer-context-strip this # customization switches off. An upstream rework of that paint would leave # the fork disabling rules that no longer exist. diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3bc6..9580d3a63a67 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,17 +15,36 @@ (() => { const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#161616"; + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + const COOL_DARK_BACKGROUND = "#1c1e20"; + /* fork:end fork-cool-dark-theme */ const themeColorMeta = document.querySelector('meta[name="theme-color"]'); try { const storedTheme = window.localStorage.getItem("t3code:theme"); + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ const theme = - storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" + storedTheme === "light" || + storedTheme === "dark" || + storedTheme === "cool-dark" || + storedTheme === "system" ? storedTheme : "system"; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const isDark = theme === "dark" || (theme === "system" && prefersDark); + const isDark = + theme === "dark" || theme === "cool-dark" || (theme === "system" && prefersDark); document.documentElement.classList.toggle("dark", isDark); - const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; + if (theme === "cool-dark") { + document.documentElement.setAttribute("data-fork-theme", "cool-dark"); + } else { + document.documentElement.removeAttribute("data-fork-theme"); + } + const chromeColor = + theme === "cool-dark" + ? COOL_DARK_BACKGROUND + : isDark + ? DARK_BACKGROUND + : LIGHT_BACKGROUND; + /* fork:end fork-cool-dark-theme */ document.documentElement.style.backgroundColor = chromeColor; themeColorMeta?.setAttribute("content", chromeColor); } catch { @@ -62,6 +81,13 @@ color: #f5f5f5; } + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + html.dark[data-fork-theme="cool-dark"] body { + background: #1c1e20; + color: #e9eaec; + } + /* fork:end fork-cool-dark-theme */ + #boot-shell { display: flex; min-height: 100%; diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 584dea036486..f929f7515c5a 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -28,6 +28,7 @@ const rules = cssRules(styles); const chatComposer = readSibling("../components/chat/ChatComposer.tsx"); const chatView = readSibling("../components/ChatView.tsx"); const primaryActions = readSibling("../components/chat/ComposerPrimaryActions.tsx"); +const envModeSelector = readSibling("../components/BranchToolbarEnvModeSelector.tsx"); function shellMarkup(input: { approvalPending?: boolean; collapsedMobile?: boolean } = {}) { return renderToStaticMarkup( @@ -261,14 +262,32 @@ describe("fork guard: fork-composer-shell", () => { } }); + it("opens the workspace select above the context chip row", () => { + expect(envModeSelector).toMatch( + /SelectPopup[^>]*alignItemWithTrigger=\{false\}[^>]*side="top"/u, + ); + }); + it("keeps context chips at 24px and the meter outside ghost geometry", () => { const context = rules.find( (rule) => rule.selector.includes("[data-fork-composer-context-row]") && rule.body.includes("height: 24px"), ); - expect(context?.body).toMatch(/border-radius:\s*4px/u); - expect(context?.body).toMatch(/padding-inline:\s*4px/u); + expect(context?.body).toMatch(/border-radius:\s*6px/u); + expect(context?.body).toMatch(/padding-inline:\s*4px 6px/u); + const checkoutChip = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-context-row]") && + rule.selector.includes('[data-slot="select-trigger"]'), + ); + expect(checkoutChip?.body).toMatch(/padding-inline-start:\s*0/u); + const branchChip = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-context-row]") && + rule.selector.includes('[data-slot="combobox-trigger"]'), + ); + expect(branchChip?.body).toMatch(/padding-inline-start:\s*6px/u); const meter = rules.find((rule) => rule.selector.endsWith("[data-fork-composer-status] button"), ); @@ -296,7 +315,7 @@ describe("fork guard: fork-composer-shell", () => { rule.selector.endsWith("[data-fork-composer-action]") && rule.body.includes("width: 24px"), ); expect(action?.body).toMatch(/height:\s*24px/u); - expect(action?.body).toMatch(/border-radius:\s*8px/u); + expect(action?.body).toMatch(/border-radius:\s*4px/u); for (const rule of rules.filter((candidate) => candidate.body.includes("height: 20px"))) { expect(rule.selector).not.toMatch( /data-chat-composer-(inline-actions|mobile-pending-actions)/u, @@ -319,6 +338,6 @@ describe("fork guard: fork-composer-shell", () => { rule.selector.includes(".chat-composer-context-strip"), ); expect(strip?.body).toMatch(/margin:\s*0/u); - expect(strip?.body).toMatch(/gap:\s*4px/u); + expect(strip?.body).toMatch(/gap:\s*8px/u); }); }); diff --git a/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts b/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts new file mode 100644 index 000000000000..3917eea32e76 --- /dev/null +++ b/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts @@ -0,0 +1,183 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/README.md` §4b and + * `.fork/customizations.yaml#fork-cool-dark-theme`. + * + * Cool Dark is a selectable alternate dark palette. Losing the preference + * wiring leaves the Appearance option as a dead label; losing the CSS + * attribute selectors silently paints Cool Dark with the default Dark fills. + */ + +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +import { + COOL_DARK_BACKGROUND, + COOL_DARK_THEME, + FORK_THEME_ATTRIBUTE, + applyForkThemeAttribute, + toDesktopTheme, +} from "../custom/forkTheme"; +import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker"; +import { cssRules } from "./cssRules"; + +function readSibling(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +const MARKER = `:root[${FORK_MARKER_ATTRIBUTE}="${FORK_MARKER_VALUE}"]`; +const theme = readSibling("../theme.custom.css"); +const indexHtml = readSibling("../../index.html"); +const settingsPanels = readSibling("../components/settings/SettingsPanels.tsx"); +const useThemeOverride = readSibling("../overrides/hooks/useTheme.ts"); +const forkTheme = readSibling("../custom/forkTheme.ts"); +const customizations = readSibling("../../../../.fork/customizations.yaml"); + +function blockFor(css: string, selectorParts: readonly string[]): string { + const pattern = new RegExp( + `${selectorParts.map(escapeRegExp).join("\\s*")}\\s*\\{([^}]*)\\}`, + "u", + ); + const match = pattern.exec(css); + expect(match, `no block found for selector ${selectorParts.join(" ")}`).not.toBeNull(); + return match?.[1] ?? ""; +} + +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); +} + +const COOL_STAGE = [`${MARKER}.dark[${FORK_THEME_ATTRIBUTE}="${COOL_DARK_THEME}"]`]; +const COOL_PANEL = [ + `${MARKER}.dark[${FORK_THEME_ATTRIBUTE}="${COOL_DARK_THEME}"]`, + '[data-sidebar-version="v2"]', +]; +const DEFAULT_STAGE = [`${MARKER}.dark`]; + +describe("fork guard: fork-cool-dark-theme", () => { + it("registers the customization and keeps the preference helpers", () => { + expect(customizations).toContain("id: fork-cool-dark-theme"); + expect(forkTheme).toContain(`export const COOL_DARK_THEME = "${COOL_DARK_THEME}"`); + expect(forkTheme).toContain(`export const COOL_DARK_BACKGROUND = "${COOL_DARK_BACKGROUND}"`); + expect(toDesktopTheme("cool-dark")).toBe("dark"); + expect(toDesktopTheme("dark")).toBe("dark"); + expect(toDesktopTheme("system")).toBe("system"); + }); + + it("offers Cool Dark in Appearance and accepts it as a theme value", () => { + expect(settingsPanels).toContain('value: "cool-dark"'); + expect(settingsPanels).toContain('label: "Cool Dark"'); + expect(settingsPanels).toContain('value === "cool-dark"'); + expect(settingsPanels).toContain('from "~/hooks/useTheme"'); + expect(settingsPanels).toContain("fork:begin fork-cool-dark-theme"); + }); + + it("overrides useTheme so cool-dark persists and maps to desktop dark", () => { + expect(useThemeOverride).toContain('"cool-dark"'); + expect(useThemeOverride).toContain("applyForkThemeAttribute"); + expect(useThemeOverride).toContain("toDesktopTheme"); + expect(useThemeOverride).toContain( + 'ThemePreference = Schema.Literals(["light", "dark", "cool-dark", "system"]', + ); + }); + + it("stamps and clears the fork theme attribute", () => { + const attrs = new Map(); + const root = { + setAttribute(name: string, value: string) { + attrs.set(name, value); + }, + removeAttribute(name: string) { + attrs.delete(name); + }, + }; + applyForkThemeAttribute(root, "cool-dark"); + expect(attrs.get(FORK_THEME_ATTRIBUTE)).toBe(COOL_DARK_THEME); + applyForkThemeAttribute(root, "dark"); + expect(attrs.has(FORK_THEME_ATTRIBUTE)).toBe(false); + }); + + it("paints Cool Dark through attribute-scoped stage and panel selectors", () => { + const stage = blockFor(theme, COOL_STAGE); + const panel = blockFor(theme, COOL_PANEL); + expect(declarationHex(stage, "--background")).toBe(COOL_DARK_BACKGROUND); + expect(declarationHex(panel, "--background")).toBe("#232628"); + expect(panel).toContain("--sidebar: #232628"); + expect(panel).toContain("--sidebar-stage-fade: #232628"); + expect(relativeLuminance(declarationHex(panel, "--background"))).toBeGreaterThan( + relativeLuminance(declarationHex(stage, "--background")), + ); + }); + + it("keeps Cool Dark lighter than the default Dark stage", () => { + const defaultStage = declarationHex(blockFor(theme, DEFAULT_STAGE), "--background"); + const coolStage = declarationHex(blockFor(theme, COOL_STAGE), "--background"); + expect(relativeLuminance(coolStage)).toBeGreaterThan(relativeLuminance(defaultStage)); + }); + + it("keeps Cool Dark barely cool without going blue-slate", () => { + // Mild cool undertone (B ≥ R) but low chroma — the old blue-slate stage + // had B−R ≈ 15; stay well under that so the option does not read blue. + const [r, , b] = parseHex(declarationHex(blockFor(theme, COOL_STAGE), "--background")); + expect(b).toBeGreaterThanOrEqual(r); + expect(b - r).toBeLessThan(8); + }); + + it("states Cool Dark row fills as opaque values", () => { + const panel = blockFor(theme, COOL_PANEL); + expect(panel).toContain("--sidebar-row-hover: #2c2f33"); + expect(panel).toContain("--sidebar-row-active: #32363a"); + expect(panel).toContain("--sidebar-row-selected: #32363a"); + expect(panel).not.toMatch( + /--sidebar-row-(?:hover|active|selected):[^;]*(?:color-mix|--alpha)/u, + ); + }); + + it("pre-paints Cool Dark in index.html so the load flash matches the stage", () => { + expect(indexHtml).toContain(COOL_DARK_BACKGROUND); + expect(indexHtml).toContain('storedTheme === "cool-dark"'); + expect(indexHtml).toContain(`data-fork-theme", "cool-dark"`); + expect(indexHtml).toContain(`html.dark[${FORK_THEME_ATTRIBUTE}="cool-dark"] body`); + expect(indexHtml).toContain("fork:begin fork-cool-dark-theme"); + }); + + it("does not leak Cool Dark fills into light mode", () => { + const coolHexes = ["#1c1e20", "#232628", "#2c2f33", "#32363a", "#34383c", "#393d42"]; + const lightRules = cssRules(theme).filter( + (rule) => rule.selector.includes(MARKER) && !rule.selector.includes(".dark"), + ); + for (const rule of lightRules) { + for (const hex of coolHexes) { + expect( + rule.body, + `${hex} declared outside a .dark scope in: ${rule.selector}`, + ).not.toContain(hex); + } + } + }); +}); diff --git a/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts b/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts index f6446aeeabd9..1547ccbec246 100644 --- a/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts +++ b/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts @@ -244,4 +244,13 @@ describe("fork guard: fork-sidebar-chrome", () => { expect(rows.split("[&>svg]:opacity-100").length - 1).toBe(1); expect(rows.split("CHROME_ROW_ICON_TINT").length - 1).toBeGreaterThanOrEqual(3); }); + + it("keeps Search and All projects at 14px, outside the panel's 13px remap", () => { + const rows = readSibling("../custom/SidebarV2ChromeRows.tsx"); + const className = /const CHROME_CONTROL[\s\S]*?"([^"]+)"/u.exec(rows)?.[1]; + expect(className).toBeDefined(); + expect(className).toContain("text-[0.875rem]"); + expect(className).not.toMatch(/\btext-xs\b/u); + expect(className).not.toMatch(/\btext-sm\b/u); + }); }); diff --git a/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts b/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts index e4dcc551b44c..364a57c68201 100644 --- a/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.ts @@ -86,7 +86,10 @@ describe("fork guard: sidebar-v2-project-grouping", () => { const header = readSibling("../custom/SidebarV2ProjectGroupHeader.tsx"); expect(header).toContain('data-testid="sidebar-v2-project-group-collapse"'); expect(header).toContain("aria-expanded={!props.collapsed}"); + // Expanded rest → open folder; collapsed rest → closed (Figma 151:6742); + // hover either way → chevron. expect(header).toContain("FolderOpenIcon"); + expect(header).toContain("FolderClosedIcon"); expect(header).toContain("ChevronDownIcon"); // Hover group is on the row; collapse is a behind-layer so the plus can // paint above it. A flex-1 collapse sibling was eating plus clicks. diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..3f8254eb8cb5 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -94,7 +94,8 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe )} - + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + Workspace @@ -123,6 +124,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : null} + {/* fork:end fork-composer-shell */} ); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5385751924e1..0f24e548e8af 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -63,7 +63,10 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; -import { useTheme } from "../../hooks/useTheme"; +/* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme + Tilde import so TypeScript resolves the override that accepts `cool-dark`. */ +import { useTheme } from "~/hooks/useTheme"; +/* fork:end fork-cool-dark-theme */ import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -152,6 +155,12 @@ const THEME_OPTIONS = [ value: "dark", label: "Dark", }, + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + { + value: "cool-dark", + label: "Cool Dark", + }, + /* fork:end fork-cool-dark-theme */ ] as const; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { @@ -976,12 +985,19 @@ export function AppearanceSettingsPanel() { { - /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ if ( value === "system" || value === "light" || value === "dark" || value === "cool-dark" ) { - setTheme(value); + setAppearance(value); } - /* fork:end fork-cool-dark-theme */ }} + /* fork:end fork-cool-dark-theme */ > - {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"} + {/* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */} + {THEME_OPTIONS.find((option) => option.value === appearance)?.label ?? "System"} + {/* fork:end fork-cool-dark-theme */} diff --git a/apps/web/src/custom/forkTheme.ts b/apps/web/src/custom/forkTheme.ts index 6b7a86d6444a..0b57f12548b9 100644 --- a/apps/web/src/custom/forkTheme.ts +++ b/apps/web/src/custom/forkTheme.ts @@ -1,42 +1,167 @@ /** - * Fork theme preference helpers — see `.fork/customizations.yaml#fork-cool-dark-theme`. + * Fork dark-palette preference — see `.fork/customizations.yaml#fork-cool-dark-theme`. * - * Cool Dark is a named dark palette stored in the same `t3code:theme` key as - * upstream's light/dark/system preference. The DOM still gets the `.dark` - * class (so Tailwind/upstream dark tokens apply); the cooler, lighter palette - * is keyed off `data-fork-theme="cool-dark"` in `theme.custom.css`. + * Cool Dark is a CSS palette overlay (`data-fork-theme="cool-dark"` + `.dark`), not a + * fourth upstream `ThemePreference`. Upstream `t3code:theme` stays `light|dark|system`; + * this module owns `t3code:fork-theme` (`cool-dark` | absent) and the DOM attribute. */ +import { useCallback, useSyncExternalStore } from "react"; + +import { syncBrowserChromeTheme } from "../hooks/useTheme"; + export const FORK_THEME_ATTRIBUTE = "data-fork-theme"; +export const FORK_PALETTE_STORAGE_KEY = "t3code:fork-theme"; +export const UPSTREAM_THEME_STORAGE_KEY = "t3code:theme"; export const COOL_DARK_THEME = "cool-dark" as const; export const COOL_DARK_LABEL = "Cool Dark"; /** Pre-paint / overscroll colour for Cool Dark — matches stage `--background`. */ export const COOL_DARK_BACKGROUND = "#1c1e20"; -export type ForkThemePreference = "light" | "dark" | "cool-dark" | "system"; -export type DesktopThemePreference = "light" | "dark" | "system"; +export type ForkPalette = typeof COOL_DARK_THEME; +export type ForkPalettePreference = ForkPalette | null; +export type AppearanceOption = "light" | "dark" | "cool-dark" | "system"; +type UpstreamTheme = "light" | "dark" | "system"; + +type AttributeRoot = { + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; +}; + +let listeners: Array<() => void> = []; +let lastPalette: ForkPalettePreference | undefined; -export function isCoolDarkTheme(theme: string | null | undefined): theme is typeof COOL_DARK_THEME { - return theme === COOL_DARK_THEME; +function emitChange() { + for (const listener of listeners) listener(); } -export function isDarkThemePreference(theme: ForkThemePreference): boolean { - return theme === "dark" || theme === COOL_DARK_THEME; +export function isCoolDarkPalette( + value: string | null | undefined, +): value is typeof COOL_DARK_THEME { + return value === COOL_DARK_THEME; } -/** Electron's nativeTheme only understands light/dark/system. */ -export function toDesktopTheme(theme: ForkThemePreference): DesktopThemePreference { - return theme === COOL_DARK_THEME ? "dark" : theme; +/** One-time bridge for builds that stored Cool Dark in `t3code:theme`. */ +export function migrateLegacyCoolDarkTheme(): void { + if (typeof window === "undefined") return; + try { + if (window.localStorage.getItem(UPSTREAM_THEME_STORAGE_KEY) !== COOL_DARK_THEME) { + return; + } + window.localStorage.setItem(UPSTREAM_THEME_STORAGE_KEY, "dark"); + window.localStorage.setItem(FORK_PALETTE_STORAGE_KEY, COOL_DARK_THEME); + } catch { + // Match upstream theme reads: ignore storage failures during bootstrap. + } +} + +export function readForkPalette(): ForkPalettePreference { + if (typeof window === "undefined") return null; + migrateLegacyCoolDarkTheme(); + try { + const raw = window.localStorage.getItem(FORK_PALETTE_STORAGE_KEY); + return isCoolDarkPalette(raw) ? raw : null; + } catch { + return null; + } } -export function applyForkThemeAttribute( - root: { setAttribute(name: string, value: string): void; removeAttribute(name: string): void }, - theme: ForkThemePreference, +export function writeForkPalette(palette: ForkPalettePreference): void { + if (typeof window === "undefined") return; + try { + if (palette === null) { + window.localStorage.removeItem(FORK_PALETTE_STORAGE_KEY); + } else { + window.localStorage.setItem(FORK_PALETTE_STORAGE_KEY, palette); + } + } catch { + // Appearance still updates the upstream theme; palette persistence is best-effort. + } +} + +export function applyForkPaletteAttribute( + root: AttributeRoot, + palette: ForkPalettePreference, ): void { - if (theme === COOL_DARK_THEME) { + if (palette === COOL_DARK_THEME) { root.setAttribute(FORK_THEME_ATTRIBUTE, COOL_DARK_THEME); return; } root.removeAttribute(FORK_THEME_ATTRIBUTE); } + +export function resolveAppearanceOption( + theme: UpstreamTheme, + palette: ForkPalettePreference, +): AppearanceOption { + return palette === COOL_DARK_THEME && theme === "dark" ? COOL_DARK_THEME : theme; +} + +function getPaletteSnapshot(): ForkPalettePreference { + const palette = readForkPalette(); + if (lastPalette === palette) return lastPalette ?? null; + lastPalette = palette; + return palette; +} + +function subscribePalette(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; + listeners.push(listener); + const handleStorage = (event: StorageEvent) => { + if (event.key === FORK_PALETTE_STORAGE_KEY || event.key === UPSTREAM_THEME_STORAGE_KEY) { + lastPalette = undefined; + emitChange(); + } + }; + window.addEventListener("storage", handleStorage); + return () => { + listeners = listeners.filter((entry) => entry !== listener); + window.removeEventListener("storage", handleStorage); + }; +} + +/** Stamp/clear `data-fork-theme` and refresh browser chrome to the active palette. */ +export function applyForkPalette(palette: ForkPalettePreference): void { + writeForkPalette(palette); + if (typeof document !== "undefined") { + applyForkPaletteAttribute(document.documentElement, palette); + syncBrowserChromeTheme(); + } + lastPalette = palette; + emitChange(); +} + +/** + * Appearance Select adapter: Cool Dark writes the fork palette and forces upstream + * `dark`; Light / Dark / System clear the palette and set the upstream preference. + */ +export function useForkAppearance(theme: UpstreamTheme, setTheme: (theme: UpstreamTheme) => void) { + const palette = useSyncExternalStore(subscribePalette, getPaletteSnapshot, () => null); + const appearance = resolveAppearanceOption(theme, palette); + + const setAppearance = useCallback( + (next: AppearanceOption) => { + if (next === COOL_DARK_THEME) { + applyForkPalette(COOL_DARK_THEME); + setTheme("dark"); + return; + } + applyForkPalette(null); + setTheme(next); + }, + [setTheme], + ); + + return { appearance, setAppearance, palette } as const; +} + +// Re-stamp after index.html pre-paint when this module first loads (e.g. Appearance). +if (typeof document !== "undefined" && typeof window !== "undefined") { + migrateLegacyCoolDarkTheme(); + const palette = readForkPalette(); + applyForkPaletteAttribute(document.documentElement, palette); + if (palette !== null) { + syncBrowserChromeTheme(); + } +} diff --git a/apps/web/src/overrides/hooks/useTheme.ts b/apps/web/src/overrides/hooks/useTheme.ts deleted file mode 100644 index fc8be7c296a4..000000000000 --- a/apps/web/src/overrides/hooks/useTheme.ts +++ /dev/null @@ -1,346 +0,0 @@ -import type { DesktopBridge } from "@t3tools/contracts"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import * as Schema from "effect/Schema"; -import { useCallback, useEffect, useSyncExternalStore } from "react"; - -import { - applyForkThemeAttribute, - isDarkThemePreference, - toDesktopTheme, - type DesktopThemePreference, -} from "../../custom/forkTheme"; - -const ThemePreference = Schema.Literals(["light", "dark", "cool-dark", "system"]); -type Theme = typeof ThemePreference.Type; -type ThemeSnapshot = { - theme: Theme; - systemDark: boolean; -}; - -type DesktopThemeBridge = Pick; - -const STORAGE_KEY = "t3code:theme"; -const MEDIA_QUERY = "(prefers-color-scheme: dark)"; -const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { - theme: "system", - systemDark: false, -}; -const THEME_COLOR_META_NAME = "theme-color"; -const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; - -const DesktopThemePreferenceSchema = Schema.Literals(["light", "dark", "system"]); - -export class ThemeStorageError extends Schema.TaggedErrorClass()( - "ThemeStorageError", - { - operation: Schema.Literals(["read", "write"]), - storageKey: Schema.String, - theme: Schema.optional(ThemePreference), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; - } -} - -export const isThemeStorageError = Schema.is(ThemeStorageError); - -export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( - "DesktopThemeSyncError", - { - theme: DesktopThemePreferenceSchema, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to sync the ${this.theme} theme to the desktop shell.`; - } -} - -export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); - -let listeners: Array<() => void> = []; -let lastSnapshot: ThemeSnapshot | null = null; -let lastDesktopTheme: DesktopThemePreference | null = null; -let lastAppliedTheme: ThemeSnapshot | null = null; -let themeStorageReadFailure: ThemeStorageError | null = null; - -function emitChange() { - for (const listener of listeners) listener(); -} - -function getSystemDark() { - return ( - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia(MEDIA_QUERY).matches - ); -} - -function isThemePreference(value: string | null): value is Theme { - return value === "light" || value === "dark" || value === "cool-dark" || value === "system"; -} - -export function readThemePreference(): Theme { - if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; - let raw: string | null; - try { - raw = window.localStorage.getItem(STORAGE_KEY); - } catch (cause) { - throw new ThemeStorageError({ - operation: "read", - storageKey: STORAGE_KEY, - cause, - }); - } - if (isThemePreference(raw)) return raw; - return DEFAULT_THEME_SNAPSHOT.theme; -} - -export function writeThemePreference(theme: Theme): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(STORAGE_KEY, theme); - themeStorageReadFailure = null; - } catch (cause) { - throw new ThemeStorageError({ - operation: "write", - storageKey: STORAGE_KEY, - theme, - cause, - }); - } -} - -function getStored(): Theme { - if (themeStorageReadFailure !== null) { - return DEFAULT_THEME_SNAPSHOT.theme; - } - try { - return readThemePreference(); - } catch (cause) { - const error = isThemeStorageError(cause) - ? cause - : new ThemeStorageError({ - operation: "read", - storageKey: STORAGE_KEY, - cause, - }); - themeStorageReadFailure = error; - console.error(error.message, { - operation: error.operation, - storageKey: error.storageKey, - ...safeErrorLogAttributes(error), - }); - return DEFAULT_THEME_SNAPSHOT.theme; - } -} - -function ensureThemeColorMetaTag(): HTMLMetaElement { - let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); - if (element) { - return element; - } - - element = document.createElement("meta"); - element.name = THEME_COLOR_META_NAME; - element.setAttribute("data-dynamic-theme-color", "true"); - document.head.append(element); - return element; -} - -function normalizeThemeColor(value: string | null | undefined): string | null { - const normalizedValue = value?.trim().toLowerCase(); - if ( - !normalizedValue || - normalizedValue === "transparent" || - normalizedValue === "rgba(0, 0, 0, 0)" || - normalizedValue === "rgba(0 0 0 / 0)" - ) { - return null; - } - - return value?.trim() ?? null; -} - -function resolveBrowserChromeSurface(): HTMLElement { - return ( - document.querySelector("main[data-slot='sidebar-inset']") ?? - document.querySelector("[data-slot='sidebar-inner']") ?? - document.body - ); -} - -export function syncBrowserChromeTheme() { - if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; - const surfaceColor = normalizeThemeColor( - getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, - ); - const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = surfaceColor ?? fallbackColor; - if (!backgroundColor) return; - - document.documentElement.style.backgroundColor = backgroundColor; - document.body.style.backgroundColor = backgroundColor; - ensureThemeColorMetaTag().setAttribute("content", backgroundColor); -} - -function applyTheme(theme: Theme, suppressTransitions = false) { - if (typeof document === "undefined" || typeof window === "undefined") return; - const systemDark = theme === "system" ? getSystemDark() : false; - if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { - syncDesktopTheme(theme); - return; - } - - if (suppressTransitions) { - document.documentElement.classList.add("no-transitions"); - } - const isDark = isDarkThemePreference(theme) || (theme === "system" && systemDark); - document.documentElement.classList.toggle("dark", isDark); - applyForkThemeAttribute(document.documentElement, theme); - lastAppliedTheme = { theme, systemDark }; - syncBrowserChromeTheme(); - syncDesktopTheme(theme); - if (suppressTransitions) { - // Force a reflow so the no-transitions class takes effect before removal - // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; - requestAnimationFrame(() => { - document.documentElement.classList.remove("no-transitions"); - }); - } -} - -export async function syncDesktopThemePreference( - bridge: DesktopThemeBridge, - theme: Theme, -): Promise { - const desktopTheme = toDesktopTheme(theme); - try { - await bridge.setTheme(desktopTheme); - } catch (cause) { - throw new DesktopThemeSyncError({ theme: desktopTheme, cause }); - } -} - -export function syncDesktopTheme(theme: Theme) { - if (typeof window === "undefined") return; - const bridge = window.desktopBridge; - const desktopTheme = toDesktopTheme(theme); - if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === desktopTheme) { - return; - } - - lastDesktopTheme = desktopTheme; - void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { - const error = isDesktopThemeSyncError(cause) - ? cause - : new DesktopThemeSyncError({ theme: desktopTheme, cause }); - console.error(error.message, { - theme: error.theme, - ...safeErrorLogAttributes(error), - }); - if (lastDesktopTheme === desktopTheme) { - lastDesktopTheme = null; - } - }); -} - -// Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && typeof window !== "undefined") { - applyTheme(getStored()); -} - -function getSnapshot(): ThemeSnapshot { - if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; - const theme = getStored(); - const systemDark = theme === "system" ? getSystemDark() : false; - - if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { - return lastSnapshot; - } - - lastSnapshot = { theme, systemDark }; - return lastSnapshot; -} - -function getServerSnapshot() { - return DEFAULT_THEME_SNAPSHOT; -} - -function subscribe(listener: () => void): () => void { - if (typeof window === "undefined") return () => {}; - listeners.push(listener); - - // Listen for system preference changes - const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; - const handleChange = () => { - if (getStored() === "system") applyTheme("system", true); - emitChange(); - }; - mq?.addEventListener("change", handleChange); - - // Listen for storage changes from other tabs - const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) { - themeStorageReadFailure = null; - applyTheme(getStored(), true); - emitChange(); - } - }; - window.addEventListener("storage", handleStorage); - - return () => { - listeners = listeners.filter((l) => l !== listener); - mq?.removeEventListener("change", handleChange); - window.removeEventListener("storage", handleStorage); - }; -} - -export function useTheme() { - const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - const theme = snapshot.theme; - - const resolvedTheme: "light" | "dark" = - theme === "system" - ? snapshot.systemDark - ? "dark" - : "light" - : theme === "light" - ? "light" - : "dark"; - - const setTheme = useCallback((next: Theme) => { - if (typeof window === "undefined") return; - try { - writeThemePreference(next); - } catch (cause) { - const error = isThemeStorageError(cause) - ? cause - : new ThemeStorageError({ - operation: "write", - storageKey: STORAGE_KEY, - theme: next, - cause, - }); - console.error(error.message, { - operation: error.operation, - storageKey: error.storageKey, - theme: next, - ...safeErrorLogAttributes(error), - }); - return; - } - applyTheme(next, true); - emitChange(); - }, []); - - // Keep DOM in sync on mount/change - useEffect(() => { - applyTheme(theme); - }, [theme]); - - return { theme, setTheme, resolvedTheme } as const; -} From 9fb6451c7c3760ba527820ead7361958b19c5501 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 1 Aug 2026 11:36:08 -0400 Subject: [PATCH 3/4] fix(web): keep Cool Dark state synchronized --- .fork/customizations.yaml | 7 +- apps/web/index.html | 2 +- .../__fork_guards__/forkCoolDarkTheme.test.ts | 23 ++- apps/web/src/custom/forkTheme.test.ts | 146 ++++++++++++++++++ apps/web/src/custom/forkTheme.ts | 120 +++++++++----- apps/web/src/main.tsx | 6 + apps/web/src/theme.custom.css | 9 +- 7 files changed, 266 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/custom/forkTheme.test.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 921cca636ab9..556352a60436 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -823,7 +823,10 @@ Selecting Cool Dark sets upstream theme to dark and stamps data-fork-theme="cool-dark" on :root; Light / Dark / System clear that attribute. A legacy t3code:theme=cool-dark value migrates to theme=dark plus - the palette key. No useTheme override. + the palette key. main.tsx starts one document-level owner after the fork + marker is stamped; it reconciles storage events in every route, while React + subscriptions only report the stored preference. Palette snapshots never + migrate or write during render. No useTheme override. The palette lives in theme.custom.css under :root[data-fork].dark[data-fork-theme="cool-dark"] (stage) and the same @@ -837,10 +840,12 @@ - apps/web/src/custom/forkTheme.ts - apps/web/src/theme.custom.css watch: + - apps/web/src/main.tsx - apps/web/src/components/settings/SettingsPanels.tsx - apps/web/index.html verify: - apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts + - apps/web/src/custom/forkTheme.test.ts - id: fork-sidebar-type-size intent: > diff --git a/apps/web/index.html b/apps/web/index.html index 67c2347dc325..d4f7688d8615 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -37,7 +37,7 @@ : "system"; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const isDark = theme === "dark" || (theme === "system" && prefersDark); - const isCoolDark = forkPalette === "cool-dark" && isDark; + const isCoolDark = forkPalette === "cool-dark" && theme === "dark"; document.documentElement.classList.toggle("dark", isDark); if (isCoolDark) { document.documentElement.setAttribute("data-fork-theme", "cool-dark"); diff --git a/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts b/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts index 16d68e1c7aa3..7a2fc21a15bf 100644 --- a/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts +++ b/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts @@ -19,6 +19,7 @@ import { FORK_PALETTE_STORAGE_KEY, FORK_THEME_ATTRIBUTE, applyForkPaletteAttribute, + resolveActiveForkPalette, resolveAppearanceOption, } from "../custom/forkTheme"; import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker"; @@ -31,6 +32,7 @@ function readSibling(relativePath: string): string { const MARKER = `:root[${FORK_MARKER_ATTRIBUTE}="${FORK_MARKER_VALUE}"]`; const theme = readSibling("../theme.custom.css"); const indexHtml = readSibling("../../index.html"); +const main = readSibling("../main.tsx"); const settingsPanels = readSibling("../components/settings/SettingsPanels.tsx"); const forkTheme = readSibling("../custom/forkTheme.ts"); const customizations = readSibling("../../../../.fork/customizations.yaml"); @@ -94,9 +96,12 @@ describe("fork guard: fork-cool-dark-theme", () => { expect(forkTheme).toContain(`export const COOL_DARK_BACKGROUND = "${COOL_DARK_BACKGROUND}"`); expect(forkTheme).toContain(`FORK_PALETTE_STORAGE_KEY = "${FORK_PALETTE_STORAGE_KEY}"`); expect(forkTheme).toContain("useForkAppearance"); + expect(main).toContain("initializeForkTheme();"); + expect(customizations).toContain("apps/web/src/custom/forkTheme.test.ts"); expect(resolveAppearanceOption("dark", "cool-dark")).toBe("cool-dark"); expect(resolveAppearanceOption("dark", null)).toBe("dark"); expect(resolveAppearanceOption("system", "cool-dark")).toBe("system"); + expect(resolveActiveForkPalette("system", "cool-dark")).toBeNull(); }); it("offers Cool Dark in Appearance via the fork palette adapter", () => { @@ -162,12 +167,28 @@ describe("fork guard: fork-cool-dark-theme", () => { ); }); + it("keeps context chips opaque without allocating their own backdrop filters", () => { + const contextRules = cssRules(theme).filter((rule) => + rule.body.includes("--fork-context-chip-bg:"), + ); + const defaultDark = contextRules.find((rule) => rule.selector === `${MARKER}.dark`); + const coolDark = contextRules.find((rule) => rule.selector === COOL_STAGE[0]); + expect(defaultDark?.body).toContain("--fork-context-chip-bg: #2a2a2a"); + expect(coolDark?.body).toContain("--fork-context-chip-bg: #2c2f33"); + expect(defaultDark?.body).not.toContain("var(--fork-composer-bg)"); + expect(coolDark?.body).not.toMatch(/--fork-context-chip-bg:[^;]*\//u); + }); + it("pre-paints Cool Dark from the palette key so the load flash matches the stage", () => { expect(indexHtml).toContain(COOL_DARK_BACKGROUND); expect(indexHtml).toContain('t3code:fork-theme"'); - expect(indexHtml).toContain('forkPalette === "cool-dark"'); + expect(indexHtml).toContain('forkPalette === "cool-dark" && theme === "dark"'); + expect(indexHtml).not.toContain('forkPalette === "cool-dark" && isDark'); expect(indexHtml).toContain(`data-fork-theme", "cool-dark"`); expect(indexHtml).toContain(`html.dark[${FORK_THEME_ATTRIBUTE}="cool-dark"] body`); + expect(indexHtml).toMatch( + /html\.dark\[data-fork-theme="cool-dark"\] body\s*\{[^}]*background:\s*#1c1e20/u, + ); expect(indexHtml).toContain("fork:begin fork-cool-dark-theme"); // Legacy migration still recognized, but cool-dark is not a live theme union member. expect(indexHtml).toContain('storedTheme === "cool-dark"'); diff --git a/apps/web/src/custom/forkTheme.test.ts b/apps/web/src/custom/forkTheme.test.ts new file mode 100644 index 000000000000..b2055860a4a5 --- /dev/null +++ b/apps/web/src/custom/forkTheme.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage( + initial: Record = {}, + overrides: Partial = {}, +): { readonly storage: Storage; readonly values: Map } { + const values = new Map(Object.entries(initial)); + return { + values, + storage: { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + ...overrides, + }, + }; +} + +function createDocumentRoot() { + const attributes = new Map(); + const classes = new Set(); + return { + attributes, + classes, + root: { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + }, + get offsetHeight() { + return 0; + }, + removeAttribute: (name: string) => attributes.delete(name), + setAttribute: (name: string, value: string) => attributes.set(name, value), + }, + }; +} + +afterEach(() => { + vi.doUnmock("../hooks/useTheme"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("fork theme synchronization", () => { + it("keeps palette reads pure and runs the legacy migration explicitly", async () => { + const setItem = vi.fn<(key: string, value: string) => void>(); + const { storage, values } = createStorage( + { "t3code:theme": "cool-dark" }, + { + setItem: (key, value) => { + setItem(key, value); + values.set(key, value); + }, + }, + ); + vi.stubGlobal("window", { localStorage: storage }); + vi.doMock("../hooks/useTheme", () => ({ syncBrowserChromeTheme: vi.fn() })); + + const { migrateLegacyCoolDarkTheme, readForkPalette } = await import("./forkTheme"); + expect(readForkPalette()).toBeNull(); + expect(setItem).not.toHaveBeenCalled(); + + migrateLegacyCoolDarkTheme(); + expect(values.get("t3code:theme")).toBe("dark"); + expect(values.get("t3code:fork-theme")).toBe("cool-dark"); + }); + + it("reconciles the DOM for palette and upstream-theme storage events", async () => { + const { storage, values } = createStorage({ + "t3code:theme": "dark", + "t3code:fork-theme": "cool-dark", + }); + const { attributes, root } = createDocumentRoot(); + let storageHandler: ((event: StorageEvent) => void) | undefined; + const syncBrowserChromeTheme = vi.fn(); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: storage, + }); + vi.stubGlobal("document", { documentElement: root }); + vi.doMock("../hooks/useTheme", () => ({ syncBrowserChromeTheme })); + + const { FORK_THEME_ATTRIBUTE, initializeForkTheme } = await import("./forkTheme"); + initializeForkTheme(); + expect(attributes.get(FORK_THEME_ATTRIBUTE)).toBe("cool-dark"); + + values.delete("t3code:fork-theme"); + values.set("t3code:theme", "system"); + storageHandler?.({ key: "t3code:fork-theme" } as StorageEvent); + expect(attributes.has(FORK_THEME_ATTRIBUTE)).toBe(false); + + values.set("t3code:fork-theme", "cool-dark"); + storageHandler?.({ key: "t3code:fork-theme" } as StorageEvent); + expect(attributes.has(FORK_THEME_ATTRIBUTE)).toBe(false); + + values.set("t3code:theme", "dark"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + expect(attributes.get(FORK_THEME_ATTRIBUTE)).toBe("cool-dark"); + expect(syncBrowserChromeTheme).toHaveBeenCalledTimes(4); + }); + + it("uses the retained storage value after a failed palette write and suppresses repaint", async () => { + const { storage, values } = createStorage( + { "t3code:theme": "system" }, + { + setItem: (key, value) => { + if (key === "t3code:fork-theme") throw new Error("palette write blocked"); + values.set(key, value); + }, + }, + ); + const { attributes, classes, root } = createDocumentRoot(); + let animationFrame: FrameRequestCallback | undefined; + vi.stubGlobal("window", { + localStorage: storage, + requestAnimationFrame: (callback: FrameRequestCallback) => { + animationFrame = callback; + return 1; + }, + }); + vi.stubGlobal("document", { documentElement: root }); + vi.doMock("../hooks/useTheme", () => ({ syncBrowserChromeTheme: vi.fn() })); + + const { FORK_THEME_ATTRIBUTE, readForkPalette, resolveAppearanceOption, setForkAppearance } = + await import("./forkTheme"); + setForkAppearance("cool-dark", (theme) => storage.setItem("t3code:theme", theme)); + + expect(values.get("t3code:theme")).toBe("dark"); + expect(readForkPalette()).toBeNull(); + expect(resolveAppearanceOption("dark", readForkPalette())).toBe("dark"); + expect(attributes.has(FORK_THEME_ATTRIBUTE)).toBe(false); + expect(classes.has("no-transitions")).toBe(true); + + animationFrame?.(0); + expect(classes.has("no-transitions")).toBe(false); + }); +}); diff --git a/apps/web/src/custom/forkTheme.ts b/apps/web/src/custom/forkTheme.ts index 0b57f12548b9..60e8ece2aa55 100644 --- a/apps/web/src/custom/forkTheme.ts +++ b/apps/web/src/custom/forkTheme.ts @@ -31,17 +31,26 @@ type AttributeRoot = { let listeners: Array<() => void> = []; let lastPalette: ForkPalettePreference | undefined; +let initialized = false; function emitChange() { for (const listener of listeners) listener(); } -export function isCoolDarkPalette( - value: string | null | undefined, -): value is typeof COOL_DARK_THEME { +function isCoolDarkPalette(value: string | null | undefined): value is typeof COOL_DARK_THEME { return value === COOL_DARK_THEME; } +function readUpstreamTheme(): UpstreamTheme { + if (typeof window === "undefined") return "system"; + try { + const raw = window.localStorage.getItem(UPSTREAM_THEME_STORAGE_KEY); + return raw === "light" || raw === "dark" || raw === "system" ? raw : "system"; + } catch { + return "system"; + } +} + /** One-time bridge for builds that stored Cool Dark in `t3code:theme`. */ export function migrateLegacyCoolDarkTheme(): void { if (typeof window === "undefined") return; @@ -56,9 +65,9 @@ export function migrateLegacyCoolDarkTheme(): void { } } +/** Pure preference read: migration belongs to startup, never React render. */ export function readForkPalette(): ForkPalettePreference { if (typeof window === "undefined") return null; - migrateLegacyCoolDarkTheme(); try { const raw = window.localStorage.getItem(FORK_PALETTE_STORAGE_KEY); return isCoolDarkPalette(raw) ? raw : null; @@ -67,7 +76,7 @@ export function readForkPalette(): ForkPalettePreference { } } -export function writeForkPalette(palette: ForkPalettePreference): void { +function writeForkPalette(palette: ForkPalettePreference): void { if (typeof window === "undefined") return; try { if (palette === null) { @@ -76,7 +85,7 @@ export function writeForkPalette(palette: ForkPalettePreference): void { window.localStorage.setItem(FORK_PALETTE_STORAGE_KEY, palette); } } catch { - // Appearance still updates the upstream theme; palette persistence is best-effort. + // The synchronization pass below applies the value storage actually retained. } } @@ -91,11 +100,18 @@ export function applyForkPaletteAttribute( root.removeAttribute(FORK_THEME_ATTRIBUTE); } +export function resolveActiveForkPalette( + theme: UpstreamTheme, + palette: ForkPalettePreference, +): ForkPalettePreference { + return theme === "dark" && palette === COOL_DARK_THEME ? COOL_DARK_THEME : null; +} + export function resolveAppearanceOption( theme: UpstreamTheme, palette: ForkPalettePreference, ): AppearanceOption { - return palette === COOL_DARK_THEME && theme === "dark" ? COOL_DARK_THEME : theme; + return resolveActiveForkPalette(theme, palette) ?? theme; } function getPaletteSnapshot(): ForkPalettePreference { @@ -106,62 +122,84 @@ function getPaletteSnapshot(): ForkPalettePreference { } function subscribePalette(listener: () => void): () => void { - if (typeof window === "undefined") return () => {}; listeners.push(listener); - const handleStorage = (event: StorageEvent) => { - if (event.key === FORK_PALETTE_STORAGE_KEY || event.key === UPSTREAM_THEME_STORAGE_KEY) { - lastPalette = undefined; - emitChange(); - } - }; - window.addEventListener("storage", handleStorage); return () => { listeners = listeners.filter((entry) => entry !== listener); - window.removeEventListener("storage", handleStorage); }; } -/** Stamp/clear `data-fork-theme` and refresh browser chrome to the active palette. */ -export function applyForkPalette(palette: ForkPalettePreference): void { - writeForkPalette(palette); +/** Reconcile the DOM and React subscribers to values storage actually retained. */ +function syncForkPaletteFromStorage(): void { + const palette = readForkPalette(); + const activePalette = resolveActiveForkPalette(readUpstreamTheme(), palette); if (typeof document !== "undefined") { - applyForkPaletteAttribute(document.documentElement, palette); + applyForkPaletteAttribute(document.documentElement, activePalette); syncBrowserChromeTheme(); } lastPalette = palette; emitChange(); } +function suppressAppearanceTransitions(update: () => void): void { + if (typeof document === "undefined" || typeof window === "undefined") { + update(); + return; + } + + const root = document.documentElement; + root.classList.add("no-transitions"); + update(); + // Force a reflow so every palette-driven property changes while suppression is active. + // oxlint-disable-next-line no-unused-expressions + root.offsetHeight; + if (typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(() => root.classList.remove("no-transitions")); + } else { + root.classList.remove("no-transitions"); + } +} + +/** Apply an Appearance option and reconcile partial storage failures to one visible state. */ +export function setForkAppearance( + next: AppearanceOption, + setTheme: (theme: UpstreamTheme) => void, +): void { + suppressAppearanceTransitions(() => { + writeForkPalette(next === COOL_DARK_THEME ? COOL_DARK_THEME : null); + setTheme(next === COOL_DARK_THEME ? "dark" : next); + syncForkPaletteFromStorage(); + }); +} + /** - * Appearance Select adapter: Cool Dark writes the fork palette and forces upstream - * `dark`; Light / Dark / System clear the palette and set the upstream preference. + * Start the document-level palette owner after the fork marker is stamped. + * React subscriptions only report state; this listener keeps every route and tab painted. */ +export function initializeForkTheme(): void { + if (initialized || typeof window === "undefined" || typeof document === "undefined") return; + initialized = true; + migrateLegacyCoolDarkTheme(); + syncForkPaletteFromStorage(); + window.addEventListener("storage", (event) => { + if ( + event.key === null || + event.key === FORK_PALETTE_STORAGE_KEY || + event.key === UPSTREAM_THEME_STORAGE_KEY + ) { + syncForkPaletteFromStorage(); + } + }); +} + +/** Appearance Select adapter over the upstream theme and fork palette stores. */ export function useForkAppearance(theme: UpstreamTheme, setTheme: (theme: UpstreamTheme) => void) { const palette = useSyncExternalStore(subscribePalette, getPaletteSnapshot, () => null); const appearance = resolveAppearanceOption(theme, palette); const setAppearance = useCallback( - (next: AppearanceOption) => { - if (next === COOL_DARK_THEME) { - applyForkPalette(COOL_DARK_THEME); - setTheme("dark"); - return; - } - applyForkPalette(null); - setTheme(next); - }, + (next: AppearanceOption) => setForkAppearance(next, setTheme), [setTheme], ); return { appearance, setAppearance, palette } as const; } - -// Re-stamp after index.html pre-paint when this module first loads (e.g. Appearance). -if (typeof document !== "undefined" && typeof window !== "undefined") { - migrateLegacyCoolDarkTheme(); - const palette = readForkPalette(); - applyForkPaletteAttribute(document.documentElement, palette); - if (palette !== null) { - syncBrowserChromeTheme(); - } -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b8b172039385..66dd70bd1863 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,6 +14,9 @@ import "./index.css"; import "./theme.custom.css"; import { applyForkMarker } from "./custom/forkMarker"; /* fork:end fork-marker */ +/* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ +import { initializeForkTheme } from "./custom/forkTheme"; +/* fork:end fork-cool-dark-theme */ import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; @@ -33,6 +36,9 @@ const router = getRouter(history); /* fork:begin fork-marker — see .fork/customizations.yaml#fork-marker */ applyForkMarker(document.documentElement); /* fork:end fork-marker */ +/* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ +initializeForkTheme(); +/* fork:end fork-cool-dark-theme */ if (isElectron) { syncDocumentElectronPlatformClasses(navigator.platform); diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index df52ecc0df2f..2d3af20875fe 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -591,9 +591,10 @@ } :root[data-fork="noahhendrickson-t3code"].dark { - /* Match the composer input fill so checkout / branch read as the same surface. */ - --fork-context-chip-bg: var(--fork-composer-bg); - --fork-context-chip-bg-hover: color-mix(in srgb, var(--fork-composer-bg) 85%, white); + /* Opaque resolved fills: unlike the composer, these chips do not own a + backdrop-filter and must not expose scrolling message text. */ + --fork-context-chip-bg: #2a2a2a; + --fork-context-chip-bg-hover: #303030; } :root[data-fork="noahhendrickson-t3code"] @@ -659,6 +660,8 @@ --fork-composer-bg: rgb(44 47 51 / 80%); --fork-composer-border: rgb(220 222 226 / 10%); --fork-composer-border-focus: #52565c; + --fork-context-chip-bg: #2c2f33; + --fork-context-chip-bg-hover: #34383c; } :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="cool-dark"] From d249ba24a6919de1700ee6b821b5f622c2c098f3 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 1 Aug 2026 11:41:39 -0400 Subject: [PATCH 4/4] fix(web): even out composer context chip spacing The PR pill shared a nested gap-1 with the branch trigger while the strip used 8px to the checkout chip, so #PR sat too close to the branch. Match both to 8px. Co-authored-by: Cursor --- .fork/customizations.yaml | 36 ++++++++++--------- .../__fork_guards__/forkComposerShell.test.ts | 19 +++++++--- .../BranchToolbarEnvModeSelector.tsx | 2 ++ apps/web/src/theme.custom.css | 11 ++++++ 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 556352a60436..7dcd1b2f5f0d 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -1238,23 +1238,25 @@ target remains a separate functional state. Which controls sit where is the main design contract. BranchToolbar's - worktree and branch chips sit above the surface as filled chips sharing - one selected-surface fill with no hairline, 4px apart, on the Auto chip's - container at 4px/6px inline padding (24px tall, 6px radius — extra 2px on - the end so the trailing chevron clears the curve; branch combobox takes - 6px start, workspace select 0 for its roomier folder glyph) at 12px type - matching the control row's ghost pills, and the workspace chip's trigger - caret is dropped. The workspace select opens above the chip row - (side top, no align-item-with-trigger) so its glass menu clears the - neighboring branch pill instead of overlapping it. Only the prompt and primary - action sit inside the base row. Runtime and interaction modes sit - below-left; context usage, model, and effort sit below-right in a 24px - control row of 20px, 4px-radius controls (Noey's radius over the drawn - full-pill ghosts, so hover fills square off). The runtime mode renders as - the design's 20px accent chip — label only, no icon or caret — with its - hue selected by a typed fork-owned token map: auto green, full access - yellow, auto-accept edits purple, supervised red. The interaction and - plan-sidebar toggles are text-only ghost chips, + worktree, PR, and branch chips sit above the surface as filled chips + sharing one selected-surface fill with no hairline, 8px apart (matching + the context row's pb-2 under the pills — including the nested PR+branch + pair that upstream keeps at gap-1), on the Auto chip's container at + 4px/6px inline padding (24px tall, 6px radius — extra 2px on the end so + the trailing chevron clears the curve; branch combobox takes 6px start, + workspace select 0 for its roomier folder glyph) at 12px type matching + the control row's ghost pills, and the workspace chip's trigger caret is + dropped. The workspace select opens above the chip row (side top, no + align-item-with-trigger) so its glass menu clears the neighboring branch + pill instead of overlapping it. Only the prompt and primary action sit + inside the base row. Runtime and interaction modes sit below-left; + context usage, model, and effort sit below-right in a 24px control row of + 20px, 4px-radius controls (Noey's radius over the drawn full-pill ghosts, + so hover fills square off). The runtime mode renders as the design's 20px + accent chip — label only, no icon or caret — with its hue selected by a + typed fork-owned token map: auto green, full access yellow, auto-accept + edits purple, supervised red. The interaction and plan-sidebar toggles + are text-only ghost chips, the model and effort selects share the row's 12px type (Noey's call over the drawn 10px), and the composer's effort trigger joins its trait labels with a plain space ("High 1M") while Settings keeps upstream's " · " diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index f929f7515c5a..bfb8bb473f80 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -263,9 +263,9 @@ describe("fork guard: fork-composer-shell", () => { }); it("opens the workspace select above the context chip row", () => { - expect(envModeSelector).toMatch( - /SelectPopup[^>]*alignItemWithTrigger=\{false\}[^>]*side="top"/u, - ); + // Assert independently — prop order must not matter to the guard. + expect(envModeSelector).toMatch(/SelectPopup[^>]*alignItemWithTrigger=\{false\}/u); + expect(envModeSelector).toMatch(/SelectPopup[^>]*side="top"/u); }); it("keeps context chips at 24px and the meter outside ghost geometry", () => { @@ -335,9 +335,20 @@ describe("fork guard: fork-composer-shell", () => { const strip = rules.find( (rule) => rule.selector.includes("[data-fork-composer-context-row]") && - rule.selector.includes(".chat-composer-context-strip"), + rule.selector.includes(".chat-composer-context-strip") && + !rule.selector.includes(">.flex") && + !rule.selector.includes("> .flex"), ); expect(strip?.body).toMatch(/margin:\s*0/u); expect(strip?.body).toMatch(/gap:\s*8px/u); + // Nested PR+branch (and env+checkout) wrappers keep upstream gap-1; the + // fork re-gaps them to 8px so checkout→PR→branch reads evenly. + const nested = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-context-row]") && + rule.selector.includes(".chat-composer-context-strip") && + (rule.selector.includes(">.flex") || rule.selector.includes("> .flex")), + ); + expect(nested?.body).toMatch(/gap:\s*8px/u); }); }); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 3f8254eb8cb5..b5490bf2be7e 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -95,6 +95,8 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* Open above the chip row so glass clears the branch pill. Base UI may + collision-flip on a short viewport; that re-overlap is accepted here. */} Workspace diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 2d3af20875fe..bd8874e3ef14 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -582,6 +582,17 @@ gap: 8px; } +/* Upstream nests the PR pill with the branch trigger at gap-1 (4px), while the + strip itself is 8px between checkout and that pair — so checkout→PR reads + loose and PR→branch reads tight. Same 8px on every direct flex child keeps + the three chips (and env→checkout when both show) evenly spaced. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-context-row] + .chat-composer-context-strip + > .flex { + gap: 8px; +} + /* The context chips are the filled controls above the surface (checkout / worktree mode and branch). Same selected-surface fill, no hairline. Light mode falls back to upstream's accent; the drawn values are dark-only. */