Skip to content

feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles - #4547

Merged
nicktrn merged 110 commits into
mainfrom
samejr/appearance-theme-toggle
Aug 21, 2026
Merged

feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles#4547
nicktrn merged 110 commits into
mainfrom
samejr/appearance-theme-toggle

Conversation

@samejr

@samejr samejr commented Aug 10, 2026

Copy link
Copy Markdown
Member

What this does

Rounds out the theme work behind the existing hasThemeSwitcher flag.

Two new themes. Black and White sit alongside Dark and Light. They inherit their neighbour's whole token set and only pin their surfaces flat, so sections are separated by grid lines rather than layered fills.

System is now configurable at both ends. You choose which theme the OS light setting lands on (Light or White) and which the dark setting lands on (Dark or Black).

Two accessibility toggles.

  • Stronger colors — swaps tinted status chips for solid fills, drops decorative icon accents to monochrome, and darkens chart series that didn't clear 3:1 on a white plot.
  • Underline links — underlines body-text links, so an underline always means the preference is on rather than being a hover style.

Contrast slider. Stores a 0–100 position within the active theme's own range rather than a shared scale, so 35% stays 35% when you switch themes. Each theme maps it in CSS, which keeps system working before hydration.

Appearance in the account popover. A submenu listing the themes with a check against the current one, plus a link through to the full set on your profile. Picking one applies immediately rather than waiting for the write to round-trip.

Profile page. Each row now saves on its own — no submit button. Name and email show their value inline with an edit button; the email row is read-only when an identity provider owns the address.

A /storybook/colors audit page. Renders every colour-carrying pattern in the app once per theme plus once under Stronger colors, and measures contrast ratios off the live DOM rather than a hard-coded table, so it can't go stale.


Demo

profile.page.mp4

Compatibility

The stored preference shape is unchanged (version: "1"), and the four new fields are all optional. The retired classic theme falls back to Dark, whose palette at contrast 0 is what Classic shipped.

One deliberate change worth knowing: the default contrast moves from 50 to 0, so existing users who never touched the slider will see slightly less contrast than before. That's what makes 0 mean "the base palette".


Testing

Switched between every theme from both the account popover and the profile page, in the expanded and collapsed rail, checking data-theme follows and survives a reload. Dragged the contrast slider in each theme and confirmed the percentage label tracks the handle and resnaps if a save fails. Checked both accessibility toggles across the /storybook/colors page, which is also where the contrast ratios were read from. Confirmed the Appearance entry stays hidden for a non-admin while the flag is off.


Open workspace in Conductor

Adds an "Appearance" submenu to the account popover listing System, Light
and Dark, with a check against the current theme, saved through a new
preferences endpoint behind the existing theme switcher flag.

The account page's theme dropdown becomes the same icon-based segmented
control (with Classic included), and its Theme and Contrast rows now match
the layout of the Profile rows above them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: a37995a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Observability map

As of a37995a.

19/100 over 446 measured of 463 entry points (base 19, no change)

What this PR changed

route base head now failing
/resources/preferences/theme new 0 request-context

FIX FIRST

  • /api/v1/projects/:projectRef/envvars (sensitive) - auth-boundary, request-context
  • /auth/sso (sensitive) - auth-boundary, request-context
  • /_app/orgs/:organizationSlug/settings/team (sensitive) - error-classification, auth-scope, request-context

AUDIT 3 of 50 sensitive mutations record an actor. 47 without one.
CONTEXT 22 of 446 entry points name a tenant on a failure path. 345 appear only here, 39 of them sensitive, in the JSON rather than the fix list.

What the score is made of
CHECKS
  error-classification  179 applicable, 102 pass,   0 sole, global without it 12
  auth-boundary          62 applicable,  57 pass,   0 sole, global without it 16
  auth-scope             19 applicable,  17 pass,   0 sole, global without it 19
  request-context       446 applicable,  22 pass, 245 sole, global without it 64
  audit-trail            50 applicable,   3 pass,   0 sole, not in the score

The score and findings here are report-only and never gate the merge. Separately, a required test suite keeps this tool's symbol and route lists in sync with the code they name, and can fail a pull request that renames or removes a symbol they reference, or that adds the first route with a segment they anticipate. Each failure names the list to edit. The rules and their reasons: internal-packages/observability-map/README.md.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR adds reusable theme icons and shared theme option metadata. It adds a protected route that validates and saves theme preferences. The account menu now includes an appearance submenu with theme selection and an account link. The side-menu popover logic moves to a dedicated component. Account settings use centralized theme metadata and updated contrast controls. The slider supports marks and formatted value tooltips during hover and dragging.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the theme refinements, new themes, and accessibility toggles covered by the changes.
Description check ✅ Passed The description thoroughly explains the changes and includes testing details, compatibility notes, and a demo link, although it omits the template headings.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch samejr/appearance-theme-toggle

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…lider

Adds a "More options" link to the Appearance submenu pointing at the profile
page, where the theme picker goes back to the standard select popover and now
covers Classic too.

The contrast slider moves from steps of 5 to 1 and gains a label above the
handle showing the percentage while hovering, dragging or focused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

samejr and others added 2 commits August 10, 2026 09:06
The value label above the contrast handle is now purely hover-driven: it was
also keyed off focus, which the thumb keeps after a click, leaving the label
stuck on once clicked. The handle itself grows from 12px to 18px.

Also halves the profile page theme picker and its popover, which was held open
by a 180px floor, brightens its icons and labels, and widens the Appearance
submenu to 144px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ticks the track at 20% and swaps the handle's label from a percentage to
"Default" when it lands there. The tick uses Radix's own thumb-offset formula
so it sits exactly under the handle's centre rather than a few pixels off.

The label also gains an arrow pointing down at the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

samejr and others added 21 commits August 10, 2026 11:39
The trigger is now sized to the widest option, and its icon can no longer
shrink - at the old width "System" and "Classic" squeezed it down to a sliver.
The popover matches that width.

The contrast label's arrow had its borders on the two left-hand edges rather
than the two facing the handle; rotating a square clockwise sends the bottom
and right edges downward, not the bottom and left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doubles the mark's height and hangs background-coloured borders either side of
the 1px line, so it cuts through the track instead of blending into it. The
border colour is a theme token, so it follows light mode too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mark's gap borders used background-bright, which is not what sits behind a
settings row - the page there is background-dimmed, so the "gap" rendered as a
slightly lighter stripe instead. Both colours now come from the slider variant,
the line matches the track, and its ends are rounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mark on the track becomes a button labelled "Reset to default" that snaps
the handle back to 20% and saves, and brightens on hover. It stops its own
pointer events reaching the track, which would otherwise drag the handle to the
press instead.

The handle still wins wherever the two overlap: the mark is drawn before the
thumb, so the thumb takes the hover, the click and its own label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…toggle

The high-contrast accent set - solid status badges, monochrome nav icons and
the darker, unified icon colours - was tied to the Dark and Light themes.
It moves to its own preference, off by default, so any theme can run either
Classic's colours or the high-contrast ones.

Comparing the two sets turned up 22 re-mapped tokens: three change hue outright
(preview and preview branches yellow to blue, queue charts purple to blue) and
the rest step down in lightness for white. The `system:` variant that carries
the badge restyle across ~50 components now keys off the preference too.

Classic's accents were drawn for dark cards and most sit under 3:1 on white, so
the Light theme gets its own Classic-derived set: same hues, stepped down in
lightness only, so yellows stay yellow and queues stay purple.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…page

Renames the section to "Interface and theme", adds descriptions to each row and
grows the theme select a size. The new App sidebar row opens the side menu's
own Customize modal - that modal builds its section list from the side menu's
project context, which this page doesn't have, so the button deep links into
the user's current environment and the param is consumed and stripped there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the row description a size (small to extra-small, 14px to 12px) and halves
the title-to-description gap, both in the shared settings layout so every
settings page follows. The gap moves to an exported constant, since the profile
page hand-rolls its rows to match the Profile section's heights.

Also describes what the Icon contrast switch does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The App sidebar row now opens the modal in place instead of navigating into the
app to open it there. The side menu's section list moves into its own module so
both callers build it from one source - the profile page resolves the user's
current environment in its loader and passes it in, since the list is keyed to a
project and environment.

Also grows the Customize button a size and lets the theme select hug its label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picking a theme from the account menu sometimes left the old one on screen. The
switch waited for the write to come back through the root loader, but dismissing
the popover unmounts the row that owns the fetcher, and without v3_fetcherPersist
React Router drops an unmounted fetcher's revalidation. The POST had already gone
out, so the preference saved and a refresh showed the new theme - which is why it
looked intermittent.

Both pickers now set the attribute themselves and let the write follow, sharing
the resolution rule with useSystemThemeSync rather than restating it, and revert
if the write comes back unsuccessful.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bg-secondary is charcoal-650 and the hover was background-raised, which is
charcoal-700 - a step down the scale, so the button dimmed. Hover now steps up to
charcoal-600 (surface-control) on the dark themes, which is where it sat before
the themes update. Light keeps darkening off white.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same step the secondary button needed: bg-secondary is charcoal-650 and the hover
was background-raised at charcoal-700, so it dimmed. Dark themes now hover to
charcoal-600; light keeps darkening off white.

Also drops the dropdown chevron's colour transition, the only animated hover on
the trigger - the background change is instant, so the chevron was arriving late.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds minimal/medium: the same toggle as medium without the padded hover box, for
rows that don't need the enlarged hit area. medium keeps it. The unchecked track
still lightens on hover, so the control keeps an affordance without the surround.

The checked fill moves from blue-500 to indigo-500, matching the primary accent
the buttons and checkboxes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves it to minimal/medium so it matches Icon contrast. The old className carried
a pr-3 that only made sense with the padded variant; without it the toggle lines
up with the other controls in the column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handle sat at charcoal-200 on the dark themes, the brightest tone on the row.
It rests a stop lower now and comes up to charcoal-200 on hover. Light reverses
the direction - it rests white and dims to charcoal-200, since on a white page
dimming is what reads as more prominent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t 30%

The slider no longer offers the bottom of the range, and the "Default" tick moves
with it. Nothing about the stored value or the colours it drives changes - the
number means what it always did, the control just starts at 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Off by default. It targets a marker class on the TextLink component rather than
anchors generally, so nav items, buttons-as-links and decorative underlines
(dashed tooltip terms, tab underlines) are untouched either way.

TextLink itself never underlined - its two variants are colour-only - so this is
the first underline it gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every link that underlines itself instead of going through TextLink, copied into
its surroundings and labelled, so each can be decided on. Also reworks the
Underline links description.

Remove the route and its storybook entry once the decisions are made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Links that underlined themselves now either use TextLink or share its colour and
the marker the "Underline links" preference targets, so the preference reaches
them. TextLink gains `textLinkClassName` for the cases that can't be the
component - markdown links that must stay in the inline flow, and triggers that
aren't anchors - plus `reloadDocument` so LabelValueStack keeps its behaviour.

Deletes the ProductHunt banner and its image, which nothing rendered.

Left alone: the admin pages, and the underlines that aren't links (dashed
tooltip terms, the dotted Vercel warning, URL-as-text, the focus affordance).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
samejr and others added 4 commits August 20, 2026 11:14
The chips carried `contrast-chip`, which draws an inset 1px ring in currentcolor
so a low-contrast tint gets a visible edge. Now that they fill solid with a white
label under Stronger colors, currentcolor is white - the ring became a white line
inset into the fill, which is what read as a stray inner border on the light
themes.

The ring only ever applies under the preference, so removing it changes nothing
about the tinted treatment underneath.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t themes

A full stop to charcoal-100 still read as a blob landing rather than a handle
lighting up. #f4f4f6 is the midpoint of white and charcoal-100 - half a stop,
which is as fine a step as the ramp allows without inventing one.

Opaque rather than charcoal-100/50, which was the obvious way to write it: the
hover rule replaces the white fill rather than sitting over it, so a 50% alpha
would let the track line show through the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lls solid

The contrast-chip ring is drawn in currentcolor. On a tinted chip that's the
accent, which is the edge it was written for; on a chip that fills solid the label
is white, so the ring is a white hairline inset into the fill. Against a dark page
that still reads as an edge, but on white it reads as a stray inner border.

Cancelled for the light themes only, and only for chips that actually fill - the
error badge's bright prominence, the queue health chips, and the Stronger colors
preview on the account page. A `contrast-chip-solid` marker carries that, because
the two remaining users (limits, private connections) stay tinted under the
preference and still want the ring.

The error badge marks only `bright`; `subtle` keeps its tint and its ring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoping the last fix to the light themes was the wrong call: the ring is drawn in
currentcolor, so on a solid chip it's a hairline in the label's colour whichever
theme you're in. On dark that's a white line around a filled badge, which is no
better than it was on white.

So chips that fill under the preference simply don't opt into `contrast-chip` any
more - the queue health chips, the Stronger colors preview, and the error badge's
bright prominence. The `contrast-chip-solid` marker and its cancel rule are gone
with it; there was nothing left for them to do.

The class is now tint-only, which is what it was written for: the error badge's
subtle prominence, the limits page and private connections all keep their ring.
That also settles the inconsistency with the Trigger Agent badges, which dropped
the ring outright a couple of commits ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

samejr and others added 5 commits August 20, 2026 17:19
The track was `bg-transparent dark:bg-background-raised/50`, which only ever drew
on the dark themes. On Light and White there was no background at all, so the
control was a row of segments floating on the page with nothing holding them
together. On Black it did draw, but half of #1f1f1f over a #000 page is #101010 -
close enough to nothing.

It now reads a `--color-segmented-track` token, so each theme answers for itself.
Dark keeps exactly what it had, half-strength raised, which lands at #1e2024
against a #15171a page. The three that pin their surfaces flat take the full
raised value instead: #1f1f1f on Black, #e9eaee on Light, #ededed on White.

Only the secondary variant changes; primary was already opaque.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… off-track

The label is held up by a dragging flag, and the flag was only cleared by the
slider Root's own onPointerUp. Drag the handle past either end, keep the button
down, let go out there, and that handler never runs - so the label stayed up until
the next interaction.

The flag now clears on a window-level pointerup, registered only while a drag is
in progress, so releasing anywhere ends it. It also clears when the pointer leaves
the control, which is what you'd expect from a hover label; Radix holds pointer
capture on the thumb mid-drag, so that doesn't fire while the handle is genuinely
being moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base

Black pins every surface flat on #000, so the grid lines are the only thing giving
a page structure - and at the base palette they carry more weight than that theme
wants. The slider had nowhere to go: the ramps run from the base upward, so 0 was
the faintest setting available.

The slider now floors at -50 on Black, and only on Black. The stored value is
split in two on the way out: `--theme-contrast` keeps the strengthening half
exactly as it was, and a new `--theme-fade` carries the negative half, which
Black's own grid ramp uses to fade the rules back toward the page. At 0 the fade
term mixes 0%, so this is the shared dark ramp untouched - nothing moves for
anyone already on a value of 0 or above.

The Default tick lands a third of the way along on Black rather than hard left,
which is the visible sign that the extra travel is there.

One consequence worth knowing: contrast is a single preference shared by all four
themes, so a negative value set on Black means nothing on the other three - they
have no fade term to read it. The slider clamps its handle to the floor of
whichever theme is showing rather than pinning it off the end of the track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ramps read `--theme-contrast`, and the live preview wrote the raw slider value
straight into it. Below zero that becomes a negative percentage inside
`color-mix`, which is invalid, so the browser dropped the declaration and the
token kept whatever it had - dragging into the negative range did nothing at all.
Only a full page render went through the split in root.tsx and behaved.

Both client writers now go through one `applyThemeContrast` helper that sets the
strengthen half and the fade half together, so there's a single place that knows
how a signed value maps onto the two variables.

Black's floor drops to -100, which takes the grid lines all the way to the page
colour, and "Default" on Black is now -40 rather than 0. That number mirrors the
light end: on White the rules measure 1.16:1 and 1.27:1 against their page, and
-40 puts Black's at 1.15:1 and 1.19:1 against #000 - the same near-invisible
weight from the other direction. The base palette sits at 1.33:1 and 1.46:1,
heavier than this theme wants when the rules are the only structure there.

The default tick and the "Default" label both follow the per-theme value, so
resetting on Black lands on -40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hide the scale

Two things were wrong with Black's extra travel.

Saving below zero didn't stick. The preference blob is parsed with a zod schema
whose contrast field floored at 0, and every field there uses
`.catch(undefined)` so an out-of-range value degrades quietly rather than
failing the whole blob. The write reached the database intact; the read then
discarded it and fell back to the default, which is why the handle sprang back
to 0% a moment after release - and why it seemed to hold if the save hadn't
settled yet. The floor now follows the offset.

The negative range was also on show. Black's floor is -30 in the ramp, and the
slider now presents that as 0%: it works in 0-100 whatever the theme, adding the
offset on the way in and taking it off on the way out. So Black's "Default" tick
sits at the left end where the lines are faintest, nothing below it is reachable
by dragging, and no negative number is ever displayed. -30 is also the floor now
rather than -100, since below it the rules disappear entirely.

One consequence: Black's window is the same width in the ramp as before but
shifted down, so its strongest setting is 70 rather than 100. That end is the
last thing this theme needs, and the slider reads 0-100 on every theme as a
result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

… every theme

The stored value was a position on one shared scale, and Black's slider was
offset on top of it. So Black at 100% stored 70, and switching to Light showed
70% - the number followed the implementation rather than the user.

It's now a percentage of whichever theme's range is active. 35% is 35% wherever
you switch to; each theme decides where its own 0% and 100% sit. Black's range is
-30 to 100, so its 0% fades the grid lines below the base palette - where this
theme wants to start - and its 100% is full strength like everywhere else. The
others run 0 to 100 as they always did.

The mapping moved into CSS, one line per theme, because the alternative was
teaching three JS call sites which theme had resolved - and on `system` that
isn't settled until hydration, so the server would have picked the wrong range
and corrected it on the client. Now JS writes a single
`--theme-contrast-percent` and each theme block turns it into the strengthen and
fade halves the ramps read.

Two things fall out of this. The stored value is a plain 0-100 again, so the
schema floor and the normalizer go back to 0 - no negative ever leaves the page.
And Black finally defaults to its faint end for a new user, rather than only
showing them where the default tick was: 0% is the bottom of Black's range, not
the base palette.

The slider is a straight pass-through again, and the page no longer needs to
resolve the active theme at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Conflicts and how they were resolved:

- Switch.tsx: kept both sides. Main narrowed the tracks and added the
  press squish; this branch's even-padding fix now sits on top of main's
  geometry, so the medium thumb is 12px travelling 14px (a 12x26 content
  box) rather than 16px, and the squish keeps the leading edge pinned.
  Main's transition-[translate,width,background-color] and unconditional
  useShortcutKeys are kept; so are our accent-fill track and white thumb.
- root.tsx: took main's fragment-wrapper removal with this branch's theme
  attributes. Main's dashboardAgentBaseUrl loader field is retained.
- SideMenu.tsx: this branch extracted SideMenuPopoverSubMenu to its own
  file, so main's inline copy is dropped and its oxlint-disable comment
  ported into the extracted component.
- SettingsLayout.tsx: both sides changed the row title gap to
  space-y-0.5; kept the constant.
- account._index/route.tsx: kept this branch's appearance section and
  removed main's now-orphaned renderTheme helper, which referenced a
  themeLabel this branch had already deleted.
- regions, concurrency, select-plan: took main's side and re-applied our
  themed-link swaps, plus main's type="button" on the reset button.

Silent auto-merges that needed fixing:

- CopyButton.tsx: both sides added asChild/tabbable, producing duplicate
  JSX attributes. Main's restructure supersedes ours - the button variant
  now uses Button's own tooltip prop, and the icon variant became a real
  <button> - so its unconditional version stands.
- storybook.usage: bucketStartMs became a required prop on
  UsageSparkline; the examples now pass a fixed timestamp, matching the
  convention main established in storybook.charts.

Main's newly-enabled React Compiler and exhaustive-deps rules flagged
this branch's code. Fixed rather than suppressed where practical: the
duplicated theme option label is hoisted to module scope, and the
marketing-emails debounce keeps fetcher.submit in a ref so it needn't
depend on an object useFetcher rebuilds every render. The remaining
disables are the fetcher-settle effects, which synchronise with the
network and are already guarded against re-entry.
@samejr samejr changed the title feat(webapp): switch themes from the account menu feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles Aug 21, 2026
samejr added 4 commits August 21, 2026 10:14
The test's VALID_THEMES still listed "classic", which this branch removed
from the enum, so both loops asserted a value the schema now rejects.
Swapped in the current set and pinned the migration behaviour the removal
implies: a stored "classic" normalizes to dark and parses to undefined.

These are excluded from tsconfig.check.json, so the stale annotation
never surfaced in typecheck.
Removes commentary that restated the code or narrated how something used to
work, and shortens what's left to the constraint it encodes. Kept: lint
directives, cascade-ordering requirements, the `color-mix` negative-percentage
limit, Tailwind's literal-class requirement, and the handful of Radix and
fetcher gotchas that would otherwise be re-discovered.

Also drops two docblocks that had drifted onto the wrong function:
`useDocumentIconContrast`'s sat above `useDocumentTheme`, and
`applyThemePreference`'s above `applyThemeContrast`.
…dress

The check treated any org with SSO switched on as owning every member's
email. It now requires SSO to be enforced, a live connection, and the
member's domain to be one the org has verified - which is what the
enforcement dialog promises, so contractors on other domains keep their
own address instead of being sent to an admin who can't help them.

An unreachable SSO service is now its own answer rather than being folded
into "an IdP owns this". The save is still refused, but the page says it
couldn't check instead of asserting something it doesn't know. A definite
answer from any org also wins over an org that couldn't be read, so one
unreachable org neither masks a real claim nor blocks the write alone.
Restores the Logs entry for teams with logs access but not query access.
Extracting the side menu's sections dropped a clause from the Observability
gate, so the whole group disappeared for them.

Gives the profile page's "Customize sidebar" dialog the same feature flags
the side menu sees. /account sits outside an org, so `useFeatureFlags`
returned nothing and the dialog offered a shorter section list - and saving
it replaced the stored order and hidden items, dropping the sections it had
never shown. Flags are now resolved in the loader through a shared helper so
the two can't drift.

Rate-limits the appearance writes. Every profile row saves on its own, and
the five appearance actions were the only ones a scripted POST could drive
unthrottled - the contrast slider being the easiest. The repeated flag gate
moves into that same helper. `update-theme` now rejects an unknown theme
instead of quietly resetting it to dark, matching its sibling action and
the /resources/preferences/theme route.

Keeps the span title legible on the light themes. The run-type accents are
drawn to clear 3:1 as icons; as 16px semibold text tasks (3.38) and agents
(3.96) fall under 4.5:1 on Light and White, so the title takes the text
colour there and the icon carries the type.

Shows the contrast slider's value label on keyboard focus, via
focus-visible so a mouse click doesn't strand it on screen.
devin-ai-integration[bot]

This comment was marked as resolved.

samejr added 5 commits August 21, 2026 12:16
Extracting the side menu's sections lost the inner
`isAdmin || featureFlags.hasQueryAccess` wrapper around Errors, Query,
Queues and Dashboards. Until the previous commit the tight outer gate hid
that - logs-only orgs never entered the block at all - so widening the
outer gate to restore their Logs entry exposed all four to them.

All four gate expressions now match the pre-extraction ones, and every
item id the side menu builds is accounted for.
The run timeline's zoom handle carried a 4px-spread drop shadow that read
as a dark halo. Removed from the variant rather than the call site, so the
storybook sample matches.

The `focus:` copy of the shadow went with it: it was byte-identical to the
base one, so focus already looked the same as rest and nothing is lost.
Also drops `hover:border-text-dimmed`, which set the border to the colour
it already was.
The tabs were the shared component, but on the default underline variant
inside `Card.Header` - which wraps its children in `Header3`, so they
inherited its semibold weight, and the container's gap-x-6 was wider than
the labels needed.

The chart card now takes `headerVariant="tabs"`, which renders the title as
the same h-10 bar the filter bar above it uses: full-width divider, label
centred in the height, and the active and hover underlines meeting the
divider at the bottom edge. `TabButton` gains a `size` so the title variant
can carry a body-sized label instead of a heading one.

Also scopes the tabs' hover underline to the tab. It keyed off an unnamed
`group`, which matches any ancestor carrying one - including the wrapper
ChartCard uses to reveal its maximize button - so hovering anywhere on the
chart lit up the inactive tab.
Doubles the tab bar's left padding to pl-4, which is also what the
non-tabbed `Card.Header` uses - so a tabbed chart title and a plain one
start at the same place.
@nicktrn
nicktrn merged commit 4c5237c into main Aug 21, 2026
51 checks passed
@nicktrn
nicktrn deleted the samejr/appearance-theme-toggle branch August 21, 2026 18:27
nicktrn added a commit that referenced this pull request Aug 21, 2026
…rk (#4756)

Fixes found while reviewing #4547, stacked on that branch so they can be
reviewed on their own and merged into it. One commit per fix.

## Write-path correctness

**Refuse account writes while impersonating.** The five
`dashboardPreferences` writers already no-op for an impersonating admin,
but the three profile writers added next to them did not, and
`requireUserId` returns the impersonated user's id. Both gates now
refuse up front and say so, rather than the preference writers silently
no-opping while the page reports success.

**Preserve unknown keys on a full-blob write.**
`mutateDashboardPreferences` parses the JSON column, hands the result to
a mutator and persists the whole object back. zod strips keys it does
not declare, so a deploy that predates a preference field drops it on
the next write through that path — and
`updateCurrentProjectEnvironmentId` sits on the navigation hot path.
`preserveUnknownKeys` re-attaches them at the write. Note this cannot
help deploys already running, so it makes this the last release able to
strip rather than retroactively protecting the fields added in #4547.

**Scope hidden-sidebar writes to what was shown.** The customize dialog
builds its hidden map from the sections it can see and the write
replaced `hiddenItems` wholesale. The profile page has no org in scope,
so it resolves sections from the most-recently-updated project's org:
confirming there dropped hidden ids belonging to sections that org's
flags exclude. The payload now carries the ids the dialog rendered and
the write only replaces those. Submissions without the list stay
authoritative.

**Consider both addresses when checking email ownership.** The check
only looked at the address the user already had; it now considers the
current and submitted address together, so an org managing either one
governs the change. Validation moved ahead of the check, and
`emailDomainOf` splits on the last `@`.

## Interaction

**Revert unsaved themes, debounce contrast saves.** The theme and
system-theme selects stamp `data-theme` before the write lands. When it
fails, the loader returns the value it always had — so
`useSystemThemeSync`'s effect deps are unchanged and React's vdom diff
sees no change either, and nothing rewrites the attribute. The page kept
rendering a theme that was never stored while the select showed the
stored one. The stored pair is now re-applied explicitly, as the side
menu's switcher already did. The contrast slider is debounced because
Radix commits on every arrow keypress, so a keyboard user crossing the
range fired one write per step.

**Tick More options for themes outside the short list.** The appearance
submenu offers System, Light and Dark; Black and White live on the
profile page. With one of those stored, every row read as unselected.

## Subtraction

**Drop the profile update rate limiter.** It covered one of four paths
that write the same column — `resources.preferences.sidemenu` and
`.favorites` take unlimited authenticated writes and go through the
locked read-modify-write, which is more expensive than the single narrow
`jsonb_set` this capped. It was also what made the contrast slider
unusable by keyboard. If preference writes want limiting, it belongs in
one place covering all of them.

**Resolve email ownership when the dialog opens.** It fans out one SSO
status lookup per organization the user belongs to and ran in the
profile loader on every page view, purely to pick which body the dialog
renders. The action re-derives it before writing either way, so the
check that guards the write now has one call site instead of two.

## Testing

`typecheck --filter webapp` and `lint` clean. New unit tests for
`preserveUnknownKeys`, `mergeHiddenItems` and `emailDomainOf`;
`themePreference`, `mergeHiddenItems` and `ssoManagedIdentity` suites
pass locally (26 tests). The rest of the webapp suite needs
testcontainers and is left to CI.

No changeset or `.server-changes` entry: everything here fixes code on
the parent branch that has not shipped. The one exception worth a
maintainer's call is `mergeHiddenItems`, which also touches the side
menu's own customize path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants