From b24630362139aa89107b42629d49a1cc53631d8d Mon Sep 17 00:00:00 2001 From: Ryan Bundy Date: Wed, 7 Oct 2026 21:57:59 -0400 Subject: [PATCH 1/4] feat(shell): add foundation contracts and baselines SHELL-0 (#128), following APP-ARCH-0 (#127). Dormant foundation only: no production page, main or preload loads src/shell. - src/shell: disposable, contribution, services, profiles, command and panel services over the UI-0 registries, tool registry, contextual panels, menu boundary, DocumentSession controller boundary. - Panel and contextual lifecycles are remountable; one record's failure never stops the others; multiple disposal failures are aggregated. - test/shell: focused contract tests, registered in run-tests.js. - qa/shell-0-baseline: repeatable QA baselines for startup, page switch and memory (evidence, not a golden file; pids anonymized). - spikes/shell-0-module-loading: native ESM vs esbuild spike; recommendation ESBUILD_ENTRY_BUNDLES, not activated. - docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md. No product behavior, dependency or repository-policy change. Signed-off-by: Ryan Bundy --- .../SHELL_0_CONTRACTS_AND_MEASUREMENTS.md | 714 + qa/shell-0-baseline/RESULTS.json | 11263 ++++++++++++++++ qa/shell-0-baseline/measure.js | 473 + scripts/run-tests.js | 1 + spikes/shell-0-module-loading/NOTES.md | 76 + .../app/bundle-basic.html | 14 + .../app/classic-defer-2.js | 2 + .../app/classic-defer.js | 2 + .../shell-0-module-loading/app/esm-bare.html | 12 + .../shell-0-module-loading/app/esm-basic.html | 14 + .../app/esm-cjs-named.html | 12 + .../app/esm-cjs-side-effect.html | 12 + .../app/esm-inline.html | 12 + .../app/esm-missing.html | 12 + .../app/esm-strict-self.html | 12 + .../shell-0-module-loading/app/esm-throw.html | 12 + .../app/esm/bare-specifier.js | 3 + .../app/esm/bundle-entry.js | 17 + .../app/esm/cjs-named.js | 3 + .../app/esm/cjs-side-effect.js | 8 + .../app/esm/entry-min.js | 2 + .../shell-0-module-loading/app/esm/entry.js | 20 + .../shell-0-module-loading/app/esm/lib/a.js | 2 + .../shell-0-module-loading/app/esm/lib/b.mjs | 2 + .../app/esm/lib/lazy.js | 1 + .../app/esm/missing-import.js | 2 + .../shell-0-module-loading/app/esm/throws.js | 3 + spikes/shell-0-module-loading/app/probe.js | 25 + spikes/shell-0-module-loading/build.js | 163 + spikes/shell-0-module-loading/main.js | 65 + spikes/shell-0-module-loading/run.js | 117 + src/shell/command-service.js | 89 + src/shell/contextual-panels.js | 168 + src/shell/contribution.js | 152 + src/shell/disposable.js | 127 + src/shell/document-session.js | 163 + src/shell/menu-boundary.js | 112 + src/shell/panel-service.js | 137 + src/shell/profiles.js | 34 + src/shell/services.js | 136 + src/shell/tool-registry.js | 151 + test/shell/boundaries.test.js | 79 + test/shell/command-panel-services.test.js | 257 + test/shell/contribution.test.js | 201 + test/shell/disposable.test.js | 134 + test/shell/fakes.js | 53 + test/shell/menu-document.test.js | 177 + test/shell/tools-contextual.test.js | 315 + 48 files changed, 15561 insertions(+) create mode 100644 docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md create mode 100644 qa/shell-0-baseline/RESULTS.json create mode 100644 qa/shell-0-baseline/measure.js create mode 100644 spikes/shell-0-module-loading/NOTES.md create mode 100644 spikes/shell-0-module-loading/app/bundle-basic.html create mode 100644 spikes/shell-0-module-loading/app/classic-defer-2.js create mode 100644 spikes/shell-0-module-loading/app/classic-defer.js create mode 100644 spikes/shell-0-module-loading/app/esm-bare.html create mode 100644 spikes/shell-0-module-loading/app/esm-basic.html create mode 100644 spikes/shell-0-module-loading/app/esm-cjs-named.html create mode 100644 spikes/shell-0-module-loading/app/esm-cjs-side-effect.html create mode 100644 spikes/shell-0-module-loading/app/esm-inline.html create mode 100644 spikes/shell-0-module-loading/app/esm-missing.html create mode 100644 spikes/shell-0-module-loading/app/esm-strict-self.html create mode 100644 spikes/shell-0-module-loading/app/esm-throw.html create mode 100644 spikes/shell-0-module-loading/app/esm/bare-specifier.js create mode 100644 spikes/shell-0-module-loading/app/esm/bundle-entry.js create mode 100644 spikes/shell-0-module-loading/app/esm/cjs-named.js create mode 100644 spikes/shell-0-module-loading/app/esm/cjs-side-effect.js create mode 100644 spikes/shell-0-module-loading/app/esm/entry-min.js create mode 100644 spikes/shell-0-module-loading/app/esm/entry.js create mode 100644 spikes/shell-0-module-loading/app/esm/lib/a.js create mode 100644 spikes/shell-0-module-loading/app/esm/lib/b.mjs create mode 100644 spikes/shell-0-module-loading/app/esm/lib/lazy.js create mode 100644 spikes/shell-0-module-loading/app/esm/missing-import.js create mode 100644 spikes/shell-0-module-loading/app/esm/throws.js create mode 100644 spikes/shell-0-module-loading/app/probe.js create mode 100644 spikes/shell-0-module-loading/build.js create mode 100644 spikes/shell-0-module-loading/main.js create mode 100644 spikes/shell-0-module-loading/run.js create mode 100644 src/shell/command-service.js create mode 100644 src/shell/contextual-panels.js create mode 100644 src/shell/contribution.js create mode 100644 src/shell/disposable.js create mode 100644 src/shell/document-session.js create mode 100644 src/shell/menu-boundary.js create mode 100644 src/shell/panel-service.js create mode 100644 src/shell/profiles.js create mode 100644 src/shell/services.js create mode 100644 src/shell/tool-registry.js create mode 100644 test/shell/boundaries.test.js create mode 100644 test/shell/command-panel-services.test.js create mode 100644 test/shell/contribution.test.js create mode 100644 test/shell/disposable.test.js create mode 100644 test/shell/fakes.js create mode 100644 test/shell/menu-document.test.js create mode 100644 test/shell/tools-contextual.test.js diff --git a/docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md b/docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md new file mode 100644 index 0000000..5f975e3 --- /dev/null +++ b/docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md @@ -0,0 +1,714 @@ +# SHELL-0 — Shell Contracts and Measurements + +| | | +|---|---| +| Lane | SHELL-0 ([#128](https://github.com/Ascendance3D/wrlforge/issues/128)), foundation implementation only | +| Architecture | APP-ARCH-0, [`DESKTOP_APPLICATION_SHELL_REVIEW.md`](DESKTOP_APPLICATION_SHELL_REVIEW.md) (PR #127), §24.1 | +| Baseline | `origin/main` `957102acf4dfae4e789810231061391b83f295ba` | +| Product behavior changed | none. No page, `main.js` or `preload.js` loads the new modules (asserted by `test/shell/boundaries.test.js`). | +| Dependencies added | none | +| Repository rules changed | none. `CLAUDE.md`, `AGENTS.md`, `WD.md` unchanged; the bundling rule text in §17.4 is for a later lane to adopt. | +| Unblocks | #121 (§18), #122 (§19) | + +This document records what SHELL-0 built and measured. It does not repeat +APP-ARCH-0; section references of the form "APP-ARCH-0 §n" point there. + +--- + +## 1. Contract map + +Every contract is a pure, framework-free first-party module under +`src/shell/`, with no callers yet. Each uses the repository's dual export +(CommonJS for main and `node:test`, `window.WrlShell*` for a classic page) and +is wrapped in an IIFE so a later classic-script load cannot collide on +top-level names. + +| Contract | Module | Builds on | Status | +|---|---|---|---| +| Disposable lifecycle | `src/shell/disposable.js` | — | new | +| Application contribution | `src/shell/contribution.js` | `services.js`, `disposable.js` | new | +| Application-service boundary | `src/shell/services.js` | — | new (closed catalog) | +| Profile ids (data only) | `src/shell/profiles.js` | `CLAUDE.md` profiles | new | +| Command contribution | `src/shell/command-service.js` | **the UI-0 Command Registry**, unchanged | wrapper, no second registry | +| Panel contribution | `src/shell/panel-service.js` | **the UI-0 Panel Registry**, unchanged | wrapper, no second registry | +| Tool contribution | `src/shell/tool-registry.js` | `app.commands` | new | +| Contextual-panel contribution | `src/shell/contextual-panels.js` | document session authorities | new | +| Menu integration (renderer half) | `src/shell/menu-boundary.js` | UI-0 §10.3, the Command Registry | new, no IPC | +| Document session | `src/shell/document-session.js` | CodeMirror handle, `sceneSelection` | new | + +**Existing production modules changed:** none. `src/editor/command-registry.js`, +`src/editor/panel-registry.js`, `src/editor/workspace-presets.js`, +`src/editor/scene-selection.js` and `renderer/command-bindings.js` are +byte-identical to the baseline. The only non-new file touched is +`scripts/run-tests.js`, which gains `test/shell` in its directory list. + +### 1.1 Ownership map + +```text +main EditorController / EditorSession ── path, format, gzip, authorization, sessionId + (unchanged; never enters the renderer contracts) + +renderer application (Shell-2 builds it; SHELL-0 defines its parts) +├─ createApplication({ services }) contribution.js +│ ├─ app.commands ─ command-service ─▶ UI-0 Command Registry (the one registry) +│ ├─ app.panels ─ panel-service ─▶ UI-0 Panel Registry (the one registry) +│ ├─ app.tools tool-registry (records over commands) +│ ├─ app.contextualPanels contextual-panels (mount/dispose by proven selection) +│ ├─ app.documents document-session (one DocumentSession at a time) +│ ├─ app.workspaces boundary only (#121 wraps setWorkspaceMode) +│ ├─ app.dialogs boundary only (#121: isModalOpen) +│ └─ app.preferences WrlPreferences (unchanged) +├─ per contribution: a scoped `app` + its own disposable store +└─ DocumentSession + ├─ CodeMirror handle source text + undo (read through, never copied) + ├─ sceneSelection selection (the same object) + ├─ analysis products derived, replaced wholesale + └─ preview reference derived projection (X_ITE) +``` + +## 2. Application contribution contract + +```js +// a feature module +module.exports = { + id: 'workspace.commands', + contribute(app) { + app.commands.register({ id: 'workspace.reset', area: 'workspace', label: 'Reset Workspace', run }); + app.disposables.add(listen(window, 'resize', onResize)); + }, +}; + +// the shell (Shell-2) +const application = createApplication({ services: { commands, panels, tools, documents, ... } }); +const handle = application.contribute(require('./workspace-commands')); +handle.dispose(); // or application.dispose() at teardown +``` + +| Rule | Behavior | Test | +|---|---|---| +| Scoped `app` | Each contribution receives a frozen view of the services. Calls to methods the catalog marks `tracked` (`register`, `subscribe`) are owned by the contribution's store. | `contribution.test.js` | +| No discarded handles | A contribution that ignores the return value of `register`/`subscribe` still has it cleaned up. | "unretained handles are still owned" | +| Retained handles | The returned handle still works; calling it early also releases it from the store, so nothing is cleaned twice. | "retained handle" | +| Atomic | `contribute()` is synchronous. If it throws, or returns a promise, everything it registered is disposed before the error propagates (`ECONTRIBUTION_FAILED`, `ECONTRIBUTION_ASYNC`). | "rolled back atomically" | +| Unique | Contribution ids follow `area.name`; a live duplicate is `ECONTRIBUTION_DUPLICATE`. The same id may contribute again after disposal. | "invalid and duplicate" | +| Ordering | `application.dispose()` disposes contributions in reverse order, then refuses new ones (`EAPP_DISPOSED`). | "reverse order" | + +There is no dependency-injection container and no state-management package. +The shell constructs each service once and passes instances in. + +## 3. Disposable lifecycle contract + +`src/shell/disposable.js`: `toDisposable`, `asDisposable`, +`createDisposableStore`, `listen`. + +| Property | Rule | +|---|---| +| Shape | A disposable is `{ dispose() }`. A bare function (today's `unregister`/`unsubscribe`) is accepted at the boundary and wrapped. Anything else is `EDISPOSABLE_INVALID`: an attachment with no cleanup handle is a contract violation. | +| Ownership | The store's owner (a contribution, a mounted panel, a document session) disposes it exactly when the thing it represents ends. | +| Repeat disposal | Idempotent. A second `dispose()` is a no-op. | +| Order | Reverse registration order, so a later attachment that may depend on an earlier one goes first. Nested stores dispose children-first. | +| Failures | Every child is disposed even if some throw; the errors are re-thrown afterwards — the error itself when one child threw, or one `AggregateError` (`EDISPOSABLE_FAILED`) whose `errors` holds **every** failure in disposal order when several did. No later failure is dropped. The store is disposed and empty afterwards either way. | +| Late attachment | `add()` after disposal disposes the newcomer immediately, so it cannot outlive its owner. | +| Early release | `release(d)` disposes one child and forgets it. | +| DOM | `listen(target, type, fn)` returns the `removeEventListener` as a disposable. | + +Leak tests: 25 `listen()` attachments and 25 contributions that each attach +selection, preference, registry and DOM subscriptions all return their +listener counts to the baseline after disposal. + +Existing renderer modules are **not** migrated (the discarded handles in +APP-ARCH-0 §6 remain until Shell-2 adopts this contract). + +## 4. Document-session boundary + +`src/shell/document-session.js` defines the renderer `DocumentSession` of +APP-ARCH-0 §15. It is a controller over existing authorities, not a model: + +| Concern | Authority | What the session does | +|---|---|---| +| Source text | CodeMirror handle | `text()` reads `handle.getText()` on every call. Nothing is cached. | +| Undo | CodeMirror `history()` | `history()` reads `handle.historyDepth()`. There is no undo, redo or stack in the session. | +| Source edits | `handle.applyVerifiedEdits` | the one edit path, delegated unchanged | +| Selection | `sceneSelection` | `session.selection` **is** the controller object | +| Path, gzip, authorization | main `EditorSession` | not present. Only `sessionId` (from main's `describe()`) is held, for `isCurrent(id)`. | +| Profile | `context` at open | fixed at construction (`'mall' \| 'world' \| 'generic'`) | +| Analysis | derived | `setAnalysis(products)` replaces wholesale; dropped on dispose | +| Preview | X_ITE projection | an optional reference, disposed with the session | +| Workspace, `sourceOpen`, layout | shell workspace service | refused | + +**Why a second canonical document cannot appear.** It is enforced in code, +not by convention: +- the constructor accepts exactly `sessionId`, `profile`, `editor`, + `selection` and `preview`. `text`, `source`, `buffer`, `baseline`, `undo`, + `history`, `sceneGraph`, `ast`, `path`, `sourcePath`, `workspaceMode`, + `sourceOpen` and `layout` are refused by name (`EDOCSESSION_FORBIDDEN`), and + any other key is refused too; +- the session object is frozen and has no `setText`, `setDoc`, `undo`, + `redo`, `serialize`, `save` or `path`; +- a test types into the handle and proves the session's enumerable state and + its JSON never contain the text, while `text()` returns it. + +`dispose()` disposes session-owned attachments, the preview reference and +the CodeMirror view (the session owns the view for the document's lifetime, +APP-ARCH-0 §15). After disposal `text()` throws `EDOCSESSION_DISPOSED`. + +`createDocumentSlot()` is `app.documents`: one current session, and opening +a new one disposes the previous one (today's one-document rule). + +**Deferred to Shell-2:** who owns the saved baseline and therefore the dirty +flag. Today `S.baseline` in `editor.js` and main's `EditorSession` hold it; +the session contract deliberately refuses it rather than add a third holder. + +> SHELL-0 does not settle the final dirty-baseline owner. +> Shell-2 must resolve that ownership without creating a second canonical source or undo model. + +The `baseline` refusal is a SHELL-0 guard against a third, unreviewed copy — +not a ruling that dirty state can never involve the session. Shell-2 may change +the option list when it settles ownership, subject to that rule. + +## 5. Application-service boundary + +`src/shell/services.js` is a **closed catalog**. `createApplication` rejects an +unknown name (`EAPP_SERVICE_UNKNOWN`), a deferred one +(`EAPP_SERVICE_DEFERRED`), a missing required one (`EAPP_SERVICE_MISSING`) and +an instance without its declared surface (`EAPP_SERVICE_INVALID`). + +| Service | Status | Owner | Surface | +|---|---|---|---| +| `commands` (required) | existing | UI-0 Command Registry via `command-service.js` | register, has, get, list, isEnabled, isChecked, execute, keyBindings, subscribe, invalidate, profilesOf | +| `panels` (required) | existing | UI-0 Panel Registry via `panel-service.js` | register, has, get, list, isVisible, focus, show, hide, mount, unmount, isMounted, notifyResize | +| `tools` | SHELL-0 | `tool-registry.js` | register, has, get, list, state, activate, subscribe | +| `contextualPanels` | SHELL-0 | `contextual-panels.js` | register, reconcile, mounted, watch, dispose | +| `documents` | SHELL-0 | `document-session.js` | current, open, close, subscribe | +| `workspaces` | boundary | `setWorkspaceMode` + `workspace-presets.js`, wrapped by #121 | mode, preset, setMode, subscribe | +| `dialogs` | boundary | dialog stack, #121 (D3) | isModalOpen | +| `preferences` | existing | `WrlPreferences` | get, set, subscribe | + +**Named by APP-ARCH-0 §16 but not admitted in SHELL-0** (passing one is an +error, so `app` cannot quietly become a god-object): + +| Name | Why not now | +|---|---| +| `keyboard` | `installKeyboard` is already the one dispatcher; #121 adds the `dialogs.isModalOpen()` gate inside it | +| `preview` | `PreviewSession` and profile adapters are Shell-2; the session holds only a reference | +| `files` | a thin façade over `window.vrmlpad.editor.*` is Shell-2; main stays the path authority | +| `validation` | profile-contributed; a shared service would invite Mall rules into shared code | +| `status`, `notifications` | one status bar and one `aria-live` service are Shell-3 | +| `menu` | main-process (#122); the renderer half is `menu-boundary.js` over `app.commands` | + +## 6. Command contribution boundary + +`createCommandService(registry, { getProfile })` is `app.commands`. It stores, +executes and paints through the **one** UI-0 registry and adds only: + +- **`profiles`** (optional array of profile ids). An unlisted command is + profile-neutral. A listed one is folded into the record's own `enabled`, so + `isEnabled`, `execute`, the toolbar painter and the future menu all see the + same answer. With no document (`getProfile() === null`) a profile-limited + command is disabled. +- **`profilesOf(id)`** for menu/toolbar presentation. + +Profile **containment** is data; no profile **rule** is in shared code +(`profiles.js` holds the three ids only; `boundaries.test.js` asserts no +`src/shell` module requires `validator.js`, `src/mall`, `src/world-project`, +`src/vrml`, `fs` or Electron). Command ids, the record shape, key ownership, +shortcut normalization and the enabled/checked pull model are unchanged. A +command registered through a contribution is removed when the contribution is +disposed. No current command was moved; #121 commands are not migrated here. + +## 7. Panel contribution boundary + +`createPanelService(registry)` is `app.panels`. Records with `element()` pass +straight through, so the existing editor panels need no change. A record with +`mount` is **mountable**: + +```js +{ id, title, mount(host), dispose(), resize?(), focus?(), canToggle?, show?(), hide?() } +``` + +| Concern | Rule | +|---|---| +| Identity | stable panel ids, validated by the UI-0 registry (reserved `console` still refused) | +| Mount | one active mount at a time: the same host again is a no-op; another host while mounted is `EPANEL_MOUNTED`; a throwing `mount` leaves it unmounted, with no `dispose()` (mount must not leave partial resources when it throws), and may be retried | +| Element | a mountable panel's `element()` is its host after mount, `null` before | +| Visibility | still derived from the element on every read (UI-0 §11.2); an unmounted panel is not visible | +| Hidden but docked | stays mounted (CodeMirror and X_ITE are not re-created on a tab switch) | +| Dispose | `panels.unmount(id)` calls `dispose()` exactly once per mount; a repeated unmount is a no-op. State is cleared before `dispose()` runs, so a throwing dispose (whose error propagates) still leaves the panel unmounted. Unregistering a mounted panel unmounts it first; a repeated unregister is a no-op | +| Remount | disposing one mounted instance never spends the registration: after an unmount, `mount(id, host)` with the same or a different host is a fresh `mount()` call with fresh resources | +| Focus | `panels.focus(id)` stays the only focus path (UI-0 D6); it never opens a panel and reports `hidden` for an unmounted one | +| Resize | `panels.notifyResize(id)` forwards a dock engine's resize to the panel | +| Workspace visibility | unchanged: CSS + workspace classes decide what renders; presets stay semantics | + +No docking engine is selected or integrated (UI-C0 #38). No panel moved. + +Tested lifecycle (`test/shell/command-panel-services.test.js`): register → +mount(host A) → hide while docked (stays mounted, focus refuses) → show → +focus → host change refused → unmount (listeners back to zero, repeat safe) → +mount(host B) → unmount → mount(B) again → unregister while mounted (disposed, +repeat safe). Each mount attaches its own listeners and the test asserts none +survive into the next. Also: a throwing dispose leaves the panel remountable; +a failed mount can be retried. + +## 8. Tool contribution boundary + +`createToolRegistry({ commands, getProfile, getSelectionInfo })` is +`app.tools`: + +```js +app.tools.register({ id: 'tool.move', commandId: 'model.move', group: 'transform', + label?, icon?, profiles?: ['world'], appliesTo?: (sel) => sel.type === 'Transform' }); +``` + +- Groups are fixed to APP-ARCH-0 §18: `select`, `transform`, `create`, + `appearance`, `hierarchy`, `behavior`, `animation`. A test registers all 16 + example tools (Select … Keyframe) as records only. +- A tool has **no behavior and no enabled state of its own.** `activate` is + `commands.execute(commandId)`; `state(id)` is + `{ available, enabled, checked }`, where `enabled`/`checked` are the + command's. +- **Available** = command registered ∧ profile listed (if any) ∧ + `appliesTo(selection)` on a **proven** selection. Without a proven + selection a tool that declares `appliesTo` is unavailable (fail closed, + `WD.md` §7). A throwing `appliesTo` is unavailable. +- The command must exist when the tool registers (`ETOOL_INVALID`). +- `list(group)` returns registration order, which is toolbar order; + `subscribe` fires when the set changes. Painting follows + `commands.subscribe`, exactly like `[data-command]` controls. + +No tool is implemented. Tool-specific behavior stays in its command, through a +pure planner → `applyVerifiedEdits` (APP-ARCH-0 §18). + +## 9. Contextual-panel boundary + +`createContextualPanelHost({ resolveContext, createHost, releaseHost })` is +`app.contextualPanels`: + +```js +{ id, title, appliesTo(selectionInfo, analysis), mount(host, ctx), update?(ctx), dispose() } +``` + +- `resolveContext()` returns `{ selection, analysis }` from the document + session's **existing** authorities (`sceneSelection` + the analysis + products). The host never parses, never builds a scene tree and keeps no + selection of its own. +- `reconcile()` answers, per contribution, *"does this apply to the current + proven selection?"* and then mounts the newly applicable ones, calls + `update` on those still applicable, and disposes those that no longer apply. +- **Fail closed:** no context, no selection, or `selection.proven !== true` + applies nothing and disposes every mounted editor. A throwing `appliesTo` + does not apply (a mounted editor is disposed); a throwing `mount` is + reported in `failed`, its host is released and no active state remains; a + throwing `update` disposes that editor rather than leave it showing stale + state; a throwing `dispose` still clears the active state and releases the + host. One record's failure never stops the others from reconciling. +- **Re-activation is a fresh mount.** After a dispose, the next applicable + proven selection gets a new `createHost(id)` and a new `mount(host, ctx)`; + nothing from the previous activation is handed back. +- `dispose()` disposes every mounted editor even when several throw, and + re-throws (one error, or one `AggregateError`). +- `watch(sceneSelection.subscribe, analysisNotifier)` wires reconcile to the + change sources and returns one cleanup. + +Tested lifecycle (`test/shell/tools-contextual.test.js`): no selection → +proven match → mount → update → non-match (disposed, subscriptions back to +zero) → matching type but unproven (nothing) → proven match again (a second +mount on a new host) → dispose. + +`selectionInfo` is supplied by the document session (Shell-2). Its contract is +`{ id, type, kind, proven }`, where `proven` means the identity is +source-proven by the WD1.4 rules. The current Object panel +(`model-workspace.js`) is the expected first adopter; no editor is implemented +here. + +## 10. Menu integration boundary + +`src/shell/menu-boundary.js` is the renderer half of UI-0 §10.3 / APP-ARCH-0 +§20. **No menu, IPC channel or preload API is added.** + +| Step | Contract | +|---|---| +| Menu-addressable | `createMenuCommandSet(ids)`: a frozen allow-list of valid, unique command ids. Being registered is not enough; an id is data. | +| Dispatch | `dispatchMenuCommand(registry, menuSet, id)` → `{ ok, reason? }`. Refuses `not-allowed`, `unregistered`, `no-registry` (a page without a registry before Shell-4: dropped and logged) and `disabled` (through the registry, including out-of-profile commands). Runs with `source: 'menu'`, the same path as toolbar and keyboard. Never throws for a bad id. | +| State | `menuStateSnapshot` / `subscribeMenuState(registry, menuSet, send)`: pulled from `isEnabled`/`isChecked`, sent once and then only on change, returned as a disposable. With no registry: one all-disabled snapshot, nothing owned. | +| Per-page | the subscription is installed per page load until Shell-4 (APP-ARCH-0 §20 amendment 2); main still disables editor-only items when `currentPage !== 'editor'`. | + +## 11. Baseline methodology + +**Scope.** Every startup, page-switch and memory figure in §12–§14 is a +**repeatable SHELL-0 QA baseline**: measured through the established +visual-QA/capture environment on one Linux/X11 machine (Ryzen 9 5900X, Node +v24.21.0, Electron 41.7.1), in capture-server QA mode, with `--no-sandbox` +(production launches are sandboxed), a warm OS page cache, and small fixtures. +They are **not** universal end-user performance claims. A future comparison +must use equivalent methodology (this harness, unchanged switches, comparable +machine and fixtures) or state explicitly how and why it differs. + +`RESULTS.json` is **evidence, not a golden file.** No test or CI job compares +it byte-for-byte or numerically; a re-run on another day or machine produces +different numbers and that is expected. It carries the method, machine, +versions, fixtures, every raw sample and the runner log. It holds no absolute +paths, temp directories or usernames; raw OS process ids were replaced by +file-wide ordinals (`p1`, `p2`, …) that keep the process-tree links and the +renderer-persistence evidence (`method.pidPolicy`). + +`qa/shell-0-baseline/measure.js`, results in +`qa/shell-0-baseline/RESULTS.json` (summary, every raw sample, and the runner +log). + +| | | +|---|---| +| Harness | `VisualQaRunner` + the existing `WRL_FORGE_CAPTURE_SERVER` mode, under the visual-QA lock and `guardWindowsWorkspace`. Jobs use the capture server's existing `world`, `keyboard.goto` (the same `loadFile` + `currentPage` update `gotoPage` performs) and `keyboard.executeJS` kinds. **No product code or capture-server code changed.** | +| Profile | a fresh temp `--user-data-dir`; scratch fixtures under the OS temp dir (`test/fixtures/valid-gzip.wrl`, `test/fixtures/world/valid70`) | +| Switches | `--no-sandbox` (as every visual-QA orchestrator), plus measurement-only `--enable-precise-memory-info` and `--js-flags=--expose-gc` | +| Clock | `performance.timeOrigin + performance.now()` in both the harness and the renderer (epoch ms, same machine) | +| Startup | 12 sequential launches, one process each, 1.5 s cooldown; statistics exclude the first launch, which is reported separately | +| Page switch | one process; 25 World → Editor → World and 25 Mall → Editor → Mall round trips. A leg is timed from sending the `goto` job to the moment the destination page is usable, polled at 5 ms inside the page: editor `__wrlEditor.ready()`; World load complete and its scan state (`current`) present; Mall load complete. | +| Memory | after each leg: 1 s settle (3 s at startup), two forced GCs, `performance.memory`; then `VmRSS` and `Pss` for every process in the Electron tree from `/proc`. Zygote-forked children keep the zygote's argv, so roles come from the tree: children of the `--no-zygote-sandbox` zygote are the GPU process; children of the plain zygote are renderers; the largest is the page renderer, the other Chromium's spare. | +| Machine | Linux 7.0.0-34-generic x64, X11; AMD Ryzen 9 5900X (24 threads), 63 GB; Node v24.21.0; Electron 41.7.1 | + +Reproduce: `node qa/shell-0-baseline/measure.js [--launches=12] [--cycles=25]` +(about 8 minutes on this machine; needs a display). + +## 12. Startup baseline + +Starting page: **`index.html` (Mall)** on every launch. + +| Launch → … (11 launches after the first) | median | p10–p90 | IQR | +|---|---|---|---| +| navigation start (process boot) | 250.9 ms | 244.1–255.1 | 9.3 | +| **Mall page usable** (DOMContentLoaded end; all `defer` scripts have run) | **1,091.3 ms** | 1,074.0–1,110.2 | 31.8 | +| load event end | 1,092.4 ms | 1,075.2–1,111.4 | 31.9 | +| capture-server READY line (`did-finish-load` in main) | 1,095.5 ms | 1,078.1–1,114.5 | 31.9 | +| navigation start → usable, in the renderer | 839.7 ms | 822.7–855.9 | 21.6 | + +First launch of the run: 1,127 ms to usable. + +| After 3 s stabilization (Mall page, no item open) | median | p10–p90 | +|---|---|---| +| renderer JS heap (after GC) | 6.8 MB | 6.8–6.8 | +| main RSS | 167.0 MB | 166.4–167.7 | +| page renderer RSS / PSS | 130.6 / 80.7 MB | 130.1–131.3 / 80.3–81.1 | +| whole Electron tree PSS | 284.4 MB | 283.8–284.7 | + +Most of the start is document load, not process boot: about 840 ms of the +1.09 s is the Mall page parsing and running its scripts, dominated by the +1.3 MB X_ITE classic script. + +## 13. Page-switch baseline + +Every leg is a **full document load**: 100 of 100 legs started a new +navigation (a new `timeOrigin`), so a round trip is **two renderer reloads**. +The page renderer **process** is reused: one page-renderer PID served all 50 +round trips; there is no process swap. + +| Leg (25 each) | median | p10–p90 | max | navigation → load | +|---|---|---|---|---| +| World → Editor | 296.4 ms | 245.8–312.0 | 535.6 (first) | 183.5 ms | +| Editor → World | 111.5 ms | 108.5–117.0 | 157.6 | 103.6 ms | +| Mall → Editor | 206.6 ms | 197.4–214.9 | 362.5 (first) | 181.4 ms | +| Editor → Mall | 101.7 ms | 96.8–106.5 | 114.9 | 98.6 ms | + +| Round trip (sum of legs + the page's open IPC) | median | p10–p90 | +|---|---|---| +| **World → Editor → World** | **408.5 ms** | 360.3–433.1 | +| **Mall → Editor → Mall** | **319.9 ms** | 311.6–332.4 | + +Request → navigation start is about 1 ms; the rest is document load and +initialization. The page's own IPC before the switch is +`editor.openWorldPrimary` 0.6 ms and `editor.openMall` 13.5 ms (medians). + +**State after the switch:** + +| | Result | +|---|---| +| Editor has its document | 50/50 | +| World page rehydrated from `world:describe` | 25/25 | +| **Mall page restored its item** | **0/25 — the known Mall rehydration gap is confirmed.** Main still holds `currentSession`, but the page comes back blank; the user had to re-open the item before 24 of 25 round trips (the first used the initial open). That manual re-open is excluded from the timings above. | + +`mall:describe` was not added (Shell-4). + +## 14. Memory baseline + +**Stabilized pages** (1 s settle, two GCs): + +| Page | JS heap | page renderer RSS / PSS | main RSS | tree PSS | +|---|---|---|---|---| +| Mall, fresh launch, no item (§12) | 6.8 MB | 130.6 / 80.7 MB | 167.0 MB | 284.4 MB | +| World, fresh process, `valid70` open | 14.5 MB | 182.2 / 126.5 MB | 183.0 MB | 366.2 MB | +| Editor, World document, first visit | 29.4 MB | 223.7 / 167.3 MB | 186.1 MB | 414.6 MB | +| Mall, item open (median of the last 5 soak samples) | 12.0 MB | 192.4 MB RSS | 193.5 MB | 403.6 MB | +| Editor, Mall document (last 5 soak samples) | 14.6 MB | 196.0 MB RSS | 194.0 MB | 407.9 MB | + +**Soak** (25 round trips per profile, one process; first-5 vs last-5 sample +medians and the least-squares slope per round trip): + +| Series | JS heap after GC | page renderer RSS | tree PSS | main RSS | +|---|---|---|---|---| +| World page | 38.2 → 38.2 MB (slope 0, r² 0.17) | 283.7 → 288.9 (+0.4, r² 0.11) | 476.1 → 498.4 (+1.2, r² 0.42) | 189.2 → 195.0 (+0.2, r² 0.08) | +| Editor, World document | 40.7 → 40.7 MB (0, r² 0.01) | 290.7 → 291.4 (+0.8, r² 0.14) | 485.2 → 502.0 (+1.5, r² 0.38) | 188.9 → 194.3 (+0.2, r² 0.10) | +| Mall page | 32.1 → 12.0 MB (−0.8, r² 0.57) | 271.5 → 192.4 (−4.2, r² 0.84) | 481.6 → 403.6 (−4.2, r² 0.86) | 197.3 → 193.5 (−0.1) | +| Editor, Mall document | 34.7 → 14.6 MB (−0.9, r² 0.62) | 275.5 → 196.0 (−4.3, r² 0.85) | 487.2 → 407.9 (−4.3, r² 0.87) | 197.4 → 194.0 (0) | + +**Classification** (no leak is claimed from a rising sample): + +- **Retained growth: none demonstrated.** The JS heap after GC is flat + across the 25 World round trips and falls across the Mall ones. +- **Reload / detached-context effect.** After the World phase the heap sits + about 24 MB above a fresh World page (14.5 → 38 MB) even after forced GCs, + and falls back only after about seven more navigations (the Mall-page series + reads 32 MB seven times, then 12–17 MB). Earlier documents' contexts outlive + a forced GC for several navigations in the reused renderer process. This is + a cost of reloading pages, not of any one page. +- **Allocator retention.** Tree PSS rises during the World phase and + plateaus (about 498 MB for the last seven samples), then releases about + 80 MB during the Mall phase: a high-water mark, not linear growth. +- **Main process.** RSS is a sawtooth (183 → 197, a drop to 182, up to 200, + ending at 194 MB): garbage-collected, with a net drift of about +11 MB over + 50 round trips. This is **not** classified as a leak: it **needs a longer + soak before classification**. At this run length it cannot be separated + from allocator/GC behavior (§21). + +No retained renderer growth was proven in the measured soak. + +## 15. Reproducing + +| Measurement | Command | Output | +|---|---|---| +| Startup, page switch, memory | `node qa/shell-0-baseline/measure.js` | `qa/shell-0-baseline/RESULTS.json` | +| Module spike build | `node spikes/shell-0-module-loading/build.js` | `spikes/shell-0-module-loading/out/BUILD.json` (gitignored) | +| Module spike run | `node spikes/shell-0-module-loading/run.js` | `spikes/shell-0-module-loading/out/RESULTS.json` (gitignored) | + +Both Electron runs take the visual-QA lock, so they never overlap each other +or any other visual QA. + +## 16. Native ES-module `file://` spike + +`spikes/shell-0-module-loading/` (see its `NOTES.md` for every probe). One +**sandboxed** Electron 41.7.1 process with WRL Forge's renderer settings and +the editor page's CSP. + +| Question | Answer | +|---|---| +| Does `type="module"` load from `file://`? | Yes. Relative static imports, `.mjs`, dynamic `import()` and top-level `await` all work. | +| Module origin | `"file://"`; `import.meta.url` is the real file URL | +| CSP | `script-src 'self' file:` and a strict `script-src 'self'` both allow it with no violation. Inline modules are blocked, as inline classic scripts are. | +| Electron 41 / sandbox | works with the default sandbox, `contextIsolation`, no `nodeIntegration` | +| Packaging | works from inside an `asar` | +| Error reporting | a top-level throw reports file, line and column. **A missing import is silent in the console**: only an element-level `error` event fires. A bare specifier fails with a clear `TypeError`. | +| Debugging | stack traces show the original file and line; no source map needed | +| Shared `src/` modules | importable for their side effect (the `window.*` global), **not by name**: CommonJS modules have no ES exports (`SyntaxError`) | +| Testability | `node:test` can import ES modules, but the shared modules must stay CommonJS for main | + +Native ESM is reliable in Electron 41 under the product's constraints. + +## 17. esbuild comparison and bundling recommendation + +### 17.1 Comparison + +| Criterion | Native ESM | esbuild entry bundles | +|---|---|---| +| CSP | no change needed | no change needed (local files, no `eval`) | +| `file://` loading | works (§16) | works (a classic `defer` script, as today) | +| Shared CommonJS `src/` | **cannot import by name** — `src/` would have to become dual-format ES modules or keep the `window.*` globals | consumes CommonJS directly; `src/` unchanged | +| Source maps | not needed | local linked `.map` files; no CSP change; applied by DevTools, `Error.stack` shows bundle lines | +| Debugging | original files in stacks | original files in DevTools via the map | +| Failure diagnosis | missing import silent in the console | missing import is a **build error** | +| Testability | pure modules unchanged; ESM files need `import()` in tests | pure modules unchanged; add "each entry builds, no Node built-in reachable" (APP-ARCH-0 §23) | +| Determinism | no build output | byte-identical across three builds | +| Packaging | many files in `asar` | one file per entry in `asar`; already built in release CI | +| Load time, 42-file / ~330 KB graph | 33.5 ms (p10–p90 30.8–34.3) | **11.6 ms** (10.9–13.5); classic tags today 32.4 ms | +| Build overhead | none | 15 ms for the graph; `npm run build:editor` already runs before start and in CI | +| Emitted entry files | every module (27+ on the editor page) | one per page entry: three now, one after Shell-4, plus the existing CodeMirror bundle | +| Developer complexity | no build step; module graph explicit | one more esbuild entry in the existing script; module graph explicit | + +**What these load figures are.** They come from a **synthetic** 42-module, +~330 KB graph sized like the editor page — not from the actual current WRL +Forge renderer scripts — and X_ITE is **outside** that comparison entirely. +The ~2.9× ratio (11.6 vs 33.5 ms) supports the *architecture selection*; it is +**not** a predicted or guaranteed speedup for real WRL Forge page loads, which +are dominated by work this spike did not model. + +Context for the load figures: the page switches in §13 take 100–300 ms, and +about 840 ms of start-up is the Mall page's scripts, dominated by X_ITE. X_ITE +stays a classic vendor script under either option. Bundling saves about 21 ms +per editor-sized load; the larger win is removing reloads (Shell-4). + +### 17.2 Recommendation + +**`ESBUILD_ENTRY_BUNDLES`** + +1. **The shared code is CommonJS, and must stay so.** Main and `node:test` + `require` `src/` directly. Native ESM cannot import those modules by name, + so it would force a dual-format conversion of `src/` or keep the `window.*` + globals the shell is meant to retire. esbuild consumes them as they are. +2. **Failures surface at build time.** A missing import in native ESM is + silent in the console. +3. **It loaded measurably faster** in the synthetic graph: one bundle vs 42 + files (11.6 vs 33.5 ms). Supporting evidence for the choice, not a product + speedup claim (§17.1). +4. **No new tool.** esbuild is an existing devDependency, its output is + deterministic, and the build already runs before start and in release CI. + +Native ESM remains a technically valid fallback; nothing in the contracts +depends on the choice (the contract modules are classic-script safe today and +bundle unchanged). + +### 17.3 Not activated + +SHELL-0 changes **no** repository rule. The binding rule is still +`CLAUDE.md`: *"Renderer UI is plain HTML/CSS/JS (plus the editor's esbuild +bundle); no framework or bundler without separate approval."* No new +renderer entry point uses esbuild in this lane. + +### 17.4 Rule text for the later approved lane + +Proposed replacement for that `CLAUDE.md` bullet (APP-ARCH-0 §12 text with +the SHELL-0 evidence folded in): + +> Renderer UI is plain HTML/CSS/JS without a UI framework. **esbuild** (already +> an approved devDependency) is the one bundler. It may bundle first-party +> renderer entry points into local IIFE files under `renderer/`, with local +> linked source maps, built by an npm script and not committed. No other +> bundler, no CDN, no runtime dependency added by bundling, no Node built-in +> reachable from a renderer bundle, and no transform that requires loosening +> the CSP. Bundle output must be deterministic. Shared `src/` modules stay +> CommonJS so main and `node:test` keep requiring them directly. Vendor +> runtimes (X_ITE) stay classic script tags. + +## 18. What #121 receives + +#121 builds against these contracts and does not wait for any later stage. + +| #121 need | SHELL-0 contract | +|---|---| +| New commands outside `editor.js` (Play, `workspace.reset`, `panel.*.focus`) | feature modules with `contribute(app)`: e.g. a workspace-commands and a panel-commands contribution, registering through `app.commands` (§2, §6) | +| Every new subscription retained | the contribution's scoped `app` owns `register`/`subscribe` handles; `app.disposables` takes DOM listeners via `listen()` (§3) | +| D3 dialog key blocking | the `dialogs` boundary: `{ isModalOpen() }`. #121 implements it and gates `installKeyboard` on it, replacing per-dialog `classList` checks (§5). | +| Workspace focus / reset / Play | the `workspaces` boundary: `{ mode, preset, setMode, subscribe }`, wrapping the existing `setWorkspaceMode` + `workspace-presets.js` authority. Play's hidden-panel set is preset **data** (APP-ARCH-0 §25). | +| `panel.*.focus` | `app.panels.focus(id)`, the only focus path (§7) | +| Command gating | the existing `enabled` pull model; `profiles` data if a command is profile-limited (§6) | + +**Adoption step #121 owns:** it is the first caller. `editor.js` creates +`createApplication({ services })` over its existing registry instances +(`createCommandService`, `createPanelService`) and contributes #121's modules. +The modules are classic-script safe, so they load as ` + + + + + + diff --git a/spikes/shell-0-module-loading/app/classic-defer-2.js b/spikes/shell-0-module-loading/app/classic-defer-2.js new file mode 100644 index 0000000..688c710 --- /dev/null +++ b/spikes/shell-0-module-loading/app/classic-defer-2.js @@ -0,0 +1,2 @@ +'use strict'; +window.__spike.order.push('classic-defer-2'); diff --git a/spikes/shell-0-module-loading/app/classic-defer.js b/spikes/shell-0-module-loading/app/classic-defer.js new file mode 100644 index 0000000..e06fcd4 --- /dev/null +++ b/spikes/shell-0-module-loading/app/classic-defer.js @@ -0,0 +1,2 @@ +'use strict'; +window.__spike.order.push('classic-defer-1'); diff --git a/spikes/shell-0-module-loading/app/esm-bare.html b/spikes/shell-0-module-loading/app/esm-bare.html new file mode 100644 index 0000000..3f6d6e9 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-bare.html @@ -0,0 +1,12 @@ + + + + + + +esm-bare.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-basic.html b/spikes/shell-0-module-loading/app/esm-basic.html new file mode 100644 index 0000000..f57ad8d --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-basic.html @@ -0,0 +1,14 @@ + + + + + + +esm-basic.html + + + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-cjs-named.html b/spikes/shell-0-module-loading/app/esm-cjs-named.html new file mode 100644 index 0000000..63f6151 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-cjs-named.html @@ -0,0 +1,12 @@ + + + + + + +esm-cjs-named.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-cjs-side-effect.html b/spikes/shell-0-module-loading/app/esm-cjs-side-effect.html new file mode 100644 index 0000000..9b6bd2f --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-cjs-side-effect.html @@ -0,0 +1,12 @@ + + + + + + +esm-cjs-side-effect.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-inline.html b/spikes/shell-0-module-loading/app/esm-inline.html new file mode 100644 index 0000000..58dfe0c --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-inline.html @@ -0,0 +1,12 @@ + + + + + + +esm-inline.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-missing.html b/spikes/shell-0-module-loading/app/esm-missing.html new file mode 100644 index 0000000..0abf39f --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-missing.html @@ -0,0 +1,12 @@ + + + + + + +esm-missing.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-strict-self.html b/spikes/shell-0-module-loading/app/esm-strict-self.html new file mode 100644 index 0000000..8429c9d --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-strict-self.html @@ -0,0 +1,12 @@ + + + + + + +esm-strict-self.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm-throw.html b/spikes/shell-0-module-loading/app/esm-throw.html new file mode 100644 index 0000000..85811f7 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm-throw.html @@ -0,0 +1,12 @@ + + + + + + +esm-throw.html + + + + + diff --git a/spikes/shell-0-module-loading/app/esm/bare-specifier.js b/spikes/shell-0-module-loading/app/esm/bare-specifier.js new file mode 100644 index 0000000..6411762 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/bare-specifier.js @@ -0,0 +1,3 @@ +// A bare specifier: there is no Node-style resolution in the browser. +import 'x_ite'; +window.__spikeFinish({ unexpectedlyRan: true }); diff --git a/spikes/shell-0-module-loading/app/esm/bundle-entry.js b/spikes/shell-0-module-loading/app/esm/bundle-entry.js new file mode 100644 index 0000000..6e8ab11 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/bundle-entry.js @@ -0,0 +1,17 @@ +// The same graph as entry.js, written for an esbuild IIFE entry bundle +// (IIFE output has no top-level await and no import.meta). +import { add, fromA } from './lib/a.js'; +import { label } from './lib/b.mjs'; + +window.__spike.order.push('bundle'); +import('./lib/lazy.js').then((lazy) => { + let stack = null; + try { throw new Error('stack-probe'); } catch (e) { stack = String(e.stack); } + window.__spikeFinish({ + sum: add(2, 3), fromA, label, lazy: lazy.value, + currentScriptIsNull: document.currentScript === null, + moduleScoped: typeof window.add === 'undefined', + readyStateAtRun: document.readyState, + stack, + }); +}); diff --git a/spikes/shell-0-module-loading/app/esm/cjs-named.js b/spikes/shell-0-module-loading/app/esm/cjs-named.js new file mode 100644 index 0000000..c5ccd1f --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/cjs-named.js @@ -0,0 +1,3 @@ +// The same dual-format module imported by NAME: it has no ES exports. +import { createCommandRegistry } from '../../../../src/editor/command-registry.js'; +window.__spikeFinish({ unexpectedlyRan: typeof createCommandRegistry }); diff --git a/spikes/shell-0-module-loading/app/esm/cjs-side-effect.js b/spikes/shell-0-module-loading/app/esm/cjs-side-effect.js new file mode 100644 index 0000000..163aaa9 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/cjs-side-effect.js @@ -0,0 +1,8 @@ +// A dual-format shared src/ module (CommonJS in Node, window.* in a classic +// script) imported as a module for its side effect. +import '../../../../src/editor/command-registry.js'; +const api = window.WrlCommandRegistry; +window.__spikeFinish({ + globalPublished: typeof api, + registryWorks: !!(api && api.createCommandRegistry && api.createCommandRegistry().list().length === 0), +}); diff --git a/spikes/shell-0-module-loading/app/esm/entry-min.js b/spikes/shell-0-module-loading/app/esm/entry-min.js new file mode 100644 index 0000000..4132e37 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/entry-min.js @@ -0,0 +1,2 @@ +import { add } from './lib/a.js'; +window.__spikeFinish({ sum: add(1, 1), strictSelfCspLoaded: true }); diff --git a/spikes/shell-0-module-loading/app/esm/entry.js b/spikes/shell-0-module-loading/app/esm/entry.js new file mode 100644 index 0000000..be936c2 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/entry.js @@ -0,0 +1,20 @@ +// Native ES module entry: static relative imports, a .mjs import, a dynamic +// import, module scope, import.meta, origin and stack-trace evidence. +import { add, fromA } from './lib/a.js'; +import { label } from './lib/b.mjs'; + +window.__spike.order.push('module'); +const topLevelThis = this; +const lazy = await import('./lib/lazy.js'); +let stack = null; +try { throw new Error('stack-probe'); } catch (e) { stack = String(e.stack); } +window.__spikeFinish({ + sum: add(2, 3), fromA, label, lazy: lazy.value, + importMetaUrl: import.meta.url, + selfOrigin: self.origin, locationOrigin: location.origin, protocol: location.protocol, + currentScriptIsNull: document.currentScript === null, + topLevelThisUndefined: topLevelThis === undefined, + moduleScoped: typeof window.add === 'undefined', + readyStateAtRun: document.readyState, + stack, +}); diff --git a/spikes/shell-0-module-loading/app/esm/lib/a.js b/spikes/shell-0-module-loading/app/esm/lib/a.js new file mode 100644 index 0000000..4ab78eb --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/lib/a.js @@ -0,0 +1,2 @@ +export function add(a, b) { return a + b; } +export const fromA = 'a.js'; diff --git a/spikes/shell-0-module-loading/app/esm/lib/b.mjs b/spikes/shell-0-module-loading/app/esm/lib/b.mjs new file mode 100644 index 0000000..c63f54c --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/lib/b.mjs @@ -0,0 +1,2 @@ +// .mjs: MIME type is derived from the extension on file://. +export const label = 'b.mjs'; diff --git a/spikes/shell-0-module-loading/app/esm/lib/lazy.js b/spikes/shell-0-module-loading/app/esm/lib/lazy.js new file mode 100644 index 0000000..7968695 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/lib/lazy.js @@ -0,0 +1 @@ +export const value = 'lazy.js (dynamic import)'; diff --git a/spikes/shell-0-module-loading/app/esm/missing-import.js b/spikes/shell-0-module-loading/app/esm/missing-import.js new file mode 100644 index 0000000..d4330d4 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/missing-import.js @@ -0,0 +1,2 @@ +import { nothing } from './lib/does-not-exist.js'; +window.__spikeFinish({ unexpectedlyRan: nothing }); diff --git a/spikes/shell-0-module-loading/app/esm/throws.js b/spikes/shell-0-module-loading/app/esm/throws.js new file mode 100644 index 0000000..3d98b59 --- /dev/null +++ b/spikes/shell-0-module-loading/app/esm/throws.js @@ -0,0 +1,3 @@ +import { add } from './lib/a.js'; +add(1, 2); +throw new Error('module top-level failure'); diff --git a/spikes/shell-0-module-loading/app/probe.js b/spikes/shell-0-module-loading/app/probe.js new file mode 100644 index 0000000..3249698 --- /dev/null +++ b/spikes/shell-0-module-loading/app/probe.js @@ -0,0 +1,25 @@ +'use strict'; +// SHELL-0 spike probe (classic script, loaded first on every spike page). +// Records execution order, errors (window AND element-level, via capture), +// unhandled rejections and CSP violations into window.__spike for main to read. +window.__spike = { order: [], errors: [], violations: [], done: false, data: {}, t: null }; +window.addEventListener('error', function (e) { + if (e.target && e.target !== window) { + window.__spike.errors.push({ kind: 'element', tag: e.target.tagName, src: e.target.src || null }); + return; + } + window.__spike.errors.push({ kind: 'window', message: e.message, filename: e.filename, lineno: e.lineno, colno: e.colno, + stack: e.error && e.error.stack ? String(e.error.stack) : null }); +}, true); +window.addEventListener('unhandledrejection', function (e) { + var r = e.reason; + window.__spike.errors.push({ kind: 'rejection', message: String(r && r.message || r), stack: r && r.stack ? String(r.stack) : null }); +}); +document.addEventListener('securitypolicyviolation', function (e) { + window.__spike.violations.push({ directive: e.violatedDirective, blockedURI: e.blockedURI, sample: e.sample || null }); +}); +window.__spikeFinish = function (data) { + Object.assign(window.__spike.data, data || {}); + window.__spike.t = performance.now(); + window.__spike.done = true; +}; diff --git a/spikes/shell-0-module-loading/build.js b/spikes/shell-0-module-loading/build.js new file mode 100644 index 0000000..d02938a --- /dev/null +++ b/spikes/shell-0-module-loading/build.js @@ -0,0 +1,163 @@ +'use strict'; +// SHELL-0 module-loading spike -- build step. Evidence only; never shipped. +// +// node spikes/shell-0-module-loading/build.js +// +// Writes everything under out/ (gitignored, regenerable): +// out/bundle-basic.js(.map) esbuild IIFE of app/esm/bundle-entry.js +// out/many/{esm,classic}/... one synthetic first-party graph, 41 modules, +// out/many/bundle.js(.map) in three loading strategies: native ESM, +// out/many/*.html classic defer tags (today) and one bundle +// out/spike.asar app/ + out/bundle-basic.* packed like a release +// out/BUILD.json build time, sizes, file counts, determinism +// +// Uses only the existing devDependencies (esbuild, and @electron/asar, which +// electron-builder already installs). Deterministic: no clock in any output. + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const esbuild = require('esbuild'); + +const HERE = __dirname; +const OUT = path.join(HERE, 'out'); +const MANY = path.join(OUT, 'many'); +const CSP = "default-src 'none'; script-src 'self' file: 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' file: data: blob:; object-src 'none'; base-uri 'none';"; +const FEATURES = 8; +const LEAVES = 4; + +const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); + +// ~8 KB of ordinary first-party-looking code per module. Every function is +// referenced at load (as a real feature module's registrations would be), so +// the bundle cannot tree-shake the comparison away. +function body(name) { + const fns = []; + for (let k = 0; k < 24; k++) { + fns.push(`function ${name}_f${k}(x, y) { + const a = [x, y, ${k}].map((v) => (typeof v === 'number' ? v * ${k + 1} : String(v).length)); + let s = 0; + for (const v of a) s += v % ${k + 7}; + if (s > ${k * 3}) return { id: '${name}.${k}', value: s, label: 'item-' + s.toString(16) }; + return { id: '${name}.${k}', value: -s, label: null }; +}`); + } + return fns.join('\n'); +} + +function moduleNames() { + const out = ['util']; + for (let f = 0; f < FEATURES; f++) { + out.push(`feature${f}`); + for (let l = 0; l < LEAVES; l++) out.push(`leaf${f}_${l}`); + } + return out; // 1 + 8 + 32 = 41 modules (+ entry) +} + +function writeGraph() { + const esmDir = path.join(MANY, 'esm'); + const classicDir = path.join(MANY, 'classic'); + fs.mkdirSync(esmDir, { recursive: true }); + fs.mkdirSync(classicDir, { recursive: true }); + const names = moduleNames(); + const classicOrder = []; + for (const name of names) { + const imports = []; + if (name.startsWith('feature')) { + const f = name.slice(7); + for (let l = 0; l < LEAVES; l++) imports.push(`import { leaf${f}_${l}_f0 } from './leaf${f}_${l}.js';`); + imports.push("import { util_f0 } from './util.js';"); + } + const uses = name.startsWith('feature') + ? `[${[...Array(LEAVES).keys()].map((l) => `leaf${name.slice(7)}_${l}_f0(1, 2)`).join(', ')}, util_f0(3, 4)]` : '[]'; + const code = body(name); + fs.writeFileSync(path.join(esmDir, `${name}.js`), + `${imports.join('\n')}\n${code}\nexport { ${[...Array(24).keys()].map((k) => `${name}_f${k}`).join(', ')} };\n(globalThis.__reg || (globalThis.__reg = [])).push(['${name}', ${uses}.length, [${[...Array(24).keys()].map((k) => `${name}_f${k}`).join(', ')}].length]);\n`); + classicOrder.push(name); + } + // classic order: leaves and util before features (what tag order must encode today) + classicOrder.sort((a, b) => (a.startsWith('feature') ? 1 : 0) - (b.startsWith('feature') ? 1 : 0)); + for (const name of classicOrder) { + const code = body(name); + fs.writeFileSync(path.join(classicDir, `${name}.js`), + `'use strict';\n(function () {\n${code}\nwindow.__mods = window.__mods || {};\nwindow.__mods.${name} = { ${[...Array(24).keys()].map((k) => `${name}_f${k}`).join(', ')} };\n(globalThis.__reg || (globalThis.__reg = [])).push(['${name}', 0]);\n})();\n`); + } + const entry = `${[...Array(FEATURES).keys()].map((f) => `import { feature${f}_f0 } from './feature${f}.js';`).join('\n')} +const r = [${[...Array(FEATURES).keys()].map((f) => `feature${f}_f0(5, 6)`).join(', ')}]; +window.__spikeFinish({ modules: globalThis.__reg.length, results: r.length }); +`; + fs.writeFileSync(path.join(esmDir, 'entry.js'), entry); + fs.writeFileSync(path.join(classicDir, 'zz-finish.js'), "'use strict';\nwindow.__spikeFinish({ modules: globalThis.__reg.length, results: 8 });\n"); + fs.copyFileSync(path.join(HERE, 'app', 'probe.js'), path.join(MANY, 'probe.js')); + const page = (title, tags) => ` + + + + +${title} + +${tags} + + + +`; + fs.writeFileSync(path.join(MANY, 'esm.html'), page('many: native ESM', '')); + fs.writeFileSync(path.join(MANY, 'classic.html'), page('many: classic defer tags', + [...classicOrder, 'zz-finish'].map((n) => ``).join('\n'))); + fs.writeFileSync(path.join(MANY, 'bundle.html'), page('many: esbuild entry bundle', '')); + return { modules: names.length + 1, classicTags: classicOrder.length + 1 }; +} + +const BUNDLE_OPTS = { bundle: true, format: 'iife', target: 'chrome122', legalComments: 'none', sourcemap: 'linked', logLevel: 'silent' }; + +async function bundle(entryPoint, outfile) { + const t0 = performance.now(); + await esbuild.build({ ...BUNDLE_OPTS, entryPoints: [entryPoint], outfile }); + const ms = performance.now() - t0; + // determinism: two in-memory rebuilds must equal the written bytes + const again = await Promise.all([1, 2].map(() => esbuild.build({ ...BUNDLE_OPTS, entryPoints: [entryPoint], outfile, write: false }))); + const written = fs.readFileSync(outfile); + const deterministic = again.every((r) => { + const js = r.outputFiles.find((f) => f.path === path.resolve(outfile)); + return js && sha(Buffer.from(js.contents)) === sha(written); + }); + return { buildMs: Math.round(ms * 10) / 10, bytes: written.length, mapBytes: fs.statSync(outfile + '.map').size, sha256: sha(written), deterministic }; +} + +function dirBytes(dir) { + let n = 0; let files = 0; + for (const f of fs.readdirSync(dir)) { n += fs.statSync(path.join(dir, f)).size; files++; } + return { bytes: n, files }; +} + +async function main() { + fs.rmSync(OUT, { recursive: true, force: true }); + fs.mkdirSync(OUT, { recursive: true }); + const graph = writeGraph(); + const basic = await bundle(path.join(HERE, 'app', 'esm', 'bundle-entry.js'), path.join(OUT, 'bundle-basic.js')); + const many = await bundle(path.join(MANY, 'esm', 'entry.js'), path.join(MANY, 'bundle.js')); + + // A release-like asar: the page tree plus the built bundle, read through + // Electron's asar-aware file:// loader. + const stage = path.join(OUT, 'asar-stage'); + fs.cpSync(path.join(HERE, 'app'), path.join(stage, 'app'), { recursive: true }); + fs.mkdirSync(path.join(stage, 'out'), { recursive: true }); + for (const f of ['bundle-basic.js', 'bundle-basic.js.map']) fs.copyFileSync(path.join(OUT, f), path.join(stage, 'out', f)); + await require('@electron/asar').createPackage(stage, path.join(OUT, 'spike.asar')); + fs.rmSync(stage, { recursive: true, force: true }); + + const report = { + esbuild: require('esbuild/package.json').version, + options: BUNDLE_OPTS, + graph, + bundleBasic: basic, + bundleMany: many, + esmMany: dirBytes(path.join(MANY, 'esm')), + classicMany: dirBytes(path.join(MANY, 'classic')), + asar: { bytes: fs.statSync(path.join(OUT, 'spike.asar')).size }, + }; + fs.writeFileSync(path.join(OUT, 'BUILD.json'), JSON.stringify(report, null, 2) + '\n'); + process.stdout.write(JSON.stringify(report, null, 2) + '\n'); +} + +main().catch((err) => { console.error(err && err.stack || err); process.exit(1); }); diff --git a/spikes/shell-0-module-loading/main.js b/spikes/shell-0-module-loading/main.js new file mode 100644 index 0000000..b08fdc4 --- /dev/null +++ b/spikes/shell-0-module-loading/main.js @@ -0,0 +1,65 @@ +'use strict'; +// SHELL-0 module-loading spike -- a minimal Electron main that loads spike +// pages with the SAME renderer security settings as WRL Forge's window +// (contextIsolation true, nodeIntegration false, Electron's default sandbox, a +// strict CSP) and reports what happened. Evidence only; never shipped. +// +// Speaks the capture-server protocol (WRL_FORGE_CAPTURE_READY / _OK / _ERR over +// stdout, newline-JSON jobs on stdin) so qa/visual-qa/runner.js drives it with +// its launch cap, PID accounting and leak check. Job: +// { id, file, timeoutMs? } -> { result: window.__spike, console: [...], loadError? } + +const path = require('path'); +const { app, BrowserWindow } = require('electron'); + +const emit = (line) => process.stdout.write(line + '\n'); + +app.whenReady().then(() => { + const win = new BrowserWindow({ + show: false, width: 900, height: 700, + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + let consoleLog = []; + win.webContents.on('console-message', (ev, level, message, line, sourceId) => { + const e = ev && ev.message !== undefined ? ev : { level, message, lineNumber: line, sourceId }; + consoleLog.push({ level: e.level, message: String(e.message), line: e.lineNumber, source: e.sourceId ? path.basename(String(e.sourceId)) : null }); + }); + + async function runJob(job) { + consoleLog = []; + let loadError = null; + try { await win.loadFile(job.file); } catch (err) { loadError = String(err && err.message || err); } + const deadline = Date.now() + (job.timeoutMs || 3000); + let result = null; + while (Date.now() < deadline) { + result = await win.webContents.executeJavaScript('window.__spike ? JSON.stringify(window.__spike) : null').catch(() => null); + if (result && JSON.parse(result).done) break; + await new Promise((r) => setTimeout(r, 20)); + } + await new Promise((r) => setTimeout(r, 50)); // let late console/error events land + result = await win.webContents.executeJavaScript('window.__spike ? JSON.stringify(window.__spike) : null').catch(() => result); + const nav = await win.webContents.executeJavaScript( + "(function(){var n=performance.getEntriesByType('navigation')[0]||{};return {dclEnd:n.domContentLoadedEventEnd,loadEnd:n.loadEventEnd};})()", + ).catch(() => null); + return { result: result ? JSON.parse(result) : null, nav, console: consoleLog, loadError }; + } + + let chain = Promise.resolve(); + const rl = require('readline').createInterface({ input: process.stdin }); + rl.on('line', (raw) => { + const line = raw.trim(); + if (!line) return; + let job; + try { job = JSON.parse(line); } catch { emit('WRL_FORGE_CAPTURE_ERR - bad-json'); return; } + chain = chain.then(async () => { + if (job.cmd === 'shutdown') { app.quit(); return; } + try { emit('WRL_FORGE_CAPTURE_OK ' + job.id + ' ' + JSON.stringify(await runJob(job))); } catch (err) { + emit('WRL_FORGE_CAPTURE_ERR ' + job.id + ' ' + String(err && err.message || err)); + } + }); + }); + rl.on('close', () => app.quit()); + emit('WRL_FORGE_CAPTURE_READY'); +}); + +app.on('window-all-closed', () => app.quit()); diff --git a/spikes/shell-0-module-loading/run.js b/spikes/shell-0-module-loading/run.js new file mode 100644 index 0000000..8647424 --- /dev/null +++ b/spikes/shell-0-module-loading/run.js @@ -0,0 +1,117 @@ +'use strict'; +// SHELL-0 module-loading spike -- runner. Evidence only; never shipped. +// +// node spikes/shell-0-module-loading/build.js +// node spikes/shell-0-module-loading/run.js [--rounds=15] +// +// ONE Electron process (spike main.js), driven by VisualQaRunner under the +// visual-QA lock and the workspace guard, loads every spike page, then +// alternates the three loading strategies of one synthetic 42-file graph +// (classic defer tags / native ESM / one esbuild entry bundle) for N rounds. +// Writes out/RESULTS.json (gitignored) and prints a summary. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const repoRoot = path.join(__dirname, '..', '..'); +const { VisualQaRunner } = require(path.join(repoRoot, 'qa', 'visual-qa', 'runner')); +const { acquire } = require(path.join(repoRoot, 'qa', 'visual-qa', 'lock')); +const { guardWindowsWorkspace } = require(path.join(repoRoot, 'qa', 'visual-qa', 'workspace-guard')); +const { parseArgs } = require(path.join(repoRoot, 'qa', 'visual-qa', 'cli')); + +const HERE = __dirname; +const OUT = path.join(HERE, 'out'); +const APP = path.join(HERE, 'app'); +const ASAR = path.join(OUT, 'spike.asar'); + +function stats(values) { + const v = values.filter(Number.isFinite).sort((a, b) => a - b); + if (!v.length) return null; + const q = (p) => { const i = (v.length - 1) * p; const lo = Math.floor(i); return v[lo] + (v[Math.ceil(i)] - v[lo]) * (i - lo); }; + const r = (x) => Math.round(x * 10) / 10; + return { n: v.length, median: r(q(0.5)), p10: r(q(0.1)), p90: r(q(0.9)), min: r(v[0]), max: r(v[v.length - 1]) }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const rounds = Number(args.flags.rounds || 15); + guardWindowsWorkspace({ cwd: repoRoot, label: 'shell-0-module-spike' }); + if (!fs.existsSync(path.join(OUT, 'BUILD.json'))) { + console.error('run build.js first'); + process.exit(2); + } + if (process.platform !== 'win32' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + console.error('module spike: no DISPLAY/WAYLAND_DISPLAY -- refusing to launch Electron headless-blind.'); + process.exit(2); + } + const sandboxed = !args.flags['no-sandbox']; + const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'wrlforge-shell0-esm-')); + const jobs = []; + const feature = (id, file, timeoutMs) => jobs.push({ id, file, timeoutMs }); + feature('esm-basic', path.join(APP, 'esm-basic.html')); + feature('esm-strict-self', path.join(APP, 'esm-strict-self.html')); + feature('esm-inline', path.join(APP, 'esm-inline.html'), 1200); + feature('esm-missing', path.join(APP, 'esm-missing.html'), 1200); + feature('esm-throw', path.join(APP, 'esm-throw.html'), 1200); + feature('esm-bare', path.join(APP, 'esm-bare.html'), 1200); + feature('esm-cjs-side-effect', path.join(APP, 'esm-cjs-side-effect.html')); + feature('esm-cjs-named', path.join(APP, 'esm-cjs-named.html'), 1200); + feature('bundle-basic', path.join(APP, 'bundle-basic.html')); + feature('asar-esm-basic', path.join(ASAR, 'app', 'esm-basic.html')); + feature('asar-bundle-basic', path.join(ASAR, 'app', 'bundle-basic.html')); + const strategies = ['classic', 'esm', 'bundle']; + for (let r = 0; r < rounds; r++) { + // rotate the order each round so no strategy always runs first + for (let k = 0; k < strategies.length; k++) { + const s = strategies[(r + k) % strategies.length]; + jobs.push({ id: `many-${s}-${r}`, file: path.join(OUT, 'many', `${s}.html`), timeoutMs: 5000 }); + } + } + + const runner = new VisualQaRunner({ + spawn: () => spawn(require('electron'), [path.join(HERE, 'main.js'), ...(sandboxed ? [] : ['--no-sandbox']), `--user-data-dir=${userData}`], { + cwd: repoRoot, stdio: ['pipe', 'pipe', 'ignore'], + }), + maxLaunches: 1, retriesPerLaunch: 0, readyTimeoutMs: 30000, captureTimeoutMs: 30000, + log: () => {}, + }); + const release = acquire(); + let results; + try { results = await runner.run(jobs); } finally { + release(); + fs.rmSync(userData, { recursive: true, force: true }); + } + const byId = Object.fromEntries(results.map((r) => [r.id, r])); + const timing = {}; + for (const s of strategies) { + const runs = results.filter((r) => r.id.startsWith(`many-${s}-`)); + timing[s] = { + allCompleted: runs.every((r) => r.result && r.result.done), + modulesSeen: runs[0] && runs[0].result && runs[0].result.data.modules, + toDoneMs: stats(runs.map((r) => r.result && r.result.t)), + toDoneMsExcludingFirst: stats(runs.slice(1).map((r) => r.result && r.result.t)), + toLoadEndMs: stats(runs.map((r) => r.nav && r.nav.loadEnd)), + }; + } + const features = {}; + for (const j of jobs.filter((x) => !x.id.startsWith('many-'))) { + const r = byId[j.id]; + features[j.id] = { done: !!(r.result && r.result.done), data: r.result && r.result.data, order: r.result && r.result.order, + errors: r.result && r.result.errors, violations: r.result && r.result.violations, console: r.console, loadError: r.loadError }; + } + const report = { + lane: 'SHELL-0 module-loading spike', + electron: require('electron/package.json').version, + platform: `${process.platform} ${os.release()}`, + sandboxed, + rounds, + build: JSON.parse(fs.readFileSync(path.join(OUT, 'BUILD.json'), 'utf8')), + features, + timing, + }; + fs.writeFileSync(path.join(OUT, 'RESULTS.json'), JSON.stringify(report, null, 2) + '\n'); + process.stdout.write(JSON.stringify({ features: Object.fromEntries(Object.entries(features).map(([k, v]) => [k, { done: v.done, errors: v.errors && v.errors.length, violations: v.violations && v.violations.length }])), timing }, null, 2) + '\n'); +} + +main().catch((err) => { console.error(err && err.stack || err); process.exit(1); }); diff --git a/src/shell/command-service.js b/src/shell/command-service.js new file mode 100644 index 0000000..6936d5c --- /dev/null +++ b/src/shell/command-service.js @@ -0,0 +1,89 @@ +'use strict'; +// SHELL-0 command contribution boundary +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §6; +// APP-ARCH-0 §17; UI-0 §6, §9). +// +// The `app.commands` service. It is NOT a second registry: every record is +// stored in, executed by, and painted from the one UI-0 Command Registry +// (src/editor/command-registry.js). This module only adds what APP-ARCH-0 §17 +// specified for contributions: +// +// * `profiles` (optional array of profile ids) on a command record. An +// unlisted command is profile-neutral. A listed one is enabled only while +// the current document's profile is in the list -- folded into the +// record's own `enabled`, so registry.isEnabled / execute / the toolbar +// painter / the future menu all see the same answer. Profile containment is +// DATA here; no profile's RULES live in this module. +// * `profilesOf(id)` so a menu or toolbar can ask which profiles a command +// belongs to without re-reading the record. +// +// Command ids, the record shape, key ownership and the enabled/checked pull +// model are the registry's and are unchanged. register() still returns the +// registry's unregister function; under the contribution host it is owned by +// the contribution (contribution.js). + +(function () { + const isNode = typeof module !== 'undefined' && module.exports; + const PR = isNode ? require('./profiles') : window.WrlShellProfiles; + + function commandServiceError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + // registry: a createCommandRegistry() instance. + // getProfile: () => current document profile id, or null with no document. + function createCommandService(registry, { getProfile } = {}) { + if (!registry || typeof registry.register !== 'function') { + throw commandServiceError('ECOMMAND_SERVICE_INVALID', 'a command registry is required'); + } + if (typeof getProfile !== 'function') { + throw commandServiceError('ECOMMAND_SERVICE_INVALID', 'getProfile() is required'); + } + const profilesById = new Map(); + + function register(record) { + if (!record || typeof record !== 'object' || record.profiles === undefined) return registry.register(record); + const profiles = PR.normalizeProfileList(record.profiles, `command ${record.id}`); + const own = record.enabled; + const inProfile = () => profiles.includes(getProfile()); + const { profiles: _omit, ...rest } = record; + void _omit; + const unregister = registry.register({ + ...rest, + enabled: () => inProfile() && (typeof own === 'function' ? !!own() : true), + }); + profilesById.set(record.id, profiles); + return function unregisterProfiled() { + unregister(); + if (!registry.has(record.id)) profilesById.delete(record.id); + }; + } + + // null = profile-neutral (or unknown id). + function profilesOf(id) { return profilesById.get(id) || null; } + + return Object.freeze({ + register, + profilesOf, + has: registry.has, + get: registry.get, + list: registry.list, + isEnabled: registry.isEnabled, + isChecked: registry.isChecked, + execute: registry.execute, + keyBindings: registry.keyBindings, + subscribe: registry.subscribe, + invalidate: registry.invalidate, + }); + } + + const WRL_SHELL_COMMAND_SERVICE_API = Object.freeze({ createCommandService }); + + if (isNode) { + module.exports = WRL_SHELL_COMMAND_SERVICE_API; + } else { + window.WrlShellCommandService = WRL_SHELL_COMMAND_SERVICE_API; + } +})(); diff --git a/src/shell/contextual-panels.js b/src/shell/contextual-panels.js new file mode 100644 index 0000000..5510b9e --- /dev/null +++ b/src/shell/contextual-panels.js @@ -0,0 +1,168 @@ +'use strict'; +// SHELL-0 contextual-panel contribution boundary +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §9; APP-ARCH-0 §19). +// +// The `app.contextualPanels` service: selection-specific editors (Transform, +// Extrusion, IndexedFaceSet, Material ...) contributed as +// +// { id, title, appliesTo(selectionInfo, analysis), mount(host, ctx), +// update?(ctx), dispose() } +// +// and mounted into ONE Context host by reconcile(), which the shell calls on +// every sceneSelection change and every analysis (watch() wires that): +// +// * The question asked is exactly "does this contribution apply to the +// current PROVEN selection?". resolveContext() supplies +// { selection, analysis } from the document session's EXISTING authorities +// (sceneSelection + the analysis products) -- this module never parses, +// never builds a scene tree and never keeps its own selection. +// * Fail closed (WD.md §7): no context, no selection, or a selection whose +// identity is not proven (`selection.proven !== true`) applies NOTHING, and +// every mounted editor is disposed. A throwing appliesTo does not apply. +// * Newly applicable -> createHost(id) + mount(host, ctx). Still applicable -> +// update(ctx) if provided. No longer applicable -> dispose() + releaseHost. +// A mount that throws leaves the editor unmounted (its host is released; +// dispose() is NOT called -- mount must not leave partial resources behind +// when it throws). An update that throws is disposed (fail closed). A +// throwing dispose still clears the active state and releases the host. +// One record's failure never stops the others from reconciling. +// * Re-activation is a NEW mount: after a dispose, the next applicable +// selection gets a fresh createHost(id) and a fresh mount(host, ctx). +// Nothing from the previous activation is handed back. +// * Editors write only through planners -> applyVerifiedEdits (APP-ARCH-0 +// §19); nothing here can edit source. +// +// None of the example editors is implemented in SHELL-0. + +(function () { + const CONTEXTUAL_ID_PATTERN = /^[a-z][a-zA-Z0-9]*$/; + + function contextualError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + function createContextualPanelHost({ resolveContext, createHost, releaseHost } = {}) { + if (typeof resolveContext !== 'function') throw contextualError('ECONTEXTUAL_INVALID', 'resolveContext() is required'); + if (typeof createHost !== 'function') throw contextualError('ECONTEXTUAL_INVALID', 'createHost(id) is required'); + const release = typeof releaseHost === 'function' ? releaseHost : () => {}; + const records = new Map(); // id -> record (registration order) + const mountedHosts = new Map(); // id -> host + + function validate(r) { + const bad = (why) => { throw contextualError('ECONTEXTUAL_INVALID', why); }; + if (!r || typeof r !== 'object') bad('a contextual panel must be an object'); + if (typeof r.id !== 'string' || !CONTEXTUAL_ID_PATTERN.test(r.id)) bad(`invalid contextual panel id ${JSON.stringify(r.id)}`); + if (typeof r.title !== 'string' || r.title.trim() === '') bad(`${r.id}: title is required`); + for (const fn of ['appliesTo', 'mount', 'dispose']) if (typeof r[fn] !== 'function') bad(`${r.id}: ${fn} must be a function`); + if (r.update !== undefined && typeof r.update !== 'function') bad(`${r.id}: update must be a function`); + } + + function unmountOne(id) { + const host = mountedHosts.get(id); + if (host === undefined) return false; + mountedHosts.delete(id); + try { records.get(id).dispose(); } finally { release(id, host); } + return true; + } + + function register(record) { + validate(record); + if (records.has(record.id)) throw contextualError('ECONTEXTUAL_DUPLICATE', `contextual panel ${record.id} is already registered`); + records.set(record.id, record); + return function unregister() { + if (records.get(record.id) !== record) return; + try { unmountOne(record.id); } finally { records.delete(record.id); } + }; + } + + function applies(record, ctx) { + if (!ctx || !ctx.selection || ctx.selection.proven !== true) return false; + try { return !!record.appliesTo(ctx.selection, ctx.analysis); } catch (err) { + console.error(`[contextual-panels] ${record.id} appliesTo failed:`, err); + return false; + } + } + + // One record's failure never stops the others from being reconciled. + function reconcile() { + const ctx = resolveContext(); + const out = { mounted: [], updated: [], disposed: [], failed: [] }; + for (const [id, record] of records) { + const want = applies(record, ctx); + const have = mountedHosts.has(id); + if (!want && have) { + try { + unmountOne(id); + out.disposed.push(id); + } catch (err) { + // unmountOne already dropped the active state and released the host + console.error(`[contextual-panels] ${id} dispose failed:`, err); + out.failed.push(id); + } + } else if (want && !have) { + const host = createHost(id); + try { + record.mount(host, ctx); + mountedHosts.set(id, host); + out.mounted.push(id); + } catch (err) { + release(id, host); + console.error(`[contextual-panels] ${id} mount failed:`, err); + out.failed.push(id); + } + } else if (want && have && record.update) { + try { + record.update(ctx); + out.updated.push(id); + } catch (err) { + // fail closed: an editor that could not take the new context is + // torn down rather than left showing stale state + console.error(`[contextual-panels] ${id} update failed:`, err); + try { unmountOne(id); } catch (err2) { console.error(`[contextual-panels] ${id} dispose failed:`, err2); } + out.failed.push(id); + } + } + } + return out; + } + + function mounted() { return [...mountedHosts.keys()]; } + + // Wire reconcile() to change sources. Each source is a subscribe(fn) + // function returning its unsubscribe (sceneSelection.subscribe, an analysis + // notifier). Returns ONE cleanup for all of them. + function watch(...subscribeFns) { + const offs = subscribeFns.map((sub) => sub(() => { reconcile(); })); + let done = false; + return function unwatch() { + if (done) return; + done = true; + for (let i = offs.length - 1; i >= 0; i--) offs[i](); + }; + } + + // Every mounted editor is disposed even if some throw; the errors are + // re-thrown afterwards (one error, or one AggregateError), never swallowed. + function dispose() { + const errors = []; + for (const id of [...mountedHosts.keys()].reverse()) { + try { unmountOne(id); } catch (err) { errors.push(err); } + } + records.clear(); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'ECONTEXTUAL_DISPOSE_FAILED: several contextual panels threw'); + } + + return Object.freeze({ register, reconcile, mounted, watch, dispose }); + } + + const WRL_SHELL_CONTEXTUAL_API = Object.freeze({ CONTEXTUAL_ID_PATTERN, createContextualPanelHost }); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = WRL_SHELL_CONTEXTUAL_API; + } else { + window.WrlShellContextualPanels = WRL_SHELL_CONTEXTUAL_API; + } +})(); diff --git a/src/shell/contribution.js b/src/shell/contribution.js new file mode 100644 index 0000000..2db7c68 --- /dev/null +++ b/src/shell/contribution.js @@ -0,0 +1,152 @@ +'use strict'; +// SHELL-0 application contribution contract +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §2; +// APP-ARCH-0 §16-§17). +// +// A feature module is a contribution: +// +// module.exports = { +// id: 'workspace.commands', +// contribute(app) { +// app.commands.register({ id: 'workspace.reset', ... }); +// app.disposables.add(listen(window, 'resize', onResize)); +// }, +// }; +// +// The shell calls `application.contribute(contribution)` once per module. +// Nothing is registered in a central file. +// +// Ownership, which is the point of the contract: +// * `app` is a per-contribution SCOPED view of the services. Every call to a +// service method the catalog marks `tracked` (register, subscribe) has its +// cleanup handle taken into the contribution's own disposable store, so a +// contribution cannot discard an unsubscribe even by accident. +// * The handle returned to the contribution still works: calling it early +// releases the registration from the store too (no double cleanup). +// * `app.disposables` takes anything else the contribution attaches (DOM +// listeners via disposable.listen, timers, mounted UI). +// * contribute() may also return a function or disposable; it is owned too. +// * contribute() is synchronous and atomic: if it throws (or returns a +// promise) everything it registered so far is disposed before the error +// propagates. A half-registered feature never stays live. +// * Disposing the application disposes contributions in reverse order. +// +// Pure: no DOM, no Electron. No dependency-injection container -- the shell +// builds each service itself and hands the instances to createApplication(). + +(function () { + const isNode = typeof module !== 'undefined' && module.exports; + const D = isNode ? require('./disposable') : window.WrlShellDisposable; + const SV = isNode ? require('./services') : window.WrlShellServices; + + const CONTRIBUTION_ID_PATTERN = /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)*$/; + + function contributionError(code, message, cause) { + const e = new Error(`${code}: ${message}`); + e.code = code; + if (cause !== undefined) e.cause = cause; + return e; + } + + // One service, viewed through one contribution's store. + function scopeService(name, svc, store) { + const tracked = new Set(SV.SERVICE_CATALOG[name].tracked); + const view = {}; + for (const key of Object.keys(svc)) { + const value = svc[key]; + if (typeof value !== 'function') { + Object.defineProperty(view, key, { enumerable: true, get: () => svc[key] }); + continue; + } + if (!tracked.has(key)) { + view[key] = value.bind(svc); + continue; + } + view[key] = function trackedCall(...args) { + const result = value.apply(svc, args); + const owned = store.add(D.asDisposable(result)); + const release = () => { store.release(owned); }; + return typeof result === 'function' ? release : Object.freeze({ dispose: release }); + }; + } + return Object.freeze(view); + } + + function validateContribution(c) { + if (!c || typeof c !== 'object') throw contributionError('ECONTRIBUTION_INVALID', 'a contribution must be an object'); + if (typeof c.id !== 'string' || !CONTRIBUTION_ID_PATTERN.test(c.id)) { + throw contributionError('ECONTRIBUTION_INVALID', `invalid contribution id ${JSON.stringify(c.id)}`); + } + if (typeof c.contribute !== 'function') throw contributionError('ECONTRIBUTION_INVALID', `${c.id}: contribute(app) is required`); + } + + function createApplication({ services } = {}) { + const instances = SV.validateServices(services); + const live = new Map(); // id -> { store } + let disposed = false; + + function scopedApp(id, store) { + const app = { contributionId: id, disposables: store }; + for (const [name, svc] of Object.entries(instances)) app[name] = scopeService(name, svc, store); + return Object.freeze(app); + } + + function contribute(contribution) { + if (disposed) throw contributionError('EAPP_DISPOSED', 'the application has been disposed'); + validateContribution(contribution); + const { id } = contribution; + if (live.has(id)) throw contributionError('ECONTRIBUTION_DUPLICATE', `contribution ${id} is already active`); + const store = D.createDisposableStore(); + let result; + try { + result = contribution.contribute(scopedApp(id, store)); + if (result && typeof result.then === 'function') { + throw contributionError('ECONTRIBUTION_ASYNC', `${id}: contribute(app) must be synchronous`); + } + if (result !== undefined) store.add(D.asDisposable(result)); + } catch (err) { + try { store.dispose(); } catch (cleanupErr) { /* report the original failure */ void cleanupErr; } + if (err && err.code === 'ECONTRIBUTION_ASYNC') throw err; + throw contributionError('ECONTRIBUTION_FAILED', `${id}: contribute(app) threw; its registrations were rolled back`, err); + } + const entry = { store }; + live.set(id, entry); + return Object.freeze({ + id, + dispose() { + if (live.get(id) !== entry) return; + live.delete(id); + store.dispose(); + }, + }); + } + + function has(id) { return live.has(id); } + function list() { return [...live.keys()]; } + // Owned registrations of one contribution (diagnostics / leak tests). + function ownedCount(id) { const e = live.get(id); return e ? e.store.size : 0; } + + function dispose() { + if (disposed) return; + disposed = true; + const entries = [...live.values()]; + live.clear(); + const errors = []; + for (let i = entries.length - 1; i >= 0; i--) { + try { entries[i].store.dispose(); } catch (err) { errors.push(err); } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'EAPP_DISPOSE_FAILED: several contributions failed to dispose'); + } + + return Object.freeze({ contribute, has, list, ownedCount, dispose, services: instances }); + } + + const WRL_SHELL_CONTRIBUTION_API = Object.freeze({ CONTRIBUTION_ID_PATTERN, createApplication }); + + if (isNode) { + module.exports = WRL_SHELL_CONTRIBUTION_API; + } else { + window.WrlShellContribution = WRL_SHELL_CONTRIBUTION_API; + } +})(); diff --git a/src/shell/disposable.js b/src/shell/disposable.js new file mode 100644 index 0000000..649faa1 --- /dev/null +++ b/src/shell/disposable.js @@ -0,0 +1,127 @@ +'use strict'; +// SHELL-0 disposable lifecycle contract +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §3). +// +// ONE cleanup model for everything a renderer module attaches: event listeners, +// registry entries, subscriptions, mounted UI, and later preview/tool state. +// Today the page reload is the only teardown (APP-ARCH-0 §6); a persistent +// shell turns every discarded unsubscribe into a leak, so every attachment must +// return something this module can own. +// +// Rules: +// * A disposable is `{ dispose() }`. A bare function is accepted at the +// boundary and wrapped (today's registries return `unregister()` functions). +// * dispose() is IDEMPOTENT: the second call is a no-op, never a double free. +// * A store disposes its children in REVERSE registration order, so a thing +// registered after (and possibly depending on) another is torn down first. +// * Every child is disposed even if an earlier one throws; the errors are +// re-thrown together afterwards (one AggregateError), never swallowed. +// * add() after the store is disposed disposes the newcomer IMMEDIATELY and +// reports it -- a late registration can never outlive its owner. +// +// Pure: no DOM, no Electron, no timers. Wrapped in an IIFE so a future +// classic-script load cannot collide on top-level names (script-load-order). + +(function () { + function disposableError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + function isDisposable(value) { + return !!value && typeof value === 'object' && typeof value.dispose === 'function'; + } + + // fn -> an idempotent disposable that calls fn at most once. + function toDisposable(fn) { + if (typeof fn !== 'function') throw disposableError('EDISPOSABLE_INVALID', 'toDisposable needs a function'); + let done = false; + return Object.freeze({ + dispose() { + if (done) return; + done = true; + fn(); + }, + }); + } + + // function | disposable -> disposable. Anything else is a contract violation: + // an attachment that returns nothing has no way to be cleaned up. + function asDisposable(value) { + if (typeof value === 'function') return toDisposable(value); + if (isDisposable(value)) return value; + throw disposableError('EDISPOSABLE_INVALID', 'expected a dispose function or a { dispose() } object'); + } + + function disposeAll(list) { + const errors = []; + for (let i = list.length - 1; i >= 0; i--) { + try { list[i].dispose(); } catch (err) { errors.push(err); } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'EDISPOSABLE_FAILED: several disposables threw'); + } + + // An owned collection of disposables. The owner calls dispose() exactly when + // the thing it represents (a contribution, a mounted panel, a document) ends. + function createDisposableStore() { + let items = []; + let disposed = false; + + function add(value) { + const d = asDisposable(value); + if (disposed) { + d.dispose(); + return d; + } + items.push(d); + return d; + } + + // Release ONE child early (e.g. a panel closed while its contribution lives + // on). Disposes it and forgets it; unknown children are ignored. + function release(d) { + const i = items.indexOf(d); + if (i === -1) return false; + items.splice(i, 1); + d.dispose(); + return true; + } + + function dispose() { + if (disposed) return; + disposed = true; + const list = items; + items = []; + disposeAll(list); + } + + return Object.freeze({ + add, + release, + dispose, + get size() { return items.length; }, + get isDisposed() { return disposed; }, + }); + } + + // target.addEventListener(type, fn, opts) with its removal as a disposable. + function listen(target, type, fn, options) { + if (!target || typeof target.addEventListener !== 'function') { + throw disposableError('EDISPOSABLE_INVALID', 'listen needs an EventTarget-like object'); + } + target.addEventListener(type, fn, options); + return toDisposable(() => target.removeEventListener(type, fn, options)); + } + + const WRL_SHELL_DISPOSABLE_API = Object.freeze({ + isDisposable, toDisposable, asDisposable, createDisposableStore, listen, + }); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = WRL_SHELL_DISPOSABLE_API; + } else { + window.WrlShellDisposable = WRL_SHELL_DISPOSABLE_API; + } +})(); diff --git a/src/shell/document-session.js b/src/shell/document-session.js new file mode 100644 index 0000000..017b18e --- /dev/null +++ b/src/shell/document-session.js @@ -0,0 +1,163 @@ +'use strict'; +// SHELL-0 document-session boundary +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §4; +// APP-ARCH-0 §15; WD.md §2, §7). +// +// The renderer DocumentSession is a CONTROLLER over existing authorities. It is +// not a document model and owns no new data: +// +// CodeMirror handle = source text + undo authority (session.text(), .history()) +// sceneSelection = selection authority (session.selection, same object) +// main EditorSession = path, authorization, sessionId (session.sessionId only) +// X_ITE preview = derived projection (session.preview, a reference) +// analysis products = derived, replaced wholesale (session.analysis()) +// +// Guards that make "no second canonical document" structural, not a promise: +// * Construction takes ONLY the keys below. Text, buffers, undo stacks, scene +// graphs, paths and workspace/layout state are refused by name and any +// unknown key is refused too -- a second source copy cannot be smuggled in. +// * text() and history() READ THROUGH to the CodeMirror handle on every call; +// nothing is cached. There is no setText, no undo/redo and no serializer: +// source changes only through applyVerifiedEdits (the handle's existing +// verified, single-transaction path) or CodeMirror itself. +// * profile and sessionId are fixed at construction (read-only after open). +// * Paths never enter the renderer session: main stays the path authority. +// * Workspace mode, sourceOpen and layout stay in the shell's workspace +// service (APP-ARCH-0 §15); they are refused here. +// +// createDocumentSlot() is the `app.documents` service: one document at a time +// (today's product rule). Opening a session disposes the previous one. + +(function () { + const isNode = typeof module !== 'undefined' && module.exports; + const D = isNode ? require('./disposable') : window.WrlShellDisposable; + const PR = isNode ? require('./profiles') : window.WrlShellProfiles; + + const ALLOWED_KEYS = Object.freeze(['sessionId', 'profile', 'editor', 'selection', 'preview']); + // Named so the refusal says WHY, not just "unknown key". + const FORBIDDEN_KEYS = Object.freeze({ + text: 'source text lives in the CodeMirror handle', + source: 'source text lives in the CodeMirror handle', + buffer: 'source text lives in the CodeMirror handle', + baseline: 'the saved baseline stays where it is today until Shell-2 decides dirty-state ownership', + undo: 'CodeMirror is the undo authority', + history: 'CodeMirror is the undo authority', + sceneGraph: 'there is no canonical scene graph (WD.md §2)', + ast: 'analysis is derived; pass it through setAnalysis()', + path: 'main owns every path', + sourcePath: 'main owns every path', + workspaceMode: 'workspace state belongs to the shell workspace service', + sourceOpen: 'workspace state belongs to the shell workspace service', + layout: 'layout state belongs to the shell workspace service', + }); + + function sessionError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + function createDocumentSession(options) { + if (!options || typeof options !== 'object') throw sessionError('EDOCSESSION_INVALID', 'options are required'); + for (const key of Object.keys(options)) { + if (Object.prototype.hasOwnProperty.call(FORBIDDEN_KEYS, key)) { + throw sessionError('EDOCSESSION_FORBIDDEN', `"${key}" is not session state: ${FORBIDDEN_KEYS[key]}`); + } + if (!ALLOWED_KEYS.includes(key)) throw sessionError('EDOCSESSION_INVALID', `unknown option "${key}"`); + } + const { sessionId, profile, editor, selection, preview } = options; + if (!(typeof sessionId === 'number' && Number.isInteger(sessionId)) && !(typeof sessionId === 'string' && sessionId !== '')) { + throw sessionError('EDOCSESSION_INVALID', 'sessionId (from main) is required'); + } + if (!PR.PROFILE_IDS.includes(profile)) throw sessionError('EDOCSESSION_INVALID', `unknown profile ${JSON.stringify(profile)}`); + if (!editor || typeof editor.getText !== 'function') throw sessionError('EDOCSESSION_INVALID', 'editor must be the CodeMirror handle (getText)'); + if (!selection || typeof selection.getSelection !== 'function' || typeof selection.subscribe !== 'function') { + throw sessionError('EDOCSESSION_INVALID', 'selection must be the sceneSelection controller'); + } + + const owned = D.createDisposableStore(); + let analysis = null; + let disposed = false; + + function live() { if (disposed) throw sessionError('EDOCSESSION_DISPOSED', `session ${sessionId} has been disposed`); } + + const session = { + get sessionId() { return sessionId; }, + get profile() { return profile; }, + get selection() { return selection; }, + get preview() { return preview || null; }, + get isDisposed() { return disposed; }, + isCurrent(id) { return !disposed && id === sessionId; }, + // Read-through, never cached. + text() { live(); return editor.getText(); }, + history() { live(); return typeof editor.historyDepth === 'function' ? editor.historyDepth() : null; }, + // The ONE source-edit path: CodeMirror's verified single transaction. + applyVerifiedEdits(args) { + live(); + if (typeof editor.applyVerifiedEdits !== 'function') return { ok: false, reason: 'unsupported' }; + return editor.applyVerifiedEdits(args); + }, + // Derived products, replaced wholesale per analysis run; never edited. + analysis() { return analysis; }, + setAnalysis(products) { live(); analysis = products == null ? null : products; }, + // Session-lifetime attachments (preview wiring, selection subscriptions). + own(value) { return owned.add(value); }, + dispose() { + if (disposed) return; + disposed = true; + analysis = null; + const errors = []; + try { owned.dispose(); } catch (err) { errors.push(err); } + if (preview && typeof preview.dispose === 'function') { try { preview.dispose(); } catch (err) { errors.push(err); } } + // The session owns the CodeMirror view for the document's lifetime. + if (typeof editor.destroy === 'function') { try { editor.destroy(); } catch (err) { errors.push(err); } } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'EDOCSESSION_DISPOSE_FAILED'); + }, + }; + return Object.freeze(session); + } + + // app.documents: the one current document session (or null). + function createDocumentSlot() { + let current = null; + const listeners = new Set(); + function notify() { + for (const fn of [...listeners]) { + try { fn(current); } catch (err) { console.error('[document-slot] listener failed:', err); } + } + } + function open(session) { + if (!session || typeof session.dispose !== 'function' || typeof session.text !== 'function') { + throw sessionError('EDOCSESSION_INVALID', 'open() needs a document session'); + } + const previous = current; + current = session; + try { if (previous && previous !== session) previous.dispose(); } finally { notify(); } + return session; + } + function close() { + if (!current) return false; + const previous = current; + current = null; + try { previous.dispose(); } finally { notify(); } + return true; + } + function subscribe(fn) { + if (typeof fn !== 'function') throw sessionError('EDOCSESSION_INVALID', 'subscribe needs a function'); + listeners.add(fn); + return () => { listeners.delete(fn); }; + } + return Object.freeze({ current: () => current, open, close, subscribe, listenerCount: () => listeners.size }); + } + + const WRL_SHELL_DOCUMENT_SESSION_API = Object.freeze({ + ALLOWED_KEYS, FORBIDDEN_KEYS, createDocumentSession, createDocumentSlot, + }); + + if (isNode) { + module.exports = WRL_SHELL_DOCUMENT_SESSION_API; + } else { + window.WrlShellDocumentSession = WRL_SHELL_DOCUMENT_SESSION_API; + } +})(); diff --git a/src/shell/menu-boundary.js b/src/shell/menu-boundary.js new file mode 100644 index 0000000..cc67c6d --- /dev/null +++ b/src/shell/menu-boundary.js @@ -0,0 +1,112 @@ +'use strict'; +// SHELL-0 menu integration boundary -- the RENDERER half that #122 consumes +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §10; +// APP-ARCH-0 §20; UI-0 §10.3). +// +// SHELL-0 builds no menu, no IPC channel and no preload API. It fixes the +// contract on the renderer side so #122 only has to connect a transport: +// +// main (#122, src/main/app-menu.js) renderer (this module) +// fixed template of command ids ──id──▶ dispatchMenuCommand(registry, allow, id) +// enabled/checked per item ◀─state── subscribeMenuState(registry, allow, send) +// +// * A command becomes menu-addressable ONLY by being listed in the menu's +// allow-list (createMenuCommandSet). Being registered is not enough, and +// an id is data, never code. +// * Dispatch refuses, with a reason and no throw, anything not a listed id, +// anything not registered on THIS page, and -- before the persistent shell +// exists -- any page that has no registry at all (Mall / World pages): +// the id is dropped and logged ('no-registry'). Disabled commands refuse +// through the registry itself ('disabled'), including profile-limited +// commands outside their profile (command-service.js). +// * Menu state is PULLED from the registry (isEnabled / isChecked), the same +// answer the toolbar paints. subscribeMenuState pushes a snapshot once and +// then only when it CHANGES, and returns the unsubscribe as a disposable -- +// re-installed per page load until Shell-4 (APP-ARCH-0 §20 amendment 2). +// * Main remains responsible for disabling editor-only items when +// currentPage !== 'editor' (§20 amendment 2); a snapshot from a page +// without a registry reports every item unregistered/disabled. + +(function () { + const isNode = typeof module !== 'undefined' && module.exports; + const CR = isNode ? require('../editor/command-registry') : window.WrlCommandRegistry; + + const MENU_SOURCE = 'menu'; + + function menuError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + // The fixed, allow-listed set of command ids the application menu may send. + function createMenuCommandSet(ids) { + if (!Array.isArray(ids) || ids.length === 0) throw menuError('EMENU_INVALID', 'the menu command set must be a non-empty array'); + for (const id of ids) { + if (typeof id !== 'string' || !CR.COMMAND_ID_PATTERN.test(id)) throw menuError('EMENU_INVALID', `invalid command id ${JSON.stringify(id)}`); + } + if (new Set(ids).size !== ids.length) throw menuError('EMENU_INVALID', 'the menu command set must not repeat an id'); + const set = new Set(ids); + return Object.freeze({ ids: Object.freeze([...ids]), allows: (id) => typeof id === 'string' && set.has(id) }); + } + + // id arriving from the menu transport -> { ok, reason?, value? }. Never throws + // for a bad id; the handler's own errors propagate exactly as for a toolbar. + function dispatchMenuCommand(registry, menuSet, id, { log } = {}) { + const note = typeof log === 'function' ? log : () => {}; + if (!menuSet || !menuSet.allows(id)) { note({ dropped: id, reason: 'not-allowed' }); return { ok: false, reason: 'not-allowed' }; } + if (!registry) { note({ dropped: id, reason: 'no-registry' }); return { ok: false, reason: 'no-registry' }; } + if (!registry.has(id)) { note({ dropped: id, reason: 'unregistered' }); return { ok: false, reason: 'unregistered' }; } + return registry.execute(id, { source: MENU_SOURCE }); + } + + function menuStateSnapshot(registry, menuSet) { + return menuSet.ids.map((id) => { + if (!registry || !registry.has(id)) return { id, registered: false, enabled: false, checked: null }; + return { id, registered: true, enabled: registry.isEnabled(id), checked: registry.isChecked(id) }; + }); + } + + function sameSnapshot(a, b) { + if (!a || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; const y = b[i]; + if (x.id !== y.id || x.registered !== y.registered || x.enabled !== y.enabled || x.checked !== y.checked) return false; + } + return true; + } + + // send(snapshot) now, then on every registry invalidate() that changes it. + // Returns a disposable; with no registry it sends once and owns nothing. + function subscribeMenuState(registry, menuSet, send) { + if (typeof send !== 'function') throw menuError('EMENU_INVALID', 'subscribeMenuState needs a send function'); + let last = null; + const push = () => { + const snap = menuStateSnapshot(registry, menuSet); + if (sameSnapshot(last, snap)) return; + last = snap; + send(snap); + }; + push(); + if (!registry) return Object.freeze({ dispose() {} }); + let off = registry.subscribe(push); + return Object.freeze({ + dispose() { + if (!off) return; + const f = off; + off = null; + f(); + }, + }); + } + + const WRL_SHELL_MENU_API = Object.freeze({ + MENU_SOURCE, createMenuCommandSet, dispatchMenuCommand, menuStateSnapshot, subscribeMenuState, + }); + + if (isNode) { + module.exports = WRL_SHELL_MENU_API; + } else { + window.WrlShellMenuBoundary = WRL_SHELL_MENU_API; + } +})(); diff --git a/src/shell/panel-service.js b/src/shell/panel-service.js new file mode 100644 index 0000000..48eee7b --- /dev/null +++ b/src/shell/panel-service.js @@ -0,0 +1,137 @@ +'use strict'; +// SHELL-0 panel contribution boundary +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §7; +// APP-ARCH-0 §17, §19; UI-0 §11). +// +// The `app.panels` service. It is NOT a second registry: identity, title, +// visibility, focus and show/hide stay in the one UI-0 Panel Registry +// (src/editor/panel-registry.js), which this wraps unchanged. +// +// What it adds is the MOUNT LIFECYCLE a future dock host needs (§19): +// +// record = { id, title, mount(host), dispose(), resize?(), focus?(), +// canToggle?, show?(), hide?() } +// +// * A record with `element()` is today's CSS-composed panel and is passed +// straight through -- current panels keep working with no change. +// * A record with `mount` is a MOUNTABLE panel. It has no element until a +// host calls mount(id, hostElement); its `element()` is then that host. +// Visibility is still derived from the element on every read (UI-0 §11.2), +// so an unmounted panel is simply not visible and focus() reports +// 'hidden' -- it never opens anything. +// * ONE active mount at a time. Hidden-but-docked panels stay mounted +// (CodeMirror and X_ITE must not be re-created on a tab switch). Mounting +// the same host again is a no-op; a different host while mounted is an +// error (EPANEL_MOUNTED). +// * unmount(id) calls the record's dispose() exactly once per mount and +// drops the element; a second unmount is a no-op. The state is cleared +// BEFORE dispose() runs, so a throwing dispose still leaves the panel +// cleanly unmounted (the error propagates). +// * After an unmount the panel is NOT spent: a later mount(id, host) -- the +// same host or a different one -- is a fresh mount() call. Disposing one +// mounted instance never makes the registration unusable. +// * A mount() that throws leaves the panel unmounted; dispose() is not +// called for it (mount must not leave partial resources when it throws). +// * Unregistering a mounted panel unmounts it first. +// * notifyResize(id) forwards a dock engine's resize to the panel (for +// CodeMirror requestMeasure / the X_ITE canvas). +// * focus stays panels.focus(id) -- the only focus path (UI-0 D6). +// +// No docking engine is chosen or integrated here (UI-C0 #38 owns that). + +(function () { + function panelServiceError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + function createPanelService(registry) { + if (!registry || typeof registry.register !== 'function') { + throw panelServiceError('EPANEL_SERVICE_INVALID', 'a panel registry is required'); + } + const mountable = new Map(); // id -> { record, host } + + function registerMountable(record) { + if (typeof record.dispose !== 'function') { + throw panelServiceError('EPANEL_INVALID', `${record.id}: a mountable panel needs dispose()`); + } + if (record.element !== undefined) { + throw panelServiceError('EPANEL_INVALID', `${record.id}: a mountable panel gets its element from mount(host); do not also pass element()`); + } + if (record.resize !== undefined && typeof record.resize !== 'function') { + throw panelServiceError('EPANEL_INVALID', `${record.id}: resize must be a function`); + } + const state = { record, host: null }; + const { mount: _m, dispose: _d, resize: _r, ...rest } = record; + void _m; void _d; void _r; + const unregister = registry.register({ ...rest, element: () => state.host }); + mountable.set(record.id, state); + return function unregisterMountable() { + if (mountable.get(record.id) !== state) return; + try { unmount(record.id); } finally { + mountable.delete(record.id); + unregister(); + } + }; + } + + function register(record) { + if (record && typeof record === 'object' && record.mount !== undefined) { + if (typeof record.mount !== 'function') throw panelServiceError('EPANEL_INVALID', `${record.id}: mount must be a function`); + return registerMountable(record); + } + return registry.register(record); + } + + function stateOf(id) { + const s = mountable.get(id); + if (!s) throw panelServiceError(registry.has(id) ? 'EPANEL_NOT_MOUNTABLE' : 'EPANEL_UNKNOWN', `panel ${JSON.stringify(id)} is not a mountable panel`); + return s; + } + + function mount(id, host) { + const s = stateOf(id); + if (!host) throw panelServiceError('EPANEL_INVALID', `${id}: mount needs a host element`); + if (s.host === host) return { ok: true, already: true }; + if (s.host) throw panelServiceError('EPANEL_MOUNTED', `${id} is already mounted in another host`); + s.record.mount(host); + s.host = host; + return { ok: true }; + } + + function unmount(id) { + const s = stateOf(id); + if (!s.host) return { ok: true, already: true }; + s.host = null; + s.record.dispose(); + return { ok: true }; + } + + function isMounted(id) { const s = mountable.get(id); return !!(s && s.host); } + + function notifyResize(id) { + const s = stateOf(id); + if (s.host && s.record.resize) s.record.resize(); + } + + return Object.freeze({ + register, mount, unmount, isMounted, notifyResize, + has: registry.has, + get: registry.get, + list: registry.list, + isVisible: registry.isVisible, + focus: registry.focus, + show: registry.show, + hide: registry.hide, + }); + } + + const WRL_SHELL_PANEL_SERVICE_API = Object.freeze({ createPanelService }); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = WRL_SHELL_PANEL_SERVICE_API; + } else { + window.WrlShellPanelService = WRL_SHELL_PANEL_SERVICE_API; + } +})(); diff --git a/src/shell/profiles.js b/src/shell/profiles.js new file mode 100644 index 0000000..bdc5359 --- /dev/null +++ b/src/shell/profiles.js @@ -0,0 +1,34 @@ +'use strict'; +// SHELL-0 profile ids (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §6). +// +// The three document profiles of CLAUDE.md, as DATA only. These ids are the +// values of the editor's existing `context` ('mall' | 'world' | 'generic'). +// No profile rule (Mall size cap, WorldInfo, texture limits, World packaging) +// lives here or in any shared shell module: a profile's rules stay in its own +// modules, and shared code only ever asks "is this contribution listed for the +// current profile?". + +(function () { + const PROFILE_IDS = Object.freeze(['mall', 'world', 'generic']); + + function normalizeProfileList(list, label) { + const where = label ? `${label}: ` : ''; + const fail = (why) => { + const e = new Error(`EPROFILE_INVALID: ${where}${why}`); + e.code = 'EPROFILE_INVALID'; + throw e; + }; + if (!Array.isArray(list) || list.length === 0) fail('profiles must be a non-empty array'); + for (const p of list) if (!PROFILE_IDS.includes(p)) fail(`unknown profile ${JSON.stringify(p)}`); + if (new Set(list).size !== list.length) fail('profiles must not repeat'); + return Object.freeze([...list]); + } + + const WRL_SHELL_PROFILES_API = Object.freeze({ PROFILE_IDS, normalizeProfileList }); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = WRL_SHELL_PROFILES_API; + } else { + window.WrlShellProfiles = WRL_SHELL_PROFILES_API; + } +})(); diff --git a/src/shell/services.js b/src/shell/services.js new file mode 100644 index 0000000..68a6e8e --- /dev/null +++ b/src/shell/services.js @@ -0,0 +1,136 @@ +'use strict'; +// SHELL-0 application-service boundary +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §5; +// APP-ARCH-0 docs/architecture/DESKTOP_APPLICATION_SHELL_REVIEW.md §16). +// +// The CLOSED list of services a contribution may receive as `app.`. +// It is a catalog, not a container: the shell (Shell-2+) constructs each +// service itself, once, and passes the instances in. There is no lookup by +// string at run time, no lazy construction and no dependency graph. +// +// A service is admitted only when current or approved future behavior needs it. +// Everything else APP-ARCH-0 §16 names is recorded in DEFERRED_SERVICES with the +// reason and the lane that owns it, and is REJECTED if passed -- so the `app` +// object cannot quietly grow into a god-object. +// +// Each entry declares: +// owner -- where the authority lives (an existing module, or a contract) +// status -- 'existing' (wraps today's module, API unchanged) | +// 'shell-0' (contract module built in SHELL-0, no callers yet) | +// 'boundary' (shape only; implemented by the named lane) +// methods -- the surface a provided instance must have +// tracked -- methods whose return value is a cleanup handle; the +// contribution host takes ownership of it (contribution.js) + +(function () { + const SERVICE_CATALOG = Object.freeze({ + commands: Object.freeze({ + owner: 'src/editor/command-registry.js via src/shell/command-service.js', + status: 'existing', + required: true, + methods: Object.freeze(['register', 'has', 'get', 'list', 'isEnabled', 'isChecked', 'execute', 'keyBindings', 'subscribe', 'invalidate']), + tracked: Object.freeze(['register', 'subscribe']), + }), + panels: Object.freeze({ + owner: 'src/editor/panel-registry.js via src/shell/panel-service.js', + status: 'existing', + required: true, + methods: Object.freeze(['register', 'has', 'get', 'list', 'isVisible', 'focus', 'show', 'hide']), + tracked: Object.freeze(['register']), + }), + tools: Object.freeze({ + owner: 'src/shell/tool-registry.js', + status: 'shell-0', + required: false, + methods: Object.freeze(['register', 'has', 'get', 'list', 'state', 'activate', 'subscribe']), + tracked: Object.freeze(['register', 'subscribe']), + }), + contextualPanels: Object.freeze({ + owner: 'src/shell/contextual-panels.js', + status: 'shell-0', + required: false, + methods: Object.freeze(['register', 'reconcile', 'mounted']), + tracked: Object.freeze(['register']), + }), + documents: Object.freeze({ + owner: 'src/shell/document-session.js (createDocumentSlot)', + status: 'shell-0', + required: false, + methods: Object.freeze(['current', 'subscribe']), + tracked: Object.freeze(['subscribe']), + }), + workspaces: Object.freeze({ + // Today: editor.js S.workspaceMode + setWorkspaceMode, reading + // src/editor/workspace-presets.js. #121 wraps that one authority; it does + // not create a second one. + owner: 'editor.js setWorkspaceMode + src/editor/workspace-presets.js (wrapped by #121)', + status: 'boundary', + required: false, + methods: Object.freeze(['mode', 'preset', 'setMode', 'subscribe']), + tracked: Object.freeze(['subscribe']), + }), + dialogs: Object.freeze({ + // #121 D3: one question replaces per-dialog classList checks. + owner: 'dialog stack (built by #121; modal stack completed in Shell-2)', + status: 'boundary', + required: false, + methods: Object.freeze(['isModalOpen']), + tracked: Object.freeze([]), + }), + preferences: Object.freeze({ + owner: 'renderer/preferences.js WrlPreferences (localStorage)', + status: 'existing', + required: false, + methods: Object.freeze(['get', 'set', 'subscribe']), + tracked: Object.freeze(['subscribe']), + }), + }); + + // Named by APP-ARCH-0 §16 but NOT admitted in SHELL-0. Passing one is an error. + const DEFERRED_SERVICES = Object.freeze({ + keyboard: 'stays command-bindings.installKeyboard (one dispatcher already); #121 adds the dialogs.isModalOpen() gate inside it', + preview: 'PreviewSession + profile adapters are Shell-2; today editor-preview.js owns it and the document session only references it', + files: 'thin façade over window.vrmlpad.editor.* is Shell-2; main stays the only path authority', + validation: 'profile-contributed (language.js, validator.js); a shared validation service would invite Mall rules into shared code', + status: 'one status bar / aria-live service is Shell-3 (shared chrome)', + notifications: 'one toast / announce service is Shell-3 (shared chrome)', + menu: 'main-process (src/main/app-menu.js, #122); the renderer side is src/shell/menu-boundary.js over app.commands, not a service', + }); + + function serviceError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + // { name: instance } -> frozen copy, after checking every name is admitted, + // every required service is present and every instance has its surface. + function validateServices(services) { + if (!services || typeof services !== 'object') throw serviceError('EAPP_SERVICE_INVALID', 'services must be an object'); + const out = {}; + for (const name of Object.keys(services)) { + if (Object.prototype.hasOwnProperty.call(DEFERRED_SERVICES, name)) { + throw serviceError('EAPP_SERVICE_DEFERRED', `"${name}" is not a SHELL-0 service: ${DEFERRED_SERVICES[name]}`); + } + const spec = SERVICE_CATALOG[name]; + if (!spec) throw serviceError('EAPP_SERVICE_UNKNOWN', `"${name}" is not in the service catalog`); + const svc = services[name]; + if (!svc || typeof svc !== 'object') throw serviceError('EAPP_SERVICE_INVALID', `${name} must be an object`); + const missing = spec.methods.filter((m) => typeof svc[m] !== 'function'); + if (missing.length) throw serviceError('EAPP_SERVICE_INVALID', `${name} is missing ${missing.join(', ')}`); + out[name] = svc; + } + for (const [name, spec] of Object.entries(SERVICE_CATALOG)) { + if (spec.required && !out[name]) throw serviceError('EAPP_SERVICE_MISSING', `required service "${name}" was not provided`); + } + return Object.freeze(out); + } + + const WRL_SHELL_SERVICES_API = Object.freeze({ SERVICE_CATALOG, DEFERRED_SERVICES, validateServices }); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = WRL_SHELL_SERVICES_API; + } else { + window.WrlShellServices = WRL_SHELL_SERVICES_API; + } +})(); diff --git a/src/shell/tool-registry.js b/src/shell/tool-registry.js new file mode 100644 index 0000000..3e8a535 --- /dev/null +++ b/src/shell/tool-registry.js @@ -0,0 +1,151 @@ +'use strict'; +// SHELL-0 modeling-tool contribution boundary +// (docs/architecture/SHELL_0_CONTRACTS_AND_MEASUREMENTS.md §8; APP-ARCH-0 §18). +// +// The `app.tools` service: tool RECORDS that toolbars render from +// `tools.list(group)`. A tool is presentation + applicability over ONE command; +// it has no behavior of its own: +// +// app.tools.register({ +// id: 'tool.move', commandId: 'model.move', group: 'transform', +// label: 'Move', icon: 'move', profiles?: ['world'], +// appliesTo?: (selectionInfo) => selectionInfo.type === 'Transform', +// }); +// +// * Running a tool is commands.execute(commandId). Enabled and checked are +// the command's (one pull model, UI-0 §6) -- no second enabled state. +// * A tool is AVAILABLE (shown/usable) only when its command is registered, +// the current profile is listed (if `profiles` is given), and appliesTo +// accepts the current proven selection. appliesTo never sees an unproven +// selection: with no proven selection a tool that declares appliesTo is +// unavailable (fail closed, WD.md §7). A throwing appliesTo is unavailable. +// * The command must already be registered when the tool is (fail closed: +// a tool can never point at nothing). +// * Tool-specific behavior never lives here: the source effect stays in the +// command, through a pure planner -> applyVerifiedEdits (APP-ARCH-0 §18). +// +// None of the §18 tools (Select ... Keyframe) is implemented in SHELL-0. + +(function () { + const isNode = typeof module !== 'undefined' && module.exports; + const PR = isNode ? require('./profiles') : window.WrlShellProfiles; + + const TOOL_GROUPS = Object.freeze(['select', 'transform', 'create', 'appearance', 'hierarchy', 'behavior', 'animation']); + const TOOL_ID_PATTERN = /^tool\.[a-z][a-zA-Z0-9]*$/; + + function toolError(code, message) { + const e = new Error(`${code}: ${message}`); + e.code = code; + return e; + } + + // commands: the app.commands service. getProfile: () => profile id | null. + // getSelectionInfo: () => { proven: true, ... } | null -- from the document + // session's existing selection + analysis authorities, never recomputed here. + function createToolRegistry({ commands, getProfile, getSelectionInfo } = {}) { + if (!commands || typeof commands.execute !== 'function') throw toolError('ETOOL_INVALID', 'the commands service is required'); + if (typeof getProfile !== 'function') throw toolError('ETOOL_INVALID', 'getProfile() is required'); + if (typeof getSelectionInfo !== 'function') throw toolError('ETOOL_INVALID', 'getSelectionInfo() is required'); + + const tools = new Map(); // id -> { record, descriptor, profiles } + const listeners = new Set(); + + function changed() { + for (const fn of [...listeners]) { + try { fn(); } catch (err) { console.error('[tool-registry] listener failed:', err); } + } + } + + function validate(r) { + const bad = (why) => { throw toolError('ETOOL_INVALID', why); }; + if (!r || typeof r !== 'object') bad('a tool record must be an object'); + if (typeof r.id !== 'string' || !TOOL_ID_PATTERN.test(r.id)) bad(`invalid tool id ${JSON.stringify(r.id)}`); + if (typeof r.commandId !== 'string' || !commands.has(r.commandId)) bad(`${r.id}: command ${JSON.stringify(r.commandId)} is not registered`); + if (!TOOL_GROUPS.includes(r.group)) bad(`${r.id}: unknown group ${JSON.stringify(r.group)}`); + if (r.label !== undefined && (typeof r.label !== 'string' || r.label.trim() === '')) bad(`${r.id}: label must be a non-empty string`); + if (r.icon !== undefined && typeof r.icon !== 'string') bad(`${r.id}: icon must be a string`); + if (r.appliesTo !== undefined && typeof r.appliesTo !== 'function') bad(`${r.id}: appliesTo must be a function`); + } + + function register(record) { + validate(record); + if (tools.has(record.id)) throw toolError('ETOOL_DUPLICATE', `tool ${record.id} is already registered`); + const profiles = record.profiles === undefined ? null : PR.normalizeProfileList(record.profiles, `tool ${record.id}`); + const command = commands.get(record.commandId); + const descriptor = Object.freeze({ + id: record.id, commandId: record.commandId, group: record.group, + label: record.label || command.label, icon: record.icon || null, profiles, + }); + const e = { record, descriptor, profiles }; + tools.set(record.id, e); + changed(); + return function unregister() { + if (tools.get(record.id) !== e) return; + tools.delete(record.id); + changed(); + }; + } + + function entry(id) { + const e = tools.get(id); + if (!e) throw toolError('ETOOL_UNKNOWN', `no tool ${JSON.stringify(id)}`); + return e; + } + + function has(id) { return tools.has(id); } + function get(id) { const e = tools.get(id); return e ? e.descriptor : null; } + // Registration order within a group is the toolbar order. + function list(group) { + return [...tools.values()].filter((e) => group === undefined || e.descriptor.group === group).map((e) => e.descriptor); + } + + function available(e) { + if (!commands.has(e.record.commandId)) return false; + if (e.profiles && !e.profiles.includes(getProfile())) return false; + if (!e.record.appliesTo) return true; + const sel = getSelectionInfo(); + if (!sel || sel.proven !== true) return false; + try { return !!e.record.appliesTo(sel); } catch (err) { + console.error(`[tool-registry] ${e.record.id} appliesTo failed:`, err); + return false; + } + } + + // Read on demand, never stored: { available, enabled, checked }. + function state(id) { + const e = entry(id); + const ok = available(e); + return Object.freeze({ + available: ok, + enabled: ok && commands.isEnabled(e.record.commandId), + checked: ok ? commands.isChecked(e.record.commandId) : null, + }); + } + + function activate(id, ctx) { + const e = entry(id); + if (!available(e)) return { ok: false, reason: 'unavailable' }; + return commands.execute(e.record.commandId, { source: (ctx && ctx.source) || 'toolbar' }); + } + + // Fires when the SET of tools changes (re-render a toolbar). State repaint + // follows commands.subscribe, exactly like [data-command] controls. + function subscribe(fn) { + if (typeof fn !== 'function') throw toolError('ETOOL_INVALID', 'subscribe needs a function'); + listeners.add(fn); + return () => { listeners.delete(fn); }; + } + + function listenerCount() { return listeners.size; } + + return Object.freeze({ register, has, get, list, state, activate, subscribe, listenerCount }); + } + + const WRL_SHELL_TOOL_REGISTRY_API = Object.freeze({ TOOL_GROUPS, TOOL_ID_PATTERN, createToolRegistry }); + + if (isNode) { + module.exports = WRL_SHELL_TOOL_REGISTRY_API; + } else { + window.WrlShellToolRegistry = WRL_SHELL_TOOL_REGISTRY_API; + } +})(); diff --git a/test/shell/boundaries.test.js b/test/shell/boundaries.test.js new file mode 100644 index 0000000..abdb561 --- /dev/null +++ b/test/shell/boundaries.test.js @@ -0,0 +1,79 @@ +'use strict'; +// SHELL-0 structural boundaries, asserted by source scan: +// * the contract modules are foundation-only -- no production page, main or +// preload loads them yet (later lanes adopt them); +// * no shared shell module reaches a profile's rules, the filesystem, +// Electron, the parser, or anything that could become a second source; +// * every module uses the dual export and an IIFE (classic-script safe). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const SHELL = path.join(ROOT, 'src', 'shell'); +const modules = fs.readdirSync(SHELL).filter((f) => f.endsWith('.js')).sort(); +const read = (p) => fs.readFileSync(p, 'utf8'); +const code = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +// code() minus string/template literal bodies (prose in error messages is not a dependency). +const bare = (src) => code(src).replace(/'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g, "''"); + +test('the shell contract module set', () => { + assert.deepEqual(modules, [ + 'command-service.js', 'contextual-panels.js', 'contribution.js', 'disposable.js', + 'document-session.js', 'menu-boundary.js', 'panel-service.js', 'profiles.js', + 'services.js', 'tool-registry.js', + ]); +}); + +// Every production source file: main, preload, renderer/** and src/** outside +// src/shell (the vendor bundle directory is generated, not authored). +function productionFiles() { + const out = ['main.js', 'preload.js']; + const walk = (rel) => { + for (const d of fs.readdirSync(path.join(ROOT, rel), { withFileTypes: true })) { + const r = path.join(rel, d.name); + if (d.isDirectory()) { + if (r === path.join('src', 'shell') || r === path.join('renderer', 'vendor')) continue; + walk(r); + } else if (/\.(html|js)$/.test(d.name)) out.push(r); + } + }; + walk('renderer'); + walk('src'); + return out; +} + +test('foundation only: no production entry point loads src/shell yet', () => { + const files = productionFiles(); + assert.ok(files.length > 20, 'scan found the production tree'); + for (const f of files) { + const src = read(path.join(ROOT, f)); + assert.ok(!src.includes('src/shell/'), `${f} must not load src/shell in SHELL-0`); + assert.ok(!/require\(\s*['"][./]*shell\//.test(src), `${f} must not require the shell layer in SHELL-0`); + assert.ok(!src.includes('WrlShell'), `${f} must not use a WrlShell* global in SHELL-0`); + } +}); + +test('no shared shell module imports profile rules, fs, Electron, the parser or a DOM global', () => { + const ALLOWED = new Set(['./disposable', './services', './profiles', '../editor/command-registry']); + for (const f of modules) { + const src = code(read(path.join(SHELL, f))); + for (const m of src.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) { + assert.ok(ALLOWED.has(m[1]), `${f} requires ${m[1]}`); + } + const body = bare(read(path.join(SHELL, f))); + for (const banned of ['validator', 'ipcRenderer', 'vrmlpad', 'document.', 'localStorage', 'process.', 'X3D']) { + assert.ok(!body.includes(banned), `${f} uses ${banned}`); + } + } +}); + +test('dual export + IIFE in every module (safe as a classic