feat(web): Cool Dark theme, closed folder marks, and composer polish - #42
Conversation
Add Cool Dark as a selectable appearance option, show FolderClosed for collapsed project groups, keep Search/scope chrome at 14px, and open the workspace menu above the branch pill so glass does not overlap the chip row. Co-authored-by: Cursor <cursoragent@cursor.com>
|
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. |
There was a problem hiding this comment.
Thermo-nuclear code quality review — REQUEST CHANGES
Cool Dark works as a product feature, but the implementation introduces the fork’s first override as a near-full copy of a hot module for ~20 lines of real delta. That fails the approval bar: clear structural regression + an available code-judo that deletes the ownership cost.
Sidebar closed-folder marks and composer chip polish look fine and local. They are not the blocker.
Blockers
- Full
useThemeshadow — owns the entire theme store forever for a widened union. - Missed judo: Cool Dark is a palette, not a
ThemePreference— CSS already keys offdata-fork-themewhile requiring.dark. Stuffingcool-darkintot3code:themeforces Schema widening, desktop remapping, Settings tilde-import hack, and the override.
Preferred fix
Keep upstream theme as light | dark | system. Persist palette separately (e.g. t3code:fork-theme = cool-dark | null) in custom/. Appearance can still show four options: Cool Dark → setTheme("dark") + set palette; other choices clear palette. Delete overrides/hooks/useTheme.ts. Rewrite guards around the palette key + attribute.
If a short-term bridge is required, a few fenced hunks in upstream hooks/useTheme.ts are still cheaper than owning 346 lines — but the separate-palette model is the real simplification.
Approval bar: not met while the full override remains and the palette judo is available.
Sent by Cursor Automation: Thermo-nuclear PR review
ReviewRead the full diff against That said, this isn't landable as-is. 1. Blocking: CI is red, and the failure is in this diffThe
Narrow fix, in export function applyForkThemeAttribute(root: ForkThemeTarget, theme: ForkThemePreference): void {
if (theme === COOL_DARK_THEME) {
root.setAttribute(FORK_THEME_ATTRIBUTE, COOL_DARK_THEME);
return;
}
// Every non-cool theme takes this path, including in tests that stub
// documentElement with only what upstream's applyTheme touched.
root.removeAttribute?.(FORK_THEME_ATTRIBUTE);
}with The interesting part is why the guard didn't catch this. 2. Blocking: this is three PRsA new theme (~500 lines, a shadowed hook, an Split into 3. Design: Cool Dark writes a value upstream doesn't understand into upstream's key
Store the preference in a fork-owned key instead, keeping
4.
|
Keep upstream ThemePreference as light|dark|system and persist Cool Dark in t3code:fork-theme. Deletes the full useTheme shadow that broke unit tests and violated fork override policy. Co-authored-by: Noah Hendrickson <NoahHendrickson@users.noreply.github.com>
Review — round 2 (
|
| Site | Predicate |
|---|---|
index.html bootstrap |
forkPalette === "cool-dark" && isDark — where isDark includes system + prefers-color-scheme: dark |
forkTheme.ts module-load re-stamp |
palette !== null — no dark check at all |
resolveAppearanceOption |
palette === "cool-dark" && theme === "dark" |
Any state with the palette key present and theme !== "dark" makes them disagree — e.g. the tab-A state above, or a writeForkPalette(null) whose removeItem throws (both storage helpers swallow failures silently, so a failed clear survives reload). In that state the app paints Cool Dark, the Appearance select reads "System", and the reset button is hidden because it keys off appearance !== "system". There is then no way to clear it from the UI — re-selecting "System" in the Select won't fire onValueChange for an unchanged value. CLAUDE.md: "Reverse states … A one-way door is a bug."
The guard currently blesses this — expect(resolveAppearanceOption("system", "cool-dark")).toBe("system") codifies the divergence rather than flagging it. Pick one predicate, export it, and use it from all three sites (the bootstrap can't import, but it can mirror one clearly-named expression). Either "the palette applies whenever the document is dark" or "the palette applies only when the stored theme is dark" is defensible — having all three is not.
4. getSnapshot writes to localStorage
getPaletteSnapshot → readForkPalette() → migrateLegacyCoolDarkTheme() → two localStorage.setItem calls. useSyncExternalStore requires getSnapshot to be pure and cheap; React calls it during render and may call it more than once per render. This does a getItem on every render minimum, and in the legacy case performs storage writes from the render phase.
The migration is a boot-time concern. Run it once — the index.html bootstrap already does exactly this migration, so the module-load block is the only other place that needs it — and drop it from the read path.
5. Dark ↔ Cool Dark skips the transition suppression every other theme switch gets
Upstream's setTheme calls applyTheme(next, true), which adds .no-transitions (index.css:858), forces a reflow, and clears it on the next frame — so a theme switch repaints instantly instead of animating every transitioned property in the tree.
setAppearance("cool-dark") from Dark stamps the attribute first (repaint happens there, unsuppressed), then calls setTheme("dark") — which early-returns on lastAppliedTheme, so .no-transitions is never applied. Same on the way back. The result is that Dark↔Cool Dark is the one theme switch in the app that animates a full-tree color transition, which is precisely the "no continuously repainting animations / GPU spike" case CLAUDE.md calls out.
applyForkPalette should do the same add-reflow-rAF-remove dance around its stamp.
6. Manifest no longer watches a file the fork depends on
Dropping shadows: apps/web/src/hooks/useTheme.ts is correct — nothing shadows it now. But forkTheme.ts imports syncBrowserChromeTheme from it, and that file is now absent from files:, shadows:, and watch:, so detect-drift.mjs will stay silent when an upstream sync touches it. TypeScript catches outright removal, so this is about review-time signal on sync PRs rather than silent breakage — but it's exactly what watch: exists for. Add it.
7. Smaller
COOL_DARK_LABELis now used (good), butisCoolDarkPaletteis exported and called only from inside the module, andUPSTREAM_THEME_STORAGE_KEYis guard-only. Trim the surface.- Round 1's unguarded
index.htmlbody-background literal is unchanged:expect(indexHtml).toContain(COOL_DARK_BACKGROUND)is still satisfied by the JS const alone, sohtml.dark[data-fork-theme="cool-dark"] body { background: #1c1e20 }can still drift silently. - The migration is one-way by design, which is right — but nothing tests it.
migrateLegacyCoolDarkThemeis the only code path in this PR that rewrites a value a user already has stored, and it's the piece with no coverage at all. Take the storage object as a parameter and it's testable in three lines, in the same node environment as the rest of the guard. - Reverting
SettingsPanelsto the relative../../hooks/useThemeimport resolves round 1's type-leak note. Good.
Generated by Claude Code
The PR pill shared a nested gap-1 with the branch trigger while the strip used 8px to the checkout chip, so #PR sat too close to the branch. Match both to 8px. Co-authored-by: Cursor <cursoragent@cursor.com>


Summary
useThemeoverride, CSS tokens, pre-paint bootstrap, and fork guards — Dark stays unchangedTest plan
vp test run apps/web/src/__fork_guards__/forkCoolDarkTheme.test.ts apps/web/src/__fork_guards__/forkComposerShell.test.ts apps/web/src/__fork_guards__/forkSidebarChrome.test.ts apps/web/src/__fork_guards__/sidebarV2ProjectGrouping.test.tsMade with Cursor