From 6a5753dd58b3f2978626c5961ae23c64e1f10e92 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Tue, 7 Jul 2026 12:18:12 -0700 Subject: [PATCH 01/10] docs(exploration): explore the malleable workbench composable workspace Co-Authored-By: Claude Fable 5 --- ...ALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md | 726 ++++++++++++++++++ 1 file changed, 726 insertions(+) create mode 100644 docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md diff --git a/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md b/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md new file mode 100644 index 000000000..535264b2e --- /dev/null +++ b/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md @@ -0,0 +1,726 @@ +# The Malleable Workbench — Designing the Composable Workspace + +## Problem Statement + +xNet's pitch — _"the application is a view over your data"_ +([The Workshop and the Walled Garden](https://xnet.fyi/blog/the-workshop-and-the-walled-garden/)) — +is true at the data layer and only half-true at the shell layer. The substrate +is genuinely open: one store of nodes, views as scoped queries, plugins as +consent forms. But the **workspace itself** — which panels exist, where they +sit, what the app _feels like_ at rest — is hand-authored in three discrete, +non-composable shells: + +- the **workbench grid** (0166) — a VS Code cosplay with fixed rail, three + named panels, and editor groups; +- the **calm shell** (0250) — a Claude-desktop grammar with exactly three + regions (List · Surface · Canvas) and no splits, no tabs; +- the **quiet posture** (0273) — the calm shell with chrome summoned from + corners and edges. + +Each is good. None is _malleable_ in the Ink & Switch sense: a user cannot +rearrange regions, save a task-shaped layout, share a workspace with a +teammate, or grow the shell from minimal to dense without jumping between +pre-built shells. The question this exploration answers: **how do we redesign +the UI/UX so the workspace transforms — smoothly, per user and per task — from +a blank sheet to a fully tooled workbench, using the same substrate, +contribution, and consent machinery the rest of xNet already runs on?** + +Target feel, in the user's words: Notion's calm composition, VS Code/Zed's +tooled density, Obsidian's saved workspaces and plugin culture, Claude/Codex's +agent-beside-artifact layout — one app that can be any of these _postures_ +without being a different app. + +## Executive Summary + +**The gap is not capability, and it is not even composition any more — it is +that the shell is code, not data.** After 0166→0250→0273 the repo has every +primitive a workbench needs: a platform-free view registry +(`packages/views/src/registry.ts`), a VS Code-grade contribution system with +15+ contribution points (`packages/plugins/src/contributions.ts`), a command +palette, a capability manifest that reads as a consent form +(`packages/plugins/src/feature-module.ts`), and three shells that already +share one zustand store (`apps/web/src/workbench/state.ts`). What it lacks is +the DotA move applied to the shell itself: **the layout as a separable, +legible "map file" the engine loads — so users (and their agents) can mod the +workspace the way modders modded Doom.** + +Recommendation, staged and each phase independently landable: + +1. **One region grammar, three presets.** Replace the hard fork between + `layout: 'calm' | 'workbench'` with a single **layout tree** (regions → + slots → views). Calm, workbench, and quiet become _presets_ — named + configurations of the same tree — instead of separate component trees. + Users move a view between slots the way VS Code drags views between + sidebars; Esc still walks the 0273 disclosure ladder. +2. **Workspaces as nodes.** A saved layout is a `xnet:workspace` node in the + substrate: named, versioned by the change log, synced, shareable, and + scoped like any other node. "My Monday triage bench" is a thing you can + send to a colleague — the shell's WAD file. +3. **The customization slope.** A six-rung gentle slope from _use a preset_ → + _toggle/drag panels_ → _save a workspace_ → _pin blocks/views onto the + Desk_ → _ask the agent to build a view (vibe-coded FeatureModule with a + consent manifest, quarantined by the trust ladder)_ → _full plugin via the + devkit bridge_. Every rung is in-app, keyboard-reachable, and reversible. +4. **The agent is a shell citizen.** Companion mode gains commands that edit + the workspace itself ("set up a writing bench", "put my tasks on the + right"), emitting the same layout-tree mutations a drag would — the + home-cooked-age thesis made concrete. + +Minimal↔robust transformation is then a _movement along one ladder_, not a +switch between products: L0 bare surface (quiet) → pinned calm → tooled bench +with splits and docks — all one state machine, all one persisted tree, all +undoable. + +## Current State In The Repository + +### The three shells and their shared store + +| Piece | Path | Notes | +| --- | --- | --- | +| Shell router | `apps/web/src/workbench/Workbench.tsx` (~200 LOC) | Chooses calm vs workbench vs mobile | +| Shell state | `apps/web/src/workbench/state.ts` (~680 LOC) | One persisted zustand store: `layout`, `calmMode`, `chrome`, `discloseLevel`, `left/right/bottom` panels, editor `groups`/tabs, `shelf`, `deskPins`, `startupTab` | +| Workbench grid | 0166 components under `apps/web/src/workbench/` | Rail (44px, hard-coded 6 icons) → panels → editor groups via `react-resizable-panels` | +| Calm shell | `apps/web/src/workbench/calm/CalmShell.tsx` (~115 LOC) | ModeSwitch · ListPane · Surface · ContextualCanvas; modes = companion/workspace/network (`calm/modes.ts`) | +| Quiet posture | `apps/web/src/workbench/calm/QuietChrome.tsx` (~373 LOC) | Corner glyphs, edge hot-zones, disclosure ladder L0–L2 | +| Surface dock | `apps/web/src/workbench/calm/SurfaceDock.tsx` (~422 LOC) | Registry-driven corner launcher; hero/secondary tiers | +| Mobile shell | `apps/web/src/workbench/MobileShell.tsx` | Separate composition below 768px, not a reflow | +| Commands | `apps/web/src/workbench/commands.ts` | ⌘B/⌘\\/⌘J/⌘./⌘K etc. registered in the global command registry | + +Three **orthogonal axes** already exist and are the germ of the design: +`layout` (calm/workbench), `chrome` (pinned/quiet), and theme axes +(`data-variant`, `data-density` in `packages/ui/src/theme/tokens.css`, +`ThemeProvider.tsx` — 0232). Zen mode snapshots and restores panel state — the +proof that chrome is already a recoverable state, not a fixed frame. + +### The contribution machinery (already VS Code-grade) + +`packages/plugins/src/contributions.ts` (614 LOC) defines contribution points +for: views, commands, slash commands, toolbar, editor extensions, status bar, +sidebar, widgets, property handlers, blocks, settings, schemas, importers, +**surface dock** (`SurfaceDockContribution` — id, label, icon, +`tier: 'hero' | 'secondary'`, group, badge, component), and seven canvas +contribution kinds (cards, ingestors, tools, layouts, edges, inspectors, +templates). `ContributionRegistry` + `TypedRegistry` hold them. + +`packages/plugins/src/feature-module.ts` (63 LOC) is the consent form the +Workshop essay showcases: `ModuleCapabilities` = `secrets` / `schemaRead` / +`schemaWrite` / `network` / `endowments`, enforced by the guarded store, +guarded fetch, and hub secret broker. + +### The view layer (already platform-free) + +`packages/views/src/registry.ts` (238 LOC): `ViewRegistry.register()`, +`getForSchema(schemaIRI)`, `getForPlatform(platform)` — ~16 built-in view +types (table, board, gallery, list, calendar, timeline, grid, tasks, form, +canvas-view, card-detail, …). Views take `ViewProps` and know nothing about +routers or shells. The 0277 convergence extracted the shared canvas core +(`packages/views/src/canvas-view/`, headless `useCanvasViewController`), +proving platform shells can be thin chrome over shared cores — +`DataWorkspaceCore` (0276) did the same for the data workspace. + +### The seams a malleable workbench plugs into + +1. `left/right/bottom` panel booleans drive **both** existing desktop shells — + the state layer is already shell-agnostic. +2. `SurfaceDockContribution`'s registry+tier pattern generalizes to any slot. +3. `canvasTarget` is already the Claude "artifact opens on the right" move. +4. The command registry gives every new shell verb a palette entry + chord + for free. +5. `startupTab` makes "home is a node" a policy, not a component. +6. The Shelf (`shelf` in state) and Desk pins (`deskPins`, 0273) are the + transfer grammar between contexts. + +### The rigidities the redesign must dissolve + +1. **Calm has exactly three regions** — no splits, no tabs, no rearrangement; + `EditorGroup`s exist only in the workbench grid. +2. **SurfaceDock is welded to `bottom`** panel state — the registry pattern is + right but the slot is hard-coded. +3. **The workbench Rail is closed** — 6 fixed icons, no contribution slot. +4. **Electron is a fourth shell** (`apps/electron/src/renderer/App.tsx`, + `shell/shell-state.ts` — document-centric `ShellKind`, no calm grammar, no + palette) — every shell improvement currently forks. +5. **MobileShell is a separate composition**, not a projection of the same + layout tree. +6. **Layout state is app-local** (localStorage zustand) — invisible to the + substrate, unsyncable, unshareable, un-undoable — the one part of a user's + environment that is _not_ "data you own". + +## External Research + +### The two essays (the brief's frame) + +**Ink & Switch, [Malleable Software](https://www.inkandswitch.com/essay/malleable-software/) +(2025):** software should be a dynamic medium users reshape, not locked-down +apps. Named ideas this design leans on: the **gentle slope** (use → inspect → +modify in small, discoverable steps — the spreadsheet's trick); **tools, not +apps** (single-purpose "avocado slicers" don't compose; knives over a shared +substrate do); **shared data infrastructure** (many tools over one corpus — +the filesystem's lost virtue); **communal creation** ("local developers" +customize for a situated group — Shirky's situated software, Sloan's +home-cooked meal); **dynamic/compound documents** +([Potluck](https://www.inkandswitch.com/potluck/): plaintext gradually +enriched into personal software; Embark: outline with embedded live tools). +Their warning is also load-bearing: AI codegen alone produces _siloed_ apps — +malleability needs the substrate, or the LLM just builds more walled sheds. + +**xNet, [The Workshop and the Walled Garden](https://xnet.fyi/blog/the-workshop-and-the-walled-garden/):** +modding built half of gaming (Doom WADs → DotA → an industry) and the pattern +requires only two things — **data separable from the engine, and permission to +put your thing where the old thing was**. The walled garden's ban on mods is a +response to the catastrophic default (all code gets all authority); the fix is +**scope the authority, not ban the code** (object capabilities — Figma +plugins, Deno permissions, VS Code's killable extension host). xNet's manifest +_is_ the consent form. The essay's closing question — "what's your DotA?" — +is, for this exploration, literal: **the shell must ship its map editor.** + +### What each reference app teaches + +**VS Code** ([UI docs](https://code.visualstudio.com/docs/getstarted/userinterface), +[contribution points](https://code.visualstudio.com/api/references/contribution-points)): +six named regions (activity bar, primary + secondary side bars, editor groups, +panel, status bar); **views are movable between containers by drag**; panel +relocates left/right/bottom; extensions contribute views/commands/status items +declaratively; **when-clause contexts** gate visibility; everything is also a +palette command. The deep lesson: _named slots + movable views + declarative +contributions_ scale from a text editor to an IDE without a redesign. + +**Zed** ([panel system](https://zed.dev/blog/new-panel-system)): three docks +(left/right/bottom), each hosting multiple panels with focus-toggling; **zoom** +(any pane temporarily full-screen — a lighter zen); layout choices persist +automatically to `settings.json`; settings-as-code makes the environment +versionable and shareable. Performance-as-UX: chrome must never make the +surface feel heavier. + +**Obsidian** ([workspace](https://docs.obsidian.md/Plugins/User+interface/Workspace), +[Workspaces plugin](https://help.obsidian.md/plugins/workspaces)): the +workspace is a **tree of splits containing leaves (tabs)** serialized to +`workspace.json`; the Workspaces plugin makes **named, recallable layouts** a +first-class user object; community plugins contribute leaf types +indistinguishable from core ones; graph/canvas/plugins hidden by default — +gradual disclosure for non-technical users with a plugin culture on top. + +**Notion** ([block model](https://www.notion.com/blog/data-model-behind-notion)): +everything is a block; **databases are data with interchangeable views** +(table/board/calendar/gallery over the same rows — exactly xNet's +views-over-substrate claim, proven at consumer scale); the slash command is +the composition verb; chrome stays minimal because _composition happens in the +document, not the frame_. + +**Claude desktop / OpenAI canvas apps** +([Artifacts](https://www.anthropic.com/news/artifacts), +[Canvas](https://openai.com/index/introducing-canvas/)): the two-region +**conversation + artifact** grammar — reasoning on the left, live output on +the right; artifacts persist as named objects, not chat scroll; projects scope +context. The agent-first lesson: the agent needs a _place to put things_ that +outlives the conversation — in xNet that place is the substrate itself, which +is a structural advantage over both. + +**Deep prior art:** Smalltalk (the live, inspectable environment); HyperCard +(user levels 0–5 — the original shipped gentle slope); Emacs (the app is a +substrate with a language inside; the only 1970s malleable system still +alive); Acme/Oberon (text itself as executable interface; tag lines as +per-pane toolbars); tldraw (composable canvas primitives as an SDK). + +### The recurring patterns (distilled) + +| # | Pattern | Seen in | Why it works | +| --- | --- | --- | --- | +| 1 | Command palette as the universal road | VS Code, Zed, Obsidian, Linear, Claude | Search beats navigation; teaches chords; makes every feature addressable | +| 2 | Named slots + movable views | VS Code side bars, Zed docks, Obsidian splits | Users rearrange without breaking; plugins target slots, not pixels | +| 3 | Layout-as-data / settings-as-code | Zed settings.json, Obsidian workspace.json, VS Code .code-workspace | Versionable, shareable, agent-editable | +| 4 | Saved named workspaces | Obsidian Workspaces, VS Code profiles, browser tab groups | Tasks have shapes; recall beats rebuild | +| 5 | Progressive disclosure ladder | 0273's L0–L3; VS Code zen→full; Notion menus | Novices see calm; density is earned/summoned, never imposed | +| 6 | Views as lenses over shared data | Notion databases, xNet views, Potluck | Kills export/import; composition without integration | +| 7 | Block/slash composition inside the surface | Notion, xNet pages, Obsidian canvas | The document is the workbench for most users | +| 8 | Declarative contribution points + capability gates | VS Code manifests, Figma/Deno sandboxes, xNet FeatureModule | Ecosystem without catastrophic defaults | +| 9 | Agent beside artifact | Claude, ChatGPT canvas, Zed agent panel | Reasoning and result visible together; iteration without context-switch | +| 10 | Zoom/zen as temporary states, not modes | Zed zoom, VS Code zen, 0273 quiet | Focus is a posture you enter and leave losslessly | +| 11 | Status/ambient indicators at the frame edge | VS Code status bar, xNet corner glyphs | Awareness without panels | +| 12 | Three roads to everything (pointer, touch, keyboard) | 0273's touch-twin contract; NN/g guidance | No dead ends per input mode; accessibility for free | + +## Key Findings + +1. **Every reference app converges on the same skeleton**: a surface that + owns the center, N dockable regions at the edges, a palette that reaches + everything, contributions that target named slots, and layout state + serialized as data. The apps differ in _defaults and slope_, not skeleton. +2. **xNet has the skeleton twice and the slope zero times.** Calm and + workbench are two hand-built instances of the same skeleton (they already + share panel state); what's missing is the continuous path between them. +3. **Layout-as-data is the highest-leverage missing piece** — it is + simultaneously: the unification mechanism (presets are just nodes), the + sharing mechanism (send a bench like a WAD), the sync mechanism (change + log), the agent mechanism (the companion edits a node, which it already + knows how to do), and the brand thesis applied to ourselves (the essay's + "you can't mod what you can't read" — today our own shell is unreadable). +4. **The dock registry is the template.** `SurfaceDockContribution`'s + tier+group+badge+component shape, generalized from one hard-coded corner + to N addressable slots, gives xNet the VS Code "views move between + containers" property with machinery that already exists. +5. **0273's disclosure ladder is the bottom half of the gentle slope.** L0–L3 + covers _chrome_ disclosure; the malleable workbench extends the same + ladder into _customization_ disclosure (save/share/compose/build) instead + of inventing a second grammar. +6. **Electron divergence is the tax on shell-as-code.** A fourth hand-built + shell (`shell-state.ts`'s `ShellKind`) that misses calm, quiet, and the + palette is exactly what stops happening once shells are presets over a + shared core — the 0276/0277 core-extraction playbook applied one level up. +7. **The agent changes who composes.** Litt's "step change in tool support + for end-user programming" means the layout tree's most frequent editor may + be the companion, not the pointer — the design must treat layout mutations + as commands (auditable, undoable, capability-scoped), which is also + exactly what a drag should emit. + +## Options And Tradeoffs + +### Option A — Status quo plus saved layouts + +Keep calm/workbench/quiet as-is; add a "save current panels as preset" list +in localStorage. + +- **Pros:** days of work; zero migration risk. +- **Cons:** presets can't capture what the shells don't share (tabs, splits); + nothing becomes shareable or agent-editable; the three-shell fork keeps + taxing every future feature (and Electron stays a fourth). Doesn't answer + the brief. + +### Option B — One layout tree, shells become presets + +Introduce a `LayoutTree` (regions → slots → view refs) in the workbench +store; render **one** shell component that walks the tree; ship +`calm`/`workbench`/`quiet` as built-in presets. Views become movable between +slots (drag + palette command). Editor groups become a `surface` region +capability available to every preset (calm finally gets optional tabs/splits — +off by default). + +- **Pros:** dissolves rigidities 1–5; every future shell feature lands once; + mobile becomes a projection (compact presets) instead of a fork. +- **Cons:** the largest pure-frontend refactor since 0166; needs a careful + state migration from `xnet:workbench:v1`; two shells must keep working + pixel-identically during the transition (view-drift tripwire from 0276 + applies). + +### Option C — Adopt a docking framework (dockview / golden-layout style) + +Full free-form tiling: arbitrary nesting, floating panels, drag-anywhere. + +- **Pros:** maximal composability, fastest route to "robust and organized." +- **Cons:** 0273 already adjudicated this as a _default_: docking chrome is + the opposite of the blank sheet, and it duplicates the existing grid. A + heavy dependency for a posture most users never enter. **Rejected as the + foundation — but its _capability_ becomes the top preset rung of Option B** + (a "Bench" preset with free splits), implemented on our own tree, not a + framework. + +### Option D — Workspaces as substrate nodes + +Serialize the layout tree as a `xnet:workspace` node: named, change-logged, +synced, shareable, permissioned. Presets ship as read-only system workspaces; +"Save workspace as…" forks one. The agent, importers, and plugins manipulate +workspaces through the same node APIs as everything else. + +- **Pros:** the thesis made real; sharing/sync/undo/audit for free; + agent-editable via existing schema-write capability; marketplace can carry + benches like it carries plugins. +- **Cons:** meaningless without B (there must be one tree to serialize); + schema design must avoid device-specific junk (monitor sizes) leaking into + synced state — needs a local/portable split. + +### Option E — Agent-only malleability + +Skip direct manipulation; all customization through companion commands +("move my tasks left"). + +- **Pros:** cheapest path to a demo; on-trend. +- **Cons:** violates the gentle slope (conversation is a cliff for precise + spatial intent) and the three-roads rule; an agent mutating opaque state no + human can inspect recreates the unreadable shell. Agent belongs as _a_ + road, not _the_ road. + +**Resolution: B + D, staged, with C-as-preset and E-as-road.** One tree +(B), serialized to the substrate (D), whose top rung is a free-splitting +bench preset (C's capability), mutated equally by drags, palette commands, +and the companion (E as one of three roads). + +## Recommendation + +Ship **the Malleable Workbench** in five phases. The organizing picture: + +```mermaid +flowchart TB + subgraph substrate["Substrate (already shipped)"] + LOG["Signed change log"] --> STORE[("Nodes: pages, tasks,
canvases, contacts, …")] + STORE --> WS[("xnet:workspace nodes
(NEW — layouts as data)")] + end + + subgraph engine["Shell engine (one, replacing three)"] + TREE["LayoutTree state
(regions → slots → views)"] + REG["Registries (exist):
views · commands · dock →
generalized SlotRegistry"] + TREE --- REG + end + + subgraph roads["Three roads mutate the same tree"] + DRAG["Pointer: drag view
between slots"] + PAL["Keyboard: ⌘K
'View: Move to right dock'"] + AGENT["Companion: 'set up
a writing bench'"] + end + + WS -->|load preset / saved bench| TREE + TREE -->|save / share / sync| WS + DRAG --> TREE + PAL --> TREE + AGENT -->|schemaWrite: xnet:workspace
(consent-gated)| WS + + subgraph presets["Presets = system workspaces"] + P0["Quiet
(bare surface)"] + P1["Calm
(list · surface · canvas)"] + P2["Bench
(docks, tabs, splits)"] + end + presets --> WS +``` + +### Phase 1 — The layout tree (engine unification) + +One `LayoutTree` replaces the `layout` fork in +`apps/web/src/workbench/state.ts`: + +- **Regions** are the stable skeleton: `surface` (center, owns editor + groups), `dock.left`, `dock.right`, `dock.bottom`, `rail`, `status`, + `overlay.*` (palette, sheets). Regions never multiply — this is the + Zed/VS Code lesson: fixed skeleton, fluid contents. +- **Slots** live in regions and hold ordered view references with per-slot + disclosure tier (`pinned | summoned | hidden`), sizes, and collapse state. +- The existing `left/right/bottom` `PanelState` migrates to three dock slots + — a mechanical migration of `xnet:workbench:v1` (keep zustand `persist` + `migrate`). +- `CalmShell.tsx`, `QuietChrome.tsx`, and the 0166 grid converge on **one** + `ShellFrame` component that renders the tree; the three former shells + become presets (below). The 0276 source-grep tripwire pattern guards + against re-forking. + +### Phase 2 — Slots and movable views (composition) + +- Generalize `SurfaceDockContribution` into a **`SlotContribution`**: + `{ id, label, icon, tier, group, badge, component, defaultSlot, allowedRegions }` + in `packages/plugins/src/contributions.ts` (sub-barrel discipline per 0276 + policy). The dock registry becomes the slot registry; the bottom-right + launcher is just the renderer of `dock.corner`'s summoned tier. +- **Every panel view becomes movable**: context menu ("Move to left dock / + right dock / corner / hide") + drag handle + palette commands. This + dissolves rigidities 2 and 3 (the Rail becomes a rendered slot list). +- The calm ListPane, ContextualCanvas, tray views, devtools dock, and status + glyphs all become slot residents. `canvasTarget` stays the "artifact on the + right" verb — it targets the `dock.right` slot. + +### Phase 3 — Workspaces as nodes (malleability) + +New `xnet:workspace` schema (registered like any schema; Tier-2 seed +auto-covers it or a Tier-1 seeder ships the presets): + +- **Portable part** (synced): tree shape, slot assignments, tier settings, + startup node, theme axes (`variant`, `density`, `chrome`). +- **Local part** (device-scoped, unsynced): pixel sizes, monitor-dependent + splits — kept in the zustand store keyed by workspace id, exactly the + react-resizable-panels split that already exists. +- Verbs: `Workspace: Save as…`, `Workspace: Switch` (⌘K + quick switcher), + `Workspace: Share` (normal node sharing — a bench sent to a teammate), + `Workspace: Reset to preset`. Presets = read-only system workspaces + (`quiet`, `calm`, `bench`); user saves fork them. +- Because workspaces are nodes, they get sync, permissions, and history for + free; "undo a layout mess" is the change log doing its day job. + +### Phase 4 — The customization slope (the product) + +The 0273 disclosure ladder (chrome: L0–L3) extends upward into the +malleability rungs — one continuous slope, every rung optional: + +```mermaid +stateDiagram-v2 + direction TB + L0: L0 — Use\n(bare quiet surface, ⌘K) + L1: L1 — Summon\n(overlays, dock, palette — 0273) + L2: L2 — Arrange\n(pin/move/resize slots, zoom a pane) + L3: L3 — Save & switch\n(named workspace nodes, quick switcher) + L4: L4 — Compose\n(pin views/blocks to Desk & dashboards;\nNotion-style lenses over your data) + L5: L5 — Grow a tool\n(companion writes a FeatureModule;\nconsent manifest; trust-ladder quarantine) + L6: L6 — Full mod\n(devkit :31416 bridge; marketplace;\nlicensed benches & views) + + L0 --> L1: hover edge / swipe / chord + L1 --> L2: drag handle / 'Move view…' + L2 --> L3: 'Save workspace as…' + L3 --> L4: 'Pin to Desk' / slash-embed + L4 --> L5: 'Make me a view that…' + L5 --> L6: open in devkit + L6 --> L0: Esc, always\n(the ladder walks down) +``` + +Design rules enforced across the slope (review-gated, like 0273's touch-twin +table): **three roads to every rung** (pointer, touch twin, palette/chord); +**Esc always walks down**; **no rung is required** — a user who never leaves +L0/L1 has a complete product; **defaults are minimal** (new identities land in +calm or quiet-Desk per the 0273 flag; density arrives only when summoned). + +### Phase 5 — The agent as bench-builder (the home-cooked age) + +- Companion gains `workspace.edit` tools: mutations of the layout tree and + `xnet:workspace` nodes expressed as the same commands the palette exposes — + auditable, undoable, visible as a diff ("Companion moved Tasks to the right + dock — Undo"). +- "Make me a focus board that shows tasks tagged `deep-work` next to a + timer": the agent scaffolds a `FeatureModule` (the essay's twelve legible + lines), registers its view into a slot, and the install dialog is the + consent form. Provenance `ai-generated` → sandbox tier per the trust + ladder; `network: []` by default. +- This is where the two essays meet the product: the substrate makes the + agent's output composable instead of siloed (Ink & Switch's warning), and + the manifest keeps the blast radius bounded (the Workshop's kitchen). + +```mermaid +sequenceDiagram + actor U as User + participant C as Companion (agent) + participant T as LayoutTree / commands + participant W as xnet:workspace node + participant M as FeatureModule sandbox + + U->>C: "Set up my Monday triage bench" + C->>T: cmd: workspace.applyPreset('bench') + C->>T: cmd: slot.place('tasks', dock.left) + C->>T: cmd: slot.place('inbox-savedview', surface.group2) + T->>W: serialize → save as "Monday triage" + W-->>U: toast: "Workspace saved — Undo · Share" + U->>C: "Add a chart of tickets by day" + C->>M: scaffold FeatureModule (schemaRead:['task'], network:[]) + M-->>U: consent dialog (the manifest, verbatim) + U->>M: approve + M->>T: registerSlotContribution(chart) → dock.right + Note over W: The bench is a node: synced,
shareable, versioned, undoable. +``` + +### What this explicitly does not do + +- **No docking framework dependency** — the bench preset is our tree with + splits the 0166 grid already knows how to render. +- **No fourth hand-built shell** — Electron consumes `ShellFrame` + presets + (its `ShellKind` becomes a preset choice), per the 0277 shared-core + playbook; until that lands, the web engine must not regress it. +- **No forced density** — the quiet Desk remains the flagship default + posture; the workbench is what it _can become_, not what it _is_. +- **No new canvas/editor engines** — composition reuses existing views, + blocks, and `CanvasWidgetCard`s. + +## Example Code + +The layout tree and its serialization (sketch): + +```ts +// apps/web/src/workbench/layout-tree.ts +export type RegionId = + | 'surface' + | 'rail' + | 'status' + | `dock.${'left' | 'right' | 'bottom' | 'corner'}` + +export type SlotTier = 'pinned' | 'summoned' | 'hidden' + +export interface SlotPlacement { + /** Contribution id from the slot registry (was SurfaceDockContribution). */ + viewId: string + tier: SlotTier + order: number +} + +export interface LayoutTree { + /** Which system/user workspace this tree was loaded from. */ + workspaceId: string + regions: Record + /** Surface keeps the 0166 editor-group grammar; calm preset = 1 group. */ + surface: { groups: EditorGroup[]; activeGroupId: string; tabsEnabled: boolean } + chrome: 'pinned' | 'quiet' // 0273 axis, unchanged + startup: { nodeType: TabNodeType; nodeId: string } | null +} + +/** Portable half of a workspace node — device-local sizes stay in zustand. */ +export interface WorkspaceNodePayload { + name: string + preset: 'quiet' | 'calm' | 'bench' | null // provenance, for "Reset to preset" + tree: LayoutTree + theme: { variant: ThemeVariant; density: Density } +} +``` + +```ts +// packages/plugins/src/contributions.ts — generalizing the dock contract +export interface SlotContribution { + id: string + label: string + icon?: string | ComponentType + tier: SlotTier + group?: 'navigate' | 'capture' | 'activity' | 'tools' | (string & {}) + badge?: () => string | number | null + component: ComponentType + /** Where it lands if the user hasn't moved it. */ + defaultRegion: RegionId + /** Regions the view is allowed to occupy (empty = anywhere). */ + allowedRegions?: RegionId[] + keywords?: string[] +} +``` + +Every mutation is a command first (the three-roads invariant): + +```ts +registerCommand({ + id: 'slot.move', + title: 'View: Move to…', + run: ({ viewId, region }: { viewId: string; region: RegionId }) => + useWorkbench.getState().moveSlot(viewId, region) + // Drag-drop and the companion both dispatch THIS, never private state. +}) +``` + +## Risks And Open Questions + +1. **Migration blast radius.** `xnet:workbench:v1` persists for every + existing identity; the tree migration must be lossless (zustand `migrate` + + a snapshot test on real persisted fixtures). Mitigation: phase 1 keeps a + feature flag (`xnet:experiment:layout-tree`) with the old shells intact + until parity is source-grep-guarded. +2. **Preset drift = the new shell fork.** If quiet/calm/bench presets grow + preset-specific code paths, we've rebuilt the three-shell problem inside + one component. Mitigation: presets must be _data only_ (workspace nodes); + CI asserts presets round-trip through the public schema. +3. **Sync semantics for layout.** LWW on a whole workspace node could clobber + a concurrent rearrange on another device. Likely fine (layouts are + low-contention, and the change log preserves history), but decide: node + payload LWW (simple, recommended first) vs per-slot CRDT keys (later, if + real complaints). +4. **Choice paralysis / mess-making.** Free composition lets users strand + themselves in bad layouts. Mitigations already in the grammar: Esc ladder, + `Workspace: Reset to preset`, presets as read-only anchors, empty-state + seeds (0273's starter chips). +5. **Capability model for shell mutation.** Should a plugin be able to move + _other_ views, or only place its own? Proposal: plugins may only place + their own contributions (`defaultRegion`), everything else requires the + user or the companion acting with explicit `xnet:workspace` schemaWrite + consent. Needs a decision before phase 5. +6. **Electron sequencing.** Porting `ShellFrame` to Electron is real work + (its command wiring is ref-based, no palette). Do we gate phase 3 on it, + or accept temporary divergence with a parity test? Recommendation: accept + divergence through phase 3, port in phase 5 alongside the agent work, keep + the 0238-style parity guard. +7. **Performance budget.** Movable slots must not turn the shell into a + re-render storm; the tree should be normalized in zustand with per-slot + selectors (the store already follows this style), and live widget cards + keep 0273's widget budget. +8. **Mobile projection rules.** Which regions exist below 768px? Proposal: + `surface` + `overlay.*` + a bottom-sheet projection of `dock.*`, with the + 0273 touch-twin table as the contract; the tree is shared, only the + renderer differs. + +## Implementation Checklist + +### Phase 1 — Layout tree engine + +- [ ] Define `LayoutTree`, `RegionId`, `SlotPlacement` in + `apps/web/src/workbench/layout-tree.ts` with unit tests +- [ ] Add tree state + actions (`moveSlot`, `setSlotTier`, `applyPreset`) to + `useWorkbench` behind `xnet:experiment:layout-tree`; zustand `migrate` + from `xnet:workbench:v1` panel booleans +- [ ] Build `ShellFrame` rendering regions/slots; express calm, workbench, + and quiet as preset tree fixtures rendering pixel-equivalently +- [ ] Source-grep tripwire test: no component may branch on preset name +- [ ] Route/mode reconciliation (`calm/modes.ts`) reads the tree, not + `layout` + +### Phase 2 — Slots and movable views + +- [ ] Generalize `SurfaceDockContribution` → `SlotContribution` + (`defaultRegion`, `allowedRegions`) in `packages/plugins` sub-barrel; + changeset (minor; keep `SurfaceDockContribution` alias to avoid a major) +- [ ] Migrate dock residents, ListPane, ContextualCanvas, tray views, Rail + items, and status glyphs to slot contributions +- [ ] `slot.move` / `slot.tier` commands + context menus + drag handles; + touch twins (long-press → move sheet) +- [ ] Palette entries for every slot verb; chords documented in shortcut help + +### Phase 3 — Workspaces as nodes + +- [ ] `xnet:workspace` schema (portable payload only) + registration; seed + the three presets as system workspaces (Tier-1 seeder in + `packages/devtools/src/seed/seeders/`) +- [ ] Save/Switch/Share/Reset commands + quick switcher UI +- [ ] Device-local size store keyed by workspace id (unsynced) +- [ ] Round-trip test: preset → node → tree → identical render +- [ ] Changesets for `data`/`views`/`plugins` surface changes (fixed-core + lockstep) + +### Phase 4 — Customization slope polish + +- [ ] Extend the 0273 Esc ladder across L2–L4 (arrange → saved → composed) +- [ ] "Pin to Desk" and slash-embed flows reuse slot registry entries +- [ ] Empty states and starter chips for the bench preset +- [ ] Three-roads audit table in the PR description (review gate) +- [ ] Onboarding coachmarks (0206 pattern) for `Workspace: Save as…` + +### Phase 5 — Agent + ecosystem + +- [ ] Companion `workspace.edit` tools emitting registered commands with + undo toasts and change-log entries +- [ ] Agent-scaffolded `FeatureModule` flow: template, consent dialog, + `ai-generated` provenance tier, `network: []` default +- [ ] Electron adopts `ShellFrame` + presets; retire `ShellKind` fork; parity + guard test +- [ ] Marketplace listing type for shared benches (workspace nodes + + optional licensed views) + +## Validation Checklist + +- [ ] A new identity lands in the default preset and can reach every feature + of today's calm shell with zero customization (L0/L1 completeness) +- [ ] Persisted `xnet:workbench:v1` state from a production profile migrates + with panels, tabs, pins, shelf, and startup node intact +- [ ] The three presets render pixel-equivalent to the pre-refactor shells + (screenshot diff via the 0185 visual-capture CI) +- [ ] A view moved from the corner dock to the left dock survives reload, + workspace switch, and sync to a second device +- [ ] A workspace shared to a second identity opens with the same tree and + only that identity's permitted views (capability scoping holds) +- [ ] Esc from any rung walks down one level at a time to L0 (automated + interaction test) +- [ ] Every slot verb is reachable via pointer, touch twin, and ⌘K (audit + table checked in review) +- [ ] Companion "set up a bench" produces a visible, undoable diff and a + change-log entry; Undo restores the prior tree exactly +- [ ] An AI-scaffolded view runs with only declared capabilities (guarded + store denies an undeclared schema read in test) +- [ ] Shell re-render count for a slot move stays bounded (no full-frame + re-render; profiler assertion) + +## References + +- [Ink & Switch — Malleable Software](https://www.inkandswitch.com/essay/malleable-software/) +- [Ink & Switch — End-User Programming](https://www.inkandswitch.com/end-user-programming/) +- [Ink & Switch — Potluck: Dynamic Documents as Personal Software](https://www.inkandswitch.com/potluck/) +- [xNet — The Workshop and the Walled Garden](https://xnet.fyi/blog/the-workshop-and-the-walled-garden/) + (`site/src/pages/blog/the-workshop-and-the-walled-garden.astro`) +- [VS Code — User Interface](https://code.visualstudio.com/docs/getstarted/userinterface) · + [Contribution Points](https://code.visualstudio.com/api/references/contribution-points) · + [When-Clause Contexts](https://code.visualstudio.com/api/references/when-clause-contexts) · + [Custom Layout](https://code.visualstudio.com/docs/configure/custom-layout) +- [Zed — New Panel System](https://zed.dev/blog/new-panel-system) +- [Obsidian — Workspace model](https://docs.obsidian.md/Plugins/User+interface/Workspace) · + [Workspaces plugin](https://help.obsidian.md/plugins/workspaces) +- [Notion — The data model behind Notion's flexibility](https://www.notion.com/blog/data-model-behind-notion) +- [Anthropic — Artifacts](https://www.anthropic.com/news/artifacts) · + [OpenAI — Introducing Canvas](https://openai.com/index/introducing-canvas/) +- [Acme — A User Interface for Programmers (Plan 9)](https://plan9.io/sys/doc/acme/acme.html) +- [Malleable Systems Collective — Emacs, the most successful malleable system](https://malleable.systems/blog/2020/04/01/the-most-successful-malleable-system-in-history/) +- [tldraw — composable canvas primitives](https://tldraw.dev/) +- Repo lineage: `docs/explorations/0166_[x]_MINIMAL_WORKBENCH_SHELL_REDESIGN.md`, + `0232_[_]_COZY_CALM_AND_AGENT_FIRST_A_DELIGHTFUL_PLACE_TO_SPEND_THE_DAY.md` (theme axes), + `0250_[_]_THE_EVERYPERSON_SHELL_A_CLAUDE_DESKTOP_UI_FOR_XNET.md`, + `0273_[x]_QUIET_SURFACE_WORKSPACE_SHELL.md`, + `0276_[x]_WELL_TRAVELED_CODE_PATHS_CHURN_WEIGHTED_REFACTOR_MAP.md` (shared-core playbook), + `0277_[x]_CANVASVIEW_FEATURE_PARITY_AUDIT_AND_CONVERGENCE_DECISIONS.md` From 85936b9874580dc439ba8827dd29dd0d8f4d0f57 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Tue, 7 Jul 2026 12:41:24 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat(web):=20layout=20tree=20engine=20?= =?UTF-8?q?=E2=80=94=20one=20ShellFrame,=20shells=20as=20presets=20(0280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regions → slots → views as data: createPresetTree(quiet|calm|bench), pure move/tier ops, workspace-payload round trip, store integration with v1→v2 persist migration, flag-gated ShellFrame renderer, preset tripwire. Co-Authored-By: Claude Fable 5 --- apps/web/src/workbench/ShellFrame.tsx | 250 ++++++++++++++ apps/web/src/workbench/Workbench.tsx | 19 +- apps/web/src/workbench/calm/CalmShell.tsx | 17 +- .../web/src/workbench/calm/use-active-mode.ts | 24 ++ apps/web/src/workbench/experiments.ts | 22 ++ apps/web/src/workbench/layout-tree.test.ts | 153 +++++++++ apps/web/src/workbench/layout-tree.ts | 319 ++++++++++++++++++ apps/web/src/workbench/state.test.ts | 80 +++++ apps/web/src/workbench/state.ts | 130 ++++++- ...ALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md | 68 ++-- 10 files changed, 1026 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/workbench/ShellFrame.tsx create mode 100644 apps/web/src/workbench/calm/use-active-mode.ts create mode 100644 apps/web/src/workbench/experiments.ts create mode 100644 apps/web/src/workbench/layout-tree.test.ts create mode 100644 apps/web/src/workbench/layout-tree.ts diff --git a/apps/web/src/workbench/ShellFrame.tsx b/apps/web/src/workbench/ShellFrame.tsx new file mode 100644 index 000000000..caf8ad4c0 --- /dev/null +++ b/apps/web/src/workbench/ShellFrame.tsx @@ -0,0 +1,250 @@ +/** + * ShellFrame — one renderer for every shell posture (exploration 0280). + * + * Walks the workbench store's {@link LayoutTree} and renders regions → + * slots → views. The former shells are preset trees: what used to be the + * CalmShell, the workbench grid and the quiet posture are now data-only + * fixtures over this single component. ShellFrame never branches on which + * preset is loaded — only on the tree's axes (chrome posture, slot tiers, + * `surface.tabsEnabled`); the tripwire in layout-tree.test.ts enforces it. + * + * Behind `xnet:experiment:layout-tree` (see experiments.ts); the legacy + * shells render by default until parity is proven. + */ +import type { ComponentType, ReactNode } from 'react' +import { DemoBanner, useDemoMode } from '@xnetjs/react' +import { Group, Panel, useDefaultLayout } from 'react-resizable-panels' +import { GlobalSearch } from '../components/GlobalSearch' +import { UndoToastProvider } from '../components/UndoToast' +import { WorkspaceCommands } from '../components/WorkspaceCommands' +import { CalmSurface } from './calm/CalmSurface' +import { Canvas } from './calm/Canvas' +import { ListPane } from './calm/ListPane' +import { ModeSwitch } from './calm/ModeSwitch' +import { QuietChrome } from './calm/QuietChrome' +import { SurfaceDockLauncher } from './calm/SurfaceDock' +import { useActiveCalmMode } from './calm/use-active-mode' +import { useWorkbenchCommands, useZenEscape } from './commands' +import { ContextPanel } from './ContextPanel' +import { EditorArea } from './EditorArea' +import { useFocusRing } from './focus' +import { Hairline } from './Hairline' +import { slotsIn, type LayoutTree, type RegionId } from './layout-tree' +import { PanelViewHost } from './PanelViewHost' +import { Rail } from './Rail' +import { useWorkbench, type CalmMode, type PanelSide } from './state' +import { StatusBar } from './StatusBar' + +const FRAME = + 'mt-[var(--storage-banner-height,0px)] flex h-[calc(100dvh-var(--storage-banner-height,0px))] flex-col text-ink-1' + +/** + * Bare frame views: slot views that bring their own chrome, keyed by the + * placement's viewId. Everything else falls back to the PanelViewHost + * (header + registered panel views) for its dock. + */ +const BARE_VIEWS: Record> = { + navigator: ({ mode }) => , + context: () => , + inspector: () => +} + +/** Rail-region views (edge strips, not dock panels). */ +const RAIL_VIEWS: Record = { + modes: ModeSwitch, + rail: Rail +} + +/** Status-region views. */ +const STATUS_VIEWS: Record = { + status: StatusBar +} + +function FrameDemoBanner() { + const { isDemo, limits } = useDemoMode() + if (!isDemo || !limits) return null + return +} + +/** The dock body: the active placement's view, bare or hosted. */ +function DockBody({ + tree, + region, + side, + mode +}: { + tree: LayoutTree + region: RegionId + side: PanelSide + mode: CalmMode +}) { + const activeViewId = useWorkbench((state) => state[side].activeViewId) + const placements = slotsIn(tree, region) + const active = placements.find((placement) => placement.viewId === activeViewId) ?? placements[0] + const Bare = active ? BARE_VIEWS[active.viewId] : undefined + if (Bare) return + // Panel-view docks (the 0166 registries) — left and bottom hosts. + if (side === 'left' || side === 'bottom') return + return +} + +function EdgeStrip({ tree, region }: { tree: LayoutTree; region: 'rail' | 'status' }) { + const views = region === 'rail' ? RAIL_VIEWS : STATUS_VIEWS + const pinned = slotsIn(tree, region, 'pinned') + return ( + <> + {pinned.map((placement) => { + const View = views[placement.viewId] + return View ? : null + })} + + ) +} + +/** Surface = the center region; tabs are a capability, not a shell. */ +function Surface({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + if (tree.surface.tabsEnabled) return {children} + return {children} +} + +function ZenFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + return ( +
+ + +
+ {children} +
+
+ ) +} + +/** + * Pinned-chrome frame: resizable docks around the surface. Panel sizes are + * device-local and keyed by the workspace id (0280 phase 3) — a saved bench + * carries its placements everywhere, its pixel widths nowhere. + */ +function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + const mode = useActiveCalmMode() + const left = useWorkbench((state) => state.left) + const right = useWorkbench((state) => state.right) + const bottom = useWorkbench((state) => state.bottom) + + const leftPlaced = slotsIn(tree, 'dock.left').length > 0 + const rightPlaced = slotsIn(tree, 'dock.right').length > 0 + const bottomPlaced = slotsIn(tree, 'dock.bottom').length > 0 + const leftOpen = leftPlaced && left.open + const rightOpen = rightPlaced && right.open + const bottomOpen = bottomPlaced && bottom.open + const cornerPlaced = slotsIn(tree, 'dock.corner').length > 0 + + const horizontal = useDefaultLayout({ + id: `xnet:frame:h:${tree.workspaceId}`, + panelIds: [...(leftOpen ? ['left'] : []), 'center', ...(rightOpen ? ['right'] : [])] + }) + const vertical = useDefaultLayout({ + id: `xnet:frame:v:${tree.workspaceId}`, + panelIds: ['editor', ...(bottomOpen ? ['bottom'] : [])] + }) + + return ( +
+ + + + +
+ + + {leftOpen && ( + <> + + + + + + )} + + + + {children} + + {bottomOpen && ( + <> + + + + + + )} + + + {rightOpen && ( + <> + + + + + + )} + + {/* The corner dock renders wherever the tree places residents — + pinned calm gains the same launcher quiet has (0280). */} + {cornerPlaced && } +
+ + +
+ ) +} + +function QuietFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + const mode = useActiveCalmMode() + return ( +
+ + + + + {children} + +
+ ) +} + +export function ShellFrame({ children }: { children: ReactNode }) { + useWorkbenchCommands() + useZenEscape() + useFocusRing() + + const tree = useWorkbench((state) => state.tree) + const mode = useWorkbench((state) => state.mode) + + if (mode === 'zen') { + return ( + + {children} + + ) + } + + return ( + + {tree.chrome === 'quiet' ? ( + {children} + ) : ( + {children} + )} + + ) +} diff --git a/apps/web/src/workbench/Workbench.tsx b/apps/web/src/workbench/Workbench.tsx index a69b0a427..e57abe831 100644 --- a/apps/web/src/workbench/Workbench.tsx +++ b/apps/web/src/workbench/Workbench.tsx @@ -20,11 +20,13 @@ import { CalmShell } from './calm/CalmShell' import { useWorkbenchCommands, useZenEscape } from './commands' import { ContextPanel } from './ContextPanel' import { EditorArea } from './EditorArea' +import { isLayoutTreeEnabled } from './experiments' import { useFocusRing } from './focus' import { Hairline } from './Hairline' import { MobileShell } from './MobileShell' import { PanelViewHost } from './PanelViewHost' import { Rail } from './Rail' +import { ShellFrame } from './ShellFrame' import { useWorkbench } from './state' import { StatusBar } from './StatusBar' import { useIsCompact } from './use-layout-mode' @@ -115,9 +117,24 @@ function WorkbenchDemoBanner() { export function Workbench({ children }: { children: ReactNode }) { const compact = useIsCompact() const layout = useWorkbench((state) => state.layout) + const tabsEnabled = useWorkbench((state) => state.tree.surface.tabsEnabled) + // 0280: behind the layout-tree flag the ShellFrame renders every posture + // from the tree; the legacy fork below stays the default until parity. + // Mobile projections read the tree's axes too (tabsEnabled, not layout). + const treeShell = isLayoutTreeEnabled() return ( <> - {layout === 'calm' ? ( + {treeShell ? ( + compact ? ( + tabsEnabled ? ( + {children} + ) : ( + {children} + ) + ) : ( + {children} + ) + ) : layout === 'calm' ? ( // Everyperson shell (0250): the same three-mode grammar at every width — // CalmMobile reflows it to a bottom-tab phone layout (Phase 4), CalmShell // is the desktop/tablet composition. diff --git a/apps/web/src/workbench/calm/CalmShell.tsx b/apps/web/src/workbench/calm/CalmShell.tsx index 90ea593dc..3d4666b15 100644 --- a/apps/web/src/workbench/calm/CalmShell.tsx +++ b/apps/web/src/workbench/calm/CalmShell.tsx @@ -12,9 +12,7 @@ * and every existing view renders unchanged in the Surface. */ import type { ReactNode } from 'react' -import { useLocation } from '@tanstack/react-router' import { DemoBanner, useDemoMode } from '@xnetjs/react' -import { useEffect } from 'react' import { GlobalSearch } from '../../components/GlobalSearch' import { UndoToastProvider } from '../../components/UndoToast' import { WorkspaceCommands } from '../../components/WorkspaceCommands' @@ -24,9 +22,9 @@ import { useWorkbench } from '../state' import { CalmSurface } from './CalmSurface' import { Canvas } from './Canvas' import { ListPane } from './ListPane' -import { modeForPath } from './modes' import { ModeSwitch } from './ModeSwitch' import { QuietChrome } from './QuietChrome' +import { useActiveCalmMode } from './use-active-mode' const CALM_FRAME = 'mt-[var(--storage-banner-height,0px)] flex h-[calc(100dvh-var(--storage-banner-height,0px))] flex-col bg-surface-1 text-ink-1' @@ -42,22 +40,13 @@ export function CalmShell({ children }: { children: ReactNode }) { useZenEscape() useFocusRing() - const { pathname } = useLocation() const mode = useWorkbench((state) => state.mode) const chrome = useWorkbench((state) => state.chrome) - const storedMode = useWorkbench((state) => state.calmMode) - const setCalmMode = useWorkbench((state) => state.setCalmMode) const listOpen = useWorkbench((state) => state.left.open) const canvasOpen = useWorkbench((state) => state.right.open) - // The route is authoritative for the active mode (so deep links + back/forward - // keep the List and ModeSwitch honest); modeless surfaces (settings) fall back - // to the last real mode. Persist that fallback so it survives navigation. - const routeMode = modeForPath(pathname) - const activeMode = routeMode ?? storedMode - useEffect(() => { - if (routeMode && routeMode !== storedMode) setCalmMode(routeMode) - }, [routeMode, storedMode, setCalmMode]) + // Route ↔ mode reconciliation, shared with the ShellFrame (0280). + const activeMode = useActiveCalmMode() // Focus (zen): chrome hidden, just the surface — same affordance as the // workbench, restored on exit. diff --git a/apps/web/src/workbench/calm/use-active-mode.ts b/apps/web/src/workbench/calm/use-active-mode.ts new file mode 100644 index 000000000..ed3efa61a --- /dev/null +++ b/apps/web/src/workbench/calm/use-active-mode.ts @@ -0,0 +1,24 @@ +/** + * Route ↔ mode reconciliation (0250, shared since 0280). + * + * The route is authoritative for the active calm mode (deep links and + * back/forward keep the List and ModeSwitch honest); modeless surfaces + * (settings) fall back to the last real mode, persisted so it survives + * navigation. Extracted from CalmShell so the ShellFrame reconciles the + * same way without duplicating the effect. + */ +import { useLocation } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useWorkbench, type CalmMode } from '../state' +import { modeForPath } from './modes' + +export function useActiveCalmMode(): CalmMode { + const { pathname } = useLocation() + const storedMode = useWorkbench((state) => state.calmMode) + const setCalmMode = useWorkbench((state) => state.setCalmMode) + const routeMode = modeForPath(pathname) + useEffect(() => { + if (routeMode && routeMode !== storedMode) setCalmMode(routeMode) + }, [routeMode, storedMode, setCalmMode]) + return routeMode ?? storedMode +} diff --git a/apps/web/src/workbench/experiments.ts b/apps/web/src/workbench/experiments.ts new file mode 100644 index 000000000..ef3ce0e2a --- /dev/null +++ b/apps/web/src/workbench/experiments.ts @@ -0,0 +1,22 @@ +/** + * Workbench experiment flags (0280). + * + * Same staged-rollout pattern as the Desk flags in `lib/desk.ts`: opt-in + * via localStorage while dogfooding; flipping the default later is + * inverting the check, never a migration. + */ + +/** + * Render the shell from the layout tree via ShellFrame (0280 phase 1) + * instead of the legacy CalmShell / workbench-grid fork. The tree state is + * always maintained; this flag only chooses the renderer. + */ +export const LAYOUT_TREE_KEY = 'xnet:experiment:layout-tree' + +export function isLayoutTreeEnabled(): boolean { + try { + return localStorage.getItem(LAYOUT_TREE_KEY) === '1' + } catch { + return false + } +} diff --git a/apps/web/src/workbench/layout-tree.test.ts b/apps/web/src/workbench/layout-tree.test.ts new file mode 100644 index 000000000..ae194f8b3 --- /dev/null +++ b/apps/web/src/workbench/layout-tree.test.ts @@ -0,0 +1,153 @@ +/** + * LayoutTree tests (0280): preset fixtures, pure tree operations, the + * workspace-payload round trip, and the preset tripwire — no shell + * component may branch on which preset is loaded (presets are data only). + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + createPresetTree, + moveSlot, + parseWorkspacePayload, + placementOf, + PRESET_IDS, + presetForWorkspaceId, + presetWorkspaceId, + regionOf, + serializeWorkspacePayload, + setSlotTier, + slotsIn +} from './layout-tree' + +describe('presets', () => { + it('builds a distinct tree per preset with stable workspace ids', () => { + for (const preset of PRESET_IDS) { + const tree = createPresetTree(preset) + expect(tree.workspaceId).toBe(presetWorkspaceId(preset)) + expect(presetForWorkspaceId(tree.workspaceId)).toBe(preset) + } + expect(presetForWorkspaceId('workspace-abc')).toBeNull() + }) + + it('expresses the former shells as data: chrome + tabs + placements', () => { + const quiet = createPresetTree('quiet') + const calm = createPresetTree('calm') + const bench = createPresetTree('bench') + + expect(quiet.chrome).toBe('quiet') + expect(calm.chrome).toBe('pinned') + expect(bench.surface.tabsEnabled).toBe(true) + expect(calm.surface.tabsEnabled).toBe(false) + + // Calm pins the navigator; quiet summons the same view. + expect(placementOf(calm, 'navigator')?.tier).toBe('pinned') + expect(placementOf(quiet, 'navigator')?.tier).toBe('summoned') + + // The bench keeps the 0166 left stack and the status bar. + expect(slotsIn(bench, 'dock.left').map((p) => p.viewId)).toContain('explorer') + expect(slotsIn(bench, 'status', 'pinned')).toHaveLength(1) + }) +}) + +describe('tree operations', () => { + it('moves a view between regions, renumbering both sides', () => { + const tree = createPresetTree('bench') + const moved = moveSlot(tree, 'console', 'dock.corner') + expect(regionOf(moved, 'console')).toBe('dock.corner') + expect(slotsIn(moved, 'dock.bottom').map((p) => p.viewId)).not.toContain('console') + // Orders stay dense in the source region. + expect(slotsIn(moved, 'dock.bottom').map((p) => p.order)).toEqual([0, 1, 2, 3]) + // The original tree is untouched (pure). + expect(regionOf(tree, 'console')).toBe('dock.bottom') + }) + + it('no-ops on unknown views and same-region moves', () => { + const tree = createPresetTree('calm') + expect(moveSlot(tree, 'nope', 'dock.left')).toBe(tree) + expect(moveSlot(tree, 'navigator', 'dock.left')).toBe(tree) + }) + + it('changes a tier in place', () => { + const tree = createPresetTree('calm') + const hidden = setSlotTier(tree, 'context', 'hidden') + expect(placementOf(hidden, 'context')?.tier).toBe('hidden') + expect(setSlotTier(hidden, 'context', 'hidden')).toBe(hidden) + }) +}) + +describe('workspace payload round trip', () => { + it('round-trips every preset losslessly', () => { + for (const preset of PRESET_IDS) { + const payload = { + name: `Preset ${preset}`, + preset, + tree: createPresetTree(preset) + } + const parsed = parseWorkspacePayload( + JSON.parse(JSON.stringify(serializeWorkspacePayload(payload))) + ) + expect(parsed).toEqual(payload) + } + }) + + it('drops malformed placements and unknown regions without crashing', () => { + const parsed = parseWorkspacePayload({ + name: 'From a stranger', + preset: 'nonsense', + tree: { + workspaceId: 'workspace-x', + chrome: 'sideways', + surface: { tabsEnabled: 'yes' }, + regions: { + 'dock.left': [ + { viewId: 'navigator', tier: 'pinned', order: 0 }, + { viewId: 42, tier: 'pinned', order: 1 }, + { viewId: 'ghost', tier: 'launched', order: 2 } + ], + 'dock.diagonal': [{ viewId: 'navigator', tier: 'pinned', order: 0 }] + } + } + }) + expect(parsed).not.toBeNull() + expect(parsed?.preset).toBeNull() + expect(parsed?.tree.chrome).toBe('pinned') + expect(parsed?.tree.surface.tabsEnabled).toBe(false) + expect(parsed?.tree.regions['dock.left']).toEqual([ + { viewId: 'navigator', tier: 'pinned', order: 0 } + ]) + expect(parseWorkspacePayload(null)).toBeNull() + expect(parseWorkspacePayload({ name: 7 })).toBeNull() + }) +}) + +/** + * The preset tripwire (0280 risk 2): if shell components branch on which + * preset is loaded, we have rebuilt the three-shell fork inside one + * component. Presets must stay data-only — components read the tree's + * axes (chrome, tiers, tabsEnabled), never the preset identity. + */ +describe('preset tripwire', () => { + const FORBIDDEN = [ + /\bpreset(Id)?\s*===/, + /\bworkspaceId\s*===\s*['"`]/, + /presetForWorkspaceId\([^)]*\)\s*===/ + ] + + function componentFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return componentFiles(path) + return entry.name.endsWith('.tsx') && !entry.name.endsWith('.test.tsx') ? [path] : [] + }) + } + + it('no shell component branches on the loaded preset', () => { + const offenders: string[] = [] + for (const file of componentFiles(__dirname)) { + const source = readFileSync(file, 'utf8') + if (FORBIDDEN.some((pattern) => pattern.test(source))) offenders.push(file) + } + expect(offenders).toEqual([]) + }) +}) diff --git a/apps/web/src/workbench/layout-tree.ts b/apps/web/src/workbench/layout-tree.ts new file mode 100644 index 000000000..879248bb3 --- /dev/null +++ b/apps/web/src/workbench/layout-tree.ts @@ -0,0 +1,319 @@ +/** + * LayoutTree — the shell as data (exploration 0280). + * + * One tree of regions → slots → view references replaces the hard fork + * between the calm shell and the workbench grid. The former shells become + * *presets*: named, data-only configurations of the same tree (`quiet`, + * `calm`, `bench`). Components never branch on which preset is loaded — + * only on the tree's axes (chrome posture, slot tiers, tabsEnabled); the + * source-grep tripwire in layout-tree.test.ts enforces this. + * + * The module is pure (no DOM, no zustand) so presets round-trip through + * the `xnet:workspace` node payload and unit tests without a shell. + */ + +export type ChromePosture = 'pinned' | 'quiet' + +/** + * The fixed region skeleton (the Zed/VS Code lesson: regions never + * multiply, only their contents move). `surface` is the center and always + * exists; docks collapse; `rail`/`status` are edge strips. + */ +export type RegionId = + | 'surface' + | 'rail' + | 'status' + | 'dock.left' + | 'dock.right' + | 'dock.bottom' + | 'dock.corner' + +export const REGION_IDS: RegionId[] = [ + 'surface', + 'rail', + 'status', + 'dock.left', + 'dock.right', + 'dock.bottom', + 'dock.corner' +] + +/** + * Disclosure tier of a placed view: `pinned` renders in the frame at rest, + * `summoned` is reachable from its region's launcher/chord/palette, + * `hidden` only from the palette. + */ +export type SlotTier = 'pinned' | 'summoned' | 'hidden' + +export interface SlotPlacement { + /** Contribution id from the slot registry (e.g. 'navigator', 'shelf'). */ + viewId: string + tier: SlotTier + /** Sort key within the region (lower = earlier). */ + order: number +} + +export interface LayoutTree { + /** Which workspace (preset or saved node) this tree was loaded from. */ + workspaceId: string + regions: Record + /** + * Surface capabilities. Tabs/split groups are content state and stay in + * the workbench store; the tree only says whether the surface *shows* + * them (the bench's tab strip vs the calm bare surface). + */ + surface: { tabsEnabled: boolean } + /** Chrome posture axis (0273) — orthogonal to placements. */ + chrome: ChromePosture +} + +/** Built-in presets — read-only system workspaces (0280 phase 3). */ +export type PresetId = 'quiet' | 'calm' | 'bench' + +export const PRESET_IDS: PresetId[] = ['quiet', 'calm', 'bench'] + +/** Deterministic node ids for the seeded system workspaces. */ +export const PRESET_WORKSPACE_ID_PREFIX = 'workspace-preset-' + +export function presetWorkspaceId(preset: PresetId): string { + return `${PRESET_WORKSPACE_ID_PREFIX}${preset}` +} + +export function presetForWorkspaceId(workspaceId: string): PresetId | null { + if (!workspaceId.startsWith(PRESET_WORKSPACE_ID_PREFIX)) return null + const rest = workspaceId.slice(PRESET_WORKSPACE_ID_PREFIX.length) + return (PRESET_IDS as string[]).includes(rest) ? (rest as PresetId) : null +} + +function place(viewId: string, tier: SlotTier, order: number): SlotPlacement { + return { viewId, tier, order } +} + +function emptyRegions(): Record { + return { + surface: [], + rail: [], + status: [], + 'dock.left': [], + 'dock.right': [], + 'dock.bottom': [], + 'dock.corner': [] + } +} + +/** + * The corner dock's residents are identical across presets (the 0273 + * grammar): hero strip + secondary behind More, all summoned. + */ +function cornerResidents(): SlotPlacement[] { + return [ + place('shelf', 'summoned', 0), + place('capture', 'summoned', 1), + place('notifications', 'summoned', 2), + place('sync', 'summoned', 3), + place('console', 'summoned', 4) + ] +} + +/** + * Build the tree for a built-in preset. Presets are data ONLY — every + * difference between the former shells must be expressible here, or it + * does not exist. + */ +export function createPresetTree(preset: PresetId): LayoutTree { + const regions = emptyRegions() + switch (preset) { + case 'quiet': + regions.rail = [place('modes', 'summoned', 0)] + regions['dock.left'] = [place('navigator', 'summoned', 0)] + regions['dock.right'] = [place('context', 'summoned', 0)] + regions['dock.corner'] = cornerResidents() + return { + workspaceId: presetWorkspaceId('quiet'), + regions, + surface: { tabsEnabled: false }, + chrome: 'quiet' + } + case 'calm': + regions.rail = [place('modes', 'pinned', 0)] + regions['dock.left'] = [place('navigator', 'pinned', 0)] + regions['dock.right'] = [place('context', 'summoned', 0)] + regions['dock.corner'] = cornerResidents() + return { + workspaceId: presetWorkspaceId('calm'), + regions, + surface: { tabsEnabled: false }, + chrome: 'pinned' + } + case 'bench': + regions.rail = [place('rail', 'pinned', 0)] + regions.status = [place('status', 'pinned', 0)] + regions['dock.left'] = [ + place('explorer', 'pinned', 0), + place('chats', 'summoned', 1), + place('tasks', 'summoned', 2), + place('today', 'summoned', 3), + place('data', 'summoned', 4), + place('ai-chat', 'summoned', 5) + ] + regions['dock.right'] = [place('context', 'summoned', 0)] + regions['dock.bottom'] = [ + place('shelf', 'summoned', 0), + place('capture', 'summoned', 1), + place('notifications', 'summoned', 2), + place('sync', 'summoned', 3), + place('console', 'summoned', 4) + ] + regions['dock.corner'] = [] + return { + workspaceId: presetWorkspaceId('bench'), + regions, + surface: { tabsEnabled: true }, + chrome: 'pinned' + } + } +} + +// ─── Pure tree operations (every mutation is a command; commands call these) ── + +function cloneRegions(regions: Record) { + return Object.fromEntries( + Object.entries(regions).map(([region, placements]) => [ + region, + placements.map((placement) => ({ ...placement })) + ]) + ) as Record +} + +/** The region currently holding a view, if any. */ +export function regionOf(tree: LayoutTree, viewId: string): RegionId | null { + for (const region of REGION_IDS) { + if (tree.regions[region].some((placement) => placement.viewId === viewId)) return region + } + return null +} + +/** The placement record for a view, if any. */ +export function placementOf(tree: LayoutTree, viewId: string): SlotPlacement | null { + const region = regionOf(tree, viewId) + if (!region) return null + return tree.regions[region].find((placement) => placement.viewId === viewId) ?? null +} + +function renumber(placements: SlotPlacement[]): SlotPlacement[] { + return [...placements] + .sort((a, b) => a.order - b.order) + .map((placement, index) => ({ ...placement, order: index })) +} + +/** + * Move a view to another region (appended last), keeping its tier. + * No-op when the view is absent or already there. + */ +export function moveSlot(tree: LayoutTree, viewId: string, to: RegionId): LayoutTree { + const from = regionOf(tree, viewId) + if (!from || from === to) return tree + const placement = tree.regions[from].find((entry) => entry.viewId === viewId) + if (!placement) return tree + const regions = cloneRegions(tree.regions) + regions[from] = renumber(regions[from].filter((entry) => entry.viewId !== viewId)) + regions[to] = renumber([...regions[to], { ...placement, order: Number.MAX_SAFE_INTEGER }]) + return { ...tree, regions } +} + +/** Change a placed view's disclosure tier. No-op when absent/unchanged. */ +export function setSlotTier(tree: LayoutTree, viewId: string, tier: SlotTier): LayoutTree { + const region = regionOf(tree, viewId) + if (!region) return tree + const current = tree.regions[region].find((entry) => entry.viewId === viewId) + if (!current || current.tier === tier) return tree + const regions = cloneRegions(tree.regions) + regions[region] = regions[region].map((entry) => + entry.viewId === viewId ? { ...entry, tier } : entry + ) + return { ...tree, regions } +} + +/** Placements of a region, sorted, optionally filtered to a tier. */ +export function slotsIn(tree: LayoutTree, region: RegionId, tier?: SlotTier): SlotPlacement[] { + const placements = [...tree.regions[region]].sort((a, b) => a.order - b.order) + return tier ? placements.filter((placement) => placement.tier === tier) : placements +} + +// ─── Serialization (the workspace node payload — phase 3) ───────────────── + +/** + * Portable workspace payload: the tree plus a human name. Device-local + * state (pixel sizes, monitor splits) deliberately stays OUT of the node — + * it lives in the local store keyed by workspaceId. + */ +export interface WorkspacePayload { + name: string + /** Preset provenance, for "Reset to preset". Null = built from scratch. */ + preset: PresetId | null + tree: LayoutTree +} + +const TIERS: SlotTier[] = ['pinned', 'summoned', 'hidden'] + +function isPlacement(value: unknown): value is SlotPlacement { + if (typeof value !== 'object' || value === null) return false + const p = value as Record + return ( + typeof p.viewId === 'string' && + typeof p.order === 'number' && + TIERS.includes(p.tier as SlotTier) + ) +} + +/** + * Parse an untrusted payload (a synced node) back into a tree. Unknown + * regions are dropped, missing regions become empty, malformed placements + * are skipped — a shared workspace must never crash the shell. + */ +export function parseWorkspacePayload(value: unknown): WorkspacePayload | null { + if (typeof value !== 'object' || value === null) return null + const payload = value as Record + if (typeof payload.name !== 'string') return null + const rawTree = payload.tree as Record | undefined + if (typeof rawTree !== 'object' || rawTree === null) return null + if (typeof rawTree.workspaceId !== 'string') return null + + const chrome: ChromePosture = rawTree.chrome === 'quiet' ? 'quiet' : 'pinned' + const rawSurface = rawTree.surface as Record | undefined + const tabsEnabled = rawSurface?.tabsEnabled === true + + const regions = emptyRegions() + const rawRegions = rawTree.regions as Record | undefined + if (typeof rawRegions === 'object' && rawRegions !== null) { + for (const region of REGION_IDS) { + const entries = rawRegions[region] + if (!Array.isArray(entries)) continue + regions[region] = renumber(entries.filter(isPlacement)) + } + } + + const preset = PRESET_IDS.includes(payload.preset as PresetId) + ? (payload.preset as PresetId) + : null + + return { + name: payload.name, + preset, + tree: { workspaceId: rawTree.workspaceId, regions, surface: { tabsEnabled }, chrome } + } +} + +/** Serialize for the node payload. Inverse of {@link parseWorkspacePayload}. */ +export function serializeWorkspacePayload(payload: WorkspacePayload): WorkspacePayload { + return { + name: payload.name, + preset: payload.preset, + tree: { + workspaceId: payload.tree.workspaceId, + regions: cloneRegions(payload.tree.regions), + surface: { tabsEnabled: payload.tree.surface.tabsEnabled }, + chrome: payload.tree.chrome + } + } +} diff --git a/apps/web/src/workbench/state.test.ts b/apps/web/src/workbench/state.test.ts index ba9dbdc55..209223603 100644 --- a/apps/web/src/workbench/state.test.ts +++ b/apps/web/src/workbench/state.test.ts @@ -3,6 +3,7 @@ * semantics, pins/recents, and the shelf. */ import { beforeEach, describe, expect, it } from 'vitest' +import { createPresetTree, moveSlot, presetWorkspaceId, regionOf } from './layout-tree' import { selectActiveTab, tabIdFor, useWorkbench } from './state' function reset() { @@ -259,3 +260,82 @@ describe('chrome posture (0273)', () => { expect(persisted.chrome).toBeDefined() }) }) + +describe('layout tree (0280)', () => { + it('applyPreset replaces the tree and keeps the legacy axes coherent', () => { + useWorkbench.getState().applyPreset('bench') + const state = useWorkbench.getState() + expect(state.tree.workspaceId).toBe(presetWorkspaceId('bench')) + expect(state.layout).toBe('workbench') + expect(state.chrome).toBe('pinned') + expect(state.left).toEqual({ open: true, activeViewId: 'explorer' }) + expect(state.right.open).toBe(false) + + useWorkbench.getState().applyPreset('quiet') + const quiet = useWorkbench.getState() + expect(quiet.layout).toBe('calm') + expect(quiet.chrome).toBe('quiet') + expect(quiet.left.open).toBe(false) + expect(quiet.left.activeViewId).toBe('navigator') + }) + + it('setLayout / toggleLayout are preset switches over the tree', () => { + useWorkbench.getState().applyPreset('calm') + useWorkbench.getState().setLayout('workbench') + expect(useWorkbench.getState().tree.surface.tabsEnabled).toBe(true) + useWorkbench.getState().toggleLayout() + expect(useWorkbench.getState().tree.workspaceId).toBe(presetWorkspaceId('calm')) + }) + + it('chrome is an axis: flipping it never resets placements', () => { + useWorkbench.getState().applyPreset('calm') + useWorkbench.getState().moveSlot('context', 'dock.left') + useWorkbench.getState().setChrome('quiet') + const state = useWorkbench.getState() + expect(state.tree.chrome).toBe('quiet') + expect(regionOf(state.tree, 'context')).toBe('dock.left') + }) + + it('moveSlot mutates the tree through the store', () => { + useWorkbench.getState().applyPreset('bench') + useWorkbench.getState().moveSlot('console', 'dock.corner') + expect(regionOf(useWorkbench.getState().tree, 'console')).toBe('dock.corner') + }) + + it('setSlotTier un-pins and closes the dock resting on that view', () => { + useWorkbench.getState().applyPreset('calm') + expect(useWorkbench.getState().left).toEqual({ open: true, activeViewId: 'navigator' }) + useWorkbench.getState().setSlotTier('navigator', 'summoned') + const state = useWorkbench.getState() + expect(state.tree.regions['dock.left'][0].tier).toBe('summoned') + expect(state.left.open).toBe(false) + }) + + it('loadWorkspace adopts a payload tree', () => { + const payload = { + name: 'Monday triage', + preset: 'bench' as const, + tree: moveSlot(createPresetTree('bench'), 'tasks', 'dock.right') + } + useWorkbench.getState().loadWorkspace(payload) + expect(regionOf(useWorkbench.getState().tree, 'tasks')).toBe('dock.right') + expect(useWorkbench.getState().layout).toBe('workbench') + }) + + it('migrates a v1 persisted profile to a tree derived from its axes', () => { + const migrate = useWorkbench.persist.getOptions().migrate + expect(migrate).toBeDefined() + const migrated = migrate?.( + { layout: 'workbench', chrome: 'pinned', left: { open: true, activeViewId: 'tasks' } }, + 0 + ) as { tree?: { workspaceId: string }; left?: { activeViewId: string } } + expect(migrated?.tree?.workspaceId).toBe(presetWorkspaceId('bench')) + // Migration derives the tree but never touches the panel state. + expect(migrated?.left?.activeViewId).toBe('tasks') + + const quiet = migrate?.({ layout: 'calm', chrome: 'quiet' }, 0) as { + tree?: { workspaceId: string } + } + expect(quiet?.tree?.workspaceId).toBe(presetWorkspaceId('quiet')) + }) +}) diff --git a/apps/web/src/workbench/state.ts b/apps/web/src/workbench/state.ts index 2651f05c6..aca0131dd 100644 --- a/apps/web/src/workbench/state.ts +++ b/apps/web/src/workbench/state.ts @@ -13,6 +13,17 @@ import type { ExplorerSort } from './views/explorer-sort' import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { + createPresetTree, + moveSlot as moveSlotInTree, + setSlotTier as setSlotTierInTree, + slotsIn, + type LayoutTree, + type PresetId, + type RegionId, + type SlotTier, + type WorkspacePayload +} from './layout-tree' export type WorkbenchMode = 'default' | 'zen' export type PanelSide = 'left' | 'right' | 'bottom' @@ -150,6 +161,14 @@ interface WorkbenchState { * `left`/`right` panels (as the calm List/Canvas) and `mode` (as focus). */ layout: ShellLayout + /** + * The layout tree (exploration 0280): regions → slots → views, the + * single data model behind every shell posture. The former shells are + * presets over this tree; behind `xnet:experiment:layout-tree` the + * ShellFrame renders it directly, and the legacy axes (`layout`, + * `chrome`) are kept coherent with it during the transition. + */ + tree: LayoutTree /** Active primary mode of the calm shell (0250). */ calmMode: CalmMode /** @@ -216,6 +235,16 @@ interface WorkbenchState { /** Set the primary scope and the multi-filter together, atomically. */ applyScopeSelection: (scope: string | null, filter: string[]) => void + // ─── Layout tree (0280) ──────────────────────────────────────── + /** Replace the tree with a built-in preset (and align the legacy axes). */ + applyPreset: (preset: PresetId) => void + /** Load a workspace payload (a `xnet:workspace` node) into the tree. */ + loadWorkspace: (payload: WorkspacePayload) => void + /** Move a view to another region (keeps its tier; ordered last). */ + moveSlot: (viewId: string, region: RegionId) => void + /** Change a placed view's disclosure tier. */ + setSlotTier: (viewId: string, tier: SlotTier) => void + // ─── Shell layout (0250) ─────────────────────────────────────── setLayout: (layout: ShellLayout) => void /** Flip between the calm shell and the workbench grid. */ @@ -302,6 +331,36 @@ function freshGroups(): EditorGroup[] { return [{ id: 'group-1', tabs: [], activeTabId: null }] } +/** + * The store patch for adopting a tree (0280): the legacy `layout`/`chrome` + * axes stay coherent, docks open where the tree pins a view, and each + * dock's active view snaps to its first placement so the panel hosts show + * what the tree says at rest. + */ +function stateForTree(tree: LayoutTree): Partial { + const patch: Partial = { + tree, + layout: tree.surface.tabsEnabled ? 'workbench' : 'calm', + chrome: tree.chrome, + discloseLevel: 0 + } + const docks: Array<[PanelSide, RegionId]> = [ + ['left', 'dock.left'], + ['right', 'dock.right'], + ['bottom', 'dock.bottom'] + ] + for (const [side, region] of docks) { + const placements = slotsIn(tree, region) + const pinned = slotsIn(tree, region, 'pinned') + const active = pinned[0] ?? placements[0] + patch[side] = { + open: pinned.length > 0, + activeViewId: active?.viewId ?? '' + } + } + return patch +} + export const useWorkbench = create()( persist( (set, get) => ({ @@ -310,6 +369,7 @@ export const useWorkbench = create()( // this default), and the workbench grid is one toggle away in Settings → // Appearance, or via the "View: Switch layout" command. layout: 'calm', + tree: createPresetTree('calm'), calmMode: 'companion', // Pinned chrome stays the shipped default (0273); quiet is one command // away (`View: Quiet chrome`) and flips for new identities post-dogfood. @@ -342,20 +402,63 @@ export const useWorkbench = create()( setExplorerSort: (sort) => set({ explorerSort: sort }), applyScopeSelection: (scope, filter) => set({ currentSpaceId: scope, spaceFilter: filter }), - setLayout: (layout) => set({ layout }), + applyPreset: (preset) => set(stateForTree(createPresetTree(preset))), + + loadWorkspace: (payload) => set(stateForTree(payload.tree)), + + moveSlot: (viewId, region) => + set((state) => { + const tree = moveSlotInTree(state.tree, viewId, region) + return tree === state.tree ? {} : { tree } + }), + + setSlotTier: (viewId, tier) => + set((state) => { + const tree = setSlotTierInTree(state.tree, viewId, tier) + if (tree === state.tree) return {} + // Un-pinning the active view of a dock closes that dock at rest. + const patch: Partial = { tree } + for (const side of ['left', 'right', 'bottom'] as const) { + const panel = state[side] + if (panel.activeViewId === viewId && tier !== 'pinned' && panel.open) { + patch[side] = { ...panel, open: false } + } + } + return patch + }), + + // Switching layout is choosing a preset (0280): the legacy axes stay + // coherent with the tree so both renderers agree during the rollout. + setLayout: (layout) => + set((state) => + stateForTree( + createPresetTree( + layout === 'workbench' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' + ) + ) + ), toggleLayout: () => - set((state) => ({ layout: state.layout === 'calm' ? 'workbench' : 'calm' })), + set((state) => + stateForTree( + createPresetTree( + state.layout === 'calm' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' + ) + ) + ), setCalmMode: (calmMode) => set({ calmMode }), - setChrome: (chrome) => set({ chrome, discloseLevel: 0 }), + // Chrome is an orthogonal axis (0273): flipping it never resets a + // customized tree, it only changes the posture of the same placements. + setChrome: (chrome) => + set((state) => ({ chrome, discloseLevel: 0, tree: { ...state.tree, chrome } })), toggleChrome: () => - set((state) => ({ - chrome: state.chrome === 'quiet' ? 'pinned' : 'quiet', - discloseLevel: 0 - })), + set((state) => { + const chrome = state.chrome === 'quiet' ? 'pinned' : 'quiet' + return { chrome, discloseLevel: 0, tree: { ...state.tree, chrome } } + }), setDiscloseLevel: (discloseLevel) => set((state) => (state.discloseLevel === discloseLevel ? {} : { discloseLevel })), @@ -660,6 +763,19 @@ export const useWorkbench = create()( }), { name: 'xnet:workbench:v1', + // v2 (0280): the layout tree joins the persisted state. Pre-tree + // profiles derive their tree from the legacy `layout`/`chrome` axes so + // panels, pins, shelf and startup node all survive the migration. + version: 2, + migrate: (persisted, version) => { + const state = persisted as Partial + if (version < 2 && !state.tree) { + state.tree = createPresetTree( + state.layout === 'workbench' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' + ) + } + return state as WorkbenchState + }, // The disclosure level is a live interaction state (0273) — persisting // it would resurrect a lit/overlaid shell on reload. partialize: (state) => diff --git a/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md b/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md index 535264b2e..4e9031b46 100644 --- a/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md +++ b/docs/explorations/0280_[_]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md @@ -75,16 +75,16 @@ undoable. ### The three shells and their shared store -| Piece | Path | Notes | -| --- | --- | --- | -| Shell router | `apps/web/src/workbench/Workbench.tsx` (~200 LOC) | Chooses calm vs workbench vs mobile | -| Shell state | `apps/web/src/workbench/state.ts` (~680 LOC) | One persisted zustand store: `layout`, `calmMode`, `chrome`, `discloseLevel`, `left/right/bottom` panels, editor `groups`/tabs, `shelf`, `deskPins`, `startupTab` | -| Workbench grid | 0166 components under `apps/web/src/workbench/` | Rail (44px, hard-coded 6 icons) → panels → editor groups via `react-resizable-panels` | -| Calm shell | `apps/web/src/workbench/calm/CalmShell.tsx` (~115 LOC) | ModeSwitch · ListPane · Surface · ContextualCanvas; modes = companion/workspace/network (`calm/modes.ts`) | -| Quiet posture | `apps/web/src/workbench/calm/QuietChrome.tsx` (~373 LOC) | Corner glyphs, edge hot-zones, disclosure ladder L0–L2 | -| Surface dock | `apps/web/src/workbench/calm/SurfaceDock.tsx` (~422 LOC) | Registry-driven corner launcher; hero/secondary tiers | -| Mobile shell | `apps/web/src/workbench/MobileShell.tsx` | Separate composition below 768px, not a reflow | -| Commands | `apps/web/src/workbench/commands.ts` | ⌘B/⌘\\/⌘J/⌘./⌘K etc. registered in the global command registry | +| Piece | Path | Notes | +| -------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shell router | `apps/web/src/workbench/Workbench.tsx` (~200 LOC) | Chooses calm vs workbench vs mobile | +| Shell state | `apps/web/src/workbench/state.ts` (~680 LOC) | One persisted zustand store: `layout`, `calmMode`, `chrome`, `discloseLevel`, `left/right/bottom` panels, editor `groups`/tabs, `shelf`, `deskPins`, `startupTab` | +| Workbench grid | 0166 components under `apps/web/src/workbench/` | Rail (44px, hard-coded 6 icons) → panels → editor groups via `react-resizable-panels` | +| Calm shell | `apps/web/src/workbench/calm/CalmShell.tsx` (~115 LOC) | ModeSwitch · ListPane · Surface · ContextualCanvas; modes = companion/workspace/network (`calm/modes.ts`) | +| Quiet posture | `apps/web/src/workbench/calm/QuietChrome.tsx` (~373 LOC) | Corner glyphs, edge hot-zones, disclosure ladder L0–L2 | +| Surface dock | `apps/web/src/workbench/calm/SurfaceDock.tsx` (~422 LOC) | Registry-driven corner launcher; hero/secondary tiers | +| Mobile shell | `apps/web/src/workbench/MobileShell.tsx` | Separate composition below 768px, not a reflow | +| Commands | `apps/web/src/workbench/commands.ts` | ⌘B/⌘\\/⌘J/⌘./⌘K etc. registered in the global command registry | Three **orthogonal axes** already exist and are the germ of the design: `layout` (calm/workbench), `chrome` (pinned/quiet), and theme axes @@ -224,20 +224,20 @@ per-pane toolbars); tldraw (composable canvas primitives as an SDK). ### The recurring patterns (distilled) -| # | Pattern | Seen in | Why it works | -| --- | --- | --- | --- | -| 1 | Command palette as the universal road | VS Code, Zed, Obsidian, Linear, Claude | Search beats navigation; teaches chords; makes every feature addressable | -| 2 | Named slots + movable views | VS Code side bars, Zed docks, Obsidian splits | Users rearrange without breaking; plugins target slots, not pixels | -| 3 | Layout-as-data / settings-as-code | Zed settings.json, Obsidian workspace.json, VS Code .code-workspace | Versionable, shareable, agent-editable | -| 4 | Saved named workspaces | Obsidian Workspaces, VS Code profiles, browser tab groups | Tasks have shapes; recall beats rebuild | -| 5 | Progressive disclosure ladder | 0273's L0–L3; VS Code zen→full; Notion menus | Novices see calm; density is earned/summoned, never imposed | -| 6 | Views as lenses over shared data | Notion databases, xNet views, Potluck | Kills export/import; composition without integration | -| 7 | Block/slash composition inside the surface | Notion, xNet pages, Obsidian canvas | The document is the workbench for most users | -| 8 | Declarative contribution points + capability gates | VS Code manifests, Figma/Deno sandboxes, xNet FeatureModule | Ecosystem without catastrophic defaults | -| 9 | Agent beside artifact | Claude, ChatGPT canvas, Zed agent panel | Reasoning and result visible together; iteration without context-switch | -| 10 | Zoom/zen as temporary states, not modes | Zed zoom, VS Code zen, 0273 quiet | Focus is a posture you enter and leave losslessly | -| 11 | Status/ambient indicators at the frame edge | VS Code status bar, xNet corner glyphs | Awareness without panels | -| 12 | Three roads to everything (pointer, touch, keyboard) | 0273's touch-twin contract; NN/g guidance | No dead ends per input mode; accessibility for free | +| # | Pattern | Seen in | Why it works | +| --- | ---------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| 1 | Command palette as the universal road | VS Code, Zed, Obsidian, Linear, Claude | Search beats navigation; teaches chords; makes every feature addressable | +| 2 | Named slots + movable views | VS Code side bars, Zed docks, Obsidian splits | Users rearrange without breaking; plugins target slots, not pixels | +| 3 | Layout-as-data / settings-as-code | Zed settings.json, Obsidian workspace.json, VS Code .code-workspace | Versionable, shareable, agent-editable | +| 4 | Saved named workspaces | Obsidian Workspaces, VS Code profiles, browser tab groups | Tasks have shapes; recall beats rebuild | +| 5 | Progressive disclosure ladder | 0273's L0–L3; VS Code zen→full; Notion menus | Novices see calm; density is earned/summoned, never imposed | +| 6 | Views as lenses over shared data | Notion databases, xNet views, Potluck | Kills export/import; composition without integration | +| 7 | Block/slash composition inside the surface | Notion, xNet pages, Obsidian canvas | The document is the workbench for most users | +| 8 | Declarative contribution points + capability gates | VS Code manifests, Figma/Deno sandboxes, xNet FeatureModule | Ecosystem without catastrophic defaults | +| 9 | Agent beside artifact | Claude, ChatGPT canvas, Zed agent panel | Reasoning and result visible together; iteration without context-switch | +| 10 | Zoom/zen as temporary states, not modes | Zed zoom, VS Code zen, 0273 quiet | Focus is a posture you enter and leave losslessly | +| 11 | Status/ambient indicators at the frame edge | VS Code status bar, xNet corner glyphs | Awareness without panels | +| 12 | Three roads to everything (pointer, touch, keyboard) | 0273's touch-twin contract; NN/g guidance | No dead ends per input mode; accessibility for free | ## Key Findings @@ -537,14 +537,14 @@ export interface LayoutTree { regions: Record /** Surface keeps the 0166 editor-group grammar; calm preset = 1 group. */ surface: { groups: EditorGroup[]; activeGroupId: string; tabsEnabled: boolean } - chrome: 'pinned' | 'quiet' // 0273 axis, unchanged + chrome: 'pinned' | 'quiet' // 0273 axis, unchanged startup: { nodeType: TabNodeType; nodeId: string } | null } /** Portable half of a workspace node — device-local sizes stay in zustand. */ export interface WorkspaceNodePayload { name: string - preset: 'quiet' | 'calm' | 'bench' | null // provenance, for "Reset to preset" + preset: 'quiet' | 'calm' | 'bench' | null // provenance, for "Reset to preset" tree: LayoutTree theme: { variant: ThemeVariant; density: Density } } @@ -584,9 +584,9 @@ registerCommand({ 1. **Migration blast radius.** `xnet:workbench:v1` persists for every existing identity; the tree migration must be lossless (zustand `migrate` - + a snapshot test on real persisted fixtures). Mitigation: phase 1 keeps a - feature flag (`xnet:experiment:layout-tree`) with the old shells intact - until parity is source-grep-guarded. + - a snapshot test on real persisted fixtures). Mitigation: phase 1 keeps a + feature flag (`xnet:experiment:layout-tree`) with the old shells intact + until parity is source-grep-guarded. 2. **Preset drift = the new shell fork.** If quiet/calm/bench presets grow preset-specific code paths, we've rebuilt the three-shell problem inside one component. Mitigation: presets must be _data only_ (workspace nodes); @@ -623,15 +623,15 @@ registerCommand({ ### Phase 1 — Layout tree engine -- [ ] Define `LayoutTree`, `RegionId`, `SlotPlacement` in +- [x] Define `LayoutTree`, `RegionId`, `SlotPlacement` in `apps/web/src/workbench/layout-tree.ts` with unit tests -- [ ] Add tree state + actions (`moveSlot`, `setSlotTier`, `applyPreset`) to +- [x] Add tree state + actions (`moveSlot`, `setSlotTier`, `applyPreset`) to `useWorkbench` behind `xnet:experiment:layout-tree`; zustand `migrate` from `xnet:workbench:v1` panel booleans -- [ ] Build `ShellFrame` rendering regions/slots; express calm, workbench, +- [x] Build `ShellFrame` rendering regions/slots; express calm, workbench, and quiet as preset tree fixtures rendering pixel-equivalently -- [ ] Source-grep tripwire test: no component may branch on preset name -- [ ] Route/mode reconciliation (`calm/modes.ts`) reads the tree, not +- [x] Source-grep tripwire test: no component may branch on preset name +- [x] Route/mode reconciliation (`calm/modes.ts`) reads the tree, not `layout` ### Phase 2 — Slots and movable views From 6795f6b0e89c225cfa7892119ab63d6a04226b8f Mon Sep 17 00:00:00 2001 From: xNet Test Date: Tue, 7 Jul 2026 12:58:02 -0700 Subject: [PATCH 03/10] feat(plugins): shell-wide movable slot views via SlotContribution (0280) Generalizes the 0273 SurfaceDock contract: SlotRegion plus defaultRegion/ allowedRegions in @xnetjs/plugins (surfaceDock kept as deprecated alias), one app-side slot registry replacing the PanelViewHost/dock maps, move/ hide/show palette commands per view, Move menu and drag between docks. Co-Authored-By: Claude Fable 5 --- .changeset/slot-contributions-0280.md | 5 + apps/web/src/workbench/PanelViewHost.tsx | 134 +++++-- apps/web/src/workbench/ShellFrame.tsx | 94 +++-- apps/web/src/workbench/calm/QuietChrome.tsx | 24 +- apps/web/src/workbench/calm/SurfaceDock.tsx | 114 ++---- apps/web/src/workbench/slot-registry.tsx | 347 ++++++++++++++++++ apps/web/src/workbench/views/register.ts | 34 +- ...ALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md | 8 +- packages/plugins/src/context.ts | 9 + packages/plugins/src/contributions.ts | 61 ++- packages/plugins/src/index.ts | 2 + packages/plugins/src/manifest.ts | 9 +- packages/plugins/src/registry.ts | 5 + 13 files changed, 633 insertions(+), 213 deletions(-) create mode 100644 .changeset/slot-contributions-0280.md create mode 100644 apps/web/src/workbench/slot-registry.tsx diff --git a/.changeset/slot-contributions-0280.md b/.changeset/slot-contributions-0280.md new file mode 100644 index 000000000..2bdcee8a9 --- /dev/null +++ b/.changeset/slot-contributions-0280.md @@ -0,0 +1,5 @@ +--- +'@xnetjs/plugins': minor +--- + +Generalize the SurfaceDock contract into shell-wide slot contributions (exploration 0280). New `SlotContribution` type (with `defaultRegion` / `allowedRegions`), `SlotRegion` union, a `slots` registry on `ContributionRegistry`, a `slots` key on `PluginContributions`, and `ExtensionContext.registerSlotView()`. `SurfaceDockContribution` and the `surfaceDock` registry remain as deprecated aliases — no breaking changes. diff --git a/apps/web/src/workbench/PanelViewHost.tsx b/apps/web/src/workbench/PanelViewHost.tsx index fd9cfc724..bfb656acf 100644 --- a/apps/web/src/workbench/PanelViewHost.tsx +++ b/apps/web/src/workbench/PanelViewHost.tsx @@ -1,14 +1,23 @@ /** - * PanelViewHost — renders the active view of the Left or Bottom panel - * (exploration 0166). + * PanelViewHost — renders the active view of a dock (exploration 0166, + * slot-registry backed since 0280). * - * Views are registered in a module-level registry so plugin - * contributions can add panel views without touching the shell - * (containers vs items — the VS Code model). The bottom slot renders - * its registered views as panel-local tabs (the tray). + * Views live in the shell-wide slot registry; which dock shows them is the + * layout tree's placement. The header carries the three-roads movement + * affordances: a Move menu (pointer + touch) and a drag handle, both + * dispatching the same `moveSlot` store action the palette commands run. */ import type { ComponentType } from 'react' -import { X } from 'lucide-react' +import { PopoverContent, PopoverRoot, PopoverTrigger } from '@xnetjs/ui' +import { ArrowLeftRight, X } from 'lucide-react' +import { useState } from 'react' +import { regionOf } from './layout-tree' +import { + getSlotView, + movableRegionsFor, + registerSlotView, + slotViewsInRegion +} from './slot-registry' import { useWorkbench, type PanelSide } from './state' export interface PanelViewDefinition { @@ -17,27 +26,86 @@ export interface PanelViewDefinition { component: ComponentType } -const registries: Record<'left' | 'bottom', Map> = { - left: new Map(), - bottom: new Map() -} +const SLOT_TO_REGION = { + left: 'dock.left', + right: 'dock.right', + bottom: 'dock.bottom' +} as const + +/** MIME type for dragging a slot view between docks (0280). */ +export const SLOT_DRAG_TYPE = 'application/x-xnet-slot-view' +/** + * Legacy registration shim (0166 API): panel views are slot contributions + * with the dock as their default region. + */ export function registerPanelView(slot: 'left' | 'bottom', view: PanelViewDefinition): () => void { - registries[slot].set(view.id, view) - return () => { - registries[slot].delete(view.id) - } + return registerSlotView({ + id: view.id, + label: view.title, + tier: 'secondary', + component: view.component, + defaultRegion: SLOT_TO_REGION[slot] + }) +} + +export function getPanelViews(slot: 'left' | 'bottom' | 'right'): PanelViewDefinition[] { + return slotViewsInRegion(SLOT_TO_REGION[slot]).map((view) => ({ + id: view.id, + title: view.label, + component: view.component + })) } -export function getPanelViews(slot: 'left' | 'bottom'): PanelViewDefinition[] { - return [...registries[slot].values()] +/** The Move menu — the pointer/touch twin of the `slot.move:*` commands. */ +export function MoveViewMenu({ viewId }: { viewId: string }) { + const [open, setOpen] = useState(false) + const view = getSlotView(viewId) + const moveSlot = useWorkbench((state) => state.moveSlot) + const currentRegion = useWorkbench((state) => regionOf(state.tree, viewId)) + if (!view) return null + const targets = movableRegionsFor(view).filter(({ region }) => region !== currentRegion) + if (targets.length === 0) return null + return ( + + + + + + {targets.map(({ region, label }) => ( + + ))} + + + ) } export function PanelViewHost({ slot }: { slot: 'left' | 'bottom' }) { const panel = useWorkbench((state) => state[slot as PanelSide]) const setPanelOpen = useWorkbench((state) => state.setPanelOpen) + // Subscribe to tree changes so placements re-render the host. + useWorkbench((state) => state.tree) - const view = registries[slot].get(panel.activeViewId) ?? getPanelViews(slot)[0] + const views = getPanelViews(slot) + const view = views.find((entry) => entry.id === panel.activeViewId) ?? views[0] if (!view) { return ( @@ -51,17 +119,27 @@ export function PanelViewHost({ slot }: { slot: 'left' | 'bottom' }) { return (
-
+
{ + event.dataTransfer.setData(SLOT_DRAG_TYPE, view.id) + event.dataTransfer.effectAllowed = 'move' + }} + > - +
+ + +
diff --git a/apps/web/src/workbench/ShellFrame.tsx b/apps/web/src/workbench/ShellFrame.tsx index caf8ad4c0..24dd786d2 100644 --- a/apps/web/src/workbench/ShellFrame.tsx +++ b/apps/web/src/workbench/ShellFrame.tsx @@ -11,54 +11,34 @@ * Behind `xnet:experiment:layout-tree` (see experiments.ts); the legacy * shells render by default until parity is proven. */ -import type { ComponentType, ReactNode } from 'react' +import type { DragEvent, ReactNode } from 'react' import { DemoBanner, useDemoMode } from '@xnetjs/react' import { Group, Panel, useDefaultLayout } from 'react-resizable-panels' import { GlobalSearch } from '../components/GlobalSearch' import { UndoToastProvider } from '../components/UndoToast' import { WorkspaceCommands } from '../components/WorkspaceCommands' import { CalmSurface } from './calm/CalmSurface' -import { Canvas } from './calm/Canvas' -import { ListPane } from './calm/ListPane' -import { ModeSwitch } from './calm/ModeSwitch' import { QuietChrome } from './calm/QuietChrome' import { SurfaceDockLauncher } from './calm/SurfaceDock' import { useActiveCalmMode } from './calm/use-active-mode' import { useWorkbenchCommands, useZenEscape } from './commands' -import { ContextPanel } from './ContextPanel' import { EditorArea } from './EditorArea' import { useFocusRing } from './focus' import { Hairline } from './Hairline' import { slotsIn, type LayoutTree, type RegionId } from './layout-tree' -import { PanelViewHost } from './PanelViewHost' -import { Rail } from './Rail' -import { useWorkbench, type CalmMode, type PanelSide } from './state' -import { StatusBar } from './StatusBar' +import { PanelViewHost, SLOT_DRAG_TYPE } from './PanelViewHost' +import { getSlotView } from './slot-registry' +import { useWorkbench, type PanelSide } from './state' const FRAME = 'mt-[var(--storage-banner-height,0px)] flex h-[calc(100dvh-var(--storage-banner-height,0px))] flex-col text-ink-1' /** - * Bare frame views: slot views that bring their own chrome, keyed by the - * placement's viewId. Everything else falls back to the PanelViewHost - * (header + registered panel views) for its dock. + * Views that bring their own chrome render bare in a dock; everything else + * goes through the PanelViewHost (header, panel tabs, move menu). Keyed by + * view id — never by preset (the tripwire forbids that). */ -const BARE_VIEWS: Record> = { - navigator: ({ mode }) => , - context: () => , - inspector: () => -} - -/** Rail-region views (edge strips, not dock panels). */ -const RAIL_VIEWS: Record = { - modes: ModeSwitch, - rail: Rail -} - -/** Status-region views. */ -const STATUS_VIEWS: Record = { - status: StatusBar -} +const BARE_VIEW_IDS = new Set(['navigator', 'context', 'inspector']) function FrameDemoBanner() { const { isDemo, limits } = useDemoMode() @@ -66,35 +46,48 @@ function FrameDemoBanner() { return } +/** Drop target props: the pointer road's landing zone for a dock (0280). */ +function dropProps(region: RegionId) { + return { + onDragOver: (event: DragEvent) => { + if (event.dataTransfer.types.includes(SLOT_DRAG_TYPE)) event.preventDefault() + }, + onDrop: (event: DragEvent) => { + const viewId = event.dataTransfer.getData(SLOT_DRAG_TYPE) + if (viewId) useWorkbench.getState().moveSlot(viewId, region) + } + } +} + /** The dock body: the active placement's view, bare or hosted. */ -function DockBody({ - tree, - region, - side, - mode -}: { - tree: LayoutTree - region: RegionId - side: PanelSide - mode: CalmMode -}) { +function DockBody({ tree, region, side }: { tree: LayoutTree; region: RegionId; side: PanelSide }) { const activeViewId = useWorkbench((state) => state[side].activeViewId) const placements = slotsIn(tree, region) const active = placements.find((placement) => placement.viewId === activeViewId) ?? placements[0] - const Bare = active ? BARE_VIEWS[active.viewId] : undefined - if (Bare) return - // Panel-view docks (the 0166 registries) — left and bottom hosts. - if (side === 'left' || side === 'bottom') return - return + const view = active ? getSlotView(active.viewId) : undefined + if (view && BARE_VIEW_IDS.has(view.id)) { + const Bare = view.component + return ( +
+ +
+ ) + } + // Panel-view docks (the 0166 host: header, tabs, move menu). + const slot = side === 'right' ? undefined : side + return ( +
+ {slot ? : view ? : null} +
+ ) } function EdgeStrip({ tree, region }: { tree: LayoutTree; region: 'rail' | 'status' }) { - const views = region === 'rail' ? RAIL_VIEWS : STATUS_VIEWS const pinned = slotsIn(tree, region, 'pinned') return ( <> {pinned.map((placement) => { - const View = views[placement.viewId] + const View = getSlotView(placement.viewId)?.component return View ? : null })} @@ -125,7 +118,8 @@ function ZenFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) * carries its placements everywhere, its pixel widths nowhere. */ function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { - const mode = useActiveCalmMode() + // Keep route ↔ mode reconciliation alive in every posture. + useActiveCalmMode() const left = useWorkbench((state) => state.left) const right = useWorkbench((state) => state.right) const bottom = useWorkbench((state) => state.bottom) @@ -164,7 +158,7 @@ function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode {leftOpen && ( <> - + @@ -183,7 +177,7 @@ function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode <> - + )} @@ -193,7 +187,7 @@ function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode <> - + )} diff --git a/apps/web/src/workbench/calm/QuietChrome.tsx b/apps/web/src/workbench/calm/QuietChrome.tsx index 30472dba7..57f21ee33 100644 --- a/apps/web/src/workbench/calm/QuietChrome.tsx +++ b/apps/web/src/workbench/calm/QuietChrome.tsx @@ -19,11 +19,11 @@ import { PopoverContent, PopoverRoot, PopoverTrigger, Sheet, SheetContent } from import { Search, Settings, type LucideIcon } from 'lucide-react' import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { contributeTips } from '../../coachmarks' +import { slotsIn } from '../layout-tree' +import { getSlotView } from '../slot-registry' import { useWorkbench, type CalmMode, type PanelSide } from '../state' import { CHIP, SystemInfoDetails } from '../SyncStatus' import { useSyncVitals } from '../useSyncVitals' -import { Canvas } from './Canvas' -import { ListPane } from './ListPane' import { CALM_MODES } from './modes' import { registerBuiltinSurfaceDock, SurfaceDockLauncher } from './SurfaceDock' @@ -335,6 +335,22 @@ function QuietOverlay({ ) } +/** + * The overlay's body: the dock's active slot view, resolved through the + * layout tree + slot registry (0280) — the same resolution the pinned + * frame's DockBody uses, so a moved view follows the user into quiet. + */ +function OverlayBody({ side }: { side: 'left' | 'right' }) { + const activeViewId = useWorkbench((state) => state[side].activeViewId) + const tree = useWorkbench((state) => state.tree) + const region = side === 'left' ? 'dock.left' : 'dock.right' + const placements = slotsIn(tree, region) + const active = placements.find((placement) => placement.viewId === activeViewId) ?? placements[0] + const view = active ? getSlotView(active.viewId) : undefined + if (!view) return null + return +} + export function QuietChrome({ activeMode, children @@ -359,14 +375,14 @@ export function QuietChrome({ {/* The List → left overlay; the contextual Canvas → right overlay. Esc/scrim dismissal via the Sheet dialog. */} - + - +
) diff --git a/apps/web/src/workbench/calm/SurfaceDock.tsx b/apps/web/src/workbench/calm/SurfaceDock.tsx index 0fcdd50c1..c572a0f8e 100644 --- a/apps/web/src/workbench/calm/SurfaceDock.tsx +++ b/apps/web/src/workbench/calm/SurfaceDock.tsx @@ -18,96 +18,37 @@ import { type SurfaceDockTier } from '@xnetjs/plugins' import { Presence } from '@xnetjs/ui' -import { - Archive, - Bell, - LayoutGrid, - MoreHorizontal, - PenLine, - RefreshCw, - Terminal, - X, - type LucideIcon -} from 'lucide-react' +import { LayoutGrid, MoreHorizontal, X, type LucideIcon } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' +import { MoveViewMenu } from '../PanelViewHost' +import { registerBuiltinSlotViews, registerSlotView, slotViewsInRegion } from '../slot-registry' import { useWorkbench } from '../state' -import { ShelfTray } from '../views/Shelf' -import { NotificationsTray, QueryConsoleTray, QuickCaptureTray, SyncTray } from '../views/tray' - -// ─── Registry ────────────────────────────────────────────────────── -const dockRegistry = new Map() +// ─── Registry (slot-registry backed since 0280) ──────────────────── +/** + * Register a dock panel — a slot view defaulting to the corner dock. + * Kept as the 0273 API; new code should use `registerSlotView` directly. + */ export function registerSurfaceDockPanel(item: SurfaceDockContribution): () => void { - dockRegistry.set(item.id, item) - return () => { - dockRegistry.delete(item.id) - } + return registerSlotView({ defaultRegion: 'dock.corner', ...item }) } +/** + * The corner dock's current residents: views the layout tree places in + * `dock.corner` (plus unplaced views defaulting there), by tier. + */ export function getSurfaceDockPanels(tier?: SurfaceDockTier): SurfaceDockContribution[] { - const all = [...dockRegistry.values()].sort( - (a, b) => (a.priority ?? 0) - (b.priority ?? 0) || a.label.localeCompare(b.label) - ) + const all = slotViewsInRegion('dock.corner') return tier ? all.filter((item) => item.tier === tier) : all } /** - * First-party residents: the tray views migrate here in quiet posture (their - * ids match the `bottom` panel-view ids, so `showPanelView('bottom', id)` - * works identically in both postures). + * First-party residents (0273): registration now lives in the shared slot + * registry; this remains the dock's idempotent entry point. */ export function registerBuiltinSurfaceDock(): void { - const builtin: SurfaceDockContribution[] = [ - { - id: 'shelf', - label: 'Shelf', - icon: Archive, - tier: 'hero', - group: 'capture', - priority: 0, - component: ShelfTray - }, - { - id: 'capture', - label: 'Capture', - icon: PenLine, - tier: 'hero', - group: 'capture', - priority: 1, - component: QuickCaptureTray - }, - { - id: 'notifications', - label: 'Notifications', - icon: Bell, - tier: 'hero', - group: 'activity', - priority: 2, - component: NotificationsTray - }, - { - id: 'sync', - label: 'Sync', - icon: RefreshCw, - tier: 'secondary', - group: 'activity', - priority: 3, - keywords: ['status', 'hub'], - component: SyncTray - }, - { - id: 'console', - label: 'Console', - icon: Terminal, - tier: 'secondary', - group: 'tools', - priority: 4, - keywords: ['query', 'sql'], - component: QueryConsoleTray - } - ] - for (const item of builtin) dockRegistry.set(item.id, item) + registerBuiltinSlotViews() } function iconFor(item: SurfaceDockContribution): LucideIcon { @@ -208,15 +149,18 @@ function DockPanelCard({ ))} - +
+ + +
diff --git a/apps/web/src/workbench/slot-registry.tsx b/apps/web/src/workbench/slot-registry.tsx new file mode 100644 index 000000000..581435a93 --- /dev/null +++ b/apps/web/src/workbench/slot-registry.tsx @@ -0,0 +1,347 @@ +/** + * Slot registry — every shell panel is a movable SlotContribution (0280). + * + * One registry replaces the three parallel ones the shells grew (the 0166 + * PanelViewHost maps, the 0273 SurfaceDock map, and the ShellFrame's bare + * view tables). *What exists* lives here; *where it sits* lives in the + * workbench store's LayoutTree — a view's `defaultRegion` only applies + * until the user (or their agent) moves it. + * + * Registering a view also registers its movement verbs as palette + * commands (`View: Move