agentgui 1.0.952 → 1.0.954

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  export const meta = {
2
2
  name: 'gui-audit',
3
3
  description: 'Sweep every agentgui GUI surface for jank, verify findings adversarially, and emit kit-update tasks',
4
- whenToUse: 'Run a maximum-effort GUI audit of agentgui: review chat, history, settings, a11y, security, performance, and glyph hygiene across the client (site/app) and the design kit (c:\\dev\\anentrypoint-design). Fans out one reviewer per surface, verifies each finding with an independent skeptic, then synthesizes a prioritized punch-list. GUI fixes land in the design kit.',
4
+ whenToUse: 'Run a maximum-effort GUI audit of agentgui: review chat, history, settings, a11y, security, performance, glyph hygiene, responsive, consistency, and CSS-override-elimination across the client (site/app) and the design kit (c:\\dev\\anentrypoint-design). Fans out one reviewer per surface, adversarially verifies each finding (with a kept-typography guard so the middot/ellipsis/dash are never wrongly flagged), then synthesizes a prioritized kit-vs-app punch-list. GUI fixes land in the design kit; index.html !important overrides are converted to kit defaults.',
5
5
  phases: [
6
6
  { title: 'Review', detail: 'one reviewer agent per GUI surface/aspect' },
7
7
  { title: 'Verify', detail: 'independent skeptic confirms each finding is real' },
@@ -18,7 +18,8 @@ const SURFACES = [
18
18
  { key: 'a11y', prompt: 'Audit agentgui ACCESSIBILITY across site/app and the kit. Check: skip-link target reachability/focus, tab order on each tab, agent/model select labels, EventList row role=button keyboard activation + expand announcement, aria-live over/under-announcing, color contrast on the dark theme (WCAG AA 4.5:1 text / 3:1 UI), focus-visible rings, reduced-motion. Report each issue with file:line.' },
19
19
  { key: 'security', prompt: 'Audit agentgui GUI-reachable SECURITY. Read lib/http-handler.js. Check: Access-Control-Allow-Origin:* with Authorization + ?token= cross-origin/leak risk; /api/image arbitrary-file-read (the .. check runs after path.normalize); markdown XSS (kit marked+dompurify) — does DOMPurify actually neutralize script/javascript: URLs in agent output. Report each issue with file:line, severity, and the concrete exploit path.' },
20
20
  { key: 'perf', prompt: 'Audit agentgui GUI PERFORMANCE. Check: per-token render+scroll thrash on the chat stream (should be rAF-coalesced via scheduleStreamRender), history EventList rebuild cost on burst SSE loads (last-300 map per rAF), webjsx keying stability, markdown/Prism cache warmup, resize/relTime tick cost. Report each hotspot with file:line and a concrete fix.' },
21
- { key: 'glyph', prompt: 'Sweep EVERY GUI source file (site/app/**, c:\\dev\\anentrypoint-design/src/**, *.css) for decorative tell-tale glyphs: arrows, box-drawing, bullets, status dots, checks, emoji, any non-ASCII decorative symbol. KEEP the typographic set (middot, ellipsis, em/en-dash in prose), SVG line-icons via Icon(), CSS-drawn discs, and intentional ascii-art (AICAT_FACE). Report every decorative glyph found with file:line and its ASCII replacement.' },
21
+ { key: 'glyph', prompt: 'Sweep EVERY GUI source file (site/app/**, c:\\dev\\anentrypoint-design/src/**, *.css) for decorative tell-tale glyphs: arrows, box-drawing, bullets, status dots, checks, emoji, any non-ASCII decorative symbol. CRITICAL KEPT-SET GUARD: the middot separator, the ellipsis, and em/en-dashes in prose are ESTABLISHED PRODUCT DESIGN per AGENTS.md - they are NOT decorative tells and you MUST NOT flag or convert them. Also keep SVG line-icons via Icon(), CSS-drawn discs (.status-dot-disc), and intentional ascii-art (AICAT_FACE). Report ONLY genuine decorative tells (arrows/box-drawing/bullets/checks/status-dot CHARS/emoji) with file:line and the ASCII replacement.' },
22
+ { key: 'overrides', prompt: 'Audit site/app/index.html\'s inline <style> for CSS rules that exist ONLY to fight a design-kit (anentrypoint-design) default - especially !important rules and fragile [class*=...] substring selectors. Each such override is a symptom: the kit ships a poor default. For each override report the rule, the kit default it fights, and the concrete KIT change (a component prop or a better default) that would let agentgui DROP the override entirely. These are the highest-value kit fixes. Read c:\\dev\\anentrypoint-design/src/components/{shell.js,content.js,chat.js} and app-shell.css to ground the proposed kit change. Report fixLocation=kit for the kit change and note the app override to remove.' },
22
23
  { key: 'responsive', prompt: 'Audit agentgui RESPONSIVE behavior from site/app/index.html media queries + the kit AppShell. Check: chat controls cluster wrap at 360/640/900px, cwd path overflow, history sidebar drawer (open/close/scrim/select-closes-it), touch-target sizes, settings two-column collapse. Report each issue with the breakpoint and file:line.' },
23
24
  { key: 'consistency', prompt: 'Audit agentgui PREDICTABILITY/CONSISTENCY. Check that the same state reads the same way everywhere: connection/activity status vocabulary across crumb dot, chat status, AgentControls status, settings health, agentsPanel; ellipsis convention (… vs ...); rail tone semantics (green=ok/selected, purple=subagent, flame=error); button labels. Report each divergence with file:line.' },
24
25
  ];
@@ -66,7 +67,7 @@ const reviewed = await pipeline(
66
67
  (review, surface) =>
67
68
  parallel((review.findings || []).map((f) => () =>
68
69
  agent(
69
- 'Adversarially verify this GUI audit finding. Default to isReal=false if you cannot confirm it from the actual source. Finding: "' + f.title + '" at ' + f.where + ' — ' + f.why,
70
+ 'Adversarially verify this GUI audit finding. Default to isReal=false if you cannot confirm it from the actual source. KEPT-SET GUARD: if the finding proposes converting/removing the middot separator, the ellipsis, or an em/en-dash in prose, it is WRONG by policy (these are kept product typography) - return isReal=false with reason "kept typographic set, not a decorative tell". Finding: "' + f.title + '" at ' + f.where + ' — ' + f.why,
70
71
  { label: 'verify:' + surface.key, phase: 'Verify', schema: VERDICT_SCHEMA }
71
72
  ).then((v) => ({ ...f, surface: surface.key, verdict: v }))
72
73
  ))
package/AGENTS.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## GUI-predictability sweep closure (2026-06-05) — third maximum-effort run
4
+
5
+ Fan-out reviewers (chat/history/settings/shell + perf/security/glyph/override) -> adversarial verify -> 19 PRD rows fixed+pushed: agentgui `fe94ade`, kit `anentrypoint-design` `236d6ef` (publish.yml -> npm/unpkg). The headline theme was **override-elimination**: index.html's `!important` overrides were converted to kit defaults (Topbar active-tab = tinted+underline; `PageHeader` `compact` prop -> `.ds-section-compact` margin-top:0; `Row` `cursor:pointer` for `role=button`/`a.row`; kit `Chat` dropped the decorative head `.dot`). Also: kit `ToolCallNode` `(no output)` placeholder for done+empty tools; kit `safeUrl()` rejects `javascript:`/`data:`/`file:` in `renderInline`/image/link parts (inline renderer bypasses DOMPurify); streaming chat text uses the `.chat-md` container to avoid settle reflow. App reconciliation/state fixes (distinct backend-status keys, per-sid running-dot key, stale-selectedSid clear, per-session live counter, toolLabel scalar fallback, expand/collapse-**shown** relabel, projectFilter cleared on search). `.claude/workflows/gui-audit.js` gained an `overrides` surface + a **KEPT-SET glyph guard** in both the reviewer and the adversarial verifier (the middot/ellipsis/em-dash are kept product typography — never flag them). Detail + the kit dist `git add -f` / rebase-rebuild caveat in rs-learn (recall "agentgui GUI-predictability sweep"). **index.html loads the kit from LOCAL `./vendor/anentrypoint-design/247420.{js,css}`** — the vendored copy IS what ships; update flow is edit kit -> `node scripts/build.mjs` -> copy dist into vendor -> browser-witness -> push kit.
6
+
3
7
  ## GUI-audit closure (2026-06-04 fan-out) — second maximum-effort sweep
4
8
 
5
9
  `.claude/workflows/gui-audit.js` ran a 64-agent fan-out (9 per-surface reviewers -> adversarial verify -> synthesize) -> 44 confirmed findings (`AUDIT-PUNCHLIST.md`, 38 deduped), all fixed across server/app/kit and pushed (agentgui `eb1eab3`/`951404e`, kit `anentrypoint-design` `bb776aa` -> CI publishes npm/unpkg). Per-finding detail + key learnings (ccsniff SSE `{sid,payload}` unwrap, CSP must allow Google Fonts, DS `Select` promotes `title`->`aria-label`, index.html now loads the kit from local `./vendor/`, kit publish flow) in rs-learn (recall "agentgui GUI-audit closure").
@@ -173,6 +177,6 @@ GUI source keeps typographic product characters — the middot separator `·`, t
173
177
 
174
178
  **`anentrypoint-design`'s `SearchInput` (internal `De`) sets `aria-label = label || placeholder`; it ignores any `aria-label` prop you pass.** To give the search box a real accessible name, pass `label:` (and a matching `placeholder:` for the visible hint). Passing only `aria-label` leaves AT announcing the placeholder. A post-render `setAttribute` race-loses against the DS re-render, so the prop is the only durable fix.
175
179
 
176
- ## DS load moved to unpkg @latestoffline regression, re-vendor follow-up (2026-06-04)
180
+ ## DS load = LOCAL vendor (resolvedthe earlier unpkg note is stale)
177
181
 
178
- **`index.html` currently loads the DS kit (`247420.css` + `247420.js`) from `https://unpkg.com/anentrypoint-design@latest` at runtime, NOT the local `vendor/` copy.** Commit `947086e` made this deliberate trade ("always-latest" over offline) because the GUI's chat surface now renders via the kit's **load-bearing `AgentChat` component**, which exists in `@latest` (0.0.186) but NOT in the older locally-vendored bundle (`vendor/anentrypoint-design/247420.js`). Consequences: the GUI requires network, the shipped UI shifts when the upstream package publishes, and the local vendoring (gzip-Ha CSS blob, font/CDN-const localization) is bypassed. The separate jsdelivr imports inside the vendored bundle (`si`=marked@15, `ri`=dompurify@3, `_s`=prismjs@1.30.0/components) are therefore moot while on unpkg. **Follow-up to restore offline + predictability:** re-vendor 0.0.186+ locally (it has AgentChat), re-apply the offline localization (recall "DS bundle gzip Ha vendoring") AND rewire its `si`/`ri`/`_s` markdown-stack consts to the local `vendor/cdn/{marked.js,dompurify.js,prismjs/components/}` copies, then point `index.html` back at `./vendor/`. Witnessed live (browser `page.on('request')`): on `@latest` the page fetches `unpkg.com/anentrypoint-design@latest/dist/247420.{css,js}` at load.
182
+ **`index.html` loads the DS kit from the local `./vendor/anentrypoint-design/247420.{js,css}`, NOT unpkg.** The prior "moved to unpkg @latest" note described a transient state that was reverted: the kit was re-vendored (with `AgentChat`) in `eb1eab3` and witnessed loading from `./vendor/` again (2026-06-04 and 2026-06-05 runs). The vendored copy IS the shipped UI predictable, no upstream-publish drift. Update flow: edit the kit at `c:\dev\anentrypoint-design`, `node scripts/build.mjs`, copy `dist/247420.{js,css}` into `site/app/vendor/anentrypoint-design/`, browser-witness, then push the kit so unpkg stays in sync for other consumers. (The markdown stack marked/dompurify/prismjs still fetches from jsdelivr on first chat render.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.952",
3
+ "version": "1.0.954",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -175,30 +175,9 @@
175
175
  outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px;
176
176
  }
177
177
 
178
- /* Topbar nav: the DS renders the active tab as a large filled green pill that
179
- sits taller than the inactive text links and reads as misaligned. Make all
180
- tabs consistent - equal padding, the active one a subtle tinted underline-pill
181
- rather than an oversized oval. */
182
- /* Prefix with .ds-247420 (the <html> class) to match the DS selector's
183
- specificity; source order then lets these win. */
184
- .ds-247420 .app-topbar nav { display: flex; align-items: center; gap: .15em; }
185
- .ds-247420 .app-topbar nav a {
186
- padding: .35em .75em; border-radius: 8px; line-height: 1.4;
187
- color: var(--fg-2, color-mix(in srgb, var(--fg, var(--agentgui-fg)) 75%, transparent));
188
- text-decoration: none; background: transparent;
189
- transition: background-color .15s ease, color .15s ease;
190
- }
191
- .ds-247420 .app-topbar nav a:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent); color: var(--fg, var(--agentgui-fg)); }
192
- /* installStyles() injects the DS CSS into a runtime <style> appended after
193
- this block, so equal-specificity rules lose on source order; !important is
194
- the targeted override for the few props we reshape on the active tab. */
195
- .ds-247420 .app-topbar nav a.active {
196
- background: color-mix(in srgb, var(--accent, var(--agentgui-accent)) 16%, transparent) !important;
197
- color: var(--accent, var(--agentgui-accent)) !important;
198
- box-shadow: inset 0 -2px 0 0 var(--accent, var(--agentgui-accent)) !important;
199
- border-radius: 8px !important;
200
- font-weight: 600;
201
- }
178
+ /* (Topbar active-tab styling - the subtle tinted underline-pill instead of an
179
+ oversized filled oval - now ships as the kit default in app-shell.css, so
180
+ agentgui no longer overrides it here.) */
202
181
 
203
182
  /* Compact working-directory bar (replaces the full-width tall banner box). */
204
183
  .cwd-bar {
@@ -237,23 +216,19 @@
237
216
  @media (min-width: 900px) {
238
217
  .settings-grid { grid-template-columns: minmax(0, 420px) minmax(0, 1fr); }
239
218
  }
240
- /* The DS PageHeader carries large vertical margins that leave a big empty
241
- band above the heading and between the lede and the panels. Neutralize
242
- them so settings/history read as normal top-aligned scrolling pages. */
243
- .agentgui-main [class*="page-header"],
244
- .agentgui-main .ds-page-header { margin-top: 0 !important; margin-bottom: var(--space-4, 16px) !important; }
245
- .agentgui-main > :first-child { margin-top: 0 !important; }
219
+ /* (PageHeader top/bottom margin is now handled by the kit's `compact` prop -
220
+ agentgui passes compact:true on its leading PageHeaders, so the fragile
221
+ [class*=page-header] !important overrides are gone.) */
246
222
  .agentgui-main-settings .settings-grid { margin-top: 0; }
247
223
 
248
224
  /* The DS Chat head computes its own zero-padded count ("00 msgs") and ignores
249
225
  our sub prop; it reads as a bug. Hide the DS sub - streaming state shows via
250
- the title and the busy banner. Also hide the DS's empty decorative head dot. */
226
+ the title and the busy banner. (The decorative head dot was removed from the
227
+ kit's Chat component, so it no longer needs hiding here.) */
251
228
  .chat-head .sub { display: none; }
252
- .chat-head .dot { display: none; }
253
229
 
254
- /* EventList rows are role=button (click to expand) but the DS doesn't give
255
- them a pointer cursor, so the affordance is invisible. */
256
- .ds-event-list .row[role="button"] { cursor: pointer; }
230
+ /* (Row role=button cursor:pointer now ships as a kit default in app-shell.css.)
231
+ A slightly stronger hover tint for event rows specifically remains local. */
257
232
  .ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 5%, transparent); }
258
233
  /* Flash the row a search result landed on, so the match is obvious. */
259
234
  .ds-event-list .row.event-flash { animation: agentgui-event-flash 2s ease-out; }
@@ -437,6 +437,13 @@ function toolLabel(inp) {
437
437
  if (inp && typeof inp === 'object') {
438
438
  const a = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
439
439
  if (a) return String(a).slice(0, 160);
440
+ // No salient arg: surface the first scalar property so the card still carries
441
+ // a hint of what it does, rather than an empty, affordance-less label.
442
+ for (const [k, v] of Object.entries(inp)) {
443
+ if (v != null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
444
+ return (k + ': ' + String(v)).slice(0, 160);
445
+ }
446
+ }
440
447
  }
441
448
  return '';
442
449
  }
@@ -748,6 +755,7 @@ function historyMain() {
748
755
  return [
749
756
  reconnectAlert(),
750
757
  PageHeader({
758
+ compact: true,
751
759
  title: 'history',
752
760
  lede: 'pick a session from the sidebar - events stream live from ccsniff /v1/history.',
753
761
  }),
@@ -767,6 +775,7 @@ function historyMain() {
767
775
  : state.selectedSid;
768
776
 
769
777
  const head = PageHeader({
778
+ compact: true,
770
779
  title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
771
780
  lede,
772
781
  });
@@ -803,7 +812,7 @@ function historyMain() {
803
812
  if (allExpanded) { shownKeys.forEach(k => state.expandedEvents.delete(k)); }
804
813
  else { shownKeys.forEach(k => state.expandedEvents.add(k)); }
805
814
  render();
806
- }, children: allExpanded ? 'collapse all' : 'expand all' }),
815
+ }, children: allExpanded ? 'collapse shown' : 'expand shown' }),
807
816
  hiddenCount > 0
808
817
  ? Btn({ key: 'older', onClick: () => { state.eventsLimit += 300; render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
809
818
  : null,
@@ -936,7 +945,7 @@ function runningPanel() {
936
945
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
937
946
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
938
947
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
939
- h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
948
+ h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
940
949
  h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
941
950
  Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
942
951
  }),
@@ -1107,6 +1116,7 @@ function settingsMain() {
1107
1116
  const isValid = isValidUrl(state.backendDraft);
1108
1117
  return [
1109
1118
  PageHeader({
1119
+ compact: true,
1110
1120
  title: 'settings',
1111
1121
  lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.',
1112
1122
  }),
@@ -1128,9 +1138,9 @@ function settingsMain() {
1128
1138
  onInput: (v) => { state.backendDraft = v; render(); },
1129
1139
  }),
1130
1140
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
1131
- state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
1132
- state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
1133
- state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
1141
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
1142
+ state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
1143
+ state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
1134
1144
  state.confirmingBackend ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
1135
1145
  healthSummary(),
1136
1146
  Btn({
@@ -1239,6 +1249,15 @@ async function refreshHistory() {
1239
1249
  // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
1240
1250
  // linear scan per event during a burst load.
1241
1251
  state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
1252
+ // If the selected session vanished from the list (deleted/aged out server-side),
1253
+ // drop the selection so the main pane doesn't sit on stale events that can no
1254
+ // longer be reloaded; fall back to the no-selection empty state.
1255
+ if (state.selectedSid && !state.sessionsBySid.has(state.selectedSid)) {
1256
+ state.selectedSid = null;
1257
+ state.events = [];
1258
+ state.eventsLoaded = false;
1259
+ writeHash(null);
1260
+ }
1242
1261
  state.historyError = null;
1243
1262
  render();
1244
1263
  } catch (e) {
@@ -1253,6 +1272,10 @@ async function runSearch() {
1253
1272
  const q = state.searchQ.trim();
1254
1273
  if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
1255
1274
  if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
1275
+ // The project-filter pills are hidden while searching; clear the filter so it
1276
+ // doesn't silently re-apply (and surprise the user) when they later clear the
1277
+ // search and the now-visible session list is unexpectedly narrowed.
1278
+ state.projectFilter = '';
1256
1279
  state.searchBusy = true;
1257
1280
  render();
1258
1281
  try {
@@ -1274,6 +1297,9 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
1274
1297
  state.eventsLoaded = false;
1275
1298
  state.eventsLimit = 300; // reset the render window per session
1276
1299
  state.expandedEvents = new Set(); // don't carry expansion to the new session
1300
+ // The live "live · N" crumb counter reads as the selected session's activity,
1301
+ // so reset it per selection rather than letting it accrue across all sessions.
1302
+ state.live.eventCount = 0;
1277
1303
  writeHash(sid, { push: true });
1278
1304
  // Close the mobile sidebar drawer on selection. The DS only auto-closes when
1279
1305
  // the clicked element is an <a>; agentgui's session rows are onClick divs, so
@@ -579,6 +579,14 @@
579
579
  .ds-247420 .fd-root .app { width: 100%; height: 100%; min-height: 0; flex: 1 1 auto; }
580
580
  .ds-247420 .fd-root .app-body { min-width: 0; }
581
581
 
582
+ /* Full-bleed canvas host (e.g. a game/3D viewport that fills the page behind the
583
+ shell): the shell's opaque var(--bg) would paint OVER the canvas and hide it.
584
+ Mark the host with `canvas-host` to make the shell surfaces transparent so the
585
+ live canvas shows through. The canvas itself is the backdrop. */
586
+ .ds-247420 .canvas-host,
587
+ .ds-247420 .canvas-host .app,
588
+ .ds-247420 body.canvas-host { background: transparent !important; }
589
+
582
590
  .ds-247420 {
583
591
  --app-status-h: var(--size-base);
584
592
  --app-topbar-h: var(--size-lg);
@@ -638,9 +646,14 @@
638
646
  outline: 2px solid var(--accent);
639
647
  outline-offset: 2px;
640
648
  }
649
+ /* Active tab reads as a subtle tinted pill with an accent underline rather than
650
+ an oversized filled oval that sits taller than its inactive siblings and reads
651
+ as misaligned. Same height as the inactive links — only the tint + underline
652
+ change. */
641
653
  .ds-247420 .app-topbar nav a.active {
642
- color: var(--accent-fg);
643
- background: var(--accent);
654
+ color: var(--accent);
655
+ background: color-mix(in oklab, var(--accent) 16%, transparent);
656
+ box-shadow: inset 0 -2px 0 0 var(--accent);
644
657
  font-weight: 600;
645
658
  }
646
659
 
@@ -933,6 +946,9 @@
933
946
  background: transparent; border-radius: 3px;
934
947
  transition: background var(--dur-snap) var(--ease);
935
948
  }
949
+ /* A clickable row (onClick -> role=button, or a link row) is interactive, so it
950
+ carries a pointer cursor; without this the click affordance is invisible. */
951
+ .ds-247420 .row[role="button"], .ds-247420 a.row { cursor: pointer; }
936
952
  .ds-247420 .row:hover { background: var(--bg-2); }
937
953
  .ds-247420 .row:hover::before { background: var(--rule-strong); }
938
954
  .ds-247420 .row.active { background: color-mix(in oklab, var(--accent) 10%, var(--bg-2)); }
@@ -981,6 +997,9 @@
981
997
  Section grouping
982
998
  ============================================================ */
983
999
  .ds-247420 .ds-section { margin: var(--space-8) 0; }
1000
+ /* Compact section (e.g. a PageHeader used as a page's first element): no large
1001
+ leading margin, modest trailing margin — top-aligns without consumer overrides. */
1002
+ .ds-247420 .ds-section-compact { margin-top: 0; margin-bottom: var(--space-4); }
984
1003
  .ds-247420 .ds-section > h3 {
985
1004
  font-family: var(--ff-body); font-weight: 600; font-size: var(--fs-h3);
986
1005
  margin: 0 0 var(--space-5);