Skip to content

feat(fork): redesign the sidebar, surfaces and workspace header - #14

Merged
NoahHendrickson merged 3 commits into
customfrom
fork/sidebar-v2-redesign
Jul 26, 2026
Merged

feat(fork): redesign the sidebar, surfaces and workspace header#14
NoahHendrickson merged 3 commits into
customfrom
fork/sidebar-v2-redesign

Conversation

@NoahHendrickson

Copy link
Copy Markdown
Owner

Implements the Figma redesign (Frame 51) across five areas. Dark mode only — light keeps upstream's zinc palette untouched.

Surfaces

The dark shell moves off pure black: workspace stage #212121, sidebar v2 panel #1e1e1e. The panel sits below the stage rather than lifting off it — the panel is chrome, the stage is the work.

Values are flat opaque sRGB rather than alphas: they're only correct against #1e1e1e, and alpha compounds through hover and selection.

Two selectors are required, not one. Upstream re-declares --background / --card / --border and the whole --sidebar-* family on the panel element itself, so a rule written only against :root is inert on exactly the surface this is about.

Upstream's --surface-grain is dropped in dark. Measured, its 0.035 white noise carried the panel from #1e1e1e to ~#212121 — the stage's colour — erasing the three-level separation the palette is built on. The workspace kept its value only because chat content paints over its copy opaquely; nothing paints over the sidebar's. The opacity that would hold spec is ~0.008, at which point there's no texture left to preserve.

Thread rows

Two lines become three at 8px radius: title, repo, and a meta line whose halves sit in fixed corners (PR + diff left, model + runtime right).

  • Surface encodes interaction only — filled on hover or selection, never because of status. An earlier revision lit working rows at rest; with several threads running the panel became a field of lit rectangles and the hover cue stopped meaning anything.
  • Working and Idle titles recede at rest and return on hover, per the component set. The rule is whether there's anything here to act on.
  • Idle gains a hollow ring so the trailing slot is never empty. It used to fall back to a relative-time string, so the column alternated between a 16px mark and a variable-width label and nothing below it could align.
  • Diff counts bind to --success-foreground / --destructive-foreground and light up once upstream's latestTurnDiff() stub carries data.

Sidebar chrome

Collapse toggle moves inline beside the traffic lights; brand moves to the trailing edge.

The toggle now leaves with the panel, so the floating control still renders while the sidebar is shut — exactly the case COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS already reserves space for — and the sidebar.toggle keybinding stays mounted above both, or a collapsed panel would have no way back.

The header always carries artwork now — green for release, orange for dev, from the designer's PNGs. Upstream treats header art as a channel indicator; here it's brand chrome. The Dev cue survives: the composer send button and auth screen still gate on the build channel and needed no fork code. Nightly keeps its night sky.

Workspace header

56px, project and title swap weight, favicon dropped, and the three action controls become fully-rounded split pills.

They were already built from the right primitives (Group + outline Button size="xs" + separator + caret), so the only edit in those three files is a data-fork-pill attribute; all styling lives in the fork stylesheet.

Selected as button rather than [data-slot="button"] — Base UI's render prop overwrites the slot with tooltip-trigger / menu-trigger, so a slot-based selector matches nothing and fails silently.

Identity

The fork is "no3y Code" everywhere, renamed from "N3 Code" — including the rendered app. branding.ts's bridge-less fallback pointed at upstream's "T3 Code", so dev and hosted sessions were branded as upstream while the packaged app wasn't.

Safe by construction: the bundle id and ~/.t3-fork are both deliberately held stable across renames, so nothing is orphaned.

⚠️ An installed N3 Code.app is not replaced by a build installing as no3y Code.app — an upgrader keeps both bundles until the old one is deleted by hand.

Fork bookkeeping

Four new manifest entries (fork-surface-palette, fork-sidebar-chrome, fork-workspace-header, plus a rewritten sidebar-v2-card-rows), each with a guard test. Three upstream tests were carried through where they pinned behaviour this deliberately changes:

  • branding.test.ts — the bridge-less display name. One of its three cases injects a bridge and exercises a path the fork doesn't touch, so it correctly still expects "T3 Code".
  • SidebarStageBackdrop.test.tsx — asserted the Dev art emits unique SVG <defs> ids; a raster has none. Only the "is this exercising anything" precondition narrows to Nightly, so reverting Dev to SVG art re-arms it.

Verification

  • Full repo suite green (188 web test files, 1642 assertions; all other packages pass), lint, typecheck and format check clean.
  • Built CSS grepped for the literal surface values; the doubled panel selector survives minification.
  • Driven in a live dev environment with seeded threads: surfaces eyedropped to exactly #212121 / #1e1e1e / #2d2e2e / #2a2a2a; hover and selection confirmed; v1 verified still on upstream's #000; light mode verified unchanged; collapsed sidebar verified still reachable.
  • detect-drift.mjs resolves every entry against its watch list.

Known follow-ups

  • The green artwork is a screenshot (300×117, ~30% flat colour) and softens on retina. The orange is much better (667×369, 76%). A higher-resolution green re-export is a file swap, no code change.
  • White brand text is low-contrast on the artwork — 1.93:1 on the orange base, 3.52:1 on the green, against 4.5:1 for 12px. Left as drawn in the mockup pending a call on a dark ink for the orange.
  • The PR badge keeps upstream's state colouring (emerald/purple/red) rather than the design's muted grey, so open/merged/closed stays readable at a glance.
  • Toggling Sidebar V2 off leaves upstream's #000 panel against the new #212121 stage. The stage is global chrome with no sidebar-version attribute to key off.
  • The Figma layers call these "tabs"; in this codebase they're threads (SidebarV2Row). "Tabs" here means RightPanelTabs.

🤖 Generated with Claude Code

Implements the Figma redesign (t3-fork, Frame 51 / node 53-1690) across five
areas. Dark mode only; light keeps upstream's zinc palette untouched.

Surfaces. The dark shell moves off pure black: the workspace stage is #212121
and the sidebar v2 panel #1e1e1e, so the panel sits below the stage rather than
lifting off it. Values are flat opaque sRGB, not alphas, because they are only
correct against #1e1e1e and alpha compounds through hover and selection. Two
selectors are required, not one: upstream re-declares --background, --card,
--border and the --sidebar-* family on the panel element itself, so a rule
written only against :root is inert on exactly the surface this is about.

Upstream's 0.035 surface grain is dropped in dark. Measured, it carried the
panel from #1e1e1e to ~#212121 -- the stage's colour -- erasing the separation
the whole palette is built on, because the chat content paints over the
workspace copy of the grain but nothing paints over the sidebar's.

Thread rows. Two lines become three, at 8px radius: title, repo, and a meta
line whose halves sit in fixed corners (PR + diff left, model + runtime right).
Surface now encodes interaction only -- a row is filled on hover or selection,
never because of its status. Working and Idle titles recede at rest and return
on hover, matching the component set: the rule is whether there is anything
here to act on. Idle gains a hollow ring so the trailing slot is never empty;
it used to fall back to a relative-time string, which left nothing below it
able to align. Diff counts bind to semantic tokens and render once upstream's
latestTurnDiff() stub carries data.

Sidebar chrome. The collapse toggle moves inline beside the traffic lights and
the brand moves to the trailing edge. The toggle leaves with the panel, so the
floating control still renders while the sidebar is shut -- the case
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS already reserves space for -- and the
keybinding stays mounted above both. The header always carries artwork now:
green for a release build, orange for dev, both painted from the designer's
PNGs. Upstream's other two art surfaces keep gating on the build channel, so
the Dev cue survives outside the sidebar. Nightly is untouched.

Workspace header. 56px, project and title swap weight, favicon dropped, and the
three action controls become fully-rounded split pills. They were already built
from the right primitives, so the only edit in those files is a data-fork-pill
attribute; the styling lives in the fork stylesheet. Selected as `button` rather
than [data-slot="button"] -- Base UI's render prop overwrites the slot, so a
slot-based selector matches nothing and fails silently.

Identity. The fork is "no3y Code" everywhere, renamed from "N3 Code", including
the rendered app: branding.ts's bridge-less fallback pointed at upstream's
"T3 Code", so dev and hosted sessions were branded as upstream while the
packaged app was not. Safe by construction -- the bundle id and ~/.t3-fork are
both held stable across renames. An installed N3 Code.app is not replaced by a
build installing as no3y Code.app.

Four new manifest entries with a guard test each, and three upstream tests
carried through where they pinned behaviour this deliberately changes.
@cursor

cursor Bot commented Jul 26, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Jul 26, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear code quality — changes requested

The redesign's fork-owned visual system (custom/*, theme.custom.css, pill attribute + CSS) is the right direction, and SidebarV2ThreadCardMeta / idle mark / header backdrop are clean seams. This still does not meet the maintainability bar: Tier-4 surgery keeps landing in SidebarV2.tsx (2741 lines) and other upstream hot files with zero overrides/ (shadows: [] on every new manifest entry), and most of the SidebarV2 presentation rewrite sits outside fences.

APPROVE: no.

Findings (priority order)

  1. Structural — no overrides/ while doing Tier-4 surgery in hot upstream modules. Fork order is custom/overrides/ → theme → last-resort fenced inline. New art/meta modules correctly live in custom/, then the PR rewrites SidebarChrome, AppSidebarLayout's toggle lifetime, and a large SidebarV2 region with fences + watch lists only. Shadow SidebarChrome (135-line leaf) and extract chrome/card shells so sync cost matches the placement rules.

  2. Missed code-judo — ~150-line fenced chrome rewrite still inside SidebarV2. Search/project rows are pure presentation. Extract to custom/ so the fence collapses to a call site — the same judo already used for meta, not applied here.

  3. Missed code-judo — card-row extraction stopped halfway. Surface policy, title mute, and the trailing status/actions grid remain in SidebarV2Row. Finish a fork-owned card shell so sidebar-v2-card-rows watches a call site, not an unfenced presentation rewrite in a megacomponent.

  4. Spaghetti — two parallel "recede" policies. shouldRecede and the title-mute predicate (working || topStatus === null) encode overlapping but different rules. Collapse to one policy helper.

  5. File-size / decomposition. SidebarV2.tsx stays at 2741 lines with in-place rewrite. Does not newly cross 1k, but refuses an available shrink that would materially improve maintainability.

Keep the pill-via-data-fork-pill pattern — that is the right Tier-4 minimalism. Do more of that shape for chrome and card presentation.

Open in Web View Automation 

Sent by Cursor Automation: Thermo-nuclear PR review

<SidebarContent className="gap-0">
<SidebarGroup className="px-2 pb-2 pt-3">
<div className="flex items-center gap-1">
{/* fork:begin fork-sidebar-chrome — see .fork/customizations.yaml#fork-sidebar-chrome

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Structural / missed code-judo (blocker). This ~150-line fenced chrome rewrite is the clearest missed simplification. Search/project rows are pure presentation (heights, icons, classNames) — extract them to custom/ (one call site) or own the region via a small override.

Leaving class/icon essays inside a 2741-line hot file is a structural regression even with guards. The PR already proved this judo with SidebarV2ThreadCardMeta; apply it here too.

) : null}
</span>
</div>
<SidebarV2ThreadCardMeta

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed decomposition (blocker). Meta extraction is good and incomplete. Surface policy, title mute, and the trailing status/actions grid above still live largely unfenced in SidebarV2Row.

Finish the card shell extraction (fork-owned title + trailing slot, or a full card presentational component) so sidebar-v2-card-rows watches a call site — not a presentation rewrite inside a high-churn megacomponent.

Comment thread apps/web/src/components/SidebarV2.tsx Outdated
// hold it bright for the same reason.
"truncate",
status === "ready" && shouldRecede ? "text-muted-foreground" : "text-foreground",
(status === "working" || topStatus === null) && !props.isActive && !isSelected

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spaghetti branching (blocker). This is a second "recede" predicate beside shouldRecede (~L447). Idle (topStatus === null) and working get special title muting with different unread/woke rules than the surface policy.

Collapse to one pure helper (e.g. resolveCardTitleTone(...)) used by surface + title, and delete the duplicate boolean algebra. Also drop the stale 65%/45% "two-specimen" lore still attached to shouldRecedeSidebarV2ThreadCardMeta already collapsed that model.

(composer send button, auth screen) still gate on the variant and are
untouched, so they keep signalling Dev. */}
<ForkSidebarHeaderBackdrop stageLabel={stageLabel} />
{/* fork:begin fork-sidebar-chrome — see .fork/customizations.yaml#fork-sidebar-chrome

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong placement tier (blocker). This is an ideal overrides/components/sidebar/SidebarChrome.tsx candidate: 135-line leaf, header mostly rewritten.

Prefer shadowing this file over Tier-4 fencing + watching it. Fork rules put overrides/ ahead of last-resort inline edits; shadows: [] here is the structural smell.

return () => window.removeEventListener("keydown", onKeyDown, true);
}, [keybindings, toggleSidebar]);

if (isSidebarVisible) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acceptable fenced seam — do not treat as license for more megacomponent surgery. The early-return so sidebar.toggle stays mounted is correctness-critical and small.

Keep this as the rare justified Tier-4 behavior change if chrome ownership moves out of inline SidebarV2 / SidebarChrome edits (via custom/ extract or override).

Comment thread .fork/customizations.yaml
- apps/web/src/custom/assets/sidebar-stage-dither-dev.png
- apps/web/src/custom/assets/sidebar-stage-dither.png
- apps/web/src/theme.custom.css
shadows: []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture boundary (blocker). shadows: [] on fork-sidebar-chrome / sidebar-v2-card-rows / fork-workspace-header while watching large upstream modules (SidebarV2.tsx, SidebarChrome.tsx, 2k-line pill hosts) inverts the fork placement ladder.

Prefer non-empty shadows for SidebarChrome (and extracted leaves) so sync cost matches .fork/AGENTS.md. Guards that string-match sources are not a substitute for ownership boundaries.

Copy link
Copy Markdown
Owner Author

Review: sidebar / surfaces / workspace header redesign

Reviewed at 0228904 against custom. The reasoning in the comments and the manifest is genuinely unusual for a UI PR — the --surface-grain measurement, the [data-slot]-vs-button note about Base UI's render prop, and the "surface encodes interaction only" reversal are all arguments I'd have had to make in review and didn't have to. What follows is where I think it's wrong, and one place where I think the safety net doesn't hold.


1. contain-intrinsic-size is 4px short, and the guard pins the wrong number

apps/web/src/components/SidebarV2.tsx:855

className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_86px]"

86px is the card height. The li that carries the intrinsic size also carries py-0.5, so the real laid-out height is 90px.

Upstream's value proves the convention: old card was py-[15px] ×2 + 18 + gap-2 + 16 = 72, and the li said 76 — 72 + 4. The new card is 24 + 18 + 7 + 15 + 7 + 15 = 86, so the li should say 90.

Under-declaring the placeholder is the direction that actually hurts: every skipped row is 4px short, so scrollbar length and scroll position drift as rows enter and leave the viewport — the exact jitter contain-intrinsic-size exists to prevent, at ~4px per offscreen row.

The PR body reproduces the error ("24 padding + 48 rows + 2x7 lands on the drawn 86px" — that's the card, not the li), and so does the guard:

// sidebarV2CardRows.test.ts
// ... three lines are 86px, not the old two-line 76.
expect(sidebarV2).toContain("[contain-intrinsic-size:auto_86px]");

The old value 76 was already 72 + 4, so that comment is comparing a card height against an li height. Worth fixing both, or the guard cements it.

2. GitActionsControl's non-repo branch never got data-fork-pill

apps/web/src/components/GitActionsControl.tsx:1657

Only the Group branch was marked. The !isRepo branch — the variant="outline" size="xs" "Initialize Git" button — is untouched, so in any project without a git repo the header shows one raised rounded-md button sitting next to fully-rounded flat pills.

ProjectScriptsControl handled exactly this case and says so:

Both branches carry it — the split Group when a primary script exists, and the lone "Add action" button when one does not.

The CSS is already built for it: :is(button[data-fork-pill], …) is the lone-button arm, and the first-child/last-child split rounds both ends. It's a one-attribute fix.

The guard can't see this — expect(readSibling(path)).toContain("data-fork-pill") passes on the file because the other branch has it.

3. role="status" per idle row

apps/web/src/components/SidebarV2.tsx:927

<span role="status" className="sr-only">Idle</span>

role="status" is aria-live="polite". This mints one live region per idle thread — dozens in a normal panel — and any list re-sort, filter change, or working→idle transition churns them. Live regions are for a small number of announcement sinks, not per-item labels.

<span className="sr-only">Idle</span> gives the same screen-reader text with none of the live-region behaviour. Better still, put it on SidebarV2IdleMark as an aria-label and drop the extra node.

4. macOS traffic lights are now off-centre against the bar the toggle sits in

apps/desktop/src/window/DesktopWindow.ts:191trafficLightPosition: { x: 16, y: 18 }, unchanged.

The lights are ~12px, so their centre is y≈24. Against the old 52px bar that's 2px high of centre; against the new 56px bar it's 4px. This PR is the one that makes it matter — it deliberately parks the collapse toggle inline beside those lights, vertically centred in the 56px header, so the misalignment is now between two things the eye reads as a pair.

The verification was done in a browser dev environment, which can't show this. DesktopWindow.ts also isn't in any entry's watch: list, so nothing connects the height token to the native constant. Worth eyeballing on a packaged build before merge; y: 20 is probably the answer.

5. --surface-grain: none removes the grain from the whole dark app, not just chrome

apps/web/src/theme.custom.css:112 sets it on :root[data-fork=…].dark. Upstream declares it on bare :root (index.css:1046) and body { background-image: var(--surface-grain) } (index.css:1051) reads it directly — so the body texture goes too, everywhere in dark, alongside every surface-grain utility site.

That may well be what you want, and the comment gestures at it ("an unspecified amount of drift on every surface"), but both the code comment and the manifest intent argue the case entirely from the sidebar panel. Since this is the single widest-reaching line in the PR, I'd state the blast radius explicitly in the intent, and have the guard assert it rather than just toContain("--surface-grain: none").


6. The pill restyle reaches across a component boundary with structural selectors

The whole treatment hangs on:

:is(button[data-fork-pill], [data-fork-pill] > button:first-child)
[data-fork-pill] > button:last-child:not(:first-child)
[data-fork-pill] > [data-slot="separator"]

> plus :first-child/:last-child on children the fork doesn't own. If Base UI ever wraps a trigger in a span, or Group gains a sentinel node, or a third action lands in the middle of one of these groups, the pills silently revert to square-ish outline buttons. No type error, no test failure — the same silent-failure class the whole .fork apparatus is built to eliminate, reintroduced in the styling layer.

.fork/README.md §Tier 3 names this exact case:

The ui/ primitives (44 shadcn-style files, mostly cva variant tables) are the highest-leverage target in the codebase. Shadowing button.tsx … propagates through every consuming component without touching a single consumer.

A variant="pill" on Button (or a pill prop on Group) is Tier 2/3, type-checked, corner logic expressed once in a cva table instead of four :first-child rules, and immune to DOM restructuring. The call sites then read variant="pill" instead of data-fork-pill, which is also a better hint to the next person. The data-fork-pill route is defensible as the smaller diff — but the PR presents it as the cheap option, and it's cheap now and fragile later.

7. SidebarStageBackdrop is dead, and its wrapper is hand-copied

After this PR, SidebarStageBackdrop (SidebarStageBackdrop.tsx:39) has zero call sites — AppSidebarLayout and SidebarChrome were its only two. Meanwhile custom/SidebarHeaderBackdrop.tsx duplicates its markup verbatim:

"sidebar-stage-backdrop pointer-events-none absolute inset-x-0 top-0 z-0 h-20 select-none overflow-hidden"

That string is load-bearing — the fork's :has(.stage-dither) override, the mask, and the ::after ramp all key off it — and it now exists in two places with no link between them. Render upstream's SidebarStageBackdrop with the art passed in (or take its children), so the wrapper stays one definition. Drift-watching flags the file but won't tell you the copy diverged.


8. The bigger concern: the four new guards test source text, not behaviour

This is the part I'd push hardest on, because it's the part the fork's whole model rests on.

.fork/README.md §4b:

Write one guard per manifest entry, asserting the outcome, not the implementation
This is the highest-leverage single thing in this whole document.

All four new guards are readFileSync + toContain/regex over source files. Specifically:

  • forkSurfacePalette.test.ts is 100% grep against theme.custom.css — a Tier-1, fork-owned file that upstream never touches. The failure mode the header comment describes (a clean rebase evaporating the hunk) cannot happen to that file. What it can't catch is the failure that can: upstream adding a more specific selector, a @layer ordering change, the stylesheet dropping out of the import graph, or a panel-scoped declaration the fork's doubled selector doesn't reach. The test asserts that a file the fork wrote still contains strings the fork wrote.
  • sidebarV2CardRows.test.ts imports both components and then asserts typeof X === "function". Nothing is rendered. expect(sidebarV2).toContain("insertions=") would also pass if the prop were handed to an entirely different component.
  • forkSidebarChrome.test.ts pins a literal implementation lineexpect(layout).toContain("if (isSidebarVisible) return null;") plus an indexOf ordering check. It fails on a harmless { return null; } reformat and passes if someone inverts the condition.

The tooling for better is already here and already in use. 14 test files in apps/web/src render with renderToStaticMarkup under the same node environment — including SidebarStageBackdrop.test.tsx, which this PR edits, and __fork_guards__/phosphorIcons.test.ts, which is one of your own guards. So:

  • SidebarV2ThreadCardMeta → render it and assert three lines, the two corners, text-success-foreground / text-destructive-foreground actually on the diff spans, and that the card is 86px worth of fixed-height rows.
  • SidebarV2IdleMark → render it and assert the ring, rather than typeof.
  • resolveForkSidebarHeaderArt → already a pure function; it's the one thing here tested properly, and worth extending to "Alpha""release" explicitly.
  • SidebarControl → the invariant is "a collapsed sidebar is still reopenable." Render it at both visibility states and assert a trigger exists in exactly one of them. That's the outcome; the if statement is the implementation.

The CSS entries are the genuinely hard case, and grep may be the honest ceiling in a node environment — but then say so in the file, and keep the grep narrow (does the doubled selector exist, is nothing declared outside .dark) rather than restating every hex the stylesheet already contains.


9. Two follow-ups I'd not defer

  • White brand text at 1.93:1 on the orange art. That's below 3:1, which is the floor for large text; this is 12px. And because the sidebar header now always carries artwork, this is permanent chrome on a release build, not a dev-only cue — the "left as drawn in the mockup" argument was stronger when the art was a channel indicator. A dark ink on the orange, or a subtle scrim, before merge.
  • bg-cover on a 300×117 source in a resizable panel. The retina note undersells it: the sidebar is user-resizable, so at a widened panel this is a >2× upscale of a screenshot-quality source. Since the higher-resolution green re-export is described as a pure file swap, I'd rather do the swap than ship the known-soft one.

Nits

  • CirclePlusIcon (custom/icons/lucide-phosphor.tsx:227) is exported with zero call sites.
  • SquareHalf / SquareHalfBottom are inserted before SidebarSimple in the import block, breaking the alphabetical ordering the rest of the list keeps.

What I checked and found fine

  • --surface-grain: none does reach the utility — @utility surface-grain reads var(--surface-grain) rather than redeclaring it locally, and :root[data-fork].dark outranks the bare :root declaration. The override is live.
  • No dangling imports from the SidebarV2.tsx extraction — ChevronDownIcon, FolderIcon, ProjectFavicon, ProviderInstanceIcon, ServerIcon, threadTimeLabel all still have live call sites; SquarePenIcon / FolderPlusIcon are gone cleanly. activeProjectCwd survives in ChatHeader via the scripts hook.
  • --success-foreground / --destructive-foreground resolve to emerald-700/red-700 in light and emerald-400/red-400 in dark, so the claim holds — and the dark values are byte-identical to the dark:text-emerald-400 literals they replace, i.e. this is a pure light-mode fix, which matches how it's described.
  • Manifest bookkeeping is right, and it's the part most likely to be got wrong. detect-drift.mjs only scans shadows: + watch:, and every inline-edited upstream file in all four entries is under watch:, not files:. PanelRightIcon / PanelBottomIcon have exactly one call site each, so the shim remap has no blast radius.
  • The toggle's pl-[var(--workspace-controls-left)] does clear the traffic lights horizontally — AppSidebarLayout overrides that variable to 90px on non-fullscreen macOS desktop, so the comment about inheriting the floating button's inset is accurate.

Blocking, in order: 1, 2, 3. 8 is the one I'd most like a reply on.


Generated by Claude Code

Addresses review findings 2, 3, 4 and 5 by pushing presentation up the
placement ladder (README §3: "push every customization as far up this list as
it will go"), so the Tier-4 surface in upstream's file collapses to call sites.

The search and project-scope rows become custom/SidebarV2ChromeRows.tsx. They
were ~150 lines of pure presentation fenced in place, which meant SidebarV2
carried the whole rewrite while the manifest could only watch the file it sat
in. The scope row is generic over the project type so callbacks hand back
upstream's own richer object while the fork declares only the four fields it
reads.

Row presentation policy becomes custom/sidebarV2RowPolicy.ts: the surface
model, the card title colour, and the predicate behind it. These are pure
decisions about how a row should look given what it is, with no React and no
upstream state.

That predicate and upstream's `shouldRecede` are now named and documented
rather than sitting inline as two anonymous overlapping tests. They are
deliberately not merged: `shouldRecede` describes a slim row in the settled
shelf, dims the whole row including its surface, and folds in read/woke state;
the card rule touches the title only, never the surface, and ignores read state.
They agree on read-ready threads and diverge everywhere else, so collapsing them
into one predicate would silently change both.

SidebarV2.tsx drops from 2741 to 2597 lines. Three new guards pin the
extraction: the chrome rows must stay fork-owned, the policy must stay out of
the megacomponent, and the recede rule is asserted as behaviour across the
component set's matrix rather than as a class string.

No behaviour change -- verified live: surfaces, row heights, title colours at
rest and on hover, and the project menu all render identically.
@NoahHendrickson

Copy link
Copy Markdown
Owner Author

Addressed findings 2, 3, 4 and 5. Pushed in 342e3f2.

What changed

#2 — chrome rows extracted. The search and project-scope rows move to custom/SidebarV2ChromeRows.tsx. The fence in SidebarV2.tsx is now two call sites. The scope row is generic over the project type, so callbacks hand back upstream's own richer object while the fork declares only the four fields it actually reads — no restating or importing a type it doesn't use.

#3 — card policy extracted. Surface model, title colour and the predicate behind it move to custom/sidebarV2RowPolicy.ts as pure functions: no React, no upstream state, no DOM.

#4 — the two recede rules are now named and documented. They are deliberately not merged, and I'd push back on collapsing them:

  • shouldRecede (upstream's) describes a slim row in the settled shelf. It dims the whole row including its surface, and folds in read/woke state.
  • threadCardTitleRecedes (fork) describes a card whose subject line has nothing waiting on you. It touches the title only, never the surface, and ignores read state entirely.

They agree on read-ready threads and diverge everywhere else, so one predicate would silently change both. The duplication was that they were two anonymous inline tests; naming them is the fix.

#5SidebarV2.tsx: 2741 → 2597 lines, from #2 and #3.

Three new guards pin it: the chrome rows must stay fork-owned, the policy must stay out of the megacomponent, and the recede rule is asserted as behaviour across the component set's matrix (Working and Idle recede; Approval/Input/Done/Failed don't; active or selected never does) rather than as a class string.

On #1overrides/

Not taking this one, because it points down the ladder rather than up. README §3 says "push every customization as far up this list as it will go", and ranks Tier 1 (custom/) above Tier 2 (overrides/). It's also explicit about the cost:

The honest trade-off: a shadowed file is a hard fork of that file. You stop receiving upstream improvements to it.

SidebarChrome.tsx is a small leaf, so it's a cheap shadow — but it also holds the update pills, the settings nav and the stage backdrop wiring, all of which currently track upstream for free. Shadowing it trades that away to remove three fenced hunks.

Extracting to custom/ gets the same shrink in upstream's file without the hard fork: upstream keeps owning the composition, the fork owns the presentation, and the fence is a call site either way. That's what findings #2 and #3 asked for, and it's the tier the README prefers. shadows: [] staying empty is the intended outcome here, not a gap.

Agreed on data-fork-pill being the right Tier-4 shape — that's the pattern #2 and #3 now follow.

Verification

Full repo suite green (188 web test files, 1643 assertions; all other packages pass), lint, typecheck and format check clean. Verified live that the extraction is behaviour-neutral: panel rgb(30,30,30), stage rgb(33,33,33), both control rows 36px, cards 86px, title colours identical at rest and on hover, project menu opens with the same items.

@NoahHendrickson NoahHendrickson left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: aggressive pass, verified against 342e3f2

Verdict: approve with minor cleanups. Read the full diff, then verified the riskiest claims against the actual PR head rather than taking the description's word for it. No correctness bugs that would block merging; a handful of small, real issues below. Most of the traps I went hunting for turned out to be already handled.

Adversarial checks that came back clean

  • The :has(.stage-dither) hard-edge override can't leak. Since DevBlueprintArt now emits dither, I checked whether the composer send button or auth screen would pick up the forced height / mask removal. They can't: AuthSurfaceShell.tsx:22-25 and ComposerPrimaryActions.tsx:221-224 both render the art in their own wrappers without the sidebar-stage-backdrop class.
  • Electron drag region. The toggle now sits inside the drag-region header on desktop — but index.css:1067-1072 gives button/a inside drag regions no-drag, so the toggle and the brand link stay clickable.
  • Mobile isn't stranded. if (isSidebarVisible) return null gates the floating control on useSidebarVisibility(), which is mobile-aware (sidebar.tsx:89-92 returns openMobile on mobile), so a closed mobile sheet still gets the floating trigger. The keybinding effect runs before the early return — correct hook order, and the guard test pins it.
  • StageBackdropArt is a real upstream export (SidebarStageBackdrop.tsx:50), so the fork's import is sound.
  • The 86px arithmetic checks out (24 padding + 18 + 15 + 15 rows + 2×7 gap), and it's applied only to the card li; the slim shelf keeps its own 34px intrinsic size.
  • threadTimeLabel is not dead — still used by slim rows (SidebarV2.tsx:790); only the card's idle slot dropped it.
  • COLS = COLUMNS.length is a good hardening — a hand-kept count was a real silent-crop hazard.
  • Rename consistency — release workflow, build script, protocol handler names, and every touched test agree on "no3y Code"; the bridge-injecting branding.test.ts case correctly still expects "T3 Code".

Findings (ranked)

  1. Five dead icon imports in SidebarV2.tsx (lines 21, 25, 26, 30, 32). ChevronsUpDownIcon, FolderIcon, FolderOpenIcon, PlusCircleIcon, and SearchIcon each appear exactly once at PR head — the import line. Leftovers from extracting the chrome rows into SidebarV2ChromeRows.tsx, which imports its own copies. Harmless at runtime, but it's exactly the kind of drift the fence discipline exists to avoid — and it shows the repo's lint doesn't catch unused imports.

  2. Inert truncate on the meta line's right half (SidebarV2ThreadCardMeta.tsx:83). The model-label span sits inside a shrink-0 flex item, so it can never be squeezed and its truncate never fires. A long model label will crowd out the PR/diff half (which can shrink) and can overflow the card. Latent today since real labels are short — either drop shrink-0 and add min-w-0, or cap the label with a max-w.

  3. Stale focus-ring styling on the inline toggle (SidebarChrome.tsx:65). focus-visible:ring-offset-blue-700 was tuned for the blue blueprint art this PR deletes; the header is now green or orange dither on every build, so keyboard focus draws a blue halo on orange artwork. The comment in the same block fixed the stroke-*text-* half but carried this one over.

  4. One role="status" live region per idle row (SidebarV2.tsx:892,915). Every idle card mounts its own polite live region saying "Idle" — dozens of live regions on a long list, and each thread transitioning to idle can trigger an announcement. If announcement-on-settle is intended, fine; if the goal was just an accessible label for the aria-hidden ring, a plain sr-only span (no role) does that without the announcement machinery.

  5. Two guard-test checks are weaker than they read.

    • forkWorkspaceHeader.test.ts ("styles the pills only under the fork marker") checks that some marker occurrence exists earlier in the file than each [data-fork-pill] line — any rule below the first marker block passes, scoped or not.
    • forkSurfacePalette.test.ts ("leaves light mode on upstream's palette") only matches blocks whose selector is exactly the bare marker; a fork hex leaked into :root[marker] .some-descendant { } outside .dark wouldn't be caught.
      Both are fine as tripwires; just don't treat them as proofs.
  6. Nits. The SquareHalf/SquareHalfBottom entries in lucide-phosphor.tsx broke the import list's alphabetical order (they landed between Sidebar and SidebarSimple). And upstream's SidebarStageBackdrop wrapper now has zero production call sites — only its test renders it; consistent with keeping upstream code intact, but worth a one-line comment so a future sync doesn't "clean it up".

Known-risk items already flagged in the description

Agreed with the calls on the low-res green raster, the v1-sidebar-on-new-stage mismatch, and the dual-bundle upgrade caveat. The 1.93:1 brand-text contrast on orange is the only user-facing one I'd prioritize — that's genuinely hard to read at 12px, not just non-compliant.

Security

Nothing concerning: no new inputs, no injection surface, assets are bundled PNGs, and the rename deliberately keeps the bundle id and ~/.t3-fork stable so no state is orphaned or adopted across identities.

Net: items 1–3 are worth fixing before merge (all five-minute changes); 4–6 are judgment calls.

🤖 Generated with Claude Code

Review follow-ups. All six findings verified against the working tree before
being acted on; every one was accurate.

Five dead icon imports in SidebarV2.tsx -- ChevronsUpDownIcon, FolderIcon,
FolderOpenIcon, PlusCircleIcon and SearchIcon each appeared exactly once, on
their own import line. Left behind when the chrome rows moved to
SidebarV2ChromeRows.tsx, which imports its own. Worth noting the repo's lint
does not flag unused imports, so nothing else would have caught them.

The meta line's right half could never truncate: the model label sat inside a
shrink-0 flex item, so a long name would have pushed the PR and diff counts --
the half that can shrink -- off the row rather than clipping itself. Now
min-w-0 with a max-w-[50%] cap so neither side can starve the other.

The inline sidebar toggle still carried focus-visible:ring-offset-blue-700,
tuned for the blue blueprint art this branch deletes. On green or orange dither
that drew a blue halo on keyboard focus.

Idle rows no longer each mount a polite live region. Idle is a resting state
rather than an event, so a long list mounted dozens of role="status" nodes and
announced on every settle; a plain sr-only span still names the aria-hidden
ring, which is all that was wanted.

Two guard assertions read stronger than they were, and both are now real:

  - The pill-scoping check asked whether the fork marker appeared anywhere
    earlier in the file, so any rule below the first marker block passed
    whether scoped or not. It now reads each rule's own selector.
  - The light-mode leak check only matched blocks whose selector was exactly
    the bare marker, missing a hex leaked into a marker-rooted descendant.
    It now checks every marker-rooted block outside .dark.

Both go through a new __fork_guards__/cssRules.ts, because a regex over
"selector { body }" mis-parses this stylesheet specifically: it holds
@Keyframes, whose nested braces swallow the wrong span, and comments
containing braces. Verified by injecting each violation and confirming the
guard fails naming the offending selector, then reverting.

Nits: the Phosphor import list is alphabetical again (SquareHalf and
SquareHalfBottom had landed between Sidebar and SidebarSimple), and upstream's
SidebarStageBackdrop wrapper -- now with no production call site, since the
header renders the fork's own -- carries a note so a future sync does not
delete it as dead code.
@NoahHendrickson

Copy link
Copy Markdown
Owner Author

Thanks — all six were accurate. I verified each against the working tree before touching it rather than taking the review's word for it, and every one reproduced. Pushed in 1868c49.

# Finding Fix
1 Five dead icon imports Removed
2 Inert truncate on the meta right half min-w-0 + max-w-[50%]
3 Stale ring-offset-blue-700 ring-offset-transparent
4 role="status" per idle row Plain sr-only span
5 Two weak guard assertions Rewritten, and proven to fail
6 Import order + orphaned wrapper Reordered + note added

#1 — worth flagging that the repo's lint doesn't catch unused imports, so nothing else would have found these. They were exactly the extraction drift you'd expect.

#2 — you were right that it's latent rather than broken today. The failure mode is worse than the label clipping: shrink-0 meant the other half (PR + diff), which can shrink, would get pushed off the row instead. Now min-w-0 with a max-w-[50%] cap so neither side can starve the other.

#4 — the label was only ever meant to name the aria-hidden ring, so no announcement was intended. Dropped the role on the idle span only; the genuine status changes above it keep theirs, since those are events.

#5 — the important one. You said "fine as tripwires; just don't treat them as proofs", and that was fair, so I made them proofs and then checked:

  • Pill scoping now reads each rule's own selector instead of asking whether a marker appears earlier in the file.
  • The light-mode check now covers every marker-rooted block outside .dark, not just the bare-marker one.

Both go through a new __fork_guards__/cssRules.ts. A regex over selector { body } mis-parses this stylesheet specifically — it holds @keyframes whose nested braces swallow the wrong span, plus comments containing braces — so it strips comments and walks the braces, returning leaf rules only. My first attempt at these was itself buggy (a lone } inside (^|}), invalid under the u flag), which rather made your point.

I then injected each violation and confirmed the guard fails naming the offender:

AssertionError: unscoped data-fork-pill rule: [data-fork-pill] > button:hover
AssertionError: #1e1e1e declared outside a .dark scope in:
  :root[data-fork="noahhendrickson-t3code"] .some-descendant

Reverted after; theme.custom.css is unchanged in this commit.

On the contrast item — agreed it's the one user-facing issue, and I'd rather not guess. 1.93:1 on the orange base at 12px; #2a1e06 would give 8.45:1. The green is 3.52:1 and matches your mockup, so my instinct is to change the orange only and leave green as drawn. Say the word and it's a one-line change, or I'll leave it if you'd rather keep them consistent.

Full repo suite green (188 web test files, 105 fork guards), lint, typecheck and format check clean.

@NoahHendrickson

Copy link
Copy Markdown
Owner Author

Closing out the contrast item: won't fix. The 1.93:1 case is the orange artwork, which only renders on a dev build — so the audience is one person who already knows. The release build uses the green, which stays as drawn in the mockup.

No code change; recording it here so it does not read as an unaddressed accessibility issue on a later pass.

@NoahHendrickson
NoahHendrickson merged commit 15d34bf into custom Jul 26, 2026
10 checks passed
@NoahHendrickson
NoahHendrickson deleted the fork/sidebar-v2-redesign branch July 26, 2026 23:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant