From 24306ff2e490dcc5506f93e31a831e9c50cea800 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 25 Jul 2026 11:53:44 -0400 Subject: [PATCH 1/6] feat(web): restyle Sidebar V2 tab rows from the Figma design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card row carried three lines — favicon + project + status label, then the title, then branch + PR — which spent its widest slot on a text status ("Working 2m 23s") and pushed the one thing you actually scan for into the middle. The Figma sketch collapses it to two: title first, everything else demoted to an 11px metadata line. - Title leads at 12px/medium; project name drops beside the branch, and the favicon goes (it repeated what the project name already said). - Status becomes a 16px mark instead of a label: an animated 8-cell cluster while the agent runs, a single 8px dot once it stops and wants something. Only working and approval were drawn — input, failed, done and woke are extended from the same vocabulary, with woke keeping a clock glyph so it can't be read as approval. - Working rows get a resting fill instead of the blanket opacity fade the whole in-flight group used to share, and approval/input stop receding: they are blocked on you, and the sketch gives them brighter metadata (65%) than working (45%) for exactly that reason. - Status hues move to --sidebar-v2-status-* tokens. The drawn values are tuned for a black panel, so the light palette drops each to the darkest shade in the same family. Radius goes 6px -> 16px and the row shrinks 78px -> 70px. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/SidebarV2.tsx | 207 +++++++++--------- .../components/SidebarV2StatusIndicator.tsx | 95 ++++++++ apps/web/src/index.css | 39 ++++ 3 files changed, 233 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/components/SidebarV2StatusIndicator.tsx diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..f23fe3b917d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -19,8 +19,6 @@ import { CheckIcon, ChevronDownIcon, CircleAlertIcon, - CircleCheckIcon, - CircleDashedIcon, ClockIcon, CopyIcon, FolderIcon, @@ -129,6 +127,12 @@ import { } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { + SidebarV2StatusDot, + SidebarV2WokeMark, + SidebarV2WorkingDither, + type SidebarV2StatusTone, +} from "./SidebarV2StatusIndicator"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; @@ -431,57 +435,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); - // In-flight rows (working, or waiting on approval/input) fade as a whole: - // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human — done (unread), read-but-unsettled, failed, and - // freshly woken. The status label keeps its hue, so waiting rows stay - // findable. In-flight rows recede the same as read-ready ones (inbox-zero: - // working threads aren't your problem yet) — only the colored status label - // stands out. - const isInFlight = status === "working" || status === "approval" || status === "input"; + // Receding is now about who owns the next move, not about whether a session + // happens to be attached. Working and read-ready rows recede (inbox-zero: + // a running agent isn't your problem yet); approval and input no longer do, + // because they are blocked ON YOU. That split is the whole point of the two + // drawn specimens — the approval row carries brighter metadata (65%) than + // the working one (45%) despite both being "in flight". const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; + (status === "ready" || status === "working") && + !isUnread && + !isWoke && + !props.isActive && + !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. - const topStatus = + // Only two of these were drawn (working, approval); the rest are extended + // from the same vocabulary. `dither` = the agent is moving, `dot` = it + // stopped and the row is waiting on something, `woke` keeps its own glyph. + // Labels are no longer painted — they survive only as the accessible name, + // since the mark itself is aria-hidden. + const topStatus: { + label: string; + tone: SidebarV2StatusTone; + mark: "dither" | "dot" | "woke"; + } | null = status === "working" - ? { - label: "Working", - icon: "working" as const, - className: - "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", - } + ? { label: "Working", tone: "working", mark: "dither" } : status === "approval" - ? { - label: "Approval", - icon: null, - className: "text-amber-700 dark:text-amber-300", - } + ? { label: "Needs approval", tone: "approval", mark: "dot" } : status === "input" - ? { - label: "Input", - icon: null, - className: "text-indigo-600 dark:text-indigo-300", - } + ? { label: "Needs input", tone: "input", mark: "dot" } : status === "failed" - ? { - label: "Failed", - icon: null, - className: "text-red-700 dark:text-red-300", - } + ? { label: "Failed", tone: "failed", mark: "dot" } : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-amber-700 dark:text-amber-300", - } + ? { label: "Woke from snooze", tone: "approval", mark: "woke" } : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", - } + ? { label: "Done", tone: "done", mark: "dot" } : null; const gitCwd = thread.worktreePath ?? props.projectCwd; @@ -653,19 +643,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // like elevated cards while settled threads were plain rows, leaving neither // a useful hierarchy nor a reliable hover cue. Status now lives in the row // content; surface is reserved for interaction (hover, multi-select, route). + // Working rows carry a resting fill (the design's rgba(255,255,255,0.08)) + // rather than the blanket opacity fade the whole in-flight group used to + // get: a running agent is the one thing on screen that is alive, and a lit + // surface says that without dimming the text you still need to read. Because + // that fill lands on the same value as the hover fill, working rows hover up + // to the active fill so the affordance survives. + const isWorkingSurface = status === "working" && !props.isActive && !isSelected; const rowSurfaceClassName = cn( - "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-2xl text-left outline-none select-none", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", + : isWorkingSurface + ? "bg-sidebar-row-working text-sidebar-foreground hover:bg-sidebar-row-active" + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", ); const title = isRenaming ? ( @@ -684,18 +679,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( } > -
-
- - {props.projectTitle ? ( + {/* Two rows, not three: the title leads (it is the only thing you + actually scan for) and the project name drops into the metadata + line beside the branch. The favicon is gone — at 282px it cost a + slot to repeat what the project name already says. */} +
+
+ {title} + - {props.projectTitle} - - ) : ( - - )} - - {topStatus ? ( - + {/* The mark is aria-hidden, so the label lives on as the + accessible name. Keeping it off the ticking duration + stops screen readers announcing every second. */} + + {topStatus.label} + + {topStatus.mark === "dither" ? ( + + ) : topStatus.mark === "woke" ? ( + + ) : ( + )} - > - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : topStatus.icon === "woke" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} {status === "working" ? ( - + ) : null} - + ) : ( - threadTimeLabel(thread) + + {threadTimeLabel(thread)} + )} {props.settlementSupported || showSnoozeButton ? ( @@ -936,17 +916,28 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className="inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground" > - Settle ) : null} ) : null}
-
{title}
-
+ {/* 65% for rows that want you, 45% for rows that are merely busy — + the one hierarchy the two drawn specimens differ on. */} +
+ {props.projectTitle ? ( + {props.projectTitle} + ) : null} {thread.branch ? ( - {thread.branch} + + + {thread.branch} + ) : ( )} @@ -959,11 +950,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} {isRemote ? ( - + ) : null} {driverKind ? ( @@ -971,7 +962,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} diff --git a/apps/web/src/components/SidebarV2StatusIndicator.tsx b/apps/web/src/components/SidebarV2StatusIndicator.tsx new file mode 100644 index 00000000000..8ccfdffd370 --- /dev/null +++ b/apps/web/src/components/SidebarV2StatusIndicator.tsx @@ -0,0 +1,95 @@ +import { AlarmClockIcon } from "lucide-react"; +import type { CSSProperties } from "react"; +import { cn } from "~/lib/utils"; + +/** The right-hand slot of a Sidebar V2 card row. Every status resolves to one + 16px-tall mark so the column stays optically aligned no matter which state + a row is in — the *form* carries the meaning (a moving cluster means the + agent is running; a static dot means it stopped and wants something), and + the hue only reinforces it. Text labels ("Working", "Approval") are gone: + at 282px wide the label crowded out the branch name, and the row already + says what it is through color plus the duration readout. */ +export type SidebarV2StatusTone = "working" | "done" | "approval" | "input" | "failed"; + +const TONE_COLOR_CLASS: Record = { + working: "bg-sidebar-v2-status-working", + done: "bg-sidebar-v2-status-done", + approval: "bg-sidebar-v2-status-approval", + input: "bg-sidebar-v2-status-input", + failed: "bg-sidebar-v2-status-failed", +}; + +/** Eight 2px cells scattered across an 8x14 box, transcribed from the design. + `live` cells take the working hue; the rest are dimmed row-foreground, so + the cluster re-tints itself for light mode instead of needing a second + hard-coded palette. Offsets are absolute px because the arrangement is + deliberately irregular — a grid would read as a spinner, and the point is + that it reads as scattered activity. */ +const DITHER_CELLS: ReadonlyArray<{ + left: number; + top: number; + live: boolean; + alpha?: number; +}> = [ + { left: 0, top: 1, live: false, alpha: 0.36 }, + { left: 0, top: 5, live: true }, + { left: 3, top: 4, live: false, alpha: 0.12 }, + { left: 3, top: 8, live: false, alpha: 0.3 }, + { left: 3, top: 12, live: true }, + { left: 6, top: 0, live: false, alpha: 0.12 }, + { left: 6, top: 4, live: false, alpha: 0.3 }, + { left: 6, top: 8, live: true }, +]; + +export function SidebarV2WorkingDither() { + return ( + + {DITHER_CELLS.map((cell) => ( + + ))} + + ); +} + +/** The blocked/settled counterpart to the dither: one 8px dot centered in the + same 16px box the provider icons use, so the trailing edge of every row + lines up whether the mark is a dot, a clock, or the cluster. */ +export function SidebarV2StatusDot({ tone }: { tone: SidebarV2StatusTone }) { + return ( + + + + ); +} + +/** Woke keeps a glyph rather than a dot: it is a *modifier* on an otherwise + settled row, not one of the five statuses, and an amber dot would be + indistinguishable from Approval — the one state it must never be confused + with, since Approval is blocking and Woke is not. */ +export function SidebarV2WokeMark() { + return ( + + + + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 29dd99b8e6d..395d92d8cf5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -122,6 +122,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; + --animate-sidebar-v2-dither: sidebar-v2-dither 1.6s ease-in-out infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -160,6 +161,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-sidebar-row-hover: var(--sidebar-row-hover); --color-sidebar-row-active: var(--sidebar-row-active); --color-sidebar-row-selected: var(--sidebar-row-selected); + --color-sidebar-row-working: var(--sidebar-row-working); + --color-sidebar-v2-status-working: var(--sidebar-v2-status-working); + --color-sidebar-v2-status-done: var(--sidebar-v2-status-done); + --color-sidebar-v2-status-approval: var(--sidebar-v2-status-approval); + --color-sidebar-v2-status-input: var(--sidebar-v2-status-input); + --color-sidebar-v2-status-failed: var(--sidebar-v2-status-failed); --color-sidebar-border: var(--sidebar-border); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -218,6 +225,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } + /* Each cell of the working cluster shimmers between a fraction of its + resting alpha and full strength, so the dim trail cells stay dim relative + to the live ones instead of every cell flashing to the same brightness. + --dither-alpha is set per cell inline; the fallback keeps the keyframe + valid if the variable is ever missing. */ + @keyframes sidebar-v2-dither { + 0%, + 100% { + opacity: calc(var(--dither-alpha, 1) * 0.35); + } + 50% { + opacity: var(--dither-alpha, 1); + } + } } @layer base { @@ -918,6 +939,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-hover: var(--color-zinc-25); --sidebar-row-active: var(--color-white); --sidebar-row-selected: var(--color-white); + --sidebar-row-working: color-mix(in srgb, var(--foreground) 5%, transparent); + /* The design's status hues are tuned for a black panel. On the light panel + the same values are near-invisible at 8px, so each one drops to the + darkest shade that still reads as the same color family. */ + --sidebar-v2-status-working: var(--color-emerald-600); + --sidebar-v2-status-done: var(--color-emerald-600); + --sidebar-v2-status-approval: var(--color-amber-500); + --sidebar-v2-status-input: var(--color-indigo-600); + --sidebar-v2-status-failed: var(--color-red-600); --sidebar-border: var(--color-zinc-200); --sidebar-stage-fade: var(--sidebar); background-color: var(--sidebar); @@ -942,6 +972,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); + /* Matches the rgba(255,255,255,0.08) fill on the working specimen. It lands + on the same value as --sidebar-row-hover, so working rows hover to + --sidebar-row-active instead — see rowSurfaceClassName in SidebarV2. */ + --sidebar-row-working: color-mix(in srgb, var(--foreground) 8%, transparent); + --sidebar-v2-status-working: #24fe8a; + --sidebar-v2-status-done: #24fe8a; + --sidebar-v2-status-approval: #ffcd59; + --sidebar-v2-status-input: #8b9cff; + --sidebar-v2-status-failed: #ff6b60; --sidebar-border: var(--border); /* The stage-channel header art must ramp to THIS panel's surface, not the global chrome background, or the fade shows a seam (same rule as the From 5f97a5779ff3a77d256f1665df97d6476999e306 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 25 Jul 2026 13:03:39 -0400 Subject: [PATCH 2/6] feat(web): port the phanttom pixel-rain working indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder shimmer with the real thing from the phanttom Ghostty fork (PixelSparkleView): four columns of drops falling through a 5-row grid, each column with its own speed and phase, the head bright with an exponential trail above it and a sharp falloff below. Swift redraws every frame from a shared clock. Every cell in a column reads the same `head`, so the motion decomposes into five fixed alpha profiles — one per row — driven by one animation per column. That buys the whole effect with zero per-frame JS in a virtualised list, and Reduce Motion falls back to the same `t = 0.6` still frame the native view uses. Verified in the browser against the Swift math over 21,575 samples: mean absolute opacity error 1.1e-3, max 0.041 away from the loop seam, where both models jump. Two deliberate departures from the original: - Drawn as SVG rects, not positioned divs. A 2.85px box on a 3.8px pitch never lands on a device-pixel boundary and browsers snap element backgrounds to whole pixels, so the drops painted at visibly different widths. SVG keeps the fractional geometry, same as the native Canvas. - Each row's rain is offset by a hash of its thread key. phanttom drives every tab from one wall clock, so its working tabs fall in lockstep; a sidebar of working threads reads better desynchronised. Hashing rather than randomising keeps a row's phase stable across re-render and re-sort. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/SidebarV2.tsx | 21 +- .../components/SidebarV2StatusIndicator.tsx | 179 ++++-- apps/web/src/index.css | 578 +++++++++++++++++- 3 files changed, 708 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f23fe3b917d..34d88ed32de 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -130,7 +130,7 @@ import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { SidebarV2StatusDot, SidebarV2WokeMark, - SidebarV2WorkingDither, + SidebarV2WorkingRain, type SidebarV2StatusTone, } from "./SidebarV2StatusIndicator"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -451,17 +451,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. // Only two of these were drawn (working, approval); the rest are extended - // from the same vocabulary. `dither` = the agent is moving, `dot` = it + // from the same vocabulary. `rain` = the agent is moving, `dot` = it // stopped and the row is waiting on something, `woke` keeps its own glyph. // Labels are no longer painted — they survive only as the accessible name, // since the mark itself is aria-hidden. const topStatus: { label: string; tone: SidebarV2StatusTone; - mark: "dither" | "dot" | "woke"; + mark: "rain" | "dot" | "woke"; } | null = status === "working" - ? { label: "Working", tone: "working", mark: "dither" } + ? { label: "Working", tone: "working", mark: "rain" } : status === "approval" ? { label: "Needs approval", tone: "approval", mark: "dot" } : status === "input" @@ -836,7 +836,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
  • -
    + {/* 18px, not the 16px the metadata line uses: that is the exact + height of the working rain's 4x5 grid, and cropping it would + clip the bottom row of drops. */} +
    {title} - + {topStatus.label} - {topStatus.mark === "dither" ? ( - + {topStatus.mark === "rain" ? ( + ) : topStatus.mark === "woke" ? ( ) : ( diff --git a/apps/web/src/components/SidebarV2StatusIndicator.tsx b/apps/web/src/components/SidebarV2StatusIndicator.tsx index 8ccfdffd370..ce5a5555ce9 100644 --- a/apps/web/src/components/SidebarV2StatusIndicator.tsx +++ b/apps/web/src/components/SidebarV2StatusIndicator.tsx @@ -3,12 +3,14 @@ import type { CSSProperties } from "react"; import { cn } from "~/lib/utils"; /** The right-hand slot of a Sidebar V2 card row. Every status resolves to one - 16px-tall mark so the column stays optically aligned no matter which state - a row is in — the *form* carries the meaning (a moving cluster means the - agent is running; a static dot means it stopped and wants something), and - the hue only reinforces it. Text labels ("Working", "Approval") are gone: - at 282px wide the label crowded out the branch name, and the row already - says what it is through color plus the duration readout. */ + mark so the column stays optically aligned no matter which state a row is + in — the *form* carries the meaning (falling pixels mean the agent is + running; a static dot means it stopped and wants something), and the hue + only reinforces it. This is the same vocabulary the phanttom Ghostty fork + uses in its sidebar: rain while working, 8px dots otherwise. Text labels + ("Working", "Approval") are gone — at 282px wide the label crowded out the + branch name, and the row already says what it is through color plus the + duration readout. */ export type SidebarV2StatusTone = "working" | "done" | "approval" | "input" | "failed"; const TONE_COLOR_CLASS: Record = { @@ -19,61 +21,146 @@ const TONE_COLOR_CLASS: Record = { failed: "bg-sidebar-v2-status-failed", }; -/** Eight 2px cells scattered across an 8x14 box, transcribed from the design. - `live` cells take the working hue; the rest are dimmed row-foreground, so - the cluster re-tints itself for light mode instead of needing a second - hard-coded palette. Offsets are absolute px because the arrangement is - deliberately irregular — a grid would read as a spinner, and the point is - that it reads as scattered activity. */ -const DITHER_CELLS: ReadonlyArray<{ - left: number; - top: number; - live: boolean; - alpha?: number; -}> = [ - { left: 0, top: 1, live: false, alpha: 0.36 }, - { left: 0, top: 5, live: true }, - { left: 3, top: 4, live: false, alpha: 0.12 }, - { left: 3, top: 8, live: false, alpha: 0.3 }, - { left: 3, top: 12, live: true }, - { left: 6, top: 0, live: false, alpha: 0.12 }, - { left: 6, top: 4, live: false, alpha: 0.3 }, - { left: 6, top: 8, live: true }, +// Geometry transcribed from PixelSparkleView: a 4x5 grid on a 3.8px pitch with +// 2.85px cells rounded at 28% of their size. That lands the box at 14.25x18.05, +// which is why the card's first line is 18px rather than the 16px the rest of +// the row uses. +const ROWS = 5; +const COLS = 4; +const PITCH = 3.8; +const CELL = 2.85; + +/** Per-column clock, straight off the Swift constants: `speed` and `phase` come + from its `frac(sin(n) * 43758.5453)` hash, one fall takes `(rows + 3) / speed` + seconds, and `phase` becomes a negative delay. This is what keeps the four + columns permanently out of step with each other. */ +const COLUMNS: ReadonlyArray<{ speed: number; phase: number }> = [ + { speed: 2.5, phase: 0 }, + { speed: 4.504345, phase: 1.961008 }, + { speed: 3.80266, phase: 2.768793 }, + { speed: 4.367242, phase: 4.657913 }, ]; -export function SidebarV2WorkingDither() { +const SPAN = ROWS + 3; + +/** The alpha curve every drop follows: a bright head, an exponential trail + above it, a sharp falloff below. Swift discards anything under 0.02. */ +function dropAlpha(dy: number): number { + const alpha = dy >= 0 ? Math.exp(-dy * 0.8) : Math.exp(dy * 8); + return alpha >= 0.02 ? alpha : 0; +} + +/** Seconds to rewind this row's rain by. Native phanttom drives every tab from + one shared wall clock, so its working tabs all fall in lockstep; here a + sidebar full of working threads would read as a single blinking block, so + each row gets its own offset. Hashing the thread key rather than randomizing + keeps a row's phase stable across re-renders and remounts — it must not jump + when the list re-sorts or the row scrolls back into view. Every column moves + by the same absolute amount, which preserves their relationship to each + other: the row looks exactly like one that started working moments earlier. */ +function rainOffsetSeconds(seed: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < seed.length; index++) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + // FNV alone leaves the high bits barely moved between keys that differ only + // in their last character, which is exactly what sequential thread ids look + // like — the offsets then land within a second of each other and the rows + // still read as synchronised. The murmur3 finalizer avalanches them apart. + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b); + hash ^= hash >>> 13; + hash = Math.imul(hash, 0xc2b2ae35); + hash ^= hash >>> 16; + return ((hash >>> 0) / 0x1_0000_0000) * 32; +} + +/** One profile per row, spelled out in full because Tailwind only sees class + names it can find literally in the source — a template built from the row + index would never get generated. Duration and delay ride in as inline + styles; keeping the name in a class is what lets `motion-reduce:animate-none` + still win, since a class rule cannot override an inline `animation-name`. */ +const RAIN_ANIMATION_CLASS = [ + "animate-[sidebar-v2-rain-0_linear_infinite]", + "animate-[sidebar-v2-rain-1_linear_infinite]", + "animate-[sidebar-v2-rain-2_linear_infinite]", + "animate-[sidebar-v2-rain-3_linear_infinite]", + "animate-[sidebar-v2-rain-4_linear_infinite]", +] as const; + +const WIDTH = (COLS - 1) * PITCH + CELL; +const HEIGHT = (ROWS - 1) * PITCH + CELL; + +/** The grid is fixed and never reorders, so the twenty drops are resolved once + at module load — position, clock and keyframe per cell. Only the per-row + time offset is left to compute at render. */ +const CELLS = COLUMNS.flatMap((column, col) => + Array.from({ length: ROWS }, (_, row) => ({ + id: `${col}:${row}`, + x: col * PITCH, + y: row * PITCH, + row, + speed: column.speed, + phase: column.phase, + duration: SPAN / column.speed, + animationClass: RAIN_ANIMATION_CLASS[row], + })), +); + +/** Drawn as SVG rather than positioned elements on purpose. A 2.85px box on a + 3.8px pitch never lands on a device-pixel boundary, and browsers snap + element backgrounds to whole pixels — so the twenty cells would paint at + visibly different widths depending on each one's subpixel offset. SVG keeps + the fractional geometry and antialiases it, which is what the native Canvas + does too, so every drop stays exactly square. */ +export function SidebarV2WorkingRain({ seed }: { seed: string }) { + const offset = rainOffsetSeconds(seed); return ( - - {DITHER_CELLS.map((cell) => ( - + {CELLS.map((cell) => ( + ))} - + ); } -/** The blocked/settled counterpart to the dither: one 8px dot centered in the +/** The blocked/settled counterpart to the rain: one 8px dot centered in the same 16px box the provider icons use, so the trailing edge of every row - lines up whether the mark is a dot, a clock, or the cluster. */ + lines up whether the mark is a dot, a clock, or the grid. */ export function SidebarV2StatusDot({ tone }: { tone: SidebarV2StatusTone }) { return ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 395d92d8cf5..f2b29d4591f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -122,7 +122,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; - --animate-sidebar-v2-dither: sidebar-v2-dither 1.6s ease-in-out infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -210,6 +209,569 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } + /* Pixel rain: the "working" indicator ported from the phanttom Ghostty fork + (macos/Sources/Features/Terminal/Sidebar/SidebarView.swift, PixelSparkleView). + Four columns of drops fall through a 5-row grid, each column with its own + speed and phase; the drop head is bright with an exponential trail above + it and a sharp falloff below. + + Swift redraws every frame from `head = (t*speed + phase) mod 8 - 1.5` and + `alpha = dy >= 0 ? exp(-0.8*dy) : exp(8*dy)`. Every cell in a column shares + one head, so the same motion decomposes into five fixed alpha profiles (one + per row) driven by one clock per column — no per-frame JS, and the + compositor owns it. GENERATED, do not hand-edit: regenerate from the Swift + constants if PixelSparkleView changes. Verified against the reference at + <=0.046 absolute opacity error away from the loop seam (mean 1.1e-3). */ + @keyframes sidebar-v2-rain-0 { + 0% { + opacity: 0; + } + 4% { + opacity: 0; + } + 7.5% { + opacity: 0.001; + } + 8% { + opacity: 0.001; + } + 8.44% { + opacity: 0.001; + } + 9.37% { + opacity: 0.002; + } + 10.31% { + opacity: 0.005; + } + 11.25% { + opacity: 0.008; + } + 12% { + opacity: 0.013; + } + 12.19% { + opacity: 0.015; + } + 13.12% { + opacity: 0.027; + } + 14.06% { + opacity: 0.05; + } + 15% { + opacity: 0.091; + } + 15.94% { + opacity: 0.166; + } + 16% { + opacity: 0.172; + } + 16.87% { + opacity: 0.3; + } + 17.81% { + opacity: 0.548; + } + 18.75% { + opacity: 1; + } + 19.69% { + opacity: 0.942; + } + 20% { + opacity: 0.923; + } + 20.62% { + opacity: 0.887; + } + 21.56% { + opacity: 0.835; + } + 22.5% { + opacity: 0.787; + } + 24% { + opacity: 0.715; + } + 28% { + opacity: 0.553; + } + 32% { + opacity: 0.428; + } + 36% { + opacity: 0.332; + } + 40% { + opacity: 0.257; + } + 44% { + opacity: 0.199; + } + 48% { + opacity: 0.154; + } + 52% { + opacity: 0.119; + } + 56% { + opacity: 0.092; + } + 60% { + opacity: 0.071; + } + 64% { + opacity: 0.055; + } + 68% { + opacity: 0.043; + } + 72% { + opacity: 0.033; + } + 76% { + opacity: 0.026; + } + 80% { + opacity: 0.02; + } + 84% { + opacity: 0.015; + } + 88% { + opacity: 0.012; + } + 92% { + opacity: 0.009; + } + 96% { + opacity: 0.007; + } + 100% { + opacity: 0.006; + } + } + @keyframes sidebar-v2-rain-1 { + 0% { + opacity: 0; + } + 16% { + opacity: 0; + } + 20% { + opacity: 0.001; + } + 20.94% { + opacity: 0.001; + } + 21.88% { + opacity: 0.002; + } + 22.81% { + opacity: 0.005; + } + 23.75% { + opacity: 0.008; + } + 24% { + opacity: 0.01; + } + 24.69% { + opacity: 0.015; + } + 25.62% { + opacity: 0.027; + } + 26.56% { + opacity: 0.05; + } + 27.5% { + opacity: 0.091; + } + 28% { + opacity: 0.125; + } + 28.44% { + opacity: 0.166; + } + 29.37% { + opacity: 0.3; + } + 30.31% { + opacity: 0.548; + } + 31.25% { + opacity: 1; + } + 32% { + opacity: 0.953; + } + 32.19% { + opacity: 0.942; + } + 33.12% { + opacity: 0.887; + } + 34.06% { + opacity: 0.835; + } + 35% { + opacity: 0.787; + } + 36% { + opacity: 0.738; + } + 40% { + opacity: 0.571; + } + 44% { + opacity: 0.442; + } + 48% { + opacity: 0.342; + } + 52% { + opacity: 0.265; + } + 56% { + opacity: 0.205; + } + 60% { + opacity: 0.159; + } + 64% { + opacity: 0.123; + } + 68% { + opacity: 0.095; + } + 72% { + opacity: 0.074; + } + 76% { + opacity: 0.057; + } + 80% { + opacity: 0.044; + } + 84% { + opacity: 0.034; + } + 88% { + opacity: 0.026; + } + 92% { + opacity: 0.02; + } + 96% { + opacity: 0.016; + } + 100% { + opacity: 0.012; + } + } + @keyframes sidebar-v2-rain-2 { + 0% { + opacity: 0; + } + 28% { + opacity: 0; + } + 32% { + opacity: 0.001; + } + 32.5% { + opacity: 0.001; + } + 33.44% { + opacity: 0.001; + } + 34.38% { + opacity: 0.002; + } + 35.31% { + opacity: 0.005; + } + 36% { + opacity: 0.007; + } + 36.25% { + opacity: 0.008; + } + 37.19% { + opacity: 0.015; + } + 38.12% { + opacity: 0.027; + } + 39.06% { + opacity: 0.05; + } + 40% { + opacity: 0.091; + } + 40.94% { + opacity: 0.166; + } + 41.87% { + opacity: 0.3; + } + 42.81% { + opacity: 0.548; + } + 43.75% { + opacity: 1; + } + 44% { + opacity: 0.984; + } + 44.69% { + opacity: 0.942; + } + 45.62% { + opacity: 0.887; + } + 46.56% { + opacity: 0.835; + } + 47.5% { + opacity: 0.787; + } + 48% { + opacity: 0.762; + } + 52% { + opacity: 0.59; + } + 56% { + opacity: 0.457; + } + 60% { + opacity: 0.353; + } + 64% { + opacity: 0.274; + } + 68% { + opacity: 0.212; + } + 72% { + opacity: 0.164; + } + 76% { + opacity: 0.127; + } + 80% { + opacity: 0.098; + } + 84% { + opacity: 0.076; + } + 88% { + opacity: 0.059; + } + 92% { + opacity: 0.046; + } + 96% { + opacity: 0.035; + } + 100% { + opacity: 0.027; + } + } + @keyframes sidebar-v2-rain-3 { + 0% { + opacity: 0; + } + 44% { + opacity: 0; + } + 45% { + opacity: 0.001; + } + 45.94% { + opacity: 0.001; + } + 46.88% { + opacity: 0.002; + } + 47.81% { + opacity: 0.005; + } + 48% { + opacity: 0.005; + } + 48.75% { + opacity: 0.008; + } + 49.69% { + opacity: 0.015; + } + 50.62% { + opacity: 0.027; + } + 51.56% { + opacity: 0.05; + } + 52% { + opacity: 0.066; + } + 52.5% { + opacity: 0.091; + } + 53.44% { + opacity: 0.166; + } + 54.37% { + opacity: 0.3; + } + 55.31% { + opacity: 0.548; + } + 56% { + opacity: 0.852; + } + 56.25% { + opacity: 1; + } + 57.19% { + opacity: 0.942; + } + 58.13% { + opacity: 0.887; + } + 59.06% { + opacity: 0.835; + } + 60% { + opacity: 0.787; + } + 64% { + opacity: 0.609; + } + 68% { + opacity: 0.471; + } + 72% { + opacity: 0.365; + } + 76% { + opacity: 0.283; + } + 80% { + opacity: 0.219; + } + 84% { + opacity: 0.169; + } + 88% { + opacity: 0.131; + } + 92% { + opacity: 0.101; + } + 96% { + opacity: 0.079; + } + 100% { + opacity: 0.061; + } + } + @keyframes sidebar-v2-rain-4 { + 0% { + opacity: 0; + } + 56% { + opacity: 0; + } + 57.5% { + opacity: 0.001; + } + 58.44% { + opacity: 0.001; + } + 59.38% { + opacity: 0.002; + } + 60% { + opacity: 0.004; + } + 60.31% { + opacity: 0.005; + } + 61.25% { + opacity: 0.008; + } + 62.19% { + opacity: 0.015; + } + 63.12% { + opacity: 0.027; + } + 64% { + opacity: 0.048; + } + 64.06% { + opacity: 0.05; + } + 65% { + opacity: 0.091; + } + 65.94% { + opacity: 0.166; + } + 66.87% { + opacity: 0.3; + } + 67.81% { + opacity: 0.548; + } + 68% { + opacity: 0.619; + } + 68.75% { + opacity: 1; + } + 69.69% { + opacity: 0.942; + } + 70.63% { + opacity: 0.887; + } + 71.56% { + opacity: 0.835; + } + 72% { + opacity: 0.812; + } + 72.5% { + opacity: 0.787; + } + 76% { + opacity: 0.629; + } + 80% { + opacity: 0.487; + } + 84% { + opacity: 0.377; + } + 88% { + opacity: 0.292; + } + 92% { + opacity: 0.226; + } + 96% { + opacity: 0.175; + } + 100% { + opacity: 0.135; + } + } @keyframes sidebar-working-text { 0%, 36% { @@ -225,20 +787,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } - /* Each cell of the working cluster shimmers between a fraction of its - resting alpha and full strength, so the dim trail cells stay dim relative - to the live ones instead of every cell flashing to the same brightness. - --dither-alpha is set per cell inline; the fallback keeps the keyframe - valid if the variable is ever missing. */ - @keyframes sidebar-v2-dither { - 0%, - 100% { - opacity: calc(var(--dither-alpha, 1) * 0.35); - } - 50% { - opacity: var(--dither-alpha, 1); - } - } } @layer base { From b9c834097dcb1db8aeea95a0c76a778d7937f289 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 25 Jul 2026 14:31:30 -0400 Subject: [PATCH 3/6] feat(web): swap the icon set to Phosphor duotone via a lucide alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app's iconography is Phosphor at the duotone weight now. The obvious way to get there is to rewrite the imports, but lucide is imported at ~89 sites concentrated in the files that churn hardest upstream — ChatView, SidebarV2, ChatComposer. Owning those diffs forever costs more than the icons are worth. So `lucide-react` is aliased instead, in `vite.config.ts` (2 commits/60d) and mirrored in `tsconfig.json` (0), to a fork-owned shim that re-exports Phosphor under lucide's export names. Every call site keeps saying `from "lucide-react"` verbatim, so upstream can add, move or delete icon usage freely and this fork inherits it for free. That makes the swap a Tier 1 customization (.fork/README.md §3): one additive file plus two low-churn config edits. Two details the shim has to get right: - Glyphs with no enclosed area — carets, checks, arrows, spinners — are pinned to bold. Duotone has nothing to shade there, so it renders as a plain thin line and reads far too light next to lucide's 2px stroke. - It stamps lucide's `lucide lucide-` classes on the svg. Nothing styles off them, but upstream tests use them to assert which icon rendered (`MessagesTimeline.test.tsx` wants `lucide-x` on a failed tool call). Honouring the contract keeps those green unmodified — without it the swap would have to reach into upstream test files, which is exactly the Tier 4 conflict surface the alias exists to avoid. The guard test covers the parts that fail silently: either alias dropped in a sync and the app quietly renders lucide again, with no conflict and no error. It also fails naming any lucide icon an upstream sync introduces that the shim has no mapping for. Co-Authored-By: Claude Opus 5 --- .fork/customizations.yaml | 24 ++ apps/web/package.json | 1 + .../src/__fork_guards__/phosphorIcons.test.ts | 105 ++++++ apps/web/src/custom/icons/lucide-phosphor.tsx | 338 ++++++++++++++++++ apps/web/tsconfig.json | 6 +- apps/web/vite.config.ts | 10 + pnpm-lock.yaml | 15 + 7 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/__fork_guards__/phosphorIcons.test.ts create mode 100644 apps/web/src/custom/icons/lucide-phosphor.tsx diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index c4ed30a9028..7e5d2452551 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -81,3 +81,27 @@ - apps/web/src/main.tsx verify: - apps/web/src/__fork_guards__/forkMarker.test.ts + +- id: phosphor-duotone-icons + intent: > + The app's iconography is Phosphor at the duotone weight, not lucide. + Rather than rewriting ~89 import sites across the hottest upstream files + (ChatView, SidebarV2, ChatComposer), "lucide-react" is aliased to a + fork-owned shim that re-exports Phosphor under lucide's export names. Call + sites keep importing "lucide-react" verbatim, so upstream can add, move, or + delete icon usage freely and this fork inherits it. Glyphs with no enclosed + area (carets, checks, arrows, spinners) are pinned to the bold weight, + since duotone is indistinguishable there and reads too light against + lucide's 2px stroke. The shim also reproduces lucide's `lucide lucide-` + SVG classes, because upstream tests assert them to identify which icon + rendered — honouring the contract keeps those tests passing unmodified + instead of forcing Tier 4 edits into upstream test files. + tier: 1 + files: + - apps/web/src/custom/icons/lucide-phosphor.tsx + shadows: [] + watch: + - apps/web/vite.config.ts + - apps/web/tsconfig.json + verify: + - apps/web/src/__fork_guards__/phosphorIcons.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..5ee8efb582e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", + "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", diff --git a/apps/web/src/__fork_guards__/phosphorIcons.test.ts b/apps/web/src/__fork_guards__/phosphorIcons.test.ts new file mode 100644 index 00000000000..99eefd57796 --- /dev/null +++ b/apps/web/src/__fork_guards__/phosphorIcons.test.ts @@ -0,0 +1,105 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/README.md` §4b and + * `.fork/customizations.yaml#phosphor-duotone-icons`. + * + * The icon swap is invisible at every call site: upstream code still imports + * from "lucide-react", and only the two alias entries redirect that to the + * Phosphor shim. That is exactly the §4 failure mode — drop either alias in a + * sync and the app silently renders lucide again, with no conflict and no + * error. These tests turn that into a red one. + */ + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +const webRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL(".", import.meta.url)), "../.."); +const SHIM_PATH = "src/custom/icons/lucide-phosphor.tsx"; + +function read(relativePath: string): string { + return NodeFS.readFileSync(NodePath.join(webRoot, relativePath), "utf8"); +} + +function sourceFiles(dir: string): string[] { + return NodeFS.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = NodePath.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.isFile() && /\.tsx?$/.test(entry.name) ? [full] : []; + }); +} + +/** Named bindings pulled from "lucide-react" anywhere under `apps/web/src`. */ +function importedLucideNames(): string[] { + const names = new Set(); + for (const file of sourceFiles(NodePath.join(webRoot, "src"))) { + const source = NodeFS.readFileSync(file, "utf8"); + for (const match of source.matchAll( + /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"lucide-react"/g, + )) { + for (const binding of (match[1] ?? "").split(",")) { + const name = binding + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0] + ?.trim(); + if (name) names.add(name); + } + } + } + return [...names]; +} + +describe("fork guard: phosphor-duotone-icons", () => { + it("keeps the bundler alias pointing lucide-react at the shim", () => { + const config = read("vite.config.ts"); + expect(config).toContain('"lucide-react": NodeURL.fileURLToPath('); + expect(config).toContain(SHIM_PATH); + }); + + it("keeps the TypeScript path mapping in step with the bundler alias", () => { + const tsconfig = read("tsconfig.json"); + expect(tsconfig).toContain(`"lucide-react": ["./${SHIM_PATH}"]`); + }); + + it("exports every lucide binding the app imports", () => { + const shim = read(SHIM_PATH); + const exported = new Set([ + ...[...shim.matchAll(/export const (\w+)/g)].map((m) => m[1]), + ...[...shim.matchAll(/export type (\w+)/g)].map((m) => m[1]), + ]); + // A missing name means an upstream sync introduced a lucide icon the shim + // has no mapping for. Fix: add a line to the table in the shim. + expect(importedLucideNames().filter((name) => !exported.has(name))).toEqual([]); + }); + + it("renders Phosphor rather than lucide, defaulting to the duotone weight", () => { + const shim = read(SHIM_PATH); + expect(shim).toContain('from "@phosphor-icons/react"'); + expect(shim).toContain('"duotone"'); + // The shim must not re-import the package it stands in for — that would + // alias lucide-react to itself and recurse. + expect(shim).not.toMatch(/^\s*(?:import|export)\b[^\n]*from "lucide-react"/m); + }); + + it("stamps lucide's class names so upstream icon assertions still pass", () => { + const shim = read(SHIM_PATH); + // Upstream identifies icons in tests by these classes (e.g. `lucide-x` in + // MessagesTimeline.test.tsx). Drop them and those tests fail with a diff + // that points at upstream code rather than at this shim. + expect(shim).toContain("`lucide lucide-${lucideName}`"); + // Every entry in the table has to carry a name, or its icon renders + // unclassed while the rest stay covered. + expect([...shim.matchAll(/export const \w+ = icon\((.)/g)].filter((m) => m[1] !== '"')).toEqual( + [], + ); + }); + + it("keeps lucide-react installed only as the alias target's public name", () => { + const pkg = JSON.parse(read("package.json")) as { + dependencies?: Record; + }; + expect(pkg.dependencies?.["@phosphor-icons/react"]).toBeTruthy(); + }); +}); diff --git a/apps/web/src/custom/icons/lucide-phosphor.tsx b/apps/web/src/custom/icons/lucide-phosphor.tsx new file mode 100644 index 00000000000..95910624c9e --- /dev/null +++ b/apps/web/src/custom/icons/lucide-phosphor.tsx @@ -0,0 +1,338 @@ +/** + * fork: lucide-react -> Phosphor duotone. + * + * `lucide-react` is aliased to this module in `apps/web/vite.config.ts` and + * `apps/web/tsconfig.json`, so all ~89 upstream import sites keep saying + * `from "lucide-react"` and transparently render Phosphor instead. That keeps + * this a Tier 1 customization (.fork/README.md S3): one additive file plus two + * low-churn config edits, instead of a rewrite of every hot component. + * + * Every icon defaults to the `duotone` weight. Glyphs with no enclosed area + * (carets, checks, arrows, spinners) render identically in every weight, so + * those are pinned to `bold` to keep the optical weight of lucide's 2px stroke. + * + * Adding an icon: if an upstream sync introduces a lucide import this module + * does not export, the build fails naming that icon. Add a line to the table. + * + * See .fork/customizations.yaml#phosphor-duotone-icons + */ +import { + Alarm as PhAlarm, + Archive as PhArchive, + ArrowCircleUp as PhArrowCircleUp, + ArrowClockwise as PhArrowClockwise, + ArrowCounterClockwise as PhArrowCounterClockwise, + ArrowDown as PhArrowDown, + ArrowElbowDownLeft as PhArrowElbowDownLeft, + ArrowElbowLeftUp as PhArrowElbowLeftUp, + ArrowLeft as PhArrowLeft, + ArrowRight as PhArrowRight, + ArrowSquareOut as PhArrowSquareOut, + ArrowUUpLeft as PhArrowUUpLeft, + ArrowUp as PhArrowUp, + ArrowsClockwise as PhArrowsClockwise, + ArrowsDownUp as PhArrowsDownUp, + ArrowsInLineVertical as PhArrowsInLineVertical, + ArrowsInSimple as PhArrowsInSimple, + ArrowsOutSimple as PhArrowsOutSimple, + BellSlash as PhBellSlash, + BoxArrowUp as PhBoxArrowUp, + Broadcast as PhBroadcast, + Bug as PhBug, + Camera as PhCamera, + CaretDown as PhCaretDown, + CaretLeft as PhCaretLeft, + CaretRight as PhCaretRight, + CaretUp as PhCaretUp, + CaretUpDown as PhCaretUpDown, + ChatCircle as PhChatCircle, + ChatText as PhChatText, + Check as PhCheck, + CheckCircle as PhCheckCircle, + CircleNotch as PhCircleNotch, + ClipboardText as PhClipboardText, + Clock as PhClock, + ClockCounterClockwise as PhClockCounterClockwise, + Cloud as PhCloud, + CloudArrowUp as PhCloudArrowUp, + Code as PhCode, + Columns as PhColumns, + Copy as PhCopy, + Cursor as PhCursor, + CursorClick as PhCursorClick, + DeviceMobile as PhDeviceMobile, + DotsThree as PhDotsThree, + DotsThreeVertical as PhDotsThreeVertical, + DownloadSimple as PhDownloadSimple, + Eye as PhEye, + EyeSlash as PhEyeSlash, + Eyedropper as PhEyedropper, + File as PhFile, + FileCode as PhFileCode, + Files as PhFiles, + Flask as PhFlask, + Folder as PhFolder, + FolderDashed as PhFolderDashed, + FolderOpen as PhFolderOpen, + FolderPlus as PhFolderPlus, + Gear as PhGear, + GearSix as PhGearSix, + GitBranch as PhGitBranch, + GitCommit as PhGitCommit, + GitDiff as PhGitDiff, + GitFork as PhGitFork, + GitPullRequest as PhGitPullRequest, + Globe as PhGlobe, + GlobeHemisphereWest as PhGlobeHemisphereWest, + Hammer as PhHammer, + HardDrives as PhHardDrives, + Info as PhInfo, + Keyboard as PhKeyboard, + Layout as PhLayout, + Lightning as PhLightning, + Link as PhLink, + LinkSimple as PhLinkSimple, + ListChecks as PhListChecks, + Lock as PhLock, + LockOpen as PhLockOpen, + MagnifyingGlass as PhMagnifyingGlass, + Minus as PhMinus, + Monitor as PhMonitor, + NotePencil as PhNotePencil, + PaintBrush as PhPaintBrush, + Paragraph as PhParagraph, + PencilRuler as PhPencilRuler, + PencilSimpleLine as PhPencilSimpleLine, + Play as PhPlay, + PlugsConnected as PhPlugsConnected, + Plus as PhPlus, + QrCode as PhQrCode, + Robot as PhRobot, + Rows as PhRows, + Selection as PhSelection, + ShippingContainer as PhShippingContainer, + Sidebar as PhSidebar, + SidebarSimple as PhSidebarSimple, + SignIn as PhSignIn, + Sparkle as PhSparkle, + SquareSplitHorizontal as PhSquareSplitHorizontal, + SquareSplitVertical as PhSquareSplitVertical, + Star as PhStar, + Terminal as PhTerminal, + TerminalWindow as PhTerminalWindow, + Trash as PhTrash, + TreeStructure as PhTreeStructure, + Warning as PhWarning, + WarningCircle as PhWarningCircle, + WifiSlash as PhWifiSlash, + Wrench as PhWrench, + X as PhX, + XCircle as PhXCircle, + type Icon as PhosphorIcon, + type IconProps, + type IconWeight, +} from "@phosphor-icons/react"; +import type { FC, SVGProps } from "react"; + +/** + * Phosphor's own `IconProps` declares its optionals without `| undefined`, + * which this repo's `exactOptionalPropertyTypes` treats as "may not be passed + * explicitly as undefined". Upstream assigns lucide icons into slots typed + * `React.FC>` (see `~/components/Icons.tsx`), so the + * shim's props must stay assignable from plain SVG props. + */ +type LucideCompatProps = SVGProps & { + alt?: string | undefined; + size?: string | number | undefined; + weight?: IconWeight | undefined; + mirrored?: boolean | undefined; +}; + +/** + * Upstream writes `React.ComponentProps` and annotates icon + * slots as `LucideIcon`, so both names have to keep resolving. + */ +export type LucideIcon = FC; +export type LucideProps = LucideCompatProps; + +/** + * Pins a default weight while still letting a caller override it per usage. + * Phosphor forwards unknown props to the ``, so upstream's `strokeWidth` + * and Tailwind `size-*` classes pass through untouched — Phosphor renders + * fills, so a stray `strokeWidth` is inert rather than wrong. + * + * `lucideName` reproduces the `lucide lucide-` classes lucide stamps on + * every icon (see its `createLucideIcon`). Nothing in the app styles off them, + * but upstream tests assert them to identify which icon rendered — e.g. + * `MessagesTimeline.test.tsx` expects `lucide-x` on a failed tool call. Keeping + * the class contract here means those tests, and future ones like them, pass + * unmodified: the swap stays a Tier 1 customization instead of leaking edits + * into upstream test files. Names are the lucide export kebab-cased, which is + * exact for canonical icons; lucide's deprecated aliases (`MoreVertical` -> + * `ellipsis-vertical`) get the alias name rather than the canonical one. + */ +function icon(lucideName: string, Base: PhosphorIcon, weight: IconWeight): LucideIcon { + const lucideClasses = `lucide lucide-${lucideName}`; + const Wrapped: LucideIcon = ({ weight: override, className, ...props }) => ( + // Cast bridges the `exactOptionalPropertyTypes` gap described above; the + // shapes are structurally identical apart from explicit-undefined. + + ); + Wrapped.displayName = `Duotone(${Base.displayName ?? "Icon"})`; + return Wrapped; +} + +export const ChevronDownIcon = icon("chevron-down", PhCaretDown, "bold"); +export const ChevronUpIcon = icon("chevron-up", PhCaretUp, "bold"); +export const ChevronLeftIcon = icon("chevron-left", PhCaretLeft, "bold"); +export const ChevronRightIcon = icon("chevron-right", PhCaretRight, "bold"); +export const ChevronRight = icon("chevron-right", PhCaretRight, "bold"); +export const ChevronsUpDownIcon = icon("chevrons-up-down", PhCaretUpDown, "bold"); +export const ChevronsDownUpIcon = icon("chevrons-down-up", PhArrowsInLineVertical, "bold"); +export const ChevronsLeftRightEllipsisIcon = icon( + "chevrons-left-right-ellipsis", + PhPlugsConnected, + "duotone", +); +export const XIcon = icon("x", PhX, "bold"); +export const X = icon("x", PhX, "bold"); +export const CircleXIcon = icon("circle-x", PhXCircle, "duotone"); +export const CheckIcon = icon("check", PhCheck, "bold"); +export const CircleCheckIcon = icon("circle-check", PhCheckCircle, "duotone"); +export const CheckCircle2Icon = icon("check-circle-2", PhCheckCircle, "duotone"); +export const PlusIcon = icon("plus", PhPlus, "bold"); +export const Plus = icon("plus", PhPlus, "bold"); +export const MinusIcon = icon("minus", PhMinus, "bold"); +export const Minus = icon("minus", PhMinus, "bold"); +export const ArrowUpIcon = icon("arrow-up", PhArrowUp, "bold"); +export const ArrowDownIcon = icon("arrow-down", PhArrowDown, "bold"); +export const ArrowLeftIcon = icon("arrow-left", PhArrowLeft, "bold"); +export const ArrowLeft = icon("arrow-left", PhArrowLeft, "bold"); +export const ArrowRightIcon = icon("arrow-right", PhArrowRight, "bold"); +export const ArrowRight = icon("arrow-right", PhArrowRight, "bold"); +export const ArrowUpDownIcon = icon("arrow-up-down", PhArrowsDownUp, "bold"); +export const ArrowUpCircleIcon = icon("arrow-up-circle", PhArrowCircleUp, "duotone"); +export const CornerLeftUpIcon = icon("corner-left-up", PhArrowElbowLeftUp, "bold"); +export const Undo2Icon = icon("undo-2", PhArrowUUpLeft, "bold"); +export const RotateCcwIcon = icon("rotate-ccw", PhArrowCounterClockwise, "bold"); +export const RotateCcw = icon("rotate-ccw", PhArrowCounterClockwise, "bold"); +export const RotateCwIcon = icon("rotate-cw", PhArrowClockwise, "bold"); +export const RotateCw = icon("rotate-cw", PhArrowClockwise, "bold"); +export const RefreshCwIcon = icon("refresh-cw", PhArrowsClockwise, "bold"); +export const RefreshCw = icon("refresh-cw", PhArrowsClockwise, "bold"); +export const HistoryIcon = icon("history", PhClockCounterClockwise, "duotone"); +export const ClockIcon = icon("clock", PhClock, "duotone"); +export const AlarmClockIcon = icon("alarm-clock", PhAlarm, "duotone"); +export const AlarmClockOffIcon = icon("alarm-clock-off", PhBellSlash, "duotone"); +export const TriangleAlertIcon = icon("triangle-alert", PhWarning, "duotone"); +export const AlertTriangleIcon = icon("alert-triangle", PhWarning, "duotone"); +export const CircleAlertIcon = icon("circle-alert", PhWarningCircle, "duotone"); +export const InfoIcon = icon("info", PhInfo, "duotone"); +export const BugIcon = icon("bug", PhBug, "duotone"); +export const LoaderIcon = icon("loader", PhCircleNotch, "bold"); +export const LoaderCircleIcon = icon("loader-circle", PhCircleNotch, "bold"); +export const LoaderCircle = icon("loader-circle", PhCircleNotch, "bold"); +export const Loader2Icon = icon("loader-2", PhCircleNotch, "bold"); +export const FileIcon = icon("file", PhFile, "duotone"); +export const Files = icon("files", PhFiles, "duotone"); +export const FileDiff = icon("file-diff", PhGitDiff, "duotone"); +export const FileDiffIcon = icon("file-diff", PhGitDiff, "duotone"); +export const FileJsonIcon = icon("file-json", PhFileCode, "duotone"); +export const ClipboardList = icon("clipboard-list", PhClipboardText, "duotone"); +export const CopyIcon = icon("copy", PhCopy, "duotone"); +export const ArchiveIcon = icon("archive", PhArchive, "duotone"); +export const ArchiveX = icon("archive-x", PhBoxArrowUp, "duotone"); +export const Trash2Icon = icon("trash-2", PhTrash, "duotone"); +export const Trash2 = icon("trash-2", PhTrash, "duotone"); +export const FolderIcon = icon("folder", PhFolder, "duotone"); +export const FolderClosedIcon = icon("folder-closed", PhFolder, "duotone"); +export const FolderOpenIcon = icon("folder-open", PhFolderOpen, "duotone"); +export const FolderPlusIcon = icon("folder-plus", PhFolderPlus, "duotone"); +export const FolderGitIcon = icon("folder-git", PhFolderDashed, "duotone"); +export const FolderGit2Icon = icon("folder-git-2", PhFolderDashed, "duotone"); +export const FolderTree = icon("folder-tree", PhTreeStructure, "duotone"); +export const GitBranchIcon = icon("git-branch", PhGitBranch, "duotone"); +export const GitBranchPlusIcon = icon("git-branch-plus", PhGitFork, "duotone"); +export const GitCommitIcon = icon("git-commit", PhGitCommit, "duotone"); +export const GitPullRequestIcon = icon("git-pull-request", PhGitPullRequest, "duotone"); +export const TerminalIcon = icon("terminal", PhTerminal, "duotone"); +export const TerminalSquare = icon("terminal-square", PhTerminalWindow, "duotone"); +export const Code2 = icon("code-2", PhCode, "duotone"); +export const HammerIcon = icon("hammer", PhHammer, "duotone"); +export const WrenchIcon = icon("wrench", PhWrench, "duotone"); +export const FlaskConicalIcon = icon("flask-conical", PhFlask, "duotone"); +export const ContainerIcon = icon("container", PhShippingContainer, "duotone"); +export const ServerIcon = icon("server", PhHardDrives, "duotone"); +export const CloudIcon = icon("cloud", PhCloud, "duotone"); +export const CloudUploadIcon = icon("cloud-upload", PhCloudArrowUp, "duotone"); +export const GlobeIcon = icon("globe", PhGlobe, "duotone"); +export const Globe = icon("globe", PhGlobe, "duotone"); +export const Globe2 = icon("globe-2", PhGlobeHemisphereWest, "duotone"); +export const Globe2Icon = icon("globe-2", PhGlobeHemisphereWest, "duotone"); +export const WifiOffIcon = icon("wifi-off", PhWifiSlash, "duotone"); +export const RadioTower = icon("radio-tower", PhBroadcast, "duotone"); +export const QrCodeIcon = icon("qr-code", PhQrCode, "duotone"); +export const KeyboardIcon = icon("keyboard", PhKeyboard, "duotone"); +export const MonitorIcon = icon("monitor", PhMonitor, "duotone"); +export const SmartphoneIcon = icon("smartphone", PhDeviceMobile, "duotone"); +export const BotIcon = icon("bot", PhRobot, "duotone"); +export const SparklesIcon = icon("sparkles", PhSparkle, "duotone"); +export const ZapIcon = icon("zap", PhLightning, "duotone"); +export const StarIcon = icon("star", PhStar, "duotone"); +export const LockIcon = icon("lock", PhLock, "duotone"); +export const LockOpenIcon = icon("lock-open", PhLockOpen, "duotone"); +export const LogInIcon = icon("log-in", PhSignIn, "duotone"); +export const EyeIcon = icon("eye", PhEye, "duotone"); +export const Eye = icon("eye", PhEye, "duotone"); +export const EyeOffIcon = icon("eye-off", PhEyeSlash, "duotone"); +export const SearchIcon = icon("search", PhMagnifyingGlass, "duotone"); +export const Search = icon("search", PhMagnifyingGlass, "duotone"); +export const SettingsIcon = icon("settings", PhGear, "duotone"); +export const Settings2Icon = icon("settings-2", PhGearSix, "duotone"); +export const DownloadIcon = icon("download", PhDownloadSimple, "bold"); +export const ExternalLinkIcon = icon("external-link", PhArrowSquareOut, "duotone"); +export const ExternalLink = icon("external-link", PhArrowSquareOut, "duotone"); +export const LinkIcon = icon("link", PhLink, "duotone"); +export const Link2 = icon("link-2", PhLinkSimple, "duotone"); +export const Link2Icon = icon("link-2", PhLinkSimple, "duotone"); +export const MessageSquareIcon = icon("message-square", PhChatText, "duotone"); +export const MessageCircle = icon("message-circle", PhChatCircle, "duotone"); +export const MessageCircleIcon = icon("message-circle", PhChatCircle, "duotone"); +export const EllipsisIcon = icon("ellipsis", PhDotsThree, "bold"); +export const MoreVertical = icon("more-vertical", PhDotsThreeVertical, "bold"); +export const PlayIcon = icon("play", PhPlay, "duotone"); +export const ListChecksIcon = icon("list-checks", PhListChecks, "duotone"); +export const ListTodoIcon = icon("list-todo", PhListChecks, "duotone"); +export const Camera = icon("camera", PhCamera, "duotone"); +export const PipetteIcon = icon("pipette", PhEyedropper, "duotone"); +export const PaintbrushIcon = icon("paintbrush", PhPaintBrush, "duotone"); +export const Paintbrush = icon("paintbrush", PhPaintBrush, "duotone"); +export const PencilRulerIcon = icon("pencil-ruler", PhPencilRuler, "duotone"); +export const PenLineIcon = icon("pen-line", PhPencilSimpleLine, "duotone"); +export const PenLine = icon("pen-line", PhPencilSimpleLine, "duotone"); +export const SquarePenIcon = icon("square-pen", PhNotePencil, "duotone"); +export const Frame = icon("frame", PhSelection, "duotone"); +export const MousePointerClick = icon("mouse-pointer-click", PhCursorClick, "duotone"); +export const MousePointerClickIcon = icon("mouse-pointer-click", PhCursorClick, "duotone"); +export const MousePointer2 = icon("mouse-pointer-2", PhCursor, "duotone"); +export const Maximize2Icon = icon("maximize-2", PhArrowsOutSimple, "bold"); +export const Minimize2Icon = icon("minimize-2", PhArrowsInSimple, "bold"); +export const PanelLeftIcon = icon("panel-left", PhSidebar, "duotone"); +export const PanelLeftCloseIcon = icon("panel-left-close", PhSidebarSimple, "duotone"); +export const PanelRightIcon = icon("panel-right", PhSidebar, "duotone"); +export const PanelBottomIcon = icon("panel-bottom", PhLayout, "duotone"); +export const Columns2Icon = icon("columns-2", PhColumns, "duotone"); +export const Rows3Icon = icon("rows-3", PhRows, "duotone"); +export const SquareSplitHorizontal = icon( + "square-split-horizontal", + PhSquareSplitHorizontal, + "duotone", +); +export const SquareSplitVertical = icon("square-split-vertical", PhSquareSplitVertical, "duotone"); +export const TextWrapIcon = icon("text-wrap", PhArrowElbowDownLeft, "bold"); +export const WrapTextIcon = icon("wrap-text", PhArrowElbowDownLeft, "bold"); +export const PilcrowIcon = icon("pilcrow", PhParagraph, "duotone"); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1a89dbd83e2..59aa344fb8f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -14,7 +14,11 @@ // TS falls back to `./src/*` when no override exists. // See `.fork/README.md` §3. "~/*": ["./src/overrides/*", "./src/*"], - "~upstream/*": ["./src/*"] + "~upstream/*": ["./src/*"], + // Fork-only: mirrors the `lucide-react` alias in `vite.config.ts` so + // TypeScript sees the same Phosphor duotone shim the bundler loads. + // See `.fork/customizations.yaml#phosphor-duotone-icons`. + "lucide-react": ["./src/custom/icons/lucide-phosphor.tsx"] }, "plugins": [ { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b9dda41ef85..c1e38113789 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -144,6 +144,16 @@ export default defineConfig(() => { resolve: { tsconfigPaths: true, dedupe: ["react", "react-dom"], + alias: { + // Fork-only: every `lucide-react` import resolves to the Phosphor + // duotone shim instead. Mirrored in `tsconfig.json` so types agree. + // Anchoring the swap here keeps all ~89 upstream import sites + // untouched. See `.fork/README.md` §3 and + // `.fork/customizations.yaml#phosphor-duotone-icons`. + "lucide-react": NodeURL.fileURLToPath( + new URL("./src/custom/icons/lucide-phosphor.tsx", import.meta.url), + ), + }, }, experimental: { bundledDev, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..78ab34d63ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,6 +547,9 @@ importers: '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3554,6 +3557,13 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pierre/diffs@1.3.0-beta.5': resolution: {integrity: sha512-d7449IY6Phcg9LCRLbPxhsxn6Bv4KoaP/vPyZtGu2uR1SFsSJPQcRoPf8lzyobNGKD0GZGuhgHW5LrOlilFo7w==} peerDependencies: @@ -13452,6 +13462,11 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 + '@phosphor-icons/react@2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.0.3 From c1b150a1e74ce92b035cd237095ca3fd97fe9051 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 25 Jul 2026 15:44:05 -0400 Subject: [PATCH 4/6] =?UTF-8?q?fork:=20address=20review=20of=20#2=20?= =?UTF-8?q?=E2=80=94=20Tier=201=20theming,=20rain=20guard,=20icon=20guard?= =?UTF-8?q?=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork doctrine (the review's main ask). The Sidebar V2 work landed 587 lines directly in apps/web/src/index.css — the second-highest-churn file in the repo by the fork's own measurements — while apps/web/src/theme.custom.css, the Tier 1 file built for exactly this, sat empty. Moves the five rain @keyframes and the status palette there; only the six --color-sidebar-v2-* registrations stay inline, because Tailwind reads utility names from the @theme block alone, and those are now fenced per §3 Tier 4. index.css goes from +587 to +11. Taking the keyframes out of @theme also removes a latent trap: Tailwind v4 prunes keyframes declared there unless a generated `animation` declaration names them, so a dynamically built class name would have silently dropped the keyframes themselves, not just the utility. Moves SidebarV2StatusIndicator to src/custom/ (fork-invented, no upstream equivalent) and registers the restyle in customizations.yaml, so detect-drift flags upstream rewrites of SidebarV2.tsx instead of missing them. The palette is also mirrored at the root, matching what upstream does for its own sidebar primitives, so a portaled row cannot resolve to a transparent dot. Makes "GENERATED, do not hand-edit" true. There was no generator and no harness; __fork_guards__/sidebarV2Rain.test.ts now re-derives all 180 stops from the Swift constants and fails on drift, and covers rainOffsetSeconds (determinism, range, the avalanche the murmur3 finalizer exists for). Doing this surfaced that the table samples the curve *unclamped* while dropAlpha applies Swift's 0.02 cutoff — correct, since clamping would step the tail, so the two are now separate functions with that documented and asserted. Closes the icon guard's gaps: default/namespace/dynamic/require imports of lucide-react are caught, packages/* is scanned since the alias covers the whole bundle graph, and one icon is rendered for real to prove the `lucide lucide-` contract rather than string-matching the shim's source. Measured the bundle question the review raised, by building twice with only the alias flipped: phosphor is 9,486 bytes *smaller* gzipped than lucide, not larger. Recorded in the shim. Comment fixes: working is emerald on web and still sky on mobile, so the "sky working" claim is replaced with the actual divergence; the compositor claim is softened (opacity on an SVG child is not layer-promoted); the "stable across remounts" claim is corrected. Drops the dead gap-1 on the now-icon-only Settle button, and the unreachable TONE_COLOR_CLASS.working — the dot's tone type now excludes it by construction. Left alone deliberately: --animate-sidebar-working-text and its keyframes are upstream's and upstream still uses them; deleting them here would be a Tier 4 edit to a hot file for no gain. Co-Authored-By: Claude Opus 5 --- .fork/customizations.yaml | 26 + .../src/__fork_guards__/phosphorIcons.test.ts | 63 +- .../src/__fork_guards__/sidebarV2Rain.test.ts | 171 +++++ apps/web/src/components/SidebarV2.tsx | 32 +- .../SidebarV2StatusIndicator.tsx | 59 +- apps/web/src/custom/icons/lucide-phosphor.tsx | 14 + apps/web/src/index.css | 586 +--------------- apps/web/src/theme.custom.css | 625 +++++++++++++++++- 8 files changed, 965 insertions(+), 611 deletions(-) create mode 100644 apps/web/src/__fork_guards__/sidebarV2Rain.test.ts rename apps/web/src/{components => custom}/SidebarV2StatusIndicator.tsx (72%) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 7e5d2452551..a602771646b 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -105,3 +105,29 @@ - apps/web/tsconfig.json verify: - apps/web/src/__fork_guards__/phosphorIcons.test.ts + +- id: sidebar-v2-card-rows + intent: > + Sidebar V2 thread rows are restyled as two-line cards: title first, an + 11px metadata line under it, 16px radius instead of 6px, and the status + text labels ("Working", "Approval") replaced by a single 16px mark in a + fixed trailing slot. The mark's form carries the state — falling pixels + while the agent runs, a static dot once it stops, a clock for woke — and + the hue only reinforces it, which is the vocabulary the phanttom Ghostty + sidebar uses. Working takes emerald from that design rather than the sky + the mobile Live Activity still uses; the divergence is deliberate and + mobile has not been migrated. The status palette and the rain keyframes + live in the Tier 1 stylesheet; only the six --color-sidebar-v2-* utility + registrations sit inline in index.css, fenced as + fork:sidebar-v2-status-palette, because Tailwind reads utility names from + the @theme block alone and will not see them anywhere else. + tier: 4 + files: + - apps/web/src/custom/SidebarV2StatusIndicator.tsx + - apps/web/src/theme.custom.css + shadows: [] + watch: + - apps/web/src/components/SidebarV2.tsx + - apps/web/src/index.css + verify: + - apps/web/src/__fork_guards__/sidebarV2Rain.test.ts diff --git a/apps/web/src/__fork_guards__/phosphorIcons.test.ts b/apps/web/src/__fork_guards__/phosphorIcons.test.ts index 99eefd57796..1fd38e9160e 100644 --- a/apps/web/src/__fork_guards__/phosphorIcons.test.ts +++ b/apps/web/src/__fork_guards__/phosphorIcons.test.ts @@ -13,9 +13,13 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; +import { XIcon } from "~/custom/icons/lucide-phosphor"; const webRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL(".", import.meta.url)), "../.."); +const repoRoot = NodePath.resolve(webRoot, "../.."); const SHIM_PATH = "src/custom/icons/lucide-phosphor.tsx"; function read(relativePath: string): string { @@ -23,17 +27,29 @@ function read(relativePath: string): string { } function sourceFiles(dir: string): string[] { + if (!NodeFS.existsSync(dir)) return []; return NodeFS.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = NodePath.join(dir, entry.name); + if (entry.name === "node_modules" || entry.name === "dist") return []; if (entry.isDirectory()) return sourceFiles(full); return entry.isFile() && /\.tsx?$/.test(entry.name) ? [full] : []; }); } -/** Named bindings pulled from "lucide-react" anywhere under `apps/web/src`. */ +/** Everything the Vite alias applies to. The alias rewrites the whole bundle + graph, not just `apps/web/src`, so a `lucide-react` import that appears in a + workspace package is aliased too — and would otherwise go unguarded. */ +function aliasedSourceFiles(): string[] { + return [ + ...sourceFiles(NodePath.join(webRoot, "src")), + ...sourceFiles(NodePath.join(repoRoot, "packages")), + ]; +} + +/** Named bindings pulled from "lucide-react" across the aliased graph. */ function importedLucideNames(): string[] { const names = new Set(); - for (const file of sourceFiles(NodePath.join(webRoot, "src"))) { + for (const file of aliasedSourceFiles()) { const source = NodeFS.readFileSync(file, "utf8"); for (const match of source.matchAll( /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"lucide-react"/g, @@ -101,5 +117,48 @@ describe("fork guard: phosphor-duotone-icons", () => { dependencies?: Record; }; expect(pkg.dependencies?.["@phosphor-icons/react"]).toBeTruthy(); + // lucide-react stays a dependency on purpose: it is the name every upstream + // import site resolves through, and dropping it would break `tsc` for any + // tool that does not read the fork's path mapping. Nothing renders from it. + expect(pkg.dependencies?.["lucide-react"]).toBeTruthy(); + }); + + it("catches import forms the named-binding scan would miss", () => { + // The whole point of this guard is catching upstream drift, and a sync can + // introduce `import Lucide from`, `import * as Lucide from`, or a dynamic + // `import("lucide-react")` — none of which the `{ … }` scan above sees, so + // a missing shim export would slip through to runtime. + const offenders: string[] = []; + // This file names all three forms in prose, and the shim is the alias + // target rather than a consumer of it. + const selfPath = NodeURL.fileURLToPath(import.meta.url); + for (const file of aliasedSourceFiles()) { + if (file === selfPath) continue; + if (file.endsWith(SHIM_PATH.replaceAll("/", NodePath.sep))) continue; + const source = NodeFS.readFileSync(file, "utf8"); + const hasDefaultOrNamespace = + /import\s+(?:\*\s+as\s+\w+|\w+)\s*(?:,\s*\{[^}]*\})?\s*from\s*"lucide-react"/.test(source); + const hasDynamic = /import\(\s*"lucide-react"\s*\)/.test(source); + const hasRequire = /require\(\s*"lucide-react"\s*\)/.test(source); + if (hasDefaultOrNamespace || hasDynamic || hasRequire) { + offenders.push(NodePath.relative(repoRoot, file)); + } + } + // Fix: convert the import to named bindings, which the shim exports, or add + // the missing shape to the shim. + expect(offenders).toEqual([]); + }); + + it("actually renders lucide's class contract, not just declares it", () => { + // Every other assertion here string-matches the shim's source, so a shim + // that compiles but renders wrong would pass them all. Render one icon for + // real and read the class off the SVG. + const html = renderToStaticMarkup(createElement(XIcon)); + expect(html).toContain(" ({ + percent: Number(match[1]), + opacity: Number(match[2]), + })); +} + +/** Where this row's drop head sits at a given point in the loop. The whole + decomposition rests on this: one clock per column advances `head` linearly + across the span, so a fixed row only ever sees `head - row`. */ +function headOffset(percent: number, row: number): number { + return (percent / 100) * RAIN_SPAN - 1.5 - row; +} + +describe("fork guard: sidebar-v2 rain keyframes", () => { + const css = read("src/theme.custom.css"); + + it("declares one keyframe per grid row", () => { + for (let row = 0; row < ROWS; row++) { + expect(css).toContain(`@keyframes sidebar-v2-rain-${row} {`); + } + // A sixth would mean the grid grew without the component following. + expect(css).not.toContain(`@keyframes sidebar-v2-rain-${ROWS} {`); + }); + + it("keeps every class the component names backed by a keyframe", () => { + expect(RAIN_ANIMATION_CLASS).toHaveLength(ROWS); + for (const className of RAIN_ANIMATION_CLASS) { + // `\w` would match the `_` separating name from timing, so spell the + // keyframe-name charset out instead. + const name = /animate-\[([a-z0-9-]+)_/.exec(className)?.[1]; + expect(name).toBeTruthy(); + expect(css).toContain(`@keyframes ${name} {`); + } + }); + + it("re-derives every stop from the Swift alpha curve", () => { + const drifted: string[] = []; + let samples = 0; + for (let row = 0; row < ROWS; row++) { + for (const stop of keyframeStops(css, row)) { + samples++; + const expected = rainAlpha(headOffset(stop.percent, row)); + if (Math.abs(expected - stop.opacity) > ROUNDING_TOLERANCE) { + drifted.push( + `rain-${row} @ ${stop.percent}%: css ${stop.opacity}, curve ${expected.toFixed(6)}`, + ); + } + } + } + expect(drifted).toEqual([]); + // Guards against the regex silently matching nothing and passing vacuously. + expect(samples).toBeGreaterThan(150); + }); + + it("peaks exactly where the drop head crosses each row", () => { + for (let row = 0; row < ROWS; row++) { + const stops = keyframeStops(css, row); + const peak = stops.find((stop) => stop.opacity === 1); + expect(peak, `rain-${row} has no full-opacity stop`).toBeTruthy(); + // head = p*SPAN - 1.5 - row = 0 → p = (row + 1.5) / SPAN + expect(peak?.percent).toBeCloseTo(((row + 1.5) / RAIN_SPAN) * 100, 1); + } + }); + + it("runs a full loop, monotonically, from 0% to 100%", () => { + for (let row = 0; row < ROWS; row++) { + const stops = keyframeStops(css, row); + expect(stops[0]?.percent).toBe(0); + expect(stops.at(-1)?.percent).toBe(100); + const percents = stops.map((stop) => stop.percent); + expect(percents).toEqual([...percents].sort((a, b) => a - b)); + expect(new Set(percents).size).toBe(percents.length); + } + }); + + it("samples the curve unclamped, unlike the still frame", () => { + // dropAlpha applies Swift's 0.02 cutoff; the table must not, or CSS would + // interpolate into a visible step where the tail should just fade out. + // If this ever flips, the tail of every column gains a hard edge. + const tail = keyframeStops(css, 0).filter((stop) => stop.opacity > 0 && stop.opacity < 0.02); + expect(tail.length).toBeGreaterThan(0); + expect(dropAlpha(4.9)).toBe(0); + expect(rainAlpha(4.9)).toBeGreaterThan(0); + }); + + it("keeps the keyframes out of the @theme block Tailwind prunes", () => { + // Tailwind v4 drops `@keyframes` declared inside `@theme` unless it finds a + // generated `animation` declaration naming them. Living in the fork's own + // stylesheet takes that failure mode off the table entirely — and keeps the + // 560-line table out of index.css, the second-highest-churn file in the repo. + expect(read("src/index.css")).not.toContain("sidebar-v2-rain"); + }); +}); + +describe("fork guard: sidebar-v2 rain phase offsets", () => { + it("is deterministic per thread key", () => { + expect(rainOffsetSeconds("thread-abc")).toBe(rainOffsetSeconds("thread-abc")); + }); + + it("stays inside one loop's worth of rewind", () => { + for (const seed of ["", "a", "thread-1", "🙂", "x".repeat(200)]) { + const offset = rainOffsetSeconds(seed); + expect(Number.isFinite(offset)).toBe(true); + expect(offset).toBeGreaterThanOrEqual(0); + expect(offset).toBeLessThan(32); + } + }); + + it("avalanches sequential ids apart", () => { + // The reason the murmur3 finalizer is there: plain FNV leaves keys that + // differ only in the last character within a second of each other, and the + // rows then read as one synchronised block. Sequential ids are exactly the + // shape real thread keys take. + const offsets = Array.from({ length: 12 }, (_, index) => rainOffsetSeconds(`thread-${index}`)); + for (let i = 0; i < offsets.length; i++) { + for (let j = i + 1; j < offsets.length; j++) { + expect(Math.abs((offsets[i] ?? 0) - (offsets[j] ?? 0))).toBeGreaterThan(0.25); + } + } + }); +}); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 34d88ed32de..e3f04905d3d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -131,8 +131,8 @@ import { SidebarV2StatusDot, SidebarV2WokeMark, SidebarV2WorkingRain, - type SidebarV2StatusTone, -} from "./SidebarV2StatusIndicator"; + type SidebarV2DotTone, +} from "~/custom/SidebarV2StatusIndicator"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; @@ -447,19 +447,26 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { !isWoke && !props.isActive && !isSelected; - // Status hues follow the system-wide convention set by sidebar v1 and the - // mobile Live Activity/widgets (amber approval, indigo input, sky working) - // so a thread reads the same color everywhere it surfaces. + // Approval stays amber and input stays indigo, matching sidebar v1 and the + // mobile Live Activity/widgets. Working does NOT: v2 takes the emerald from + // the phanttom Ghostty sidebar this design is ported from, so a working + // thread reads green on web and still sky on mobile + // (`apps/mobile/src/features/threads/thread-list-v2-items.tsx`). Migrating + // mobile is a separate call; the divergence is deliberate, not an oversight. + // Working and done share that emerald on purpose — the mark's *form* + // separates them (falling pixels vs a static dot), not its hue. // Only two of these were drawn (working, approval); the rest are extended // from the same vocabulary. `rain` = the agent is moving, `dot` = it // stopped and the row is waiting on something, `woke` keeps its own glyph. // Labels are no longer painted — they survive only as the accessible name, // since the mark itself is aria-hidden. - const topStatus: { - label: string; - tone: SidebarV2StatusTone; - mark: "rain" | "dot" | "woke"; - } | null = + // + // Discriminated on `mark` so the dot branch cannot be handed the `working` + // tone, which has no dot rendering. + const topStatus: + | { label: string; tone: "working"; mark: "rain" } + | { label: string; tone: SidebarV2DotTone; mark: "dot" | "woke" } + | null = status === "working" ? { label: "Working", tone: "working", mark: "rain" } : status === "approval" @@ -916,8 +923,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground" + className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground" > + {/* Icon-only in v2: at 282px the "Settle" text pushed the + hover actions over the title, which now shares their + line. `aria-label` carries the name. */} ) : null} diff --git a/apps/web/src/components/SidebarV2StatusIndicator.tsx b/apps/web/src/custom/SidebarV2StatusIndicator.tsx similarity index 72% rename from apps/web/src/components/SidebarV2StatusIndicator.tsx rename to apps/web/src/custom/SidebarV2StatusIndicator.tsx index ce5a5555ce9..a3affe27e81 100644 --- a/apps/web/src/components/SidebarV2StatusIndicator.tsx +++ b/apps/web/src/custom/SidebarV2StatusIndicator.tsx @@ -1,4 +1,7 @@ -import { AlarmClockIcon } from "lucide-react"; +// Imported through the shim's own path rather than the `lucide-react` alias. +// This file is fork-owned, so there is no upstream import site to preserve — +// the alias exists to keep *upstream's* imports untouched, not this one. +import { AlarmClockIcon } from "./icons/lucide-phosphor"; import type { CSSProperties } from "react"; import { cn } from "~/lib/utils"; @@ -10,11 +13,22 @@ import { cn } from "~/lib/utils"; uses in its sidebar: rain while working, 8px dots otherwise. Text labels ("Working", "Approval") are gone — at 282px wide the label crowded out the branch name, and the row already says what it is through color plus the - duration readout. */ + duration readout. + + Known limit (WCAG 1.4.1): form only separates rain / dot / clock, so the four + settled states — done, approval, input, failed — are one 8px dot apart from + each other and differ by hue alone. Screen readers get the `role="status"` + label SidebarV2 renders alongside the mark; color-blind sighted users + currently do not. Differentiating the dot shapes (a ring for approval, a + hollow dot for input) is the open follow-up. */ export type SidebarV2StatusTone = "working" | "done" | "approval" | "input" | "failed"; -const TONE_COLOR_CLASS: Record = { - working: "bg-sidebar-v2-status-working", +/** The tones a *dot* can carry. `working` is excluded by construction: a working + row always draws the rain, so a working dot is unreachable — the type is what + keeps that true as `topStatus` in SidebarV2 grows new branches. */ +export type SidebarV2DotTone = Exclude; + +const TONE_COLOR_CLASS: Record = { done: "bg-sidebar-v2-status-done", approval: "bg-sidebar-v2-status-approval", input: "bg-sidebar-v2-status-input", @@ -41,12 +55,24 @@ const COLUMNS: ReadonlyArray<{ speed: number; phase: number }> = [ { speed: 4.367242, phase: 4.657913 }, ]; -const SPAN = ROWS + 3; +export const RAIN_SPAN = ROWS + 3; +const SPAN = RAIN_SPAN; /** The alpha curve every drop follows: a bright head, an exponential trail - above it, a sharp falloff below. Swift discards anything under 0.02. */ -function dropAlpha(dy: number): number { - const alpha = dy >= 0 ? Math.exp(-dy * 0.8) : Math.exp(dy * 8); + above it, a sharp falloff below. + + `rainAlpha` is the bare curve — this is what the generated keyframes in + `theme.custom.css` sample. `dropAlpha` adds Swift's "discard anything under + 0.02" cutoff, which only applies to the still frame: CSS interpolates + between stops, so clamping the table would put a visible step in the tail + where the native view just fades out. Exported for + `__fork_guards__/sidebarV2Rain.test.ts`, which re-derives the table. */ +export function rainAlpha(dy: number): number { + return dy >= 0 ? Math.exp(-dy * 0.8) : Math.exp(dy * 8); +} + +export function dropAlpha(dy: number): number { + const alpha = rainAlpha(dy); return alpha >= 0.02 ? alpha : 0; } @@ -54,11 +80,14 @@ function dropAlpha(dy: number): number { one shared wall clock, so its working tabs all fall in lockstep; here a sidebar full of working threads would read as a single blinking block, so each row gets its own offset. Hashing the thread key rather than randomizing - keeps a row's phase stable across re-renders and remounts — it must not jump - when the list re-sorts or the row scrolls back into view. Every column moves + is what makes the spread deterministic: the same thread always draws the same + offset, so a re-render cannot reshuffle the row's phase. (A *remount* still + restarts the CSS delay clock, so a row that scrolls out and back re-phases + regardless — the hash guarantees rows differ from each other, not that any + one row is continuous across remount.) Every column moves by the same absolute amount, which preserves their relationship to each other: the row looks exactly like one that started working moments earlier. */ -function rainOffsetSeconds(seed: string): number { +export function rainOffsetSeconds(seed: string): number { let hash = 0x811c9dc5; for (let index = 0; index < seed.length; index++) { hash ^= seed.charCodeAt(index); @@ -78,10 +107,12 @@ function rainOffsetSeconds(seed: string): number { /** One profile per row, spelled out in full because Tailwind only sees class names it can find literally in the source — a template built from the row - index would never get generated. Duration and delay ride in as inline + index would never get generated. (The keyframes themselves are safe either + way: they live in `theme.custom.css`, outside the `@theme` block Tailwind + prunes, so an unreferenced one survives.) Duration and delay ride in as inline styles; keeping the name in a class is what lets `motion-reduce:animate-none` still win, since a class rule cannot override an inline `animation-name`. */ -const RAIN_ANIMATION_CLASS = [ +export const RAIN_ANIMATION_CLASS = [ "animate-[sidebar-v2-rain-0_linear_infinite]", "animate-[sidebar-v2-rain-1_linear_infinite]", "animate-[sidebar-v2-rain-2_linear_infinite]", @@ -161,7 +192,7 @@ export function SidebarV2WorkingRain({ seed }: { seed: string }) { /** The blocked/settled counterpart to the rain: one 8px dot centered in the same 16px box the provider icons use, so the trailing edge of every row lines up whether the mark is a dot, a clock, or the grid. */ -export function SidebarV2StatusDot({ tone }: { tone: SidebarV2StatusTone }) { +export function SidebarV2StatusDot({ tone }: { tone: SidebarV2DotTone }) { return ( diff --git a/apps/web/src/custom/icons/lucide-phosphor.tsx b/apps/web/src/custom/icons/lucide-phosphor.tsx index 95910624c9e..ff4a4077143 100644 --- a/apps/web/src/custom/icons/lucide-phosphor.tsx +++ b/apps/web/src/custom/icons/lucide-phosphor.tsx @@ -14,6 +14,20 @@ * Adding an icon: if an upstream sync introduces a lucide import this module * does not export, the build fails naming that icon. Add a line to the table. * + * Bundle cost, measured on the production build (main chunk, 2026-07-25) by + * building twice with only the two alias entries flipped: + * + * phosphor 3,542,642 raw / 1,050,150 gzip + * lucide 3,564,063 raw / 1,059,636 gzip + * delta -21,421 raw / -9,486 gzip + * + * So the swap is slightly *cheaper*, not more expensive — worth recording, + * because the naive read says otherwise: each Phosphor icon module is a single + * `new Map` carrying all six weights, so the four weights this shim never uses + * cannot be tree-shaken out of an icon that is imported. That overhead is real + * (~341 KB raw across the icons in use) and is simply outweighed by lucide + * shipping more bytes overall. Re-measure if the icon count moves materially. + * * See .fork/customizations.yaml#phosphor-duotone-icons */ import { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f2b29d4591f..8e7fa443e8f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -160,12 +160,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-sidebar-row-hover: var(--sidebar-row-hover); --color-sidebar-row-active: var(--sidebar-row-active); --color-sidebar-row-selected: var(--sidebar-row-selected); + /* fork:begin sidebar-v2-status-palette — see .fork/customizations.yaml#sidebar-v2-card-rows + Utility names only. Tailwind reads `@theme` to decide which `bg-*`/`fill-*` + classes exist, so these six registrations cannot live in the fork's own + stylesheet. The values they resolve to do — see `theme.custom.css`. */ --color-sidebar-row-working: var(--sidebar-row-working); --color-sidebar-v2-status-working: var(--sidebar-v2-status-working); --color-sidebar-v2-status-done: var(--sidebar-v2-status-done); --color-sidebar-v2-status-approval: var(--sidebar-v2-status-approval); --color-sidebar-v2-status-input: var(--sidebar-v2-status-input); --color-sidebar-v2-status-failed: var(--sidebar-v2-status-failed); + /* fork:end sidebar-v2-status-palette */ --color-sidebar-border: var(--sidebar-border); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -209,569 +214,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } - /* Pixel rain: the "working" indicator ported from the phanttom Ghostty fork - (macos/Sources/Features/Terminal/Sidebar/SidebarView.swift, PixelSparkleView). - Four columns of drops fall through a 5-row grid, each column with its own - speed and phase; the drop head is bright with an exponential trail above - it and a sharp falloff below. - - Swift redraws every frame from `head = (t*speed + phase) mod 8 - 1.5` and - `alpha = dy >= 0 ? exp(-0.8*dy) : exp(8*dy)`. Every cell in a column shares - one head, so the same motion decomposes into five fixed alpha profiles (one - per row) driven by one clock per column — no per-frame JS, and the - compositor owns it. GENERATED, do not hand-edit: regenerate from the Swift - constants if PixelSparkleView changes. Verified against the reference at - <=0.046 absolute opacity error away from the loop seam (mean 1.1e-3). */ - @keyframes sidebar-v2-rain-0 { - 0% { - opacity: 0; - } - 4% { - opacity: 0; - } - 7.5% { - opacity: 0.001; - } - 8% { - opacity: 0.001; - } - 8.44% { - opacity: 0.001; - } - 9.37% { - opacity: 0.002; - } - 10.31% { - opacity: 0.005; - } - 11.25% { - opacity: 0.008; - } - 12% { - opacity: 0.013; - } - 12.19% { - opacity: 0.015; - } - 13.12% { - opacity: 0.027; - } - 14.06% { - opacity: 0.05; - } - 15% { - opacity: 0.091; - } - 15.94% { - opacity: 0.166; - } - 16% { - opacity: 0.172; - } - 16.87% { - opacity: 0.3; - } - 17.81% { - opacity: 0.548; - } - 18.75% { - opacity: 1; - } - 19.69% { - opacity: 0.942; - } - 20% { - opacity: 0.923; - } - 20.62% { - opacity: 0.887; - } - 21.56% { - opacity: 0.835; - } - 22.5% { - opacity: 0.787; - } - 24% { - opacity: 0.715; - } - 28% { - opacity: 0.553; - } - 32% { - opacity: 0.428; - } - 36% { - opacity: 0.332; - } - 40% { - opacity: 0.257; - } - 44% { - opacity: 0.199; - } - 48% { - opacity: 0.154; - } - 52% { - opacity: 0.119; - } - 56% { - opacity: 0.092; - } - 60% { - opacity: 0.071; - } - 64% { - opacity: 0.055; - } - 68% { - opacity: 0.043; - } - 72% { - opacity: 0.033; - } - 76% { - opacity: 0.026; - } - 80% { - opacity: 0.02; - } - 84% { - opacity: 0.015; - } - 88% { - opacity: 0.012; - } - 92% { - opacity: 0.009; - } - 96% { - opacity: 0.007; - } - 100% { - opacity: 0.006; - } - } - @keyframes sidebar-v2-rain-1 { - 0% { - opacity: 0; - } - 16% { - opacity: 0; - } - 20% { - opacity: 0.001; - } - 20.94% { - opacity: 0.001; - } - 21.88% { - opacity: 0.002; - } - 22.81% { - opacity: 0.005; - } - 23.75% { - opacity: 0.008; - } - 24% { - opacity: 0.01; - } - 24.69% { - opacity: 0.015; - } - 25.62% { - opacity: 0.027; - } - 26.56% { - opacity: 0.05; - } - 27.5% { - opacity: 0.091; - } - 28% { - opacity: 0.125; - } - 28.44% { - opacity: 0.166; - } - 29.37% { - opacity: 0.3; - } - 30.31% { - opacity: 0.548; - } - 31.25% { - opacity: 1; - } - 32% { - opacity: 0.953; - } - 32.19% { - opacity: 0.942; - } - 33.12% { - opacity: 0.887; - } - 34.06% { - opacity: 0.835; - } - 35% { - opacity: 0.787; - } - 36% { - opacity: 0.738; - } - 40% { - opacity: 0.571; - } - 44% { - opacity: 0.442; - } - 48% { - opacity: 0.342; - } - 52% { - opacity: 0.265; - } - 56% { - opacity: 0.205; - } - 60% { - opacity: 0.159; - } - 64% { - opacity: 0.123; - } - 68% { - opacity: 0.095; - } - 72% { - opacity: 0.074; - } - 76% { - opacity: 0.057; - } - 80% { - opacity: 0.044; - } - 84% { - opacity: 0.034; - } - 88% { - opacity: 0.026; - } - 92% { - opacity: 0.02; - } - 96% { - opacity: 0.016; - } - 100% { - opacity: 0.012; - } - } - @keyframes sidebar-v2-rain-2 { - 0% { - opacity: 0; - } - 28% { - opacity: 0; - } - 32% { - opacity: 0.001; - } - 32.5% { - opacity: 0.001; - } - 33.44% { - opacity: 0.001; - } - 34.38% { - opacity: 0.002; - } - 35.31% { - opacity: 0.005; - } - 36% { - opacity: 0.007; - } - 36.25% { - opacity: 0.008; - } - 37.19% { - opacity: 0.015; - } - 38.12% { - opacity: 0.027; - } - 39.06% { - opacity: 0.05; - } - 40% { - opacity: 0.091; - } - 40.94% { - opacity: 0.166; - } - 41.87% { - opacity: 0.3; - } - 42.81% { - opacity: 0.548; - } - 43.75% { - opacity: 1; - } - 44% { - opacity: 0.984; - } - 44.69% { - opacity: 0.942; - } - 45.62% { - opacity: 0.887; - } - 46.56% { - opacity: 0.835; - } - 47.5% { - opacity: 0.787; - } - 48% { - opacity: 0.762; - } - 52% { - opacity: 0.59; - } - 56% { - opacity: 0.457; - } - 60% { - opacity: 0.353; - } - 64% { - opacity: 0.274; - } - 68% { - opacity: 0.212; - } - 72% { - opacity: 0.164; - } - 76% { - opacity: 0.127; - } - 80% { - opacity: 0.098; - } - 84% { - opacity: 0.076; - } - 88% { - opacity: 0.059; - } - 92% { - opacity: 0.046; - } - 96% { - opacity: 0.035; - } - 100% { - opacity: 0.027; - } - } - @keyframes sidebar-v2-rain-3 { - 0% { - opacity: 0; - } - 44% { - opacity: 0; - } - 45% { - opacity: 0.001; - } - 45.94% { - opacity: 0.001; - } - 46.88% { - opacity: 0.002; - } - 47.81% { - opacity: 0.005; - } - 48% { - opacity: 0.005; - } - 48.75% { - opacity: 0.008; - } - 49.69% { - opacity: 0.015; - } - 50.62% { - opacity: 0.027; - } - 51.56% { - opacity: 0.05; - } - 52% { - opacity: 0.066; - } - 52.5% { - opacity: 0.091; - } - 53.44% { - opacity: 0.166; - } - 54.37% { - opacity: 0.3; - } - 55.31% { - opacity: 0.548; - } - 56% { - opacity: 0.852; - } - 56.25% { - opacity: 1; - } - 57.19% { - opacity: 0.942; - } - 58.13% { - opacity: 0.887; - } - 59.06% { - opacity: 0.835; - } - 60% { - opacity: 0.787; - } - 64% { - opacity: 0.609; - } - 68% { - opacity: 0.471; - } - 72% { - opacity: 0.365; - } - 76% { - opacity: 0.283; - } - 80% { - opacity: 0.219; - } - 84% { - opacity: 0.169; - } - 88% { - opacity: 0.131; - } - 92% { - opacity: 0.101; - } - 96% { - opacity: 0.079; - } - 100% { - opacity: 0.061; - } - } - @keyframes sidebar-v2-rain-4 { - 0% { - opacity: 0; - } - 56% { - opacity: 0; - } - 57.5% { - opacity: 0.001; - } - 58.44% { - opacity: 0.001; - } - 59.38% { - opacity: 0.002; - } - 60% { - opacity: 0.004; - } - 60.31% { - opacity: 0.005; - } - 61.25% { - opacity: 0.008; - } - 62.19% { - opacity: 0.015; - } - 63.12% { - opacity: 0.027; - } - 64% { - opacity: 0.048; - } - 64.06% { - opacity: 0.05; - } - 65% { - opacity: 0.091; - } - 65.94% { - opacity: 0.166; - } - 66.87% { - opacity: 0.3; - } - 67.81% { - opacity: 0.548; - } - 68% { - opacity: 0.619; - } - 68.75% { - opacity: 1; - } - 69.69% { - opacity: 0.942; - } - 70.63% { - opacity: 0.887; - } - 71.56% { - opacity: 0.835; - } - 72% { - opacity: 0.812; - } - 72.5% { - opacity: 0.787; - } - 76% { - opacity: 0.629; - } - 80% { - opacity: 0.487; - } - 84% { - opacity: 0.377; - } - 88% { - opacity: 0.292; - } - 92% { - opacity: 0.226; - } - 96% { - opacity: 0.175; - } - 100% { - opacity: 0.135; - } - } @keyframes sidebar-working-text { 0%, 36% { @@ -1487,15 +929,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-hover: var(--color-zinc-25); --sidebar-row-active: var(--color-white); --sidebar-row-selected: var(--color-white); - --sidebar-row-working: color-mix(in srgb, var(--foreground) 5%, transparent); - /* The design's status hues are tuned for a black panel. On the light panel - the same values are near-invisible at 8px, so each one drops to the - darkest shade that still reads as the same color family. */ - --sidebar-v2-status-working: var(--color-emerald-600); - --sidebar-v2-status-done: var(--color-emerald-600); - --sidebar-v2-status-approval: var(--color-amber-500); - --sidebar-v2-status-input: var(--color-indigo-600); - --sidebar-v2-status-failed: var(--color-red-600); --sidebar-border: var(--color-zinc-200); --sidebar-stage-fade: var(--sidebar); background-color: var(--sidebar); @@ -1520,15 +953,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); - /* Matches the rgba(255,255,255,0.08) fill on the working specimen. It lands - on the same value as --sidebar-row-hover, so working rows hover to - --sidebar-row-active instead — see rowSurfaceClassName in SidebarV2. */ - --sidebar-row-working: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-v2-status-working: #24fe8a; - --sidebar-v2-status-done: #24fe8a; - --sidebar-v2-status-approval: #ffcd59; - --sidebar-v2-status-input: #8b9cff; - --sidebar-v2-status-failed: #ff6b60; --sidebar-border: var(--border); /* The stage-channel header art must ramp to THIS panel's surface, not the global chrome background, or the fade shows a seam (same rule as the diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index e25f9205fcd..f861ed19c98 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -5,9 +5,628 @@ * here win ties against upstream at equal specificity. Keep every rule scoped * under the fork marker attribute (set by `custom/forkMarker.ts`) so nothing * in this file can leak into an unmarked, pure-upstream build. + * + * `@keyframes` are the one exception: they are global by definition and cannot + * be scoped to a selector. The fork-owned ones are prefixed `sidebar-v2-rain-*` + * so the names cannot collide with upstream's. + * + * The Tailwind-facing half of the Sidebar V2 palette — the six + * `--color-sidebar-v2-*` registrations — necessarily stays in `index.css`'s + * `@theme` block, since that is the only place Tailwind reads utility names + * from. It is fenced there as `fork:sidebar-v2-status-palette`. Everything the + * cascade can carry lives here instead. */ -:root[data-fork="noahhendrickson-t3code"] { - /* No visual deviation yet. The first real theme change lands here; larger - structural UI changes go through `src/overrides/` instead. */ +/* Sidebar V2 status palette, keyed to the fork marker so an unmarked build + falls back to upstream's variables. The design's hues are tuned for a black + panel; on the light panel the same values are near-invisible at 8px, so each + one drops to the darkest shade that still reads as the same color family. + + `working` and `done` deliberately share a hue: the mark's *form* is what + separates them (falling pixels vs a static dot), matching the phanttom + Ghostty sidebar this is ported from. See `custom/SidebarV2StatusIndicator`. + + Note this diverges from the mobile app, which still uses sky for working + (`apps/mobile/src/features/threads/thread-list-v2-items.tsx`, + `apps/mobile/src/widgets/AgentActivity.tsx`). Web leads here; mobile has not + been migrated. + + Declared at the root as well as on the panel, mirroring what upstream does + for its own sidebar primitives in `index.css` ("including portaled mobile + sheets and settings navigation outside the app sidebar"). A row rendered + through a portal sits outside `[data-sidebar-version]`, and without the root + copy its dot would resolve to a transparent color. */ +:root[data-fork="noahhendrickson-t3code"], +:root[data-fork="noahhendrickson-t3code"] [data-sidebar-version="v2"] { + --sidebar-row-working: color-mix(in srgb, var(--foreground) 5%, transparent); + --sidebar-v2-status-working: var(--color-emerald-600); + --sidebar-v2-status-done: var(--color-emerald-600); + --sidebar-v2-status-approval: var(--color-amber-500); + --sidebar-v2-status-input: var(--color-indigo-600); + --sidebar-v2-status-failed: var(--color-red-600); +} + +/* `.dark` is toggled on the document element (see `hooks/useTheme.ts`), which + is the same node the fork marker lands on — hence the compound selector + rather than a descendant one. */ +:root[data-fork="noahhendrickson-t3code"].dark, +:root[data-fork="noahhendrickson-t3code"].dark [data-sidebar-version="v2"] { + /* Matches the rgba(255,255,255,0.08) fill on the working specimen. It lands + on the same value as --sidebar-row-hover, so working rows hover to + --sidebar-row-active instead — see rowSurfaceClassName in SidebarV2. */ + --sidebar-row-working: color-mix(in srgb, var(--foreground) 8%, transparent); + --sidebar-v2-status-working: #24fe8a; + --sidebar-v2-status-done: #24fe8a; + --sidebar-v2-status-approval: #ffcd59; + --sidebar-v2-status-input: #8b9cff; + --sidebar-v2-status-failed: #ff6b60; +} + +/* Pixel rain: the "working" indicator ported from the phanttom Ghostty fork + (macos/Sources/Features/Terminal/Sidebar/SidebarView.swift, PixelSparkleView). + Four columns of drops fall through a 5-row grid, each column with its own + speed and phase; the drop head is bright with an exponential trail above + it and a sharp falloff below. + + Swift redraws every frame from `head = (t*speed + phase) mod 8 - 1.5` and + `alpha = dy >= 0 ? exp(-0.8*dy) : exp(8*dy)`. Every cell in a column shares + one head, so the same motion decomposes into five fixed alpha profiles (one + per row) driven by one clock per column — so there is no per-frame JS at all. + (Opacity on an SVG *child* is not promoted to its own compositor layer, so + the SVG still repaints each frame; `content-visibility: auto` on the row is + what keeps offscreen rows from paying for it.) + + GENERATED, do not hand-edit: `__fork_guards__/sidebarV2Rain.test.ts` + re-derives this whole table from the Swift constants and fails if it drifts. + Verified against the reference at <=0.046 absolute opacity error away from + the loop seam (mean 1.1e-3). */ +@keyframes sidebar-v2-rain-0 { + 0% { + opacity: 0; + } + 4% { + opacity: 0; + } + 7.5% { + opacity: 0.001; + } + 8% { + opacity: 0.001; + } + 8.44% { + opacity: 0.001; + } + 9.37% { + opacity: 0.002; + } + 10.31% { + opacity: 0.005; + } + 11.25% { + opacity: 0.008; + } + 12% { + opacity: 0.013; + } + 12.19% { + opacity: 0.015; + } + 13.12% { + opacity: 0.027; + } + 14.06% { + opacity: 0.05; + } + 15% { + opacity: 0.091; + } + 15.94% { + opacity: 0.166; + } + 16% { + opacity: 0.172; + } + 16.87% { + opacity: 0.3; + } + 17.81% { + opacity: 0.548; + } + 18.75% { + opacity: 1; + } + 19.69% { + opacity: 0.942; + } + 20% { + opacity: 0.923; + } + 20.62% { + opacity: 0.887; + } + 21.56% { + opacity: 0.835; + } + 22.5% { + opacity: 0.787; + } + 24% { + opacity: 0.715; + } + 28% { + opacity: 0.553; + } + 32% { + opacity: 0.428; + } + 36% { + opacity: 0.332; + } + 40% { + opacity: 0.257; + } + 44% { + opacity: 0.199; + } + 48% { + opacity: 0.154; + } + 52% { + opacity: 0.119; + } + 56% { + opacity: 0.092; + } + 60% { + opacity: 0.071; + } + 64% { + opacity: 0.055; + } + 68% { + opacity: 0.043; + } + 72% { + opacity: 0.033; + } + 76% { + opacity: 0.026; + } + 80% { + opacity: 0.02; + } + 84% { + opacity: 0.015; + } + 88% { + opacity: 0.012; + } + 92% { + opacity: 0.009; + } + 96% { + opacity: 0.007; + } + 100% { + opacity: 0.006; + } +} +@keyframes sidebar-v2-rain-1 { + 0% { + opacity: 0; + } + 16% { + opacity: 0; + } + 20% { + opacity: 0.001; + } + 20.94% { + opacity: 0.001; + } + 21.88% { + opacity: 0.002; + } + 22.81% { + opacity: 0.005; + } + 23.75% { + opacity: 0.008; + } + 24% { + opacity: 0.01; + } + 24.69% { + opacity: 0.015; + } + 25.62% { + opacity: 0.027; + } + 26.56% { + opacity: 0.05; + } + 27.5% { + opacity: 0.091; + } + 28% { + opacity: 0.125; + } + 28.44% { + opacity: 0.166; + } + 29.37% { + opacity: 0.3; + } + 30.31% { + opacity: 0.548; + } + 31.25% { + opacity: 1; + } + 32% { + opacity: 0.953; + } + 32.19% { + opacity: 0.942; + } + 33.12% { + opacity: 0.887; + } + 34.06% { + opacity: 0.835; + } + 35% { + opacity: 0.787; + } + 36% { + opacity: 0.738; + } + 40% { + opacity: 0.571; + } + 44% { + opacity: 0.442; + } + 48% { + opacity: 0.342; + } + 52% { + opacity: 0.265; + } + 56% { + opacity: 0.205; + } + 60% { + opacity: 0.159; + } + 64% { + opacity: 0.123; + } + 68% { + opacity: 0.095; + } + 72% { + opacity: 0.074; + } + 76% { + opacity: 0.057; + } + 80% { + opacity: 0.044; + } + 84% { + opacity: 0.034; + } + 88% { + opacity: 0.026; + } + 92% { + opacity: 0.02; + } + 96% { + opacity: 0.016; + } + 100% { + opacity: 0.012; + } +} +@keyframes sidebar-v2-rain-2 { + 0% { + opacity: 0; + } + 28% { + opacity: 0; + } + 32% { + opacity: 0.001; + } + 32.5% { + opacity: 0.001; + } + 33.44% { + opacity: 0.001; + } + 34.38% { + opacity: 0.002; + } + 35.31% { + opacity: 0.005; + } + 36% { + opacity: 0.007; + } + 36.25% { + opacity: 0.008; + } + 37.19% { + opacity: 0.015; + } + 38.12% { + opacity: 0.027; + } + 39.06% { + opacity: 0.05; + } + 40% { + opacity: 0.091; + } + 40.94% { + opacity: 0.166; + } + 41.87% { + opacity: 0.3; + } + 42.81% { + opacity: 0.548; + } + 43.75% { + opacity: 1; + } + 44% { + opacity: 0.984; + } + 44.69% { + opacity: 0.942; + } + 45.62% { + opacity: 0.887; + } + 46.56% { + opacity: 0.835; + } + 47.5% { + opacity: 0.787; + } + 48% { + opacity: 0.762; + } + 52% { + opacity: 0.59; + } + 56% { + opacity: 0.457; + } + 60% { + opacity: 0.353; + } + 64% { + opacity: 0.274; + } + 68% { + opacity: 0.212; + } + 72% { + opacity: 0.164; + } + 76% { + opacity: 0.127; + } + 80% { + opacity: 0.098; + } + 84% { + opacity: 0.076; + } + 88% { + opacity: 0.059; + } + 92% { + opacity: 0.046; + } + 96% { + opacity: 0.035; + } + 100% { + opacity: 0.027; + } +} +@keyframes sidebar-v2-rain-3 { + 0% { + opacity: 0; + } + 44% { + opacity: 0; + } + 45% { + opacity: 0.001; + } + 45.94% { + opacity: 0.001; + } + 46.88% { + opacity: 0.002; + } + 47.81% { + opacity: 0.005; + } + 48% { + opacity: 0.005; + } + 48.75% { + opacity: 0.008; + } + 49.69% { + opacity: 0.015; + } + 50.62% { + opacity: 0.027; + } + 51.56% { + opacity: 0.05; + } + 52% { + opacity: 0.066; + } + 52.5% { + opacity: 0.091; + } + 53.44% { + opacity: 0.166; + } + 54.37% { + opacity: 0.3; + } + 55.31% { + opacity: 0.548; + } + 56% { + opacity: 0.852; + } + 56.25% { + opacity: 1; + } + 57.19% { + opacity: 0.942; + } + 58.13% { + opacity: 0.887; + } + 59.06% { + opacity: 0.835; + } + 60% { + opacity: 0.787; + } + 64% { + opacity: 0.609; + } + 68% { + opacity: 0.471; + } + 72% { + opacity: 0.365; + } + 76% { + opacity: 0.283; + } + 80% { + opacity: 0.219; + } + 84% { + opacity: 0.169; + } + 88% { + opacity: 0.131; + } + 92% { + opacity: 0.101; + } + 96% { + opacity: 0.079; + } + 100% { + opacity: 0.061; + } +} +@keyframes sidebar-v2-rain-4 { + 0% { + opacity: 0; + } + 56% { + opacity: 0; + } + 57.5% { + opacity: 0.001; + } + 58.44% { + opacity: 0.001; + } + 59.38% { + opacity: 0.002; + } + 60% { + opacity: 0.004; + } + 60.31% { + opacity: 0.005; + } + 61.25% { + opacity: 0.008; + } + 62.19% { + opacity: 0.015; + } + 63.12% { + opacity: 0.027; + } + 64% { + opacity: 0.048; + } + 64.06% { + opacity: 0.05; + } + 65% { + opacity: 0.091; + } + 65.94% { + opacity: 0.166; + } + 66.87% { + opacity: 0.3; + } + 67.81% { + opacity: 0.548; + } + 68% { + opacity: 0.619; + } + 68.75% { + opacity: 1; + } + 69.69% { + opacity: 0.942; + } + 70.63% { + opacity: 0.887; + } + 71.56% { + opacity: 0.835; + } + 72% { + opacity: 0.812; + } + 72.5% { + opacity: 0.787; + } + 76% { + opacity: 0.629; + } + 80% { + opacity: 0.487; + } + 84% { + opacity: 0.377; + } + 88% { + opacity: 0.292; + } + 92% { + opacity: 0.226; + } + 96% { + opacity: 0.175; + } + 100% { + opacity: 0.135; + } } From 074075f749ac71b11f16bebaabefd9b7bf39f261 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 25 Jul 2026 15:49:39 -0400 Subject: [PATCH 5/6] fork: correct the rain accuracy figure and assert it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS comment claimed <=0.046 max opacity error "away from the loop seam" and the PR body claimed 0.041. Measured directly — linear interpolation of the committed stops against the exact curve, 100k samples — it is 1.08e-3 mean and 0.0339 max, and the worst point is the steep rise into row 0's peak, not the seam. The mean was right; the max was overstated and misattributed. Adds the interpolation check to the guard test so both bounds are enforced. Stop-for-stop fidelity alone would not catch a table that samples the curve correctly but cuts the corner off a peak, which is what actually ships. Co-Authored-By: Claude Opus 5 --- .../src/__fork_guards__/sidebarV2Rain.test.ts | 40 +++++++++++++++++++ apps/web/src/theme.custom.css | 5 ++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/web/src/__fork_guards__/sidebarV2Rain.test.ts b/apps/web/src/__fork_guards__/sidebarV2Rain.test.ts index 59ec6444536..ea53a05c941 100644 --- a/apps/web/src/__fork_guards__/sidebarV2Rain.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2Rain.test.ts @@ -61,6 +61,23 @@ function headOffset(percent: number, row: number): number { return (percent / 100) * RAIN_SPAN - 1.5 - row; } +/** What the browser actually paints between two stops: `linear` timing on an + opacity keyframe is a straight line, so this is the shipped curve. */ +function interpolate(stops: Stop[], percent: number): number { + for (let index = 1; index < stops.length; index++) { + const previous = stops[index - 1]; + const current = stops[index]; + if (previous === undefined || current === undefined) break; + if (percent <= current.percent) { + const span = current.percent - previous.percent; + if (span === 0) return current.opacity; + const t = (percent - previous.percent) / span; + return previous.opacity + (current.opacity - previous.opacity) * t; + } + } + return stops.at(-1)?.opacity ?? 0; +} + describe("fork guard: sidebar-v2 rain keyframes", () => { const css = read("src/theme.custom.css"); @@ -133,6 +150,29 @@ describe("fork guard: sidebar-v2 rain keyframes", () => { expect(rainAlpha(4.9)).toBeGreaterThan(0); }); + it("tracks the reference curve once the browser interpolates between stops", () => { + // Stop-for-stop fidelity is not the whole story: what ships is the linear + // interpolation between them, and a table could sample the curve perfectly + // yet still cut the corner off a peak. This pins the shipped motion. + let sum = 0; + let count = 0; + let max = 0; + for (let row = 0; row < ROWS; row++) { + const stops = keyframeStops(css, row); + for (let step = 0; step <= 4000; step++) { + const percent = (step / 4000) * 100; + const error = Math.abs(interpolate(stops, percent) - rainAlpha(headOffset(percent, row))); + sum += error; + count++; + max = Math.max(max, error); + } + } + // Measured 1.08e-3 mean / 0.0339 max; the worst point is the steep rise + // into row 0's peak. Loosening either bound means the motion changed. + expect(sum / count).toBeLessThan(1.5e-3); + expect(max).toBeLessThan(0.035); + }); + it("keeps the keyframes out of the @theme block Tailwind prunes", () => { // Tailwind v4 drops `@keyframes` declared inside `@theme` unless it finds a // generated `animation` declaration naming them. Living in the fork's own diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index f861ed19c98..74301d378c6 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -78,8 +78,9 @@ GENERATED, do not hand-edit: `__fork_guards__/sidebarV2Rain.test.ts` re-derives this whole table from the Swift constants and fails if it drifts. - Verified against the reference at <=0.046 absolute opacity error away from - the loop seam (mean 1.1e-3). */ + Interpolating these stops tracks the reference curve to 1.08e-3 mean and + 0.034 max absolute opacity (100k samples); the worst point is the steep rise + into row 0's peak, not the loop seam. The guard test asserts both bounds. */ @keyframes sidebar-v2-rain-0 { 0% { opacity: 0; From 92a5382dc441f72b3016cccdc8a5a059133ddbd3 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 25 Jul 2026 16:05:41 -0400 Subject: [PATCH 6/6] fork: record the dot-hue limit as accepted, not pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-mark comment framed shape differentiation as an open follow-up. It isn't scheduled: the four settled dots differ by hue alone by decision, not by oversight. Keeps the limitation documented — done/failed is the pair that collapses first — and leaves the approach for whoever does pick it up, without claiming work that is planned. Co-Authored-By: Claude Opus 5 --- apps/web/src/custom/SidebarV2StatusIndicator.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/custom/SidebarV2StatusIndicator.tsx b/apps/web/src/custom/SidebarV2StatusIndicator.tsx index a3affe27e81..8589fbedfdd 100644 --- a/apps/web/src/custom/SidebarV2StatusIndicator.tsx +++ b/apps/web/src/custom/SidebarV2StatusIndicator.tsx @@ -15,12 +15,15 @@ import { cn } from "~/lib/utils"; branch name, and the row already says what it is through color plus the duration readout. - Known limit (WCAG 1.4.1): form only separates rain / dot / clock, so the four - settled states — done, approval, input, failed — are one 8px dot apart from - each other and differ by hue alone. Screen readers get the `role="status"` - label SidebarV2 renders alongside the mark; color-blind sighted users - currently do not. Differentiating the dot shapes (a ring for approval, a - hollow dot for input) is the open follow-up. */ + Known limit, accepted (WCAG 1.4.1): form only separates rain / dot / clock, + so the four settled states — done, approval, input, failed — are one 8px dot + apart and differ by hue alone. Screen readers get the `role="status"` label + SidebarV2 renders alongside the mark; sighted users with a color vision + deficiency do not, and done/failed is the pair that collapses first. If that + ever needs fixing, vary the dot's fill rather than its shape (ring for + approval, hollow for input) — it keeps the all-circles vocabulary this is + ported from, and `SidebarV2WokeMark` below is precedent for breaking dot + uniformity when a state genuinely needs it. */ export type SidebarV2StatusTone = "working" | "done" | "approval" | "input" | "failed"; /** The tones a *dot* can carry. `working` is excluded by construction: a working