feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles - #4547
Conversation
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>
|
Observability mapAs of 19/100 over 446 measured of 463 entry points (base 19, no change) What this PR changed
FIX FIRST
AUDIT 3 of 50 sensitive mutations record an actor. 47 without one. What the score is made ofThe 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. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe 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)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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 |
…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>
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>
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>
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>
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>
… 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>
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.
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.
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.
…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.
What this does
Rounds out the theme work behind the existing
hasThemeSwitcherflag.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.
Systemis 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.
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
systemworking 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/colorsaudit 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 retiredclassictheme 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-themefollows 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/colorspage, 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