feat(web): give each project its own colour - #5085
Conversation
Projects are told apart in the sidebar by favicon and name alone. Neither scales: icons do not resolve for every repo, and a list of similarly named projects reads as one wall of text. Each project now carries an accent colour, applied to its name on sidebar rows and pickable in project settings — ten swatches, any hue behind Custom, Auto to go back to the derived default. The colour is a stored OKLCH hue angle rather than a finished colour, so lightness and chroma can be pinned per theme at the render site: every project reads at the same weight and one stored value stays legible in both themes. The default is derived from the project's workspace key, so a project has a stable colour before anyone configures anything, and the same checkout lands on the same colour on every machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces a new feature (per-project color customization) with substantial new UI components and state management. Additionally, there are two unresolved medium-severity comments identifying potential bugs in the drag interaction logic that should be addressed. You can customize Macroscope's approvability policy. Learn more. |
# Conflicts: # apps/web/src/components/SidebarV2.tsx
| onPointerUp={(event) => { | ||
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | ||
| const nextHue = hueFromEvent(event); | ||
| if (nextHue !== null) onCommit(nextHue); | ||
| event.currentTarget.releasePointerCapture(event.pointerId); | ||
| } | ||
| }} |
There was a problem hiding this comment.
🟡 Medium components/ProjectColorPicker.tsx:120
If the browser cancels an active pointer drag on the hue strip (e.g. app switch, hardware interruption), pointercancel fires but HueStrip has no handler for it, so onCommit is never called. The preview onChange calls during the drag already mutated ProjectColorPicker's draftHue, but props.hue never changes, so the useEffect that resyncs draftHue does not rerun. Closing and reopening the Custom popover then shows the uncommitted preview hue instead of the project's actual hue. Add an onPointerCancel handler that either commits the final preview via onCommit or restores draftHue to props.hue.
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
const nextHue = hueFromEvent(event);
if (nextHue !== null) onCommit(nextHue);
event.currentTarget.releasePointerCapture(event.pointerId);
}
}}
+ onPointerCancel={(event) => {
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ onChange(clampHue(props.hue));
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ }}🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ProjectColorPicker.tsx around lines 120-126:
If the browser cancels an active pointer drag on the hue strip (e.g. app switch, hardware interruption), `pointercancel` fires but `HueStrip` has no handler for it, so `onCommit` is never called. The preview `onChange` calls during the drag already mutated `ProjectColorPicker`'s `draftHue`, but `props.hue` never changes, so the `useEffect` that resyncs `draftHue` does not rerun. Closing and reopening the Custom popover then shows the uncommitted preview hue instead of the project's actual hue. Add an `onPointerCancel` handler that either commits the final preview via `onCommit` or restores `draftHue` to `props.hue`.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 422d3b5. Configure here.
| if (nextHue !== null) onCommit(nextHue); | ||
| event.currentTarget.releasePointerCapture(event.pointerId); | ||
| } | ||
| }} |
There was a problem hiding this comment.
Aborted drag leaves draft unsaved
Medium Severity
The HueStrip component lacks an onPointerCancel handler. If a drag is interrupted, the draftHue updates for preview but onCommit isn't called. This leaves draftHue with an unsaved value, which can reappear when the custom color picker is reopened, even though the project's color hasn't changed.
Reviewed by Cursor Bugbot for commit 422d3b5. Configure here.


Closes #1376
What Changed
Each project gets an accent colour, shown on its name in Sidebar V2 rows and pickable from the existing project settings dialog: ten swatches, any hue behind Custom, Auto to drop back to the derived default.
Why
Projects are told apart in the sidebar by favicon and name. Neither scales — icons don't resolve for every repo (#4304, #1020), and a list of similarly-named projects reads as one wall of text. #1376 asks for this directly, comparing it to colouring terminal tabs, and a second person there offered to build it.
How this differs from #2381
#2381 built this feature and was closed as stale. The design here is deliberately smaller, so this is not that PR rebased:
sidebarProjectColorssetting to turn it onsidebarProjectColorOverrides+ the enable flagsidebarProjectColorOverridesonlyThe collision-avoidance is the part I most wanted to avoid inheriting: it makes a project's colour depend on which other projects exist, so adding one can restyle its neighbours, and the assignment has to be recomputed and kept somewhere. Deriving each colour from its own key independently gives up guaranteed adjacent-distinctness and gets back statelessness — no write on project create, no repair path, no reshuffling. With ten hues a couple of neighbours will occasionally match; that's what the picker is for.
Dropping the on/off setting is the other deliberate difference. A colour that has to be enabled is a colour most people never see, and the derived default means there is no unconfigured state worth hiding.
Design notes
A hue, not a colour.
sidebarProjectColorOverridesstores an OKLCH hue angle (0–359), not hex. Lightness and chroma are fixed per theme at the render site (oklch(0.52 0.12 h)light,oklch(0.78 0.11 h)dark), which buys two things a hex triple can't: every project reads at the same visual weight, so no project's label shouts over another's or over the thread title; and one stored value stays legible in both themes, so there's no second value to keep in sync and no way to pick "dark red on a dark sidebar". The hue travels to CSS as a custom property so the theme pair can live in the class list.Keyed by workspace path. Overrides use
derivePhysicalProjectKey— the same identitysidebarProjectGroupingOverridesalready uses. Renaming a project keeps its colour, and the same checkout added on two machines lands on the same colour.Additive schema. A new optional record with a decoding default of
{}. Existing settings blobs decode unchanged; no migration.Absent means auto. Clearing an override deletes the key rather than writing a sentinel, so "never touched" and "reset to auto" are one state that can't drift apart.
Verification
tsc --noEmitclean;vp lintandvp format --checkclean.apps/webprojectColor.test.ts— 13 new tests: palette size/uniqueness, minimum hue separation between neighbouring swatches, default stability and spread across sequential keys, override precedence, custom hues outside the palette, and hue0surviving as a real override rather than being swallowed as falsy.packages/contractssettings.test.ts— 31 pass unchanged.Scope
Sidebar V2 rows and the project settings dialog only. Not touched: Sidebar V1, mobile, the project filter menu, and the favicon fallback glyph — each is a reasonable next surface, but they're separable and this is easier to judge on its own. Happy to extend or trim to taste.
Note
Low Risk
UI and additive client-settings only; no auth or server paths. Wrong override keys would only mis-colour labels.
Overview
Adds per-project accent colours in Sidebar V2: project names in thread rows tint via OKLCH (
--project-hue), and the project settings dialog includes a Colour row with ten swatches, a Custom hue strip (pointer preview on drag, commit on release), and Auto to clear overrides.Overrides live in new client setting
sidebarProjectColorOverrides, keyed by the same physical project key as grouping overrides. With no override, hue is derived on read from an FNV-1a hash of that key into a ten-entry palette—no enable flag and no collision-avoidance across neighbours.Contracts add
ProjectColorHue(0–359) and the optional override record with default{}. Tests cover palette/resolveProjectHueandHueStrippointer behaviour.Reviewed by Cursor Bugbot for commit 422d3b5. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add per-project color overrides to the sidebar
ProjectColorPickercomponent with a palette of 10 hues and a custom hue strip (HueStrip) supporting drag, keyboard, and numeric input.projectColor.tswithdefaultProjectColor(FNV-1a hash → palette entry),resolveProjectHue(override or default), and CSS utility classes using OKLCH via a--project-huecustom property.ClientSettingsSchemaandClientSettingsPatchin settings.ts withsidebarProjectColorOverrides, aRecord<string, ProjectColorHue>persisted in client settings.📊 Macroscope summarized 422d3b5. 1 file reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.