refactor(client): fold the responsive layout into Tailwind variants and intrinsic flex

All seven hand-written @container blocks move out of the sheets: the
head-band stack, the stats grid's wide/narrow columns, the donut row's
gap fold and stack, and the events/file rows' narrow-card wraps now ride
@min/@max-[…]/lc-card utilities on the elements (named lc-card /
lc-head-row containers). Reviewing that migration against the shared
column idiom folded the head band's 720px container query away entirely —
the rows were already flex-wrap rows, so the head cards take the same
`flex-1 min-w-[min(360px,100%)]` floor as every other column and wrap
intrinsically. The count grid became repeat(auto-fit, minmax(108px,1fr))
— 108px is the floor where the longest English label fits — replacing
the 700px/320px breakpoints and filling the card at every width instead
of jumping 3 to 6 columns.

What stays hand-written: the 240px donut stack and 380px row wraps (the
ring/legend and the 92px/46px reservations are measured-content facts,
and Tailwind's preset container scale is rem-based where this plugin is
px-anchored), plus the lc-card container definition whose .lc-root scope
deliberately keeps the /context modal's cards container-less.

Verified in the browser across 1280/900/736/640/390 in both locales and
in the /context modal: same folds at the same widths, no overflow, no
horizontal scrollbars. The visible delta is the count grid filling
intermediate widths with 4/5 columns.
This commit is contained in:
Bowen Liang committed 2026-09-13 10:09:27 +08:00
1 parent 17183882be
commit ff10cff71c
13 files changed
+60 -110

No files matched your search

+8 -7
View File
@@ -509,8 +509,8 @@ export function makeContextView(
{/* The head band splits into two rows: the session's shape beside the
plugin card, then the two donut cards together. The sidebar panel
drops the first row (context stats / plugin info pay off only on
the full-width tab) and keeps the donut row, whose container query
stacks the pair in the narrow pane. */}
the full-width tab); the rows' own flex-wrap stacks the pair in a
narrow pane at the shared 360px card floor. */}
{inSidebar ? null : (
<div className="lc-cols lc-head">
<StatsContext counts={counts} humanInputs={data.humanInputs} toolCalls={data.toolCalls} usage={usage}
@@ -525,18 +525,19 @@ export function makeContextView(
{/* One arrangement for every host: composition over trend in the left
column, the browser beside them and stretched to the pair's height.
The sidebar panel's column simply folds to one at the shared 360px
floor instead of splitting the three cards across two ragged rows. */}
All columns share the 360px floor (`min-w-[min(360px,100%)]`): the
rows wrap at it, and a sub-360px pane narrows the column instead
of overflowing. */}
<div className="lc-cols lc-cols-main">
<div className="lc-col">{compositionCard}{trendCard}</div>
<div className="lc-col flex-1 min-w-[min(360px,100%)]">{compositionCard}{trendCard}</div>
{/* `lc-col-browser` stretches the browser card to the left column's
height; the /context modal, which draws its own stack, must stay
content-sized. */}
<div className="lc-col lc-col-browser">{browserCard}</div>
<div className="lc-col lc-col-browser flex-1 min-w-[min(360px,100%)]">{browserCard}</div>
</div>
<div className="lc-cols">
<div className="lc-card lc-col">
<div className="lc-card lc-col flex-1 min-w-[min(360px,100%)]">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('events.title')}</span>
<div className="lc-kinds">
+1 -1
View File
@@ -80,7 +80,7 @@ export function makeDonut(kit: ViewKit): (props: DonutProps) => ReactElement {
&& arcs.some(a => a.key === props.hoverKey)
return (
<div
className={'lc-donut' + (hovering ? ' lc-donut-dim' : '')}
className={'lc-donut @max-[240px]/lc-card:mx-auto' + (hovering ? ' lc-donut-dim' : '')}
style={{ width: size, height: size }}
onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}
>
+6 -2
View File
@@ -119,11 +119,15 @@ export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactEle
: EVENT_ICONS[ev.kind] || '•'
// Key on the durable seq alone: the list renders newest-first, so a fresh event lands at index 0
// and an index-bearing key would shift EVERY existing row's key — a full-list remount on every push.
// Narrow cards (phone panes): the single-line row would crush the label and push the meta
// tail into a scrollbar, so the row wraps — the label's near-full-width basis keeps line 1
// to icon + kind + label, and the meta tail folds onto line 2. The 92px basis reservation
// covers icon + gap + the widest kind chip (both locales) + gap.
return (
<div key={ev.seq} className="lc-event">
<div key={ev.seq} className="lc-event @max-[380px]/lc-card:flex-wrap">
<span className={'lc-event-icon lc-event-' + ev.kind}>{glyph}</span>
<span className={'lc-kind lc-kind-' + ev.kind}>{t('kind.' + ev.kind)}</span>
<span className="lc-event-label">{label}</span>
<span className="lc-event-label flex-1 @max-[380px]/lc-card:basis-[calc(100%-92px)]">{label}</span>
{at !== null ? <span className="lc-event-at">{at}</span> : null}
{ev.tokens ? (
<span className={'lc-event-tokens' + (ev.kind === 'inject' ? ' lc-up' : ' lc-down')}>
+7 -3
View File
@@ -156,7 +156,8 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
) : (
<div>
<div className="lc-fa-ctl">
<div className="lc-gran">
{/* The five purpose chips (labels + counts) overflow a ~300px card in English — flow the group to two lines. */}
<div className="lc-gran @max-[380px]/lc-card:flex-wrap">
{chips.map(c => (
<button
key={c.key}
@@ -225,7 +226,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
<div key={e.path} className={'lc-fa-item' + (open ? ' lc-fa-item-on' : '')}>
<button
type="button"
className="lc-fa-row hover:bg-(--dsw-alias-interactive-bg-hover)"
className="lc-fa-row hover:bg-(--dsw-alias-interactive-bg-hover) @max-[380px]/lc-card:flex-wrap"
title={e.path}
onClick={() => { setOpenPath(open ? null : e.path) }}
>
@@ -239,7 +240,10 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
)
: glyph.glyph}
</span>
<span className="lc-fa-path">
{/* Narrow cards: wrap instead of crushing — the path's near-full-width basis keeps
line 1 to chevron + icon + path, badges/delta/time fold onto line 2. The 46px
reservation is chevron (12) + gaps (2×7) + form icon (20). */}
<span className="lc-fa-path flex-1 @max-[380px]/lc-card:basis-[calc(100%-46px)]">
{dir !== '' ? <em>{dir}</em> : null}
{openable
? (
+1 -1
View File
@@ -34,7 +34,7 @@ export function makePluginInfo(kit: ViewKit): () => ReactElement {
const nameValue: ReactNode[] = [nameText]
if (update) nameValue.push(<span key="update" className="lc-pi-update">{'↑ v' + update}</span>)
return (
<div className="lc-card">
<div className="lc-card flex-1 min-w-[min(360px,100%)]">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('plugin.title')}</span>
{/* The tagline doubles as the repo link: hover underlines it, a click opens GitHub. */}
+1 -1
View File
@@ -38,7 +38,7 @@ export function makeSliceList(kit: ViewKit): (props: {
onHoverKey?: (key: string | null) => void
}): ReactElement {
return (
<div className="lc-sl" onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}>
<div className="lc-sl flex-auto @max-[240px]/lc-card:basis-full" onMouseLeave={() => { if (props.onHoverKey !== undefined) props.onHoverKey(null) }}>
{props.rows.map(r => (
<div
key={r.key}
+5 -2
View File
@@ -171,11 +171,14 @@ export function makeStatsContext(kit: ViewKit): (props: {
</div>
)
return (
<div className="lc-card lc-col-stats">
<div className="lc-card lc-col-stats flex-1 min-w-[min(360px,100%)]">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('stats.title')}</span>
</div>
<div className="lc-stats">
{/* The count grid: auto-fit keeps every cell ≥108px (the floor where the longest
English label still fits), so cells fill the card — 3 across at the default
half-card, 6 across on a wide card, 2 on a phone-width one. */}
<div className="lc-stats grid grid-cols-[repeat(auto-fit,minmax(108px,1fr))] gap-1.5">
{cell(t('stats.turns'), props.counts.turns)}
{cell(t('stats.steps'), props.counts.steps)}
{cell(t('stats.humanInputs'), props.humanInputs ?? 0, t('stats.humanInputsTip'))}
+6 -2
View File
@@ -148,14 +148,18 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac
].filter(row => !row.dim)
}
return (
<div className="lc-card lc-col-stats lc-col-donut">
<div className="lc-card lc-col-stats lc-col-donut flex-1 min-w-[min(360px,100%)]">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('timing.title')}</span>
</div>
{rows.length === 0
? <div className="lc-empty">{t('timing.empty')}</div>
: (
<div className="lc-donut-row">
<div
className="lc-donut-row flex items-center justify-start gap-3 min-w-0 @max-[320px]/lc-card:gap-2 @max-[240px]/lc-card:flex-wrap"
>
{/* donut + legend row: the gap folds at a 320px card, below 240px the row wraps
and the ring centers over the full-width legend (keyed to the lc-card container). */}
<Donut
segments={segments}
size={96}
+4 -2
View File
@@ -58,11 +58,13 @@ export function makeStatsTokens(kit: ViewKit, Donut: (props: DonutProps) => Reac
count: p.key === 'output' ? `${fmt(p.value)} · ${t('tokens.outputNote')}` : '≈' + fmt(p.value),
}))
return (
<div className="lc-card lc-col-stats lc-col-donut">
<div className="lc-card lc-col-stats lc-col-donut flex-1 min-w-[min(360px,100%)]">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('tokens.title')}</span>
</div>
<div className="lc-donut-row">
{/* donut + legend row: the gap folds at a 320px card, below 240px the row wraps
and the ring centers over the full-width legend (all keyed to the lc-card container). */}
<div className="lc-donut-row flex items-center justify-start gap-3 min-w-0 @max-[320px]/lc-card:gap-2 @max-[240px]/lc-card:flex-wrap">
<Donut
segments={shown}
size={96}
+12 -23
View File
@@ -89,12 +89,6 @@
The row owns the 14px vertical rhythm (lc-cols gap); cards drop their own bottom margin (incl. the last in a column) so every section boundary measures exactly one 14px whichever way the flex wraps.
*/
.lc-cols { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
/* Columns fold at 360px: the cards that pair up — the main row's composition/trend
against the browser, and the events card against file activity — read cramped at a
quarter-share of a narrow pane, so the main row needs 2 x 360px + the 7px gap to stay
side by side. min() caps the floor at the row's own width: on a sub-360px pane the
column narrows instead of overflowing. */
.lc-col { flex: 1; min-width: min(360px, 100%); }
.lc-cols > .lc-card { margin-bottom: 0; }
.lc-col > .lc-card:last-child { margin-bottom: 0; }
.lc-col-browser { display: flex; flex-direction: column; }
@@ -102,24 +96,19 @@
/*
Head band: TWO rows of two EQUAL-width cards — the session's shape beside
the plugin card (context / plugin), then the two donut cards together
(token / timing). `flex-basis: 0` + equal grow halves each row exactly, and
`min-width: 0` lets the split ignore content. Each row is its own
`lc-head-row` query container, so the split folds instead of crushing the
cards (issue #48): below 720px of row width (a half under ~350px — where the
donut cards' ring+legend single-line and the context card's 3-column cell
grid both start to cramp) the pair stacks single-column. Cards under that
end of the band sit under their own lc-card reflows (stats.css: the ring
stacks over its legend, the cell grid folds), so the two-card line is the
trade this threshold buys. Engines without container queries keep the wide
two-across layout. The right Sidebar's panel renders only the donut row (it
drops the context stats / plugin info row — see contextView.tsx); the same
rules then stack the pair in the narrow pane.
(token / timing). The rows are `.lc-cols` flex-wrap rows and the cards ride
the shared column idiom (`flex-1 min-w-[min(360px,100%)]` in the
components): equal `flex: 1` halves each row exactly and the min-width
floor is the fold — below 2 x 360px + gap of row width (a half under
~360px, where the donut cards' ring+legend single-line and the count grid
both start to cramp, issue #48) the pair wraps onto full-width lines. Cards
under that end of the band sit under their own lc-card reflows (stats.css:
the ring stacks over its legend, the cell grid folds), so the two-card
line is the trade this floor buys. Engines without container queries keep
the wide two-across layout. The right Sidebar's panel renders only the
donut row (it drops the context stats / plugin info row — see
contextView.tsx); the same wrap then stacks the pair in the narrow pane.
*/
.lc-head { container: lc-head-row / inline-size; }
.lc-head > .lc-card { flex: 1 1 0; min-width: 0; }
@container lc-head-row (max-width: 720px) {
.lc-head > .lc-card { flex-basis: 100%; }
}
/* The context card's cell grid stretches to the plugin card's height, so the row reads as one level band. */
.lc-head > .lc-card:has(.lc-stats) { display: flex; flex-direction: column; }
.lc-head .lc-stats { flex: 1; align-content: stretch; }
-12
View File
@@ -18,15 +18,3 @@
.lc-event-tokens { color: var(--dsw-alias-state-success-primary); font-weight: 600; white-space: nowrap; }
.lc-event-tokens.lc-up { color: var(--dsw-alias-state-warn-primary); }
.lc-event-time { color: var(--dsw-alias-label-secondary); font-size: 12px; }
/*
Narrow cards (phone panes): the single-line row crushes the label to a
couple of characters and the no-shrink meta tail (at/tokens/time) overflows
into a horizontal scrollbar. Wrap instead: the label's near-full-width
basis keeps line 1 to icon + kind + label, and the meta tail folds onto
line 2 intact. The 92px reservation covers icon + gap + the widest kind
chip (both locales) + gap.
*/
@container lc-card (max-width: 380px) {
.lc-event { flex-wrap: wrap; }
.lc-event-label { flex-basis: calc(100% - 92px); }
}
+1 -16
View File
@@ -22,22 +22,7 @@
.lc-fa-lang { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 13px; padding: 0 2px; border-radius: 4px; font-size: 8px; font-weight: 700; line-height: 1; }
/* The chevron sits close to the icon: only half the row gap stays visible. */
.lc-fa-row .lc-br-chev { margin-right: -2px; }
.lc-fa-path { flex: 1; min-width: 0; word-break: break-all; }
/*
Narrow cards (phone panes): the nowrap row's break-all path otherwise
collapses into a one-character-wide vertical strip beside the chips. Wrap
instead: the path's near-full-width basis keeps line 1 to chevron + icon +
path (free to wrap to two text lines), and the badges/delta/time fold onto
line 2 intact. The 46px reservation is chevron (12) + gaps (2×7) + form
icon (20). Must sit AFTER the `.lc-fa-path` base rule — equal specificity,
so only source order lets the query's basis beat the `flex: 1` shorthand.
*/
@container lc-card (max-width: 380px) {
.lc-fa-row { flex-wrap: wrap; }
.lc-fa-path { flex-basis: calc(100% - 46px); }
/* The five purpose chips (labels + counts) overflow a ~300px card in English — let the segmented group flow to two lines. */
.lc-fa-ctl .lc-gran { flex-wrap: wrap; }
}
.lc-fa-path { min-width: 0; word-break: break-all; }
.lc-fa-file { cursor: pointer; }
.lc-fa-path em { font-style: normal; color: var(--dsw-alias-label-secondary); }
/* Per-purpose count badges: pill chips, one fixed tint per purpose (read = brand, write = success, search = warn). */
+8 -38
View File
@@ -4,10 +4,6 @@
* the composition overview's big number, and the cards' shared anatomy
* — section heading, donut, slice list, plugin-info rows.
*/
/* The context card's six count cells: the session's shape, the cache-hit
rate, and the cost — 3×2 by default (the cells stay wide enough for the
longest English label), folding to a single row on wide cards. */
.lc-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.lc-stat { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 6px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* The 5-cell stats grid shares a half-width head-row card with a sibling, so its cells are moderate: tighter padding plus a corner-pinned '?' (out of the label's line) keep the longest labels on one line. */
.lc-stats .lc-stat { padding: 6px 3px; }
@@ -36,11 +32,13 @@
row, and the token card's cost line (the '.lc-tip' bubble pattern). ----
*/
.lc-stat-head { font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); margin: 12px 0 8px; }
/* The ring hugs the left edge; the legend fills the rest of the card with its quantity and share columns right-aligned. The donut cards stretch the group over their full height, so the head row's extra space sits around the chart instead of pooling under it. */
.lc-donut-row { display: flex; align-items: center; justify-content: flex-start; gap: 12px; min-width: 0; }
/* The ring hugs the left edge; the legend fills the rest of the card with its quantity and share columns
right-aligned. The donut cards stretch the donut+legend row over their full height (`flex: 1` below), so
the head row's extra space sits around the chart instead of pooling under it; the row's own flex layout
and its 320px/240px folds ride utilities on the elements (statsTokens.tsx / statsTiming.tsx /
sliceList.tsx / donut.tsx). */
.lc-col-donut { display: flex; flex-direction: column; }
.lc-col-donut .lc-donut-row { flex: 1; }
.lc-donut-row .lc-sl { flex: 1 1 auto; }
.lc-donut { position: relative; flex: none; }
.lc-donut svg { display: block; }
.lc-donut-track { stroke: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); }
@@ -99,37 +97,9 @@
*/
.lc-pi-value { min-width: 0; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
.lc-pi-row-btn .lc-pi-value { font-weight: 400; }
/*
Wide head cards: on roomy panes the context card's six cells fit ONE line
(~110px each at the 700px mark). Degrades silently on engines without
container queries.
*/
@container lc-card (min-width: 700px) {
.lc-stats { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
/*
Narrow head cards: the side-by-side ring + legend reads single-line only
above ~320px of card content width. The two-tier rows already absorb the
squeeze — the name gives ground before the fixed share column — so the
compact block only tightens the ring gap. The context card's 6-cell grid
folds to 2 columns instead: three ~65px cells would ellipsize the longer
English labels (Tool Calls). Below 240px even the ring stops
fitting, so the card stacks: the ring centers over the full-width legend
rows. Cards resolve these queries against themselves (the shared `lc-card`
container in base.css) at every split of the head band — two-across or
stacked. Engines without container queries drop both blocks and keep
the wide layout. The 240px block must stay AFTER the 320px one — at that
width both match and the stack rules win by order.
*/
@container lc-card (max-width: 320px) {
.lc-donut-row { gap: 8px; }
.lc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container lc-card (max-width: 240px) {
.lc-donut-row { flex-wrap: wrap; }
.lc-donut { margin: 0 auto; }
.lc-donut-row .lc-sl { flex-basis: 100%; }
}
/* The stats grid's cell count is intrinsic (`repeat(auto-fit, minmax(108px,1fr))` on the element, see
statsContext.tsx); the donut row's 320px gap fold and 240px stack still ride
`@max-[…]/lc-card` utilities — statsTokens.tsx / statsTiming.tsx / sliceList.tsx / donut.tsx. */
/* Reduced motion (the harness's own idiom): the ring shows its final arcs immediately. */
@media (prefers-reduced-motion: reduce) {
.lc-donut-seg { animation: none; }