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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 80 additions & 32 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 " · "
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 35 additions & 4 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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%;
Expand Down
40 changes: 35 additions & 5 deletions apps/web/src/__fork_guards__/forkComposerShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"),
);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
});
});
Loading
Loading