From 90e43f3f655bb7af225cfb3429c208042a900ad7 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Sat, 25 Jul 2026 01:56:36 -0300 Subject: [PATCH 1/7] feat: wip layout settings --- DESIGN.md | 6 +- docs/design/opendesign/modals/CHECKLIST.md | 60 - .../opendesign/modals/IMPLEMENTATION-SPEC.md | 437 ----- .../opendesign/modals/MODAL-STANDARD.md | 81 - docs/design/opendesign/modals/STATE-MATRIX.md | 28 - .../opendesign/modals/VISUAL-VALIDATION.md | 23 - .../os/ONBOARDING-IMPLEMENTATION-SPEC.md | 98 + docs/design/opendesign/os/OS-V2-DELIVERY.md | 65 - docs/design/opendesign/os/onboarding.css | 484 +++++ docs/design/opendesign/os/onboarding.html | 233 +++ .../os/onboarding.html.artifact.json | 19 + docs/design/opendesign/os/onboarding.js | 863 +++++++++ .../settings/LAYOUTS-IMPLEMENTATION-SPEC.md | 327 ++++ .../settings/LAYOUTS-REDESIGN-SPEC.md | 110 ++ .../settings/settings-automation.html | 1 + .../settings/settings-extensions.html | 1 + .../opendesign/settings/settings-general.html | 4 + .../opendesign/settings/settings-hooks.html | 1 + .../opendesign/settings/settings-layouts.html | 723 ++++++++ .../settings-layouts.html.artifact.json | 19 + .../opendesign/settings/settings-layouts.js | 1603 +++++++++++++++++ .../opendesign/settings/settings-memory.html | 1 + .../opendesign/settings/settings-network.html | 1 + .../settings/settings-observability.html | 1 + .../settings/settings-providers.html | 4 + .../opendesign/settings/settings-skills.html | 1 + .../ET-layout-editor-drag-rebalance.md | 21 + .../ET-layout-editor-gaps-follow-canvas.md | 21 + .../ET-layout-editor-group-overlap-refused.md | 21 + .../ET-layout-editor-load-saved-layout.md | 21 + .../ET-layout-editor-shortcut-recorder.md | 21 + .../ET-layout-editor-split-orientation.md | 21 + .../ET-layout-editor-split-weights.md | 21 + .../scenarios/ET-web-route-chrome-topbar.md | 4 +- .../scenarios/MS-configure-window-manager.md | 6 +- .../MS-layout-profile-cli-roundtrip.md | 21 + internal/cli/client_window_manager.go | 54 + internal/cli/root.go | 1 + internal/cli/window_manager_layout_profile.go | 273 +++ .../cli/window_manager_layout_profile_test.go | 269 +++ internal/cli/window_manager_test.go | 44 +- .../content/runtime/cli-reference/agh.mdx | 95 +- .../cli-reference/layout-profile/delete.mdx | 42 + .../cli-reference/layout-profile/get.mdx | 47 + .../cli-reference/layout-profile/index.mdx | 39 + .../cli-reference/layout-profile/list.mdx | 41 + .../cli-reference/layout-profile/meta.json | 4 + .../cli-reference/layout-profile/put.mdx | 50 + .../runtime/cli-reference/layout/arrange.mdx | 4 +- .../runtime/cli-reference/layout/index.mdx | 26 +- .../runtime/cli-reference/layout/watch.mdx | 5 +- .../src/components/__tests__/slider.test.tsx | 81 + packages/ui/src/components/menubar.tsx | 267 +++ packages/ui/src/components/slider.tsx | 69 + .../src/components/stories/slider.stories.tsx | 74 + packages/ui/src/primitives.ts | 1 + packages/ui/src/tokens.css | 3 + skills/agh/references/window-management.md | 15 + .../_app/settings/-layouts-settings-page.tsx | 198 +- .../settings/-providers-settings-page.tsx | 2 +- web/src/systems/os/index.ts | 50 + .../window-manager-shortcuts.test.ts | 104 ++ .../os/lib/window-manager-shortcuts.ts | 58 + web/src/systems/os/lib/window-manager-view.ts | 14 +- web/src/systems/os/mocks/fixtures.ts | 3 +- .../__tests__/layout-canvas.test.tsx | 160 ++ .../__tests__/layout-review-bar.test.tsx | 168 ++ .../window-manager-config-editor.test.tsx | 80 - ...ow-manager-layout-document-editor.test.tsx | 265 --- web/src/systems/settings/components/index.ts | 3 +- .../layouts/layout-canvas-board-bar.tsx | 92 + .../layouts/layout-canvas-floating.tsx | 135 ++ .../layouts/layout-canvas-group-edge.tsx | 82 + .../components/layouts/layout-canvas-seam.tsx | 92 + .../components/layouts/layout-canvas-tile.tsx | 92 + .../components/layouts/layout-canvas.tsx | 153 ++ .../layouts/layout-desktop-tabs.tsx | 105 ++ .../layouts/layout-inspector-node.tsx | 173 ++ .../layouts/layout-inspector-window.tsx | 140 ++ .../components/layouts/layout-inspector.tsx | 170 ++ .../layouts/layout-node-diagrams.tsx | 76 + .../layouts/layout-profile-card.tsx | 140 ++ .../layouts/layout-profile-editor.tsx | 208 +++ .../layouts/layout-profile-grid.tsx | 102 ++ .../components/layouts/layout-review-bar.tsx | 152 ++ .../components/layouts/layout-stage.tsx | 96 + .../window-manager-behavior-diagrams.tsx | 204 +++ .../layouts/window-manager-behavior-picks.tsx | 278 +++ .../layouts/window-manager-gap-editor.tsx | 176 ++ .../layouts/window-manager-ratio-track.tsx | 189 ++ .../layouts/window-manager-shortcut-row.tsx | 121 ++ .../layouts/window-manager-shortcut-table.tsx | 147 ++ .../layouts/window-manager-snap-map.tsx | 249 +++ .../window-manager-tiling-diagrams.tsx | 32 + .../components/settings-page-frame.tsx | 19 +- .../stories/settings-page-frame.stories.tsx | 2 +- .../window-manager-behavior-fields.tsx | 109 -- .../window-manager-binding-fields.tsx | 94 - .../window-manager-config-editor.tsx | 188 +- .../window-manager-config-field-types.ts | 13 - .../window-manager-config-fields.ts | 3 - .../window-manager-geometry-fields.tsx | 105 -- .../window-manager-layout-document-editor.tsx | 220 --- .../window-manager-layout-node-editor.tsx | 208 --- .../window-manager-layout-profiles.tsx | 152 -- .../window-manager-number-field.tsx | 40 - .../window-manager-select-field.tsx | 40 - .../window-manager-toggle-field.tsx | 25 - .../use-window-manager-config-editor.test.tsx | 133 ++ .../use-window-manager-layout-editor.test.tsx | 132 ++ ...se-window-manager-layout-profiles.test.tsx | 175 ++ .../systems/settings/hooks/use-drag-value.ts | 70 + .../hooks/use-layout-canvas-selection.ts | 50 + .../settings/hooks/use-layout-draft-seam.ts | 106 ++ .../hooks/use-layout-group-frame-drag.ts | 103 ++ .../settings/hooks/use-pointer-drag.ts | 72 + .../hooks/use-window-manager-config-editor.ts | 178 +- .../hooks/use-window-manager-layout-editor.ts | 77 +- .../use-window-manager-layout-profiles.ts | 63 +- .../use-window-manager-shortcut-recorder.ts | 115 ++ web/src/systems/settings/index.ts | 3 +- .../window-manager-layout-mutations.test.ts | 144 ++ .../lib/window-manager-layout-bridge.ts | 52 + .../lib/window-manager-layout-canvas.ts | 73 + .../lib/window-manager-layout-detents.ts | 46 + .../lib/window-manager-layout-mutations.ts | 95 + .../lib/window-manager-layout-reference.ts | 24 + .../lib/window-manager-layout-thumbnail.ts | 75 + .../lib/window-manager-layout-tree.ts | 83 + .../lib/window-manager-layout-window-face.ts | 36 + .../lib/window-manager-snap-geometry.ts | 31 + web/src/systems/settings/mocks/handlers.ts | 18 +- web/src/systems/settings/mocks/index.ts | 2 + .../settings/mocks/window-manager-fixtures.ts | 240 ++- .../routes/settings-layouts.stories.tsx | 100 +- 135 files changed, 12527 insertions(+), 2372 deletions(-) delete mode 100644 docs/design/opendesign/modals/CHECKLIST.md delete mode 100644 docs/design/opendesign/modals/IMPLEMENTATION-SPEC.md delete mode 100644 docs/design/opendesign/modals/MODAL-STANDARD.md delete mode 100644 docs/design/opendesign/modals/STATE-MATRIX.md delete mode 100644 docs/design/opendesign/modals/VISUAL-VALIDATION.md create mode 100644 docs/design/opendesign/os/ONBOARDING-IMPLEMENTATION-SPEC.md delete mode 100644 docs/design/opendesign/os/OS-V2-DELIVERY.md create mode 100644 docs/design/opendesign/os/onboarding.css create mode 100644 docs/design/opendesign/os/onboarding.html create mode 100644 docs/design/opendesign/os/onboarding.html.artifact.json create mode 100644 docs/design/opendesign/os/onboarding.js create mode 100644 docs/design/opendesign/settings/LAYOUTS-IMPLEMENTATION-SPEC.md create mode 100644 docs/design/opendesign/settings/LAYOUTS-REDESIGN-SPEC.md create mode 100644 docs/design/opendesign/settings/settings-layouts.html create mode 100644 docs/design/opendesign/settings/settings-layouts.html.artifact.json create mode 100644 docs/design/opendesign/settings/settings-layouts.js create mode 100644 docs/qa/scenarios/ET-layout-editor-drag-rebalance.md create mode 100644 docs/qa/scenarios/ET-layout-editor-gaps-follow-canvas.md create mode 100644 docs/qa/scenarios/ET-layout-editor-group-overlap-refused.md create mode 100644 docs/qa/scenarios/ET-layout-editor-load-saved-layout.md create mode 100644 docs/qa/scenarios/ET-layout-editor-shortcut-recorder.md create mode 100644 docs/qa/scenarios/ET-layout-editor-split-orientation.md create mode 100644 docs/qa/scenarios/ET-layout-editor-split-weights.md create mode 100644 docs/qa/scenarios/MS-layout-profile-cli-roundtrip.md create mode 100644 internal/cli/window_manager_layout_profile.go create mode 100644 internal/cli/window_manager_layout_profile_test.go create mode 100644 packages/site/content/runtime/cli-reference/layout-profile/delete.mdx create mode 100644 packages/site/content/runtime/cli-reference/layout-profile/get.mdx create mode 100644 packages/site/content/runtime/cli-reference/layout-profile/index.mdx create mode 100644 packages/site/content/runtime/cli-reference/layout-profile/list.mdx create mode 100644 packages/site/content/runtime/cli-reference/layout-profile/meta.json create mode 100644 packages/site/content/runtime/cli-reference/layout-profile/put.mdx create mode 100644 packages/ui/src/components/__tests__/slider.test.tsx create mode 100644 packages/ui/src/components/menubar.tsx create mode 100644 packages/ui/src/components/slider.tsx create mode 100644 packages/ui/src/components/stories/slider.stories.tsx create mode 100644 web/src/systems/os/lib/__tests__/window-manager-shortcuts.test.ts create mode 100644 web/src/systems/settings/components/__tests__/layout-canvas.test.tsx create mode 100644 web/src/systems/settings/components/__tests__/layout-review-bar.test.tsx delete mode 100644 web/src/systems/settings/components/__tests__/window-manager-config-editor.test.tsx delete mode 100644 web/src/systems/settings/components/__tests__/window-manager-layout-document-editor.test.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-canvas-board-bar.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-canvas-floating.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-canvas-group-edge.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-canvas-seam.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-canvas-tile.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-canvas.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-desktop-tabs.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-inspector-node.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-inspector-window.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-inspector.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-node-diagrams.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-profile-card.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-profile-editor.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-profile-grid.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-review-bar.tsx create mode 100644 web/src/systems/settings/components/layouts/layout-stage.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-behavior-diagrams.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-behavior-picks.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-gap-editor.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-ratio-track.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-shortcut-row.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-shortcut-table.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-snap-map.tsx create mode 100644 web/src/systems/settings/components/layouts/window-manager-tiling-diagrams.tsx delete mode 100644 web/src/systems/settings/components/window-manager-behavior-fields.tsx delete mode 100644 web/src/systems/settings/components/window-manager-binding-fields.tsx delete mode 100644 web/src/systems/settings/components/window-manager-config-field-types.ts delete mode 100644 web/src/systems/settings/components/window-manager-config-fields.ts delete mode 100644 web/src/systems/settings/components/window-manager-geometry-fields.tsx delete mode 100644 web/src/systems/settings/components/window-manager-layout-document-editor.tsx delete mode 100644 web/src/systems/settings/components/window-manager-layout-node-editor.tsx delete mode 100644 web/src/systems/settings/components/window-manager-layout-profiles.tsx delete mode 100644 web/src/systems/settings/components/window-manager-number-field.tsx delete mode 100644 web/src/systems/settings/components/window-manager-select-field.tsx delete mode 100644 web/src/systems/settings/components/window-manager-toggle-field.tsx create mode 100644 web/src/systems/settings/hooks/__tests__/use-window-manager-config-editor.test.tsx create mode 100644 web/src/systems/settings/hooks/__tests__/use-window-manager-layout-editor.test.tsx create mode 100644 web/src/systems/settings/hooks/__tests__/use-window-manager-layout-profiles.test.tsx create mode 100644 web/src/systems/settings/hooks/use-drag-value.ts create mode 100644 web/src/systems/settings/hooks/use-layout-canvas-selection.ts create mode 100644 web/src/systems/settings/hooks/use-layout-draft-seam.ts create mode 100644 web/src/systems/settings/hooks/use-layout-group-frame-drag.ts create mode 100644 web/src/systems/settings/hooks/use-pointer-drag.ts create mode 100644 web/src/systems/settings/hooks/use-window-manager-shortcut-recorder.ts create mode 100644 web/src/systems/settings/lib/__tests__/window-manager-layout-mutations.test.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-bridge.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-canvas.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-detents.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-mutations.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-reference.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-thumbnail.ts create mode 100644 web/src/systems/settings/lib/window-manager-layout-window-face.ts create mode 100644 web/src/systems/settings/lib/window-manager-snap-geometry.ts diff --git a/DESIGN.md b/DESIGN.md index 5ff57c0ee..b03fb5dd7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -247,6 +247,9 @@ tokens: container-setting-description: "52ch" container-settings-page-form: "768px" container-settings-page-wide: "960px" + container-settings-page-canvas: "1040px" + container-layout-stage-split: "720px" + width-layout-inspector: "236px" container-settings-page-description: "72ch" spacing-settings-page-bottom: "96px" spacing-layout-node-indent: "12px" @@ -704,7 +707,8 @@ widths come from `--site-*` tokens. | `--width-settings-nav` | `264px` | `--container-settings-takeover` | `56rem` | `--container-task-detail-rail` | `64rem` | | `--width-settings-sheet` | `640px` | `--width-task-properties-rail` | `320px` | `--spacing-settings-sheet-viewport-gutter` | `24px` | | `--height-setting-row` | `54px` | `--container-setting-description` | `52ch` | `--container-settings-page-form` | `768px` | -| `--container-settings-page-wide` | `960px` | `--container-settings-page-description` | `72ch` | `--spacing-settings-page-bottom` | `96px` | +| `--container-settings-page-wide` | `960px` | `--container-settings-page-canvas` | `1040px` | `--container-layout-stage-split` | `720px` | +| `--width-layout-inspector` | `236px` | `--container-settings-page-description` | `72ch` | `--spacing-settings-page-bottom` | `96px` | | `--spacing-layout-node-indent` | `12px` | `--container-settings-save-bar` | `560px` | `--size-settings-save-dot` | `7px` | | `--height-modal-md` | `760px` | `--height-modal-tall` | `900px` | `--height-modal-wizard` | `960px` | | `--height-modal-xl` | `840px` | `--height-menubar` | `44px` | `--height-menubar-chip` | `26px` | diff --git a/docs/design/opendesign/modals/CHECKLIST.md b/docs/design/opendesign/modals/CHECKLIST.md deleted file mode 100644 index c3e341f00..000000000 --- a/docs/design/opendesign/modals/CHECKLIST.md +++ /dev/null @@ -1,60 +0,0 @@ -# Modal Library Checklist - -Use before claiming a static surface or a production migration is done. Markup migration of the 16 HTMLs is complete (retired work order deleted). - -## Runtime truth - -- [ ] Every control maps to a daemon field or an existing catalog endpoint. -- [ ] Immutable update fields render as `ImmutableIdentity`, not disabled inputs. -- [ ] Provider settings never contain RuntimeSelector. -- [ ] Secrets are write-only and expose only presence/reference after save. - -## Reuse gate - -- [ ] Provider + model + reasoning uses RuntimeSelector. -- [ ] Agent selection uses AgentCommandSelect or AgentCommandMultiSelect. -- [ ] Scope uses ScopeSelector and WorkspaceCommandSelect. -- [ ] Existing catalogs use CommandSelect composition. -- [ ] Fixed enums use NativeSelect; consequence-bearing choices use RadioCard. -- [ ] Provider sheets use SettingsFieldRow. - -## Shell and responsive behavior - -- [ ] Host is 560, 720, 880, 1180px, a 576px provider sheet, or the 960px-tall wizard envelope for the documented task class. -- [ ] Dialog has one accessible name, one body scroll owner, and one primary action. -- [ ] Simple/Advanced has at most one disclosure tier. -- [ ] 360px stacks grids, preserves copy, and provides 44px targets. -- [ ] 200% zoom retains all controls without body-level horizontal scrolling. - -## States and accessibility - -- [ ] Default, hover, active, focus-visible, disabled, loading, empty, error, selected, saving, and save-error states are designed where applicable. -- [ ] Focus enters the modal, remains trapped, closes with Escape, and returns to the opener. -- [ ] Combobox/listbox/radiogroup semantics match behavior; reasoning uses `role="group"` + `aria-pressed`, with Default separate from seven efforts. -- [ ] Favorite is a separate `aria-pressed` button keyed by `provider:model`, never nested inside a model option. -- [ ] Escape closes the innermost popup; ArrowDown enters the first result; provider radio rails support Home/End. -- [ ] Every `.sec`, `.settings-row`, and `.notice`/`.note` carries the marker for the real FormSection, SettingsFieldRow, or Alert instance. -- [ ] Labels and errors are programmatically associated. -- [ ] Rendered contrast meets WCAG 2.2 AA and color never carries meaning alone. -- [ ] Reduced motion preserves state clarity without transforms. - -## Drift scan - -- [ ] No inline `style` attributes or page-local scripts. -- [ ] No parallel token aliases, raw production palette, gradients, accent side rails, or decorative shadows. -- [ ] No native agent selectors, `.agent-field`, separate provider/model/reasoning fields, or numbered editorial sections. -- [ ] Every surface declares stable `data-od-component` markers owned by `verify.mjs`. - -## Static library health - -- [x] `node verify.mjs` passes. -- [x] `node --check modal-system.js` passes. -- [x] 14 dialogs carry icon-well + eyebrow; provider sheets keep their sheet headers. -- [x] Zero unicode UI glyphs (`⌄ ★ ▮ ×`) in modal HTML; meters are 7-bar `.im`. - -## Evidence (pending for Visual Contract Mode) - -- [ ] All 16 surfaces have 360, 768, and 1440px evidence; dense dialogs and sheets add 1920px. -- [ ] Runtime, Agent, Agent Multi, Scope, Workspace, and Command selector mocks have matched production-story pairs at 1100×700. -- [ ] Every visual-contract bundle contains source identity, reference, implementation, side-by-side, diff, comparison JSON, and a passing review. -- [ ] Owned processes are stopped and teardown evidence is clean. diff --git a/docs/design/opendesign/modals/IMPLEMENTATION-SPEC.md b/docs/design/opendesign/modals/IMPLEMENTATION-SPEC.md deleted file mode 100644 index 531833ab3..000000000 --- a/docs/design/opendesign/modals/IMPLEMENTATION-SPEC.md +++ /dev/null @@ -1,437 +0,0 @@ -# Modal System — Production Implementation Spec - -Everything required to land the 16 designed entity editors in `web/`. - -- **Design source of truth:** the 16 surfaces in this folder + `MODAL-STANDARD.md` + `../design-system/patterns.html` § Modals. -- **Runtime source of truth:** `internal/api/contract/*` (verified 2026-07-24 against the current tree — the line numbers in `analysis/02_analysis_runtime-contracts.md` are stale; `contract.go` was split into per-domain files). -- **Token source of truth:** `packages/ui/src/tokens.css` → generated `DESIGN.md`. - -Authority on conflict: runtime contract > tokens/`packages/ui` primitives > this spec > the static HTML surfaces. Where a designed control has no write contract it is cut, not implemented (§5.2). - ---- - -## 1. Current state — audit - -Every target dialog was re-read on 2026-07-24. The production tree has drifted from `analysis/01`: Vault and Sandbox gained *inspect* sheets (their editors still run through `SettingsEditorDialog`), Provider moved from a sheet to `provider-detail-dialog.tsx`, and the Task editor was split into `task-editor-modal.tsx` + `task-editor-surface.tsx` and **lost its header entirely**. - -| # | Surface | Production file | Host today | Header today | Mode switch | Primary gap | -|---|---|---|---|---|---|---| -| R1 | Task editor (reference) | `web/src/systems/tasks/components/task-editor-surface.tsx:101` | `--width-modal-md` ✓ | **none** — description is a `

` in the body (`:134`) | ✓ `ModeToolbar` | Header regression: no icon well / eyebrow / `DialogTitle` | -| R2 | Job / Trigger editor (reference) | `web/src/systems/automation/components/automation-editor-dialog.tsx:136` | `--width-modal-xl` ✓ | ✓ **canonical** icon well + `Eyebrow` + title + description | preview rail | Header is local — promote it | -| 1 | Create agent | `web/src/systems/agent/components/agent-create-dialog.tsx:117` | `--width-modal-lg` ✓ | plain `

` (`:124`) | ✗ 4-step wizard (`:63`) | Wizard → Simple/Advanced; header; MCP field is untruthful | -| 2 | Start session | `web/src/systems/session/components/session-create-dialog.tsx:145` | `--width-modal-sm` ✓ | ruled, bare (`:151`) | ✗ | Header; Simple/Advanced; overrides section | -| 3 | Add workspace | `web/src/systems/workspace/components/workspace-setup.tsx:171` | `max-w-xl` ✗ | ruled, bare (`:177`) | n/a | Split shell + `DirectoryBrowser`; keep the global-default card | -| 4 | Create knowledge | `web/src/systems/knowledge/components/knowledge-create-dialog.tsx:118` | `sm:max-w-2xl` ✗ | ruled, bare (`:123`) | n/a | Host token; header | -| 5 | Edit knowledge | `web/src/systems/knowledge/components/knowledge-edit-dialog.tsx:76` | `sm:max-w-2xl` ✗ | ruled, bare (`:82`) | n/a | Host token; header; `ImmutableIdentity` | -| 6 | Create channel | `web/src/systems/network/components/network-create-channel-dialog.tsx:61` | `sm:max-w-120` ✗ | **not ruled** (`:65`) | n/a | Host token; ruled header; fanout `RadioCard` | -| 7 | Edit channel | `web/src/systems/network/components/shell/channel-policy-dialog.tsx:157` | inherits | bare | n/a | Header; locked identity; fanout `RadioCard` | -| 8 | Create bridge | `web/src/systems/bridges/components/bridge-create-dialog.tsx:201` | `--width-modal-lg` ✓ | plain `
` (`:212`) | ✗ 3-step wizard (`:32`) | Wizard → Simple/Advanced; header; secret slots | -| 9 | Edit bridge | `web/src/systems/bridges/components/bridge-edit-dialog.tsx:62` | `sm:max-w-3xl` ✗ | ruled, bare (`:67`) | ✗ | Host token; header; rotation; locked identity | -| 10 | Add MCP server | `web/src/systems/settings/components/mcp-server-editor.tsx:92` | `max-w-xl`/`sm:max-w-3xl` ✗ | ruled + muted eyebrow (`:99`) | ✗ | Host token; accent eyebrow + icon well; Simple/Advanced | -| 11 | Edit MCP server | same file (`mode="edit"`) | same | same | ✗ | + `ImmutableIdentity` for name/scope | -| 12 | Add vault secret | `web/src/systems/vault/routes/vault-page.tsx:238` (via `SettingsEditorDialog`) | shell default ✗ | shell header, bare | n/a | Host token; header; `SecretField` | -| 13 | Create sandbox profile | `web/src/systems/sandbox/routes/sandbox-page.tsx:252` (via `SettingsEditorDialog`) | shell default ✗ | shell header, bare | ✗ | Header; Simple/Advanced; secret env rotation | -| 14 | Edit sandbox profile | same file (`mode="edit"`) | same | same | ✗ | + `ImmutableIdentity` | -| 15 | Create provider | `web/src/systems/settings/components/provider-detail-dialog.tsx` | dialog + `LaneTabs` | `Eyebrow` + `DialogTitle`, no icon well | ✗ | §14 D1 (sheet vs dialog); auth-ownership cards; slot gating | -| 16 | Edit provider | same file (`mode="edit"`) | same | same | ✗ | + rotation-only credentials | - -**Shared shell:** `web/src/systems/settings/components/settings-editor-dialog.tsx:36` backs #12–#14. Upgrading it lifts three surfaces at once, and it is consumed by `settings-dialogs.stories.tsx`, so its story pins the contract. - -Systemic findings: - -1. **The designed header exists in exactly one place** — `automation-editor-dialog.tsx:136-160`. 15 of 16 surfaces need it; the Task reference needs it back. -2. **Six surfaces use ad-hoc Tailwind widths** instead of `--width-modal-*` (`sm:max-w-2xl`, `sm:max-w-3xl`, `sm:max-w-120`, `max-w-xl`). -3. **Simple/Advanced exists only in tasks** — `web/src/systems/tasks/components/task-form/mode-toolbar.tsx`, coupled to `TaskFormMode` and the task scope/workspace props. -4. **No shared secret or immutable-identity primitive.** The only secret control is `web/src/systems/marketplace/components/mcp-secret-field.tsx` (marketplace-local). -5. **Two step wizards remain** (agent 4-step, bridge 3-step) with per-step `canAdvance`/`stepValidity` gating that must survive the flattening. - ---- - -## 2. Foundation — build before any surface migrates - -Phase 0. Nothing in §4 starts until these land with stories + tests. - -### F1 · `EntityDialogHeader` → `packages/ui` - -Lift `EditorHeader` (`automation-editor-dialog.tsx:144-166`) verbatim into `packages/ui/src/components/custom/entity-dialog-header.tsx`, export from `primitives.ts`/`index.ts`. - -``` - - icon well 36px · rounded-icon-well · bg-accent-tint · text-accent-strong · ring-1 ring-accent-dim ring-inset · 16px glyph - Eyebrow (text-accent-strong) · DialogTitle (mt-1) · DialogDescription (mt-1) - [optional] close button — Button variant="ghost" size="icon-sm" - -``` - -Props: `{ icon: LucideIcon; eyebrow: string; title: string; description?: ReactNode; onClose?: () => void }`. The icon well is the **only** accent-tinted surface in the shell (`MODAL-STANDARD.md` § Hosts). `ConfirmDialog` keeps its neutral well and is out of scope. - -`automation-editor-dialog.tsx` must be refactored onto it in the same change (no duplicate header definition survives). - -### F2 · `EntityDialogFooter` - -Two footers exist today and neither is the modal contract: `DialogFooter variant="ruled"` (`dialog.tsx:109`) has the right chrome but no hint slot, and `EditorFooter` (`custom/editor-footer.tsx`) has `meta`/`secondary`/`primary` slots but page-editor geometry (`sticky bottom-0`, `bg-canvas`, `min-h-editor-footer`, `px-4 py-2.5`, `text-form-label text-muted`). Add the modal footer as a `DialogFooter` composition — do not restyle `EditorFooter`, which page and sheet editors depend on. - -``` -footer ruled · flex · gap-3 · px-6 py-4 · border-t border-line - [hint] flex-1 · text-form-hint text-subtle · leading info glyph (optional) - [Cancel] Button variant="outline" - [Primary] Button min-w-32 · Spinner while saving · verb+object label -``` - -One primary action per dialog. Label switches by mode (`Create agent` → `Save changes`). - -### F3 · Host size contract - -Add a `dialogShellClass(size)` helper (`web/src/components/` or `packages/ui`) emitting the token classes, and route every migrated dialog through it. No raw `max-w-*` on an entity editor after Phase 3. - -| Size | Token | Surfaces | -|---|---|---| -| `sm` | `--width-modal-sm` 560 | start session, create/edit knowledge, create/edit channel, add vault secret | -| `md` | `--width-modal-md` 720 | task (R1), add/edit MCP server, create/edit sandbox profile, edit bridge | -| `lg` | `--width-modal-lg` 880 | create agent, create bridge | -| `xl` | `--width-modal-xl` 1180 | job/trigger (R2), add workspace (split) | -| `sheet` | 576 | provider — pending §14 D1 | - -Height caps stay `min(var(--height-modal-*), calc(100vh - 2rem))`; the body is the only scroll owner (`grid-rows-[auto_auto_minmax(0,1fr)_auto]`). - -### F4 · `ModeToolbar` → generic - -Generalize `web/src/systems/tasks/components/task-form/mode-toolbar.tsx` into a domain-agnostic component (`web/src/components/entity-mode-toolbar.tsx`): - -```ts -{ mode: "simple" | "advanced"; - onModeChange(mode): void; - scope?: ScopeSelectorProps; // optional trailing ScopeSelector + WorkspaceCommandSelect - label?: string } -``` - -Contract from `MODAL-STANDARD.md`: `PillGroup` `role="group"` + `aria-pressed`, **one disclosure tier only**, Advanced never hides a required field, leaving Advanced snaps unsupported advanced-only selections back to a Simple-valid default (the task template snap-back at `task-editor-surface.tsx:75-85` is the reference). - -Consumers: agent create, bridge create/edit, MCP create/edit, sandbox create/edit, start session, provider. - -### F5 · `SecretField` - -New shared component. Generalize `web/src/systems/marketplace/components/mcp-secret-field.tsx`; delete the marketplace copy and re-point `mcp-install-dialog.tsx`. - -States (normative — `STATE-MATRIX.md`): `absent` · `present` · `editing` · `invalid` · `saving` · `rotated`. - -- Create → single write input, `type="password"`, no value ever read back. -- Edit → `•••• present · [Replace]`; pressing Replace opens the write input; Cancel restores `present` and preserves the existing binding. -- Never prefill from a GET. Every AGH secret read path returns presence/ref/hash only (`internal/api/contract/vault.go:12-19`, `settings_collection_payloads.go:64-69`, `bridges.go:83-91`). -- Emits the preservation signal each contract needs: MCP `preserve_secrets` (`settings_collection_payloads.go:116-119`), provider `secrets[]` write list, bridge secret-binding PUT. - -### F6 · `ImmutableIdentity` - -Readable summary row for create-only fields. **Disabled inputs are forbidden as data display** (`MODAL-STANDARD.md` § Component grammar). - -Applies to: bridge platform / extension / scope, channel name + members, workspace `root_dir`, knowledge name+type, MCP name + scope, sandbox profile name, provider name, vault ref. - -### F7 · Split-dialog shell - -Two-pane body for `add-workspace` only: `--width-modal-xl`, `grid-template-columns: minmax(0,1fr) 420px`, right pane on `--color-canvas` with a left hairline, each pane its own scroll owner, collapsing to one column below 980px. Ship as a `variant="split"` on the entity dialog body, not a bespoke layout. - -### F8 · Reuse register (build nothing new here) - -| Need | Use | Location | -|---|---|---| -| provider+model+reasoning | `RuntimeSelector` | `@/systems/runtime` | -| agent pick / multi-pick | `AgentCommandSelect`, `AgentCommandMultiSelect` | `@/systems/agent` | -| scope + workspace | `ScopeSelector`, `WorkspaceCommandSelect` | `@/systems/workspace` | -| filesystem root | `DirectoryBrowser` | `@/systems/onboarding` | -| catalog pick | `CommandSelect` | `@agh/ui` | -| fixed enum ≤7 | `NativeSelect` | `@agh/ui` | -| consequence choice 2–4 | `RadioCard` | `@agh/ui` — selected = `bg-surface-glaze` + `line-strong` rim, **never accent** | -| section frame | `FormSection`, `NumberedSection` | `@agh/ui`, `tasks/task-form` | -| settings row | `FieldRow` / `SettingsFieldRow` | `@agh/ui`, `@/systems/settings` | -| notices | `Alert` | `@agh/ui` | -| effort meter | `IntensityMeter` (7 bars) | `@agh/ui` | - -Shadowing any of these in `web/` is a blocked lint error (`compozy-ui-reuse/no-shadow-ui-primitive`). - ---- - -## 3. Shell contract (applies to every surface in §4) - -``` -Dialog (base-ui, modal, focus-trapped, Escape-dismissible, focus returns to opener) -└ DialogContent unframed · dialogShellClass(size) · showCloseButton={false} - ├ EntityDialogHeader (F1) - ├ EntityModeToolbar (F4 — only where Simple/Advanced exists) - ├ body min-h-0 flex-1 overflow-y-auto px-6 py-5 ← sole scroll owner - │ └ FormSection / NumberedSection blocks - └ EntityDialogFooter (F2) -``` - -Geometry (production components, non-negotiable): buttons 22/26/30px at `--radius-md`, 12px/500 labels · inputs / selects / `CommandSelect` triggers 36px on `--color-elevated` · `RuntimeSelector` trigger 34px with `--color-line-strong` border · `PillGroup` segments 24px in a 2px track · switch 18×32 with `--color-fg-strong` thumb · scrim `--color-overlay-scrim` + `--overlay-blur` + `--shadow-overlay` + `--radius-lg`. - ---- - -## 4. Per-surface migration - -Each entry: designed source → production target, host, header copy, body, and the delta to implement. Field → contract bindings are consolidated in §5. - -### 4.1 Create agent — `create-agent.html` - -**Target:** `agent-create-dialog.tsx` (+ `agent-create-runtime-step.tsx`, `agent-create-access-step.tsx`). **Host:** `lg` 880. -**Header:** robot glyph · `Operate · Agent` · *Create agent* · "An agent is a reusable **definition** — instructions plus a runtime. Sessions launch from it and inherit its access policy." - -- Delete the 4-step wizard (`WIZARD_STEPS`, stepper nav, Back/Continue, `Step N of M`) and the plain `
`. -- Mode toolbar: Simple/Advanced + `ScopeSelector` (+ `WorkspaceCommandSelect` when `scope=workspace`). -- **Simple:** `01 The definition` (name, prompt) · `02 Runtime` (`RuntimeSelector`) · `03 What can it do on its own?` (3 `RadioCard`s → `permissions`, with an `Alert` translating the pick into the contract value). -- **Advanced adds:** `04 Runtime details` (command, category path) · `05 Tools & skills` (tools, toolsets, deny tools, disabled skills). -- Per-step `canAdvance` gating becomes field-level validation + a footer submit gate. Name and prompt stay required; nothing else blocks submit. -- **Cut:** the MCP servers multi-select (§5.2 T1). -- Edit parity: `agents.$name.settings.tsx` remains the edit surface; this dialog stays create-only (§14 D3). - -### 4.2 Start session — `start-session.html` - -**Target:** `session-create-dialog.tsx`. **Host:** `sm` 560. -**Header:** play glyph · `Operate · Session` · *Start session* · launcher framing — a session is a run, not a stored record, so there is no edit twin. - -- **Simple:** `Who runs, and where` — `AgentCommandSelect`, `WorkspaceCommandSelect`, session name. -- **Advanced:** `Launch overrides` — `RuntimeSelector` override, network channel, working path. -- Keep the existing `CatalogStatusLine` states (loading / refreshing / stale / error / empty) inside the Runtime field — a Simple view that hides catalog truth is a regression. It stays a status line: **no `Catalog current · Refresh` banner in the dialog chrome**; catalog refresh lives only inside the `RuntimeSelector` popover. - -### 4.3 Add workspace — `add-workspace.html` - -**Target:** `workspace-setup.tsx` (`WorkspaceSetupDialog`). **Host:** `xl` 1180, **split** (F7). -**Header:** layers glyph · `Workspace` · *Add workspace*. - -- Left pane `Location`: `DirectoryBrowser` (home/up toolbar, mono path, "Use this folder", hover-reveal pick, reading/empty states) → selected-root summary card → display name with folder-name autofill. A plain path input is forbidden for root selection. -- Right pane `Session defaults`: default agent (`AgentCommandSelect`), sandbox profile (`CommandSelect`), additional directories. -- **Preserve the production "register the global default workspace" card** (`workspace-setup.tsx:58-89`) — the design does not cover it; keep it as a left-pane shortcut above Location. -- `variant="onboarding"` (`WorkspaceOnboarding`) is out of scope and must keep rendering from the same `WorkspaceSetupContent`. - -### 4.4 / 4.5 Knowledge create + edit — `create-knowledge.html`, `edit-knowledge.html` - -**Targets:** `knowledge-create-dialog.tsx`, `knowledge-edit-dialog.tsx`. **Host:** `sm` 560 (replaces `sm:max-w-2xl`). -**Header:** book glyph · `Catalog · Knowledge` · *Create knowledge entry* / *Edit knowledge entry*. - -- Create: `What kind of knowledge?` (`RadioCard` grid, already correct at `:143`) → `The content` (name, description, content). -- Edit: `ImmutableIdentity` for name + type (retrieval stability), then description + content only. -- Both dialogs hold state locally (`useState`/`useReducer`); lift to the route-owned draft shape so validation and dirty-state gating match the other editors. - -### 4.6 / 4.7 Network channel create + edit — `create-network-channel.html`, `edit-network-channel.html` - -**Targets:** `network-create-channel-dialog.tsx`, `shell/channel-policy-dialog.tsx`. **Host:** `sm` 560. -**Header:** graph glyph · `Network · Channel` · *Create channel* / *Edit channel*. - -- Create: `The channel` (name, purpose) · `Members` (`AgentCommandMultiSelect`) · `Who receives a message?` (fanout `RadioCard`s + coordinator `CommandSelect` revealed only for `coordinator`). -- Edit: name and members render as `ImmutableIdentity` — `UpdateNetworkChannelRequest` cannot mutate them (`network_payloads.go:93-98`). Purpose + fanout + coordinator stay editable. -- Replace the non-ruled `` at `:65` with F1. - -### 4.8 Create bridge — `create-bridge.html` - -**Target:** `bridge-create-dialog.tsx`. **Host:** `lg` 880. -**Header:** link glyph · `Catalog · Bridge` · *Create bridge*. - -- Delete the 3-step wizard; per-step `stepValidity` (`:170`) becomes field validation. -- Mode toolbar: Simple/Advanced + `ScopeSelector`. -- **Simple:** `Where does it connect?` (provider `RadioCard`s from `GET /api/bridges/providers`) · `Identity` (display name) · `Credentials` — one `SecretField` per provider-declared `secret_slots`, rendered dynamically. -- **Advanced:** `Routing & delivery` (DM policy, delivery mode, routing policy, provider config). -- Secrets go through secret bindings only. Never into `provider_config` — the contract rejects secret-shaped JSON there (`ErrUnsafeBridgeProviderConfigPayload`, `bridges.go`). - -### 4.9 Edit bridge — `edit-bridge.html` - -**Target:** `bridge-edit-dialog.tsx`. **Host:** `md` 720 (replaces `sm:max-w-3xl`). - -- Platform / extension / scope → `ImmutableIdentity` (`UpdateBridgeRequest` omits them, `bridges.go:71-81`). -- Credentials → `SecretField` in rotate mode. -- Keep the `?delivery=error` path: the delivery test lives in `bridge-test-delivery-dialog.tsx`; either fold it into an Advanced "Test delivery" action inside this dialog or keep it as a launched sub-dialog — do not render two competing entry points. - -### 4.10 / 4.11 MCP server add + edit — `create-mcp-server.html`, `edit-mcp-server.html` - -**Target:** `mcp-server-editor.tsx`. **Host:** `md` 720. -**Header:** connector glyph · `System · MCP server` · *Add MCP server* / *Edit MCP server*. The eyebrow becomes accent-strong (it is muted today at `:100`). - -- **Simple:** `How does it run?` — transport `RadioCard` (local / remote) revealing command or endpoint URL, plus server name. -- **Advanced:** `Process environment` (args, env, secret env) · `Remote authentication` (OAuth method, issuer, client id, token URL, scopes, client secret) — remote transport only. -- Edit: name + scope → `ImmutableIdentity`; secrets rotate-only and emit `preserve_secrets` / `preserve_env` for untouched bindings. - -### 4.12 Add vault secret — `create-vault-secret.html` - -**Target:** `vault-page.tsx:238` via `SettingsEditorDialog`. **Host:** `sm` 560. -**Header:** lock glyph · `System · Vault` · *Add vault secret*. - -- `The reference` (ref, kind) · `The value` (`SecretField`, write-only). -- Overwrite of an existing ref requires explicit confirmation copy in an `Alert` — `PUT` is an upsert (`vault.go:32-36`). -- Replacement of an existing secret continues to run through `vault-secret-sheet.tsx` (inspect + replace); this dialog is create-only. - -### 4.13 / 4.14 Sandbox profile create + edit — `create-sandbox-profile.html`, `edit-sandbox-profile.html` - -**Target:** `sandbox-page.tsx:252` via `SettingsEditorDialog`. **Host:** `md` 720. -**Header:** cube glyph · `System · Sandbox` · *Create sandbox profile* / *Edit sandbox profile*. - -- **Simple:** `The profile` (name) · `Where does it run?` (backend `RadioCard`: local / Daytona). -- **Advanced:** `Isolation & lifecycle` (sync mode, persistence, runtime root, env, secret env) · `Network policy` (allow/deny lists) · `Daytona workspace` (api url, target, image, snapshot, class, auto stop, auto archive) — Daytona backend only. -- Edit: profile name → `ImmutableIdentity`; secret env → `SecretField` rotation. -- `sandbox-profile-sheet.tsx` stays the inspect surface and must keep launching this editor via `onRequestEdit`. - -### 4.15 / 4.16 Provider create + edit — `create-provider-sheet.html`, `edit-provider-sheet.html` - -**Target:** `provider-detail-dialog.tsx` + `provider-edit-form*.tsx`. **Host:** pending §14 D1. -**Header:** gear glyph · `Settings · Provider` · *Create provider* / *Edit provider*. - -- `Provider basics` (name, display name, command) · `Who owns authentication?` (`RadioCard`: `native_cli` vs `bound_secret`) · `Runtime & models` (harness, runtime provider, transport, base URL, default model, curated models, env policy, home policy). -- **Auth gate is a security boundary:** credential slots and secret writes render **only** for `auth_mode = bound_secret`. Under `native_cli` the provider owns login via `auth_login_command` and AGH must not offer credential inputs (`internal/CLAUDE.md` § Provider auth boundary). -- Rows use `SettingsFieldRow` (17 in create, 11 in edit). -- Edit: name → `ImmutableIdentity`; credential values rotate-only. -- **RuntimeSelector is forbidden here** — a provider sheet configures a provider, it does not choose a runtime. - -### 4.17 Reference surfaces (regression fixes, same wave) - -- **R1 Task** (`task-editor-surface.tsx`): re-apply `EntityDialogHeader` (clipboard glyph · `Autonomy · Task` · *Create task* / *Edit task* · the `TASK_DESCRIPTION` string at `:48`) and delete the in-body description `

` at `:134`. Without this the reference no longer matches the standard it defines. -- **R2 Job/Trigger** (`automation-editor-dialog.tsx`): refactor `EditorHeader` onto F1; behavior unchanged. - ---- - -## 5. Runtime-truth register - -### 5.1 Field → contract - -Verified against the current `internal/api/contract` tree. - -| Surface | Contract | Fields | -|---|---|---| -| Create agent | `CreateAgentRequest` / `CreateAgentPayload` — `agent_observe_payloads.go:21,28` | `scope`, `workspace`, `name`, `provider`, `command`, `model`, `reasoning_effort`, `tools[]`, `toolsets[]`, `deny_tools[]`, `permissions`, `category_path[]`, `skills.disabled[]`, `prompt` | -| Edit agent | `UpdateAgentRequest` — `agent_definitions.go:14` | same payload + **`expected_digest`** (409 conflict path required) | -| Start session | `CreateSessionRequest` — `session_runtime_payloads.go:18` | `agent_name`, `provider`, `model`, `reasoning_effort`, `name`, `workspace`, `workspace_path`, `network_participation` | -| Add workspace | `CreateWorkspaceRequest` — `workspace_payloads.go:6` | `root_dir`, `name`, `add_dirs[]`, `default_agent`, `sandbox_ref` | -| Knowledge create | `MemoryCreateRequest` — `memory.go:118` (`POST /api/memory`) | `scope`, `workspace_id`, `agent_name`, `type`, `name`, `description`, `content` | -| Knowledge edit | `MemoryEditRequest` — `memory.go:136` (`PATCH /api/memory/{filename}`) | `description`, `content` (+ scope keys) | -| Channel create | `CreateNetworkChannelRequest` — `network_payloads.go:83` | `channel`, `workspace_id`, `purpose`, `fanout_policy`, `coordinator_peer_id`, `agent_names[]` | -| Channel edit | `UpdateNetworkChannelRequest` — `network_payloads.go:93` | `purpose`, `fanout_policy`, `coordinator_peer_id` **only** | -| Bridge create | `CreateBridgeRequest` — `bridges.go:13` | `scope`, `workspace_id`, `platform`, `extension_name`, `display_name`, `enabled`, `dm_policy`, `routing_policy`, `provider_config`, `delivery_defaults`, `notification_suppress` | -| Bridge edit | `UpdateBridgeRequest` — `bridges.go:71` | `display_name`, `dm_policy`, `routing_policy`, `provider_config`, `delivery_defaults`, `notification_suppress` | -| Bridge secret | `PutBridgeSecretBindingRequest` — `bridges.go:83` | `secret_ref`, `kind`, `secret_value` (write-only) | -| MCP server | `PutSettingsMCPServerRequest` — `settings_mutations.go:39`; payload `settings_collection_payloads.go:87` | `name`, `transport`, `command`, `args[]`, `env{}`, `secret_env{}`, `url`, `auth{}` + `secret_values`, `preserve_secrets`, `preserve_env` | -| Sandbox profile | `PutSettingsSandboxRequest` — `settings_mutations.go:46`; payload `:181` | `backend`, `sync_mode`, `persistence`, `runtime_root`, `env{}`, `secret_env{}`, `network{}`, `daytona{}` | -| Provider | `PutSettingsProviderRequest` — `settings_mutations.go:33`; payload `:9` | `command`, `display_name`, `models`, `harness`, `runtime_provider`, `transport`, `base_url`, `auth_mode`, `env_policy`, `home_policy`, `auth_status_command`, `auth_login_command`, `credential_slots[]` + `model_curation`, `secrets[]` | -| Vault secret | `PutVaultSecretRequest` — `vault.go:32` | `ref`, `kind`, `secret_value` (write-only) | - -Enums (never invent): permissions `{deny-all, approve-reads, approve-all}` · reasoning `{none, minimal, low, medium, high, xhigh, max}` + provider default · fanout `{capability_match, coordinator, all_members}` · scope `{global, workspace}` with `workspace_id` required iff `scope=workspace` and forbidden otherwise. - -### 5.2 Designed controls with no write contract — cut or gate - -| ID | Control | Finding | Action | -|---|---|---|---| -| **T1** | `create-agent.html` → *MCP servers* multi-select | `CreateAgentPayload` (`agent_observe_payloads.go:28-40`) has **no** `mcp_servers` field. `AgentPayload.MCPServers` (`agent_definitions.go:89`) is read-only, populated on the response path in `internal/api/core/agent_conversions.go:37-72`. There is no authoring route. | Remove the control from the implementation **and** from `create-agent.html`. Re-introducing it requires a contract change first (`agh-contract-codegen-coship`). | -| **T2** | `create-agent.html` → *Category path* as a `/`-joined string | Contract is `category_path []string`. | Keep the single mono input; split/join on `/` at the adapter boundary and document it. | -| **T3** | Provider credential slots under `native_cli` | Security boundary, not a layout choice. | Render slots only when `auth_mode = bound_secret`; under `native_cli` show the login command as read-only guidance. | - -Nothing else in the 16 surfaces renders a field the daemon does not accept. - ---- - -## 6. States - -`STATE-MATRIX.md` is normative. Every listed state needs a visible treatment; a state without one is an incomplete surface. - -- **Dialog:** open, closing, dismissed — named, inert background, trapped focus, deterministic focus return. -- **Form:** pristine, dirty, invalid, saving, save-error, saved. Duplicate submit blocked. **Edit submit is enabled only when dirty** (PATCH contracts reject empty patches via `HasChanges()`). A save error retains every entered value. -- **Catalog** (`RuntimeSelector`, `CommandSelect`): closed, open, searching, loading, refreshing, stale, empty, no-match, error, disabled, read-only, no-model. Drafts survive every catalog state; only dependent controls disable. -- **Secret:** absent, present, editing, invalid, saving, rotated. -- **Optimistic concurrency:** agent `expected_digest` must round-trip and surface a 409 as a "this changed elsewhere — reload" recovery path, not a silent failure. - -Deterministic review routes already supported by `modal-system.js`: `?save=error`, `?auth=error`, `?delivery=error`. Mirror them as Storybook story args. - ---- - -## 7. Accessibility - -- Named, modal, focus-trapped dialogs; Escape dismisses the innermost popup before the dialog; focus returns to the trigger. -- Every field has a visible programmatic label; errors associate via `aria-describedby` and focus moves to the first invalid field on submit. -- `CommandSelect`: button trigger with `aria-controls`, searchable popup, listbox semantics. ArrowDown from search enters the first option. -- `RuntimeSelector`: dialog + combobox + provider-filter radiogroup (arrow keys + Home/End) + model listbox + **separate** favorite toggle keyed by `provider:model` + reasoning `role="group"` with `aria-pressed`, Default separate from the seven efforts, meter rendered by `IntensityMeter` (7 bars — never glyph characters). -- `PillGroup` uses production `role="group"` + `aria-pressed`; do not add roving-arrow behavior production lacks. -- Distinct 2px `focus-visible` indicator on every interactive element. Text contrast ≥ 4.5:1, non-text/focus ≥ 3:1. Color never carries meaning alone. -- Motion uses `--duration-*` / `--ease-*`; reduced motion drops transforms and collapses durations without losing state clarity. - ---- - -## 8. Responsive - -- ≤ 760px: dialogs become full-width bottom surfaces; field grids and selection grids stack; interactive targets reach 44px. -- ≤ 980px: the split shell (F7) collapses to one column with the side pane below. -- 360px must preserve all copy; 200% zoom must retain every control without body-level horizontal scrolling. - ---- - -## 9. Testing - -Test placement first (`consolidate-test-suites`): name the invariant, owning layer, and canonical suite; extend the existing suite rather than adding standalone regressions. - -| Layer | Owns | Suite | -|---|---|---| -| `packages/ui` unit | F1/F2/F5/F6 render + state contracts | `packages/ui/src/components/custom/__tests__/` | -| Web component | per-surface field wiring, Simple/Advanced disclosure, immutable rendering, submit gating | each system's existing `components/__tests__/` | -| Web adapter | request shape per §5.1, `category_path` split/join (T2), preserve-secret signals | each system's `adapters/__tests__/` | -| Storybook | one story per surface × {create, edit, saving, save-error, secret-present} | each system's `components/stories/` — `settings-dialogs.stories.tsx` already pins the shared shell | -| E2E | agent create, bridge create + secret binding, vault write | existing Playwright specs | - -Forbidden: CSS-literal, snapshot, prose, or file-existence tests. Gates: `bunx turbo run lint typecheck test --filter=./web` and `--filter=./packages/ui` during iteration; `make verify` once at completion. - ---- - -## 10. Visual validation - -`VISUAL-VALIDATION.md` is the contract. Implementation-only screenshots are **not** parity evidence. - -- All 16 surfaces at 360×800, 768×900, 1440×900; agent create, bridge create, and the provider surface add 1920×1080. -- Matched reference/implementation pairs at 1100×700 for `RuntimeSelector`, `AgentCommandSelect`, `AgentCommandMultiSelect`, `ScopeSelector`, `WorkspaceCommandSelect`, `CommandSelect`. -- `RuntimeSelector` additionally captures open, stale, no-model, disabled, empty, and error, including the separate favorite toggle and the Default + seven-effort footer. -- Capture through `agh-ui-screenshot` against Storybook (resolve ids with `list-stories.mjs`). Every bundle carries source identity, reference, implementation, side-by-side, diff, comparison JSON, and a passing review with zero unresolved structural mismatches. -- Teardown is mandatory on every terminal path (`eval "$TEARDOWN_COMMAND"` / `make qa-reap`); cite `teardown.json` with `"clean": true`. - ---- - -## 11. AGH Impact Audit - -- **Native tools:** no impact — no `agh__*` tool id, toolset, descriptor, schema digest, risk flag, or capability gate changes. Surfaces checked: `internal/tools`, tool descriptors, capability gates. -- **Extensibility and hooks:** bridge create reads the extension-declared provider catalog (`GET /api/bridges/providers` → `secret_slots`, `config_schema`) and MCP add/edit reads the settings MCP collection — both are consumption of existing extension surfaces, no registry/bundle/hook/config-lifecycle change. `config.toml` keys unchanged. -- **Workspace data isolation:** every scoped surface (agent, bridge, channel, MCP, sandbox, provider, session, workspace) propagates `scope` + `workspace_id` on the existing contracts; the invariant "workspace_id required iff scope=workspace" is enforced in the form, and list/read/cache/SSE paths are untouched by this UI wave. No new cross-workspace read. -- **Official AGH skill:** no impact — no public tool id, CLI path, hook event, capability, bundle, or memory/network/task semantic changes. `skills/agh/` unchanged. - -**QA tracker:** user-visible behavior changes across 16 surfaces. Add `untested` content-addressed scenario files under `docs/qa/scenarios/` for the new Simple/Advanced flows and the secret rotate/replace path; reset `qa_status` to `untested` on existing scenarios covering agent create, bridge create, MCP add, vault write, workspace add, session start, knowledge create/edit, and channel create/edit. Flag, don't retest. - ---- - -## 12. Phasing - -| Phase | Content | Exit gate | -|---|---|---| -| **0 · Foundation** | F1–F7 + reuse register; refactor R2 onto F1 | `packages/ui` stories + tests green; `bunx turbo run lint typecheck test --filter=./packages/ui` | -| **1 · Reference repair** | R1 task header (§4.17) | Task modal matches the standard it defines; capture pair | -| **2 · Shared shell** | `SettingsEditorDialog` upgraded (F1+F2+F3) → lifts vault, sandbox ×2 | Existing `settings-dialogs.stories.tsx` + tests green | -| **3 · Low-risk surfaces** | knowledge ×2, channel ×2, vault, start session | Host tokens applied; no `max-w-*` left on these | -| **4 · Dense surfaces** | agent create (wizard→Simple/Advanced, T1 cut), bridge create/edit, MCP ×2, sandbox ×2 | Step validation preserved as field validation; no submit regression | -| **5 · Provider** | after §14 D1 is answered | Auth gate (T3) proven by test | -| **6 · Close** | full capture matrix, QA scenario flags, `make verify` | Visual bundles reviewed; `make verify` clean; teardown evidence | - -Two-touch rule: if any surface needs a third patch in this wave, it becomes a new TechSpec rather than a third patch. - ---- - -## 13. Delete targets - -Greenfield alpha — hard cuts, no compat shims. - -1. `WIZARD_STEPS`, stepper nav, Back/Continue, and `Step N of M` in `agent-create-dialog.tsx` and `bridge-create-dialog.tsx`. -2. The local `EditorHeader` in `automation-editor-dialog.tsx:144-166` (replaced by F1). -3. The in-body description `

` in `task-editor-surface.tsx:134` (moves into the header). -4. All ad-hoc modal widths: `sm:max-w-2xl` ×2, `sm:max-w-3xl` ×2, `sm:max-w-120`, `max-w-xl` ×2. -5. `web/src/systems/marketplace/components/mcp-secret-field.tsx` (absorbed by F5). -6. The MCP servers multi-select in `create-agent.html` (T1) — design file edited in the same change. -7. Any disabled input used as read-only data display (replaced by F6). - ---- - -## 14. Open decisions - -| ID | Decision | Options | Recommendation | -|---|---|---|---| -| **D1** | Provider host | (a) 576px sheet per the design; (b) keep `provider-detail-dialog.tsx` (tri-mode inspect/edit/create with `LaneTabs`) | **(b)** — production moved to the dialog after the design was drawn, and it owns inspect+edit+create in one surface. Apply the sheet's *body grammar* (`SettingsFieldRow` rows, auth-ownership `RadioCard`s, write-only slots) and F1; update `MODAL-STANDARD.md` § Hosts to match. | -| **D2** | Bridge delivery test | fold into edit-bridge Advanced vs keep `bridge-test-delivery-dialog.tsx` | Fold in — one entry point; delete the standalone dialog. | -| **D3** | Agent edit symmetry | create is a modal, edit is a route (`agents.$name.settings.tsx`) | Keep the asymmetry this wave; the route carries more than a modal should. Revisit separately. | -| **D4** | `NumberedSection` vs `FormSection` | task uses mono ordinals; `FormSection` is a card with no ordinal slot | Add an optional ordinal to `FormSection` and standardize; otherwise the two grammars keep diverging. | -| **D5** | T1 MCP-on-agent | cut permanently vs add `mcp_servers` to `CreateAgentPayload` | Cut now; if the capability is wanted, it is a contract change with CLI/UDS parity, not a UI change. | diff --git a/docs/design/opendesign/modals/MODAL-STANDARD.md b/docs/design/opendesign/modals/MODAL-STANDARD.md deleted file mode 100644 index 515bf0036..000000000 --- a/docs/design/opendesign/modals/MODAL-STANDARD.md +++ /dev/null @@ -1,81 +0,0 @@ -# AGH Modal Standard - -Living authority for the modal library under `docs/design/opendesign/modals/`. - -**Authority chain (higher wins):** - -1. `packages/ui/src/tokens.css`, `DESIGN.md`, `packages/ui/src/index.ts`, and production Web selectors/`Dialog` shells in `web/src/systems` -2. `docs/design/opendesign/design-system/` — especially `patterns.html` § Modals and `GUIDE.md` -3. This file + the 16 runnable surfaces in this folder (`index.html`, `modal-system.css` / `modal-system.js`, `verify.mjs`) - -Historical Tier-1 artboards (`create-{task,trigger,job}-redesign.html`) stay as research references; production Task/Job/Trigger editors are the live Tier-1. - -## Frame - -- Register: Product. -- Scene: an operator configures a runtime entity on a dark desktop control surface, scanning dense but calm forms while retaining a complete mobile path. -- Dials: visual variance 2, motion intensity 2, information density 8. -- Static artifacts may transcribe daemon-backed controls and existing catalog integrations. They must not invent runtime behavior. - -## Hosts and shell - -| Host | Width | Use | -| --- | ---: | --- | -| Compact dialog | `--width-modal-sm` / 560px | Short, focused task | -| Standard dialog | `--width-modal-md` / 720px | Default configuration flow | -| Wide dialog | `--width-modal-lg` / 880px | Dense policy/catalog composition | -| Extra-wide host | `--width-modal-xl` / 1180px | Workspace-scale task that cannot preserve comprehension at 880px | -| Wizard host | `--height-modal-wizard` / 960px max | Ordered, validated steps for one irreversible task | -| Provider sheet | 576px | Persistent provider settings | -| ConfirmDialog | `--width-modal-sm` / 560px | Destructive or irreversible confirmation | - -- Use `--color-overlay-scrim`, `--overlay-blur`, `--shadow-overlay`, and `--radius-lg` exactly. -- Header pattern (production `task-editor-modal.tsx`): a 36px icon well (`--radius-icon-well`, `--color-accent-tint`, inset `--color-accent-dim` ring, 16px glyph) beside an accent-strong `Eyebrow`, a 15px/500 `DialogTitle` (`--tracking-tight`), and a 13px muted description. Entity editor modals and provider sheets use it; ConfirmDialog uses a neutral well or a plain title. The icon well is the only accent-tinted surface in the shell. The body is the only scroll owner. The footer stays stable and contains one primary action. -- Control geometry (production components): buttons 22/26/30px at `--radius-md` and 12px/500 labels; inputs, selects, and CommandSelect triggers 36px on `--color-elevated`; RuntimeSelector trigger 34px with a `--color-line-strong` border; PillGroup segments 24px in a 2px track; switch 18×32 with a `--color-fg-strong` thumb. -- Simple shows the common path. Advanced is the only disclosure tier and never hides required configuration. -- At 760px and below, dialogs become full-width bottom surfaces; fields and selection grids stack and interactive targets reach 44px. -- Use a route or sheet instead of a modal for browse-heavy, long-lived, multi-entity, or cross-navigation work. - -## Component grammar - -- `RuntimeSelector`: provider, model, and reasoning are one segmented control. Provider filters remain a radiogroup (active item carries a 2px accent bar). Reasoning is a `role="group"` of `aria-pressed` buttons: Default is separate from `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (displayed as Extra high), and `max`; effort intensity renders as the production 7-bar meter (2.5px bars, 3→12px, `--color-accent-strong` fill), never as glyph characters. Inside the popup the selected model row uses `--color-accent-tint` with an accent-strong check, and the pressed effort uses `--color-accent-tint-strong` — this runtime-catalog accent selection is production behavior and does not extend to RadioCard. The selected model's favorite toggle is a separate button keyed by `provider:model` (pressed state is `--color-warning`); never nest it in an option. Do not rebuild the provider/model/reasoning trio. -- `AgentCommandSelect` / `AgentCommandMultiSelect`: searchable agent catalogs with provider and category metadata. Native agent ` + + + + + + + + +

+ + +
+
+ Default model + +
+
+ + +
+
+ + + + + + +
+ + + + diff --git a/docs/design/opendesign/os/onboarding.html.artifact.json b/docs/design/opendesign/os/onboarding.html.artifact.json new file mode 100644 index 000000000..21c11f34a --- /dev/null +++ b/docs/design/opendesign/os/onboarding.html.artifact.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "kind": "html", + "title": "os/onboarding.html", + "entry": "os/onboarding.html", + "renderer": "html", + "status": "complete", + "exports": [ + "html", + "pdf", + "zip" + ], + "createdAt": "2026-07-24T22:30:45.656Z", + "updatedAt": "2026-07-24T22:30:45.656Z", + "metadata": { + "inferred": true, + "reconciled": true + } +} \ No newline at end of file diff --git a/docs/design/opendesign/os/onboarding.js b/docs/design/opendesign/os/onboarding.js new file mode 100644 index 000000000..70314c97f --- /dev/null +++ b/docs/design/opendesign/os/onboarding.js @@ -0,0 +1,863 @@ +/* ============================================================ + AGH OS — first-run onboarding + Two steps, mirroring web/src/systems/onboarding: + 1. default model → RuntimeSelector + auth mode (+ bound secret) + 2. workspaces → /api/fs/browse directory browser + selection + The shell renders behind, inert, and wakes when setup commits. + Catalog + selector logic transcribed from + _done/agents/provider-model-reasoning-selector.html. + ============================================================ */ +(() => { +'use strict'; + +const $ = (sel, el = document) => el.querySelector(sel); +const $$ = (sel, el = document) => [...el.querySelectorAll(sel)]; +const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +/* ============================================================ + SHELL BACKDROP — dock mirrors app-sidebar.tsx (os-v2 parity) + ============================================================ */ +const ICONS = { + dashboard: '', + sessions: '', + agents: '', + tasks: '', + marketplace: '', + network: '', + vault: '', + loops: '', + jobs: '', + triggers: '', + bridges: '', + knowledge: '', + sandbox: '', +}; +const DOCK_ORDER = [ + 'sessions', 'dashboard', + 'agents', 'network', 'tasks', 'loops', 'jobs', 'triggers', 'sep', + 'marketplace', 'bridges', 'knowledge', 'sep', + 'sandbox', 'vault', +]; +const DOCK_LABEL = { + sessions: 'Sessions', dashboard: 'Dashboard', agents: 'Agents', network: 'Network', + tasks: 'Tasks', loops: 'Loops', jobs: 'Jobs', triggers: 'Triggers', + marketplace: 'Marketplace', bridges: 'Bridges', knowledge: 'Knowledge', + sandbox: 'Sandbox', vault: 'Vault', +}; + +const dockEl = $('#dock'); +const dockZone = $('#dockZone'); +function buildDock() { + dockEl.innerHTML = DOCK_ORDER.map((id, i) => { + if (id === 'sep') return ''; + return ``; + }).join(''); +} +buildDock(); + +/* ============================================================ + CATALOG — mirrors internal/config/provider.go + model-catalog + ============================================================ */ +const HARNESS = { acp: 'cli', pi_acp: 'api key' }; +const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; +const EFFORT_LABEL = { none: 'None', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' }; +const effortPos = (e) => { const i = EFFORT_ORDER.indexOf(e); return i < 0 ? 0 : i + 1; }; + +const PROVIDERS = [ + { id: 'claude', name: 'Claude Code', harness: 'acp', auth: 'ready' }, + { id: 'codex', name: 'Codex', harness: 'acp', auth: 'ready' }, + { id: 'gemini', name: 'Gemini CLI', harness: 'acp', auth: 'ready' }, + { id: 'opencode', name: 'OpenCode', harness: 'acp', auth: 'ready' }, + { id: 'cursor', name: 'Cursor Agent', harness: 'acp', auth: 'ready' }, + { id: 'openrouter', name: 'OpenRouter', harness: 'pi_acp', auth: 'ready' }, + { id: 'xai', name: 'xAI', harness: 'pi_acp', auth: 'ready' }, + { id: 'groq', name: 'Groq', harness: 'pi_acp', auth: 'ready' }, + { id: 'moonshot', name: 'Moonshot', harness: 'pi_acp', auth: 'ready' }, + { id: 'zai', name: 'z.ai', harness: 'pi_acp', auth: 'signin' }, +]; +const PROV = {}; PROVIDERS.forEach(p => { PROV[p.id] = p; }); + +const MODELS = [ + { id: 'opus-4.6', provider: 'claude', name: 'Opus 4.6', ctx: 200000, cin: 15, cout: 75, tools: true, reasoning: true, efforts: [], avail: 'live', fav: true, recent: true }, + { id: 'sonnet-4.6', provider: 'claude', name: 'Sonnet 4.6', ctx: 200000, cin: 3, cout: 15, tools: true, reasoning: true, efforts: [], avail: 'live', recent: true }, + { id: 'haiku-4.5', provider: 'claude', name: 'Haiku 4.5', ctx: 200000, cin: 1, cout: 5, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'gpt-5.4', provider: 'codex', name: 'GPT-5.4', ctx: 400000, cin: 1.25, cout: 10, tools: true, reasoning: true, efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'], def: 'medium', src: 'config', avail: 'live', fav: true, recent: true }, + { id: 'gpt-5.4-mini', provider: 'codex', name: 'GPT-5.4 Mini', ctx: 400000, cin: 0.25, cout: 2, tools: true, reasoning: true, efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'], def: 'low', src: 'config', avail: 'live' }, + { id: 'gpt-5.3', provider: 'codex', name: 'GPT-5.3', ctx: 256000, cin: 1.1, cout: 9, tools: true, reasoning: true, efforts: ['low', 'medium', 'high', 'xhigh'], def: 'medium', src: 'config', avail: 'stale' }, + { id: 'gemini-2.5-pro', provider: 'gemini', name: 'Gemini 2.5 Pro', ctx: 1000000, cin: 1.25, cout: 10, tools: true, reasoning: true, efforts: [], avail: 'live' }, + { id: 'oc-gpt-5.4', provider: 'opencode', name: 'GPT-5.4', ctx: 400000, cin: null, cout: null, tools: true, reasoning: true, efforts: ['low', 'medium', 'high'], def: 'medium', src: 'acp', avail: 'live' }, + { id: 'oc-sonnet', provider: 'opencode', name: 'Sonnet 4.6', ctx: 200000, cin: null, cout: null, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'cursor-auto', provider: 'cursor', name: 'Auto', ctx: null, cin: null, cout: null, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'or-deepseek-r1', provider: 'openrouter', name: 'DeepSeek R1', ctx: 128000, cin: 0.55, cout: 2.2, tools: true, reasoning: true, efforts: [], avail: 'live' }, + { id: 'or-llama-3.3', provider: 'openrouter', name: 'Llama 3.3 70B', ctx: 128000, cin: 0.12, cout: 0.3, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'or-qwen-2.5', provider: 'openrouter', name: 'Qwen 2.5 Coder', ctx: 128000, cin: 0.18, cout: 0.5, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'grok-4', provider: 'xai', name: 'Grok 4', ctx: 256000, cin: 3, cout: 15, tools: true, reasoning: true, efforts: [], avail: 'live' }, + { id: 'groq-llama-3.3', provider: 'groq', name: 'Llama 3.3 70B', ctx: 128000, cin: 0.59, cout: 0.79, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'groq-r1-distill', provider: 'groq', name: 'R1 Distill 70B', ctx: 128000, cin: 0.75, cout: 0.99, tools: true, reasoning: true, efforts: [], avail: 'live' }, + { id: 'kimi-k2', provider: 'moonshot', name: 'Kimi K2', ctx: 256000, cin: 0.6, cout: 2.5, tools: true, reasoning: false, efforts: [], avail: 'live' }, + { id: 'glm-4.6', provider: 'zai', name: 'GLM-4.6', ctx: 200000, cin: 0.6, cout: 2.2, tools: true, reasoning: false, efforts: [], avail: 'unavailable', reason: 'Sign in' }, +]; +const MODEL = {}; MODELS.forEach(m => { MODEL[m.id] = m; }); + +/* ---- provider marks (packages/ui/src/logos) ---- */ +const LOGO = { + claude: { vb: '0 0 256 257', inner: '' }, + codex: { vb: '0 0 721 721', inner: '' }, + gemini: { vb: '0 0 296 298', inner: '' }, + cursor: { vb: '0 0 466.73 532.09', inner: '' }, + openrouter: { vb: '0 0 24 24', inner: '' }, + xai: { vb: '0 0 841.89 595.28', inner: '' }, + groq: { vb: '0 0 201 201', fill: true, inner: '' }, + moonshot: { vb: '0 0 1024 1024', inner: '' }, + zai: { vb: '0 0 30 30', inner: '' }, +}; +function glyphSVG(id) { + const L = LOGO[id]; + if (!L) return ''; + return `${L.inner}`; +} +const glyphTile = (cls, id) => `${glyphSVG(id)}`; + +function icon(name) { + const p = { + chev: '', + warn: '', + tools: '', + brain: '', + plus: '', + star: '', + gear: '', + all: '', + }[name]; + return `${p}`; +} +const starSvg = (fill) => ``; + +const fmtCtx = (t) => t == null ? null : t >= 1e6 ? `${t / 1e6}M` : t >= 1000 ? `${Math.round(t / 1000)}k` : String(t); +const fmtCost = (m) => m.cin == null ? null : `$${m.cin}/${m.cout}`; +function imHTML(level, hollow) { + let out = ``; + for (let i = 1; i <= 7; i++) out += ``; + return `${out}`; +} +function reasoningStateFor(m) { + if (m.efforts && m.efforts.length) return { mode: 'levels', levels: m.efforts, def: m.def || m.efforts[0], src: m.src }; + if (m.reasoning) return { mode: 'supported-nolevels' }; + return { mode: 'none' }; +} + +/* ============================================================ + REASONING RANGE SELECTOR (runtime-selector/reasoning-bar.tsx) + ============================================================ */ +function makeSlider(host, cfg) { + const order = (cfg.levels || []).slice().sort((a, b) => effortPos(a) - effortPos(b)); + const values = [''].concat(order); + const last = values.length - 1; + const el = document.createElement('div'); + el.className = 'rz'; + let labels = '', stops = ''; + values.forEach((v, i) => { + const p = last > 0 ? i / last : 0; + const pos = `left:calc(${p}*(100% - var(--rz-h)) + var(--rz-h)/2)`; + let lpos = pos, edge = ''; + if (i === 0) { lpos = 'left:0'; edge = ' data-edge="start"'; } + else if (i === last) { lpos = 'right:0'; edge = ' data-edge="end"'; } + labels += ``; + stops += ``; + }); + el.innerHTML = `
${labels}
` + + `
` + + `
${stops}
`; + host.innerHTML = ''; + host.appendChild(el); + + const track = $('.rz__track', el); + const labelEls = $$('.rz__label', el); + let idx = Math.max(0, values.indexOf(cfg.value || '')); + + const markNearest = (i) => { + el.setAttribute('data-default', i === 0 ? 'true' : 'false'); + labelEls.forEach((l, k) => l.setAttribute('data-on', k === i ? 'true' : 'false')); + }; + const paint = () => { + el.style.setProperty('--rz-p', last > 0 ? idx / last : 0); + markNearest(idx); + track.setAttribute('aria-valuenow', String(idx)); + track.setAttribute('aria-valuetext', idx === 0 ? `Provider default (${EFFORT_LABEL[cfg.def] || 'auto'})` : EFFORT_LABEL[values[idx]]); + }; + const commit = (i) => { + i = Math.max(0, Math.min(last, i)); + const changed = i !== idx; + idx = i; paint(); + if (changed) cfg.onChange?.(values[idx]); + }; + labelEls.forEach(l => l.addEventListener('click', () => { commit(parseInt(l.dataset.idx, 10)); track.focus(); })); + + const pFrom = (ev) => { + const r = track.getBoundingClientRect(), pad = r.height / 2; + return Math.max(0, Math.min(1, (ev.clientX - r.left - pad) / Math.max(1, r.width - 2 * pad))); + }; + let drag = null; + track.addEventListener('pointerdown', ev => { + if (ev.button) return; + drag = { sx: ev.clientX, engaged: false }; + track.setPointerCapture(ev.pointerId); + }); + track.addEventListener('pointermove', ev => { + if (!drag) return; + if (!drag.engaged) { + if (Math.abs(ev.clientX - drag.sx) < 3) return; + drag.engaged = true; el.classList.add('is-dragging'); + } + const p = pFrom(ev); + el.style.setProperty('--rz-p', p); + markNearest(Math.round(p * last)); + }); + track.addEventListener('pointerup', ev => { + if (!drag) return; + drag = null; el.classList.remove('is-dragging'); + commit(Math.round(pFrom(ev) * last)); + track.focus(); + }); + track.addEventListener('pointercancel', () => { if (drag) { drag = null; el.classList.remove('is-dragging'); paint(); } }); + track.addEventListener('keydown', e => { + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); commit(idx + 1); } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); commit(idx - 1); } + else if (e.key === 'Home') { e.preventDefault(); commit(0); } + else if (e.key === 'End') { e.preventDefault(); commit(last); } + }); + paint(); +} + +/* ============================================================ + ONBOARDING DRAFT (stores/use-onboarding-draft-store.ts) + ============================================================ */ +const draft = { + step: 1, + maxStep: 1, + provider: 'claude', + model: 'opus-4.6', + reasoning: '', + authMode: 'native_cli', + envVar: '', + apiKey: '', + workspaces: [], +}; +const envVarFor = (id) => `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`; +const defaultAuthFor = (id) => (PROV[id]?.harness === 'pi_acp' ? 'bound_secret' : 'native_cli'); + +/* ============================================================ + RUNTIME SELECTOR — trigger + popup + ============================================================ */ +const LS = { fav: 'agh:pmr:fav', recent: 'agh:pmr:recent' }; +const lsLoad = (k, d) => { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; } catch { return d; } }; +const lsSave = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } }; +let favSet = lsLoad(LS.fav, {}); +let recentList = lsLoad(LS.recent, null) || MODELS.filter(m => m.recent).map(m => m.id); +MODELS.forEach(m => { if (m.fav) favSet[m.id] = true; }); + +const trigger = document.createElement('div'); +trigger.className = 'pmr'; +trigger.setAttribute('role', 'group'); +trigger.setAttribute('data-open', 'false'); +trigger.setAttribute('aria-labelledby', 'obRuntimeLabel'); +trigger.setAttribute('data-od-id', 'pmr-trigger'); +$('#obRuntimeMount').appendChild(trigger); + +function renderTrigger() { + const m = MODEL[draft.model], p = PROV[m.provider]; + const rz = reasoningStateFor(m); + const needsAuth = p.auth !== 'ready' || m.avail === 'unavailable'; + let html = `` + + ``; + if (rz.mode === 'levels') { + const cur = draft.reasoning || rz.def; + const isDefault = !draft.reasoning; + html += ``; + } + if (needsAuth) { + const why = m.reason || 'Needs sign in'; + html += `${icon('warn')}`; + } + html += ``; + trigger.innerHTML = html; + $$('[data-focus]', trigger).forEach(seg => { + seg.addEventListener('click', e => { e.stopPropagation(); openPopup(seg.dataset.focus); }); + }); +} + +function renderFacts() { + const m = MODEL[draft.model], p = PROV[m.provider]; + const parts = []; + const ctx = fmtCtx(m.ctx); if (ctx) parts.push(`${ctx} context`); + const cost = fmtCost(m); if (cost) parts.push(`${cost} per Mtok`); + if (m.tools) parts.push('tool calls'); + const rz = reasoningStateFor(m); + if (rz.mode === 'levels') parts.push(`${m.efforts.length} reasoning levels`); + else if (rz.mode === 'supported-nolevels') parts.push('reasoning on'); + parts.push(`${HARNESS[p.harness]} harness`); + $('#obFacts').innerHTML = parts.join(''); +} + +/* ---- popup ---- */ +const pop = $('#rsel'); +const popList = $('#rselList'); +const popRail = $('#rselRail'); +const popRz = $('#rselRz'); +const popSearch = $('#rselSearch'); +const popRefresh = $('#rselRefresh'); +let popOpen = false, railFilter = 'all', query = '', hlIndex = -1; + +function openPopup(focus) { + popOpen = true; railFilter = 'all'; query = ''; popSearch.value = ''; hlIndex = -1; + trigger.setAttribute('data-open', 'true'); + renderRail(); renderList(); renderRz(); + positionPopup(); + pop.setAttribute('data-show', 'true'); + setTimeout(() => { + if (focus === 'reasoning') { const t = $('.rz__track', popRz); if (t) return t.focus(); } + if (focus === 'provider') { const a = $(`.rail__item[data-rail="${draft.provider}"]`, popRail); if (a) return a.focus(); } + popSearch.focus(); + }, 20); + document.addEventListener('mousedown', onDocDown, true); +} +function closePopup() { + if (!popOpen) return; + popOpen = false; + trigger.setAttribute('data-open', 'false'); + pop.setAttribute('data-show', 'false'); + document.removeEventListener('mousedown', onDocDown, true); +} +function onDocDown(e) { if (!pop.contains(e.target) && !trigger.contains(e.target)) closePopup(); } + +function positionPopup() { + const r = trigger.getBoundingClientRect(); + const shown = pop.getAttribute('data-show') === 'true'; + pop.style.visibility = 'hidden'; pop.setAttribute('data-show', 'true'); + const pw = pop.offsetWidth, ph = pop.offsetHeight; + pop.style.visibility = ''; + if (!shown) pop.setAttribute('data-show', 'false'); + let left = r.left, top = r.bottom + 6; + left = Math.max(12, Math.min(left, window.innerWidth - pw - 12)); + if (top + ph > window.innerHeight - 12) top = Math.max(12, r.top - ph - 6); + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; +} + +const railBtn = (id, svg, title, on) => ``; +function renderRail() { + const searching = query.trim().length > 0; + let html = railBtn('all', icon('all'), 'All models', railFilter === 'all') + + railBtn('fav', starSvg(false), 'Favorites', railFilter === 'fav') + + '
'; + PROVIDERS.forEach(p => { + const dim = p.auth !== 'ready'; + html += ``; + }); + html += ``; + popRail.innerHTML = html; + popRail.style.opacity = searching ? '.45' : '1'; + popRail.style.pointerEvents = searching ? 'none' : 'auto'; + $$('.rail__item', popRail).forEach(b => b.addEventListener('click', () => { + const v = b.dataset.rail; + if (v === '__settings') { b.style.color = 'var(--accent-strong)'; setTimeout(() => { b.style.color = ''; }, 500); return; } + railFilter = v; hlIndex = -1; renderRail(); renderList(); popSearch.focus(); + })); +} + +function matchQ(m, p) { + const q = query.trim().toLowerCase(); + if (!q) return true; + const hay = `${m.name} ${p.name} ${m.id} ${m.provider}`.toLowerCase(); + return q.split(/\s+/).every(tok => hay.includes(tok)); +} +function providerAvail(p, ms) { + if (p.auth === 'signin') return { cls: 'av-off', label: 'Sign in' }; + if (ms.some(m => m.avail === 'stale')) return { cls: 'av-stale', label: 'Stale' }; + return { cls: 'av-live', label: 'Live' }; +} +function groupHead(name, harness, avail) { + let h = `
${esc(name)}`; + if (harness) h += `${HARNESS[harness]}`; + if (avail) h += `${avail.label}`; + return `${h}
`; +} +function rowHTML(m) { + const p = PROV[m.provider]; + const sel = draft.model === m.id; + const disabled = m.avail === 'unavailable' || p.auth === 'signin'; + let chips = ''; + const ctx = fmtCtx(m.ctx); if (ctx) chips += `${ctx}`; + const cost = fmtCost(m); if (cost) chips += `${chips ? '' : ''}${cost}`; + if (m.tools) chips += `${chips ? '' : ''}${icon('tools')}tools`; + if (m.efforts && m.efforts.length) chips += `${chips ? '' : ''}${icon('brain')}${m.efforts.length} levels`; + else if (m.reasoning) chips += `${chips ? '' : ''}${icon('brain')}reasoning`; + let right = disabled ? `${esc(m.reason || 'Sign in')}` : ''; + right += `${starSvg(!!favSet[m.id])}`; + return `
` + + glyphTile('mrow__glyph', m.provider) + + `${esc(m.name)}` + + `${esc(p.name)}${chips}` + + `${right}
`; +} +function renderList() { + const searching = query.trim().length > 0; + let html = '', any = false; + if (!searching && (railFilter === 'all' || railFilter === 'fav')) { + const favModels = MODELS.filter(m => favSet[m.id]); + const seen = new Set(); + const pinned = (railFilter === 'fav' ? favModels : recentList.map(id => MODEL[id]).filter(Boolean).concat(favModels)) + .filter(m => m && !seen.has(m.id) && seen.add(m.id)); + if (pinned.length) { + html += groupHead(railFilter === 'fav' ? 'Favorites' : 'Recent & favorites'); + pinned.forEach(m => { html += rowHTML(m); any = true; }); + } + if (railFilter === 'fav') return finishList(html, any); + } + const provs = PROVIDERS.filter(p => (railFilter === 'all' || railFilter === 'fav') ? true : p.id === railFilter); + provs.forEach(p => { + const ms = MODELS.filter(m => m.provider === p.id && matchQ(m, p)); + if (!ms.length) return; + html += groupHead(p.name, p.harness, providerAvail(p, ms)); + ms.forEach(m => { html += rowHTML(m); any = true; }); + }); + if (!any) html = `
No models match “${esc(query.trim())}”.
Try a provider name, or a shorter query.
`; + finishList(html, any); +} +function finishList(html, any) { + popList.innerHTML = html; + if (!any) return; + $$('.mrow', popList).forEach(row => { + const id = row.dataset.model; + row.addEventListener('click', e => { + if (e.target.closest('.mrow__star')) return; + if (row.dataset.disabled === 'true') return; + pickModel(id); + }); + const star = $('.mrow__star', row); + star?.addEventListener('click', e => { + e.stopPropagation(); + if (favSet[id]) delete favSet[id]; else favSet[id] = true; + lsSave(LS.fav, favSet); renderList(); + }); + }); + paintHighlight(); +} +function renderRz() { + const m = MODEL[draft.model]; + const rz = reasoningStateFor(m); + if (rz.mode === 'none') { + popRz.innerHTML = `
${icon('brain')}This model doesn’t use reasoning effort; the provider handles it.
`; + return; + } + if (rz.mode === 'supported-nolevels') { + popRz.innerHTML = `
${icon('brain')}Reasoning is on. ${esc(PROV[m.provider].name)} doesn’t expose selectable effort for ${esc(m.name)}.
`; + return; + } + popRz.innerHTML = `
Reasoning effort` + + `${esc(PROV[m.provider].name)} · ${esc(m.name)}` + + `${rz.src === 'acp' ? 'ACP' : 'catalog'}
`; + makeSlider($('.rzbar__slider', popRz), { + levels: rz.levels, def: rz.def, value: draft.reasoning, model: m.name, + onChange: (v) => { draft.reasoning = v || ''; renderTrigger(); renderFacts(); syncFooter(); positionPopup(); }, + }); +} +function pickModel(id) { + const m = MODEL[id]; + if (!m) return; + const providerChanged = m.provider !== draft.provider; + draft.provider = m.provider; + draft.model = id; + const rz = reasoningStateFor(m); + if (rz.mode !== 'levels' || (draft.reasoning && !rz.levels.includes(draft.reasoning))) draft.reasoning = ''; + // provider change clears bound credentials (use-onboarding-default-model.ts) + if (providerChanged) { + draft.envVar = ''; draft.apiKey = ''; + draft.authMode = defaultAuthFor(draft.provider); + syncAuth(); + } + const seen = new Set(); + recentList = [id, ...recentList].filter(x => !seen.has(x) && seen.add(x)).slice(0, 6); + lsSave(LS.recent, recentList); + renderList(); renderRz(); renderTrigger(); renderFacts(); syncFooter(); positionPopup(); +} +function paintHighlight() { + const rows = $$('.mrow', popList); + rows.forEach((r, i) => r.setAttribute('data-hl', i === hlIndex ? 'true' : 'false')); + const r = rows[hlIndex]; + if (!r) return; + const top = r.offsetTop, h = r.offsetHeight, st = popList.scrollTop, vh = popList.clientHeight; + if (top < st) popList.scrollTop = top - 6; + else if (top + h > st + vh) popList.scrollTop = top + h - vh + 6; +} +function moveHL(dir) { + const rows = $$('.mrow', popList); + const enabled = rows.map((r, i) => r.dataset.disabled !== 'true' ? i : -1).filter(i => i >= 0); + if (!enabled.length) return; + if (hlIndex < 0) hlIndex = enabled[0]; + else { + const pos = enabled.indexOf(hlIndex); + hlIndex = pos < 0 ? enabled[0] : enabled[(pos + dir + enabled.length) % enabled.length]; + } + paintHighlight(); +} +popSearch.addEventListener('input', () => { query = popSearch.value; hlIndex = -1; renderRail(); renderList(); }); +popRefresh.addEventListener('click', () => { + popRefresh.classList.add('spin'); + setTimeout(() => { popRefresh.classList.remove('spin'); renderList(); }, 520); +}); +window.addEventListener('resize', () => { if (popOpen) positionPopup(); }); + +/* ============================================================ + STEP 1 — authentication mode + ============================================================ */ +const authWrap = $('#obAuth'); +const keyFields = $('#obKeyFields'); +const envInput = $('#obEnvVar'); +const keyInput = $('#obApiKey'); + +function syncAuth() { + $$('.authc', authWrap).forEach(b => b.setAttribute('aria-pressed', String(b.dataset.auth === draft.authMode))); + keyFields.hidden = draft.authMode !== 'bound_secret'; + envInput.placeholder = envVarFor(draft.provider); + envInput.value = draft.envVar; + keyInput.value = draft.apiKey; + measurePane(); +} +$$('.authc', authWrap).forEach(b => b.addEventListener('click', () => { + draft.authMode = b.dataset.auth; + if (draft.authMode === 'native_cli') { draft.envVar = ''; draft.apiKey = ''; } + syncAuth(); syncFooter(); + if (draft.authMode === 'bound_secret') envInput.focus(); +})); +envInput.addEventListener('input', () => { draft.envVar = envInput.value; syncFooter(); }); +keyInput.addEventListener('input', () => { draft.apiKey = keyInput.value; syncFooter(); }); + +/* ============================================================ + STEP 2 — directory browser (GET /api/fs/browse, dirs_only) + ============================================================ */ +const HOME = '/Users/pedro'; +const FS = { + '/': ['Applications', 'Library', 'System', 'Users'], + '/Users': ['pedro', 'Shared'], + '/Users/pedro': ['Desktop', 'Developer', 'Documents', 'Downloads', 'Projects'], + '/Users/pedro/Desktop': [], + '/Users/pedro/Developer': ['compozy', 'courses', 'labs', 'oss'], + '/Users/pedro/Developer/compozy': ['agh', 'looper', 'pi', 'releasepr'], + '/Users/pedro/Developer/compozy/agh': ['cmd', 'internal', 'packages', 'web'], + '/Users/pedro/Developer/compozy/looper': [], + '/Users/pedro/Developer/compozy/pi': [], + '/Users/pedro/Developer/compozy/releasepr': [], + '/Users/pedro/Developer/courses': ['branas'], + '/Users/pedro/Developer/courses/branas': ['branasio'], + '/Users/pedro/Developer/courses/branas/branasio': [], + '/Users/pedro/Developer/labs': ['sketches'], + '/Users/pedro/Developer/labs/sketches': [], + '/Users/pedro/Developer/oss': [], + '/Users/pedro/Documents': ['notes'], + '/Users/pedro/Documents/notes': [], + '/Users/pedro/Downloads': [], + '/Users/pedro/Projects': ['field-notes'], + '/Users/pedro/Projects/field-notes': [], + '/Applications': [], '/Library': [], '/System': [], '/Users/Shared': [], + '/Users/pedro/Developer/compozy/agh/cmd': [], '/Users/pedro/Developer/compozy/agh/internal': [], + '/Users/pedro/Developer/compozy/agh/packages': [], '/Users/pedro/Developer/compozy/agh/web': [], +}; +const parentOf = (p) => (p === '/' ? null : p.replace(/\/[^/]+$/, '') || '/'); +const basename = (p) => p.replace(/\/+$/, '').split('/').pop() || p; + +let cwd = HOME; +const dbPath = $('#dbPath'); +const dbList = $('#dbList'); +const dbUse = $('#dbUse'); +const wsListEl = $('#wsList'); +const wsEmpty = $('#wsEmpty'); +const wsCount = $('#wsCount'); + +const isAdded = (p) => draft.workspaces.some(w => w.path === p); + +function renderBrowser() { + dbPath.textContent = cwd; + dbPath.title = cwd; + $('#dbHome').disabled = cwd === HOME; + $('#dbUp').disabled = parentOf(cwd) === null; + dbUse.disabled = isAdded(cwd); + const entries = FS[cwd] || []; + if (!entries.length) { + dbList.innerHTML = '

No sub-folders here. Use this folder, or step back up.

'; + return; + } + dbList.innerHTML = entries.map(name => { + const path = cwd === '/' ? `/${name}` : `${cwd}/${name}`; + const added = isAdded(path); + return `
+ + +
`; + }).join(''); + $$('[data-nav]', dbList).forEach(b => b.addEventListener('click', () => { cwd = b.dataset.nav; renderBrowser(); })); + $$('[data-add]', dbList).forEach(b => b.addEventListener('click', () => addWorkspace(b.dataset.add))); +} +$('#dbHome').addEventListener('click', () => { cwd = HOME; renderBrowser(); }); +$('#dbUp').addEventListener('click', () => { const p = parentOf(cwd); if (p) { cwd = p; renderBrowser(); } }); +dbUse.addEventListener('click', () => addWorkspace(cwd)); + +function addWorkspace(path) { + if (!path || isAdded(path)) return; + draft.workspaces.push({ path, name: basename(path) }); + renderBrowser(); renderWorkspaces(); syncFooter(); syncShellWorkspace(); +} +function removeWorkspace(path) { + draft.workspaces = draft.workspaces.filter(w => w.path !== path); + renderBrowser(); renderWorkspaces(); syncFooter(); syncShellWorkspace(); +} +function renderWorkspaces() { + const n = draft.workspaces.length; + wsCount.textContent = `${n} folder${n === 1 ? '' : 's'}`; + wsEmpty.hidden = n > 0; + wsListEl.hidden = n === 0; + wsListEl.innerHTML = draft.workspaces.map(w => `
  • + + ${esc(w.name)}${esc(w.path)} +
  • `).join(''); + $$('[data-rm]', wsListEl).forEach(b => b.addEventListener('click', () => removeWorkspace(b.dataset.rm))); +} +/* the menu bar fills in behind the scrim as soon as a workspace resolves */ +function syncShellWorkspace() { + const first = draft.workspaces[0]; + const trig = $('#wsTrigger'); + $('#wsName').textContent = first ? first.name : 'No workspace'; + $('#wsAvatar').textContent = first ? first.name.slice(0, 2).toUpperCase() : '··'; + trig.dataset.empty = String(!first); +} + +/* ============================================================ + WIZARD (hooks/use-onboarding-wizard.ts) + ============================================================ */ +const ob = $('#ob'); +const obBody = $('#obBody'); +const backBtn = $('#obBack'); +const nextBtn = $('#obNext'); +const sumEl = $('#obSum'); +const sumLabel = $('#obSumLabel'); +const sumValue = $('#obSumValue'); + +const paneOf = (n) => $(`.ob__pane[data-pane="${n}"]`); + +function configurationError() { + if (draft.step !== 1) return null; + if (draft.authMode === 'bound_secret' && draft.envVar.trim() === '') { + return 'Enter the environment variable the provider expects.'; + } + return null; +} +function canContinue() { + if (draft.step === 1) return configurationError() === null; + return draft.workspaces.length > 0; +} + +function syncFooter() { + const err = configurationError(); + envInput.setAttribute('aria-invalid', String(!!err && draft.authMode === 'bound_secret')); + if (err) { + sumEl.dataset.tone = 'error'; + sumLabel.textContent = 'Needs an answer'; + sumValue.textContent = err; + } else if (draft.step === 1) { + const m = MODEL[draft.model], p = PROV[m.provider]; + const rz = reasoningStateFor(m); + const bits = [p.name, m.name]; + if (rz.mode === 'levels') bits.push(draft.reasoning ? EFFORT_LABEL[draft.reasoning] : 'default effort'); + bits.push(draft.authMode === 'native_cli' ? 'CLI sign-in' : draft.envVar.trim() || 'bound key'); + sumEl.dataset.tone = 'ok'; + sumLabel.textContent = 'Saves as your default'; + sumValue.textContent = bits.join(' · '); + } else { + const n = draft.workspaces.length; + sumEl.dataset.tone = 'ok'; + sumLabel.textContent = 'Workspaces'; + sumValue.textContent = n === 0 + ? 'None yet — add at least one folder to finish.' + : `${n} folder${n === 1 ? '' : 's'} · ${draft.workspaces.map(w => w.name).join(', ')}`; + } + nextBtn.disabled = !canContinue(); +} + +/* The pane is stretched to the body, so its own scrollHeight can never + report less than the current height. `.ob__inner` is the flow-sized + content box, so its offsetHeight is the step's natural height. */ +function measurePane() { + const pane = paneOf(draft.step); + const inner = pane && $('.ob__inner', pane); + if (!inner) return; + if (window.matchMedia('(max-width: 759px)').matches) { obBody.style.removeProperty('--ob-h'); return; } + const chrome = 52 + 44 + 58; + const max = Math.max(280, window.innerHeight - 48 - chrome); + obBody.style.setProperty('--ob-h', `${Math.min(inner.offsetHeight, max)}px`); +} + +function renderSteps() { + $$('.ob__step', $('#obSteps')).forEach(btn => { + const n = Number(btn.dataset.goto); + const state = n < draft.step ? 'done' : n === draft.step ? 'on' : 'off'; + btn.dataset.state = state; + btn.disabled = n > draft.maxStep; + btn.setAttribute('aria-current', state === 'on' ? 'step' : 'false'); + const num = $('.ob__num', btn); + num.innerHTML = state === 'done' + ? '' + : String(n); + }); +} + +function goToStep(n) { + if (n < 1 || n > 2 || n > draft.maxStep || n === draft.step) return; + closePopup(); + const prev = paneOf(draft.step); + draft.step = n; + ob.dataset.step = String(n); + const pane = paneOf(n); + prev.hidden = true; + pane.hidden = false; + pane.dataset.enter = 'true'; + setTimeout(() => pane.removeAttribute('data-enter'), 320); + renderSteps(); + backBtn.disabled = n === 1; + nextBtn.textContent = n === 2 ? 'Finish setup' : 'Continue'; + measurePane(); + syncFooter(); + const landing = n === 2 + ? (dbUse.disabled ? $('.drow__nav', dbList) : dbUse) + : $('.pmr__seg', trigger); + landing?.focus(); +} + +backBtn.addEventListener('click', () => goToStep(draft.step - 1)); +nextBtn.addEventListener('click', () => { + if (!canContinue()) return; + if (draft.step === 1) { + draft.maxStep = 2; + renderSteps(); + goToStep(2); + return; + } + finish(); +}); +$$('.ob__step', $('#obSteps')).forEach(btn => btn.addEventListener('click', () => goToStep(Number(btn.dataset.goto)))); + +/* ============================================================ + COMMIT — POST /api/onboarding/complete, then the shell wakes + ============================================================ */ +function finish() { + nextBtn.disabled = true; + nextBtn.innerHTML = 'Finishing…'; + setTimeout(unlockShell, 720); +} + +function unlockShell() { + const scrim = $('#obScrim'); + scrim.classList.add('is-leaving'); + document.body.dataset.onboarding = 'done'; + setTimeout(() => { + scrim.remove(); + closePopup(); + pop.remove(); + $('#os').removeAttribute('inert'); + dockZone.dataset.locked = 'false'; + $('#deskHint').hidden = false; + setTimeout(() => $$('.dock-item').forEach(el => { el.style.transitionDelay = ''; }), 700); + const m = MODEL[draft.model], p = PROV[m.provider]; + const n = draft.workspaces.length; + toast({ + title: `${draft.workspaces[0].name} · ready`, + body: `Default model ${esc(p.name)} / ${esc(m.name)} · ${n} workspace${n === 1 ? '' : 's'} registered. Sessions start from the dock or ⌘K.`, + actions: [ + { label: 'Open AGH', primary: true, run: () => { window.location.href = 'agh-os-v2.html'; } }, + { label: 'Run setup again', run: () => window.location.reload() }, + ], + }); + }, 300); +} + +/* dock + hint hand off to the running shell once setup is done */ +dockEl.addEventListener('click', e => { + if (document.body.dataset.onboarding !== 'done') return; + if (e.target.closest('.dock-item')) window.location.href = 'agh-os-v2.html'; +}); +$('#dockNew').addEventListener('click', () => { + if (document.body.dataset.onboarding === 'done') window.location.href = 'agh-os-v2.html'; +}); + +function toast({ title, body, actions = [], timeout = 14000 }) { + const el = document.createElement('div'); + el.className = 'toast'; + el.innerHTML = `
    ${title}
    +

    ${body}

    + ${actions.length ? `
    ${actions.map((a, i) => ``).join('')}
    ` : ''}`; + $('#toasts').appendChild(el); + const dismiss = () => { el.classList.add('is-leaving'); setTimeout(() => el.remove(), 240); }; + actions.forEach((a, i) => $(`[data-ta="${i}"]`, el).addEventListener('click', () => { dismiss(); a.run?.(); })); + setTimeout(dismiss, timeout); +} + +/* ============================================================ + KEYBOARD + FOCUS CONTAINMENT + ============================================================ */ +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && popOpen) { e.preventDefault(); closePopup(); return; } + if (popOpen) { + if (e.target.closest?.('.rz')) return; // the slider owns its own keys + if (e.key === 'ArrowDown') { e.preventDefault(); moveHL(1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); moveHL(-1); return; } + if (e.key === 'Enter') { + const row = $$('.mrow', popList)[hlIndex]; + if (row && row.dataset.disabled !== 'true') { e.preventDefault(); pickModel(row.dataset.model); } + } + return; + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && document.body.dataset.onboarding === 'active') { + e.preventDefault(); nextBtn.click(); + } +}); +/* setup blocks the shell: focus never escapes the panel while it is open */ +document.addEventListener('focusin', e => { + if (document.body.dataset.onboarding !== 'active') return; + const scrim = $('#obScrim'); + if (!scrim || scrim.contains(e.target) || pop.contains(e.target)) return; + trigger.querySelector('.pmr__seg')?.focus(); +}); +window.addEventListener('resize', measurePane); + +/* ============================================================ + BOOT + ============================================================ */ +draft.authMode = defaultAuthFor(draft.provider); +renderTrigger(); +renderFacts(); +syncAuth(); +renderBrowser(); +renderWorkspaces(); +syncShellWorkspace(); +renderSteps(); +syncFooter(); +measurePane(); +requestAnimationFrame(measurePane); +document.fonts?.ready.then(measurePane); + +})(); diff --git a/docs/design/opendesign/settings/LAYOUTS-IMPLEMENTATION-SPEC.md b/docs/design/opendesign/settings/LAYOUTS-IMPLEMENTATION-SPEC.md new file mode 100644 index 000000000..085c50ca5 --- /dev/null +++ b/docs/design/opendesign/settings/LAYOUTS-IMPLEMENTATION-SPEC.md @@ -0,0 +1,327 @@ +# Settings › Layouts — implementation spec + +Work order to land `settings/settings-layouts.html` in `web/`. +Design rationale and field map: `LAYOUTS-REDESIGN-SPEC.md` (read it first — this document does not repeat the "why"). + +Status: **draft, not started.** +Owning surface: `web/src/systems/settings/` + `web/src/routes/_app/settings/layouts.tsx`. +Breaking change: yes — 11 components are deleted outright (§4). No compat shims, no aliases; this is a hard cut per the greenfield rule. + +--- + +## 1. The load-bearing discovery — do not write geometry code + +**The projector this redesign needs already exists and is already shipping in the OS shell.** + +| Existing | What it gives the settings canvas | +|---|---| +| `web/src/systems/os/lib/layout-projection.ts:371` — `projectLayout(input): LayoutProjection` | pixel rects for every window, stack and **seam** on a desktop, from the same normalized document the daemon stores | +| `ProjectedSeam` (`os/lib/window-manager-types.ts`) | per-boundary `{ id, splitId, boundaryIndex, orientation, rect, value, minValue, maxValue, axisSpan, leadingWeight, trailingWeight }` — a complete drag contract with the identity `layout.resize` uses | +| `web/src/systems/os/lib/seam-preview.ts:23` — `seamWeightDelta(seam, deltaPx)` | pixel delta → weight delta, clamped by the projection minimums **and** the daemon's `MIN_SPLIT_WEIGHT` floor, mirroring `reducer_layout_resize.go` | +| `seam-preview.ts:36` — `applySeamPreviewToDesktop(desktop, seam, deltaPx): LayoutDesktop` | pure, immutable, returns a new desktop with the boundary moved and the weight vector still summing to 1 | +| `os/lib/layout-projection.ts:407` — `clampFloatingRect(input)` | keeps a dragged floating window inside the work area, preserving the title-bar grab relationship | + +Consequences, and these are not optional: + +1. **The settings canvas renders `projectLayout()` output.** It does not compute its own rects. This is what makes the preview *truthful* — it is pixel-identical to what the runtime will render, including the `adaptive-stack` and `minimum-unmet` diagnostics, which the settings editor has never shown. +2. **Seam drags call `applySeamPreviewToDesktop`.** Bug D-2 (§3) then cannot recur, because that function is already a mirror of the reducer and preserves the sum-to-1 invariant by construction. Do not hand-roll pairwise rebalancing in `systems/settings/`. +3. **`useLayoutSeam` (`os/hooks/use-layout-seam.ts`) is NOT reusable as-is** — it dispatches live `layout.resize` commands against the runtime store. Settings edits a *draft document*. Reuse the two pure functions from `seam-preview.ts`; write a separate `use-layout-draft-seam.ts` that applies them to the draft instead of dispatching. +4. `projectLayout` currently lives under `systems/os/`. Settings importing from `systems/os/lib/` is a cross-system import — check `make lint` boundaries before writing the import. If the boundary rule forbids it, the fix is to **move** `layout-projection.ts`, `seam-preview.ts`, `window-manager-types.ts` and `window-manager-schemas.ts` into a shared `systems/window-manager/` module consumed by both `os` and `settings` — not to duplicate them. Duplicating geometry is a blocking failure. + +--- + +## 2. Current inventory + +| File | LOC | Fate | +|---|---:|---| +| `routes/_app/settings/layouts.tsx` | 11 | keep | +| `routes/_app/settings/-layouts-settings-page.tsx` | 167 | rewrite (composition only) | +| `settings/components/window-manager-layout-document-editor.tsx` | 220 | **delete** → canvas + review bar | +| `settings/components/window-manager-layout-node-editor.tsx` | 208 | **delete** → inspector + `layout-mutations.ts` | +| `settings/components/window-manager-layout-profiles.tsx` | 152 | **delete** → profile grid + card + editor | +| `settings/components/window-manager-behavior-fields.tsx` | 109 | **delete** → behavior picks | +| `settings/components/window-manager-geometry-fields.tsx` | 105 | **delete** → gap editor + snap map + ratio track | +| `settings/components/window-manager-binding-fields.tsx` | 94 | **delete** → snap map (zones) + shortcut table + advanced | +| `settings/components/window-manager-config-editor.tsx` | 53 | rewrite (drop the bespoke sticky bar) | +| `settings/components/window-manager-number-field.tsx` | 40 | **delete** | +| `settings/components/window-manager-select-field.tsx` | 40 | **delete** | +| `settings/components/window-manager-toggle-field.tsx` | 25 | **delete** | +| `settings/components/window-manager-config-field-types.ts` | 13 | **delete** if unreferenced after the above | +| `settings/components/window-manager-config-fields.ts` | 3 | **delete** if unreferenced after the above | +| `settings/hooks/use-window-manager-config-editor.ts` | 118 | keep, extend (§6) | +| `settings/hooks/use-window-manager-layout-editor.ts` | 121 | keep, extend (§6) | +| `settings/hooks/use-window-manager-layout-profiles.ts` | 136 | keep, fix (§3 D-4) | +| `settings/lib/window-manager-layout-{types,schema,tree,projection,query}.ts` | 131/404/19/88/72 | keep unchanged | +| `settings/adapters/window-manager-layouts-api.ts` | 262 | keep unchanged | +| `settings/mocks/window-manager-fixtures.ts` | 91 | **rewrite** — currently contains invalid enum values (§3 D-3) | + +Everything else in `systems/settings/` is untouched. + +--- + +## 3. Fix these before building the UI + +Four defects, each independently reproducible today. **D-1 and D-2 must land first** — the redesign's structure assumes they are fixed. + +### D-1 · The split buttons are inverted +`window-manager-layout-node-editor.tsx:79` maps `Split rows → axis "horizontal"`, `:87` maps `Split columns → axis "vertical"`. `layout-projection.ts:190` divides the **width** on `horizontal`, so `horizontal` produces columns. "Split rows" currently produces columns. +**Fix:** the axis vocabulary leaves the UI entirely. The inspector exposes two diagram buttons — **Rows** → `axis: "vertical"`, **Columns** → `axis: "horizontal"`. Add a regression test asserting the mapping against `projectLayout` output, not against the label. + +### D-2 · Every split created in Settings fails validation +`window-manager-layout-node-editor.tsx:41` emits `weights: windowIds.map(() => 1)`. `validate.go:249` requires `|Σweights − 1| ≤ 1e-6`, and `validateLayoutDocument` (`internal/windowmanager/service_layout.go:40-74`) runs `ValidateSnapshot` on the document **as sent**, with no normalization pass. A two-way split therefore sends `[1,1]` and "Validate and preview" returns `topology.split_weight_sum`. +**Fix:** `toSplit` emits `1/n`. Structural conversions move into `settings/lib/window-manager-layout-mutations.ts` (§5) so the invariant has one owner and one test. + +### D-3 · The Storybook fixture asserts enum values that do not exist +`settings/mocks/window-manager-fixtures.ts` declares `aspect_variant: "square" | "wide"` and `overflow_policy: "floating" | "ignore"`, and sets document `version: 1`. The real contract is `any|landscape|portrait`, `stack|reject`, and `SnapshotVersion = 2` (`internal/windowmanager/types.go:9`, enforced at `resource_codec.go:174-180`). +**Fix:** rebuild the fixture from the zod schemas. It should be dense enough to exercise the canvas: ≥ 2 desktops, ≥ 2 groups, a nested split, a stack, a floating window and a minimized window. The current fixture (1 desktop / 1 group / 1 leaf) is why the density problem was never visible in review captures. + +### D-4 · Loading a profile silently discards unapplied edits +`use-window-manager-layout-profiles.ts:92-103` — `selectProfile` calls `onLoad(structuredClone(record.spec.document))`, replacing the whole draft with no confirmation. `remove` (`:75-79`) also deletes with no confirmation. +**Fix:** `Load` is confirmed when `editor.dirty` is true; `Delete` uses `ConfirmDialog` from `@agh/ui`. Also fix the reset asymmetry at `:80-89` — after a delete it clears `id`/`displayName` but leaves `aspect`/`overflow`/`scope` on the deleted record's values. + +--- + +## 4. Delete targets + +Removed in the same change, with no deprecation window: + +``` +web/src/systems/settings/components/window-manager-layout-document-editor.tsx +web/src/systems/settings/components/window-manager-layout-node-editor.tsx +web/src/systems/settings/components/window-manager-layout-profiles.tsx +web/src/systems/settings/components/window-manager-behavior-fields.tsx +web/src/systems/settings/components/window-manager-geometry-fields.tsx +web/src/systems/settings/components/window-manager-binding-fields.tsx +web/src/systems/settings/components/window-manager-number-field.tsx +web/src/systems/settings/components/window-manager-select-field.tsx +web/src/systems/settings/components/window-manager-toggle-field.tsx +web/src/systems/settings/components/window-manager-config-field-types.ts ← if unreferenced +web/src/systems/settings/components/window-manager-config-fields.ts ← if unreferenced +web/src/systems/settings/components/__tests__/window-manager-layout-document-editor.test.tsx +``` + +Plus their entries in `settings/components/index.ts`. The three thin wrappers (`window-manager-{number,select,toggle}-field`) duplicated `SettingsNumberInput` / `SettingsChoiceGroup` / a bare `Switch` and must not be reintroduced. + +`window-manager-layout-document-editor.test.tsx` is deleted rather than migrated because it asserts the DOM of a component that ceases to exist. Its behavioural coverage moves per §9. + +--- + +## 5. New files + +Directory: `web/src/systems/settings/components/layouts/`. Hard cap 500 lines per file; the budgets below are targets, and any file that grows past its budget splits rather than absorbs. + +### Canvas +| File | Responsibility | ~LOC | +|---|---|---:| +| `layout-canvas.tsx` | consumes `projectLayout()`, positions groups/tiles/seams/floats, owns selection | 190 | +| `layout-canvas-tile.tsx` | one projected window or stack, presentational only | 110 | +| `layout-canvas-seam.tsx` | seam handle + detent tooltip; delegates math to `seam-preview.ts` | 120 | +| `layout-canvas-group-edge.tsx` | four group edge handles, overlap flag | 130 | +| `layout-canvas-floating.tsx` | floating/minimized window, drag via `clampFloatingRect` | 110 | +| `layout-desktop-tabs.tsx` | desktop tab strip, click-to-rename | 90 | + +### Inspector + review +| File | Responsibility | ~LOC | +|---|---|---:| +| `layout-inspector.tsx` | routes on selection kind, renders the empty/desktop summary | 130 | +| `layout-inspector-node.tsx` | Rows/Columns/Stack actions, weight readout, Distribute evenly | 150 | +| `layout-inspector-window.tsx` | leaf window picker + stack active picker, "in use" gating | 120 | +| `layout-review-bar.tsx` | dirty → Review → Apply gate, preview counts, diagnostics list | 140 | + +### Profiles +| File | Responsibility | ~LOC | +|---|---|---:| +| `layout-profile-grid.tsx` | card grid + load confirm + delete confirm | 130 | +| `layout-profile-card.tsx` | card, chips, computed thumbnail | 110 | +| `layout-profile-editor.tsx` | 5 fields (name, id, scope, aspect, overflow) + derived slot count | 150 | + +### Global config +| File | Responsibility | ~LOC | +|---|---|---:| +| `window-manager-behavior-picks.tsx` | the 5 diagram pick rows + 2 modifier keycap rows + 3 switch rows | 150 | +| `window-manager-behavior-diagrams.tsx` | the SVG set as data; no logic | 150 | +| `window-manager-gap-editor.tsx` | 1:1 box model, 6 handles, keyboard steppers | 170 | +| `window-manager-snap-map.tsx` | edge bands, corner reach, exit slack, 2 centre-zone bindings | 200 | +| `window-manager-ratio-track.tsx` | repeat-width stops: add / drag / remove / uniqueness | 160 | +| `window-manager-shortcut-table.tsx` | grouped rows, disclosure, conflict banner | 140 | +| `window-manager-shortcut-row.tsx` | label + tiling diagram + chord chip + reset | 100 | + +### Hooks — `web/src/systems/settings/hooks/` +| File | Responsibility | ~LOC | +|---|---|---:| +| `use-layout-canvas-selection.ts` | selection state + `findNode`/`replaceNode` over the draft | 110 | +| `use-layout-draft-seam.ts` | pointer drag → `applySeamPreviewToDesktop` on the draft (**not** a `layout.resize` dispatch) | 120 | +| `use-layout-group-frame-drag.ts` | edge drag → normalized frame, detents, overlap detection | 140 | +| `use-window-manager-shortcuts.ts` | effective chord resolution, recording, conflict map | 150 | + +### Lib — `web/src/systems/settings/lib/` +| File | Responsibility | ~LOC | +|---|---|---:| +| `window-manager-layout-mutations.ts` | `toSplit` / `toStack` / `evenWeights` — **the single owner of the weight-sum invariant** | 90 | +| `window-manager-shortcut-chords.ts` | parse / format / canonicalize / symbolize / detect conflicts | 130 | +| `window-manager-layout-thumbnail.ts` | profile-card rect math (no gaps, no minimums — a diagram, not a projection) | 80 | +| `window-manager-snap-geometry.ts` | px ↔ map-scale conversion for the snap map and gap editor | 70 | + +### `packages/ui` +`Slider` does not exist. `packages/ui/src/components/` has `popover.tsx`, `switch.tsx`, `tabs.tsx`, `tooltip.tsx`, `scroll-area.tsx` — no slider. The `history_limit` control needs one, and a slider is a generic primitive, so per the reuse rule it lands in `packages/ui` with a story and a test, not in `web/`: + +``` +packages/ui/src/components/slider.tsx +packages/ui/src/components/__tests__/slider.test.tsx +web/.storybook/... or packages/ui story colocated per the existing convention +``` + +Everything else composes existing primitives: `Button`, `Input`, `Switch`, `Popover`, `Tooltip`, `ConfirmDialog`, `Field*`, `Eyebrow`, `Item*`, `Spinner`. + +--- + +## 6. Hook changes + +`use-window-manager-layout-editor.ts` — add: +- `setDesktopName(desktopIndex, name)` +- `setNode(nodeId, next)` — replaces a node anywhere in the active desktop +- `setFloatingRect(windowId, rect)` — the document contract already validates `floating_rect`; the current editor simply never wrote it +- `applySeam(seam, deltaPx)` — thin wrapper over `applySeamPreviewToDesktop` + +Keep `reviewCurrent` exactly as it is. The fingerprint gate is correct and the redesign depends on it. + +`use-window-manager-config-editor.ts` — replace the current all-or-nothing `canSave` with per-field validity so the UI can surface *which* value is out of range. Today the only feedback is `aria-invalid` plus a disabled button, and the explanatory string (`"Fix repeat ratios and shortcut JSON before saving."`) is thrown from a code path that can only run after a click that cannot happen. + +`use-window-manager-layout-profiles.ts` — add the confirm gates from D-4; fix the post-delete reset. + +--- + +## 7. Page composition + +`-layouts-settings-page.tsx` becomes composition only (~120 LOC), rendering: + +``` +SettingsPageFrame (needs a width beyond `wide`; see below) +├── LayoutStage ← desktop tabs + LayoutCanvas + LayoutInspector + LayoutReviewBar +├── LayoutProfileGrid + LayoutProfileEditor +├── SettingsGroup "Window behavior" → WindowManagerBehaviorPicks +├── SettingsGroup "Spacing and snapping" → WindowManagerGapEditor | WindowManagerSnapMap | WindowManagerRatioTrack +├── SettingsGroup "Shortcuts" → WindowManagerShortcutTable +├── SettingsAdvancedFold → history Slider + SettingsTiles (provenance) +└── SettingsSaveBar ← the shared one, for the global config draft only +``` + +Two structural fixes here: + +- **One floating bar.** Today `window-manager-config-editor.tsx:34-50` and `window-manager-layout-document-editor.tsx:183-217` are both `sticky bottom-3` in the same scroll region, so two bespoke bars can float on top of each other. In the redesign the document's review bar is **docked inside the stage card** (not sticky) and only the global config uses the shared `SettingsSaveBar` — matching every other settings section's dirty/saved dot and `Discard` / `Save changes` wording. +- **Column width.** `settings-page-frame.tsx:17,46` exposes a boolean `wide` that picks between the `max-w-settings-page-form` and `max-w-settings-page-wide` Tailwind tokens. The canvas + 236px inspector needs ~1040px, so the boolean becomes a `width?: "form" | "wide" | "canvas"` union with a matching `max-w-settings-page-canvas` token — not a bespoke `className` on the call site. Migrate the existing `wide` call sites (Providers, and Layouts itself) in the same change. Below 1100px the inspector stacks under the canvas. + +--- + +## 8. Backend and contract + +**No daemon changes are required for the UI to work.** Every control maps to an existing field; the endpoint list is unchanged from `LAYOUTS-REDESIGN-SPEC.md` §3. + +Two gaps worth deciding on separately — neither blocks this work: + +1. **Layout profiles have no CLI surface.** `internal/cli/window_manager_layout.go` ships `export`, `validate`, `apply`, `watch`; `grep -rn "layout-profile" internal/cli/` returns nothing. The redesign promotes profiles to a first-class surface, so the core premise ("every capability must be manageable by agents") says the CLI should catch up: `agh window-manager layout-profile {list,get,put,delete}` over the existing `…/layout-profiles` routes. **File as its own task** — the web work does not depend on it. +2. **Per-workspace overrides have no UI.** `document.overrides` is a full `WorkspaceConfig` on the wire (`web/src/generated/agh-openapi.d.ts:6220-6252`) but `Record` in the settings types, and the section declares `scope: "global"`, `available_scopes: ["global"]`. Surfacing it needs a new schema and a scope switcher, not a new component. Out of scope here; the redesign states "Global only" in Advanced so the UI does not imply otherwise. + +Config lifecycle: `window_manager.*` is `DiffClassLive` (`internal/config/lifecycle/lifecycle.go:108`) — it hot-applies. The page shows `Applies live` in the head and must **not** render a restart notice. + +--- + +## 9. Test plan + +Placement first, per `consolidate-test-suites`. Every row names the invariant, the layer that owns it, and the suite it belongs in. + +| Invariant | Owning layer | Canonical suite | +|---|---|---| +| `toSplit`/`toStack` always emit weights summing to 1 (D-2) | pure lib | **new** `settings/lib/__tests__/window-manager-layout-mutations.test.ts` | +| Rows→`vertical` / Columns→`horizontal` produce the geometry the projector renders (D-1) | pure lib, asserted through `projectLayout` | same suite as above | +| Chord parse/format/canonicalize round-trips; conflicts detected | pure lib | **new** `settings/lib/__tests__/window-manager-shortcut-chords.test.ts` | +| Seam drag preserves the weight sum and respects `MIN_SPLIT_WEIGHT` | already owned by `seam-preview.ts` | **existing** `os/lib/__tests__/` — extend only if a settings-specific path is added; do **not** duplicate | +| Draft fingerprint invalidates the preview on any edit | hook | **existing** `settings/lib/__tests__/window-manager-layout-query.test.ts` | +| Apply stays disabled until validate + preview match the draft | component | **new** `settings/components/__tests__/layout-review-bar.test.tsx` (replaces the deleted document-editor test) | +| Loading a profile over a dirty draft confirms first (D-4) | component | **new** `settings/components/__tests__/layout-profile-grid.test.tsx` | +| Canvas renders one tile per projected window and marks overlapping groups | component | **new** `settings/components/__tests__/layout-canvas.test.tsx` | +| Config ranges (gaps 0–64, edge band 4–128, corner reach 16–512, exit slack 0–64, ratios 1–8 × 0.1–0.9 unique, history 1–500) | hook | **existing** `settings/components/__tests__/window-manager-config-editor.test.tsx`, retargeted at the hook | +| Slider primitive keyboard + aria contract | primitive | **new** `packages/ui/src/components/__tests__/slider.test.tsx` | + +Forbidden by default and **not** to be added: snapshot tests of the canvas SVG/DOM, CSS assertions, and tests that only re-assert the zod schemas. The schemas already own those. + +Storybook: rewrite `settings/routes/settings-layouts.stories.tsx` against the new fixture (§3 D-3) with at least `default`, `dirty-unreviewed`, `validation-failed` and `no-workspace` stories, so the visual-contract capture actually exercises density. + +--- + +## 10. Accessibility contract + +Non-negotiable — the page is otherwise pointer-only, which would be a regression from a page made of native inputs. + +- Every drag affordance (seam, group edge, gap guide, snap grip, ratio stop) is also `role="slider"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-label`, and responds to arrow keys (shift = ×10 on px values). +- The canvas is a `listbox`-style selection surface: tiles are focusable, arrow keys move selection, `Enter` opens the inspector's primary action. +- Centre-zone bindings open a real `Popover` menu with `role="menu"`, not a custom div. +- Shortcut recording announces state via `aria-live="polite"` and is cancellable with `Escape`. +- Conflicts and diagnostics are `role="status"` regions, not colour-only signals. + +--- + +## 11. AGH Impact Audit + +```markdown +AGH Impact Audit: + +- Native tools: no impact — checked skills/agh/ tool descriptors and the agh__* registry; + no window-manager tool IDs exist and none are added. The window-manager surface is reached + through the CLI (internal/cli/window_manager_*.go) and HTTP/UDS routes + (internal/api/httpapi/window_manager_routes.go), neither of which changes shape here. + +- Extensibility and hooks: no impact on extensions, hooks, skills/capabilities, bundles, + registries or bridge SDKs — checked internal/hooks event list and the extension surface; + window-manager emits no hook events and exposes no extension point. Config lifecycle is + unchanged: window_manager.* stays DiffClassLive (internal/config/lifecycle/lifecycle.go:108) + and no config.toml key is added, removed or re-ranged. + +- Workspace data isolation: no new data. The layout document is workspace-scoped and already + keyed by workspace_id through the client-state engine (internal/daemon/window_manager_repository.go:17-19); + layout profiles are scope-tagged global|workspace in resource_records with the CHECK at + internal/store/globaldb/schema/definitions/34_resources.sql:5 and the visibility filter at + internal/api/core/window_manager_layout_profiles.go:212-219. The redesign reads the same + queries with the same keys (settings/lib/query-keys.ts:24-36); no new cache, SSE channel or + event path is introduced, so no new cross-workspace leak surface exists. + +- Official AGH skill: no impact — checked skills/agh/ for window-manager references; the skill + documents no layout tool IDs, CLI paths or capabilities. If §8 item 1 (layout-profile CLI verbs) + is taken up as its own task, that task must update skills/agh/. +``` + +**Web/Docs Impact.** Web: the whole of §4–§7. Docs (`packages/site`): `content/runtime/core/configuration/config-toml.mdx:133-161` documents `[window_manager]` and stays accurate — no key changes. No docs change is required for this task; if the layout-profile CLI lands, its reference page is generated and will co-ship there. + +--- + +## 12. QA tracker impact + +User-visible behaviour changes, so this is a **flag, not a retest**: + +- Add `untested` content-addressed scenarios under `docs/qa/scenarios/` for: drag-to-rebalance a split; drag a group edge into an overlap and see it refused; record and reset a shortcut; edit gaps and see the canvas follow; load a saved layout over a dirty draft. +- Reset `qa_status` to `untested` on any existing scenario covering Settings › Layouts. +- D-1 and D-2 are behaviour fixes and each need their own scenario. + +E2E: the page is UI-bearing, so a Playwright lane belongs in `make test-e2e-web` covering the review → apply gate. Pointer-drag assertions are brittle; assert the *outcome* (weights, diagnostics, enabled state) through the store, not the pixel path. + +--- + +## 13. Phasing + +**P1 — correctness.** D-1, D-2, D-3, D-4 with their tests. Ships against the current UI. Independently mergeable and independently valuable. + +**P2 — the stage.** Shared window-manager module if the boundary check demands it (§1.4), canvas + inspector + review bar + desktop tabs, page recomposed, delete targets removed, one save bar. This is the bulk of the work. + +**P3 — the config surface.** Behavior picks, gap editor, snap map, ratio track, shortcut table, `Slider` primitive, advanced fold. + +**P4 — profiles.** Card grid with thumbnails, editor, confirm gates. + +Acceptance for the whole change: `make verify` green; zero `input[type=number]` bound to a geometry field under `systems/settings/`; no file over 500 lines; `agh-ui-screenshot` capture of the new Storybook stories cited in the completion notes. + +--- + +## 14. Open questions + +1. **Boundary rule** — may `systems/settings/` import from `systems/os/lib/`? If not, the shared-module move in §1.4 is mandatory and should be its own commit ahead of P2. Resolve before starting P2. +2. **Group repositioning** — the prototype only resizes groups via their edges, which cannot move a group without also resizing a neighbour. Is a drag-to-move affordance wanted, and if so what happens to the vacated space? The daemon has no "swap groups" command, so any answer here is client-side rect math. +3. **Adaptive-stack diagnostics** — `projectLayout` reports `adaptive-stack` and `minimum-unmet` at the current viewport. Should the settings canvas surface them (honest: "this layout will fold at this width") or suppress them, given the canvas is a fixed-aspect reference and not the user's real viewport? diff --git a/docs/design/opendesign/settings/LAYOUTS-REDESIGN-SPEC.md b/docs/design/opendesign/settings/LAYOUTS-REDESIGN-SPEC.md new file mode 100644 index 000000000..42a819abd --- /dev/null +++ b/docs/design/opendesign/settings/LAYOUTS-REDESIGN-SPEC.md @@ -0,0 +1,110 @@ +# Settings › Layouts — visual redesign + +Prototype: `settings-layouts.html` + `settings-layouts.js` +Replaces: `web/src/routes/_app/settings/-layouts-settings-page.tsx` and its three editor components. + +--- + +## 1. Why + +The current page flattens a spatial domain into scalars. In the *minimal* Storybook fixture (1 desktop, 1 group, 1 leaf) it renders **9 number inputs, 4 free-text inputs, 1 JSON textarea and 12 selects** — and it scales linearly with the layout: `+4 numbers per group`, `+1 number per split child`, `+1 select per node`. A modest 3-desktop workspace is ~59 typed boxes on one scroll. + +Every one of those boxes is a rectangle, a ratio or an inset that got turned into a number on its way to the user: + +| Today | Actually is | +|---|---| +| `x` `y` `w` `h`, four 0–1 decimals, `step=0.05` | a rectangle on a screen | +| `Weight`, a decimal in a detached 7rem column | the position of a divider | +| `Repeat ratios`, a comma-separated string | a set of stops along an axis | +| `Inner/Top/Right/Bottom/Left gap`, five px fields | a box model | +| `Edge band` / `Corner reach` / `Exit slack` | hit zones around the screen edges | +| `Top center` / `Bottom center`, two selects | two positions on the screen edge | +| `Shortcut map`, a raw JSON textarea | 24 named actions with chords | + +The redesign makes each of those the thing it is. **The page contains zero `input[type=number]` for geometry.** The only remaining slider is `history_limit`, which genuinely is a scalar. + +## 2. Information shape + +Per the settings-section rule, Layouts leads with what no other section has: **a canvas**. The page is: + +1. **Workspace layout** — a desktop canvas you manipulate directly, plus a selection inspector and the daemon's validate → preview → apply gate docked inside the stage card. +2. **Saved layouts** — profile cards whose thumbnail is their real geometry, computed from the stored document. +3. **Window behavior** — diagram choice-cards and modifier keycaps instead of 7 selects. +4. **Spacing and snapping** — a 1:1 gap box model, a snap-zone map with the controls sitting where the zones are, and a track of repeat-width stops. +5. **Shortcuts** — a grouped recorder table with tiling geometry icons and live conflict detection. +6. **Advanced** — history limit + provenance. + +## 3. Field map — every control to its contract + +### Layout document (`PUT …/window-manager/layout`, gated by validate + preview) + +| Control | Writes | Rule enforced by the control | +|---|---|---| +| Drag a group edge | `desktops[].groups[].frame.{x,y,w,h}` | clamped to `x,y ≥ 0`, `w,h ≥ 0.08`, `x+w ≤ 1`; magnetic detents at 0, ¼, ⅓, ½, ⅔, ¾, 1; live overlap flag (`topology.group_overlap`) | +| Drag a seam | `…root…weights[]` | the pair is rebalanced, so the vector **always sums to 1** — `topology.split_weight_sum` becomes unreachable | +| Rows / Columns / Stack | replaces the node via the same flatten-and-rebuild as `toSplit`/`toStack` | disabled below 2 windows; emits normalized weights | +| Distribute evenly | `weights[] = 1/n` | — | +| Window picker on a leaf | `leaf.windowId` | windows already claimed by another node are disabled (`topology.window_membership`) | +| Member list on a stack | `stack.activeId` | warns below 2 members (`topology.stack_size`) | +| Desktop tab, click-to-rename | `desktops[].name` | — | +| Drag a floating window | `windows[].floatingRect` | clamped inside 0–1 (`topology.floating_rect`) | +| Import / Export JSON | whole document | import lands in the draft and still passes the review gate | +| Review changes | `POST …/layout/validate` then `POST …/preview` | preview counts come from `changes.{desktopIds,groupIds,nodeIds,windowIds}` — the contract returns ids and counts, never geometry, so the panel shows counts only | +| Apply layout | `PUT …/layout` with `expected_revision` | stays disabled until the preview fingerprint matches the current draft, exactly as `reviewCurrent` does today | + +### Layout profiles (`window_layout` resource) + +| Control | Writes | +|---|---| +| Card → Load | replaces the draft document (now behind a confirm when the draft is dirty) | +| Name / Resource ID | `spec.display_name` / `spec.id` — the hint states that changing the id forks a new record, because `expectedVersion` resets to 0 | +| Scope segmented | `scope.kind` ∈ `global \| workspace` | +| Screen shape segmented | `spec.aspect_variant` ∈ `any \| landscape \| portrait` — labelled as stored-only, because nothing selects a profile by it | +| Overflow cards | `spec.overflow_policy` ∈ `stack \| reject` | +| Footer sentence | `spec.participant_slots`, derived from the document — shown, never edited | + +### Global config (`PATCH /api/settings/window-manager`, one save bar) + +| Control | Writes | Range | +|---|---|---| +| 5 diagram pick rows | `new_window_policy`, `small_viewport_policy`, `focus_policy`, `drag_away_policy`, `desktop_transition` | the literal unions | +| 2 keycap rows | `group_move_modifier`, `swap_modifier` | `alt \| control \| meta \| shift \| none` | +| 3 switches | `focus_wrap`, `focus_follows_pointer`, `raise_on_focus` | — | +| Gap box guides | `gaps.{inner,top,right,bottom,left}` | 0–64, integer | +| Snap map grips | `snap.{edge_band,corner_reach,exit_slack}` | 4–128 / 16–512 / 0–64 | +| Centre zone buttons | `bindings.{top_center,bottom_center}` | `none \| reserved \| zoom` | +| Repeat-width stops | `snap.repeat_ratios` | 1–8 stops, each 0.1–0.9, unique | +| History slider | `history_limit` | 1–500 | +| Shortcut chips | `shortcuts[actionId]` | chord = modifiers + one `KeyboardEvent.code`; only overrides are stored, so the table shows the shipped default with a reset affordance | + +## 4. Deliberately not built + +These do not exist in the contract and were not invented: + +- **Add / remove / reorder desktops, groups or windows.** The stage says so in the tab strip. Those are `desktop.create/delete/reorder` semantic commands on `POST …/commands`, not layout-document edits. +- **Per-display / multi-monitor config.** No display entity exists anywhere in the model. +- **Auto-tiling modes (bsp, master-stack, fibonacci).** `layout.arrange` is a command with presets `horizontal | vertical | grid | stack`; it is not a stored layout property. +- **Floating rules ("app X always floats").** `new_window_policy` is a single global switch; there is no per-app rule table. +- **Per-workspace overrides.** `document.overrides` and `.agh/config.toml` exist, but the settings section is `scope: "global"`, `available_scopes: ["global"]`. Surfacing it needs a new schema, not a new component. +- **Profile auto-activation by screen shape.** `aspect_variant` is stored and nothing consumes it — the UI says so instead of implying a match rule. +- **Undo / redo in the editor.** Only `Reset`. `layout.undo/redo` are daemon commands the settings editor does not call. +- **A visual preview from the daemon.** `preview` returns ids and counts. Every rectangle on this page is computed client-side from `frame` + `weights` + `axis`. + +## 5. Two production defects found while mapping the contract + +**D-1 · The split buttons are inverted.** +`window-manager-layout-node-editor.tsx:79` maps `Split rows → axis "horizontal"` and `:87` maps `Split columns → axis "vertical"`. The projector divides the **width** on `horizontal` (`web/src/systems/os/lib/layout-projection.ts:190`), so `horizontal` produces columns. Pressing "Split rows" today produces columns. The redesign drops the axis vocabulary entirely and labels the two diagram buttons **Rows** (`vertical`) and **Columns** (`horizontal`). + +**D-2 · Every split created in Settings fails validation.** +`toSplit` emits `weights: windowIds.map(() => 1)` (`window-manager-layout-node-editor.tsx:41`). `validate.go:249` requires `|Σweights − 1| ≤ 1e-6`, and `validateLayoutDocument` (`service_layout.go:40-74`) runs `ValidateSnapshot` on the document **as sent**, with no normalization. So a two-way split sends `[1,1]` and "Validate and preview" returns `topology.split_weight_sum`. The redesign emits `1/n` and rebalances pairwise on drag, which makes the invalid state unreachable rather than merely reported. + +Both are code fixes, independent of this redesign. + +## 6. Notes for implementation in `web/` + +- The document review gate, the profile CRUD and the config draft keep their existing hooks (`useWindowManagerLayoutEditor`, `useWindowManagerLayoutProfiles`, `useWindowManagerConfigEditor`). Only the presentation changes. +- The page currently floats **two** competing `sticky bottom-3` bars. Here the document's review bar is docked inside the stage card and only the global config uses the shared `SettingsSaveBar` — one floating bar on the page, matching every other settings section. +- `SettingsPageFrame` needs a width beyond `wide` (960px) for the canvas + inspector; the prototype uses 1040px and collapses the inspector under the canvas below 1100px. +- New primitives worth promoting to `packages/ui` if reused: none. The canvas, gap box, snap map and chord recorder are domain composites and belong in `web/src/systems/settings/`. +- `Delete` on a profile still has no confirmation in production; the prototype confirms only the destructive *load* (which silently discards unapplied edits today). Both deserve confirmation. +- Accessibility: every drag affordance is also a `role="slider"` with arrow-key support and an `aria-valuenow`, so the page is operable without a pointer. diff --git a/docs/design/opendesign/settings/settings-automation.html b/docs/design/opendesign/settings/settings-automation.html index 77e11c88c..d8160c572 100644 --- a/docs/design/opendesign/settings/settings-automation.html +++ b/docs/design/opendesign/settings/settings-automation.html @@ -208,6 +208,7 @@
    Runtime