🎨 feat: Color the Context Gauge by Category and Collapse its Breakdown (#14855)

* 🎨 feat: Color the Context Gauge by Category and Collapse its Breakdown

The context window bar becomes a stacked meter — one hue per category — and
the breakdown collapses behind a disclosure so the gauge alone is the default
view. The collapse choice persists per user.

Adds a categorical series scale (`rgb-series-1`…`rgb-series-7`) to the
versioned theme registry, so themes and `REACT_APP_THEME_SERIES_*` can retint
it. Hues are anchored on LibreChat's own brand tokens; every step was computed
rather than picked, by enumerating slot orderings and snapping each step until
all gates passed in both modes:

  worst adjacent CVD ΔE          12.4 light / 13.0 dark  (target 8)
  worst adjacent normal-vision   19.0 light / 19.0 dark  (floor 15)
  contrast                       all 14 steps ≥ 3:1 on both the popover
                                 surface and the meter track

Slot order is the colour-vision-deficiency safety mechanism, not cosmetics.
Reserved status colors are never reused for series identity, and the circular
composer gauge is deliberately untouched — it answers "how close am I to the
limit", which stays a status question.

- `SegmentedMeter` + `MeterSwatch` land beside `Progress` in `@librechat/client`,
  owning the 2px surface gaps, rounded ends, the min-width floor, and the hatch.
  The category-to-slot mapping stays feature-local: the palette is theme data,
  the mapping is not.
- Every present category gets a 2px floor so a 251-token row cannot render as
  0.09px; the shortfall comes out of free space, never another category.
- Deferred tools keep their family's hue and add a 135° hatch, so a hue never
  means two things. Segments are reordered to put each deferred pair beside its
  parent, which is also the adjacency the palette was validated on.
- Messages is drawn as a translucent fill with a solid edge: it is the only
  category the user grows, and the form difference doubles as secondary encoding.
- A row carries a swatch if and only if it is a segment. The estimate path knows
  the total but not the composition, so it keeps a single unsegmented fill.
- Usage totals gain a "Totals" heading, and row text lifts to primary ink on
  hover/focus.
- The popover widens 256px → 288px to absorb the chevron and the legend swatches.

Guardrails: the series scale is held to the 3:1 mark floor on both surfaces, the
app CSS defaults are held in step with the runtime themes, and each slot is
asserted to resolve to a Tailwind utility backed by its CSS variable.

* 🐛 fix: Address Codex Review on the Segmented Context Gauge

Three P2 findings, all confirmed.

**Gaps inflated the fill.** Segment widths were percentages of the whole track
while `gap-[2px]` was added on top, so the gaps ate into the free-space
remainder instead of living inside the filled region. Measured on the real
component: a window at 47.2% painted 55.6% full, and the bar read full at ~94%.
Each segment now surrenders its share of the gap budget, so fills plus gaps span
exactly the used fraction. Same case now paints 50.2%.

The residual 3.0pp is the `SEGMENT_MIN` floor doing its job — five sub-pixel
categories rounded up to 2px each. That overshoot is deliberate and bounded, it
comes out of free space rather than a neighbouring category, and the doc comment
now states the magnitude instead of leaving it implicit.

**No reference-theme test.** The suite only exercised the bundled token tables,
so it could not detect the shared component becoming coupled to LibreChat's
values. Adds a deliberately different reference `ThemeDefinition` and asserts the
registry accepts it, the values reach the applied CSS variables, and every
rendered mark takes its colour from those variables — no literal colours in the
tree. `SegmentedMeter.tsx` also joins the shared-primitive colour guardrail.

**Series tokens missing from the public maps.** `IThemeVariables` and
`IThemeColors` are exported for downstream consumers to type their CSS-variable
and Tailwind maps, and would have rejected the new keys. Adds the series entries
to both, plus a compile-time guard in the registry so a slot added to one map and
missed in another fails the build.

The guard deliberately lives in `registry.ts`, not the spec: `tsconfig.json`
excludes `*.spec.ts`, so an assertion there is never checked by the build —
verified by removing a key from each map in turn and confirming the error.

*  fix: Expand the Breakdown in the Context Gauge e2e Specs

`e2e/specs/mock/usage.spec.ts` asserts on rows that now sit behind the
disclosure, so four tests failed on the collapsed default. My miss — I updated
the component spec and never grepped for e2e coverage.

`openBreakdown` now expands the detail after opening, so every caller that
reads a row keeps working; the helper is idempotent, since a reload restores an
already-expanded preference. The one inline `gauge.click()` that duplicated the
helper now uses it.

Adds the case the regression should have been caught by, and which only e2e can
reach: the popover opens to the gauge alone with no detail mounted, expanding
reveals the labelled Totals section, and the choice survives a real reload
through localStorage without a second click.

`e2e/specs/real/usage.spec.ts` reads the totals the same way. It also hovered
rather than clicked, which never opened the popover at all — hover surfaces only
the compact snapshot tooltip, as the mock spec asserts.
This commit is contained in:
Danny Avila 2026-08-14 22:46:10 -04:00 committed by GitHub
parent 58f0ab7f62
commit 530a935a74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1082 additions and 182 deletions

View file

@ -1,5 +1,8 @@
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import { Provider } from 'jotai';
import userEvent from '@testing-library/user-event';
import { Constants, Tools } from 'librechat-data-provider';
import { render, screen, within } from '@testing-library/react';
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
import Breakdown from './Breakdown';
@ -37,22 +40,199 @@ const view = {
messagesPruned: false,
} as TokenUsageView;
/** A snapshot-backed branch: messages, instructions, and one tool per group,
* with a deferred entry on both the system and MCP families. */
const snapshotView = {
...view,
usedTokens: 1000,
maxTokens: 2000,
percent: 50,
isEstimate: false,
snapshotActive: true,
effectiveInstructionTokens: 0,
snapshot: {
anchorMessageId: 'message-1',
effectiveInstructionTokens: 400,
breakdown: {
maxContextTokens: 2000,
instructionTokens: 400,
systemMessageTokens: 100,
dynamicInstructionTokens: 20,
toolSchemaTokens: 280,
summaryTokens: 50,
toolCount: 5,
messageCount: 4,
messageTokens: 550,
availableForMessages: 1600,
toolTokenCounts: {
web_search: 90,
deferred_tool: 30,
[`server${Constants.mcp_delimiter}tool`]: 80,
[`server${Constants.mcp_delimiter}lazy`]: 40,
[Tools.skill]: 25,
[Constants.SUBAGENT]: 15,
},
deferredToolNames: ['deferred_tool', `server${Constants.mcp_delimiter}lazy`],
},
},
} as unknown as TokenUsageView;
const renderBreakdown = (props: Partial<React.ComponentProps<typeof Breakdown>> = {}) =>
render(
<Provider>
<Breakdown view={view} showCost={false} {...props} />
</Provider>,
);
const toggle = () => screen.getByTestId('context-breakdown-toggle');
beforeEach(() => {
localStorage.clear();
});
describe('TokenUsage Breakdown', () => {
it('renders the Langfuse session as an external link when available', () => {
describe('collapse', () => {
it('opens showing only the gauge, with the detail behind the disclosure', () => {
renderBreakdown();
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByTestId('token-usage-totals')).not.toBeInTheDocument();
});
it('reveals the detail once expanded', async () => {
renderBreakdown();
await userEvent.click(toggle());
expect(toggle()).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('token-usage-totals')).toBeInTheDocument();
});
it('persists the expanded choice across mounts', async () => {
const { unmount } = renderBreakdown();
await userEvent.click(toggle());
unmount();
renderBreakdown();
expect(toggle()).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('token-usage-totals')).toBeInTheDocument();
});
it('keeps the gauge readout visible while collapsed', () => {
renderBreakdown({ view: snapshotView });
expect(within(toggle()).getByText('1K / 2K (50%)')).toBeInTheDocument();
});
});
describe('segments', () => {
it('stacks the segments in slot order, deferred beside its parent', () => {
renderBreakdown({ view: snapshotView });
const segments = Array.from(screen.getByRole('progressbar').children) as HTMLElement[];
expect(
segments.map(
(segment) => /(?:bg-series-\d(?:\/25)?)/.exec(segment.className)?.[0] ?? 'none',
),
).toEqual([
'bg-series-1/25', // messages
'bg-series-2', // system prompt
'bg-series-3', // system tools
'bg-series-3', // system tools, deferred
'bg-series-4', // mcp tools
'bg-series-4', // mcp tools, deferred
'bg-series-5', // skills
'bg-series-6', // subagents
'bg-series-7', // summary
]);
});
it('drops a category that contributes nothing', () => {
const noSkills = JSON.parse(JSON.stringify(snapshotView)) as TokenUsageView;
delete noSkills.snapshot?.breakdown.toolTokenCounts?.[Tools.skill];
renderBreakdown({ view: noSkills });
expect(screen.getByRole('progressbar').children).toHaveLength(8);
expect(screen.getByRole('progressbar').querySelector('.bg-series-5')).toBeNull();
});
it('gives the deferred rows their family slot and a hatch', async () => {
renderBreakdown({ view: snapshotView });
await userEvent.click(toggle());
const breakdown = screen.getByTestId('context-breakdown');
const rowFor = (label: string) =>
within(breakdown).getByText(label).parentElement as HTMLElement;
const system = rowFor('com_ui_context_tools_system').firstElementChild as HTMLElement;
const deferred = rowFor('com_ui_context_tools_system_deferred')
.firstElementChild as HTMLElement;
expect(system).toHaveClass('bg-series-3');
expect(deferred).toHaveClass('bg-series-3');
expect(system.getAttribute('style') ?? '').not.toContain('repeating-linear-gradient');
expect(deferred.getAttribute('style')).toContain('repeating-linear-gradient');
});
it('marks Messages as the one translucent, edged segment', async () => {
renderBreakdown({ view: snapshotView });
await userEvent.click(toggle());
const messages = within(screen.getByTestId('context-breakdown')).getByText(
'com_ui_context_messages',
).parentElement?.firstElementChild as HTMLElement;
expect(messages).toHaveClass('bg-series-1/25');
expect(messages).toHaveClass('ring-series-1');
});
it('leaves the estimate path unsegmented, with no swatches on its rows', async () => {
renderBreakdown();
await userEvent.click(toggle());
const estimate = screen.getByTestId('context-estimate');
expect(estimate).toBeInTheDocument();
expect(estimate.querySelector('[class*="bg-series-"]')).toBeNull();
});
});
describe('totals', () => {
it('labels the usage section so the numbers are not read as context', async () => {
renderBreakdown();
await userEvent.click(toggle());
expect(
within(screen.getByTestId('token-usage-totals')).getByRole('heading', {
name: 'com_ui_context_totals',
}),
).toBeInTheDocument();
});
});
describe('langfuse', () => {
const url = 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1';
render(<Breakdown view={view} showCost={false} langfuseSessionUrl={url} />);
it('renders the Langfuse session as an external link when available', async () => {
renderBreakdown({ langfuseSessionUrl: url });
await userEvent.click(toggle());
expect(screen.getByRole('link', { name: 'com_ui_langfuse_view_session' })).toHaveAttribute(
'href',
url,
);
expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
});
expect(screen.getByRole('link', { name: 'com_ui_langfuse_view_session' })).toHaveAttribute(
'href',
url,
);
expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
});
it('omits the Langfuse session link when no traced message is available', () => {
render(<Breakdown view={view} showCost={false} />);
it('omits the Langfuse session link when no traced message is available', async () => {
renderBreakdown();
await userEvent.click(toggle());
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
});

View file

@ -1,25 +1,52 @@
import { Button } from '@librechat/client';
import { ExternalLink } from 'lucide-react';
import { useAtom } from 'jotai';
import { ChevronDown, ExternalLink } from 'lucide-react';
import {
Button,
Collapsible,
MeterSwatch,
SegmentedMeter,
CollapsibleContent,
CollapsibleTrigger,
} from '@librechat/client';
import type { MeterSegment } from '@librechat/client';
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
import type { CurrencyConfig } from '~/utils';
import { groupToolTokens, formatTokens, formatCost } from '~/utils';
import { contextBreakdownExpandedAtom } from '~/store/usage';
import { useLocalize } from '~/hooks';
/** Row text lifts to primary ink while the row is hovered or holds focus. */
const HOVER_INK =
'transition-colors group-hover:text-text-primary group-focus-within:text-text-primary';
interface RowProps {
label: string;
value: number;
max?: number;
/** Present only when the row corresponds to real estate in the meter */
segment?: Pick<MeterSegment, 'slot' | 'hatched' | 'outlined'>;
/** The free-space remainder, keyed to the bare track rather than a series */
track?: boolean;
}
function Row({ label, value, max }: RowProps) {
function Row({ label, value, max, segment, track }: RowProps) {
const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null;
return (
<div className="flex items-center justify-between gap-4 text-sm">
<span className="text-text-secondary">{label}</span>
<div className="group flex items-center justify-between gap-4 text-sm">
<span className="flex min-w-0 items-center gap-2">
{segment != null && <MeterSwatch segment={segment} />}
{track === true && (
<span
aria-hidden="true"
className="size-2 flex-none rounded-sm bg-surface-tertiary ring-1 ring-inset ring-border-medium"
/>
)}
<span className={`text-text-secondary ${HOVER_INK}`}>{label}</span>
</span>
<span className="font-medium text-text-primary">
{formatTokens(value)}
{percent != null && (
<span className="ml-1 text-xs text-text-secondary" aria-hidden="true">
<span className={`ml-1 text-xs text-text-secondary ${HOVER_INK}`} aria-hidden="true">
({Math.round(percent)}%)
</span>
)}
@ -42,6 +69,7 @@ export default function Breakdown({
langfuseSessionUrl,
}: BreakdownProps) {
const localize = useLocalize();
const [expanded, setExpanded] = useAtom(contextBreakdownExpandedAtom);
const { usedTokens, maxTokens, percent, snapshot, snapshotActive, branchUsage, hasUsage } = view;
/** Show the all-branches total only when it (a) exceeds the active branch
* epsilon guards against float summation order surfacing a spurious row in an
@ -66,175 +94,240 @@ export default function Breakdown({
breakdown?.toolTokenCounts != null
? groupToolTokens(breakdown.toolTokenCounts, breakdown.deferredToolNames)
: null;
const toolRows =
groups == null
? null
: ([
[localize('com_ui_context_tools_system'), groups.system],
[localize('com_ui_context_tools_mcp'), groups.mcp],
[localize('com_ui_skills'), groups.skills],
[localize('com_ui_context_subagents'), groups.subagents],
[localize('com_ui_context_tools_system_deferred'), groups.systemDeferred],
[localize('com_ui_context_tools_mcp_deferred'), groups.mcpDeferred],
] as const);
/** Single source of truth for both the meter and its legend: a row carries a
* swatch if and only if it is a segment, so the two can never disagree.
* Deferred tools keep their family's slot and are hatched same identity,
* held out of the active set. */
const segments: Array<MeterSegment & { label: string }> =
breakdown == null
? []
: [
{
id: 'messages',
label: localize('com_ui_context_messages'),
value: messageTokens,
slot: 1,
outlined: true,
},
{ id: 'system', label: localize('com_ui_context_system'), value: systemTokens, slot: 2 },
...(groups == null
? [
{
id: 'tools',
label: localize('com_ui_context_tools'),
value: breakdown.toolSchemaTokens,
slot: 3,
},
]
: [
{
id: 'tools-system',
label: localize('com_ui_context_tools_system'),
value: groups.system,
slot: 3,
},
{
id: 'tools-system-deferred',
label: localize('com_ui_context_tools_system_deferred'),
value: groups.systemDeferred,
slot: 3,
hatched: true,
},
{
id: 'tools-mcp',
label: localize('com_ui_context_tools_mcp'),
value: groups.mcp,
slot: 4,
},
{
id: 'tools-mcp-deferred',
label: localize('com_ui_context_tools_mcp_deferred'),
value: groups.mcpDeferred,
slot: 4,
hatched: true,
},
{ id: 'skills', label: localize('com_ui_skills'), value: groups.skills, slot: 5 },
{
id: 'subagents',
label: localize('com_ui_context_subagents'),
value: groups.subagents,
slot: 6,
},
]),
{
id: 'summary',
label: localize('com_ui_context_summary'),
value: breakdown.summaryTokens,
slot: 7,
},
];
/** The estimate path knows the total but not the composition, so it keeps a
* single unsegmented fill and its rows carry no swatches. */
const meterSegments: MeterSegment[] =
segments.length > 0 ? segments : [{ id: 'used', value: usedTokens, slot: 1, outlined: true }];
return (
<div className="w-64 space-y-3" role="region" aria-label={localize('com_ui_context_usage')}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_context_window')}
</span>
<span className="text-xs font-medium text-text-secondary">
{maxTokens != null
? `${formatTokens(usedTokens)} / ${formatTokens(maxTokens)} (${Math.round(percent)}%)`
: formatTokens(usedTokens)}
</span>
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={maxTokens != null ? Math.round(percent) : undefined}
aria-label={localize('com_ui_context_usage')}
className="h-2 w-full overflow-hidden rounded-full bg-surface-tertiary"
>
{percent > 0 && (
<div
className="h-full rounded-full bg-text-primary transition-all duration-300"
style={{ width: `${Math.min(percent, 100)}%` }}
/>
)}
</div>
<div
className="space-y-1.5"
data-testid={breakdown ? 'context-breakdown' : 'context-estimate'}
>
{breakdown ? (
<>
<Row
label={localize('com_ui_context_messages')}
value={messageTokens}
max={maxTokens}
<div className="w-72" role="region" aria-label={localize('com_ui_context_usage')}>
<Collapsible open={expanded} onOpenChange={setExpanded} className="space-y-3">
<CollapsibleTrigger
className="group flex w-full items-center justify-between gap-2 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
data-testid="context-breakdown-toggle"
>
<span className="whitespace-nowrap text-sm font-medium text-text-primary">
{localize('com_ui_context_window')}
</span>
<span className="flex items-center gap-1 whitespace-nowrap text-xs font-medium text-text-secondary">
<span className={HOVER_INK}>
{maxTokens != null
? `${formatTokens(usedTokens)} / ${formatTokens(maxTokens)} (${Math.round(percent)}%)`
: formatTokens(usedTokens)}
</span>
<ChevronDown
aria-hidden="true"
className="size-3.5 shrink-0 text-text-tertiary transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
{systemTokens > 0 && (
<Row label={localize('com_ui_context_system')} value={systemTokens} max={maxTokens} />
)}
{toolRows != null ? (
toolRows.map(
([label, value]) =>
value > 0 && <Row key={label} label={label} value={value} max={maxTokens} />,
)
) : (
<Row
label={localize('com_ui_context_tools')}
value={breakdown.toolSchemaTokens}
max={maxTokens}
/>
)}
{breakdown.summaryTokens > 0 && (
<Row
label={localize('com_ui_context_summary')}
value={breakdown.summaryTokens}
max={maxTokens}
/>
)}
{freeTokens != null && (
<Row label={localize('com_ui_context_free')} value={freeTokens} max={maxTokens} />
)}
</>
) : (
<>
{view.branchTotals.summaryBaseline > 0 && (
<Row
label={localize('com_ui_context_summary')}
value={view.branchTotals.summaryBaseline}
max={maxTokens}
/>
)}
{view.messagesPruned ? (
/** Over-window: the per-category split no longer describes what's
* sent, so show the pruned message total (incl. in-flight). */
<Row
label={localize('com_ui_context_messages')}
value={view.messageTokens + view.liveTokens}
max={maxTokens}
/>
) : (
</span>
</CollapsibleTrigger>
<SegmentedMeter
segments={maxTokens != null ? meterSegments : []}
max={maxTokens ?? 1}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={maxTokens != null ? Math.round(percent) : undefined}
aria-label={localize('com_ui_context_usage')}
/>
<CollapsibleContent className="space-y-3 overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none">
<div
className="space-y-1.5"
data-testid={breakdown ? 'context-breakdown' : 'context-estimate'}
>
{breakdown ? (
<>
<Row label={localize('com_ui_input')} value={view.branchTotals.input} />
<Row
label={localize('com_ui_output')}
value={view.branchTotals.output + view.liveTokens}
/>
{view.estimatedTokens > 0 && (
<Row label={localize('com_ui_context_estimated')} value={view.estimatedTokens} />
{segments.map(
({ id, label, value, ...segment }) =>
value > 0 && (
<Row key={id} label={label} value={value} max={maxTokens} segment={segment} />
),
)}
{freeTokens != null && (
<Row
label={localize('com_ui_context_free')}
value={freeTokens}
max={maxTokens}
track
/>
)}
</>
)}
{view.overheadTokens > 0 && (
<Row label={localize('com_ui_context_system')} value={view.overheadTokens} />
)}
{maxTokens == null && (
<p className="text-xs text-text-secondary">{localize('com_ui_context_unknown')}</p>
)}
<p className="text-xs italic text-text-secondary">{localize('com_ui_estimated')}</p>
</>
)}
</div>
{hasUsage && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-totals">
<Row label={localize('com_ui_input')} value={branchUsage.input} />
<Row label={localize('com_ui_output')} value={branchUsage.output} />
{branchUsage.cacheRead > 0 && (
<Row label={localize('com_ui_cache_read')} value={branchUsage.cacheRead} />
)}
{branchUsage.cacheWrite > 0 && (
<Row label={localize('com_ui_cache_write')} value={branchUsage.cacheWrite} />
) : (
<>
{view.branchTotals.summaryBaseline > 0 && (
<Row
label={localize('com_ui_context_summary')}
value={view.branchTotals.summaryBaseline}
max={maxTokens}
/>
)}
{view.messagesPruned ? (
/** Over-window: the per-category split no longer describes what's
* sent, so show the pruned message total (incl. in-flight). */
<Row
label={localize('com_ui_context_messages')}
value={view.messageTokens + view.liveTokens}
max={maxTokens}
/>
) : (
<>
<Row label={localize('com_ui_input')} value={view.branchTotals.input} />
<Row
label={localize('com_ui_output')}
value={view.branchTotals.output + view.liveTokens}
/>
{view.estimatedTokens > 0 && (
<Row
label={localize('com_ui_context_estimated')}
value={view.estimatedTokens}
/>
)}
</>
)}
{view.overheadTokens > 0 && (
<Row label={localize('com_ui_context_system')} value={view.overheadTokens} />
)}
{maxTokens == null && (
<p className="text-xs text-text-secondary">
{localize('com_ui_context_unknown')}
</p>
)}
<p className="text-xs italic text-text-secondary">{localize('com_ui_estimated')}</p>
</>
)}
</div>
</>
)}
{showCost && hasUsage && branchUsage.costKnown && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-cost">
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">
{showTotal
? localize('com_ui_context_cost_branch')
: localize('com_ui_context_cost')}
</span>
<span className="font-medium text-text-primary">
{formatCost(view.branchCost, currency)}
</span>
</div>
{showTotal && (
<div className="flex items-center justify-between text-xs">
<span className="text-text-secondary">{localize('com_ui_context_cost_total')}</span>
<span className="text-text-secondary">{formatCost(view.totalCost, currency)}</span>
{hasUsage && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-totals">
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-tertiary">
{localize('com_ui_context_totals')}
</h3>
<Row label={localize('com_ui_input')} value={branchUsage.input} />
<Row label={localize('com_ui_output')} value={branchUsage.output} />
{branchUsage.cacheRead > 0 && (
<Row label={localize('com_ui_cache_read')} value={branchUsage.cacheRead} />
)}
{branchUsage.cacheWrite > 0 && (
<Row label={localize('com_ui_cache_write')} value={branchUsage.cacheWrite} />
)}
</div>
)}
</div>
</>
)}
</>
)}
{langfuseSessionUrl && (
<>
<div className="border-t border-border-light" role="separator" />
<Button asChild variant="link" className="h-auto w-full justify-between gap-2 p-0">
<a href={langfuseSessionUrl} target="_blank" rel="noopener noreferrer">
<span>{localize('com_ui_langfuse_view_session')}</span>
<ExternalLink className="size-4 shrink-0" aria-hidden="true" />
</a>
</Button>
</>
)}
{showCost && hasUsage && branchUsage.costKnown && (
<>
<div className="border-t border-border-light" role="separator" />
<div className="space-y-1.5" data-testid="token-usage-cost">
<div className="group flex items-center justify-between text-sm">
<span className={`text-text-secondary ${HOVER_INK}`}>
{showTotal
? localize('com_ui_context_cost_branch')
: localize('com_ui_context_cost')}
</span>
<span className="font-medium text-text-primary">
{formatCost(view.branchCost, currency)}
</span>
</div>
{showTotal && (
<div className="group flex items-center justify-between text-xs">
<span className={`text-text-secondary ${HOVER_INK}`}>
{localize('com_ui_context_cost_total')}
</span>
<span className={`text-text-secondary ${HOVER_INK}`}>
{formatCost(view.totalCost, currency)}
</span>
</div>
)}
</div>
</>
)}
{langfuseSessionUrl && (
<>
<div className="border-t border-border-light" role="separator" />
<Button asChild variant="link" className="h-auto w-full justify-between gap-2 p-0">
<a href={langfuseSessionUrl} target="_blank" rel="noopener noreferrer">
<span>{localize('com_ui_langfuse_view_session')}</span>
<ExternalLink className="size-4 shrink-0" aria-hidden="true" />
</a>
</Button>
</>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -1028,6 +1028,7 @@
"com_ui_context_tools_mcp_deferred": "MCP tools (deferred)",
"com_ui_context_tools_system": "System tools",
"com_ui_context_tools_system_deferred": "System tools (deferred)",
"com_ui_context_totals": "Totals",
"com_ui_context_unknown": "Context size unknown",
"com_ui_context_usage": "Context usage",
"com_ui_context_usage_label": "Context window: {{0}} of {{1}} tokens used ({{2}}%)",

View file

@ -3,6 +3,14 @@ import { atom, getDefaultStore } from 'jotai';
import type { TMessage, TContextUsageEvent } from 'librechat-data-provider';
import type { BranchTotals, BranchUsage } from '~/utils/tokens';
import { EMPTY_BRANCH, EMPTY_USAGE } from '~/utils/tokens';
import { createStorageAtom } from './jotai-utils';
/** Sticky preference: does the context popover open with the breakdown showing?
* The gauge alone is the default; a user who wants the detail sets it once. */
export const contextBreakdownExpandedAtom = createStorageAtom<boolean>(
'contextBreakdownExpanded',
false,
);
/** Latest backend context snapshot, anchored to the run's user message for staleness checks */
export interface ContextSnapshot extends TContextUsageEvent {

View file

@ -215,6 +215,15 @@ html {
--status-neutral-border: var(--gray-300);
--text-on-status: var(--white);
/* Categorical series scale — slot order is the CVD-safety mechanism */
--series-1: 5 110 189;
--series-2: 233 86 13;
--series-3: 0 148 142;
--series-4: 182 123 5;
--series-5: 216 90 142;
--series-6: 126 35 205;
--series-7: 1 131 1;
--surface-inverted: var(--gray-850);
--surface-inverted-hover: var(--gray-700);
--text-inverted: var(--white);
@ -299,6 +308,15 @@ html {
--status-neutral-border: var(--gray-700);
--text-on-status: var(--white);
/* Categorical series scale — same hues, stepped for the dark surface */
--series-1: 9 140 238;
--series-2: 217 87 35;
--series-3: 6 158 152;
--series-4: 200 133 12;
--series-5: 213 82 130;
--series-6: 171 104 254;
--series-7: 80 167 49;
--surface-inverted: var(--white);
--surface-inverted-hover: var(--gray-100);
--text-inverted: var(--gray-850);

View file

@ -36,6 +36,15 @@ module.exports = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
/** Radix Collapsible exposes its own height variable, not the accordion one. */
'collapsible-down': {
from: { height: 0 },
to: { height: 'var(--radix-collapsible-content-height)' },
},
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 0 },
},
'slide-in-right': {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
@ -75,6 +84,8 @@ module.exports = {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out',
'slide-in-right': 'slide-in-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { Page, Locator } from '@playwright/test';
import {
mockReply,
sendMessage,
@ -18,12 +18,26 @@ async function expectGaugeAboveZero(page: Page) {
await expect(gaugeMeter(page)).toHaveAttribute('aria-valuenow', /[1-9]/, { timeout: 20000 });
}
/** Opens the gauge breakdown popover (click, not hover) and returns its region. */
/** The popover opens showing the gauge alone; the detail sits behind a
* disclosure whose state is remembered per user. Idempotent, so it is safe to
* call after a reload that restored an already-expanded preference. */
async function expandBreakdown(popover: Locator) {
const toggle = popover.getByTestId('context-breakdown-toggle');
await expect(toggle).toBeVisible({ timeout: 10000 });
if ((await toggle.getAttribute('aria-expanded')) === 'false') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
/** Opens the gauge breakdown popover (click, not hover), expands the detail,
* and returns its region. */
async function openBreakdown(page: Page) {
await expectGaugeAboveZero(page);
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
await expandBreakdown(popover);
return popover;
}
@ -64,9 +78,7 @@ test.describe('context usage gauge', () => {
/** Breakdown popover: context section always; the usage section is
* scoped by testid since the pre-snapshot fallback renders its own
* Input/Output rows when the lib predates on_context_usage */
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
const popover = await openBreakdown(page);
await expect(popover.getByText('Context window')).toBeVisible();
const usageSection = popover.getByTestId('token-usage-totals');
await expect(usageSection).toBeVisible({ timeout: 10000 });
@ -197,6 +209,47 @@ test.describe('context usage gauge', () => {
await expect(costSection.getByText(/\$\d|<\$0\.01/).first()).toBeVisible();
});
test('opens to the gauge alone and remembers an expanded breakdown', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
await sendAndAwaitReply(page, 'hello');
await expectGaugeAboveZero(page);
/** Default view is the gauge: the meter and its readout, nothing else. */
await gauge(page).click();
const popover = page.getByRole('region', { name: 'Context usage' });
await expect(popover).toBeVisible({ timeout: 10000 });
const toggle = popover.getByTestId('context-breakdown-toggle');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(popover.getByRole('progressbar')).toBeVisible();
await expect(popover.getByTestId('token-usage-totals')).toHaveCount(0);
await expect(popover.getByTestId('context-breakdown')).toHaveCount(0);
/** Expanding reveals the detail, and the usage section is labelled so its
* numbers are not read as part of the context composition. */
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const totals = popover.getByTestId('token-usage-totals');
await expect(totals).toBeVisible({ timeout: 10000 });
await expect(totals.getByRole('heading', { name: 'Totals' })).toBeVisible();
await page.keyboard.press('Escape');
/** The choice is a stored preference, so a reload reopens expanded with no
* second click the part a component test cannot reach. */
await page.reload({ timeout: 15000 });
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await expectGaugeAboveZero(page);
await gauge(page).click();
await expect(popover).toBeVisible({ timeout: 10000 });
await expect(popover.getByTestId('context-breakdown-toggle')).toHaveAttribute(
'aria-expanded',
'true',
);
await expect(popover.getByTestId('token-usage-totals')).toBeVisible({ timeout: 10000 });
});
test('hides on a new chat, then reveals snapshot on hover and breakdown on click', async ({
page,
}) => {

View file

@ -38,7 +38,15 @@ function parseTokens(text: string): number {
}
async function readUsageTotals(page: Page): Promise<{ input: number; output: number }> {
await gauge(page).hover();
/** Click, not hover: hover only surfaces the compact snapshot tooltip. The
* popover then opens to the gauge alone, with the totals behind a remembered
* disclosure idempotent, since the preference survives the previous turn. */
await gauge(page).click();
await expect(popover(page)).toBeVisible({ timeout: 15000 });
const toggle = popover(page).getByTestId('context-breakdown-toggle');
if ((await toggle.getAttribute('aria-expanded')) === 'false') {
await toggle.click();
}
const section = popover(page).getByTestId('token-usage-totals');
await expect(section).toBeVisible({ timeout: 15000 });
const rows = section.locator('div');

View file

@ -0,0 +1,215 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import type { ThemeDefinition } from '../theme/types';
import type { MeterSegment } from './SegmentedMeter';
import { resolveTheme, validateThemeDefinition, THEME_VERSION } from '../theme/registry';
import { applyResolvedTheme, clearAppliedTheme } from '../theme/utils/applyTheme';
import { SegmentedMeter, MeterSwatch, SERIES_SLOT_COUNT } from './SegmentedMeter';
import { createTailwindColors } from '../theme/utils/createTailwindColors';
const segments: MeterSegment[] = [
{ id: 'a', value: 500, slot: 1, outlined: true },
{ id: 'b', value: 250, slot: 2 },
{ id: 'c', value: 10, slot: 2, hatched: true },
];
const renderMeter = (props: Partial<React.ComponentProps<typeof SegmentedMeter>> = {}) =>
render(<SegmentedMeter segments={segments} max={1000} data-testid="meter" {...props} />);
const children = () => Array.from(screen.getByTestId('meter').children) as HTMLElement[];
/** The CSS serializer folds `0.5 * 100%` down to `50%`, so read the parts back
* out of the declaration rather than matching the authored string. */
const sizing = (el: HTMLElement): { fraction: number; gapShare: number } => {
const width = el.style.width;
const fraction = Number(/([\d.]+)%/.exec(width)?.[1]) / 100;
const gapShare = Number(/-\s*([\d.]+)px/.exec(width)?.[1] ?? 0);
return { fraction, gapShare };
};
describe('SegmentedMeter', () => {
it('sizes each segment against max and leaves the shortfall as bare track', () => {
renderMeter();
expect(children().map((child) => sizing(child).fraction)).toEqual([0.5, 0.25, 0.01]);
expect(screen.getByTestId('meter')).toHaveClass('bg-surface-tertiary');
});
it('takes the gaps out of the fill, not out of the free track', () => {
renderMeter();
/** Three segments touch across two 2px gaps. Each gives up its share of that
* 4px, so fills + gaps span exactly the 76% that is actually used a bar
* reading three-quarters full means three-quarters of the window is gone. */
const parts = children().map(sizing);
const filled = parts.reduce((sum, part) => sum + part.fraction, 0);
const surrendered = parts.reduce((sum, part) => sum + part.gapShare, 0);
/** Shares are rounded to 3dp on the way into the declaration. */
expect(filled).toBeCloseTo(0.76, 5);
expect(surrendered).toBeCloseTo(4, 2);
parts.forEach((part) => expect(part.gapShare).toBeCloseTo((4 * part.fraction) / filled, 2));
});
it('reserves no gap share for a lone segment', () => {
renderMeter({ segments: [{ id: 'only', value: 500, slot: 1 }] });
expect(sizing(children()[0])).toEqual({ fraction: 0.5, gapShare: 0 });
});
it('floors every rendered segment so a present category cannot vanish', () => {
renderMeter({ segments: [{ id: 'tiny', value: 1, slot: 3 }], max: 1_000_000 });
expect(children()[0].style.minWidth).toBe('2px');
});
it('drops segments that contribute nothing', () => {
renderMeter({
segments: [
{ id: 'a', value: 0, slot: 1 },
{ id: 'b', value: -5, slot: 2 },
{ id: 'c', value: 5, slot: 3 },
],
});
expect(children()).toHaveLength(1);
expect(children()[0]).toHaveClass('bg-series-3');
});
it('never lets a segment exceed the full track', () => {
renderMeter({ segments: [{ id: 'over', value: 5000, slot: 1 }], max: 1000 });
expect(sizing(children()[0])).toEqual({ fraction: 1, gapShare: 0 });
});
it('renders the outlined variant as a tint plus a solid edge', () => {
renderMeter();
expect(children()[0]).toHaveClass('bg-series-1/25');
expect(children()[0]).toHaveClass('ring-1', 'ring-inset', 'ring-series-1');
expect(children()[1]).toHaveClass('bg-series-2');
expect(children()[1].className).not.toContain('ring-series');
});
it('hatches without changing the slot, and reads the stripe from the theme', () => {
renderMeter();
const [, solid, hatched] = children();
expect(hatched).toHaveClass('bg-series-2');
expect(solid.style.backgroundImage).toBe('');
expect(hatched.style.backgroundImage).toContain('repeating-linear-gradient');
expect(hatched.style.backgroundImage).toContain('var(--surface-tertiary)');
/** A literal colour here would not follow a theme swap. */
expect(hatched.style.backgroundImage).not.toMatch(/#[0-9a-f]{3,8}/i);
});
it('wraps slots past the end of the scale instead of rendering untinted', () => {
renderMeter({
segments: [
{ id: 'wrap', value: 1, slot: SERIES_SLOT_COUNT + 1 },
{ id: 'zero', value: 1, slot: 0 },
],
});
expect(children()[0]).toHaveClass('bg-series-1');
expect(children()[1]).toHaveClass(`bg-series-${SERIES_SLOT_COUNT}`);
});
it('leaves the accessible description to the caller', () => {
renderMeter({ role: 'progressbar', 'aria-valuenow': 75, 'aria-label': 'Context usage' });
const meter = screen.getByRole('progressbar', { name: 'Context usage' });
expect(meter).toHaveAttribute('aria-valuenow', '75');
children().forEach((child) => expect(child).toHaveAttribute('aria-hidden', 'true'));
});
});
describe('MeterSwatch', () => {
it('paints the same treatment as the segment it keys', () => {
render(
<>
<MeterSwatch segment={{ slot: 1, outlined: true }} data-testid="outlined" />
<MeterSwatch segment={{ slot: 2, hatched: true }} data-testid="hatched" />
<MeterSwatch segment={{ slot: 3 }} data-testid="plain" />
</>,
);
expect(screen.getByTestId('outlined')).toHaveClass('bg-series-1/25', 'ring-series-1');
expect(screen.getByTestId('hatched')).toHaveClass('bg-series-2');
expect(screen.getByTestId('hatched').style.backgroundImage).toContain(
'var(--surface-tertiary)',
);
expect(screen.getByTestId('plain')).toHaveClass('bg-series-3');
expect(screen.getByTestId('plain').style.backgroundImage).toBe('');
});
});
/** CLAUDE.md requires a deliberately different reference theme, to prove the
* component follows theme data rather than the bundled LibreChat values. */
const referenceTheme: ThemeDefinition = {
version: THEME_VERSION,
name: 'reference',
modes: {
light: {
colors: {
'rgb-series-1': '12 34 56',
'rgb-series-2': '210 5 90',
'rgb-series-3': '7 199 111',
'rgb-series-4': '250 250 10',
'rgb-series-5': '99 0 200',
'rgb-series-6': '1 2 3',
'rgb-series-7': '240 120 0',
'rgb-surface-tertiary': '30 30 30',
},
},
},
};
describe('reference theme', () => {
afterEach(() => {
clearAppliedTheme();
});
it('is accepted by the registry, so a theme author can retint the scale', () => {
expect(validateThemeDefinition(referenceTheme)).toEqual([]);
});
it('carries the reference values through to the applied CSS variables', () => {
applyResolvedTheme(resolveTheme(referenceTheme, 'light'));
const root = document.documentElement;
expect(root.style.getPropertyValue('--series-1')).toBe('12 34 56');
expect(root.style.getPropertyValue('--series-7')).toBe('240 120 0');
expect(root.style.getPropertyValue('--surface-tertiary')).toBe('30 30 30');
});
it('paints every mark from those variables, with no value of its own', () => {
applyResolvedTheme(resolveTheme(referenceTheme, 'light'));
const { container } = render(
<>
<SegmentedMeter segments={segments} max={1000} data-testid="meter" />
<MeterSwatch segment={{ slot: 3 }} data-testid="swatch" />
</>,
);
/** Nothing in the rendered tree may carry a colour the theme cannot reach:
* no literal colours, and every hatch stripe read from a theme variable. */
const markup = container.innerHTML;
expect(markup).not.toMatch(/#[0-9a-f]{3,8}\b/i);
expect(markup).not.toMatch(/\brgba?\((?!var\()/);
const colours = createTailwindColors();
const meter = screen.getByTestId('meter');
const slotOf = (el: Element) => /\bbg-series-(\d)\b/.exec(el.className)?.[1];
[...meter.children, screen.getByTestId('swatch')].forEach((el) => {
const slot = slotOf(el) ?? /\bbg-series-(\d)\/25\b/.exec(el.className)?.[1];
expect(slot).toBeDefined();
/** The utility the mark wears resolves to the variable the theme just set. */
expect(colours[`series-${slot}`]).toBe(`rgb(var(--series-${slot}) / <alpha-value>)`);
});
});
});

View file

@ -0,0 +1,159 @@
import * as React from 'react';
import { cn } from '~/utils';
/** Static class lookups — Tailwind cannot see an interpolated `bg-series-${n}`. */
const SERIES_FILL = [
'bg-series-1',
'bg-series-2',
'bg-series-3',
'bg-series-4',
'bg-series-5',
'bg-series-6',
'bg-series-7',
] as const;
const SERIES_TINT = [
'bg-series-1/25',
'bg-series-2/25',
'bg-series-3/25',
'bg-series-4/25',
'bg-series-5/25',
'bg-series-6/25',
'bg-series-7/25',
] as const;
const SERIES_EDGE = [
'ring-series-1',
'ring-series-2',
'ring-series-3',
'ring-series-4',
'ring-series-5',
'ring-series-6',
'ring-series-7',
] as const;
export const SERIES_SLOT_COUNT: number = SERIES_FILL.length;
/** Wraps out-of-range slots so a caller can never render an untinted segment. */
const slotIndex = (slot: number): number =>
(((Math.trunc(slot) - 1) % SERIES_SLOT_COUNT) + SERIES_SLOT_COUNT) % SERIES_SLOT_COUNT;
export interface MeterSegment {
/** Stable identity for keys; also the legend key's pairing id */
id: string;
value: number;
/** 1-based slot in the categorical series scale */
slot: number;
/** Same hue, hatched — present but held out of the active set */
hatched?: boolean;
/** Translucent fill with a solid edge, for the one segment that grows */
outlined?: boolean;
}
/** A hatch can't be expressed with semantic utilities; the stripe colour is
* still read from the theme at paint time so it follows every theme. */
const hatchStyle = (spacing: string): React.CSSProperties => ({
backgroundImage: `repeating-linear-gradient(135deg, transparent 0 ${spacing}, rgb(var(--surface-tertiary)) ${spacing} calc(${spacing} + 2px))`,
});
export function seriesSwatchClass(segment: Pick<MeterSegment, 'slot' | 'outlined'>): string {
const index = slotIndex(segment.slot);
return segment.outlined
? cn(SERIES_TINT[index], 'ring-1 ring-inset', SERIES_EDGE[index])
: SERIES_FILL[index];
}
/** The legend key. Lives beside the meter so a row and its segment cannot drift. */
export interface MeterSwatchProps extends React.ComponentPropsWithoutRef<'span'> {
segment: Pick<MeterSegment, 'slot' | 'hatched' | 'outlined'>;
}
export function MeterSwatch({
segment,
className,
...props
}: MeterSwatchProps): React.ReactElement {
return (
<span
aria-hidden="true"
className={cn('size-2 flex-none rounded-sm', seriesSwatchClass(segment), className)}
style={segment.hatched ? hatchStyle('1.5px') : undefined}
{...props}
/>
);
}
export interface SegmentedMeterProps extends React.ComponentPropsWithoutRef<'div'> {
segments: MeterSegment[];
/** Denominator for every segment width; the shortfall renders as free track */
max: number;
}
/** Surface gap between touching fills, and the floor that keeps a present
* category from rendering as nothing. Both in px. */
const SEGMENT_GAP = 2;
const SEGMENT_MIN = 2;
/**
* A part-to-whole meter: one tinted segment per series, free space left as
* bare track.
*
* Each segment surrenders its share of the gap budget, so the fills and the
* gaps between them together span exactly the used fraction a bar reading
* half full means half the window is used, however many categories are in it.
*
* The one deliberate overshoot is the {@link SEGMENT_MIN} floor: a category
* present but too small to see is rounded up, and that rounding comes out of
* free space, never out of a neighbouring category. It is bounded by
* `SEGMENT_MIN` per sub-pixel category on a 288px meter, five such categories
* read about 3 percentage points fuller than the window actually is, and the
* exact figures stay in the legend beside the bar.
*/
export const SegmentedMeter: React.ForwardRefExoticComponent<
SegmentedMeterProps & React.RefAttributes<HTMLDivElement>
> = React.forwardRef<HTMLDivElement, SegmentedMeterProps>(
({ segments, max, className, ...props }, ref) => {
const rendered = segments.filter((segment) => segment.value > 0);
const gapBudget = Math.max(rendered.length - 1, 0) * SEGMENT_GAP;
const fractions = rendered.map((segment) => Math.min(segment.value / max, 1));
const filled = Math.min(
fractions.reduce((sum, fraction) => sum + fraction, 0),
1,
);
return (
<div
ref={ref}
className={cn(
'flex h-2 w-full gap-[2px] overflow-hidden rounded-full bg-surface-tertiary',
className,
)}
{...props}
>
{rendered.map((segment, index) => {
const fraction = fractions[index];
/** Each segment gives up its share of the gap budget, so the fills and
* the gaps between them together span exactly `filled` of the track. */
const gapShare = filled > 0 ? (gapBudget * fraction) / filled : 0;
return (
<div
key={segment.id}
aria-hidden="true"
className={cn(
'h-full transition-[width] duration-300 motion-reduce:transition-none',
seriesSwatchClass(segment),
)}
style={{
width: `calc(${fraction} * 100% - ${gapShare.toFixed(3)}px)`,
minWidth: `${SEGMENT_MIN}px`,
...(segment.hatched ? hatchStyle('2.5px') : undefined),
}}
/>
);
})}
</div>
);
},
);
SegmentedMeter.displayName = 'SegmentedMeter';

View file

@ -30,6 +30,8 @@ export * from './Toast';
export * from './Tooltip';
export * from './Pagination';
export * from './Progress';
export * from './SegmentedMeter';
export * from './Collapsible';
export * from './InputOTP';
export * from './IconButton';
export * from './MultiSearch';

View file

@ -1,5 +1,7 @@
import type {
IThemeAppearance,
IThemeColors,
IThemeVariables,
IThemeRGB,
ResolvedThemeDefinition,
ThemeDefinition,
@ -10,6 +12,21 @@ import { darkTheme } from './themes/dark';
export const THEME_VERSION = 1 as const;
/**
* Compile-time guard: the categorical series scale is declared across three
* hand-maintained token maps, so a slot added to one and missed in another
* fails the build rather than surfacing as a broken theme downstream.
*/
type SeriesSlot = 1 | 2 | 3 | 4 | 5 | 6 | 7;
type Assert<Declared extends true> = Declared;
type DeclaredIn<Keys extends PropertyKey, Tokens> = [Keys] extends [keyof Tokens] ? true : false;
export type SeriesTokensAreDeclared = [
Assert<DeclaredIn<`rgb-series-${SeriesSlot}`, IThemeRGB>>,
Assert<DeclaredIn<`--series-${SeriesSlot}`, IThemeVariables>>,
Assert<DeclaredIn<`series-${SeriesSlot}`, IThemeColors>>,
];
export const themeColorTokens: readonly (keyof IThemeRGB)[] = Object.freeze(
Object.keys(defaultTheme) as Array<keyof IThemeRGB>,
);

View file

@ -1,6 +1,7 @@
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
import type { IThemeRGB } from './types';
import { createTailwindColors } from './utils/createTailwindColors';
import { defaultTheme } from './themes/default';
import { darkTheme } from './themes/dark';
@ -9,6 +10,7 @@ const sharedComponents = [
'AlertDialog.tsx',
'Button.tsx',
'Chip.tsx',
'SegmentedMeter.tsx',
'Dialog.tsx',
'DialogTemplate.tsx',
'IconButton.tsx',
@ -229,3 +231,77 @@ describe.each([
expect(failures).toEqual([]);
});
});
/** The meter paints segments on `surface-tertiary`; the swatch and popover chrome
* sit on `surface-secondary`. Both have to clear the 3:1 mark-contrast floor. */
const seriesTokens = Array.from(
{ length: 7 },
(_, index) => `rgb-series-${index + 1}` as keyof IThemeRGB,
);
const seriesSurfaces: Array<keyof IThemeRGB> = ['rgb-surface-tertiary', 'rgb-surface-secondary'];
const WCAG_MARK_MIN = 3;
describe('categorical series scale', () => {
it('defines every slot in both modes as an "R G B" triplet', () => {
seriesTokens.forEach((token) => {
expect(() => toRgb(defaultTheme, token)).not.toThrow();
expect(() => toRgb(darkTheme, token)).not.toThrow();
});
});
it('never reuses a reserved status colour for series identity', () => {
const reserved = new Set(
statusHues.flatMap((hue) => [
defaultTheme[`rgb-status-${hue}` as keyof IThemeRGB],
darkTheme[`rgb-status-${hue}` as keyof IThemeRGB],
]),
);
seriesTokens.forEach((token) => {
expect(reserved.has(defaultTheme[token])).toBe(false);
expect(reserved.has(darkTheme[token])).toBe(false);
});
});
it('keeps the app CSS defaults in step with the runtime themes', () => {
const appStyles = readFileSync(
join(__dirname, '..', '..', '..', '..', 'client', 'src', 'style.css'),
'utf8',
);
seriesTokens.forEach((token) => {
const property = token.slice(4);
const declared = [...appStyles.matchAll(new RegExp(`--${property}:\\s*([^;]+);`, 'g'))].map(
(match) => match[1].trim(),
);
/** One declaration for `html`, one for `.dark` — and both must match. */
expect(declared).toEqual([defaultTheme[token], darkTheme[token]]);
});
});
it('exposes each slot as a Tailwind utility backed by its CSS variable', () => {
const colors = createTailwindColors();
seriesTokens.forEach((token) => {
const property = token.slice(4);
expect(colors[property]).toBe(`rgb(var(--${property}) / <alpha-value>)`);
});
});
});
describe.each([
['default', defaultTheme],
['dark', darkTheme],
])('%s series contrast', (_name, theme: IThemeRGB) => {
it('keeps every series slot at the 3:1 mark floor on the track and the panel', () => {
const failures = seriesTokens.flatMap((token) =>
seriesSurfaces.flatMap((surface) => {
const ratio = contrast(toRgb(theme, token), toRgb(theme, surface));
return ratio < WCAG_MARK_MIN ? [`${token} on ${surface}: ${ratio.toFixed(2)}:1`] : [];
}),
);
expect(failures).toEqual([]);
});
});

View file

@ -88,6 +88,16 @@ export const darkTheme: IThemeRGB = {
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff
/** Categorical series scale the same seven hues stepped for the #212121
* surface: worst adjacent CVD ΔE 13.0, normal-vision ΔE 19.0, all 3:1. */
'rgb-series-1': '9 140 238', // #098cee (cerulean)
'rgb-series-2': '217 87 35', // #d95723 (orange)
'rgb-series-3': '6 158 152', // #069e98 (aqua)
'rgb-series-4': '200 133 12', // #c8850c (amber)
'rgb-series-5': '213 82 130', // #d55282 (magenta)
'rgb-series-6': '171 104 254', // #ab68fe (violet)
'rgb-series-7': '80 167 49', // #50a731 (green)
// Presentation
'rgb-presentation': '33 33 33', // #212121 (gray-800)
};

View file

@ -88,6 +88,17 @@ export const defaultTheme: IThemeRGB = {
// Brand colors
'rgb-brand-purple': '126 34 206', // #7e22ce (purple-700)
/** Categorical series scale. Steps clear 3:1 against BOTH the popover surface
* and the #ececec meter track, with worst adjacent CVD ΔE 12.4 and worst
* adjacent normal-vision ΔE 19.0. Slot order is the CVD-safety mechanism. */
'rgb-series-1': '5 110 189', // #056ebd (cerulean)
'rgb-series-2': '233 86 13', // #e9560d (orange)
'rgb-series-3': '0 148 142', // #00948e (aqua)
'rgb-series-4': '182 123 5', // #b67b05 (amber)
'rgb-series-5': '216 90 142', // #d85a8e (magenta)
'rgb-series-6': '126 35 205', // #7e23cd (violet)
'rgb-series-7': '1 131 1', // #018301 (green)
// Presentation
'rgb-presentation': '255 255 255', // #fff (white)
};

View file

@ -86,6 +86,19 @@ export interface IThemeRGB {
// Brand colors
'rgb-brand-purple'?: string;
/**
* Categorical data-visualisation scale. Slots carry series identity only the
* order is the colour-vision-deficiency safety mechanism and must not be
* reshuffled. Reserved status colors never appear here.
*/
'rgb-series-1'?: string;
'rgb-series-2'?: string;
'rgb-series-3'?: string;
'rgb-series-4'?: string;
'rgb-series-5'?: string;
'rgb-series-6'?: string;
'rgb-series-7'?: string;
// Presentation
'rgb-presentation'?: string;
}
@ -165,6 +178,15 @@ export interface IThemeVariables {
'--status-neutral-border': string;
'--text-on-status': string;
'--brand-purple': string;
'--series-1': string;
'--series-2': string;
'--series-3': string;
'--series-4': string;
'--series-5': string;
'--series-6': string;
'--series-7': string;
'--presentation': string;
}
@ -239,6 +261,14 @@ export interface IThemeColors {
'status-neutral-border'?: string;
'text-on-status'?: string;
'brand-purple'?: string;
'series-1'?: string;
'series-2'?: string;
'series-3'?: string;
'series-4'?: string;
'series-5'?: string;
'series-6'?: string;
'series-7'?: string;
presentation?: string;
// Retained for excluded SidePanel/Agents + SidePanel/Builder (pending migration)

View file

@ -140,6 +140,14 @@ function createTailwindColors() {
'status-neutral-border': cssVar('--status-neutral-border'),
'text-on-status': cssVar('--text-on-status'),
'series-1': cssVar('--series-1'),
'series-2': cssVar('--series-2'),
'series-3': cssVar('--series-3'),
'series-4': cssVar('--series-4'),
'series-5': cssVar('--series-5'),
'series-6': cssVar('--series-6'),
'series-7': cssVar('--series-7'),
'switch-unchecked': hslVar('--switch-unchecked'),
};
}