agentgui 1.0.943 → 1.0.945

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.
package/AGENTS.md CHANGED
@@ -6,9 +6,11 @@ One surface. `server.js` serves `site/app/` under `BASE_URL` (default `/gm`) and
6
6
 
7
7
  When `PASSWORD` env var is set, every HTTP route is gated by `lib/http-handler.js` accepting **Basic auth**, **`Authorization: Bearer <pwd>`**, OR **`?token=<pwd>`** query param (added 2026-05-26 so `EventSource` and direct deep-links work — neither can set headers). WS `/sync` requires `?token=` only. The HTML head script injects `window.__BASE_URL`, `window.__SERVER_VERSION`, and `window.__WS_TOKEN`; `site/app/js/backend.js` reads `__WS_TOKEN` and threads it onto every fetch (Bearer header) / EventSource (qs) / WebSocket (qs).
8
8
 
9
- - `site/app/index.html` — shell + CSS, imports `anentrypoint-design` from unpkg
10
- - `site/app/js/backend.js` — same-origin client (`DEFAULT_BACKEND = ''`); `?backend=` query override for cross-origin debugging
11
- - `site/app/js/app.js` — webjsx view + state, kits-only rendering (PageHeader, SearchInput, TextField, EventList, Panel, Row, Section); exposes `window.__agentgui`
9
+ - `site/app/index.html` — shell + CSS; imports `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (always-latest; requires network — the prior offline guarantee is intentionally dropped)
10
+ - `site/app/js/backend.js` — same-origin WS/HTTP client (`DEFAULT_BACKEND = ''`); the transport glue that wires the kit; `?backend=` query override for cross-origin debugging
11
+ - `site/app/js/app.js` — webjsx view + state; renders the `AgentChat` kit (from `anentrypoint-design`) for the chat surface and wires agentgui's WS/ccsniff state as kit callbacks; history/settings remain agentgui-local. Exposes `window.__agentgui`
12
+
13
+ The chat GUI lives in the design kit, not in agentgui. The reusable multi-agent chat surface is the `AgentChat` component in `anentrypoint-design` (`src/components/agent-chat.js`); agentgui keeps only the transport glue (WS `backend.js`, ccsniff history wiring, agent orchestration) and passes state + callbacks into the kit. To change chat UI, edit the kit and push it (CI publishes to npm → unpkg `@latest`), not agentgui.
12
14
  - `server.js` — boots ACP/agents/websocket plugins, mounts `createHistoryRouter()` from `ccsniff` at `/`, serves `site/app/` as static root
13
15
  - Plugins kept (lib/plugins/): acp, agents, database, files, stream, websocket, workflow
14
16
 
@@ -145,9 +147,9 @@ Throttle renders via `requestAnimationFrame` to avoid event storm during burst l
145
147
 
146
148
  The endpoints are served by ccsniff's Express router mounted in-process from `server.js`. No external proxy.
147
149
 
148
- ## Zero-runtime-CDN: vendored DS deps (2026-05-28)
150
+ ## Runtime CDN: GUI loaded from unpkg (2026-05-29)
149
151
 
150
- The GUI runs fully offline. `site/app/vendor/cdn/` holds marked, dompurify, prismjs components, and JetBrains+Space Grotesk woff2. The DS bundle (`anentrypoint-design/247420.js`) had its CDN string constants rewritten to local paths, AND its gzip+base64-embedded CSS blob (`Ha`) regenerated to point at local fonts. Full mechanism is in rs-learn (recall "DS bundle gzip Ha vendoring"). Witness offline by browser route-abort of non-origin hosts.
152
+ The GUI loads `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (import map + CSS link in `index.html`) so agentgui always tracks the latest published kit with no hand-maintained vendored copy. This **requires a network connection** the earlier offline guarantee was intentionally dropped in favour of always-latest. The DS itself fetches its fonts from `unpkg.com/anentrypoint-design@latest/dist/fonts/` (set in the kit's `scripts/build.mjs` `FONT_BASE`). The `site/app/vendor/` tree is no longer imported; the old vendored DS bundle + `vendor/cdn/` assets are dead and may be removed. Witness the live GUI by confirming the import map resolves to unpkg and `.agentchat` renders with 0 console errors.
151
153
 
152
154
  ## Agent/model/session management (2026-05-28)
153
155
 
package/CHANGELOG.md CHANGED
@@ -1,4 +1,16 @@
1
- ## [Unreleased] - Antigravity (agy) orchestration + glyph-to-ASCII sweep + spawn-injection fix
1
+ ## [Unreleased] - GUI moved into the anentrypoint-design kit, loaded from unpkg
2
+
3
+ - the chat GUI now lives in the design kit: added a reusable `AgentChat` component to anentrypoint-design (agent-then-model picker + AICat auto-scroll/thinking + tool parts + cwd bar + caret-stable composer), pure props-in/vnode-out with all transport as host callbacks. Published via the kit's CI to npm -> unpkg.
4
+ - site/app/index.html: imports anentrypoint-design from https://unpkg.com/anentrypoint-design@latest at runtime (always-latest), replacing the vendored bundle. Trades the offline guarantee for always-latest, per project decision.
5
+ - site/app/js/app.js: chatMain renders the AgentChat kit and wires agentgui state + WS/ccsniff glue as callbacks; the agent/model picker, cwd bar, and composer moved out of agentgui into the kit. backend.js (WS client) stays as the transport glue.
6
+ - verified end-to-end in a real browser: GUI sourced from unpkg, AgentChat renders, chat round-trips (reply streams into the kit's transcript), 0 console errors.
7
+
8
+ ## [Unreleased] - live e2e chat verified + composer caret fix + ccsniff memory cap
9
+
10
+ - verified the full GUI chat round-trip end-to-end in a real browser: compose -> WS chat.sendMessage -> claude-code -> streaming_progress -> transcript renders the streamed reply (not just the runner harness).
11
+ - site/app/js/app.js: composer onInput re-renders only on the empty<->non-empty draft transition (the only change that toggles the send button), not on every keystroke. Why: a synchronous re-render per character fought the caret on the controlled textarea.
12
+ - vendored anentrypoint-design DS rebuilt: ChatComposer reads the live textarea value on send and syncs the controlled value into the DOM only when it differs, so a mid-type re-render no longer resets the caret or drops keystrokes. Why: DS override belongs in the kit; the composer is shared.
13
+ - ccsniff (sibling repo, pushed): bounded the in-memory history store (event cap + eviction + per-event text cap) and bounded loadOnce to read newest events first up to the cap. Why: the store grew unbounded to 6GB+ (OOM) and parsed the entire JSONL backlog at load (~2.4GB transient peak); heap now settles ~47MB and load peak ~570MB.
2
14
 
3
15
  - lib/agent-discovery.js + lib/claude-runner-agents.js + lib/agent-descriptors.js: added `agy` (Antigravity) as a direct-protocol CLI agent — `agy --print "<prompt>" --dangerously-skip-permissions [--continue]`, plain-text parseOutput, no model picker. Completes the four flagship orchestration targets (Claude Code, OpenCode, Kilo, Antigravity). Why: the GUI could not drive Antigravity at all.
4
16
  - lib/claude-runner.js + lib/claude-runner-direct.js: SECURITY — the direct runner now spawns a resolved binary with `shell:false`. Previously `shell:true` on Windows concatenated argv unescaped, so a prompt containing `&`/`|`/`>`/backticks executed as shell commands (verified injection). Why: arbitrary command execution from chat input.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.943",
3
+ "version": "1.0.945",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -5,9 +5,13 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>agentgui</title>
7
7
  <meta name="description" content="agentgui — multi-agent client with same-origin server, in-process ccsniff history, and ACP chat.">
8
- <link rel="stylesheet" href="vendor/anentrypoint-design/247420.css">
8
+ <!-- The GUI lives in the anentrypoint-design kit and is loaded from unpkg at
9
+ its latest published version, so agentgui always tracks the kit without a
10
+ hand-maintained vendored copy. (This trades the prior offline guarantee for
11
+ always-latest, per project decision; a network connection is required.) -->
12
+ <link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">
9
13
  <script type="importmap">
10
- { "imports": { "anentrypoint-design": "./vendor/anentrypoint-design/247420.js" } }
14
+ { "imports": { "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js" } }
11
15
  </script>
12
16
  <style>
13
17
  :root {
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
6
+ const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
7
7
 
8
8
  const state = {
9
9
  backend: B.getBackend(),
@@ -135,6 +135,27 @@ function selectModel(id) {
135
135
  function agentById(id) { return state.agents.find(a => a.id === id); }
136
136
  function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
137
137
 
138
+ // The four flagship orchestration targets surface first, then other available
139
+ // agents, then npx-installable, then not-installed — so the agents the GUI
140
+ // exists to drive are reachable without scanning a flat 17-item list. This
141
+ // ordering is agentgui's orchestration priority, so it stays in the host and is
142
+ // passed pre-sorted to the (app-agnostic) AgentChat kit.
143
+ const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
144
+ function sortedAgents() {
145
+ const rank = (a) => {
146
+ const primary = PRIMARY_AGENTS.indexOf(a.id);
147
+ const avail = a.available !== false;
148
+ if (primary !== -1 && avail) return primary;
149
+ if (avail) return 10;
150
+ if (a.npxInstallable) return 20;
151
+ return 30;
152
+ };
153
+ return state.agents
154
+ .map(a => ({ a, rank: rank(a) }))
155
+ .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
156
+ .map(({ a }) => a);
157
+ }
158
+
138
159
  function navTo(tab) {
139
160
  const prev = state.tab;
140
161
  state.tab = tab;
@@ -287,55 +308,9 @@ function view() {
287
308
  onNav: (label) => navTo(label),
288
309
  });
289
310
 
290
- // The four flagship orchestration targets surface first, then the rest of the
291
- // available agents, then unavailable ones so the agents the GUI exists to
292
- // drive are reachable without scanning a flat 17-item list. Ordering only;
293
- // the DS Select renders a plain option list.
294
- const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
295
- const agentRank = (a) => {
296
- const primary = PRIMARY_AGENTS.indexOf(a.id);
297
- const avail = a.available !== false;
298
- if (primary !== -1 && avail) return primary; // 0..3 flagship + available
299
- if (avail) return 10; // other available
300
- if (a.npxInstallable) return 20; // installable via npx
301
- return 30; // not installed
302
- };
303
- const agentOptions = state.agents
304
- .map(a => ({ a, rank: agentRank(a) }))
305
- .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
306
- .map(({ a }) => ({
307
- value: a.id,
308
- label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
309
- disabled: a.available === false && !a.npxInstallable,
310
- }));
311
- const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
312
-
313
- const crumbRight = state.tab === 'chat'
314
- ? [h('div', { key: 'cc', class: 'chat-controls' },
315
- Select({
316
- key: 'agentsel',
317
- value: state.selectedAgent,
318
- placeholder: '— agent —',
319
- title: 'Select coding agent',
320
- options: agentOptions,
321
- onChange: (v) => { selectAgent(v); },
322
- }),
323
- showModelPicker
324
- ? Select({
325
- key: 'modelsel',
326
- value: state.selectedModel,
327
- placeholder: '— model —',
328
- title: 'Select model for this agent',
329
- options: state.agentModels.map(m => ({ value: m.id, label: m.name || m.id })),
330
- onChange: (v) => { selectModel(v); },
331
- })
332
- : null,
333
- state.chat.busy
334
- ? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
335
- : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
336
- dot,
337
- )]
338
- : [dot];
311
+ // The agent/model picker + new/stop now live inside the AgentChat kit (chat
312
+ // tab), so the crumb carries only the status dot on every tab.
313
+ const crumbRight = [dot];
339
314
 
340
315
  // Topbar already shows "agentgui / <tab>"; the crumb is reserved for contextual
341
316
  // controls (model picker, new/stop, live status) so it doesn't duplicate the path.
@@ -407,37 +382,11 @@ function errText(e) {
407
382
  }
408
383
 
409
384
  function chatMain() {
410
- const lastIdx = state.chat.messages.length - 1;
411
385
  const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
412
- const msgs = state.chat.messages.map((m, i) => {
413
- const isAssistant = m.role === 'assistant';
414
- const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
415
- const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
416
- const isEmptyStreaming = isStreaming && !m.content && !hasParts;
417
- const parts = [];
418
- if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
419
- if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
420
- return {
421
- key: m.id || String(i),
422
- who: isAssistant ? 'them' : 'you',
423
- name: isAssistant ? agentName : 'you',
424
- time: m.time || '',
425
- typing: isEmptyStreaming,
426
- parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
427
- };
428
- });
429
-
430
- const placeholder = !state.selectedAgent
431
- ? 'choose an agent first'
432
- : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
433
- const composer = ChatComposer({
434
- value: state.chat.draft,
435
- disabled: !canSend(),
436
- placeholder,
437
- onInput: (v) => { state.chat.draft = v; render(); },
438
- onSend: (v) => { state.chat.draft = v; sendChat(); },
439
- });
440
386
 
387
+ // Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
388
+ // stream error) are pre-built here and handed to the kit, which renders them
389
+ // above the thread. The kit holds no agentgui-specific banner logic.
441
390
  const banners = [];
442
391
  if (state.chat.resumeSid) {
443
392
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
@@ -447,7 +396,6 @@ function chatMain() {
447
396
  banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
448
397
  }
449
398
  }
450
- banners.push(cwdBanner());
451
399
  if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
452
400
  banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
453
401
  children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
@@ -459,26 +407,59 @@ function chatMain() {
459
407
  Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
460
408
  Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
461
409
  }
462
- // Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
463
410
  const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
464
411
  if (lastErr && !state.chat.busy) {
465
412
  banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
466
413
  }
414
+
415
+ const placeholder = !state.selectedAgent
416
+ ? 'choose an agent first'
417
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
418
+
419
+ // The reusable AgentChat kit owns the agent/model picker, cwd bar, transcript
420
+ // (with AICat-style auto-scroll + thinking), and the caret-stable composer.
421
+ // agentgui supplies state and wires every server interaction as a callback.
467
422
  return [
468
423
  offlineBanner(),
469
- ...banners,
470
- Chat({
471
- title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
472
- // Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
473
- // leaks an event-list style into chat. Hide it when there are no messages
474
- // (the empty-state already says "no messages yet").
475
- sub: state.chat.busy
476
- ? 'streaming…'
477
- : (state.chat.messages.length
478
- ? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
479
- : ''),
480
- messages: msgs,
481
- composer,
424
+ AgentChat({
425
+ agents: sortedAgents(),
426
+ selectedAgent: state.selectedAgent,
427
+ models: state.agentModels,
428
+ selectedModel: state.selectedModel,
429
+ messages: state.chat.messages,
430
+ busy: state.chat.busy,
431
+ draft: state.chat.draft,
432
+ status: state.chat.busy ? 'streaming…' : (state.chat.resumeSid ? 'resume' : 'ready'),
433
+ agentName,
434
+ placeholder,
435
+ canSend: canSend(),
436
+ banners,
437
+ cwd: state.chatCwd,
438
+ cwdEditing: !!state.cwdEditing,
439
+ cwdDraft: state.cwdDraft,
440
+ onSelectAgent: (v) => selectAgent(v),
441
+ onSelectModel: (v) => selectModel(v),
442
+ onNewChat: newChat,
443
+ onStop: cancelChat,
444
+ onInput: (v) => {
445
+ // The kit's textarea is controlled and reads its live DOM value on send,
446
+ // so re-render only when the draft crosses empty<->non-empty (the only
447
+ // transition that toggles the send button's disabled state).
448
+ const was = !!(state.chat.draft && state.chat.draft.trim());
449
+ const now = !!(v && v.trim());
450
+ state.chat.draft = v;
451
+ if (was !== now) render();
452
+ },
453
+ onSend: (v) => { state.chat.draft = v; sendChat(); },
454
+ onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
455
+ onCwdSave: () => {
456
+ state.chatCwd = (state.cwdDraft ?? '').trim();
457
+ if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
458
+ state.cwdEditing = false; state.cwdDraft = undefined; render();
459
+ },
460
+ onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); },
461
+ onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
462
+ onCwdDraft: (v) => { state.cwdDraft = v; },
482
463
  }),
483
464
  ].filter(Boolean);
484
465
  }
@@ -489,24 +470,8 @@ function offlineBanner() {
489
470
  children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
490
471
  }
491
472
 
492
- function cwdBanner() {
493
- if (state.cwdEditing) {
494
- return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
495
- TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
496
- placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
497
- Btn({ key: 'cwdsave', primary: true, onClick: () => {
498
- state.chatCwd = (state.cwdDraft ?? '').trim();
499
- if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
500
- state.cwdEditing = false; state.cwdDraft = undefined; render();
501
- }, children: 'save' }),
502
- Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
503
- }
504
- return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
505
- h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
506
- state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
507
- h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
508
- state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
509
- }
473
+ // (The working-directory bar now lives in the AgentChat kit; agentgui wires its
474
+ // cwd state + handlers as kit callbacks in chatMain.)
510
475
 
511
476
  function newChat() {
512
477
  if (state.chat.messages.length && !state.confirmingNewChat) {
@@ -518,14 +518,43 @@
518
518
 
519
519
  .ds-247420 .app-topbar {
520
520
  position: sticky; top: 0; z-index: var(--z-header);
521
- display: grid; grid-template-columns: auto 1fr auto;
521
+ display: flex; flex-wrap: wrap;
522
522
  align-items: center; gap: var(--space-4);
523
523
  min-height: var(--app-topbar-h);
524
524
  padding: 10px var(--pad-x);
525
525
  background: color-mix(in oklab, var(--bg) 88%, transparent);
526
526
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
527
527
  }
528
- .ds-247420 .app-topbar nav { display: flex; gap: 4px; font-size: var(--fs-sm); }
528
+
529
+ /* Merged chrome: when AppShell gets both a topbar and a crumb it wraps them
530
+ in .app-chrome and they share ONE sticky band instead of stacking as two
531
+ bars. The breadcrumb provides the left identity (it already begins with the
532
+ brand), so the topbar's standalone brand is hidden to avoid showing the name
533
+ twice; nav and the crumb's right slot sit together on the right. */
534
+ .ds-247420 .app-chrome {
535
+ position: sticky; top: 0; z-index: var(--z-header);
536
+ display: flex; flex-wrap: wrap; align-items: center;
537
+ gap: var(--space-2) var(--space-4);
538
+ padding: 8px var(--pad-x);
539
+ background: color-mix(in oklab, var(--bg) 88%, transparent);
540
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
541
+ }
542
+ .ds-247420 .app-chrome > .app-topbar,
543
+ .ds-247420 .app-chrome > .app-crumb {
544
+ position: static; background: none; backdrop-filter: none;
545
+ -webkit-backdrop-filter: none; padding: 0; min-height: 0;
546
+ flex: 1 1 auto;
547
+ }
548
+ .ds-247420 .app-chrome > .app-crumb { order: 1; flex: 1 1 auto; }
549
+ .ds-247420 .app-chrome > .app-topbar { order: 2; flex: 0 0 auto; }
550
+ .ds-247420 .app-chrome > .app-topbar > .brand { display: none; }
551
+ .ds-247420 .app-chrome > .app-topbar > nav { margin-left: 0; }
552
+ .ds-247420 .app-chrome > .app-crumb > .crumb-right { margin-left: auto; }
553
+ .ds-247420 .app-topbar > .brand { flex: 0 0 auto; }
554
+ .ds-247420 .app-topbar > .app-search { flex: 1 1 auto; }
555
+ .ds-247420 .app-topbar > nav { margin-left: auto; }
556
+ .ds-247420 .app-topbar nav { display: flex; gap: 4px; font-size: var(--fs-sm); flex-wrap: wrap; }
557
+ .ds-247420 .app-topbar nav a { flex: 0 0 auto; }
529
558
  .ds-247420 .app-topbar nav a {
530
559
  color: var(--fg-2);
531
560
  padding: 12px 14px;
@@ -1083,25 +1112,21 @@
1083
1112
  /* App Layout: single-column + drawer is handled once in the ≤900px block;
1084
1113
  no need to re-declare grid-template-columns here (was a redundant !important). */
1085
1114
 
1086
- /* Topbar Navigation */
1115
+ /* Topbar Navigation: stack the nav onto its own full-width row below the
1116
+ brand and let it scroll horizontally. The brand stays on the first row;
1117
+ nav wraps to a second row at 100% width (flex-wrap on .app-topbar). */
1087
1118
  .ds-247420 .app-topbar {
1088
- grid-template-columns: 1fr auto;
1089
1119
  gap: var(--space-2); padding: 12px var(--space-3);
1090
1120
  }
1091
- .ds-247420 .app-topbar nav a {
1092
- padding: 12px 10px;
1093
- min-height: 44px;
1094
- }
1095
- /* Keep primary nav reachable on small screens (apps without a sidebar have
1096
- no other entry point). Compact it and let it scroll horizontally rather
1097
- than hiding it entirely. */
1098
- .ds-247420 .app-topbar nav {
1099
- display: flex; gap: 2px;
1121
+ .ds-247420 .app-topbar > nav {
1122
+ flex: 1 1 100%; margin-left: 0; order: 3;
1123
+ display: flex; gap: 2px; flex-wrap: nowrap;
1100
1124
  overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
1101
- max-width: 60vw;
1102
1125
  }
1103
1126
  .ds-247420 .app-topbar nav::-webkit-scrollbar { display: none; }
1104
- .ds-247420 .app-topbar nav a { padding: 10px 8px; white-space: nowrap; }
1127
+ .ds-247420 .app-topbar nav a {
1128
+ flex: 0 0 auto; padding: 10px 12px; min-height: 44px; white-space: nowrap;
1129
+ }
1105
1130
  .ds-247420 .brand { font-size: var(--fs-tiny); font-weight: 600; }
1106
1131
 
1107
1132
  /* Search */