FoldBounds restated six Config fields, DEFAULT_BOUNDS restated them again, and
resolveBounds listed them a third time. Deriving the type from the defaults leaves
one source for the bound vocabulary, and Config now composes Partial<FoldBounds>
with Partial<PluginSettings> — which also declares defaultDeltaBase, a field the
schema already carried while the interface omitted it.
DecodeSpans and DecodeCounts were byte-identical aliases of the same record shape.
fold.ts held the persisted state shape, the retention bounds, the untrusted-usage
sanitizer with its accumulators, the surface projection, and the served-value
builders at 1234 lines. The state, the metering accumulators, and the wire builders
move into flat siblings; applyTimeline and the surface projection stay together
because the clone contract they enforce spans both.
The metering extraction also unifies BilledUsage with the shared CostBucketTotals:
one four-bucket vocabulary, so the accumulator input is spelled `uncached` like the
totals it feeds. The persisted and wire shapes are unchanged (both already carried
`uncached`), so no stateVersion bump and no cached-row invalidation.
Production callers import the owning sibling; fold.ts keeps re-exporting the
pre-split surface for the specs.
Apply the repo's comments guideline to src/client/**: delete comments that
restate JSX, Tailwind utilities or standard React behaviour, narrate history,
duplicate docs/, or describe what the next line does; keep only container-query
and containment traps, portal requirements, reference-stability rules with a
real re-render consequence, CSS<->TS constant parities, harness seat and payload
contracts. Align the page's name with the shipped UI label ("Context Insights",
not "Context Dashboard"), unify drifted phrasings, drop the last non-English
fragments, and rewrap survivors onto fewer, fuller lines within the 140-col limit.
Comment lines 5,183 -> 1,822. No code change: comment-stripped content is
identical per file; the client bundle differs only by dead Tailwind utilities
that existed because deleted comments contained those words.
Apply the repo's comments guideline to src/host/** and src/shared/**: delete
comments that restate the adjacent declaration, narrate development history,
duplicate docs/compatibility.md, or walk through a function body; keep only
critical decisions and facts the code cannot self-explain (harness and log-shape
contracts, retention invariants, untrusted-input traps, plain-JSON cache
preconditions). Rewrap the survivors onto fewer, fuller lines within the 140-col
lint limit, and drop the last non-English comment fragments.
Comment lines 4,020 -> 1,026. No code change: every file is proven identical
after comment stripping, and the built lib/index.js is byte-identical to the
previous build (modulo the build-time banner).
Every retained bar carried `animate-lc-bar-in`, so the chart's mount animated
each one — the host retains up to maxRequestSteps (1500) requests — and every
animating bar layerizes on its own. Only the columns a pane can reach are ever
seen: the chart mounts anchored to the newest bars, so the rise is now drawn by
the newest RISE_CAP (200) columns, the stagger counted from the window's edge
so those columns still cascade left to right. Bars appended to a live session
keep rising, and a log shorter than the window is unchanged.
Measured on the 218-step session, the per-bar animations cost 17ms of the worst
frame gap and 16ms of blocking on top of the strip fix. With both changes the
open measures 243 animations, a 133ms worst frame gap (was 250ms) and 98ms of
blocking (was 173ms); with every CSS animation disabled it measures the same
133ms, so what remains is plain mount cost. Three tests pin the window in
total, DNA and DNA+delta modes.
Opening the Context tab on the 218-step session started 1584 CSS animations at
once, 1324 of them the timing card's strip — one `animate-lc-stacked-in` per
band. Tracing the open showed 1582 animationstart / 1578 animationend
dispatches, 343 Layerize events (392ms of layer churn) and a 250ms worst frame
gap where the shell's own tab switch measures 67-83ms. Disabling just the
strip's per-band animations on the same build already cut that open to a 150ms
gap and 109ms of blocking (from 173ms).
The bands now ride one `.lc-tstrip-fill` wrapper carrying the single sweep, so
the strip wipes open on one composited transform over the same track. Packing,
colours, the kind hover link and the tip are untouched; the per-band ripple it
replaces was capped at eight stagger slots, so a packed strip already settled
as a sweep. A regression test pins the single wrapper and the absence of any
per-band animation class.
The backoff timer re-armed unconditionally after every failed read, so an
unreachable models.dev registry kept being polled for the rest of the page's
life even after the Insights page that subscribed had gone. Measured with
fake timers: 7 polls in the 20 minutes after the last unsubscribe, at the
capped 240s cadence, forever.
The timer callback now ends the cycle when no subscriber remains; the next
subscribe already kicks a fresh read. Measured after: 0 polls in the same
window, and the first subscribe still fills the book as before.
The per-session DetailStore map is page-lifetime and every store kept its
fetched collections forever, so the page's heap grew with each session ever
opened. Measured over 24 distinct visits with planted weak references: 9, 17
and then 25 live payloads (one per visit), ~194KB of JSON (~300KB of live
heap) each — nothing released them but a reload.
The newest DETAIL_STORES_MAX sessions now keep their payload for an instant
re-open; older idle ones are demoted (payload and ledger dropped) and read
again when opened. Measured after: a flat 8 live payloads at 8, 16 and 24
visits, the two oldest re-opens read once more, the newest re-opens read
nothing. An active store is never demoted, so the view on screen keeps its
data; rendering was byte-identical across the change.
The per-session DetailStore map is page-lifetime, so a store whose Context
tab, sidebar panel, or /context modal had gone kept trailing its own read
schedule: a broken detail route was re-POSTed every 2.4s for the rest of the
page's life with nobody reading the answer (measured on a real session: 2
extra POSTs in the 3s after unmount, unbounded).
The last unsubscribe now parks the store: the armed window is cleared and no
read is armed while nobody subscribes. The cached payload is kept, so a
re-open still renders instantly (measured: identical rendering digest and no
refetch on remount); a resume with nothing cached clears the ledger and the
old backoff, so the mount's own request(rev) reads again instead of sticking
on the retry note.
The Less→More key reused the grid's own 20px cells, so the key read as a
second row of cells competing with the grid. Its swatches now render at half
size and keep the grid's depth classes, so the key stays a caption while
never drifting from the colors it explains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the timing-stats text row from 模型输出/Answer to 模型回复/Answers to match the Answer wording already used by the stats card, and update the affected assertions.
The notice trailed the version inside the value box, whose ellipsis cuts
the line last-in-first-out, so at the card's 240px floor it was always the
part lost. Measured in the live harness with English labels, the box
overflowed by 29-44px and ~15px of a 52px chip survived, at every version
string tried. A block-level chip drops below instead, leaving the version
its own ellipsis on the first line.
The Insights page's range group gains a calendar picker: one panel that
takes BOTH ends, in either order. A backwards pick — end before start —
is sorted on the second click, so `orderedDayRange` hands every reader
downstream (the scope window, the ledger-day predicate, the chip that
names it) a pair with `from <= to`; `dayRangeWindow` orders it again at
the seam, so a hand-built pair can never filter the page to nothing.
The trigger IS the scope: one pill carrying the start and the end, fed
the LIVE window — a picked range when one owns the scope, the preset's
own window read as days otherwise, so "last 30 days" prints the thirty
days it covers and the two controls can never disagree. Only a picked
range takes the chip's brand voice and grows the way back, and the panel
keeps no second copy of the two dates: the chip above it is already
showing them, live, while the pick runs.
Underneath, the range model moves from a start-only floor to a two-sided
`RangeWindow`, which closes the top of a picked scope: the ledger-day
predicate gains the ceiling it never had, so a day beyond the range no
longer leaks into the skill card, and both picked days count whole
(midnight through the last millisecond). `days.ts` gains the calendar
arithmetic this needs — a day's epoch bounds, its weekday, month
stepping, and one month laid out as Sunday-first weeks — with the
round-trip key validation its siblings already used.
The panel portals to `<body>` and seats itself off its trigger's rect,
the containment escape the page's hover tips already take: the page is
the `lc-ov` query container, so an in-tree popover would be both
captured and clipped by the one scroller. It measures itself before
paint, clamps inside the viewport, flips above the trigger when it would
fall off the bottom, and retracts on any scroll, resize, outside press,
or Escape.
Closes#128
The Insights page's first range option read "Last 24 hours" while the
ledger it scopes is day-grained, so the rolling window and the day floor
underneath it disagreed: past midnight the 24h window still admitted
yesterday's ledger day whole, and before 1am it admitted only part of it.
"Today" is the local calendar day from midnight — the window the day-
granular heatmap beside it already draws.
`rangeStartOf` opens the window on the local midnight by date-field
arithmetic (the harness's own `setHours(0, 0, 0, 0)` idiom, as in
`startOfLocalDay`), which stays exact across DST-short and DST-long days
where an epoch-ms subtract drifts into the neighbouring day. The harness
helper itself is only reachable through a deep
`@deepseek-ai/dsh-client-ui-chat/src/*` path — a package this plugin does
not depend on — so the idiom is mirrored at its one call site instead of
taking the dependency.
Both locales carry the shorter 今天 / Today; the deprecated
`ov.range.24h` key is gone rather than aliased.
The specs that anchored time on raw epoch offsets silently assumed UTC
(`11 * 86_400_000`), and the panel fixture at `NOW - 1h` would have
failed whenever the suite runs in the first hour after midnight; every
boundary is now anchored on a local `Date`, so the day floor is proved
identically in every timezone.
A case-insensitive substring filter rides the skill card's totals row,
narrowing the rows, the summary counts, and the overflow line; the
input stays mounted on an empty match, next to a zeroed summary and a
dedicated no-match note. The bars keep the scope's heaviest tally as
their denominator so they hold still while the filter narrows.
A pin the filter hides now falls back to the detached detail block with
its head row — previously the head rendered only for pins outside the
scope, leaving a filter-hidden (or render-bound-hidden) pin unnamed.
The Context Insights page's second insight card lists every skill loaded
within the page's scope (the range floor, or the heatmap's pinned day):
a color-dotted row per name with its load tally, a proportional share
bar scaled to the heaviest row, and the last load's relative time,
ordered by the title's loads / last-used toggle. A row click pins the
session list to that skill's loaders; the pinned row wraps with its
detail into one ringed unit — the description first, then a fact grid
(origin, usage, exact last load, the full path as a click-to-copy
value). A structured hover card answers the same in a floating register.
The catalog read joins description, origin, and path onto the tallies by
name and degrades to bare tallies when the route cannot serve.
The activity ledger's day entry gains a per-name skill table, booked from
the two durable load gestures the timeline fold tags sub: 'skill' — a
/name invocation's skill-invocation source, and a skill-tool result's
<skill_content> wrapper trusted only when its callId pairs with a pending
skill-tool call (a read of source text quoting the wrapper books
nothing). Tables are bounded per day and the pairing list per session;
the state shape change bumps the unit's stateVersion (3 → 4) so cached
rows refold on upgrade.
The plugin-owned /api/dsh-context/skills route serves what a loaded skill
IS — registry description, origin bucket, on-disk path — resolving the
session's cwd and preset scope the way the harness's own skill catalog
does (web compositions mount discovery on presets, so the global registry
alone lists nothing), every face re-proved per request.
The bare "Settings" / "设置" label read as the shell's own settings next
to the plugin's; both the page-heading chip and the plugin-info row now
name the plugin's own page.
The Context Insights page's preferences entry sat as a full-width row
under the activity heatmap, at the bottom of the insight column and easy
to miss. It now rides the page heading as a quiet chip directly right of
the title, cut like the balance capsule beside it, with the long
"open plugin settings" hint carried on the hover title.
The shell's own panel rows draw their glyphs with the icon set's one-unit
stroke in a 16-unit box — 6.25% of the icon edge — while the sheet's bars
measure 98-101 units of its 1024 artboard (9.6-9.9%), so the row read half
again as heavy as the Plugins and Automation tasks rows beside it; at that
weight antialiasing also filled the strokes enough to look darker than
neighbours that ride the very same `--dsw-alias-label-primary` currentColor
in both themes.
The mono seat now derives the thin glyph from the SAME single artwork
instead of shipping a second SVG: the sheet masks down to the harness
weight by 17 units a side (98 -> 64) as the intersection of the drawing
with its own copies shifted along each axis — a white flood minus one
black shift per direction, every shift painted through the drawing's
complement mask, which stays the only copy of the artwork on the page.
Compositing, not a filter: `feMorphology` rounds its radius to whole
device pixels, so it measured as a no-op at 1x and an over-thin at 2x.
The polychrome identity seats (tab chip, /context entry, guide capsule)
are untouched.
Verified: live strokes measure 17px against the clock's 17px and the
pinwheel's 19px at 256px; screenshots in light and dark; lint:fix + test
(100% per-file coverage) + build.
The session node's second-row cells (Skill Loads / Answers) become
keyboard-accessible jumps (role=button + Enter/Space, the team card's
idiom): clicking scrolls the Context Browser card into view and opens
the matching section on the live surface — skill loads expand Skill
Injections with the row lens cleared; answers expand Assistant Messages
with the Answer kind chip picked, so tool-call-only steps filter out.
Hover/focus carry the team's affordance (soft wash, brightened label,
brand ring).
The browser gains a one-shot `catFocus` prop mirroring `nodeFocus`:
select live, open the named category, pick the optional kind chip,
clear the lens, report handled so the same cell can fire again. The
cells stay inert where no browser mounts (no handler, no affordance);
tooltips name the destination in both locales.
Verified end to end: lint:fix + test (100% per-file coverage) + build.
The Current Session node now reads two figure rows: turns/steps above
skill loads/answers, each row's cells splitting the width evenly and
centering (one column pair whatever a label's length).
- Skill Loads (counts.skills): the distinct skill names among the
retained skill-tagged inject events — `/name` invocations and
`skill`-tool loads; the `<available_skills>` catalog digest rides an
untagged inject event and never counts. Derived at view time, so no
stateVersion bump: cached rows recompute the exact tally on serve.
- Answers (state.answers, head scalar): one per assistant message
carrying a non-blank text block — tool-call-only steps are work, not
replies. A running total over the complete log (the humanInputs
precedent), so the timeline stateVersion bumps 26 → 27 and the v20
warm-up rebuilds idle sessions' rows on the first dashboard open.
- The subagent count leaves the session node (the Agent Team ledger
row still carries it); docs/compatibility.md records the bump.
Verified end to end: lint:fix + test (100% per-file coverage) + build.
The overlay rode the brand token (near-black in light, near-white in
dark), making the secondary curve the loudest element on the chart and
colliding with the bar hover outline and the DNA highlight slices that
share the token. It now paints in label-tertiary gray — the right-hand
axis labels' own family — at a thinner 1.25px, over a 3px card-bg
underlay that knocks the bar colors out behind the line so it lifts
clear instead of fighting them.
Rows the log holds no request for yet — the newest assistant reply, the
tool results still waiting to be sent — now wear a PENDING step stamp
instead of none. The prediction follows the harness's step loop
(agent.ts): a newest user message opens a new turn, a text-only final
reply means the turn ended, and pending tool results or an in-flight
injection continue the same turn, so the next step is lastStep + 1.
dsh names that step at step/start before the request exists — the
session log's own tail confirms the prediction (step/start Turn 1 Step
42 over exactly the rows that lacked a stamp).
The estimate reads like the confirmed stamp but with a quiet dotted
underline and a 'not yet sent' tooltip in both locales; the confirmed
stamp replaces it the moment the request logs. Past-step views never
speculate: they reconstruct only what their request already carried.
Every context-browser row (system prompt, tool schemas, user/assistant/
tool/skill/inject messages) now names the turn/step of the request that
first carried the item. The stamp derives client-side from the first
request logged after the item's seq — assemble's inclusion rule is
n.seq < R.seq — the same next-request attachment the events card uses,
so no wire change and graceful degradation on older rows.
The stamp merges with the row clock into ONE muted meta run
('T1 S41 · 22:58:10'), so the meta tail reads as a single unit instead
of three separate clusters; the token figure stays separate. Hover
explains the stamp in both locales. The live tail (items no request
carries yet) and turn-less introducing requests (older hosts) carry no
stamp rather than a mislabeled one.
- compareVersions had zero production callers (the gate rides
meetsBaseline alone) and is not re-exported through the package entry;
its spec block duplicated what meetsBaseline's cases already pin
through compareParsed (a major-gap case joins meetsBaseline's block to
keep every arm covered). isNewerVersion keeps its own numeric-only
compare: ignoring prereleases there is the upgrade nudge's deliberate
semantics, and adopting the channel-aware order would change
user-visible behavior.
- pad2 hand-rolled padStart(2, '0') in a function that already used
padStart for the year.
- StoredHeaderTool/StoredHeaderRecord and DecodeCounts/DecodeSpans lost
export keywords nothing inside or outside the repo imported.
- The ConnectionHostFace interface and its bind-at-extraction guard were
copy-pasted across detail/backfill/balance; one host/connection.ts
module now owns the face and fetchRouteRegistrar.
- accumulateCost (timeline fold) and pricedDayOf (activity ledger) were
two hand-written copies of the same clone-along-path cost accumulation,
period rule included; both now call the exported addBilledUsage.
- The cost zod schemas were defined field-for-field in activity.ts and
timeline.ts; activity imports costUsageSchema from the wire owner.
Rejected from the audit after inspection: sharing firstText/firstFullText
(the walks differ in the acceptance predicate — trim-nonempty vs
nonempty — and unifying would change skill-name matching on a
whitespace-only first block of untrusted log data), and unifying the two
host asRecord guards (a one-line stdlib idiom; cross-module imports for
five lines is churn, not simplification).
makeDetailFetcher's read switched to a plain fetch in a82e6a1, leaving a
lone void-statement as the ctx parameter's only use — yet ctx kept being
threaded through detailStoreOf, useTimelineSource, and makeAgentHeads.
All four drop the parameter; the spec's ctxWithCall factory (which only
ever stubbed global fetch and returned a ctx nobody read) collapses to
a plain stubDetailFetch.
- timelineOf's slow path and detailOf carried a byte-identical
requests/events/nodes/droppedNodes/archive/floors/fileOps/spans block;
both now spread one collectionsOf(data) at the same boundary rigor.
- sessionsSnapshotOf/workspacesSnapshotOf were the same guarded-hook read
differing only in the prop key; both delegate to standardSnapshotOf.
Rejected from the audit after inspection: merging billedTokensOf into
usageTotalsOf (their input contracts differ — the former's per-level
re-proving against hostile wire data is pinned by cost.spec and the
compatibility contract), and a shared own() disposer helper (the three
sites have two different shapes; the guard is a one-token idiom).
- BlocksBody's richable prop was declared and passed at all four call
sites but never read.
- RawSection was a single-use Section wrapper; inlined at its one call.
- makeSliceList/makeDonut took a kit they immediately voided; the
factories and all call sites (specs included) drop the parameter.
- seqOfMessageId/flowAnchor lost export keywords nothing imported.
- EVENT_ICONS shed the inject/model entries the icon short-circuit
renders unreachable.
- a.lc-stat(:hover): orphaned since the anchor cost cell was rebuilt as
the Agent Team ledger (3dc892c); every lc-stat is now a div.
- .lc-rich-seg*: byte-identical re-declaration of the shared .lc-gran*
segmented control; RichSwitch now wears the lc-gran tokens, and the
browser spec scopes its queries to the detail body accordingly.
- lc-col-stats: a class token no rule, query, or script ever read.
The ported AttachmentLightbox predates the discovery that the shell seeds
ALL of ui-primitives into the browser module table: ImageLightbox is
exported on both supported lines (0.1.7-rc.2, 0.2.0-rc.2) with the exact
props the call site already builds. Deleting the port also drops the
lc-att-lightbox sheet; the spec keeps only the plugin-owned open/close
wiring (dialog behavior is covered upstream), and upstream adds Escape
stopPropagation plus a Tab trap.
The supported set is now the two V4-generation lines (0.1.7-rc.2+ and
0.2.0-rc.2+): the manifest's dshReleases, the peer floors (>=0.1.7-rc.2,
cordis ^4.0.4 — both lines vendor 4.0.4), the devDependency pins, the
baseline matrix, the release workflow's tag fetch, and the compatibility
docs.
With every supported line on session format V4, the V3-only paths leave:
the fold reads the tool result's first-class message alone (source.callId
pairing, message.isError — the admission validation refuses any row whose
data.error outlives it); the settings.register host face module is gone
(the Config-form entry schema serves the preferences); the client drops
the settingsScope/settings.plugin.item card seat, the retired sessions.open
jump fallback, the legacy icon spellings, and the settings-dialog leg of
the settings jump; the history page's tool/result mapper reads the V4
message (its V3 wrapper reads were dropping result content on supported
lines); the cache probe rides the 2-arg cachedSnapshot face. The baseline
gate stays: a below-floor harness composes the fallback units and the
upgrade prompt, and an undetectable version still fails open.
Verified: pnpm run lint:fix && pnpm run test && pnpm run build green (1918
tests, 100% per-file coverage); the compat matrix passes against the real
dsh sources at both baseline tags; live on dsh 0.2.0-rc.2 the plugin
composes, the Plugins-page preference card renders and round-trips a
write, the insights warm-up fills the heatmap, and the session jump and
Context tab work end to end.
The mask was inset on the left sidebar alone, so with the right
Sidebar open the dialog centered over the main column plus the right
panel and its right half disappeared underneath. measureDock now
parses the frame template's trailing px track too, clamped to the
space the centre track's declared minimum leaves: on the minmax
generations the trailing bound is only the right panel's bid, not the
solved width — the frame lets the grid squeeze the right track before
the centre drops below its minimum (measured live: template
minmax(0px, 495px), solved track 420px at a 1100px viewport), so an
unclamped parse over-insets the mask in exactly that squeeze regime.
The V3 line's fixed trailing track and every degrade-to-full-mask
branch are unchanged.
The card width now resolves against the backdrop (100% instead of
100vw), so when both sidebars bound the mask the card clamps inside
the main column instead of poking underneath either edge. Verified
live at 1280/1100/900/390 px with the right panel open, closed, and
across its track removal at phone width.
Overflowing style alone is not enough: on layouts where an outer
container scrolls (the web app's conversation scroller), the Context
tab's own .lc-root reports overflow-y auto yet grows with its content
and never moves — taking it short-circuited the walk into a silent
no-op, so neither the team card's jump to the Agent network card nor
the chat→context jump's composition reveal ever scrolled. The walk now
requires scrollHeight > clientHeight, so the first ancestor that can
actually move takes the rect delta (verified live: click lands the
foot card at the scrollport top).
The Agent Team ledger becomes the Agent Network card — the same name as
the big card at the tab's foot, sharing the agents.title key so the two
can never drift. Clicking the card anywhere outside the price link (or
pressing Enter/Space on it) scrolls the foot card into view through the
shared revealInScrollParent helper; the price link and the '?' hover tips
keep their own behavior, and the jump is a quiet no-op when the harness
hides the foot card (no sessions service).
The dashboard's row IS the team now: each listed session folds its WHOLE
subagent subtree into itself (all levels, mirroring the Context tab's cost
fold) — subagent-origin sessions never list, so nothing counts twice. The
session card's billed tokens and priced cost (now labeled 团队用量 /
团队费用), the KPI band's volume and spend, the last-7-days usage chart,
and the heatmap all read the family scope: the subtree's cost buckets
merge per row, the daily ledgers merge day by day, and the composition
split proportions each member's OWN composition instead of the root's.
The flow's fourth node becomes a three-row team ledger: the family's
billed-token and estimated-cost totals in the header, then one row per
scope — the current agent and the subagent subtree with its session count
— each pairing its billed tokens with the estimated cost (billedTokensOf:
the token face of the same buckets the estimate prices). The subagent
scope is the WHOLE descendant tree, which the copy now spells out; the
rows render unconditionally (a scope with nothing billed reads 0 / —),
and both tooltips gloss the token figure.
The six-cell grid becomes a left-to-right flow: two source cards (Input /
Output — human inputs plus the whole session's file reads/writes, with
searches and image reads when they happen; Context Events broken down by
kind) feed the highlighted Current Session node (turns / steps / subagents
— the subtree's session count, folded from the session list alongside the
cost usage), which drains into the two effect cards (Tool Calls with the
three most-called tools and an overflow pill; Cost with the family figure
and, when a subagent subtree reports, the this-agent/subagents split).
Measured bezier ribbons — a translucent band plus a traveling dash per
element, not a conservative Sankey — connect the nodes and flip to a
top-to-bottom stack when the card narrows past the container-query fold.
A ctrl+f-style lens scoped to one section, toggled from a magnifier
button beside the copy control: a debounced query (150ms, so rapid
typing or deleting rescans once instead of per keystroke), matches
wrapped as DOM-level <mark> over the rendered body (same lens in raw
and markdown modes, capped at 500 rendered marks with a '+' counter),
Enter/Shift+Enter or the chevrons cycling the active match into view,
Escape folding the bar. The section body remounts on a text change so
React never reconciles over the injected marks; the restore rebuilds
the original text nodes, keeping the copyable source byte-identical.
The card's reading was the ring's integer (`Math.round`), so a 101.6k /
1.0M session printed 10% while the kernel's own occupancy read 10.2%.
The percent now comes from one shared `occupancyPercent` helper that
mirrors dsh's `contextOccupancy` — the composer ring's derivation
(`min(100, round(used / window * 100))`, `used` = `contextPressure`'s
`projectedTokens ?? pressureTokens`) — on a 0.1 grid, and keeps the
kernel's numerator precedence ahead of the plugin's derived anchor. The
kernel symbol is unreachable from a client plugin (no package export, a
self-registering browser bundle), so the formula is mirrored by
necessity, as docs/compatibility.md now records.
The Agent Network rings read the same helper (whole tenths stay compact
there), and the number row wraps so the wider reading folds to its own
line instead of spilling on the narrowest panes.
The token card's corner already carries the session's cache-hit share
(ef72a7b), so the Context Stats board duplicated it. The grid is now six
cells: turns, steps, human inputs, live tool calls, and the two cost cells.
The removal takes its dead plumbing with it — the card drops its `usage`
prop, and `cacheHitPercent` loses the `decimals` override that existed only
for this cell's one-decimal display. The README rows and the card's
screenshot move the figure to the token card.
The token card's title row gains the session's cache-hit share in its
right corner — the harness chat line's own reads-over-billed formula,
reusing the stats board's label and tooltip — hidden until something
was billed. The timing card's throughput chip drops its pill chrome and
bold weight, now reading 输出速度 N tok/s (zh) / TPS N tok/s (en) with
the TPS expansion on the tooltip; both corners share a new plain-text
.lc-card-rate style and keep their native-title tooltips.
The day strip spanned the card's full width while the columns sit inset
between the two 48px tick rails, so every label fanned out from its bar
group; the topmost tick also rode the title's 4px margin. The plot is now
a 3x2 grid — rail | chart | rail, with the strip as row 2 of the chart's
own column, so labels center under their columns by construction — and
the title falls back to the shared 10px margin. Under a 400px container
the seven MM-DD labels thin to every other one instead of crushing.
The donut cards' slice legend auto-fills at a 150px floor (from 160px)
with an 8px column gap (from 20px) and a 30px share column (from 34px),
so two columns start at a 308px legend — the Insights page's ~440px
stats cards ride this — and wider cards take more; the floor is
`min(150px, 100%)` so a narrower legend shrinks the track instead of
clipping the share column (the 390px fold previously cut 11px off the
percentages). Narrow panes keep their single column; the Context tab
keeps the same column count at every width it already wrapped at.