Skip to content

feat(webapp): switch themes from the account menu - #4547

Open
samejr wants to merge 83 commits into
mainfrom
samejr/appearance-theme-toggle
Open

feat(webapp): switch themes from the account menu#4547
samejr wants to merge 83 commits into
mainfrom
samejr/appearance-theme-toggle

Conversation

@samejr

@samejr samejr commented Aug 10, 2026

Copy link
Copy Markdown
Member

✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works

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-theme followed 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 the hasThemeSwitcher flag 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/theme endpoint 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 main on 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

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: 61b390a

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

19/100 over 444 measured of 460 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 444 entry points name a tenant on a failure path. 343 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       444 applicable,  22 pass, 243 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 summarizes the primary change: adding theme switching to the webapp account menu.
Description check ✅ Passed The description includes the checklist, testing steps, changelog, and screenshots section, but it omits an issue reference and actual screenshots.
✨ 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 16 commits August 14, 2026 17:44
… 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>
@samejr
samejr marked this pull request as ready for review August 19, 2026 08:19

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

},
]
: []),
...buildSideMenuSections({ ...context, isAdmin, featureFlags, isManagedCloud }).map(

@devin-ai-integration devin-ai-integration Bot Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +462 to +466
/* 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"] *));

@devin-ai-integration devin-ai-integration Bot Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +36 to +39
/** 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

samejr and others added 2 commits August 19, 2026 08:55
…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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +102 to +106
// 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"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

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"]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

samejr and others added 2 commits August 19, 2026 09:33
…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>
@trigger-dot-bot

trigger-dot-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

Preview Deployment

Status Preview Commit Updated
🟢 Ready samejr-appearance-theme-toggle.triggerlabs.dev 61b390a Aug 19, 12:06 UTC

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +126 to +180
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,
},
],
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread apps/webapp/app/components/primitives/Spinner.tsx
…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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +399 to +406
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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +102 to +106
// 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"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +26 to +39
* 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant