diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 2657b07ce208..7dcd1b2f5f0d 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,52 @@ verify: - apps/web/src/__fork_guards__/forkSurfacePalette.test.ts +- id: fork-cool-dark-theme + intent: > + Appearance offers Cool Dark as a fourth option 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. + + Cool Dark is a fork palette overlay, not a widened upstream ThemePreference. + Upstream t3code:theme stays light|dark|system. The palette is persisted + separately in t3code:fork-theme (cool-dark | absent) by custom/forkTheme.ts. + 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. 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 + 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 + 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: > - 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 +927,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 @@ -1198,20 +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 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 - 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 " · " @@ -1227,10 +1272,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 +1308,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..d4f7688d8615 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,17 +15,41 @@ (() => { 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 */ + // Cool Dark is a separate palette key; migrate legacy t3code:theme=cool-dark. + let forkPalette = window.localStorage.getItem("t3code:fork-theme"); + if (storedTheme === "cool-dark") { + window.localStorage.setItem("t3code:theme", "dark"); + window.localStorage.setItem("t3code:fork-theme", "cool-dark"); + forkPalette = "cool-dark"; + } const theme = - storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" - ? storedTheme - : "system"; + storedTheme === "cool-dark" + ? "dark" + : storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" + ? storedTheme + : "system"; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const isDark = theme === "dark" || (theme === "system" && prefersDark); + const isCoolDark = forkPalette === "cool-dark" && theme === "dark"; document.documentElement.classList.toggle("dark", isDark); - const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; + if (isCoolDark) { + document.documentElement.setAttribute("data-fork-theme", "cool-dark"); + } else { + document.documentElement.removeAttribute("data-fork-theme"); + } + const chromeColor = isCoolDark + ? 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 +86,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..bfb8bb473f80 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", () => { + // 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", () => { 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, @@ -316,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*4px/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/__fork_guards__/forkCoolDarkTheme.test.ts b/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts new file mode 100644 index 000000000000..7a2fc21a15bf --- /dev/null +++ b/apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts @@ -0,0 +1,211 @@ +// @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_LABEL, + COOL_DARK_THEME, + FORK_PALETTE_STORAGE_KEY, + FORK_THEME_ATTRIBUTE, + applyForkPaletteAttribute, + resolveActiveForkPalette, + resolveAppearanceOption, +} 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 main = readSibling("../main.tsx"); +const settingsPanels = readSibling("../components/settings/SettingsPanels.tsx"); +const forkTheme = readSibling("../custom/forkTheme.ts"); +const customizations = readSibling("../../../../.fork/customizations.yaml"); +const useThemeOverridePath = NodeURL.fileURLToPath( + new URL("../overrides/hooks/useTheme.ts", import.meta.url), +); + +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 as a separate palette, not a useTheme override", () => { + expect(customizations).toContain("id: fork-cool-dark-theme"); + expect(customizations).toContain("t3code:fork-theme"); + expect(customizations).not.toContain("apps/web/src/overrides/hooks/useTheme.ts"); + expect(customizations).not.toMatch(/shadows:\s*\n\s*- apps\/web\/src\/hooks\/useTheme\.ts/u); + expect(NodeFS.existsSync(useThemeOverridePath)).toBe(false); + expect(forkTheme).toContain(`export const COOL_DARK_THEME = "${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", () => { + expect(settingsPanels).toContain('value: "cool-dark"'); + expect(settingsPanels).toContain("COOL_DARK_LABEL"); + expect(forkTheme).toContain(`export const COOL_DARK_LABEL = "${COOL_DARK_LABEL}"`); + expect(settingsPanels).toContain("useForkAppearance"); + expect(settingsPanels).toContain("setAppearance"); + expect(settingsPanels).toContain('from "../../hooks/useTheme"'); + expect(settingsPanels).not.toContain('from "~/hooks/useTheme"'); + expect(settingsPanels).toContain("fork:begin fork-cool-dark-theme"); + }); + + it("stamps and clears the fork theme attribute from the palette", () => { + const attrs = new Map(); + const root = { + setAttribute(name: string, value: string) { + attrs.set(name, value); + }, + removeAttribute(name: string) { + attrs.delete(name); + }, + }; + applyForkPaletteAttribute(root, "cool-dark"); + expect(attrs.get(FORK_THEME_ATTRIBUTE)).toBe(COOL_DARK_THEME); + applyForkPaletteAttribute(root, null); + 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("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" && 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"'); + }); + + 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..b5490bf2be7e 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -94,7 +94,10 @@ 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 @@ -123,6 +126,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..eaf189b09b91 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -64,6 +64,9 @@ import { 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 */ +import { COOL_DARK_LABEL, useForkAppearance } from "../../custom/forkTheme"; +/* 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_LABEL, + }, + /* fork:end fork-cool-dark-theme */ ] as const; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { @@ -557,6 +566,9 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + const { appearance, setAppearance } = useForkAppearance(theme, setTheme); + /* fork:end fork-cool-dark-theme */ const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -568,7 +580,9 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ - ...(theme !== "system" ? ["Theme"] : []), + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + ...(appearance !== "system" ? ["Theme"] : []), + /* fork:end fork-cool-dark-theme */ ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -635,7 +649,9 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, - theme, + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + appearance, + /* fork:end fork-cool-dark-theme */ ], ); @@ -649,7 +665,9 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - setTheme("system"); + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + setAppearance("system"); + /* fork:end fork-cool-dark-theme */ updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -673,7 +691,9 @@ export function useSettingsRestore(onRestored?: () => void) { textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + }, [changedSettingLabels, onRestored, setAppearance, updateSettings]); + /* fork:end fork-cool-dark-theme */ return { changedSettingLabels, @@ -949,6 +969,9 @@ function BackgroundActivityAdvancedDialog({ export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + const { appearance, setAppearance } = useForkAppearance(theme, setTheme); + /* fork:end fork-cool-dark-theme */ const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -968,22 +991,33 @@ export function AppearanceSettingsPanel() { title="Theme" description="Choose how T3 Code looks across the app." resetAction={ - theme !== "system" ? ( - setTheme("system")} /> + /* fork:begin fork-cool-dark-theme — see .fork/customizations.yaml#fork-cool-dark-theme */ + appearance !== "system" ? ( + setAppearance("system")} /> ) : null + /* fork:end fork-cool-dark-theme */ } control={