feat(webapp): switch themes from the account menu - #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 444 measured of 460 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>
… tooltip The chip now sits to the left of the switch and fades in while the pointer is over the control, so flipping the toggle no longer dismisses it - a Radix tooltip closes on pointerdown, which fought the one interaction the preview exists for. Keyboard focus reveals it via :focus-visible rather than focus-within, so a click doesn't strand the chip on screen after the pointer has left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… edit icon The Full name and Email address rows put their current value in description text under the label. It now sits as normal text on the right of the row, just left of the action, so the label and the value read as one line and the rows match the shape of the switch and select rows around them. The action itself is now a square icon-only button carrying a pencil glyph rather than the words Update and Edit. The small-icon variant is a fixed 34px wide so a row of icon buttons with different glyph ratios lines up; here there is only one, so it's overridden to a true square against its own height. Inside the modals: the name dialog is titled Full name and its primary button now says Update rather than Edit, and both dialogs drop the label above the input, which only repeated the title a few pixels below it. The inputs carry an aria-label instead so they still have an accessible name. The new icon strokes in currentColor rather than white, so it follows the theme and the button's hover state - a hard-coded white glyph would have all but vanished on Light and White. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The medium track is h-4 with a 2px transparent border, so its content box is 12px - but the thumb was 14px. It overflowed by a pixel top and bottom, leaving 1px of track above and below the handle against 2px at whichever end it had travelled to. On the profile page's toggles that read as a handle pressed up against the rails. The thumb is now 12px travelling 16px, so the gap is an even 2px on all four sides in both positions. That's the rule `large` already follows: thumb equal to the content box, travel equal to the content width minus the thumb. `small` has the same mismatch and is left alone here - it's used across the app rather than on this page, and the fix is noted in place for whenever someone wants it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… themes The hover chip beside the switch was tinted with the warning accent, which does nothing on Light and White. Those themes already darken success and warning for white, and they land on the very same values the high-contrast set uses - so the preference genuinely has no effect on that token there, and the preview sat still while claiming to demonstrate the change. It now borrows the Resolved error-status treatment: a tinted green chip that fills solid with white text under the preference. That swap is driven by the preference itself rather than by an accent value, so it reads in all four themes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling sections sat 48px apart, and the profile page - which hand-rolls its
section headers rather than using SettingsSection - used 32px, so Profile,
Appearance and Interface ran together more than the pages built from the layout
components did.
Both now take 52px from one place, so the profile page gains the most and every
settings page lands on the same rhythm.
The value is written out twice, as a bare class for callers and inside
SettingsSection's own variant. That's deliberate: Tailwind only generates
classes it can see as complete literals, so composing
`[&:not(:first-child)]:${gap}` from the constant would emit no CSS at all and
silently collapse the gap to nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… colors The page was built when the preference swapped icons as well as colors, and was named and organised around that. The preference now only moves colors, so the page name, the column header, the intro and the callout all said something the feature no longer does. Renamed throughout, including the route, so the URL matches the sidebar. Section 2 was nine entries on glyphs shared between states. Two of them survive: the environment labels and the three run statuses on the stack icon, where the label drops to the surrounding text color and the icon carries a tint that genuinely moves. The other seven argued purely from shape, or from a color the preference never touches, so they're gone along with the constants and imports they needed. What's left is retitled "Icon and label pairs", where the icon is context for the label rather than the thing under audit. The intro also now says which accents move where: several only shift on the dark themes, because Light and White already darken them to the high-contrast value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s page The charts section audited series colors as legend swatches only, which shows the accents side by side but not at the size or the adjacency a real chart puts them at. Two examples with static data, sharing one series list so the same four accents can be compared as fills and as strokes. The bars are stacked so the fills meet edge to edge with nothing between them - the hardest case for telling two accents apart, and the one a legend can't help with once you're reading a single column. The lines are the same colors at 2px, where a series carries a fraction of a bar's area and can drop under the 3:1 floor a fill clears comfortably. The series are a deliberate mix rather than a best case: the queue accent moves on all four themes, success and warning only on the dark ones, and error never moves at all. Animation is off. Each chart renders twice on this page, and one growing out of the axis on every theme switch makes the two columns hard to compare mid-flight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dot was a fixed size-2 next to a ring stretched to the container, and nothing centred either. At the default size those agree, so it looked right - but any call site that resized the whole thing got a small dot sitting in the corner of an oversized halo, flex-start in both axes. The dot now fills the container, so a size on the wrapper scales the dot and the ring together, and both are centred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rounded variant put text-text-bright on a blue-600 fill. That token is near-black on Light and White, so the label all but disappeared into the fill while reading correctly on the dark themes. The fill is the same in every theme, so the label is now plain white in every theme too - which is what the variant's own system: override was already doing under Stronger colors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…everywhere Two token changes. The app had no primary color worth the name: --color-primary was an apple green that four call sites used and nothing else, while the indigo link accent quietly did the job a primary usually does. --color-primary now points at --color-text-link rather than carrying its own value, so it's the same hue by definition and can't drift. The light themes needed no separate entry once it became an alias. Preview environments were yellow-400 on the dark themes and yellow-700 on the light ones - the darker end reading closer to brown - and blue only under Stronger colors. Preview and its branch labels are now blue in every theme, so an environment looks like itself at both ends of the `system` setting. Their per-mode and Stronger colors entries are gone, since the base value now matches what those blocks were reaching for. Worth knowing: because the base and high-contrast values now agree, Stronger colors no longer moves either preview token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…theme at once The Stronger colors page compared one theme against its own high-contrast treatment, two columns at a time, which meant four passes with the theme switcher to see whether an accent held up everywhere. Theme tokens sat separately as a plain swatch gallery of the layer underneath it. They're now one page, "Colors", under Foundations - the Accessibility group is gone, and so is the separate Theme tokens page. Every block renders five times: once per theme, then once under Stronger colors, which follows the switcher so any theme can sit beside its own high-contrast treatment. The measured tables keep the Fill / Ratio / Verdict readings and repeat them per theme, sixteen columns in one flat grid so a sub-column stays under its header all the way down. Ratios are still measured live off the DOM, which is what makes this work at all: pinning data-theme on a wrapper is enough to get that theme's answer, with no table of hard-coded values to fall out of date. Two things that made it possible: every theme block is written as a plain attribute selector rather than being anchored to <html>, and the dark: variant is descendant-capable - so a nested column inherits the whole token set. The theme groups use display:contents so their three cells stay direct grid items of the row. As a bonus the --chart-2/--chart-3 override now resolves here too, since it wants both attributes on one element and the last column restates the theme it inherited; the note saying it couldn't is gone. The charts drop to three bars, since each one renders five times across a row now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not for merge. Hard-codes isSsoManaged so the identity-provider copy can be read in the browser without an SSO org to test against. Revert once the copy is signed off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ider "Signs you in through its own identity provider" led with the jargon. Single sign-on is the term someone will have been given by whoever set it up, so the sentence now opens with that and leaves the identity provider as the place the address actually lives. "An admin on your account" was also ambiguous - it's an organization admin, and "your account" could be read as the reader's own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit 81bacb1. The copy is signed off, so the modal goes back to branching on the real isSsoManaged value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The paragraph carried py-4, inherited from the form it replaces. A form needs that room below it before the footer; a line of explanatory text with no footer under it doesn't, and the modal read bottom-heavy. pt-2 keeps it clear of the title and lets the dialog's own padding close the gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return "text-warning bg-warning/10 border-warning/20"; | ||
| case "TRACE": | ||
| return "text-purple-400 bg-purple-500/10 border-purple-500/20"; | ||
| return "text-log-trace bg-log-trace/10 border-log-trace/20"; |
There was a problem hiding this comment.
🟡 Trace log entries lose their colour and blend in with the rest of the list
The trace severity chip is painted with a colour name that doesn't exist in the design system (text-log-trace at apps/webapp/app/utils/logUtils.ts:108), so nothing is applied and the chip renders uncoloured.
Impact: On the Logs page, TRACE rows no longer stand out — the badge loses its purple text, tinted background and border, unlike every other severity.
Missing colour token behind the new chip styles
The rewrite of getLevelColor switched TRACE from raw palette utilities (text-purple-400 bg-purple-500/10 border-purple-500/20) to semantic ones (text-log-trace bg-log-trace/10 border-log-trace/20). Tailwind v4 generates these utilities from --color-* custom properties declared in @theme, but --color-log-trace is not declared anywhere — apps/webapp/app/tailwind.css has no such token, and a repo-wide search finds log-trace only at apps/webapp/app/utils/logUtils.ts:108. All three classes are therefore dropped at build time, leaving the chip with inherited text colour, a transparent background and Tailwind's default currentColor border.
The sibling changes in the same function are fine: --color-pending (used for INFO) is declared at apps/webapp/app/tailwind.css:186, and the DEBUG/default branches use black/5 and white/5, which need no token.
Prompt for agents
In apps/webapp/app/utils/logUtils.ts, getLevelColor now returns "text-log-trace bg-log-trace/10 border-log-trace/20" for the TRACE level, but no --color-log-trace custom property exists in apps/webapp/app/tailwind.css (or anywhere else), so Tailwind generates none of those three utilities and the chip renders with no colour at all. Either add a --color-log-trace token to the @theme block (and per-theme overrides alongside the other accents, so it follows the light themes and the Stronger colors preference the way the comment above the function claims), or fall back to an existing themable token. The other levels in the same function reference tokens that do exist (--color-error, --color-warning, --color-pending).
Was this helpful? React with 👍 or 👎 to provide feedback.
| }, | ||
| ] | ||
| : []), | ||
| ...buildSideMenuSections({ ...context, isAdmin, featureFlags, isManagedCloud }).map( |
There was a problem hiding this comment.
🔴 Customising the sidebar from the profile page can silently reset saved sidebar preferences
The sidebar customisation dialog on the profile page is built with no organisation feature flags available (useFeatureFlags() feeding buildSideMenuSections at apps/webapp/app/routes/account._index/route.tsx:774), so whole sidebar sections are missing from it and confirming wipes the choices previously saved for them.
Impact: A non-admin user who opens "Customize" from their profile sees a shorter list than their real sidebar, and pressing confirm makes items they had hidden reappear.
Feature flags come from the organization route, which is not mounted under /account
useFeatureFlags() (apps/webapp/app/hooks/useFeatureFlags.ts) reads useOptionalOrganization(), which looks up the route match routes/_app.orgs.$organizationSlug (apps/webapp/app/hooks/useOrganizations.ts:25-37). The account page renders under app/routes/account/route.tsx, not under the org layout, so that match never exists and the hook returns {}.
buildSideMenuSections gates two sections on those flags (apps/webapp/app/components/navigation/sideMenuSections.tsx:98 and :126): isAdmin || featureFlags.hasAiAccess for "AI" and isAdmin || featureFlags.hasQueryAccess for "Observability". For a non-admin whose org has those flags on, both sections are present in the real side menu (built inside the org route, where the flags resolve) but absent here.
The dialog's confirm handler rebuilds the whole preference payload from only the sections it was given (apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx:203-251): hiddenItems is recomputed by iterating sections, and sectionItemOrder likewise. Any hidden override or item order stored for ai / metrics items is therefore not re-emitted, and the save at apps/webapp/app/routes/account._index/route.tsx:815-818 replaces the stored preference — so those items silently become visible again and their ordering is lost.
The loader already resolves an organization for sidebarContext via SelectBestEnvironmentPresenter; the feature flags for that organization could be returned from the loader alongside it instead of being read from a client hook that has no org in scope.
Was this helpful? React with 👍 or 👎 to provide feedback.
| /* system: the high-contrast icon and badge treatment - solid status badges, | ||
| monochrome nav icons. Named for the System themes it shipped with, but it now | ||
| follows the "Distinguish without color" preference in any theme; off means the | ||
| base tinted chips and colored icons. */ | ||
| @custom-variant system (&:where([data-icon-contrast="true"], [data-icon-contrast="true"] *)); |
There was a problem hiding this comment.
🔍 system: variant now keys off a preference rather than the theme — verify all existing call sites still read correctly
@custom-variant system changes from "the Dark or Light themes" to "data-icon-contrast="true" in any theme". Every pre-existing system:* utility in the codebase therefore flips meaning: styles that previously always applied on Dark/Light now only apply when the user opts into Stronger colors, and they additionally apply on Black/White when they do. The PR fixes the obvious casualties it found (Badge.tsx rounded, the run title in the span route), but this is a broad semantic change across every system: usage — worth a sweep of grep -r "system:" to confirm nothing else relied on the old "is a non-Classic theme" meaning (e.g. system-mono-label call sites, which now only go monochrome under the preference).
Was this helpful? React with 👍 or 👎 to provide feedback.
| /** Dark's palette at contrast 0 is exactly the palette the Classic theme | ||
| * shipped, so 0 is the default: anyone arriving from Classic sees no change, | ||
| * and the slider only ever adds contrast on top. */ | ||
| export const DEFAULT_THEME_CONTRAST = 0; |
There was a problem hiding this comment.
🔍 Default contrast dropped from 50 to 0 changes the look for everyone already on the theme switcher
DEFAULT_THEME_CONTRAST goes from 50 to 0 and normalizeThemePreference now maps the removed classic value onto dark. The comments argue Dark at contrast 0 reproduces the Classic palette exactly, which is plausible given the contrast blocks are color-mix(..., 0%) at contrast 0 — but the same change also drops anyone who was already on the new Dark/Light themes (and had no saved contrast) from a 50% contrast bump to 0%. That's a visible regression in perceived contrast for existing flagged users, and the changelog note doesn't mention it.
Was this helpful? React with 👍 or 👎 to provide feedback.
…as the rows Debug was missing from the filter list entirely, so there was no way to filter for it. The dropdown also hand-rolled its own coloured labels, which meant two places deciding what a level looks like; it now renders the same LogLevel chip the log rows use, with the label alongside for screen readers, so the two can't drift. The level type comes from the presenter rather than being redeclared, which is what turned up the missing entry. Recovered from an interrupted session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The initial was always light, which is fine on the darker tiles in the set and poor on the pale ones - and the pale tiles are the ones the light themes lean on. It now measures the tile's own colour and picks whichever of the two letter colours has the better ratio against it, using WCAG relative luminance so the comparison matches what a contrast checker would report rather than a gamma-encoded average. Recovered from an interrupted session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // The button variant is a real <button>; without asChild the tooltip | ||
| // trigger wraps it in its own, and the browser parser splits the nested | ||
| // buttons apart, which React then fails to hydrate. | ||
| asChild={variant === "button"} | ||
| tabbable={variant === "button"} |
There was a problem hiding this comment.
🟡 Copy buttons no longer show their "Copy"/"Copied!" hover hint
The copy control is now handed straight to the hover-hint wrapper instead of being wrapped by it (asChild={variant === "button"} at apps/webapp/app/components/primitives/CopyButton.tsx:105), and the wrapper's hover handlers are silently dropped, so the hint never appears.
Impact: Hovering a copy button anywhere in the dashboard no longer shows the "Copy" tooltip, nor the "Copied!" confirmation after clicking.
Why the merged props are lost
With asChild, SimpleTooltip renders TooltipTrigger asChild (apps/webapp/app/components/primitives/Tooltip.tsx:93-100), which uses Radix Slot: it clones the child element with the trigger's props (onPointerEnter, onPointerLeave, onFocus, onBlur, data-state, aria-describedby).
The child here is the Button component, and Button does not spread unknown props onto its DOM node — it destructures a fixed allowlist (type, disabled, autoFocus, onClick, aria-label) and forwards the rest only into ButtonContent (apps/webapp/app/components/primitives/Buttons.tsx:392-429), which likewise renders plain divs and never spreads its props (apps/webapp/app/components/primitives/Buttons.tsx:250-265). So the pointer/focus handlers and the a11y attributes are discarded; only onClick survives (copying still works). Ref forwarding works, so positioning would be fine — the trigger simply never opens.
The icon variant keeps asChild={false} and is unaffected.
Prompt for agents
CopyButton now passes `asChild` to SimpleTooltip for the `button` variant to avoid nesting a <button> inside the tooltip trigger's own button. However Radix's Slot forwards the trigger's props (onPointerEnter/onPointerLeave/onFocus/onBlur, data-state, aria-describedby) to the child component, and `Button` in apps/webapp/app/components/primitives/Buttons.tsx does not spread unknown props onto its underlying <button> — it forwards only a fixed allowlist and passes the rest to ButtonContent, which also drops them. The result is that the hover tooltip never opens for the button variant. Options: make `Button` spread remaining DOM-safe props onto its <button> element, or wrap the Button in a plain <span> passed as the tooltip trigger child (with asChild) so the handlers land on a real DOM node, or use the Button component's own built-in `tooltip` prop instead of SimpleTooltip here.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Shared between server (dashboard preferences) and client (theme UI, system | ||
| // theme sync) - must stay free of server-only imports. | ||
| export const ThemePreference = z.enum(["classic", "system", "dark", "light"]); | ||
| export const ThemePreference = z.enum(["system", "dark", "light", "black", "white"]); |
There was a problem hiding this comment.
🔍 Existing theme preference test still asserts the removed "classic" value
apps/webapp/test/themePreference.test.ts declares const VALID_THEMES: ThemePreference[] = ["classic", "system", "dark", "light"] and asserts normalizeThemePreference(theme) returns each value unchanged, plus that parseDashboardPreferences accepts each. With classic removed from the enum in apps/webapp/app/utils/themePreference.ts:5, the test no longer typechecks and both assertions fail at runtime ("classic" now normalizes to "dark"). The test also never covers the two new values (black, white) or the new SystemLightTheme/SystemDarkTheme normalizers. Worth updating in this PR so CI stays green.
Was this helpful? React with 👍 or 👎 to provide feedback.
…y theme The measured tables sit inside an Audit, which renders its children once per theme - and the table was building the five themes a second time internally. The result was twenty-five mode blocks per table, each one a full Token / Fill / Ratio / Verdict set, which made the section unreadable. The table is back to one mode: token details, then Fill, Ratio and Verdict. The themes come only from the Audit columns around it. That works without the table knowing anything about themes, because each column is already a `data-theme` context and the ratios are measured off the DOM, so the same table reports that column's answer. Audit takes a column width now, since a block holding four sub-columns needs more room than one holding a single sample. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…me-toggle Eight conflicts, resolved as follows. themePreference.ts: kept this branch's contrast default of 0 and its two new preference normalizers, but left the constant unexported as main had it - nothing imports it, and main's new unused-code gate would reject the export. user.server.ts: kept the three per-field profile writes; dropped grantUserCloudAccess, which main deleted as dead code and which has no callers on either side. tailwind.css: kept this branch's ramp-travel contrast ramps and added main's new radio-card tokens on top - surface-hover-subtle on the dark ramp, mirroring background-hover as it does at contrast 0, and border-selected on the light ramp, a stop steeper than border-bright so the selected card stays the loudest edge. SideMenu.tsx: kept the extracted buildSideMenuSections call over main's inline definition, after checking the two section lists are item-for-item identical. Main's Webhooks item and Upgrade-to-v4 link live outside the sections array and merged untouched. dashboardPreferences.server.ts, storybook.popover, private-connections: took the union where both sides added imports, and this branch's richer popover story. LabelValueStack: accepted main's deletion. This branch had added a storybook section for it, which is gone too - the component was unused in the app, which is why main removed it. Main's unused-code pass also deleted exports that only this branch's storybook pages consumed. Where the code still exists it is re-exported (AlertAction, SheetDescription, SheetFooter); where main removed a small list, it now lives in the page that needs it (log levels, attempt statuses); and where the component itself is gone, the story went with it - AnimatingArrow, PrettyDuration, SmartDateTime, LiveCountUp, InputOTPSeparator, PopoverSideMenuTrigger, TableCellChevron. That same gate flagged exports of this branch's own that nothing imports. They're now module-local, and AppearanceIcon - added by the first commit here and superseded by MonitorIcon - is deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Preview Deployment
|
| if (isAdmin || featureFlags.hasQueryAccess) { | ||
| staticSections.push({ | ||
| id: "metrics", | ||
| title: "Observability", | ||
| items: [ | ||
| ...(isAdmin || featureFlags.hasLogsPageAccess | ||
| ? [ | ||
| { | ||
| id: "logs", | ||
| name: "Logs", | ||
| icon: LogsIcon, | ||
| activeIconColor: "text-logs", | ||
| to: v3LogsPath(organization, project, environment), | ||
| dataAction: "logs", | ||
| badge: <AlphaBadge />, | ||
| } satisfies SideMenuItemConfig, | ||
| ] | ||
| : []), | ||
| { | ||
| id: "errors", | ||
| name: "Errors", | ||
| icon: BugIcon, | ||
| activeIconColor: "text-errors", | ||
| to: v3ErrorsPath(organization, project, environment), | ||
| dataAction: "errors", | ||
| }, | ||
| { | ||
| id: "query", | ||
| name: "Query", | ||
| icon: CodeSquareIcon, | ||
| activeIconColor: "text-query", | ||
| to: queryPath(organization, project, environment), | ||
| dataAction: "query", | ||
| }, | ||
| { | ||
| id: "queues", | ||
| name: "Queues", | ||
| icon: QueuesIcon, | ||
| activeIconColor: "text-queues", | ||
| to: v3QueuesPath(organization, project, environment), | ||
| dataAction: "queues", | ||
| }, | ||
| { | ||
| id: "dashboards", | ||
| name: "Dashboards", | ||
| icon: ChartBarIcon, | ||
| activeIconColor: "text-metrics", | ||
| to: v3DashboardsLandingPath(organization, project, environment), | ||
| dataAction: "dashboards-landing", | ||
| action: dashboards?.action, | ||
| after: dashboards?.after, | ||
| }, | ||
| ], | ||
| }); | ||
| } |
There was a problem hiding this comment.
🔴 Logs navigation disappears for teams that only have logs access
The Observability group of the sidebar is now only built for people with query access (isAdmin || featureFlags.hasQueryAccess at apps/webapp/app/components/navigation/sideMenuSections.tsx:126), instead of also for people who only have logs access, so those users lose the Logs entry from the sidebar entirely.
Impact: Organisations enabled for the Logs page but not Query can no longer reach Logs from the sidebar.
How the section gate changed when the config moved out of SideMenu.tsx
The original code in SideMenu.tsx gated the section with isAdmin || featureFlags.hasQueryAccess || featureFlags.hasLogsPageAccess, and then gated the Errors/Query/Queues/Dashboards items with an inner isAdmin || featureFlags.hasQueryAccess spread. The extracted version keeps the inner Logs check (apps/webapp/app/components/navigation/sideMenuSections.tsx:131) but narrows the outer gate to isAdmin || featureFlags.hasQueryAccess and makes Errors/Query/Queues/Dashboards unconditional. Net effect: with hasLogsPageAccess: true, hasQueryAccess: false (independent flags, see apps/webapp/app/v3/featureFlags.ts), the whole section — including Logs — is now dropped. The Logs route itself still authorizes such users (apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx:76), so the page exists but is unreachable from the menu.
Prompt for agents
When the side-menu section config was extracted from SideMenu.tsx into buildSideMenuSections, the Observability section's gate lost the hasLogsPageAccess branch: it is now `isAdmin || featureFlags.hasQueryAccess`, where the original was `isAdmin || featureFlags.hasQueryAccess || featureFlags.hasLogsPageAccess`. At the same time the Errors/Query/Queues/Dashboards items, which the original only included under `isAdmin || featureFlags.hasQueryAccess`, are now unconditional inside the section. Restore the original semantics: the section should appear when the user is an admin or has either flag, the Logs item should be gated on `isAdmin || hasLogsPageAccess`, and the query-only items should be gated on `isAdmin || hasQueryAccess`. hasLogsPageAccess and hasQueryAccess are independent org feature flags.
Was this helpful? React with 👍 or 👎 to provide feedback.
…them #4654's unused-code pass removed these, correctly by its own measure: each had no importer at all, and the storybook pages that document them exist only on this branch, so the pass couldn't see them. Restored deliberately, because a design-system storybook that silently omits primitives is worse than carrying a few components the product doesn't currently call. Two whole modules come back (AnimatingArrow, PrettyDuration) plus five exports whose surrounding components stayed (SmartDateTime, LiveCountUp, InputOTPSeparator, PopoverSideMenuTrigger, TableCellChevron), and their stories along with them. Every one carries a comment saying why it's kept and pointing here, so the next unused-code pass doesn't quietly take them out again - knip is satisfied either way, since a storybook route is an entry point and anything it imports counts as used. LabelValueStack is the exception and stays deleted: its only historical consumer was a test, not a page. Collateral from reverting those hunks wholesale - useLocalTimeZone, useUserTimeZone and the PopoverArrowTriggerVariant type - is kept but no longer exported, since nothing outside their own files uses them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| const { email } = submission.data; | ||
| const existingUser = await prisma.user.findFirst({ where: { email } }); | ||
| if (existingUser && existingUser.id !== userId) { | ||
| return profileUpdateError("Email is already being used by a different account", 400); | ||
| } | ||
|
|
||
| await updateUserEmail({ id: userId, email }); | ||
| return json({ success: true as const }); |
There was a problem hiding this comment.
🔍 Email change no longer confirms basic details
The single-form action previously called updateUser, which (per the new comment in apps/webapp/app/models/user.server.ts:401-406) also confirmed basic details as a side effect. The per-field writes deliberately drop that. If any onboarding gate keys off confirmedBasicDetails, a user who only ever edits their name/email from this page will no longer clear it — previously editing the profile did. Worth checking whether onboarding relies on the profile page as a path to confirmation.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // The button variant is a real <button>; without asChild the tooltip | ||
| // trigger wraps it in its own, and the browser parser splits the nested | ||
| // buttons apart, which React then fails to hydrate. | ||
| asChild={variant === "button"} | ||
| tabbable={variant === "button"} |
There was a problem hiding this comment.
🔍 Nested-button hydration fix is scoped to one variant only
asChild/tabbable are keyed off variant === "button", which fixes the nested-<button> hydration failure for that variant. The icon variant renders through a different path — worth confirming it isn't also a real <button> under the tooltip trigger, since the same parser/hydration problem would apply there and the conditional would silently leave it broken.
Was this helpful? React with 👍 or 👎 to provide feedback.
…ocument them" This reverts commit 90f04957a0e60c0e4d7a51e2c0b2c1e3f4d8b7a1. #4654's unused-code cleanup stands as written. Every file it touched is now byte-identical to main again: the two modules it deleted stay deleted, the five exports it removed stay removed, and the three it merely unexported (AlertAction, SheetDescription, SheetFooter) are private again too - that last part went beyond the reverted commit, since those re-exports came in with the merge itself. The storybook stories for all of them are gone with them. This branch keeps less primitive coverage than it had, which is the cost of not carrying components the product doesn't call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| * values fall back to `dark`, which is the default. This is also the upgrade | ||
| * path off the removed `classic` theme: a stored "classic" no longer parses, so | ||
| * it lands here and resolves to Dark - which at contrast 0 renders the exact | ||
| * palette Classic used to. Pinned rather than system-resolved, so nobody gets | ||
| * surprised by light mode. */ | ||
| export function normalizeThemePreference(value: unknown): ThemePreference { | ||
| const result = ThemePreference.safeParse(value); | ||
| return result.success ? result.data : "dark"; | ||
| } | ||
|
|
||
| /** The default dark theme ships with a slight contrast bump. */ | ||
| const DEFAULT_THEME_CONTRAST = 50; | ||
| /** Dark's palette at contrast 0 is exactly the palette the Classic theme | ||
| * shipped, so 0 is the default: anyone arriving from Classic sees no change, | ||
| * and the slider only ever adds contrast on top. */ | ||
| const DEFAULT_THEME_CONTRAST = 0; |
There was a problem hiding this comment.
🔍 Stored classic preference silently maps to Dark at contrast 0, but saved contrast is preserved
normalizeThemePreference now rejects classic and falls back to dark, and DEFAULT_THEME_CONTRAST drops from 50 to 0 so the fallback reproduces Classic's palette. Note this only holds for users with no saved contrast value: a user who previously picked Classic and moved the contrast slider keeps their stored contrast, which is now applied to Dark (Classic never read --theme-contrast). Those users will see a contrast shift on first load after deploy. Probably acceptable, but worth a conscious decision since there's no migration of the stored preference.
Was this helpful? React with 👍 or 👎 to provide feedback.
✅ Checklist
Testing
Ran the dashboard locally and switched between System, Light and Dark from the account menu — both in the expanded rail and in the collapsed rail, where the submenu nests inside Account — plus all four themes from the profile page, checking that
data-themefollowed each choice and persisted across reloads. Also dragged the contrast slider to confirm the percentage label tracks the handle and that the Appearance entry stays hidden for a non-admin while thehasThemeSwitcherflag is off.Changelog
Adds an Appearance submenu to the account popover listing System, Light and Dark with a check against the current theme and a "More options" link to your profile, saved through a new
/resources/preferences/themeendpoint behind the existing theme switcher flag. On Your profile the Theme and Contrast rows now match the layout of the Profile rows above them, and the contrast slider moves in steps of 1 with a percentage label above the handle.Screenshots
To attach: the Appearance submenu open in the account popover, and the Appearance section on Your profile.
💯
Note for reviewers: storybook coverage after #4654
This branch merges
main, which includes #4654 (enforce no unused deps or code in ci). That pass removed a set of primitives that had no importer anywhere —AnimatingArrow,PrettyDuration,SmartDateTime,LiveCountUp,InputOTPSeparator,PopoverSideMenuTrigger,TableCellChevron,LabelValueStack— plus a few exports it reduced to module-private.That cleanup is kept exactly as written. Every file it touched is byte-identical to
mainon this branch. The consequence is that the storybook sections this branch had added for those components are removed too, so primitive coverage here is smaller than it was. Deliberate, not an oversight.Open workspace in Conductor