Skip to content

feat(web): warm Neutral Dark palette with flat popups - #222

Merged
NoahHendrickson merged 2 commits into
customfrom
t3/neutral-dark-sidebar
Oct 26, 2026
Merged

NoahHendrickson merged 2 commits into
customfrom
t3/neutral-dark-sidebar

Conversation

@NoahHendrickson

@NoahHendrickson NoahHendrickson commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

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 #34322f and 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, prompt rgb(71 69 66 / 80%), chips #4b4946. The pre-paint color in index.html and NEUTRAL_DARK_BACKGROUND match the new stage. Text and the white accents are unchanged.

Popups. Menus, selects, comboboxes, popovers, tooltips and .dropdown-glass dialogs (Usage) open flat on --popover #403e3b with a --border hairline and no blur. Standard dialog-glass dialogs keep upstream's recipe. The shared dark frost now reads its hairline and filter from --fork-popup-border and --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-dark storage key is unchanged.

Before/after screenshots to follow.

Model: Claude Opus 5.5 via Claude Code in T3 Code.

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Oct 26, 2026

@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.

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.

Comment thread apps/web/src/theme.custom.palettes.css Outdated
) {
border-color: var(--border);
-webkit-backdrop-filter: none;
backdrop-filter: none;

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.

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.

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.

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.

Comment thread apps/web/src/theme.custom.palettes.css Outdated
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"]) {

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.

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.

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.

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.

Comment thread apps/web/src/theme.custom.palettes.css Outdated
/* ---------------------------------------------------------------------------
Neutral Darker — see `.fork/customizations.yaml#fork-neutral-darker-theme`.

Same true-neutral family as Neutral Dark, pushed down: chat stage #1a1a1a

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.

This now contradicts the retune: Neutral Dark is warm, not "true-neutral", and #1a1a1a vs #2c2a27 is no longer "slightly" darker.

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.

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", () => {

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.

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.

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.

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 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.

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.

Comment thread apps/web/src/theme.custom.palettes.css Outdated
[data-slot="popover-popup"],
[data-slot="tooltip-popup"],
[data-slot="dialog-popup"]
).dropdown-glass,

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.

[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.

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.

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 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.

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 to neutral-dark only, so Glass (whose own popup and panel-toggle rules in theme.custom.glass.css are all cool-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's aria-invalid: variant. Upstream draws focus with outline, not box-shadow, so box-shadow: none leaves 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 the body rule) and NEUTRAL_DARK_BACKGROUND agree 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;

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.

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.

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.

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.

Comment thread apps/web/src/theme.custom.palettes.css Outdated
/* ---------------------------------------------------------------------------
Neutral Darker — see `.fork/customizations.yaml#fork-neutral-darker-theme`.

Same true-neutral family as Neutral Dark, pushed down: chat stage #1a1a1a

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.

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.

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.

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>
@github-actions

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 20.9 KiB 20.9 KiB 0 B (0.0%) 29.3 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.2 KiB 21.2 KiB 0 B (0.0%) 29.3 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: 5538b0a · PR result: 3e24dfc · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@NoahHendrickson
NoahHendrickson merged commit 0b7e72b into custom Oct 26, 2026
26 checks passed
@NoahHendrickson
NoahHendrickson deleted the t3/neutral-dark-sidebar branch October 26, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 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.

2 participants