mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🎨 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:
parent
58f0ab7f62
commit
530a935a74
17 changed files with 1082 additions and 182 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%)",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
215
packages/client/src/components/SegmentedMeter.spec.tsx
Normal file
215
packages/client/src/components/SegmentedMeter.spec.tsx
Normal 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>)`);
|
||||
});
|
||||
});
|
||||
});
|
||||
159
packages/client/src/components/SegmentedMeter.tsx
Normal file
159
packages/client/src/components/SegmentedMeter.tsx
Normal 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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue