From 75fada4fb3afdcacd33757cb924751f7d0d9a968 Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Sun, 13 Sep 2026 09:18:40 +0800 Subject: [PATCH] refactor(client): adopt Tailwind color tokens for the plugin's palette Replace the hand-picked hex literals with the nearest standard Tailwind tokens now emitted by the CSS channel: the category/timing/ring accents (indigo, amber, green, purple, blue, teal, pink, violet, red, slate-400) and the neutral chart tracks and zebra fills, recast as color-mix over --color-neutral-500 at the original alphas. Donut and agent-graph ring segments now carry their stroke as an inline style instead of the presentation attribute: the colors are CSS variables and var() is unusable in an attribute. Free segments keep their class rule by omitting the inline stroke. Visual output is unchanged (the v4 values are the nearest equivalents of the former v3 hexes); harness --dsw-* semantic tokens, the logo fills and the language identity colors stay as-is. --- src/client/categories.ts | 14 ++++++------ src/client/components/agentGraph.tsx | 11 +++++---- src/client/components/donut.tsx | 5 +++-- src/client/components/statsTiming.tsx | 12 +++++----- src/client/components/trendChart.tsx | 5 ++++- src/client/styles/agentGraph.css | 2 +- src/client/styles/events.css | 4 ++-- src/client/styles/requestDetail.css | 4 ++-- src/client/styles/stackedBar.css | 2 +- src/client/styles/stats.css | 2 +- src/client/styles/trendChart.css | 4 ++-- tests/client/categories.spec.ts | 26 +++++++++++----------- tests/client/components/agentGraph.spec.ts | 6 ++--- tests/client/components/donut.spec.ts | 4 +++- tests/client/components/trendChart.spec.ts | 6 ++--- 15 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/client/categories.ts b/src/client/categories.ts index 51b4b91..86c0b2f 100644 --- a/src/client/categories.ts +++ b/src/client/categories.ts @@ -27,12 +27,12 @@ export interface PartsPart { } export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [ - { key: 'system', color: '#6366f1' }, - { key: 'tools', color: '#f59e0b' }, - { key: 'user', color: '#22c55e' }, - { key: 'inject', color: '#a855f7' }, - { key: 'assistant', color: '#3b82f6' }, - { key: 'tool', color: '#14b8a6' }, + { key: 'system', color: 'var(--color-indigo-500)' }, + { key: 'tools', color: 'var(--color-amber-500)' }, + { key: 'user', color: 'var(--color-green-500)' }, + { key: 'inject', color: 'var(--color-purple-500)' }, + { key: 'assistant', color: 'var(--color-blue-500)' }, + { key: 'tool', color: 'var(--color-teal-500)' }, ] /** Category key → bar color, for per-item bands (the browser's DNA mode) that bypass the CATS-order part builders. */ @@ -130,5 +130,5 @@ export function billedParts( const prompt = input > 0 ? anchoredParts(estimated, input) : estimated.map(p => ({ ...p, value: 0 })) - return [...prompt, { key: 'output', color: '#ec4899', value: Math.max(0, usage.outputTokens) }] + return [...prompt, { key: 'output', color: 'var(--color-pink-500)', value: Math.max(0, usage.outputTokens) }] } diff --git a/src/client/components/agentGraph.tsx b/src/client/components/agentGraph.tsx index 445ce2b..422df2a 100644 --- a/src/client/components/agentGraph.tsx +++ b/src/client/components/agentGraph.tsx @@ -40,9 +40,9 @@ const CAPTION_H = 60 /** Fallback arc color for pressure-only nodes (no composition data), by fill ratio. */ export function ringColorOf(pct: number | null): string { if (pct === null) return 'var(--dsw-alias-border-l1)' - if (pct >= 90) return '#ef4444' - if (pct >= 70) return '#f59e0b' - return '#22c55e' + if (pct >= 90) return 'var(--color-red-500)' + if (pct >= 70) return 'var(--color-amber-500)' + return 'var(--color-green-500)' } export function makeAgentGraph( @@ -269,9 +269,12 @@ function AgentNodeView(props: NodeViewProps): ReactElement { key={seg.key} className={'lc-agent-seg' + (seg.free ? ' lc-agent-free' : '')} r={AGENT_RING_R} - stroke={seg.free ? undefined : seg.color} strokeDasharray={`${seg.len} ${ring - seg.len}`} strokeDashoffset={-seg.offset} + // Inline style, not the stroke attribute: segment colors are CSS variables + // (var() is unusable in a presentation attribute). Free segments carry no + // inline stroke so the .lc-agent-free class rule keeps painting the remainder. + style={{ stroke: seg.free ? undefined : seg.color }} transform="rotate(-90)" /> ))} diff --git a/src/client/components/donut.tsx b/src/client/components/donut.tsx index 5a7445f..4180a2b 100644 --- a/src/client/components/donut.tsx +++ b/src/client/components/donut.tsx @@ -95,13 +95,14 @@ export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactElement { cy="21" r="15.9155" fill="none" - stroke={a.color} strokeWidth="4" strokeDasharray={`${a.len} ${100 - a.len}`} strokeDashoffset={a.offset} // Sweep-in stagger slot (stats.css animates stroke-dasharray from 0 100 up to these attribute // values, so the slices build clockwise from 12 o'clock, one after another). - style={{ '--lc-i': i } as CSSProperties} + // The stroke rides inline because the colors are CSS variables and SVG + // presentation attributes cannot carry var(). + style={{ '--lc-i': i, stroke: a.color } as CSSProperties} onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(a.key) }} /> ))} diff --git a/src/client/components/statsTiming.tsx b/src/client/components/statsTiming.tsx index 9003532..2ffb6ac 100644 --- a/src/client/components/statsTiming.tsx +++ b/src/client/components/statsTiming.tsx @@ -38,12 +38,12 @@ interface Slice { } const COLOR = { - ttft: '#3b82f6', - reasoning: '#8b5cf6', - text: '#ec4899', - toolarg: '#f59e0b', - tools: '#14b8a6', - other: '#94a3b8', + ttft: 'var(--color-blue-500)', + reasoning: 'var(--color-violet-500)', + text: 'var(--color-pink-500)', + toolarg: 'var(--color-amber-500)', + tools: 'var(--color-teal-500)', + other: 'var(--color-slate-400)', } as const export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactElement): (props: { diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index e01b59f..ac70dee 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -107,7 +107,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const STAGGER_CAP = 20 // Neutral zebra, deliberately DISJOINT from the category palette — the strip must read as a partition layer, not a bottom segment of the // composition bars. - const TURN_FILLS = ['rgba(128,128,128,0.12)', 'rgba(128,128,128,0.26)'] + const TURN_FILLS = [ + 'color-mix(in srgb, var(--color-neutral-500) 12%, transparent)', + 'color-mix(in srgb, var(--color-neutral-500) 26%, transparent)', + ] // Turn labels render at natural width (a 2-digit "12" is wider than a 14px turn bar) and overflow their block. // Every label must stay on the single line, so the strip shrinks ALL labels to one font size — the largest at // which the tightest adjacent pair still clears the gap (analytic widths below, no measurement) — and the diff --git a/src/client/styles/agentGraph.css b/src/client/styles/agentGraph.css index 6420141..b71ba6b 100644 --- a/src/client/styles/agentGraph.css +++ b/src/client/styles/agentGraph.css @@ -40,7 +40,7 @@ .lc-agent-self-badge { margin-left: 5px; vertical-align: 1px; } .lc-agents-badge-on { color: var(--dsw-alias-state-success-primary); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 40%, transparent); } .lc-agents-inspector-stats { color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.lc-agents-inspector-open { margin-left: auto; flex: none; color: var(--dsw-alias-brand-primary, #3b82f6); } +.lc-agents-inspector-open { margin-left: auto; flex: none; color: var(--dsw-alias-brand-primary, var(--color-blue-500)); } .lc-agents-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; font-size: 11px; color: var(--dsw-alias-label-secondary); } .lc-agents-legend-item { display: inline-flex; align-items: center; gap: 5px; } .lc-agents-legend-item i { width: 8px; height: 8px; border-radius: 2px; } diff --git a/src/client/styles/events.css b/src/client/styles/events.css index 41c36d4..cbfa95b 100644 --- a/src/client/styles/events.css +++ b/src/client/styles/events.css @@ -1,7 +1,7 @@ /* The event log: list rows and kind tint pills (events.tsx). */ .lc-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; } .lc-event-icon { width: 18px; text-align: center; color: var(--dsw-alias-state-warn-primary); } -.lc-event-icon.lc-event-inject { color: #a855f7; } +.lc-event-icon.lc-event-inject { color: var(--color-purple-500); } .lc-event-icon.lc-event-model { color: var(--dsw-alias-brand-primary); } .lc-event-icon.lc-event-mode { color: var(--dsw-alias-label-secondary); } /* @@ -10,7 +10,7 @@ .lc-kind { flex: none; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; white-space: nowrap; } .lc-kind-inject { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); color: var(--dsw-alias-state-success-primary); } .lc-kind-compaction { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); color: var(--dsw-alias-state-error-primary); } -.lc-kind-prune { background: color-mix(in srgb, #8b5cf6 15%, transparent); color: #8b5cf6; } +.lc-kind-prune { background: color-mix(in srgb, var(--color-violet-500) 15%, transparent); color: var(--color-violet-500); } .lc-kind-model { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 15%, transparent); color: var(--dsw-alias-brand-primary); } .lc-kind-mode { background: color-mix(in srgb, var(--dsw-alias-label-secondary) 15%, transparent); color: var(--dsw-alias-label-secondary); } .lc-event-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/client/styles/requestDetail.css b/src/client/styles/requestDetail.css index 6ab0627..e823bc6 100644 --- a/src/client/styles/requestDetail.css +++ b/src/client/styles/requestDetail.css @@ -52,10 +52,10 @@ button.lc-brief-row-link { cursor: pointer; } .lc-detail-row-on i { box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); } .lc-detail-row-on .lc-detail-label { font-weight: 600; } .lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); } -.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; position: relative; } +.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); overflow: hidden; display: block; position: relative; } .lc-bar-fill { display: block; height: 100%; border-radius: 3px; } /* Delta rows: a hairline zero at the middle, growth fills right of it, shrinkage left. */ -.lc-bar-zero { position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid rgba(128,128,128,0.45); } +.lc-bar-zero { position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid color-mix(in srgb, var(--color-neutral-500) 45%, transparent); } .lc-bar-fill-up, .lc-bar-fill-down { position: absolute; top: 0; height: 100%; display: block; } .lc-bar-fill-up { left: 50%; border-radius: 0 3px 3px 0; } .lc-bar-fill-down { right: 50%; border-radius: 3px 0 0 3px; } diff --git a/src/client/styles/stackedBar.css b/src/client/styles/stackedBar.css index 237b5b2..3a642e8 100644 --- a/src/client/styles/stackedBar.css +++ b/src/client/styles/stackedBar.css @@ -1,6 +1,6 @@ /* The composition stacked bar with occupied-window frame, reserve band, legend chips and hover tip (stackedBar.tsx). */ .lc-stacked-wrap { position: relative; width: 100%; } -.lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: rgba(128,128,128,0.18); position: relative; } +.lc-stacked { display: flex; width: 100%; border-radius: 5px; overflow: hidden; background: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); position: relative; } /* Hover reference frame around the OCCUPIED region of the composition bar: a solid box from the left edge to the used/window boundary so the legend/tooltip 'share of used' percentages visibly map to the boxed part; pointer-events: none keeps hover on the segments/free; the free track echoes it dashed so the two read as one system. */ diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index ec98fbd..8942e58 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -45,7 +45,7 @@ .lc-donut-row .lc-sl { flex: 1 1 auto; } .lc-donut { position: relative; flex: none; } .lc-donut svg { display: block; } -.lc-donut-track { stroke: rgba(128,128,128,0.18); } +.lc-donut-track { stroke: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); } /* The legend row ↔ segment hover link: hovering either side dims the ring's other slices to 0.35 (the stacked bars' idiom) while the active one stays solid; the center figures stay at rest. */ diff --git a/src/client/styles/trendChart.css b/src/client/styles/trendChart.css index 565cc61..039dd10 100644 --- a/src/client/styles/trendChart.css +++ b/src/client/styles/trendChart.css @@ -30,7 +30,7 @@ .lc-grid-q1 { top: 102px; } /* The SOLID zero baseline in BOTH modes — inline-positioned off the up-arm in delta, the chart floor (top 130px, right under the bars) under the '0' label in total; painted after the dashed guides. */ -.lc-grid-zero { top: 130px; border-top-style: solid; border-top-color: rgba(128,128,128,0.5); } +.lc-grid-zero { top: 130px; border-top-style: solid; border-top-color: color-mix(in srgb, var(--color-neutral-500) 50%, transparent); } .lc-bar { position: relative; width: 14px; flex: none; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; outline: 1px dashed transparent; outline-offset: 1px; transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), outline-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } .lc-chart-dim .lc-bar { opacity: 0.35; } .lc-chart-dim .lc-bar-in-turn { opacity: 1; } @@ -55,7 +55,7 @@ */ .lc-bar-hovered { outline-color: var(--dsw-alias-brand-primary); } .lc-bar-selected { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 1px; } -.lc-bar-in-turn { background: rgba(128,128,128,0.14); } +.lc-bar-in-turn { background: color-mix(in srgb, var(--color-neutral-500) 14%, transparent); } .lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; } .lc-bar-stack > div { width: 100%; } /* diff --git a/tests/client/categories.spec.ts b/tests/client/categories.spec.ts index e1c58ea..64fca65 100644 --- a/tests/client/categories.spec.ts +++ b/tests/client/categories.spec.ts @@ -24,12 +24,12 @@ describe('partsOf', () => { test('reads all six categories in CATS order with their colors', () => { const parts = partsOf(current({ system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6 })) assert.deepEqual(parts.map(p => [p.key, p.color, p.value]), [ - ['system', '#6366f1', 1], - ['tools', '#f59e0b', 2], - ['user', '#22c55e', 3], - ['inject', '#a855f7', 4], - ['assistant', '#3b82f6', 5], - ['tool', '#14b8a6', 6], + ['system', 'var(--color-indigo-500)', 1], + ['tools', 'var(--color-amber-500)', 2], + ['user', 'var(--color-green-500)', 3], + ['inject', 'var(--color-purple-500)', 4], + ['assistant', 'var(--color-blue-500)', 5], + ['tool', 'var(--color-teal-500)', 6], ]) }) @@ -131,13 +131,13 @@ describe('billedParts', () => { // Input 400 equals the estimated total, so the ratios carry over as-is; // the seven parts sum to the chat line's 460 by construction. assert.deepEqual(parts.map(p => [p.key, p.color, p.value]), [ - ['system', '#6366f1', 50], - ['tools', '#f59e0b', 50], - ['user', '#22c55e', 100], - ['inject', '#a855f7', 0], - ['assistant', '#3b82f6', 100], - ['tool', '#14b8a6', 100], - ['output', '#ec4899', 60], + ['system', 'var(--color-indigo-500)', 50], + ['tools', 'var(--color-amber-500)', 50], + ['user', 'var(--color-green-500)', 100], + ['inject', 'var(--color-purple-500)', 0], + ['assistant', 'var(--color-blue-500)', 100], + ['tool', 'var(--color-teal-500)', 100], + ['output', 'var(--color-pink-500)', 60], ]) }) diff --git a/tests/client/components/agentGraph.spec.ts b/tests/client/components/agentGraph.spec.ts index 4db4ee3..965e37e 100644 --- a/tests/client/components/agentGraph.spec.ts +++ b/tests/client/components/agentGraph.spec.ts @@ -393,8 +393,8 @@ describe('AgentGraph — the family tree', () => { describe('ringColorOf', () => { test('occupancy thresholds', () => { assert.equal(ringColorOf(null), 'var(--dsw-alias-border-l1)') - assert.equal(ringColorOf(95), '#ef4444') - assert.equal(ringColorOf(70), '#f59e0b') - assert.equal(ringColorOf(12), '#22c55e') + assert.equal(ringColorOf(95), 'var(--color-red-500)') + assert.equal(ringColorOf(70), 'var(--color-amber-500)') + assert.equal(ringColorOf(12), 'var(--color-green-500)') }) }) diff --git a/tests/client/components/donut.spec.ts b/tests/client/components/donut.spec.ts index d7d36b1..2accda9 100644 --- a/tests/client/components/donut.spec.ts +++ b/tests/client/components/donut.spec.ts @@ -53,7 +53,9 @@ describe('Donut', () => { })) const circles = queryAll(m.container, 'circle') assert.equal(circles.length, 1) - assert.equal(circles[0].getAttribute('stroke'), '#ffffff') + // The color rides inline style (var() values can't live in a stroke attribute); + // jsdom reports the style keyword in its normalized rgb() spelling. + assert.equal(circles[0].style.stroke, 'rgb(255, 255, 255)') await m.unmount() }) diff --git a/tests/client/components/trendChart.spec.ts b/tests/client/components/trendChart.spec.ts index 0345262..1a0c25a 100644 --- a/tests/client/components/trendChart.spec.ts +++ b/tests/client/components/trendChart.spec.ts @@ -179,7 +179,7 @@ describe('TrendChart step granularity, total mode', () => { // Zero-value categories are skipped entirely (r3.user = 0 → five segments, no user-green segment). const segs3 = queryAll(bs[2], '.lc-bar-stack > div') assert.equal(segs3.length, 5) - assert.ok(![...segs3].some(s => s.style.background.includes('34, 197, 94') || s.style.background === '#22c55e')) + assert.ok(![...segs3].some(s => s.style.background.includes('var(--color-green-500)'))) // Total-mode axis: full quartile graduation — max, ¾, ½, ¼, 0. assert.equal(query(m.container, '.lc-axis-top').textContent, '600') @@ -194,8 +194,8 @@ describe('TrendChart step granularity, total mode', () => { assert.equal(turns.length, 2) assert.equal(turns[0].style.width, '30px') assert.equal(turns[1].style.width, '14px') - assert.ok(turns[0].style.background.includes('0.12')) - assert.ok(turns[1].style.background.includes('0.26')) + assert.ok(turns[0].style.background.includes('var(--color-neutral-500) 12%')) + assert.ok(turns[1].style.background.includes('var(--color-neutral-500) 26%')) assert.deepEqual(turns.map(t => t.textContent), ['1', '0']) await m.unmount() })