♿ feat: High Contrast Light and Dark Appearance Modes (#15178)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* refactor: read status fill labels from the theme token

Feature call sites paint the label on bg-surface-destructive,
bg-surface-submit and the bg-status-*-strong fills with the raw
text-white utility, while the shared Button, IconButton and Toast
primitives already use text-text-on-status. The token exists so the
label colour can differ per theme mode, and hard-coding white pins it.

Point every call site at text-text-on-status, and compose
DeleteIconButton onto the Button destructive variant rather than
re-declaring the fill classes. Five call sites paired a status fill with
the wrong token entirely (text-text-secondary, text-text-inverted) or
used the non-strong hue as a solid fill; those move to the -strong fill
with the on-status label. Tooltip.css and the app stylesheet copy of the
same rule read colour from --text-primary instead of literal black and
white, and the dead outline: var(--bg-surface-hover) in Dropdown.css
becomes a real 2px token outline, since that variable is not declared
anywhere and the focus ring never rendered.

--text-on-status is 255 255 255 in both shipped themes, so this is a
no-op for the light and dark palettes.

* feat: add high contrast light and dark appearance modes

Adds two appearance modes to the theme setting for users who need more
than the standard palettes, plus contrast resolution for the system mode.

The palettes are complete IThemeRGB maps rather than partial overrides,
because a token left to fall back to defaultTheme or darkTheme would
reintroduce a mid-grey the mode exists to eliminate. They resolve as a
built-in ThemeDefinition that outranks a deployment's custom theme, since
a contrast choice is an accessibility need rather than a branding
preference.

Text clears WCAG AAA on every surface it can render on, including the
hover and active fills. Borders, rings and series marks clear the 3:1
non-text floor. Solid fills clear AAA against both their label and the
page, which is what the per-mode text-on-status token buys: each mode
paints its fills on the far side of its own canvas, dark fills under a
white label on white and bright fills under a black label on black, so
the two ratios coincide instead of pulling apart.

The system mode now follows prefers-contrast the same way it already
follows prefers-color-scheme, so a user who has switched on Increase
contrast or Contrast themes gets the accessible palette without first
finding this setting. isDark reports high-contrast-dark as dark;
isHighContrast answers what the user picked, which is what the theme
toggle preserves when it flips the scheme; resolvesToHighContrast answers
whether the palette applies.

The icon toggle keeps a contrast choice when it flips the scheme, and
announces which of the four states it landed in.

* feat: cover the non-tokenized chrome in high contrast

The token layer reaches every IThemeRGB surface, but a good deal of the
stylesheet predates it and holds literals the palette cannot move. Under
html.high-contrast:

The typography variable block is 32 hard-coded hex values, and message
content carries prose dark:prose-invert, so the invert half is what dark
mode reads. Both halves now resolve from tokens: prose links were 3.77:1
on a black canvas and 5.40:1 on a white one, and every rule and border in
the set sat below the 3:1 non-text floor in one mode or the other.

The highlight.js palette is raw hex and tops out at AA, worst cases
4.84:1 for dark attributes and 5.32:1 for the alpha-blended dark comment;
these reuse the palette's own hues at AAA on each canvas.

Focus perimeters go to 3px and read the ink token rather than literal
black and white. Selection was left to UA system colours and is now an
explicit inversion. Ring opacity is pinned at full strength for the
controls that halve their own. Popover and tooltip elevation trades a
black shadow, which separates against nothing on a black surface, for a
token border. Prose links carry an underline so they are not distinguished
by colour alone. The switch track, the scrollbar thumb and track, the
markdown structure borders and the body background that .dark pins to
#212121 all move onto tokens.

* feat: draw panel and dialog edges in high contrast

Adds a high-contrast: Tailwind variant to the shared preset, so styling
can be scoped to the contrast modes the way dark: is scoped to the dark
class. Deliberately not named contrast-more, which Tailwind already ships
as the raw prefers-contrast media query: this follows the resolved app
mode, so it covers an explicit choice as well as the OS preference.

Two surfaces rely on a colour step that high contrast removes. The
sidebar's right edge is a resize separator that is transparent until
hovered, which is fine where surface-primary-alt carries the boundary but
leaves no edge at all when both sides are the same pure white or black.
Dialogs are borderless and lean on a black shadow, which separates
against nothing on a black canvas.

Both now draw a real edge in the contrast modes only. The dialog change
sits in the Radix content primitives and the two shared templates so the
feature dialogs inherit it, plus the settings panel, which is Headless UI
and bypasses them. OGDialogTemplate carries border-none, so the variant
restores the border style as well as the width.

* fix: address PR review bot findings

chatgpt-codex-connector:

Move the syntax palette into semantic theme tokens. The highlight.js
colours were raw hex in style.css, so a palette change had to be made
twice and neither copy was covered by the registry's completeness check
or any contrast test. They are eight rgb-syntax-* tokens now, declared
across all four theme maps and consumed as CSS variables, which lets the
whole high-contrast block for highlight.js go away. Two guardrails come
with them: the contrast modes assert AAA for every syntax role on the
code surface, and the stylesheet is pinned to the runtime themes with no
hard-coded hex allowed in an .hljs rule.

Two values could not be carried over literally: the dark comment and meta
colours were alpha-blended whites, so they are stored as the flattened
equivalents over the #212121 code surface. Everything else, including the
dark code text, renders byte-identical to before.

Declare Tailwind as a preset dependency. The preset ships as a raw file
through the ./tailwind-preset export while tailwindcss is only a
devDependency, so require('tailwindcss/plugin') could fail to resolve for
a consumer under pnpm or Yarn PnP. Dropped the require instead of adding
a peer dependency, since Tailwind accepts a plain function in plugins and
the wrapper only adds option handling this variant does not use.

Route remaining solid controls through semantic status roles. The
assistant builder kept bg-red-600/bg-red-700 and bg-green-500 with
text-white, which the built-in palette cannot reach, leaving them below
the mode's contract at roughly 2.28:1 for the green controls. All five
now use the destructive and submit surfaces with the on-status label, and
the two that also suppressed their focus ring get a semantic one.

* fix: address second round of PR review bot findings

chatgpt-codex-connector:

Migrate the builder submit to semantic status colors. AssistantPanel's
submit button kept btn-primary, which hardcodes #10a37f and white in
style.css, alongside a raw green hover and focus border. It uses the
submit surface with the on-status label now, and drops btn-primary rather
than fighting its specificity. Left .btn-primary itself alone: restyling
every primary button in the app is not this PR's business.

Give provider avatars contrast-safe brand colors. The white glyph sat at
2.30:1 on OpenAI green, 2.45:1 on Anthropic tan and 3.41:1 on GPT-4
purple. brands was a single theme-wide set, and a brand fill has to carry
a glyph and stand out from the canvas, both of which flip between modes,
so one set cannot serve both at enhanced contrast. ThemeModeDefinition
takes an optional brands block now, resolved after the theme-wide set, and
high contrast declares dark tints under a white glyph for light and bright
tints under a black glyph for dark. Worst pair is 8.76:1 for both the
glyph and the silhouette, with provider hues still recognisable. Brand
validation is shared between the two levels rather than duplicated.

Raise active-surface contrast against the canvas. The active fills sat at
1.48:1 and 1.98:1 on white, 1.93:1 and 2.91:1 on black, and since every
ink token collapses to one colour here, dozens of call sites were left
conveying the selected state with a sub-3:1 fill alone. The fills now
clear 1.4.11 at 3.14:1 to 4.29:1.

That costs the AAA label on those two fills, and the trade is unavoidable:
above the luminance where a fill clears the 3:1 state floor, its own label
cannot reach 7:1, and below it the state disappears. No integer grey
satisfies both. 1.4.11 is Level AA and 1.4.6 is Level AAA, so the state
wins and the labels land at 4.89:1 to 6.69:1. The spec now separates
canvas and hover surfaces, which keep AAA, from the active fills, which
assert the state floor and an AA label.

* fix: address third round of PR review bot findings

chatgpt-codex-connector:

Drop the overriding btn-primary class from Select. My previous round
assumed Tailwind utilities would win here by source order; they do not.
In the built stylesheet .btn-primary sits at byte 136886 and
.bg-surface-submit at 69233, equal specificity, so the hardcoded green won
and that button had never followed the palette. Removed btn-primary, the
same way as the submit button beside it.

Preserve contrast mode when theming Mermaid output. isDark collapses a
contrast mode onto the same boolean, so the cache key was unchanged across
light to high-contrast light and an already-rendered diagram survived the
switch untouched. Both diagram paths take the resolved contrast now: the
cache key gains a suffix, and the config carries themeVariables built from
the contrast palette, since Mermaid's own neutral and dark themes hold a
palette no theme token can reach. Diagrams render on the canvas with ink
nodes, ink edges and ink labels, which is 21:1 for every mark and label.

Artifact diagrams go through the same helper: getMermaidFiles takes the
flag, switches the generated document to Mermaid's base theme, which is
the only built-in that honours themeVariables, and paints the page with
the contrast canvas instead of the fixed #212121 or #FFFFFF. The helper
returns undefined outside the contrast modes, so standard themes keep
their current rendering exactly.

* fix: publish resolved contrast through theme context

chatgpt-codex-connector: notify consumers when system contrast changes.

Under `system` the contrast comes from a media query, so `theme` stays
`system` when the OS preference flips and React has nothing to rerender
on. The listener I added last round only touched the root element, which
is enough for CSS but not for anything computing from the resolved value:
the Mermaid cache key and the artifact files would have kept the old
palette while the rest of the app switched.

ThemeContext exposes `highContrast` now, held as state and set wherever
the mode is applied, which covers both an explicit choice and a media
change. `useMermaid` and `useArtifactProps` read it from context instead
of deriving it locally. `resolvesToHighContrast` stays exported as the
predicate the provider itself resolves with.

The existing media-change test now also asserts the context value moves,
not just the class on the root, since the DOM assertion alone passed
before this fix.

* fix: publish the resolved scheme through theme context too

chatgpt-codex-connector: publish resolved scheme changes through context.

Same staleness as the contrast case, one field over. With the mode at
`system` and contrast already on, an OS colour-scheme change reapplied the
DOM palette but `setHighContrast(true)` was a no-op and `theme` stayed
`system`, so no consumer rerendered and the diagram hooks kept the
previous `isDark` result. This half predates the PR: those hooks derived
the scheme from `theme` before it too, so a scheme flip under `system` has
never reached them.

ThemeContext exposes `resolvedMode` alongside `highContrast`, both set
wherever the mode is applied. The two diagram hooks read the scheme from
context instead of calling isDark themselves.

The other isDark call sites are left alone: none of them cache, so they
recompute whenever something else rerenders them, and sweeping every
consumer onto the context value is a wider change than this PR should
carry.

* fix: address sixth round of PR review bot findings

chatgpt-codex-connector:

Restore a visible focus cue for dropdown options. `.select-item` blanks
its own outline with `outline: none !important`, so the contrast focus
rule never landed and the navigated option was marked only by a
surface-hover fill at 1.48:1 on white and 1.93:1 on black. Hover stays
deliberately soft in these palettes, since it is transient and can afford
an AAA label, so the cue has to be a perimeter: a 3px inset outline on
the active and focused option, matching the !important it has to beat.
Verified with real keyboard navigation that the outline resolves to the
ink colour in both modes.

Apply contrast tokens to artifact controls. getMermaidFiles took the
contrast flag last round but still built its controls from the standard
palettes, whose fixed greys leave the zoom label below AAA and whose
10%-alpha border and divider sit below the 3:1 non-text floor against a
pure canvas. The controls now come from the same tokens as the diagram:
canvas fill, ink border and label, the active surface for hover, and no
shadow, since a black shadow separates against nothing there.

* fix: ignore undefined brand overrides when resolving a theme

chatgpt-codex-connector: ignore undefined mode brand overrides.

Partial<IThemeBrands> lets a key be present with undefined, and the brand
validator accepts it, so spreading a mode block over the inherited set
could overwrite a brand with nothing. Colors survive this because
mapColors skips undefined, but every brand token is written to the DOM
unconditionally, so the avatar would have lost its fill instead of falling
back the way a partial theme promises.

Both spreads now drop undefined entries, which covers the theme-wide set
as well as the mode block, and keeps ResolvedThemeDefinition.brands honest
about being fully populated.

* fix: address eighth round of PR review bot findings

chatgpt-codex-connector:

Guard media queries during server rendering. The state initializers I
added read prefers-color-scheme and prefers-contrast synchronously during
render, so a server-rendered provider threw before producing markup. That
was a regression: nothing in the provider read a media query during render
before. Both reads go through a helper that returns false without a
window, the way the storage helpers already tolerate its absence, so the
server renders the light palette with no contrast override and hydration
applies the real values. The subscription effect is guarded too, which
also covers a host that has no matchMedia at all.

Keep Mermaid pie slices distinct in contrast mode. My own regression: the
contrast variables set primaryColor, secondaryColor and tertiaryColor to
the canvas, and mermaid derives pie1 through pie3 from those, so every
slice collapsed into the background and into its neighbours. The pie slots
come from the palette's series ramp now, which is the categorical scale
already built to clear the mark floor on that canvas and stay separable
under simulated deuteranopia. Twelve slots wrap over seven, as mermaid's
own palettes do.

Remove the raw white override from bookmark submit. The save button passed
text-white into the shared submit variant, which tailwind-merge resolved
in favour of the local class, leaving white on the bright submit fill at
1.40:1. Dropped the override so the variant's token applies.

Preserve the high-contrast canvas in screenshot exports. The export chose
between two fixed colours, so a contrast export was filled with #171717
instead of the mode's canvas. It reads the resolved canvas token now, with
the old pair as the fallback.

Theme markdown artifact previews in contrast modes. useArtifactProps
forwarded the appearance only to the mermaid branch. getMarkdownFiles
takes it now and appends a contrast block derived from the palette; being
unconditional it also outranks the sheet's own prefers-color-scheme query,
so an explicit contrast choice is honoured whatever the OS reports.

* fix: migrate agent version controls to semantic status roles

chatgpt-codex-connector: migrate agent-version controls to semantic status
roles.

The active-version marker and the Restore action used raw green fills with
text-white, about 2.28:1 and 3.30:1, and the built-in palette cannot reach
either. The marker takes the strong success fill with the on-status label,
and Restore takes the submit surface, matching the assistant builder
migration.

Also finished the rest of that component's active-version treatment rather
than leaving it half migrated: the card border and tint, the title, and
the Current chip now use the success subtle, border and strong roles. The
same indicator was expressing one state through both raw and semantic
colour otherwise.

ToolCard's native badge and selected tick were the same shape, raw emerald
under white, so they move to the strong success fill too.

Left alone: the HTTP method badges in ActionsTable/Columns.tsx, the
tool-kind colours in items/icons.ts, and CategoryFilter's active border.
Those are categorical identity rather than status, so mapping them onto
status roles would be wrong; they need the treatment provider brands got,
which is its own change.

* fix: address tenth round of PR review bot findings

chatgpt-codex-connector:

Use opposing ink for pie-section labels. My own regression from the slice
fix: the series ramp deliberately sits on the far side of the canvas so
slices are visible, which makes the canvas ink the wrong colour for a
label drawn inside one. Section labels take the on-status ink now, the
same colour the palette uses for a label on any solid fill, while the
title and legend keep the canvas ink because they are drawn on the canvas.

Preserve the blue initializing status in standard themes. Also mine, from
the first round: I moved this dot to bg-status-info-strong, and the strong
info slot is a neutral grey in both standard palettes, so a blue
indicator turned grey. Back on bg-status-info, with the pulse taking the
on-status ink so it inverts with the fill rather than being a fixed white.

Route action method badges through contrast tokens. I declined this last
round as categorical identity rather than status; the reviewer's framing
is better. The series ramp is precisely the home for categorical identity,
it is contrast-checked per mode, and its slots are separable under
simulated deuteranopia, so the verbs map onto it while staying close to
their conventional colours. `delete` keeps a red by taking the error role,
since destructive genuinely is a status.

* fix: seed published theme values without reading a media query

chatgpt-codex-connector: defer media-query resolution until after
hydration.

Guarding window stopped the crash but left the server and the first client
render disagreeing: the server produced light and false while the client
resolved the OS preferences during that same first render, so any child
reading the published values hydrated against different context.

Both are seeded from the mode string alone now. An explicit mode needs no
query, so it publishes immediately and stays consistent with isDark; only
system starts at the light palette with no contrast override, on both
sides, and applyThemeMode publishes the resolved values from its effect
after hydration.

The pre-existing localStorage read in getInitialTheme has the same shape
of problem for `theme` itself, but changing where the stored mode is read
is a wider change than this and belongs on its own.

* fix: address eleventh round of PR review bot findings

chatgpt-codex-connector:
- keep the active version timeline rail opaque so it clears the 3:1 mark
  floor in both high contrast modes instead of compositing to ~2:1
- balance the dark success fill (#088759) so the selected-tool marker,
  the timeline rail and the prompt-version chip keep a visible silhouette
  on the #212121 panel while the white label stays at WCAG AA
- include the resolved scheme and contrast in the mermaid cache key even
  when a custom theme is supplied, so a contrast switch re-renders

* fix: address twelfth round of PR review bot findings

chatgpt-codex-connector:
- select mermaid's base theme ahead of a caller-supplied theme in the
  contrast modes, after the config spread, so the contrast variables
  actually reach the diagram
- ship the tooltip and popover contrast edges in the package stylesheets
  they belong to, so a consumer importing @librechat/client gets them

* chore: sort imports in the theme registry, provider and specs

* fix: override the markdown preview table header in contrast modes

The appended contrast block answered the ink, the canvas and the cell
borders but not the thead tint, so an explicit contrast choice on the
opposite OS scheme left white text on #f6f8fa or black text on #161b22.
The header now takes the palette canvas and reads through its bold cells
and border, and a new guardrail asserts every colour the base sheet sets
inside prefers-color-scheme is answered by the override.

* fix: address thirteenth round of PR review bot findings

chatgpt-codex-connector:
- keep an OS-requested contrast when the scheme toggle runs under the
  system mode, by reading the resolved contrast rather than the stored
  appearance name
- move the unchecked switch track into the theme token registry so the
  shared Switch keeps its track for consumers of @librechat/client, and
  drop the SPA-only high-contrast overrides it no longer needs

* fix: address fourteenth round of PR review bot findings

chatgpt-codex-connector:
- migrate agent and assistant validation states off raw red utilities and
  onto the destructive text and border roles, so the messages, the field
  outlines and the required markers follow the contrast palettes
- theme the file-source badges with series slots and the status label ink
  instead of fixed blue and yellow fills behind an alpha

* fix: paint the loading canvas from the resolved appearance

The bootstrap in index.html read the stored mode's scheme but not the OS
contrast, so system plus prefers-contrast: more flashed the standard-dark
#0d0d0d behind the pure-black palette for the whole application load. An
unset mode also painted white on a dark OS, though getInitialTheme
resolves it as system. Both branches now resolve the same way the theme
provider does, and a new spec runs the shipped script to prove it.

* fix: theme the markdown renderer failure message

The notice painted when the marked CDN does not load carried an inline
color:#e53e3e, the one colour the appended contrast block could not
reach, leaving it at 4.13:1 on the high-contrast light canvas. It is a
.markdown-error rule now, which the base sheet declares with the current
red and the contrast block overrides with the palette's destructive ink.

* fix: address PR review bot findings

chatgpt-codex-connector:

- Route shortcut, steer, warning, and help-link states through semantic theme roles.
- Add contrast-safe Mermaid Gantt variables for task, status, section, grid, and label states.
- Theme Mermaid artifact render failures with destructive text colors.
- Apply native color-scheme behavior through ThemeProvider and restore host values.

* fix: address follow-up PR review findings

chatgpt-codex-connector:

- Route conflicting shortcuts through the semantic warning border.
- Give checked composer capabilities opaque semantic series borders.
- Move search results and their fade onto the presentation token.
- Restore a visible semantic focus ring on the virtualized results list.

* fix: address third-round review findings

* fix: address fourth-round review findings

* fix: align accessibility theme after dev rebase

* fix: address PR review bot findings

chatgpt-codex-connector:
- Route the steer and queue identities through semantic `status-warning` /
  `status-info` roles in `Chat/Steering/identity.ts`, so the receipt label,
  its pulse dot and every Zap/Clock mark move with the theme instead of
  staying pinned to amber-500/600 and cyan-500. Fixes the high-contrast
  light failures (3.19:1 label text, 1.97:1 and 2.43:1 marks) and the
  default light label, which was also below the 4.5:1 text floor.
- Pair each Monaco theme with the canvas it paints in `ArtifactCodeEditor`,
  so the transparent `@monaco-editor/react` loading view no longer flashes
  the light `surface-code` fill before the `vs-dark` editor mounts outside
  high contrast.

* fix: declare the new dev theme tokens in the high contrast palettes

dev added rgb-text-muted and the two chart widget tokens after this branch
last rebased. The high contrast palettes did not name them, so the widget
canvas and muted text fell back to the standard themes' greys, which the
palette coverage spec caught. Both modes now take the plain canvas, pure
ink and a pure-ink edge, and the contrast tables cover the new tokens.

* style: sort imports in the specs this branch touched

The added imports put ArtifactCodeEditor, ShortcutRecorder and OptionToggle
out of the repo's import order, which the static-checks import-sort gate
rejected.

* fix: address fifth-round review findings

chatgpt-codex-connector:
- Preserve the pressed treatment on a disabled OptionToggle, so a tool made
  programmatic-only keeps showing the Intent state aria-pressed still reports.
- Expose high contrast from the auth appearance control: the scheme toggle
  preserves contrast but never introduces it, so a logged-out user had no way
  to reach the palette. Adds a contrast toggle beside it, and the scheme toggle
  now shows the scheme rather than duplicating the contrast glyph.
- Resolve system contrast from prefers-contrast: custom and forced-colors:
  active as well as more, since a Windows Contrast Theme never reports more.
  Mirrored in the index.html loading bootstrap and the theme README.
- Route the remaining raw palette utilities through theme roles: agent success
  checks (Action, AdvancedPanel, ToolSection) to status-success, the favorited
  tool star to series-4, the orchestration Beta pill to brand-purple, the
  temporary-composer accent to series-6, and the pending quote marks to
  status-info.
- Override the legacy btn-neutral chrome in the contrast block, whose
  hard-coded edge and hover fill sat at 1.1:1 and 2.09:1 on the new canvases.

Rebased onto dev.

* fix: mark the selected tab and theme placeholder ink in contrast modes

Two states rendered invisibly under the high contrast palettes, both verified
in a browser before and after:

- Radix tab triggers mark selection with `surface-tertiary`, which is the plain
  canvas here, so the selected Settings section computed to the same
  rgb(0 0 0) fill as every unselected sibling. Give it the same inset perimeter
  `.select-item` already uses for its navigated option.
- Tailwind preflight paints `::placeholder` with the raw gray-400 #999696, and
  a field that omits a `placeholder:` utility inherits it: the Custom
  Instructions textarea measured 4.06:1 on the black canvas and 3.08:1 on the
  white one, both under the 1.4.3 floor for what is ordinary text. Route it
  through `text-secondary`, which is pure ink in both palettes.

* fix: mark selection with a perimeter and lighten the contrast selected fills

The selected fill was carrying WCAG 1.4.11 on its own, which forced it dark
enough (#8c8c8c light, #5c5c5c dark) that its own label capped at 6.25:1 and
6.69:1 — under the AAA these modes exist for, and visibly heavy on the light
canvas. The `.select-item` rule already showed the better answer: mark the
state with a `text-primary` perimeter at 21:1 and let the fill stay light.

Generalise that rule to the two families it missed — every other Ariakit menu,
whose navigated row carried only a `surface-hover` fill at 1.48:1 on white, and
any row painted with the selected-surface role — then lighten the fills behind
it: #e3e3e3 / #d4d4d4 light and #2b2b2b / #3d3d3d dark, all four AAA under
their label. `-alt` meets `surface-hover`, as it does in the standard palettes.
Inline `span` is excluded so a highlighted citation passage is not boxed per
wrapped fragment.

The palette contract moves with it: `activeFills` is now held to an AAA label
rather than to 3:1 against the canvas, which the perimeter discharges.

* fix: address sixth-round review findings

chatgpt-codex-connector:
- Ship the selected-state contrast with the package. The lightened
  `surface-active` fills only work because a perimeter marks the selection, and
  that perimeter lived in the SPA stylesheet, which `tsdown` never publishes —
  so `TimePicker`, `SelectDropDown`, `MultiSelect`, `InputWithDropDown`, `Badge`
  and `DataTable` gave an external consumer a 1.28:1 fill and nothing else. The
  perimeters and the placeholder ink now live in
  `packages/client/src/theme/highContrast.css`, imported by `ThemeProvider`;
  `style.css` keeps only what is genuinely SPA-only. The Ariakit selector also
  widened from four menu roles to `[data-active-item]`, and picked up
  `[data-state='selected']` for package data-table rows.
- Give Monaco selections a contrast-safe highlight: a focused selection now
  inverts the canvas (`text-primary` on `surface-primary`), matching
  `::selection`, because Monaco draws no perimeter. Weaker emphases share
  `surface-hover-alt` behind the ink find-match border.
- Populate Mermaid's separate `git0`-`git7` branch palette from the series ramp.
  Eight slots against a seven-slot ramp, so the last takes the ink rather than
  repeating branch one; labels take the canvas, since the ramp sits on its far
  side.
- Debounce the two appearance controls separately. They are independent
  settings, so light -> high-contrast dark is one flip of each, and the shared
  500ms window swallowed the second click.
- Route sharing principal badges (USER/GROUP/ROLE) through series-1/7/6.
- Apply the selected contrast mode to Office artifacts. The backend document
  picks its palette from `prefers-color-scheme` alone, so an explicit mode
  inverted whenever the OS disagreed. `withOfficeContrast` re-declares its
  `:root` tokens and pins `color-scheme` from inside the head.

* fix: address seventh-round review findings

chatgpt-codex-connector:
- Restore the citation highlight. The perimeter rule skips `span` so a wrapped
  passage is not boxed per fragment, which left the inline highlight on the
  lightened fill alone. It inverts now instead — the same treatment the mode
  gives `::selection`, 21:1 and with no geometry to wrap.
- Underline non-prose links. The 1.4.1 rule only reached `.markdown` and
  `.prose`, so the auth resend actions and in-dialog help links were colour-only
  against ink that is under 3:1 from them. It now also covers the shared
  `text-link` role, including the ones rendered as a `button`, and moved to the
  package stylesheet since that role is shared. MCP server descriptions style
  their anchors through an `[&_a]` variant rather than a class of their own, so
  they get the underline at the call site — running text, so it applies in every
  theme, not just the contrast ones.
- Route the selected tool card through `status-success` instead of
  `emerald-500/60` over a 6% fill, which composited to about 1.7:1 and left a
  selected card with a weaker edge than an unselected one.

* fix: keep helper hints opaque in the contrast modes

chatgpt-codex-connector: the `opacity-40` default-value hints composite pure
ink to about #999 on the white canvas (2.85:1) and #666 on the black one
(3.66:1), so small active labels missed even AA under a palette that contracts
for AAA. They take `high-contrast:opacity-100` now — the variant this branch
already added and `MessageNav` already uses — rather than a new token: every
text role is the same pure ink in these palettes, so there is no muted colour
to fall back to and the hierarchy is carried by size and the parentheses.

The mobile artifact drag handle gets the same treatment: it is a control, so
1.4.11 applies, and a 40% ink bar does not clear 3:1. The two remaining
`opacity-40` sites are left alone deliberately — `AssistantTool` dims a
disabled row, which WCAG exempts, and the `MarketplaceCatalog` empty-state
glyph is `aria-hidden` decoration.

* style: sort imports in the two parameter controls this branch touched

* fix: soften the temporary-chat accent outside the contrast modes

Moving the accent to `series-6` swapped a 60%-alpha `violet-800` edge for a
full-strength one, and series-6 is a saturated #7e23cd on the light palette and
#ab68fe on the dark one — the composer read as a warning rather than a quiet
mode hint. Half alpha in the standard palettes, opaque only under
`high-contrast:`, which is the one place the edge has to clear the 3:1
non-text floor.
This commit is contained in:
Marco Beretta 2026-09-05 22:20:33 +02:00 • committed by GitHub
parent 1c115cdebf
commit 8144574b43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
149 changed files with 4038 additions and 434 deletions

View file

@ -32,15 +32,35 @@
const loadingContainerStyle = document.createElement('style');
let backgroundColor;
if (theme === 'dark') {
if (theme === 'high-contrast-dark') {
backgroundColor = '#000000';
} else if (theme === 'high-contrast-light') {
backgroundColor = '#ffffff';
} else if (theme === 'dark') {
backgroundColor = '#0d0d0d';
} else if (theme === 'light') {
backgroundColor = '#ffffff';
} else if (theme === 'system') {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
backgroundColor = prefersDarkScheme ? '#0d0d0d' : '#ffffff';
} else {
backgroundColor = '#ffffff';
/* `system`, and anything unset or unrecognised, which ThemeProvider also
* resolves as `system`. The OS decides the scheme and the contrast, and
* a contrast request moves the dark canvas to pure black, so keying off
* the scheme alone would flash #0d0d0d behind the black palette.
* The three contrast queries mirror ThemeProvider's `CONTRAST_QUERIES`:
* a Windows Contrast Theme reports `forced-colors: active` with
* `prefers-contrast: custom`, never `more`. */
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
const prefersMoreContrast = [
'(prefers-contrast: more)',
'(prefers-contrast: custom)',
'(forced-colors: active)',
].some(function (query) {
return window.matchMedia(query).matches;
});
backgroundColor = prefersDarkScheme
? prefersMoreContrast
? '#000000'
: '#0d0d0d'
: '#ffffff';
}
loadingContainerStyle.innerHTML = `

View file

@ -0,0 +1,76 @@
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
/** The pre-React canvas is painted by an inline bootstrap in `index.html`, which
* no bundle imports, so the only way to hold it to the resolved palette is to
* run the script the document actually ships. */
const bootstrap = (() => {
const html = readFileSync(join(__dirname, '..', '..', 'index.html'), 'utf8');
const script = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map(([, body]) => body)
.find((body) => body.includes('#loading-container'));
if (!script) {
throw new Error('index.html no longer carries a #loading-container bootstrap');
}
return script;
})();
const canvasFor = (stored: string | null, matching: string[]): string | undefined => {
window.localStorage.clear();
if (stored !== null) {
window.localStorage.setItem('color-theme', stored);
}
window.matchMedia = ((query: string) =>
({ matches: matching.includes(query) }) as MediaQueryList) as typeof window.matchMedia;
document.head.querySelectorAll('style').forEach((node) => node.remove());
new Function(bootstrap)();
return document.head
.querySelector('style')
?.innerHTML.match(/background-color:\s*([^;\s]+)/)?.[1];
};
const DARK_SCHEME = '(prefers-color-scheme: dark)';
const MORE_CONTRAST = '(prefers-contrast: more)';
const CUSTOM_CONTRAST = '(prefers-contrast: custom)';
const FORCED_COLORS = '(forced-colors: active)';
describe('loading canvas', () => {
it.each([
['high-contrast-dark', [], '#000000'],
['high-contrast-light', [], '#ffffff'],
['dark', [], '#0d0d0d'],
['light', [], '#ffffff'],
])('paints the stored %s mode', (stored, matching, expected) => {
expect(canvasFor(stored, matching as string[])).toBe(expected);
});
/** The mismatch this guards: `system` plus `prefers-contrast: more` resolves to
* the pure-black palette, so the standard-dark canvas would flash behind it
* for the whole application load. */
it('follows both OS preferences under system', () => {
expect(canvasFor('system', [DARK_SCHEME, MORE_CONTRAST])).toBe('#000000');
expect(canvasFor('system', [DARK_SCHEME])).toBe('#0d0d0d');
expect(canvasFor('system', [MORE_CONTRAST])).toBe('#ffffff');
expect(canvasFor('system', [])).toBe('#ffffff');
});
/** A Windows Contrast Theme reports `forced-colors: active` with
* `prefers-contrast: custom`, never `more`, so keying the canvas off `more`
* alone flashes #0d0d0d on the platform the theme README names. */
it('treats a forced-colors palette as a contrast request', () => {
expect(canvasFor('system', [DARK_SCHEME, CUSTOM_CONTRAST, FORCED_COLORS])).toBe('#000000');
expect(canvasFor('system', [DARK_SCHEME, FORCED_COLORS])).toBe('#000000');
});
/** An unset or unrecognised value is what `getInitialTheme` resolves as
* `system`, so the canvas has to resolve it the same way. */
it('treats an unset or unknown mode as system', () => {
expect(canvasFor(null, [DARK_SCHEME])).toBe('#0d0d0d');
expect(canvasFor(null, [DARK_SCHEME, MORE_CONTRAST])).toBe('#000000');
expect(canvasFor('sepia', [DARK_SCHEME, MORE_CONTRAST])).toBe('#000000');
});
});

View file

@ -25,6 +25,8 @@ export const ThemeSelector = ({
{ value: 'system', label: localize('com_nav_theme_system') },
{ value: 'dark', label: localize('com_nav_theme_dark') },
{ value: 'light', label: localize('com_nav_theme_light') },
{ value: 'high-contrast-light', label: localize('com_nav_theme_high_contrast_light') },
{ value: 'high-contrast-dark', label: localize('com_nav_theme_high_contrast_dark') },
];
const labelId = 'theme-selector-label';

View file

@ -1,7 +1,7 @@
// ThemeSelector.spec.tsx
import 'test/matchMedia.mock';
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { clickDropdown, flushDropdownEffects } from 'test/dropdown';
import '@testing-library/jest-dom/extend-expect';
import { RecoilRoot } from 'recoil';
@ -58,4 +58,41 @@ describe('ThemeSelector', () => {
expect(mockOnChange).toHaveBeenCalledWith('dark');
});
});
it('offers both high contrast options and reports the selected one', async () => {
global.ResizeObserver = class MockedResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
const { getByText, getByTestId } = render(
<RecoilRoot>
<ThemeSelector theme="system" onChange={mockOnChange} />
</RecoilRoot>,
);
fireEvent.click(getByTestId('theme-selector'));
expect(getByText('High contrast light')).toBeInTheDocument();
fireEvent.click(getByText('High contrast dark'));
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('high-contrast-dark');
});
});
it('shows the active high contrast mode as the current value', () => {
global.ResizeObserver = class MockedResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
const { getByRole } = render(
<RecoilRoot>
<ThemeSelector theme="high-contrast-light" onChange={mockOnChange} />
</RecoilRoot>,
);
expect(getByRole('combobox')).toHaveTextContent('High contrast light');
});
});

View file

@ -1,5 +1,8 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import { ThemeContext, highContrastDarkTheme, highContrastLightTheme } from '@librechat/client';
import type { Monaco } from '@monaco-editor/react';
import type { IThemeRGB } from '@librechat/client';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
@ -17,7 +20,13 @@ interface MutationHandlers {
onError?: (error?: unknown) => void;
}
const mockEditorProps: { onChange?: (value: string | undefined) => void } = {};
interface MonacoEditorProps {
onChange?: (value: string | undefined) => void;
beforeMount?: (monaco: Monaco) => void;
theme?: string;
}
const mockEditorProps: MonacoEditorProps = {};
const mockMutationHandlers: MutationHandlers = {};
// Calling mutate replays onMutate synchronously so currentUpdateRef reflects the
@ -28,8 +37,8 @@ const mockMutate = jest.fn((vars: MutationVars) => {
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: (props: { onChange?: (value: string | undefined) => void }) => {
mockEditorProps.onChange = props.onChange;
default: (props: MonacoEditorProps) => {
Object.assign(mockEditorProps, props);
return null;
},
}));
@ -81,12 +90,65 @@ const otherArtifact: Artifact = {
type: 'text/plain',
};
const renderEditor = (initial: Artifact = artifact) => {
type Appearance = {
resolvedMode: 'light' | 'dark';
highContrast: boolean;
};
const defaultAppearance: Appearance = { resolvedMode: 'light', highContrast: false };
const renderEditor = (initial: Artifact = artifact, initialAppearance = defaultAppearance) => {
const monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null> = { current: null };
const utils = render(<ArtifactCodeEditor artifact={initial} monacoRef={monacoRef} />);
const rerenderWith = (next: Artifact) =>
utils.rerender(<ArtifactCodeEditor artifact={next} monacoRef={monacoRef} />);
return { ...utils, rerenderWith };
let currentArtifact = initial;
let currentAppearance = initialAppearance;
const tree = () => (
<ThemeContext.Provider
value={
{
resolvedMode: currentAppearance.resolvedMode,
highContrast: currentAppearance.highContrast,
} as React.ContextType<typeof ThemeContext>
}
>
<ArtifactCodeEditor artifact={currentArtifact} monacoRef={monacoRef} />
</ThemeContext.Provider>
);
const utils = render(tree());
const rerenderWith = (next: Artifact) => {
currentArtifact = next;
utils.rerender(tree());
};
const rerenderAppearance = (next: Appearance) => {
currentAppearance = next;
utils.rerender(tree());
};
return { ...utils, rerenderWith, rerenderAppearance };
};
const toHexColor = (palette: IThemeRGB, token: keyof IThemeRGB) =>
`#${palette[token]
?.split(/\s+/)
.map((channel) => Number(channel).toString(16).padStart(2, '0'))
.join('')}`;
const createMonacoMock = () => {
const defaults = {
setDiagnosticsOptions: jest.fn(),
setCompilerOptions: jest.fn(),
};
const defineTheme = jest.fn();
const monaco = {
editor: { defineTheme },
languages: {
typescript: {
typescriptDefaults: defaults,
javascriptDefaults: defaults,
JsxEmit: { React: 1 },
},
},
} as unknown as Monaco;
return { monaco, defineTheme };
};
const fireEdit = (value: string) => {
@ -96,10 +158,12 @@ const fireEdit = (value: string) => {
});
};
describe('ArtifactCodeEditor retry guard', () => {
describe('ArtifactCodeEditor', () => {
beforeEach(() => {
jest.useFakeTimers();
mockEditorProps.onChange = undefined;
mockEditorProps.beforeMount = undefined;
mockEditorProps.theme = undefined;
mockMutationHandlers.onMutate = undefined;
mockMutationHandlers.onSuccess = undefined;
mockMutationHandlers.onError = undefined;
@ -110,6 +174,73 @@ describe('ArtifactCodeEditor retry guard', () => {
jest.useRealTimers();
});
it('paints the loading canvas with the standard Monaco canvas outside high contrast', () => {
const { container } = renderEditor();
expect(mockEditorProps.theme).toBe('vs-dark');
expect(container.firstElementChild).toHaveStyle({ backgroundColor: '#1e1e1e' });
});
it('moves the Monaco theme and its loading canvas together across contrast appearances', () => {
const { rerenderAppearance, container } = renderEditor(artifact, {
resolvedMode: 'light',
highContrast: true,
});
expect(mockEditorProps.theme).toBe('librechat-high-contrast-light');
expect(container.firstElementChild).toHaveStyle({
backgroundColor: toHexColor(highContrastLightTheme, 'rgb-surface-primary-alt'),
});
rerenderAppearance({ resolvedMode: 'dark', highContrast: true });
expect(mockEditorProps.theme).toBe('librechat-high-contrast-dark');
expect(container.firstElementChild).toHaveStyle({
backgroundColor: toHexColor(highContrastDarkTheme, 'rgb-presentation'),
});
});
it('defines both contrast themes from the semantic syntax palettes', () => {
renderEditor(artifact, { resolvedMode: 'light', highContrast: true });
const { monaco, defineTheme } = createMonacoMock();
mockEditorProps.beforeMount?.(monaco);
expect(defineTheme).toHaveBeenCalledTimes(2);
expect(defineTheme).toHaveBeenCalledWith(
'librechat-high-contrast-light',
expect.objectContaining({
base: 'vs',
inherit: false,
colors: expect.objectContaining({
'editor.background': toHexColor(highContrastLightTheme, 'rgb-surface-primary-alt'),
'editor.foreground': toHexColor(highContrastLightTheme, 'rgb-syntax-text'),
'editor.selectionBackground': toHexColor(highContrastLightTheme, 'rgb-text-primary'),
'editor.selectionForeground': toHexColor(highContrastLightTheme, 'rgb-surface-primary'),
}),
rules: expect.arrayContaining([
expect.objectContaining({
token: 'keyword',
foreground: toHexColor(highContrastLightTheme, 'rgb-syntax-keyword').slice(1),
}),
]),
}),
);
expect(defineTheme).toHaveBeenCalledWith(
'librechat-high-contrast-dark',
expect.objectContaining({
base: 'vs-dark',
inherit: false,
colors: expect.objectContaining({
'editor.background': toHexColor(highContrastDarkTheme, 'rgb-presentation'),
'editor.foreground': toHexColor(highContrastDarkTheme, 'rgb-syntax-text'),
'editor.selectionBackground': toHexColor(highContrastDarkTheme, 'rgb-text-primary'),
'editor.selectionForeground': toHexColor(highContrastDarkTheme, 'rgb-surface-primary'),
}),
}),
);
});
it('does not re-run a mutation for content that just failed', () => {
renderEditor();

View file

@ -1,7 +1,9 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import React, { useMemo, useState, useEffect, useRef, useCallback, useContext } from 'react';
import debounce from 'lodash/debounce';
import MonacoEditor from '@monaco-editor/react';
import { ThemeContext, highContrastDarkTheme, highContrastLightTheme } from '@librechat/client';
import type { Monaco } from '@monaco-editor/react';
import type { IThemeRGB } from '@librechat/client';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
@ -50,6 +52,126 @@ const TYPE_MAP: Record<string, string> = {
'application/vnd.mermaid': 'markdown',
};
const HIGH_CONTRAST_LIGHT_EDITOR_THEME = 'librechat-high-contrast-light';
const HIGH_CONTRAST_DARK_EDITOR_THEME = 'librechat-high-contrast-dark';
/** Monaco's stock `vs-dark` canvas. `@monaco-editor/react` renders its loading
* view transparent, so the wrapper has to paint whatever the active Monaco
* theme paints or the Code tab flashes the wrong color before the editor
* mounts. The contrast themes take their canvas from the semantic palettes
* below; this one belongs to a Monaco built-in, so no LibreChat role names it
* and no theme can move it. */
const STANDARD_EDITOR_BACKGROUND = '#1e1e1e';
const toHexColor = (palette: IThemeRGB, token: keyof IThemeRGB): string => {
const value = palette[token];
if (value == null) {
throw new Error(`Missing Monaco theme token: ${token}`);
}
const channels = value.split(/\s+/).map((channel) => Number(channel));
if (channels.length !== 3 || channels.some((channel) => !Number.isInteger(channel))) {
throw new Error(`Invalid Monaco theme token: ${token}`);
}
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
};
const createHighContrastEditorTheme = (
palette: IThemeRGB,
base: 'vs' | 'vs-dark',
backgroundToken: keyof IThemeRGB,
): editor.IStandaloneThemeData => {
const color = (token: keyof IThemeRGB) => toHexColor(palette, token);
const syntax = (token: keyof IThemeRGB) => color(token).slice(1);
return {
base,
inherit: false,
rules: [
{ token: '', foreground: syntax('rgb-syntax-text') },
{ token: 'identifier', foreground: syntax('rgb-syntax-text') },
{ token: 'comment', foreground: syntax('rgb-syntax-comment') },
{ token: 'meta', foreground: syntax('rgb-syntax-meta') },
{ token: 'annotation', foreground: syntax('rgb-syntax-meta') },
{ token: 'delimiter', foreground: syntax('rgb-syntax-meta') },
{ token: 'predefined', foreground: syntax('rgb-syntax-builtin') },
{ token: 'class', foreground: syntax('rgb-syntax-builtin') },
{ token: 'keyword', foreground: syntax('rgb-syntax-keyword') },
{ token: 'literal', foreground: syntax('rgb-syntax-keyword') },
{ token: 'string', foreground: syntax('rgb-syntax-string') },
{ token: 'regexp', foreground: syntax('rgb-syntax-string') },
{ token: 'variable', foreground: syntax('rgb-syntax-attr') },
{ token: 'number', foreground: syntax('rgb-syntax-attr') },
{ token: 'type', foreground: syntax('rgb-syntax-attr') },
{ token: 'attribute.name', foreground: syntax('rgb-syntax-attr') },
{ token: 'tag', foreground: syntax('rgb-syntax-title') },
{ token: 'symbol', foreground: syntax('rgb-syntax-title') },
],
colors: {
'editor.background': color(backgroundToken),
'editor.foreground': color('rgb-syntax-text'),
'editorLineNumber.foreground': color('rgb-text-secondary'),
'editorLineNumber.activeForeground': color('rgb-text-primary'),
'editorCursor.foreground': color('rgb-ring-primary'),
/** These palettes have no mid-tones, so a focused selection inverts the
* canvas exactly as `::selection` does in `client/src/style.css` — 21:1,
* and the only treatment that works without the perimeter the selected
* surfaces rely on elsewhere. The weaker emphases (unfocused selection,
* matching occurrences, other find hits) share `surface-hover-alt` so
* they never outshout the focused one; the current find match is told
* apart by its ink `findMatchBorder`. */
'editor.selectionBackground': color('rgb-text-primary'),
'editor.selectionForeground': color('rgb-surface-primary'),
'editor.inactiveSelectionBackground': color('rgb-surface-hover-alt'),
'editor.selectionHighlightBackground': color('rgb-surface-hover-alt'),
'editor.lineHighlightBackground': color('rgb-surface-hover'),
'editor.findMatchBackground': color('rgb-surface-hover-alt'),
'editor.findMatchBorder': color('rgb-border-heavy'),
'editor.findMatchHighlightBackground': color('rgb-surface-hover-alt'),
'editorWidget.background': color('rgb-surface-dialog'),
'editorWidget.border': color('rgb-border-medium'),
'input.background': color('rgb-surface-primary'),
'input.foreground': color('rgb-text-primary'),
'input.border': color('rgb-border-medium'),
focusBorder: color('rgb-ring-primary'),
'editorIndentGuide.background1': color('rgb-border-light'),
'editorIndentGuide.activeBackground1': color('rgb-border-heavy'),
'scrollbarSlider.background': color('rgb-border-light'),
'scrollbarSlider.hoverBackground': color('rgb-border-heavy'),
'scrollbarSlider.activeBackground': color('rgb-border-xheavy'),
},
};
};
const highContrastLightEditorTheme = createHighContrastEditorTheme(
highContrastLightTheme,
'vs',
'rgb-surface-primary-alt',
);
const highContrastDarkEditorTheme = createHighContrastEditorTheme(
highContrastDarkTheme,
'vs-dark',
'rgb-presentation',
);
/** Theme name paired with the canvas it paints, so the wrapper behind the
* editor and the editor itself can never disagree. */
type EditorAppearance = { theme: string; background: string };
const standardEditorAppearance: EditorAppearance = {
theme: 'vs-dark',
background: STANDARD_EDITOR_BACKGROUND,
};
const highContrastLightEditorAppearance: EditorAppearance = {
theme: HIGH_CONTRAST_LIGHT_EDITOR_THEME,
background: highContrastLightEditorTheme.colors['editor.background'],
};
const highContrastDarkEditorAppearance: EditorAppearance = {
theme: HIGH_CONTRAST_DARK_EDITOR_THEME,
background: highContrastDarkEditorTheme.colors['editor.background'],
};
type ArtifactEditTarget = {
artifactId: string;
messageId: string;
@ -107,6 +229,7 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null>;
readOnly?: boolean;
}) {
const { resolvedMode, highContrast } = useContext(ThemeContext);
const { isSubmitting } = useArtifactsContext();
const readOnly = (externalReadOnly ?? false) || isSubmitting;
const { setCurrentCode } = useCodeState();
@ -338,6 +461,9 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
* as `{ deprecated: true }` while the runtime API is fully functional.
*/
const handleBeforeMount = useCallback((monaco: Monaco) => {
monaco.editor.defineTheme(HIGH_CONTRAST_LIGHT_EDITOR_THEME, highContrastLightEditorTheme);
monaco.editor.defineTheme(HIGH_CONTRAST_DARK_EDITOR_THEME, highContrastDarkEditorTheme);
const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages
.typescript as unknown as {
typescriptDefaults: {
@ -388,6 +514,13 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
);
const language = getMonacoLanguage(artifact.type, artifact.language);
let editorAppearance = standardEditorAppearance;
if (highContrast) {
editorAppearance =
resolvedMode === 'dark'
? highContrastDarkEditorAppearance
: highContrastLightEditorAppearance;
}
const editorOptions = useMemo<editor.IStandaloneEditorConstructionOptions>(
() => ({
@ -433,11 +566,11 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
}
return (
<div className="h-full w-full bg-[#1e1e1e]">
<div className="h-full w-full" style={{ backgroundColor: editorAppearance.background }}>
<MonacoEditor
height="100%"
language={readOnly ? 'plaintext' : language}
theme="vs-dark"
theme={editorAppearance.theme}
defaultValue={artifact.content}
onChange={handleChange}
beforeMount={handleBeforeMount}

View file

@ -382,7 +382,7 @@ export default function Artifacts() {
onPointerCancel={handleDragEnd}
onKeyDown={handleDragKeyDown}
>
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60 motion-reduce:transition-none" />
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60 high-contrast:opacity-100 motion-reduce:transition-none" />
</div>
)}

View file

@ -107,7 +107,6 @@ const BookmarkEditDialog = ({
type="submit"
disabled={mutation.isLoading}
onClick={handleSubmitForm}
className="text-white"
>
{mutation.isLoading ? <Spinner /> : localize('com_ui_save')}
</Button>

View file

@ -74,7 +74,8 @@ const DeleteBookmarkButton: FC<{
}
selection={{
selectHandler: confirmDelete,
selectClasses: 'bg-surface-destructive hover:bg-surface-destructive-hover text-white',
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -598,8 +598,16 @@ const ChatForm = memo(function ChatForm({
composerSurfaceClasses(),
isTextAreaFocused ? composerSurfaceShadow.focused : composerSurfaceShadow.blurred,
/* Temporary-chat accent is a ChatForm-only override, not part of
the shared composer-surface decision. */
isTemporary && 'border-violet-800/60 bg-violet-950/10',
the shared composer-surface decision. Semantic `series-6`, the
same categorical slot the purple tool badge uses, so the accent
follows the theme instead of the raw `violet-800/60` edge that
composited to 1.48:1 on the high contrast dark canvas.
Held at half alpha in the standard palettes, where series-6 is
a saturated #7e23cd / #ab68fe and a full-strength edge reads as
a warning rather than a quiet mode hint. The contrast modes take
it opaque, because that is the only way it clears the 3:1
non-text floor there. */
isTemporary && 'border-series-6/50 bg-series-6/10 high-contrast:border-series-6',
)}
>
{project ? <ProjectLandingChip project={project} /> : null}

View file

@ -13,8 +13,11 @@ const sourceToClassname = {
[FileSources.azure]: 'azure-bg-color',
[FileSources.azure_blob]: 'azure-bg-color',
[FileSources.execute_code]: 'bg-black text-white opacity-85',
[FileSources.text]: 'bg-blue-500 dark:bg-blue-900 opacity-85 text-white',
[FileSources.vectordb]: 'bg-yellow-700 dark:bg-yellow-900 opacity-85 text-white',
/** Categorical rather than status: a source is an identity, so it takes a
* series slot and the label ink that pairs with one. No alpha here, which
* composited the fill back into the preview the badge sits on. */
[FileSources.text]: 'bg-series-1 text-text-on-status',
[FileSources.vectordb]: 'bg-series-4 text-text-on-status',
};
const defaultClassName =

View file

@ -19,6 +19,7 @@ import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDia
import { supportsGenerationProtocolV2, useArmSteerMutation } from '~/data-provider';
import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer';
import MessageQuotes from '~/components/Chat/Messages/Content/MessageQuotes';
import { QUEUE_ICON, STEER_ICON } from '~/components/Chat/Steering/identity';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks';
@ -438,7 +439,7 @@ const InFlightSteer = memo(function InFlightSteer({
{
key: 'queue',
label: localize('com_ui_convert_to_queue'),
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
icon: <Clock className={cn('h-4 w-4', QUEUE_ICON)} aria-hidden="true" />,
onClick: () => {
void reclaim().then((reclaimed) => {
if (reclaimed) {
@ -520,7 +521,7 @@ const InFlightSteer = memo(function InFlightSteer({
>
<MorphIcon
icon={preempting ? ZapOff : Zap}
className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500"
className={cn('mt-1 h-3.5 w-3.5 shrink-0', STEER_ICON)}
/>
<span className="sr-only">
{localize(preempting ? 'com_ui_steer_in_flight_preempt' : 'com_ui_steer_in_flight')}

View file

@ -115,7 +115,7 @@ export default function MCPConfigDialog({
/>
{details.description && (
<p
className="text-xs text-text-secondary [&_a]:text-link [&_a]:hover:text-link-hover"
className="text-xs text-text-secondary [&_a]:text-link [&_a]:underline [&_a]:hover:text-link-hover"
dangerouslySetInnerHTML={{ __html: sanitize(details.description) }}
/>
)}
@ -128,7 +128,7 @@ export default function MCPConfigDialog({
}
selection={{
selectHandler: handleSubmit(onFormSubmit),
selectClasses: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
selectClasses: 'bg-surface-submit text-text-on-status hover:bg-surface-submit-hover',
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
}}
buttons={

View file

@ -42,7 +42,7 @@ function PendingManualSkillsChips({ conversationId }: { conversationId: string }
role="listitem"
className="inline-flex items-center gap-1 rounded-full border border-border-light bg-surface-secondary px-2 py-0.5 text-xs text-text-secondary"
>
<ScrollText className="h-3 w-3 text-cyan-500" aria-hidden="true" />
<ScrollText className="h-3 w-3 text-status-info" aria-hidden="true" />
<span className="max-w-[12rem] truncate">{name}</span>
<button
type="button"

View file

@ -11,6 +11,13 @@ const REMOVE_BTN_CLASS =
'-mr-0.5 shrink-0 rounded-full p-0.5 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
const TRIGGER_CLASS =
'inline-flex min-w-0 items-center gap-1.5 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
/**
* Quote mark. Semantic `status-info` rather than `cyan-500`, matching the queue
* identity in `Chat/Steering/identity.ts`: the raw hue lands at 2.43:1 on the
* high contrast light composer, under the 3:1 floor for a chip whose only type
* cue is this icon.
*/
const QUOTE_ICON_CLASS = 'h-4 w-4 shrink-0 text-status-info';
/** Grace period so moving the pointer between the chip and the popup doesn't close it. */
const CLOSE_DELAY_MS = 120;
@ -87,7 +94,7 @@ function PendingQuoteChips({ conversationId }: { conversationId: string }) {
>
{!isMulti ? (
<span role="listitem" className={CHIP_CLASS}>
<TextQuote className="h-4 w-4 shrink-0 text-cyan-500" aria-hidden="true" />
<TextQuote className={QUOTE_ICON_CLASS} aria-hidden="true" />
<span className="max-w-[16rem] truncate" title={quotes[0]}>
{quotes[0]}
</span>
@ -114,7 +121,7 @@ function PendingQuoteChips({ conversationId }: { conversationId: string }) {
aria-label={localize('com_ui_quote_selections', { 0: quotes.length })}
onClick={() => setFocusOnShow(true)}
>
<TextQuote className="h-4 w-4 shrink-0 text-cyan-500" aria-hidden="true" />
<TextQuote className={QUOTE_ICON_CLASS} aria-hidden="true" />
<span className="truncate">
{localize('com_ui_quote_selections', { 0: quotes.length })}
</span>

View file

@ -27,6 +27,7 @@ import {
useDefaultToggleEntry,
useInterruptToggleEntry,
} from './SteerMenu';
import { QUEUE_ICON, STEER_ICON } from '~/components/Chat/Steering/identity';
import { escalatingSteerFamily } from '~/store/steer';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -211,7 +212,7 @@ function QueuedRow({
{isRejected || isUnconfirmed || isIndeterminate ? (
<TriangleAlert className="h-4 w-4 shrink-0 text-text-warning" aria-hidden="true" />
) : (
<Clock className="h-4 w-4 shrink-0 text-cyan-500" aria-hidden="true" />
<Clock className={cn('h-4 w-4 shrink-0', QUEUE_ICON)} aria-hidden="true" />
)}
<span className="min-w-0 flex-1 truncate" title={message.text}>
{message.text}
@ -238,7 +239,7 @@ function QueuedRow({
>
{canSteerNow ? (
<>
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
<Zap className={cn('h-4 w-4', STEER_ICON)} aria-hidden="true" />
{localize('com_ui_steer')}
</>
) : (
@ -338,7 +339,7 @@ function FailedSteerRow({
{
key: 'queue',
label: localize('com_ui_convert_to_queue'),
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
icon: <Clock className={cn('h-4 w-4', QUEUE_ICON)} aria-hidden="true" />,
onClick: () =>
steering.convertSteerToQueue(
steer.steerId,
@ -354,10 +355,10 @@ function FailedSteerRow({
return (
<div
role="listitem"
className={cn(ROW_CLASS, 'border-red-500/60')}
className={cn(ROW_CLASS, 'border-border-destructive')}
data-testid="steer-message-row"
>
<Zap className="h-4 w-4 shrink-0 text-red-500" aria-hidden="true" />
<Zap className="h-4 w-4 shrink-0 text-text-destructive" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate" title={steer.text}>
{steer.text}
</span>
@ -367,7 +368,7 @@ function FailedSteerRow({
0: String(steer.quotes?.length ?? 0),
})}
/>
<span className="shrink-0 text-xs text-red-500">
<span className="shrink-0 text-xs text-text-destructive">
{localize(
steer.deliveryUncertain ? 'com_ui_steer_delivery_unconfirmed' : 'com_ui_steer_failed',
)}

View file

@ -19,7 +19,7 @@ import store from '~/store';
const commandChar = '$';
const ROW_HEIGHT = 44;
const skillIcon = <ScrollText className="icon-md text-cyan-500" />;
const skillIcon = <ScrollText className="icon-md text-status-info" />;
/**
* Determines whether a skill should appear in the `$` command popover.

View file

@ -7,6 +7,7 @@ import { ArrowUp, CircleHelp, MoreHorizontal } from 'lucide-react';
import type { Ref } from 'react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { useShortcutAriaKey, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
import { QUEUE_ICON, STEER_ICON } from '~/components/Chat/Steering/identity';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -288,7 +289,7 @@ export function useDefaultToggleEntry(steering: SteeringControls): MenuEntry {
icon: (
<MorphIcon
icon={next === 'queue' ? Clock : Zap}
className={cn('h-4 w-4', next === 'queue' ? 'text-cyan-500' : 'text-amber-500')}
className={cn('h-4 w-4', next === 'queue' ? QUEUE_ICON : STEER_ICON)}
/>
),
info: localize('com_nav_info_during_run_action'),
@ -314,7 +315,10 @@ export function useInterruptToggleEntry(): MenuEntry {
? localize('com_ui_wait_for_tool_steps')
: localize('com_ui_always_interrupt'),
icon: (
<MorphIcon icon={interruptsByDefault ? Zap : ZapOff} className="h-4 w-4 text-amber-500" />
<MorphIcon
icon={interruptsByDefault ? Zap : ZapOff}
className={cn('h-4 w-4', STEER_ICON)}
/>
),
info: localize(
!interruptsByDefault && defaultAction === 'queue'

View file

@ -31,11 +31,12 @@ describe('PendingManualSkillsChips', () => {
});
it('renders one chip per queued skill', () => {
renderWithSkills(['brand-guidelines', 'pptx']);
const { container } = renderWithSkills(['brand-guidelines', 'pptx']);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('brand-guidelines');
expect(items[1]).toHaveTextContent('pptx');
expect(container.querySelector('svg')).toHaveClass('text-status-info');
});
it('removes the chip when its × button is clicked', async () => {

View file

@ -146,6 +146,24 @@ describe('PendingSteerChips — ambiguous delivery retry', () => {
{ preempt: false, createdAt: 1, generationProtocolVersion: 2 },
);
});
it('uses semantic destructive roles for a failed steer', () => {
renderChips([], {
steers: [
{
steerId: 'failed-steer',
text: 'failed message',
status: 'failed',
createdAt: 1,
},
],
});
const row = screen.getByTestId('steer-message-row');
expect(row).toHaveClass('border-border-destructive');
expect(row.querySelector('svg')).toHaveClass('text-text-destructive');
expect(screen.getByText('com_ui_steer_failed')).toHaveClass('text-text-destructive');
});
});
describe('PendingSteerChips — queued primary availability', () => {

View file

@ -0,0 +1,17 @@
import { badgeAccents } from '../accents';
describe('badgeAccents', () => {
it('uses opaque semantic series borders for checked capability badges', () => {
expect(badgeAccents).toEqual({
amber: 'border-series-4 bg-series-4/10 hover:bg-series-4/20',
blue: 'border-series-1 bg-series-1/10 hover:bg-series-1/20',
cyan: 'border-series-3 bg-series-3/10 hover:bg-series-3/20',
green: 'border-series-7 bg-series-7/10 hover:bg-series-7/20',
purple: 'border-series-6 bg-series-6/10 hover:bg-series-6/20',
});
expect(Object.values(badgeAccents).join(' ')).not.toMatch(
/(?:amber|blue|cyan|green|purple)-\d/,
);
});
});

View file

@ -3,9 +3,9 @@
* Written out in full because Tailwind cannot resolve interpolated class names.
*/
export const badgeAccents = {
amber: 'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
blue: 'border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10',
cyan: 'border-cyan-600/40 bg-cyan-500/10 hover:bg-cyan-700/10',
green: 'border-green-600/40 bg-green-500/10 hover:bg-green-700/10',
purple: 'border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10',
amber: 'border-series-4 bg-series-4/10 hover:bg-series-4/20',
blue: 'border-series-1 bg-series-1/10 hover:bg-series-1/20',
cyan: 'border-series-3 bg-series-3/10 hover:bg-series-3/20',
green: 'border-series-7 bg-series-7/10 hover:bg-series-7/20',
purple: 'border-series-6 bg-series-6/10 hover:bg-series-6/20',
} as const;

View file

@ -36,7 +36,7 @@ const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
className="absolute flex items-center justify-center rounded-full bg-surface-destructive"
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
>
<AlertCircle size={10} className="text-white" />
<AlertCircle size={10} className="text-text-on-status" />
</div>
)}
</div>

View file

@ -2,6 +2,7 @@ import React from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { render, screen, fireEvent } from '@testing-library/react';
import type { TMessage } from 'librechat-data-provider';
import { STEER_ICON } from '~/components/Chat/Steering/identity';
import SteerPart from '../SteerPart';
import store from '~/store';
@ -277,13 +278,13 @@ describe('SteerPart live receipt draw-in', () => {
describe('SteerPart receipt settling', () => {
const checks = () => screen.getByLabelText('com_ui_steer_applied_info').querySelector('svg');
it('keeps the amber identity while the owning response is still generating', () => {
it('keeps the steer identity while the owning response is still generating', () => {
render(
<RecoilRoot initializeState={({ set }) => set(store.user, SEEDED_USER as never)}>
<SteerPart steer="steered words" steerId="s1" createdAt={1} isSubmitting />
</RecoilRoot>,
);
expect(checks()).toHaveClass('dark:text-amber-500');
expect(checks()).toHaveClass(STEER_ICON);
expect(checks()).not.toHaveClass('text-text-secondary');
});
@ -292,6 +293,6 @@ describe('SteerPart receipt settling', () => {
// rendering: still a double check, no longer lit.
renderPart();
expect(checks()).toHaveClass('text-text-secondary');
expect(checks()).not.toHaveClass('dark:text-amber-500');
expect(checks()).not.toHaveClass(STEER_ICON);
});
});

View file

@ -53,9 +53,9 @@ function SkillPills({
className="inline-flex items-center gap-1 rounded-full border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary"
>
{source === 'always-apply' ? (
<Pin className="h-3 w-3 text-cyan-500" aria-hidden="true" />
<Pin className="h-3 w-3 text-status-info" aria-hidden="true" />
) : (
<ScrollText className="h-3 w-3 text-cyan-500" aria-hidden="true" />
<ScrollText className="h-3 w-3 text-status-info" aria-hidden="true" />
)}
<span className="max-w-[12rem] truncate">{name}</span>
</span>

View file

@ -60,10 +60,11 @@ describe('SkillPills', () => {
const manualSvg = manual.querySelector('svg');
expect(alwaysApplySvg).toBeTruthy();
expect(manualSvg).toBeTruthy();
// Pin vs. ScrollText have distinct lucide class names; both carry the
// cyan-500 accent the component applies, but only one carries the
// lucide-pin class.
// Pin vs. ScrollText have distinct lucide class names, but both use the
// same semantic informational color.
expect(alwaysApplySvg?.getAttribute('class')).toContain('lucide-pin');
expect(manualSvg?.getAttribute('class')).not.toContain('lucide-pin');
expect(alwaysApplySvg).toHaveClass('text-status-info');
expect(manualSvg).toHaveClass('text-status-info');
});
});

View file

@ -290,7 +290,7 @@ const indicatorButtonClasses = cn(
);
const dimIndicatorClasses =
'opacity-40 group-hover/nav:opacity-100 group-focus-within/nav:opacity-100';
'opacity-40 high-contrast:opacity-50 group-hover/nav:opacity-100 group-focus-within/nav:opacity-100';
const MessageIndicator = memo(function MessageIndicator({
entry,
@ -350,7 +350,7 @@ const MessageIndicator = memo(function MessageIndicator({
});
const chevronButtonClasses = cn(
'-mr-1 rounded-md p-0.5 text-text-tertiary opacity-40 transition-[color,opacity] duration-300',
'-mr-1 rounded-md p-0.5 text-text-tertiary opacity-40 transition-[color,opacity] duration-300 high-contrast:opacity-50',
'group-hover/nav:text-text-secondary group-hover/nav:opacity-100',
'group-focus-within/nav:text-text-secondary group-focus-within/nav:opacity-100',
'group-hover/nav:hover:text-text-primary',

View file

@ -1,5 +1,5 @@
import React from 'react';
import { render, act, fireEvent } from '@testing-library/react';
import { render, act, fireEvent, screen } from '@testing-library/react';
type ReactNode = React.ReactNode;
type RefObject<T> = React.RefObject<T>;
@ -392,7 +392,17 @@ describe('MessageNav', () => {
const ribB = container.querySelector('[data-msg-id="b"]') as HTMLElement;
expect(ribA.className).toContain('opacity-100');
expect(ribA.className).not.toContain('opacity-40');
expect(ribA.querySelector('span')).toHaveClass('bg-text-primary');
expect(ribB.className).toContain('opacity-40');
expect(ribB.className).toContain('high-contrast:opacity-50');
expect(ribB.querySelector('span')).toHaveClass('bg-text-tertiary');
expect(ribA.querySelector('span')?.className).not.toMatch(/bg-gray-|dark:bg-gray-/);
expect(ribB.querySelector('span')?.className).not.toMatch(/bg-gray-|dark:bg-gray-/);
const previous = screen.getByRole('button', { name: 'com_ui_message_nav_previous' });
const next = screen.getByRole('button', { name: 'com_ui_message_nav_next' });
expect(previous).toHaveClass('text-text-tertiary', 'high-contrast:opacity-50');
expect(next).toHaveClass('text-text-tertiary', 'high-contrast:opacity-50');
});
});
@ -716,7 +726,7 @@ describe('MessageNav', () => {
const activeLine = container.querySelector('[aria-current="true"] span');
expect(activeLine?.className).toContain('bg-text-primary');
/** The other two rows are on screen, so they read as the in-view band —
/** The other two rows are on screen, so they read as the in-view band:
* lit, but plainly not the mark that says where the reader is. */
const band = messageRibs(container)
.slice(1)
@ -1218,7 +1228,7 @@ describe('MessageNav', () => {
expect(writes.length).toBeGreaterThan(0);
});
it('highlights only the hovered rib white, dimming the rest', () => {
it('highlights only the hovered rib, dimming the rest', () => {
const messages = [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
@ -1234,11 +1244,11 @@ describe('MessageNav', () => {
});
const ribs = messageRibs(container);
const white = ribs.filter((r) =>
const highlighted = ribs.filter((r) =>
r.querySelector('span')?.className.includes('bg-text-primary'),
);
expect(white).toHaveLength(1);
expect(white[0]).toHaveAttribute('data-msg-id', 'a');
expect(highlighted).toHaveLength(1);
expect(highlighted[0]).toHaveAttribute('data-msg-id', 'a');
});
});

View file

@ -1,16 +1,12 @@
import { memo } from 'react';
import { Check, CheckCheck } from 'lucide-react';
import { InfoHoverCard, ESide } from '@librechat/client';
import { STEER_DOT, STEER_ICON } from './identity';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export type SteerReceiptState = 'sending' | 'delivered' | 'interrupting' | 'applied';
/** Amber-500 is the steering identity (the in-flight Zap), but it reads weak
* on white; 600 carries the same identity at legible contrast in light. */
const AMBER_ICON = 'text-amber-600 dark:text-amber-500';
const AMBER_DOT = 'bg-amber-600 dark:bg-amber-500';
/**
* Delivery receipt for a steered message: iMessage-style checkmarks whose
* every state is driven by an event the protocol already emits, so a check
@ -20,11 +16,11 @@ const AMBER_DOT = 'bg-amber-600 dark:bg-amber-500';
* exists from the first frame instead of flickering in.
* - `delivered`: the 202 durably queued it (one gray check).
* - `interrupting`: asked to seal generation at the next safe boundary — the
* amber label and pulsing dot say "working, not stuck". Until the arm is
* server-confirmed (`confirmed` false) the label shows without its check.
* steer-colored label and pulsing dot say "working, not stuck". Until the arm
* is server-confirmed (`confirmed` false) the label shows without its check.
* - `applied`: the running response durably absorbed the words (double check,
* persistent). It stays lit amber only while the owning response is still
* generating — the window where "did it land" is the question — then
* persistent). It stays lit in the steer color only while the owning response
* is still generating — the window where "did it land" is the question — then
* settles to the same muted gray as the timestamp, so a conversation full
* of historical steers doesn't glow forever.
*
@ -40,7 +36,7 @@ const SteerReceipt = memo(function SteerReceipt({
}: {
state: SteerReceiptState;
/** Applied only: the owning response is still generating, so the receipt is
* the newest thing on screen and keeps the amber steering identity. Off
* the newest thing on screen and keeps the steering identity color. Off
* (settled, reload, share, search) it dims to the timestamp gray. */
live?: boolean;
/** Server confirmation of the underlying enqueue/arm; an interrupt awaiting
@ -76,7 +72,7 @@ const SteerReceipt = memo(function SteerReceipt({
<CheckCheck
className={cn(
'h-4 w-4 transition-colors duration-theme-normal motion-reduce:transition-none',
live ? AMBER_ICON : 'text-text-secondary',
live ? STEER_ICON : 'text-text-secondary',
animateIn && 'ease-out animate-in fade-in-0 zoom-in-50 motion-reduce:animate-none',
)}
aria-hidden="true"
@ -102,20 +98,20 @@ const SteerReceipt = memo(function SteerReceipt({
<span className="flex items-center gap-1.5">
{interrupting && (
<span
className={cn('h-1.5 w-1.5 rounded-full motion-safe:animate-pulse', AMBER_DOT)}
className={cn('h-1.5 w-1.5 rounded-full motion-safe:animate-pulse', STEER_DOT)}
aria-hidden="true"
/>
)}
{(!interrupting || confirmed) && (
<Check
className={cn('h-4 w-4', interrupting ? AMBER_ICON : 'text-text-secondary')}
className={cn('h-4 w-4', interrupting ? STEER_ICON : 'text-text-secondary')}
aria-hidden="true"
/>
)}
<span
className={cn(
'text-xs',
interrupting ? cn('font-medium', AMBER_ICON) : 'text-text-secondary',
interrupting ? cn('font-medium', STEER_ICON) : 'text-text-secondary',
)}
>
{localize(interrupting ? 'com_ui_steer_in_flight_preempt' : 'com_ui_steer_delivered')}

View file

@ -0,0 +1,22 @@
/**
* Steering identity colors, shared by the composer chips, the row menus and the
* inline delivery receipt so one steer reads the same everywhere it appears.
*
* Written as semantic status roles rather than palette hues. Both marks carry
* state, and the receipt renders its label in the steer color at 12px, so the
* identity has to clear the 4.5:1 text floor as well as the 3:1 floor for
* graphical objects — on every canvas, including the enhanced-contrast themes.
* A fixed palette hue cannot: `amber-600` label text lands at 3.19:1 on the
* high-contrast light canvas, and the `amber-500`/`cyan-500` marks at 1.97:1
* and 2.43:1, because a raw utility does not move when the theme does.
*
* `status-warning` and `status-info` are the theme-aware roles nearest the
* amber and cyan identities they replace, and they clear both floors in every
* theme: 4.69:1 and 4.83:1 at worst on the default light surfaces, 8.42:1 and
* 10.31:1 at high contrast light, and higher on both dark canvases. The roles
* also match the semantics — a steer preempts the running response, a queued
* message is merely waiting.
*/
export const STEER_ICON = 'text-status-warning';
export const STEER_DOT = 'bg-status-warning';
export const QUEUE_ICON = 'text-status-info';

View file

@ -84,7 +84,7 @@ describe('MessageEndpointIcon', () => {
/>,
);
const badge = container.querySelector('[title="OpenAI"] > .bg-status-error');
const badge = container.querySelector('[title="OpenAI"] > .bg-status-error-strong');
expect(badge).toHaveTextContent('!');
});

View file

@ -78,7 +78,7 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
};
const errorBadge = error === true && (
<span className="absolute right-0 top-[20px] -mr-2 flex h-3 w-3 items-center justify-center rounded-full border border-white bg-status-error text-[10px] text-white">
<span className="absolute right-0 top-[20px] -mr-2 flex h-3 w-3 items-center justify-center rounded-full border border-surface-primary bg-status-error-strong text-[10px] text-text-on-status">
!
</span>
);

View file

@ -38,7 +38,7 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
>
{icon}
{error === true && (
<span className="absolute right-0 top-[20px] -mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-white bg-status-error text-[10px] text-text-secondary">
<span className="absolute right-0 top-[20px] -mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-surface-primary bg-status-error-strong text-[10px] text-text-on-status">
!
</span>
)}

View file

@ -81,7 +81,9 @@ export default function Settings({
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
{localize('com_endpoint_custom_name')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_blank')})
</small>
</Label>
<Input
id="chatGptLabel"
@ -99,7 +101,9 @@ export default function Settings({
<div className="grid w-full items-center gap-2">
<Label htmlFor="promptPrefix" className="text-left text-sm font-medium">
{localize('com_endpoint_prompt_prefix')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_blank')})
</small>
</Label>
<TextareaAutosize
id="promptPrefix"
@ -120,7 +124,7 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_with_num', { 0: '1' })})
</small>
</Label>
@ -161,7 +165,7 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_with_num', { 0: '1' })})
</small>
</Label>
@ -203,7 +207,7 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_frequency_penalty')}{' '}
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
@ -245,7 +249,7 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_presence_penalty')}{' '}
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>

View file

@ -152,7 +152,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="grid w-full items-center gap-2">
<Label htmlFor="promptPrefix" className="text-left text-sm font-medium">
{localize('com_endpoint_prompt_prefix_assistants')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_blank')})
</small>
</Label>
<TextareaAutosize
id="promptPrefix"
@ -169,7 +171,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="grid w-full items-center gap-2">
<Label htmlFor="instructions" className="text-left text-sm font-medium">
{localize('com_endpoint_instructions_assistants')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_blank')})
</small>
</Label>
<TextareaAutosize
id="instructions"

View file

@ -32,7 +32,9 @@ function Examples({ readonly, examples, setExample, addExample, removeExample }:
<div className="grid w-full items-center gap-2">
<Label htmlFor={`input-${idx}`} className="text-left text-sm font-medium">
{localize('com_ui_input')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_blank')})
</small>
</Label>
<TextareaAutosize
id={`input-${idx}`}
@ -57,7 +59,9 @@ function Examples({ readonly, examples, setExample, addExample, removeExample }:
<div className="grid w-full items-center gap-2">
<Label htmlFor={`output-${idx}`} className="text-left text-sm font-medium">
{localize('com_endpoint_output')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_blank')})
</small>
</Label>
<TextareaAutosize
id={`output-${idx}`}

View file

@ -35,10 +35,10 @@ export const URLIcon = memo(
</div>
{imageError && iconURL && (
<div
className="absolute flex items-center justify-center rounded-full bg-status-error"
className="absolute flex items-center justify-center rounded-full bg-status-error-strong"
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
>
<AlertCircle size={10} className="text-white" aria-hidden="true" />
<AlertCircle size={10} className="text-text-on-status" aria-hidden="true" />
</div>
)}
</div>

View file

@ -8,10 +8,7 @@ type DeleteIconButtonProps = {
export default function DeleteIconButton({ onClick }: DeleteIconButtonProps) {
return (
<div className="w-fit">
<Button
className="bg-surface-destructive p-3 hover:bg-surface-destructive-hover"
onClick={onClick}
>
<Button variant="destructive" className="p-3" onClick={onClick}>
<TrashIcon />
</Button>
</div>

View file

@ -137,7 +137,7 @@ const RevokeKeysButton = ({
variant="destructive"
onClick={onClick}
disabled={isLoading}
className="bg-surface-destructive text-white transition-all duration-200 hover:bg-surface-destructive-hover"
className="bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover"
>
{isLoading ? <Spinner /> : localize('com_ui_revoke')}
</Button>

View file

@ -118,8 +118,11 @@ interface CompactStatusDotProps {
function CompactStatusDot({ serverStatus, isInitializing }: CompactStatusDotProps) {
if (isInitializing) {
return (
/** `status-info` rather than `-strong`: the strong slot is a neutral grey
* in both standard palettes, so using it turned this blue dot grey. The
* pulse takes the on-status ink so it inverts with the fill. */
<div className="flex size-3.5 items-center justify-center rounded-full border-2 border-surface-secondary bg-status-info">
<div className="size-1.5 animate-pulse rounded-full bg-white" />
<div className="size-1.5 animate-pulse rounded-full bg-text-on-status" />
</div>
);
}

View file

@ -63,7 +63,10 @@ export default function SettingsDialog({ open, onOpenChange }: TDialogProps) {
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel
className={cn(
'flex max-h-[85vh] w-full flex-col overflow-hidden rounded-2xl bg-surface-dialog shadow-2xl',
/** Headless UI panel, so it bypasses the shared dialog primitives
* and needs the contrast edge declared here. `shadow-2xl` is a
* black shadow with nothing to separate against on a black canvas. */
'flex max-h-[85vh] w-full flex-col overflow-hidden rounded-2xl bg-surface-dialog shadow-2xl high-contrast:border high-contrast:border-solid high-contrast:border-border-medium high-contrast:shadow-none',
'md:h-[85vh] md:w-[900px]',
)}
>

View file

@ -63,7 +63,15 @@ export const registry: SettingEntry[] = [
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_theme',
keywords: ['dark', 'light', 'appearance', 'color'],
keywords: [
'dark',
'light',
'appearance',
'color',
'contrast',
'high contrast',
'accessibility',
],
Component: ThemeSetting,
},
{

View file

@ -186,7 +186,7 @@ const renderDeleteButton = (
'mt-4 w-full gap-0 bg-surface-tertiary text-text-primary transition-all duration-200 hover:bg-surface-tertiary',
isLocked
? 'cursor-not-allowed opacity-30 disabled:opacity-30'
: 'bg-surface-destructive text-white hover:bg-surface-destructive-hover disabled:opacity-100',
: 'bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover disabled:opacity-100',
)}
onClick={handleDeleteUser}
disabled={isDeleting || isLocked}

View file

@ -58,7 +58,7 @@ export default function DeleteKeyDialog({
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-surface-destructive text-white transition-all duration-200 hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover',
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>

View file

@ -45,7 +45,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
disabled={disabled}
onClick={onClick}
className={cn(
'btn relative border-none bg-surface-destructive text-white hover:bg-surface-destructive-hover',
'btn relative border-none bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover',
className,
)}
>

View file

@ -54,7 +54,7 @@ export const ClearChats = () => {
selection={{
selectHandler: clearConvos,
selectClasses:
'bg-surface-destructive text-white transition-all duration-200 hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover',
selectText: clearConvosMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>

View file

@ -67,7 +67,7 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
selection={{
selectHandler: revokeAllUserKeys,
selectClasses:
'bg-surface-destructive text-white transition-all duration-200 hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover',
selectText: isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>

View file

@ -62,7 +62,7 @@ export const RevokeKeys = ({
selection={{
selectHandler: onClick,
selectClasses:
'bg-surface-destructive text-white transition-all duration-200 hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover',
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
}}
/>

View file

@ -345,7 +345,7 @@ export default function SharedLinks() {
}
selection={{
selectHandler: confirmDelete,
selectClasses: `bg-surface-destructive hover:bg-surface-destructive-hover text-white ${
selectClasses: `bg-surface-destructive hover:bg-surface-destructive-hover text-text-on-status ${
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
}`,
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),

View file

@ -15,7 +15,7 @@ export default function DecibelSelector() {
<div className="flex items-center justify-between">
<div id="decibel-selector-label">{localize('com_nav_db_sensitivity')}</div>
<div className="w-2" />
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_with_num', { 0: '-45' })})
</small>
</div>

View file

@ -15,7 +15,7 @@ export default function DecibelSelector() {
<div className="flex items-center justify-between">
<div id="playback-rate-label">{localize('com_nav_playback_rate')}</div>
<div className="w-2" />
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default_with_num', { 0: '1' })})
</small>
</div>

View file

@ -167,9 +167,9 @@ export function RecorderPill({
state;
let stateBorder = 'border-border-medium';
if (hasConflict) {
stateBorder = 'border-amber-500/60';
stateBorder = 'border-status-warning-border';
} else if (showInvalid) {
stateBorder = 'animate-shortcut-shake border-red-500/60';
stateBorder = 'animate-shortcut-shake border-border-destructive';
}
return (
<div
@ -247,7 +247,7 @@ export function RecorderInfo({
<span
className={cn(
'text-[11.5px]',
showInvalid ? 'text-red-600 dark:text-red-400' : 'text-text-secondary',
showInvalid ? 'text-text-destructive' : 'text-text-secondary',
)}
>
{showInvalid

View file

@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { RecorderInfo, RecorderPill, useShortcutRecorder } from '../ShortcutRecorder';
import { bindingHash } from '~/utils/shortcuts';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
function Harness({ bindingMap = new Map() }: { bindingMap?: Map<string, string> }) {
const state = useShortcutRecorder({
initial: null,
bindingMap,
ownerId: 'test-action',
getActionLabel: (id) => id,
onSave: jest.fn(),
onCancel: jest.fn(),
});
return (
<>
<RecorderPill state={state} ariaLabel="Shortcut recorder" ownerId="test-action" />
<RecorderInfo
state={state}
ownerId="test-action"
onCancel={jest.fn()}
onSaveReplacing={jest.fn()}
/>
</>
);
}
describe('ShortcutRecorder', () => {
it('uses semantic destructive roles when a shortcut has no modifier', () => {
render(<Harness />);
const recorder = screen.getByRole('textbox', { name: 'Shortcut recorder' });
fireEvent.keyDown(recorder, { key: 'a' });
expect(recorder).toHaveClass('border-border-destructive');
expect(screen.getByText('com_shortcut_recorder_needs_modifier')).toHaveClass(
'text-text-destructive',
);
});
it('uses the semantic warning border when a shortcut conflicts', () => {
const binding = { meta: false, ctrl: true, alt: false, shift: false, key: 'A' };
render(<Harness bindingMap={new Map([[bindingHash(binding), 'other-action']])} />);
const recorder = screen.getByRole('textbox', { name: 'Shortcut recorder' });
fireEvent.keyDown(recorder, { key: 'a', ctrlKey: true });
expect(recorder).toHaveClass('border-status-warning-border');
});
});

View file

@ -183,11 +183,7 @@ function PluginAuthForm({
</span>
)}
{showHint && (
<span
id={`${authField}-hint`}
aria-live="polite"
className="text-amber-600 dark:text-amber-500"
>
<span id={`${authField}-hint`} aria-live="polite" className="text-text-warning">
{localize('com_ui_auth_format_hint', { 0: expectedPrefix })}
</span>
)}

View file

@ -48,7 +48,7 @@ describe('PluginAuthForm', () => {
expect(urlField.parentElement?.querySelector('button')).toBeNull();
});
it('shows a format-hint placeholder for recognized credential fields', () => {
it('shows a semantic warning for a recognized credential with the wrong format', async () => {
const openAiPlugin = {
pluginKey: 'dalle',
authConfig: [{ authField: 'DALLE3_API_KEY||DALLE_API_KEY', label: 'OpenAI API Key' }],
@ -56,7 +56,12 @@ describe('PluginAuthForm', () => {
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={openAiPlugin} onSubmit={onSubmit} />);
expect(screen.getByLabelText('OpenAI API Key')).toHaveAttribute('placeholder', 'sk-...');
const input = screen.getByLabelText('OpenAI API Key');
expect(input).toHaveAttribute('placeholder', 'sk-...');
await userEvent.type(input, 'invalid-key');
expect(document.getElementById('DALLE3_API_KEY-hint')).toHaveClass('text-text-warning');
});
it('calls the onSubmit function with the form data when submitted', async () => {

View file

@ -60,7 +60,7 @@ const AdminSettings = () => {
setConfirmAdminUseChange(null);
},
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-white transition-colors duration-200',
'bg-surface-destructive hover:bg-surface-destructive-hover text-text-on-status transition-colors duration-200',
selectText: localize('com_ui_confirm_action'),
isLoading: false,
}}

View file

@ -58,7 +58,7 @@ const DeleteConfirmDialog = ({
selection={{
selectHandler,
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-white',
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -49,7 +49,7 @@ const VersionBadge = ({
const getTimelineConnectorClasses = (isSelected: boolean, isProduction: boolean) => {
if (isSelected) {
return 'border-status-success bg-status-success text-text-inverted';
return 'border-status-success-strong bg-status-success-strong text-text-on-status';
}
if (isProduction) {
return 'border-status-success-border bg-transparent text-status-success';

View file

@ -0,0 +1,42 @@
import React from 'react';
import { render } from '@testing-library/react';
import CategoryIcon from './CategoryIcon';
const categoryColors = {
code: 'text-series-5',
misc: 'text-series-1',
shop: 'text-series-6',
idea: 'text-series-4',
write: 'text-series-6',
travel: 'text-series-4',
finance: 'text-series-2',
roleplay: 'text-series-2',
teach_or_explain: 'text-series-1',
general: 'text-series-1',
hr: 'text-series-7',
rd: 'text-series-6',
it: 'text-series-5',
sales: 'text-series-2',
aftersales: 'text-series-4',
};
describe('CategoryIcon', () => {
it.each(Object.entries(categoryColors))(
'uses the semantic series color for %s',
(category, color) => {
const { container } = render(<CategoryIcon category={category} />);
const icon = container.querySelector('svg');
expect(icon).toHaveClass(color);
expect(icon?.getAttribute('class')).not.toMatch(
/(?:dark:)?text-(?:red|blue|purple|yellow|orange|green)-/,
);
},
);
it('uses secondary text for an unknown category', () => {
const { container } = render(<CategoryIcon category="unknown" />);
expect(container.querySelector('svg')).toHaveClass('text-text-secondary');
});
});

View file

@ -35,21 +35,21 @@ const categoryIconMap: Record<string, React.ElementType> = {
};
const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-500/90 dark:text-yellow-300',
write: 'text-purple-400',
travel: 'text-yellow-500/90 dark:text-yellow-300',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
general: 'text-blue-500',
hr: 'text-green-500',
rd: 'text-purple-500',
it: 'text-red-500',
sales: 'text-orange-500',
aftersales: 'text-yellow-500',
code: 'text-series-5',
misc: 'text-series-1',
shop: 'text-series-6',
idea: 'text-series-4',
write: 'text-series-6',
travel: 'text-series-4',
finance: 'text-series-2',
roleplay: 'text-series-2',
teach_or_explain: 'text-series-1',
general: 'text-series-1',
hr: 'text-series-7',
rd: 'text-series-6',
it: 'text-series-5',
sales: 'text-series-2',
aftersales: 'text-series-4',
};
export default function CategoryIcon({

View file

@ -20,22 +20,24 @@ const PeoplePickerSearchItem = forwardRef<HTMLDivElement, PeoplePickerSearchItem
const displayName = name || localize('com_ui_unknown');
const subtitle = email || `${type} (${principal.source || 'local'})`;
/** Get badge styling based on type */
/** Semantic series roles rather than palette hues: a raw utility does not
* move when the theme does, and these labels render at 12px, so they answer
* to the text floor on whichever canvas the viewer picked. */
const getBadgeConfig = () => {
switch (type) {
case PrincipalType.USER:
return {
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
className: 'bg-series-1/10 text-series-1',
label: localize('com_ui_user'),
};
case PrincipalType.GROUP:
return {
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
className: 'bg-series-7/10 text-series-7',
label: localize('com_ui_group'),
};
case PrincipalType.ROLE:
return {
className: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
className: 'bg-series-6/10 text-series-6',
label: localize('com_ui_role'),
};
default:

View file

@ -269,7 +269,7 @@ export default function ActionsInput({
className="block min-h-[12rem] w-full resize-y rounded-lg border border-border-light bg-transparent p-3 font-mono text-xs leading-relaxed transition-colors focus-visible:border-border-heavy focus-visible:ring-0"
/>
{validationError && (
<div className="mt-1.5 text-xs text-red-500">
<div className="mt-1.5 text-xs text-text-destructive">
{validationError.split('\n').map((line: string, i: number) => (
<div key={i}>{line}</div>
))}
@ -323,7 +323,7 @@ export default function ActionsInput({
/>
</div>
{validationError && (
<div className="max-h-24 shrink-0 overflow-y-auto text-xs text-red-500">
<div className="max-h-24 shrink-0 overflow-y-auto text-xs text-text-destructive">
{validationError.split('\n').map((line: string, i: number) => (
<div key={i}>{line}</div>
))}

View file

@ -10,13 +10,20 @@ export type Spec = {
domain: string;
};
/** Color-codes the HTTP verb the way API docs do, so the method reads at a glance. */
/**
* Color-codes the HTTP verb the way API docs do, so the method reads at a
* glance. The hues come from the categorical series ramp, which is what that
* scale is for: identity rather than status, contrast-checked per mode, and
* separable under simulated deuteranopia. Slots are chosen to stay close to the
* conventional verb colours. `delete` is the one exception, taking the error
* role, because destructive really is a status.
*/
const METHOD_STYLES: Record<string, string> = {
get: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
post: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
put: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
patch: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
delete: 'bg-red-500/10 text-red-600 dark:text-red-400',
get: 'bg-series-1/10 text-series-1',
post: 'bg-series-7/10 text-series-7',
put: 'bg-series-4/10 text-series-4',
patch: 'bg-series-6/10 text-series-6',
delete: 'bg-status-error-subtle text-status-error',
};
function HeaderCell({ labelKey }: { labelKey: TranslationKeys }) {

View file

@ -84,7 +84,7 @@ export default function AdvancedPanel() {
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<span className="t-icon" data-icon="b">
<Check className="h-3.5 w-3.5 text-green-500" aria-hidden="true" />
<Check className="h-3.5 w-3.5 text-status-success" aria-hidden="true" />
</span>
</span>
</Button>

View file

@ -158,7 +158,7 @@ const AgentSubagents: React.FC<AgentSubagentsProps> = ({ field, currentAgentId,
</div>
{nothingToSpawn && (
<p className="flex items-center gap-2 text-xs italic text-amber-600 dark:text-amber-400">
<p className="flex items-center gap-2 text-xs italic text-text-warning">
<Users size={14} aria-hidden="true" />
{localize('com_ui_agent_subagents_empty')}
</p>

View file

@ -102,4 +102,10 @@ describe('AgentSubagents', () => {
),
);
});
it('uses the semantic warning role for an empty configuration', () => {
render(<Harness />);
expect(screen.getByText('com_ui_agent_subagents_empty')).toHaveClass('text-text-warning');
});
});

View file

@ -23,11 +23,18 @@ export function CountPill({ children }: { children: ReactNode }) {
);
}
/** "Beta" accent pill. */
/**
* "Beta" accent pill.
*
* Semantic `brand-purple` rather than the raw purple ramp: the label renders at
* 10px on a tinted fill, so it has to keep clearing the text floor on every
* canvas, and `purple-600/40` edges sit at 1.89:1 on the high contrast light
* page. A palette utility does not move when the theme does.
*/
export function BetaPill() {
const localize = useLocalize();
return (
<span className="rounded-full border border-purple-600/40 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-purple-700 dark:text-purple-400">
<span className="rounded-full border border-brand-purple/40 bg-brand-purple/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-brand-purple">
{localize('com_ui_beta')}
</span>
);

View file

@ -65,7 +65,11 @@ export default function AgentConfig() {
aria-describedby={errors.name ? 'agent-name-error' : undefined}
/>
{errors.name && (
<div id="agent-name-error" className="mt-1 text-xs text-red-500" role="alert">
<div
id="agent-name-error"
className="mt-1 text-xs text-text-destructive"
role="alert"
>
{errors.name.message}
</div>
)}
@ -98,7 +102,7 @@ export default function AgentConfig() {
className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-text-secondary"
htmlFor="provider"
>
{localize('com_ui_model')} <span className="text-red-500">*</span>
{localize('com_ui_model')} <span className="text-text-destructive">*</span>
</Label>
<button
id="provider"
@ -132,7 +136,7 @@ export default function AgentConfig() {
className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-text-secondary"
htmlFor="category-selector"
>
{localize('com_ui_category')} <span className="text-red-500">*</span>
{localize('com_ui_category')} <span className="text-text-destructive">*</span>
</Label>
<AgentCategorySelector className="w-full rounded-lg" />
</div>
@ -171,7 +175,7 @@ export default function AgentConfig() {
<Input
{...field}
value={field.value ?? ''}
className={cn(fieldClass, error && 'border-2 border-red-500')}
className={cn(fieldClass, error && 'border-2 border-border-destructive')}
id="support-contact-name"
type="text"
placeholder={localize('com_ui_support_contact_name_placeholder')}
@ -182,7 +186,7 @@ export default function AgentConfig() {
{error && (
<span
id="support-contact-name-error"
className="mt-1 text-xs text-red-500"
className="mt-1 text-xs text-text-destructive"
role="alert"
aria-live="polite"
>
@ -204,7 +208,7 @@ export default function AgentConfig() {
<Input
{...field}
value={field.value ?? ''}
className={cn(fieldClass, error && 'border-2 border-red-500')}
className={cn(fieldClass, error && 'border-2 border-border-destructive')}
id="support-contact-email"
type="email"
placeholder={localize('com_ui_support_contact_email_placeholder')}
@ -215,7 +219,7 @@ export default function AgentConfig() {
{error && (
<span
id="support-contact-email-error"
className="mt-1 text-xs text-red-500"
className="mt-1 text-xs text-text-destructive"
role="alert"
aria-live="polite"
>

View file

@ -64,7 +64,7 @@ function AgentSelect({
provider: createProviderOption(fullAgent.provider),
label: fullAgent.name ?? '',
value: fullAgent.id || '',
icon: isGlobal ? <EarthIcon className={'icon-lg text-green-400'} /> : null,
icon: isGlobal ? <EarthIcon className="icon-lg text-status-success" /> : null,
};
const capabilities: TAgentCapabilities = {

View file

@ -140,7 +140,8 @@ export default function AgentTool({
}
selection={{
selectHandler: () => removeTool(tool),
selectClasses: 'bg-surface-destructive hover:bg-surface-destructive-hover text-white',
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -125,7 +125,8 @@ function DeleteButton({
}
selection={{
selectHandler: () => deleteAgent.mutate({ agent_id }),
selectClasses: 'bg-surface-destructive hover:bg-surface-destructive-hover text-white',
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -88,7 +88,7 @@ export default function MCPToolItem({
pressed={isDeferred}
label={localize('com_ui_mcp_defer_loading')}
tooltip={localize('com_ui_mcp_click_to_defer')}
activeClass="text-text-warning"
activeBorderClass="border-series-4"
onToggle={onToggleDefer}
/>
)}
@ -102,7 +102,7 @@ export default function MCPToolItem({
? 'com_ui_mcp_click_to_programmatic'
: 'com_ui_mcp_programmatic_requires_code',
)}
activeClass="text-violet-500"
activeBorderClass="border-series-6"
disabled={!programmaticToolsAvailable && !isProgrammatic}
onToggle={onToggleProgrammatic}
/>
@ -113,7 +113,7 @@ export default function MCPToolItem({
pressed={isBackground}
label={localize('com_ui_mcp_background')}
tooltip={localize('com_ui_mcp_click_to_background')}
activeClass="text-sky-500"
activeBorderClass="border-series-1"
onToggle={onToggleBackground}
/>
)}
@ -126,7 +126,7 @@ export default function MCPToolItem({
tooltip={localize(
intentDisabled ? 'com_ui_mcp_intent_programmatic' : 'com_ui_mcp_click_to_intent',
)}
activeClass="text-teal-500"
activeBorderClass="border-series-3"
onToggle={onToggleIntent}
/>
)}

View file

@ -128,7 +128,7 @@ export default function ModelPanel({
)}
htmlFor="provider"
>
{localize('com_ui_provider')} <span className="text-red-500">*</span>
{localize('com_ui_provider')} <span className="text-text-destructive">*</span>
</label>
<Controller
name="provider"
@ -171,14 +171,14 @@ export default function ModelPanel({
label: typeof provider === 'string' ? provider : provider.label,
value: typeof provider === 'string' ? provider : provider.value,
}))}
className={cn(error ? 'border-2 border-red-500' : '')}
className={cn(error ? 'border-2 border-border-destructive' : '')}
ariaLabel={localize('com_ui_provider')}
disabled={selectionDisabled}
isCollapsed={false}
showCarat={true}
/>
{error && (
<span className="mt-1 text-xs text-red-500" role="alert">
<span className="mt-1 text-xs text-text-destructive" role="alert">
{localize('com_ui_field_required')}
</span>
)}
@ -197,7 +197,7 @@ export default function ModelPanel({
)}
htmlFor="model"
>
{localize('com_ui_model')} <span className="text-red-500">*</span>
{localize('com_ui_model')} <span className="text-text-destructive">*</span>
</label>
<Controller
name="model"
@ -225,13 +225,16 @@ export default function ModelPanel({
value: model,
}))}
disabled={!provider || selectionDisabled}
className={cn('disabled:opacity-50', error ? 'border-2 border-red-500' : '')}
className={cn(
'disabled:opacity-50',
error ? 'border-2 border-border-destructive' : '',
)}
ariaLabel={localize('com_ui_model')}
isCollapsed={false}
showCarat={true}
/>
{provider && error && (
<span className="mt-1 text-xs text-red-500" role="alert">
<span className="mt-1 text-xs text-text-destructive" role="alert">
{localize('com_ui_field_required')}
</span>
)}

View file

@ -8,8 +8,8 @@ interface OptionToggleProps {
label: string;
/** Defaults to `label` (bulk toggles use the same text for both). */
tooltip?: string;
/** Text color applied directly to the icon when pressed. */
activeClass: string;
/** Semantic series border applied to the pressed button. */
activeBorderClass: string;
onToggle: () => void;
size?: 'sm' | 'md';
/**
@ -30,11 +30,35 @@ export default function OptionToggle({
pressed,
label,
tooltip,
activeClass,
activeBorderClass,
onToggle,
size = 'sm',
disabled = false,
}: OptionToggleProps) {
/**
* Pressed and disabled compose rather than override each other: a tool the
* user switched to programmatic-only keeps its stored Intent state, and
* `aria-pressed` keeps reporting it, so the control has to keep looking
* pressed. Collapsing to the unpressed treatment would hide a setting that
* becomes active again the moment programmatic mode is lifted.
*/
let stateClass: string;
if (pressed) {
stateClass = cn(
activeBorderClass,
'bg-surface-active text-text-primary',
disabled
? 'cursor-not-allowed opacity-60 hover:bg-surface-active hover:text-text-primary'
: 'hover:bg-surface-active-alt hover:text-text-primary',
);
} else if (disabled) {
stateClass =
'cursor-not-allowed border-transparent text-text-tertiary opacity-60 hover:bg-transparent hover:text-text-tertiary';
} else {
stateClass =
'border-transparent text-text-secondary hover:bg-surface-hover hover:text-text-secondary';
}
return (
<TooltipAnchor
description={tooltip ?? label}
@ -47,15 +71,9 @@ export default function OptionToggle({
aria-pressed={pressed}
aria-label={label}
aria-disabled={disabled || undefined}
className={cn(
'rounded-md',
size === 'sm' ? 'size-6' : 'size-7',
disabled
? 'cursor-not-allowed text-text-tertiary opacity-60 hover:bg-transparent hover:text-text-tertiary'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-secondary',
)}
className={cn('rounded-md border', size === 'sm' ? 'size-6' : 'size-7', stateClass)}
>
<Icon className={cn('size-4', pressed && activeClass)} aria-hidden="true" />
<Icon className="size-4" aria-hidden="true" />
</Button>
}
/>

View file

@ -65,7 +65,7 @@ export default function Action({
>
<MorphIcon
icon={isToolAuthenticated ? CircleCheck : KeyRound}
className={cn('h-4 w-4', isToolAuthenticated && 'text-green-500')}
className={cn('h-4 w-4', isToolAuthenticated && 'text-status-success')}
/>
{localize(
isToolAuthenticated

View file

@ -141,10 +141,18 @@ describe('ApiKeyDialog', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
render(<ApiKeyDialog {...defaultProps} />);
// Default is Jina
expect(screen.getByText('com_ui_web_search_reranker_jina_key')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'com_ui_web_search_reranker_jina_key' })).toHaveClass(
'text-link',
'underline',
'hover:text-link-hover',
);
// Switch to Cohere
fireEvent.click(screen.getByText('com_ui_web_search_reranker_cohere'));
expect(screen.getByText('com_ui_web_search_reranker_cohere_key')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'com_ui_web_search_reranker_cohere_key' })).toHaveClass(
'text-link',
'underline',
'hover:text-link-hover',
);
});
it('does not render provider section if SYSTEM_DEFINED', () => {

View file

@ -358,7 +358,7 @@ export default function ApiKeyDialog({
}
selection={{
selectHandler: handleSubmit(handleVisibleSubmit),
selectClasses: 'bg-surface-submit hover:bg-surface-submit-hover text-white',
selectClasses: 'bg-surface-submit hover:bg-surface-submit-hover text-text-on-status',
selectText: localize('com_ui_save'),
}}
buttons={

View file

@ -103,7 +103,7 @@ export default function InputSection({
href={config.link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
className="text-link underline hover:text-link-hover"
>
{config.link.text}
</a>

View file

@ -132,7 +132,7 @@ export default function ActionEditor({
});
},
selectClasses:
'bg-surface-destructive text-white transition-colors duration-200 hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status transition-colors duration-200 hover:bg-surface-destructive-hover',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -14,6 +14,7 @@ const mockIsConnectionDeferred = jest.fn((): boolean => false);
const mockToggleIntentAll = jest.fn();
const mockIsToolProgrammaticOnly = jest.fn((_toolId: string): boolean => false);
const mockAreAllToolsProgrammatic = jest.fn((): boolean => false);
const mockAreAllToolsBackground = jest.fn((): boolean => false);
const mockCapabilities = {
codeEnabled: false,
deferredToolsEnabled: false,
@ -76,7 +77,7 @@ jest.mock('~/hooks', () => ({
toggleToolIntent: jest.fn(),
areAllToolsDeferred: () => false,
areAllToolsProgrammatic: mockAreAllToolsProgrammatic,
areAllToolsBackground: () => false,
areAllToolsBackground: mockAreAllToolsBackground,
areAllToolsIntent: () => false,
toggleDeferAll: jest.fn(),
toggleProgrammaticAll: jest.fn(),
@ -182,6 +183,8 @@ describe('McpSection', () => {
mockIsToolProgrammaticOnly.mockReturnValue(false);
mockAreAllToolsProgrammatic.mockReset();
mockAreAllToolsProgrammatic.mockReturnValue(false);
mockAreAllToolsBackground.mockReset();
mockAreAllToolsBackground.mockReturnValue(false);
mockGetToolOptions.mockReset();
mockGetToolOptions.mockReturnValue(undefined);
mockMcpServersMap.mockReset();
@ -477,6 +480,19 @@ describe('McpSection', () => {
).not.toHaveAttribute('aria-disabled');
});
test('bulk background toggle uses the shared semantic pressed state', () => {
mockCapabilities.backgroundToolsEnabled = true;
mockAreAllToolsBackground.mockReturnValue(true);
render(<McpSection item={item} />);
expect(screen.getByRole('button', { name: 'com_ui_mcp_unbackground_all' })).toHaveClass(
'border-series-1',
'bg-surface-active',
'text-text-primary',
);
});
test('bulk intent skips programmatic-only tools (label can never reach them)', () => {
mockCapabilities.toolIntentsEnabled = true;
mockIsToolProgrammaticOnly.mockImplementation((toolId: string) => toolId === 'mcp:srv:a');

View file

@ -494,7 +494,7 @@ export default function McpSection({ item }: Props) {
size="md"
pressed={allDeferred}
label={localize(allDeferred ? 'com_ui_mcp_undefer_all' : 'com_ui_mcp_defer_all')}
activeClass="text-amber-600 dark:text-amber-500"
activeBorderClass="border-series-4"
onToggle={() => toggleDeferAll(tools)}
/>
)}
@ -504,7 +504,7 @@ export default function McpSection({ item }: Props) {
size="md"
pressed={allProgrammatic}
label={programmaticBulkLabel}
activeClass="text-violet-600 dark:text-violet-500"
activeBorderClass="border-series-6"
tooltip={programmaticBulkTooltip}
disabled={!programmaticToolsAvailable && !allProgrammatic}
onToggle={() => toggleProgrammaticAll(tools)}
@ -518,7 +518,7 @@ export default function McpSection({ item }: Props) {
label={localize(
allBackground ? 'com_ui_mcp_unbackground_all' : 'com_ui_mcp_background_all',
)}
activeClass="text-sky-600 dark:text-sky-500"
activeBorderClass="border-series-1"
onToggle={() => toggleBackgroundAll(tools)}
/>
)}
@ -529,7 +529,7 @@ export default function McpSection({ item }: Props) {
pressed={allIntent}
disabled={intentEligibleTools.length === 0}
label={localize(allIntent ? 'com_ui_mcp_unintent_all' : 'com_ui_mcp_intent_all')}
activeClass="text-teal-600 dark:text-teal-500"
activeBorderClass="border-series-3"
onToggle={() => toggleIntentAll(intentEligibleTools)}
/>
)}

View file

@ -81,7 +81,7 @@ export default function ToolSection({ item }: Props) {
{showConfigured && (
<div className="flex items-center justify-between rounded-xl border border-border-light bg-surface-secondary px-3 py-2.5">
<span className="flex items-center gap-2 text-sm font-medium text-text-primary">
<CheckCircle2 className="size-4 text-emerald-500" aria-hidden="true" />
<CheckCircle2 className="size-4 text-status-success" aria-hidden="true" />
{localize('com_ui_tools_info_configured')}
</span>
<Button

View file

@ -108,7 +108,7 @@ function ToolCardImpl({
className={cn(
'group relative flex h-32 w-full flex-col overflow-hidden rounded-2xl border',
selected
? 'border-emerald-500/60 bg-emerald-500/[0.06] shadow-sm'
? 'border-status-success bg-status-success/10 shadow-sm'
: 'border-border-light bg-transparent hover:border-border-medium hover:bg-surface-tertiary hover:shadow-sm',
)}
>
@ -129,7 +129,7 @@ function ToolCardImpl({
<span className="truncate">{name}</span>
{isNative && (
<BadgeCheck
className="size-4 shrink-0 fill-emerald-500 text-white dark:text-surface-primary"
className="size-4 shrink-0 fill-status-success-strong text-text-on-status"
strokeWidth={2}
aria-label={localize('com_ui_tools_native')}
/>
@ -137,7 +137,7 @@ function ToolCardImpl({
</p>
{selected && (
<span
className="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white"
className="flex size-5 shrink-0 items-center justify-center rounded-full bg-status-success-strong text-text-on-status"
aria-hidden="true"
>
<Check className="size-3" strokeWidth={3} />
@ -226,8 +226,7 @@ function ToolCardImpl({
'opacity-0 transition duration-150 hover:bg-surface-hover hover:text-text-primary',
'group-focus-within:opacity-100 group-hover:opacity-100',
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
isFavorited &&
'text-amber-600 opacity-100 hover:text-amber-500 dark:text-amber-500 dark:hover:text-amber-400',
isFavorited && 'text-series-4 opacity-100 hover:text-series-4',
)}
>
<Star className={cn('size-4', isFavorited && 'fill-current')} aria-hidden="true" />

View file

@ -443,7 +443,7 @@ export default function ToolsSection({ agentId }: Props) {
selection={{
selectHandler: confirmActionRemoval,
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-white',
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>
@ -468,7 +468,7 @@ export default function ToolsSection({ agentId }: Props) {
selection={{
selectHandler: confirmMcpRemoval,
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-white',
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -95,7 +95,7 @@ export default function VersionItem({
<div
className={cn(
'absolute -bottom-3 top-0 w-px',
isActive ? 'bg-green-500/40' : 'bg-border-light',
isActive ? 'bg-status-success-strong' : 'bg-border-light',
)}
/>
)}
@ -103,7 +103,7 @@ export default function VersionItem({
className={cn(
'relative z-10 mt-4 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isActive
? 'border-green-500 bg-green-500 text-white'
? 'border-status-success-strong bg-status-success-strong text-text-on-status'
: 'border-border-medium bg-surface-primary text-text-secondary',
)}
aria-hidden="true"
@ -121,7 +121,7 @@ export default function VersionItem({
className={cn(
'group relative mb-2 ml-2 flex flex-1 flex-col rounded-xl border p-3 transition-colors',
isActive
? 'border-green-500/40 bg-green-50/60 dark:border-green-500/30 dark:bg-green-950/20'
? 'border-status-success-border bg-status-success-subtle'
: 'border-border-light bg-transparent hover:border-border-medium hover:bg-surface-secondary',
)}
>
@ -131,14 +131,17 @@ export default function VersionItem({
<span
className={cn(
'truncate text-sm font-semibold',
isActive ? 'text-green-700 dark:text-green-300' : 'text-text-primary',
isActive ? 'text-status-success' : 'text-text-primary',
)}
>
{versionTitle}
</span>
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
<span className="size-1.5 rounded-full bg-green-500" aria-hidden="true" />
<span className="inline-flex items-center gap-1 rounded-full bg-status-success-subtle px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success">
<span
className="size-1.5 rounded-full bg-status-success-strong"
aria-hidden="true"
/>
{localize('com_ui_agent_version_current')}
</span>
)}
@ -203,7 +206,7 @@ export default function VersionItem({
selection={{
selectHandler: () => onRestore(index),
selectClasses:
'bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 text-white',
'bg-surface-submit hover:bg-surface-submit-hover text-text-on-status',
selectText: localize('com_ui_agent_version_restore'),
}}
/>

View file

@ -24,6 +24,42 @@ const tool = {
metadata: { name: 'execute', description: 'Runs JavaScript code against the API.' },
} as unknown as AgentToolType;
const pressedOptions: Array<{
name: string;
overrides: Partial<React.ComponentProps<typeof MCPToolItem>>;
label: string;
border: string;
}> = [
{
name: 'deferred',
overrides: { deferredToolsEnabled: true, isDeferred: true },
label: 'com_ui_mcp_defer_loading',
border: 'border-series-4',
},
{
name: 'programmatic',
overrides: {
programmaticToolsEnabled: true,
programmaticToolsAvailable: true,
isProgrammatic: true,
},
label: 'com_ui_mcp_programmatic',
border: 'border-series-6',
},
{
name: 'background',
overrides: { backgroundToolsEnabled: true, isBackground: true },
label: 'com_ui_mcp_background',
border: 'border-series-1',
},
{
name: 'intent',
overrides: { toolIntentsEnabled: true, isIntent: true },
label: 'com_ui_mcp_intent',
border: 'border-series-3',
},
];
function setup(overrides: Partial<React.ComponentProps<typeof MCPToolItem>> = {}) {
const props = {
tool,
@ -94,11 +130,19 @@ describe('MCPToolItem', () => {
expect(props.onToggleDefer).toHaveBeenCalledTimes(1);
});
test('deferred tool keeps its warning color while hovered', () => {
setup({ deferredToolsEnabled: true, isDeferred: true });
const deferButton = screen.getByRole('button', { name: 'com_ui_mcp_defer_loading' });
expect(deferButton).toHaveClass('hover:text-text-secondary');
expect(deferButton.querySelector('svg')).toHaveClass('text-text-warning');
test.each(pressedOptions)('$name option uses a semantic pressed-state border', (option) => {
setup(option.overrides);
const button = screen.getByRole('button', { name: option.label });
expect(button).toHaveClass(
option.border,
'bg-surface-active',
'text-text-primary',
'hover:bg-surface-active-alt',
);
expect(button.querySelector('svg')?.className.baseVal).not.toMatch(
/text-(?:amber|violet|sky|teal)-/,
);
});
test('defer button is absent when deferred tools are disabled', () => {

View file

@ -0,0 +1,81 @@
import '@testing-library/jest-dom/extend-expect';
import React from 'react';
import { Circle } from 'lucide-react';
import { render, screen } from '@testing-library/react';
import OptionToggle from '../OptionToggle';
jest.mock('@librechat/client', () => ({
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
Button: ({
children,
variant: _variant,
size: _size,
...props
}: React.ComponentProps<'button'> & { variant?: string; size?: string }) => (
<button {...props}>{children}</button>
),
}));
describe('OptionToggle', () => {
it('gives the pressed state a semantic surface, foreground, and series border', () => {
render(
<OptionToggle
icon={Circle}
pressed={true}
label="Background"
activeBorderClass="border-series-1"
onToggle={jest.fn()}
/>,
);
const button = screen.getByRole('button', { name: 'Background' });
expect(button).toHaveAttribute('aria-pressed', 'true');
expect(button).toHaveClass(
'border',
'border-series-1',
'bg-surface-active',
'text-text-primary',
'hover:bg-surface-active-alt',
);
expect(button.querySelector('svg')).toHaveClass('size-4');
expect(button.querySelector('svg')).not.toHaveClass('text-series-1');
});
it('keeps the unpressed state neutral and transparent', () => {
render(
<OptionToggle
icon={Circle}
pressed={false}
label="Background"
activeBorderClass="border-series-1"
onToggle={jest.fn()}
/>,
);
const button = screen.getByRole('button', { name: 'Background' });
expect(button).toHaveAttribute('aria-pressed', 'false');
expect(button).toHaveClass('border-transparent', 'text-text-secondary');
expect(button).not.toHaveClass('border-series-1', 'bg-surface-active');
});
/** A tool switched to programmatic-only keeps its stored Intent state and
* keeps reporting it through `aria-pressed`, so the disabled treatment has to
* layer over the pressed one instead of replacing it. */
it('keeps the pressed treatment while disabled', () => {
render(
<OptionToggle
icon={Circle}
pressed={true}
disabled={true}
label="Intent"
activeBorderClass="border-series-1"
onToggle={jest.fn()}
/>,
);
const button = screen.getByRole('button', { name: 'Intent' });
expect(button).toHaveAttribute('aria-pressed', 'true');
expect(button).toHaveClass('border-series-1', 'bg-surface-active', 'cursor-not-allowed');
expect(button).not.toHaveClass('border-transparent');
});
});

View file

@ -108,7 +108,7 @@ export default function BookmarkCardActions({ bookmark }: BookmarkCardActionsPro
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-surface-destructive text-white transition-all duration-200 hover:bg-surface-destructive-hover',
'bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -249,7 +249,7 @@ export default function ActionsInput({
{/* TODO: format input button */}
</div>
{validationResult && validationResult.message !== 'OpenAPI spec is valid.' && (
<div className="border-token-border-light border-t p-2 text-red-500">
<div className="border-token-border-light border-t p-2 text-text-destructive">
{validationResult.message.split('\n').map((line: string, i: number) => (
<div key={i}>{line}</div>
))}
@ -286,7 +286,7 @@ export default function ActionsInput({
<button
disabled={!functions || !functions.length}
onClick={saveAction}
className="mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
className="mt-1 flex min-w-[100px] items-center justify-center rounded bg-surface-submit px-4 py-2 font-semibold text-text-on-status hover:bg-surface-submit-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary disabled:opacity-50"
type="button"
>
{submitContext()}

View file

@ -149,7 +149,7 @@ export default function ActionsPanel({
});
},
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
'bg-surface-destructive hover:bg-surface-destructive-hover transition-color duration-200 text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -281,7 +281,7 @@ export default function AssistantPanel({
{/* Select Button */}
{assistant_id && (
<button
className="btn btn-primary mx-2 mt-1 h-[40px] rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
className="btn mx-2 mt-1 h-[40px] rounded bg-surface-submit px-4 py-2 font-semibold text-text-on-status hover:bg-surface-submit-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary"
type="button"
disabled={!assistant_id}
onClick={(e) => {
@ -415,10 +415,13 @@ export default function AssistantPanel({
cardStyle,
'flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer',
)}
containerClassName={cn('rounded-md', error ? 'border-red-500 border-2' : '')}
containerClassName={cn(
'rounded-md',
error ? 'border-border-destructive border-2' : '',
)}
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
<span className="text-sm text-text-destructive transition duration-300 ease-in-out">
{localize('com_ui_field_required')}
</span>
)}
@ -505,7 +508,7 @@ export default function AssistantPanel({
/>
{/* Submit Button */}
<button
className="btn btn-primary flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
className="btn flex w-full items-center justify-center bg-surface-submit px-4 py-2 font-semibold text-text-on-status hover:bg-surface-submit-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary"
type="submit"
>
{submitContext}

View file

@ -101,7 +101,7 @@ export default function AssistantTool({
selection={{
selectHandler: () => removeTool(currentTool.pluginKey),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-colors duration-200 text-white',
'bg-surface-destructive hover:bg-surface-destructive-hover transition-colors duration-200 text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -116,7 +116,8 @@ export default function ContextButton({
selection={{
selectHandler: () =>
deleteAssistant.mutate({ assistant_id, model: activeModel, endpoint }),
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-text-on-status',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -130,7 +130,8 @@ export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
}
selection={{
selectHandler: confirmDelete,
selectClasses: 'bg-surface-destructive text-white hover:bg-surface-destructive-hover',
selectClasses:
'bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover',
selectText: localize('com_ui_delete'),
}}
/>

View file

@ -64,7 +64,7 @@ function DynamicCheckbox({
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default')}:{' '}
{defaultValue != null ? localize('com_ui_yes') : localize('com_ui_no')})
</small>

View file

@ -1,6 +1,6 @@
import { useMemo, useState, useCallback } from 'react';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, HoverCard, HoverCardTrigger, ControlCombobox } from '@librechat/client';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
@ -86,7 +86,7 @@ function DynamicCombobox({
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
{showDefault && (
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default')}: {defaultValue})
</small>
)}

View file

@ -80,7 +80,7 @@ function DynamicDropdown({
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
{showDefault && (
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default')}: {defaultValue})
</small>
)}

View file

@ -178,7 +178,7 @@ function DynamicInput({
>
{labelCode ? localize(label as TranslationKeys) || label : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
(
{typeof defaultValue === 'undefined' || !(defaultValue as string).length
? localize('com_endpoint_default_blank')

View file

@ -179,7 +179,7 @@ function DynamicSlider({
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default')}: {getDefaultDisplayValue()})
</small>
)}

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Switch, HoverCard, HoverCardTrigger } from '@librechat/client';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
@ -54,7 +54,7 @@ function DynamicSwitch({
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
<small className="opacity-40 high-contrast:opacity-100">
({localize('com_endpoint_default')}:{' '}
{defaultValue != null ? localize('com_ui_on') : localize('com_ui_off')})
</small>

Some files were not shown because too many files have changed in this diff Show more