Repository navigation
feat(web): warm Neutral Dark palette with flat popups - #222
Conversation
Neutral Dark moves from true-neutral grays to a warm family keyed off the sidebar #34322f, with the chat column a darker shade (#2c2a27) and every lift (rows, borders, composer, chips) re-derived in the same hue so the surface steps still read. Popups drop the shared dark frost for a flat #403e3b, and unchecked checkboxes become a flat white hairline. Glass and the other palettes are untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NoahHendrickson
left a comment
There was a problem hiding this comment.
Code-quality review of d2d63eeaa6d543ec778fb4514bf7b984a65df52a.
The palette retune itself is fine. It swaps hexes in the existing two-block pattern, and the pre-paint, NEUTRAL_DARK_BACKGROUND, manifest and guard all move together. The problem is how the popup override is built, and it's the one thing I'd hold the PR on.
1. The popup override copies the frost rule's selector list a third time. (Blocker)
The five-arm popup selector (menu-popup / menu-sub-content / popover-popup / tooltip-popup / dialog-popup, plus the select child, the combobox wrapper and the thread-details card) already exists in theme.custom.css:2869 (frost) and in the Glass block of this file (~L850). This PR adds a third verbatim copy just to change two declarations. The next popup primitive that gets an arm in the frost rule will silently stay frosted on Neutral Dark. The guard can't catch that either, because it only checks that a rule containing .dropdown-glass says backdrop-filter: none.
The frost rule already routes its fill through --fork-popup-fill. Route the other two values the same way. In the base :root[...].dark block:
--fork-popup-border: color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
--fork-popup-filter: blur(28px) saturate(1.4);The frost rule then reads border-color: var(--fork-popup-border); backdrop-filter: var(--fork-popup-filter);. Neutral Dark's whole change becomes three declarations in its existing stage block:
--fork-popup-fill: var(--popover);
--fork-popup-border: var(--border);
--fork-popup-filter: none;This removes the extra :root block (it repeats the stage block's selector only so it can hold one variable), the 20-line rule, and the specificity argument in its comment. The guard turns into three toContain checks on the stage body, the same way the other tokens are pinned. Glass could later move onto the same knobs, but that's out of scope here.
2. The checkbox rule's scope doesn't match its stated reason.
The comment says upstream's bevel looked wrong "on the flat popups above", but the selector restyles every unchecked checkbox in the palette: settings, dialogs, the sidebar. Either scope it to the popup surfaces or rewrite the reason to cover the whole app. :not([data-checked], [aria-invalid="true"]) also catches indeterminate boxes. Base UI emits data-unchecked, so [data-unchecked]:not([aria-invalid="true"]) states the actual condition. It's also the only part of this customization with no guard assertion.
3. Docs left stale by the retune.
- The Neutral Darker header (L362) still says "Same true-neutral family as Neutral Dark", which this PR makes false. It also says "slightly darker than Neutral Dark's #2c2a27", but 0x1a vs 0x2c is not slight. The PR edited the hexes in that sentence and left the claims around them.
- The frost section in
theme.custom.css(~L2843) still lists Glass as the only palette that drops the filter. With #1 the frost comment can just name the three knobs, and palettes override them without that section needing to list them.
Minor. The light-mode leak list leaves out two new hexes, #54524e and #5c5a57. theme.custom.palettes.css was already 1129 lines before this PR and is 1172 after. That's not a new 1k crossing, but #1 would win back about 20 of those lines.
Not approving: #1 is a copy of a selector list where a fix that deletes code is available.
| ) { | ||
| border-color: var(--border); | ||
| -webkit-backdrop-filter: none; | ||
| backdrop-filter: none; |
There was a problem hiding this comment.
This is a third verbatim copy of the frost rule's arm list (theme.custom.css:2869, plus the Glass block below), made only to change border-color and backdrop-filter. Can the frost rule read --fork-popup-border / --fork-popup-filter the way it already reads --fork-popup-fill? Then this whole rule, and the separate :root block above that holds one variable, become three declarations in the stage block, and a new popup arm can't silently stay frosted here.
There was a problem hiding this comment.
Fixed in 3e24dfc. The frost rule now reads --fork-popup-border and --fork-popup-filter next to --fork-popup-fill. Neutral Dark sets all three in its existing stage block. The extra :root block and the restated arm list are gone, so a new arm in the frost rule reaches Neutral Dark automatically. The forkPopupSurface, forkUsagePopover and forkLocalDictation guards matched the frost rule by its literal blur(Npx), so they now look for the token instead.
| as a raised bevel on the flat popups above. Checked and invalid boxes keep | ||
| upstream's look. */ | ||
| :root[data-fork="noahhendrickson-t3code"].dark[data-fork-theme="neutral-dark"] | ||
| [data-slot="checkbox"]:not([data-checked], [aria-invalid="true"]) { |
There was a problem hiding this comment.
The reason given is "on the flat popups above", but this selector reaches every unchecked checkbox in the palette. Should it be scoped to the popups, or should the comment say this is app-wide? Also, [data-unchecked]:not([aria-invalid="true"]) says what's meant; the current :not([data-checked], …) also catches indeterminate boxes.
There was a problem hiding this comment.
Fixed in 3e24dfc. It stays palette-wide on purpose: the request was "the checkbox should just be a flat border", not "the checkbox in popups". The comment now says it covers popups and settings alike. The selector is [data-unchecked]:not([aria-invalid="true"]). I checked Base UI 1.5 (useStateAttributesMapping): it emits data-unchecked only when the box is not indeterminate, so indeterminate boxes now keep upstream's look. The guard pins both rules.
| /* --------------------------------------------------------------------------- | ||
| Neutral Darker — see `.fork/customizations.yaml#fork-neutral-darker-theme`. | ||
|
|
||
| Same true-neutral family as Neutral Dark, pushed down: chat stage #1a1a1a |
There was a problem hiding this comment.
This now contradicts the retune: Neutral Dark is warm, not "true-neutral", and #1a1a1a vs #2c2a27 is no longer "slightly" darker.
There was a problem hiding this comment.
Fixed in 3e24dfc. The header and the manifest intent now call Neutral Darker "a true-neutral counterpart to Neutral Dark, pushed down", and its stage is "well below" Neutral Dark's, not "slightly darker".
| expect(indexHtml).toContain("fork:begin fork-neutral-dark-theme"); | ||
| }); | ||
|
|
||
| it("opens popups flat on --popover instead of the shared dark frost", () => { |
There was a problem hiding this comment.
This finds a rule containing .dropdown-glass, so it won't notice when the frost rule gains an arm this copy is missing. If the frost rule becomes token-driven, this test is just toContain("--fork-popup-filter: none") on the stage body.
There was a problem hiding this comment.
Fixed in 3e24dfc. The guard now checks the three tokens on the stage body. It also fails if any Neutral Dark rule restates a .dropdown-glass selector.
NoahHendrickson
left a comment
There was a problem hiding this comment.
Reviewed commit d2d63ee against base 5538b0a. One finding below.
Validation: the focused Neutral Dark, Neutral Darker, and popup-surface guards passed (3 files, 30 tests). Review covered the web theme and its desktop reuse; native mobile is outside this CSS change. No browser or visual verification was performed. No changes pushed and no approval submitted.
Model: GPT-6.1 Sol via the Codex harness in T3 Code.
| [data-slot="popover-popup"], | ||
| [data-slot="tooltip-popup"], | ||
| [data-slot="dialog-popup"] | ||
| ).dropdown-glass, |
There was a problem hiding this comment.
[P2] Include standard dialogs in the flat popup styling
The dialog arm requires .dropdown-glass, but ordinary DialogPopup and AlertDialogPopup use .dialog-glass through DIALOG_POPUP_CLASS in components/ui/dialog-styles.ts (alerts also have data-slot="alert-dialog-popup"). As a result, opening dialogs such as Add a CLIProxyAPI hub or the switch-branch confirmation in Neutral Dark still uses the translucent --background fill and backdrop blur from the dialog-glass utility; only special dialogs such as Usage that explicitly add .dropdown-glass become flat. To deliver the stated flat-dialog behavior, include the standard dialog surfaces and set their opaque --popover background as well as their border and filter, while preserving the media-dialog variant.
There was a problem hiding this comment.
Leaving this as is. The requested change was dropdown menus, and the standard dialog-glass dialogs keep upstream's recipe on every non-Glass palette. The PR description overstated it by listing "dialogs", and I've corrected that. Only .dropdown-glass dialogs (Usage) go flat. Whether standard dialogs should also go flat on Neutral Dark is a design decision for the maintainer, so I'm not changing them in this PR.
NoahHendrickson
left a comment
There was a problem hiding this comment.
Code review of d2d63eeaa6d543ec778fb4514bf7b984a65df52a.
I found no correctness bugs. The scoping holds up:
- Popup override. The rule is
:root[data-fork].dark[data-fork-theme="neutral-dark"] :is(…), specificity (0,6,0), which beats the shared frost rule at (0,5,0). It is keyed toneutral-darkonly, so Glass (whose own popup and panel-toggle rules intheme.custom.glass.cssare allcool-darker-scoped), Westworld, Neutral Darker and default Dark keep their frost. - Popup fill.
--fork-popup-fill: var(--popover)is declared on the root, so it resolves to the stage--popover(#403e3b) wherever it is consumed. That includes the Questions card (fork-pending-user-input), which now goes flat too. This is consistent with the comment. - Checkbox. The rule excludes
[data-checked]and[aria-invalid="true"], which matches Tailwind'saria-invalid:variant. Upstream draws focus withoutline, notbox-shadow, sobox-shadow: noneleaves the focus ring alone. The "focus unchanged" claim holds. Indeterminate boxes also match:not([data-checked])and go flat. Upstream already treats them as unchecked for the fill, so this is fine. - Pre-paint.
index.html(both the script map and thebodyrule) andNEUTRAL_DARK_BACKGROUNDagree on#2c2a27.
Tests: vp test run on forkNeutralDarkTheme, forkNeutralDarkerTheme, forkComposerShell, forkSurfacePalette and custom/forkTheme: 5 files, 68/68 passed.
Two minor notes inline: a possible contrast drop for muted text in popups, and Neutral Darker docs that still call Neutral Dark true-neutral.
Reviewed by Claude Opus 5.5 via Claude Code in T3 Code.
| --card: #383633; | ||
| --card-foreground: #e8e8e8; | ||
| --popover: #2e2e2e; | ||
| --popover: #403e3b; |
There was a problem hiding this comment.
Low: muted text in popups drops below WCAG AA. --muted-foreground: #9a9a9a on the new flat --popover: #403e3b is about 3.79:1. The old frost over #1f1f1f worked out to roughly #373737, which gave about 4.2:1. The stage is fine at 5.09:1 and the card is at 4.28:1. Menu descriptions, select hints and shortcut labels use muted text, and the PR's "text is unchanged" means they now sit on a lighter fill. If that's not intended, raise --muted-foreground slightly for this palette (around #a6a6a6 gets back above 4.5:1 on #403e3b), or step --popover down toward --card.
There was a problem hiding this comment.
Confirmed 3.79:1 for #9a9a9a on #403e3b. The proposed fix doesn't clear AA, though: #a6a6a6 on #403e3b is 4.38:1. The menu shade was picked by hand this session, and the old frost was also below AA (about 4.2:1), so this isn't a new class of problem. I'm not changing text color unasked and have flagged the trade-off to the maintainer.
| /* --------------------------------------------------------------------------- | ||
| Neutral Darker — see `.fork/customizations.yaml#fork-neutral-darker-theme`. | ||
|
|
||
| Same true-neutral family as Neutral Dark, pushed down: chat stage #1a1a1a |
There was a problem hiding this comment.
Nit: stale wording. This block now quotes Neutral Dark's warm hexes, but it still opens with "Same true-neutral family as Neutral Dark". Neutral Dark is no longer true-neutral. The fork-neutral-darker-theme manifest intent has the same problem: "a deeper sibling of Neutral Dark … True-neutral grays only" now reads as a contradiction. Something like "a true-neutral counterpart to Neutral Dark, pushed down" would keep both accurate.
There was a problem hiding this comment.
Fixed in 3e24dfc, in both the palettes header and the fork-neutral-darker-theme manifest intent.
The shared dark frost now reads its hairline and filter from --fork-popup-border / --fork-popup-filter beside --fork-popup-fill, so Neutral Dark goes flat with three declarations in its stage block instead of restating the frost rule's arm list. The checkbox rule keys on data-unchecked and says it is palette-wide, the guard pins it, and the Neutral Darker docs stop calling Neutral Dark true-neutral. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
Neutral Dark was a set of cool, true-neutral grays. It now takes a warm tone from a new sidebar color,
#34322f. Its popups also used the shared dark frost, which read as Glass.Palette. The sidebar is
#34322fand the chat column is a darker shade of it (#2c2a27). Every lift is re-derived in the same hue, so the surface steps still read against the lighter sidebar: rows#42403d/#44423f, borders#494744, card#383633, composer vessel#3c3a37, promptrgb(71 69 66 / 80%), chips#4b4946. The pre-paint color inindex.htmlandNEUTRAL_DARK_BACKGROUNDmatch the new stage. Text and the white accents are unchanged.Popups. Menus, selects, comboboxes, popovers, tooltips and
.dropdown-glassdialogs (Usage) open flat on--popover#403e3bwith a--borderhairline and no blur. Standarddialog-glassdialogs keep upstream's recipe. The shared dark frost now reads its hairline and filter from--fork-popup-borderand--fork-popup-filter, alongside--fork-popup-fill. Neutral Dark overrides those three tokens in its stage block, so Glass (cool-darker), Westworld, Neutral Darker and default Dark keep their frost.Checkbox. On Neutral Dark, every unchecked checkbox (
data-unchecked) is a flat white-16% hairline, without upstream's fill, top highlight or shadow. Checked, indeterminate, invalid and focus states are unchanged.The Neutral Dark guard drops the R = G = B assertion. It now checks that the stage is a darker shade of the warm sidebar, that the popups are flat, and the new hexes. The manifest entry is updated to match. The label is still "Neutral Dark", and the
neutral-darkstorage key is unchanged.Before/after screenshots to follow.
Model: Claude Opus 5.5 via Claude Code in T3 Code.
🤖 Generated with Claude Code