@adia-ai/web-modules 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/chat/chat-shell/chat-shell.js +28 -40
  3. package/chat/chat-shell/css/chat-shell.empty.css +3 -3
  4. package/chat/chat-shell/css/chat-shell.layout.css +2 -2
  5. package/editor/editor-canvas/editor-canvas.a2ui.json +87 -0
  6. package/editor/editor-canvas/editor-canvas.examples.html +65 -0
  7. package/editor/editor-canvas/editor-canvas.html +43 -0
  8. package/editor/editor-canvas/editor-canvas.js +103 -0
  9. package/editor/editor-canvas/editor-canvas.test.js +100 -0
  10. package/editor/editor-canvas/editor-canvas.yaml +88 -0
  11. package/editor/editor-canvas-empty/editor-canvas-empty.a2ui.json +69 -0
  12. package/editor/editor-canvas-empty/editor-canvas-empty.examples.html +65 -0
  13. package/editor/editor-canvas-empty/editor-canvas-empty.html +42 -0
  14. package/editor/editor-canvas-empty/editor-canvas-empty.yaml +56 -0
  15. package/editor/editor-shell/css/editor-shell.bespoke.css +237 -0
  16. package/editor/editor-shell/css/editor-shell.layout.css +6 -6
  17. package/editor/editor-shell/editor-shell.css +1 -0
  18. package/editor/editor-shell/editor-shell.js +87 -30
  19. package/editor/editor-sidebar/editor-sidebar.a2ui.json +93 -0
  20. package/editor/editor-sidebar/editor-sidebar.examples.html +65 -0
  21. package/editor/editor-sidebar/editor-sidebar.html +43 -0
  22. package/editor/editor-sidebar/editor-sidebar.js +197 -0
  23. package/editor/editor-sidebar/editor-sidebar.test.js +145 -0
  24. package/editor/editor-sidebar/editor-sidebar.yaml +91 -0
  25. package/editor/editor-statusbar/editor-statusbar.a2ui.json +76 -0
  26. package/editor/editor-statusbar/editor-statusbar.examples.html +65 -0
  27. package/editor/editor-statusbar/editor-statusbar.html +42 -0
  28. package/editor/editor-statusbar/editor-statusbar.yaml +57 -0
  29. package/editor/editor-toolbar/editor-toolbar.a2ui.json +96 -0
  30. package/editor/editor-toolbar/editor-toolbar.examples.html +65 -0
  31. package/editor/editor-toolbar/editor-toolbar.html +43 -0
  32. package/editor/editor-toolbar/editor-toolbar.js +58 -0
  33. package/editor/editor-toolbar/editor-toolbar.test.js +99 -0
  34. package/editor/editor-toolbar/editor-toolbar.yaml +81 -0
  35. package/editor/index.js +3 -0
  36. package/package.json +4 -4
  37. package/shell/admin-shell/admin-shell.js +27 -243
  38. package/shell/admin-shell/css/admin-shell.bespoke.css +22 -26
  39. package/shell/admin-shell/css/admin-shell.main.css +2 -2
  40. package/shell/admin-shell/css/admin-shell.shell.css +2 -2
  41. package/shell/admin-shell/css/admin-shell.sidebar.css +35 -33
package/CHANGELOG.md CHANGED
@@ -11,6 +11,61 @@ Built from `@adia-ai/web-components` primitives.
11
11
 
12
12
  _No pending changes._
13
13
 
14
+ ## [0.4.0] - 2026-05-10
15
+
16
+ **⚠️ BREAKING** — first non-patch release in the 0.x line. Closes the ADR-0023 arc per [ADR-0024](../../.brain/adrs/0024-legacy-shell-shapes-retired.md). All 6 in-repo consumers migrated in v0.3.6; this release retires the legacy authoring shapes from the shell hosts.
17
+
18
+ ### Removed (BREAKING)
19
+
20
+ - **`<admin-shell>`** no longer reads `<aside-ui slot="leading|trailing">`, `<aside data-sidebar>`, `<dialog data-command>`, or `[data-resize]` external handle children. Use `<admin-sidebar slot resizable collapsible>` + `<admin-command>` instead.
21
+ - **`<chat-shell>`** no longer reads `<section data-chat-messages>`, `<chat-input-ui data-chat-input>`, `<empty-state-ui data-chat-empty>`, `<header>` with `[data-chat-name]` / `[data-chat-status]`. Use `<chat-thread>` + `<chat-composer>` + `<chat-empty>` + `<chat-header>` + `<chat-status>` instead.
22
+ - **`<editor-shell>`** no longer reads `<header>` toolbar, `<div data-editor-body>` wrapper, `<pane-ui data-left|data-right>`, `<div data-canvas>`, `<footer>` statusbar. Use `<editor-toolbar>` + `<editor-canvas>` + `<editor-sidebar slot>` + `<editor-statusbar>` + `<editor-canvas-empty>` instead.
23
+
24
+ ### Changed
25
+
26
+ - **`admin-shell.js` host** — 305 → 87 LOC (-71%). The host now does only `[mode]` reflection + 2 attribute-forwarding handlers. All resize / collapse / persistence / dialog wiring is owned by `<admin-sidebar>` and `<admin-command>` per ADR-0023.
27
+ - **`chat-shell.js` host** — `connected()` simplified; only `composer-submit` event recognized (no longer falls back to `submit`).
28
+ - **`editor-shell.js` host** — `connected()` drops dual-shape fallbacks.
29
+ - **CSS layered files** — all `:is(legacy, bespoke)` lifts collapsed to bespoke-only in `admin-shell.sidebar.css`, `admin-shell.shell.css`, `admin-shell.main.css`, `chat-shell.empty.css`, `chat-shell.layout.css`, `editor-shell.layout.css`.
30
+ - **CSS bridge files** — `admin-shell.bespoke.css` / `chat-shell.bespoke.css` / `editor-shell.bespoke.css` updated to canonical-styling commentary (no longer "bridge to existing selectors").
31
+
32
+ ### Verification
33
+
34
+ - Bespoke children unit tests: **78/78** pass (admin 19 + chat 27 + editor 32)
35
+ - `npm run smoke:consumers` Playwright probe: **6/6** pass
36
+ - `npm run build:site`: clean
37
+ - `check-links --all`: 0 broken
38
+
39
+ ### Migration
40
+
41
+ See root [CHANGELOG.md `## [0.4.0]`](../../CHANGELOG.md) for the full diff-style migration recipe. The 6 in-repo consumers (`apps/app-shell/`, `site/index.html`, `apps/chat/`, `apps/genui/gen-ui/`, `apps/construct-canvas/`, `apps/genui/a2ui-editor/`) were all migrated in v0.3.6 — see commit `c477d371`.
42
+
43
+ ## [0.3.6] - 2026-05-10
44
+
45
+ ### Added
46
+
47
+ - **Editor cluster bespoke family** — third cluster decomposed per ADR-0023, confirming the pattern is canonical across 3 distinct shell archetypes.
48
+ - **`<editor-toolbar full-screen?>`** — JS-bearing (58 LOC + 8 unit tests). Replaces legacy `<header>` chrome bar inside `<editor-shell>`. Click-bubble for `[data-toolbar-action]` fires `toolbar-action` event. Slot vocabulary (title, status, action, action-leading).
49
+ - **`<editor-canvas empty? focused?>`** — JS-bearing (103 LOC + 11 unit tests). Replaces legacy `<div data-canvas>`. Owns scroll/zoom container, `[empty]` reflected, `[focused]` reflected. Public API: `.focus()` / `.blur()` / `.resetView()` / `.zoom` getter+setter.
50
+ - **`<editor-sidebar collapsible>`** — JS-bearing (170 LOC + 11 unit tests). **First bespoke child that DELEGATES rather than DUPLICATES a primitive's behavior** — wraps `<pane-ui resizable>` for drag, owns `[collapsed]` reflected via ResizeObserver, localStorage persistence (`adia-editor-sidebar-{name}` cluster-distinct).
51
+ - **2 CSS-only structural children** — `<editor-statusbar>`, `<editor-canvas-empty>` (visibility driven by parent `<editor-canvas>[empty]`).
52
+
53
+ - **CSS bridge** `editor/editor-shell/css/editor-shell.bespoke.css` (172 LOC) — maps the 5 bespoke editor tags to a CSS Grid layout. Imported last in `editor-shell.css`.
54
+
55
+ - **`editor/index.js` exports** all 4 JS-bearing editor children (EditorShell + EditorToolbar + EditorCanvas + EditorSidebar). Subpath import unchanged: `import '@adia-ai/web-modules/editor'`.
56
+
57
+ ### Changed
58
+
59
+ - **`<editor-shell>` host** — refactored from 56 LOC (just select-options JSON parsing) to 115 LOC. Reads BOTH legacy + bespoke shapes via priority chain. Adds `[focus-mode]` reflected attribute that propagates to bespoke children's `[full-screen]` / `[focused]`. Listens for `toolbar-action` bubble events. Backwards compat preserved.
60
+
61
+ ### Fixed
62
+
63
+ - **`<editor-sidebar>.collapse()` persist-before-snap bug** (commit `1d63f0d1`) — the implementation was setting `style.width = SNAP_THRESHOLD` THEN calling `#persistWidth()`, which overwrote storage with 96 (the snap value). On `expand()` the stored value would be ≤ threshold and fall through to default 240, losing the user's pre-collapse width. Fix: capture pre-collapse width by calling `#persistWidth()` BEFORE the snap, guarded by `if (!this.collapsed)`. Caught by the `expand() restores from stored width or defaults to 240` test on first run. Documented as Pitfall #12 in the `bespoke-shell-children` skill.
64
+
65
+ ### Backwards compatibility
66
+
67
+ Legacy authoring shapes (`<header>`, `<div data-editor-body>`, `<pane-ui data-left|data-right>`, `<div data-canvas>`, `<footer>`) all still work identically. Mixed markup renders the same. Migration is opt-in.
68
+
14
69
  ## [0.3.5] - 2026-05-07
15
70
 
16
71
  ### Added
@@ -15,20 +15,22 @@ let msgId = 0;
15
15
  * Behavior-only orchestrator: stamps no HTML of its own.
16
16
  * The author writes the structure; chat-shell wires the behaviors.
17
17
  *
18
- * Structure:
18
+ * Structure (bespoke vocabulary per ADR-0023; **bespoke-only since v0.4.0**):
19
19
  * <chat-shell proxy-url="/api/chat" model="claude-sonnet-4-20250514">
20
- * <header>
21
- * <span data-chat-name>Claude</span>
22
- * <span data-chat-status></span>
23
- * </header>
24
- * <section data-chat-messages>
25
- * <empty-state-ui data-chat-empty icon="chat-circle"
26
- * heading="Hello!" description="Ask me anything.">
27
- * </empty-state-ui>
28
- * </section>
29
- * <footer>
30
- * <chat-input-ui data-chat-input placeholder="Message..."></chat-input-ui>
31
- * </footer>
20
+ * <chat-header>
21
+ * <span slot="name">Claude</span>
22
+ * <chat-status slot="status"></chat-status>
23
+ * </chat-header>
24
+ * <chat-thread>
25
+ * <chat-empty>
26
+ * <empty-state-ui icon="chat-circle"
27
+ * heading="Hello!" description="Ask me anything.">
28
+ * </empty-state-ui>
29
+ * </chat-empty>
30
+ * </chat-thread>
31
+ * <chat-composer>
32
+ * <chat-input-ui placeholder="Message..."></chat-input-ui>
33
+ * </chat-composer>
32
34
  * </chat-shell>
33
35
  *
34
36
  * Two modes:
@@ -79,36 +81,22 @@ class ChatShell extends UIElement {
79
81
  // ── Lifecycle ──
80
82
 
81
83
  connected() {
82
- // Per ADR-0023 read BOTH legacy (data-* / raw HTML) AND bespoke
83
- // (chat-thread / chat-composer / chat-empty / chat-status) shapes.
84
- // The :is() selectors let consumers mix shapes without breakage.
85
- this.#messagesEl = this.querySelector('chat-thread')
86
- || this.querySelector('[data-chat-messages]')
87
- || this.querySelector('section');
88
- this.#inputEl = this.querySelector('chat-composer')
89
- || this.querySelector('[data-chat-input]')
90
- || this.querySelector('chat-input-ui');
91
- this.#emptyEl = this.querySelector('chat-empty')
92
- || this.querySelector('[data-chat-empty]');
93
- this.#statusEl = this.querySelector('chat-status')
94
- || this.querySelector('[data-chat-status]');
95
-
96
- // Bespoke <chat-composer> emits 'composer-submit' instead of 'submit'
97
- // (so the legacy 'submit' event from inside <chat-input-ui> doesn't
98
- // double-fire). Listen for both depending on which shape is present.
99
- if (this.#inputEl?.tagName?.toLowerCase() === 'chat-composer') {
100
- this.#inputEl.addEventListener('composer-submit', this.#onSubmit);
101
- } else {
102
- this.#inputEl?.addEventListener('submit', this.#onSubmit);
103
- }
84
+ // **Bespoke-only since v0.4.0** (ADR-0023 Phase 3). The legacy
85
+ // shapes (<section data-chat-messages>, <chat-input-ui data-chat-input>,
86
+ // <empty-state-ui data-chat-empty>, [data-chat-status>, [data-chat-name>)
87
+ // are no longer recognized. Consumers MUST use the bespoke vocabulary.
88
+ this.#messagesEl = this.querySelector('chat-thread');
89
+ this.#inputEl = this.querySelector('chat-composer');
90
+ this.#emptyEl = this.querySelector('chat-empty');
91
+ this.#statusEl = this.querySelector('chat-status');
92
+
93
+ // <chat-composer> forwards inner <chat-input-ui> submit as
94
+ // composer-submit (bespoke event name prevents double-fire).
95
+ this.#inputEl?.addEventListener('composer-submit', this.#onSubmit);
104
96
  }
105
97
 
106
98
  disconnected() {
107
- if (this.#inputEl?.tagName?.toLowerCase() === 'chat-composer') {
108
- this.#inputEl.removeEventListener('composer-submit', this.#onSubmit);
109
- } else {
110
- this.#inputEl?.removeEventListener('submit', this.#onSubmit);
111
- }
99
+ this.#inputEl?.removeEventListener('composer-submit', this.#onSubmit);
112
100
  this.abort();
113
101
  }
114
102
 
@@ -2,11 +2,11 @@
2
2
  chat-shell — Empty state
3
3
  ═══════════════════════════════════════════════════════════════ */
4
4
 
5
- chat-shell [data-chat-empty] {
5
+ chat-shell chat-empty {
6
6
  margin: auto;
7
7
  }
8
8
 
9
- chat-shell[streaming] [data-chat-empty],
10
- chat-shell:has([data-role]) [data-chat-empty] {
9
+ chat-shell[streaming] chat-empty,
10
+ chat-shell:has([data-role]) chat-empty {
11
11
  display: none;
12
12
  }
@@ -29,14 +29,14 @@ chat-shell > header [data-chat-name] {
29
29
  font-size: var(--chat-header-name-font);
30
30
  }
31
31
 
32
- chat-shell > header [data-chat-status] {
32
+ chat-shell > header chat-status {
33
33
  font-size: var(--chat-header-status-font);
34
34
  color: var(--chat-header-status-fg);
35
35
  margin-inline-start: auto;
36
36
  }
37
37
 
38
38
  /* Messages scroll container */
39
- chat-shell > [data-chat-messages],
39
+ chat-shell > chat-thread,
40
40
  chat-shell > section {
41
41
  flex: 1;
42
42
  overflow-y: auto;
@@ -0,0 +1,87 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/EditorCanvas.json",
4
+ "title": "EditorCanvas",
5
+ "description": "Module-tier editor canvas surface — replaces legacy <div data-canvas>\ninside <editor-shell> per ADR-0023. Owns scroll/zoom container\nsemantics, [empty] and [focused] reflected attributes, and a stable\ntarget for the host's content rendering pipeline.\n\nSits as the central content region inside <editor-shell>, between\nthe optional <editor-sidebar slot=\"leading\"> and <editor-sidebar\nslot=\"trailing\">. Authors compose <editor-canvas-empty> as an\noptional first child for the empty state; canvas content children\n(artboards, document body, etc.) are appended either statically or\ndynamically by the host.\n\nBackwards compat — <editor-shell> still recognizes the legacy\n<div data-canvas> shape via :is() selector. New code should prefer\n<editor-canvas>.\n",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "component": {
17
+ "const": "EditorCanvas"
18
+ },
19
+ "empty": {
20
+ "description": "Reflected — set when zero non-<editor-canvas-empty> children.\nDrives the <editor-canvas-empty> visibility via parent CSS —\nno JS toggling needed.\n",
21
+ "type": "boolean",
22
+ "default": true
23
+ },
24
+ "focused": {
25
+ "description": "Reflected — set when canvas claims focus ownership (programmatic\n.focus() call or toolbar full-screen toggle).\n",
26
+ "type": "boolean",
27
+ "default": false
28
+ }
29
+ },
30
+ "required": [
31
+ "component"
32
+ ],
33
+ "unevaluatedProperties": false,
34
+ "x-adiaui": {
35
+ "anti_patterns": [],
36
+ "category": "container",
37
+ "events": {},
38
+ "examples": [],
39
+ "keywords": [
40
+ "editor-canvas",
41
+ "canvas",
42
+ "workspace",
43
+ "artboard",
44
+ "editor-surface"
45
+ ],
46
+ "name": "EditorCanvas",
47
+ "related": [
48
+ "EditorShell",
49
+ "EditorCanvasEmpty",
50
+ "EditorToolbar",
51
+ "EditorSidebar"
52
+ ],
53
+ "slots": {
54
+ "default": {
55
+ "description": "Default — canvas content (artboards, document body, blocks, etc.) plus an optional first <editor-canvas-empty> sibling for the empty state."
56
+ }
57
+ },
58
+ "states": [
59
+ {
60
+ "description": "Default canvas mode.",
61
+ "name": "idle"
62
+ },
63
+ {
64
+ "description": "Zero content children — empty state visible.",
65
+ "attribute": "empty",
66
+ "name": "empty"
67
+ },
68
+ {
69
+ "description": "Canvas is the focus surface.",
70
+ "attribute": "focused",
71
+ "name": "focused"
72
+ }
73
+ ],
74
+ "synonyms": {
75
+ "editor-canvas": [
76
+ "canvas",
77
+ "workspace",
78
+ "artboard",
79
+ "design-surface"
80
+ ]
81
+ },
82
+ "tag": "editor-canvas",
83
+ "tokens": {},
84
+ "traits": [],
85
+ "version": 1
86
+ }
87
+ }
@@ -0,0 +1,65 @@
1
+ <header>
2
+ <div>
3
+ <h1>Editor Canvas</h1>
4
+ <div data-actions>
5
+ <tag-ui size="sm">editor-canvas</tag-ui>
6
+ <tag-ui size="sm" variant="ghost">JS-bearing</tag-ui>
7
+ </div>
8
+ </div>
9
+ <p>Module-tier editor canvas surface — replaces legacy <div data-canvas>. Owns scroll/zoom container, [empty] + [focused] reflected.</p>
10
+ </header>
11
+
12
+ <section data-section>
13
+ <h2 variant="section">Role</h2>
14
+ <p>Per <a href="../../../../.brain/adrs/0023-bespoke-shell-tier-children.md">ADR-0023</a>, the editor cluster's bespoke family — <code>&lt;editor-shell&gt;</code> (host), <code>&lt;editor-toolbar&gt;</code>, <code>&lt;editor-canvas&gt;</code>, <code>&lt;editor-sidebar&gt;</code> (JS-bearing) + <code>&lt;editor-statusbar&gt;</code>, <code>&lt;editor-canvas-empty&gt;</code> (CSS-only). Confirms the family pattern is canonical across 3 archetypes (admin/chat/editor).</p>
15
+ </section>
16
+
17
+ <section data-section>
18
+ <h2 variant="section">Composition</h2>
19
+ <p>Typical placement inside <code>&lt;editor-shell&gt;</code>:</p>
20
+ <code-ui language="html">&lt;editor-shell&gt;
21
+ &lt;editor-toolbar&gt;
22
+ &lt;span slot="title"&gt;Untitled.fig&lt;/span&gt;
23
+ &lt;button-ui slot="action" icon="gear"&gt;&lt;/button-ui&gt;
24
+ &lt;/editor-toolbar&gt;
25
+
26
+ &lt;editor-sidebar slot="leading" collapsible&gt;
27
+ &lt;pane-ui resizable&gt;
28
+ &lt;header&gt;Navigator&lt;/header&gt;
29
+ &lt;section&gt;…layers…&lt;/section&gt;
30
+ &lt;/pane-ui&gt;
31
+ &lt;/editor-sidebar&gt;
32
+
33
+ &lt;editor-canvas&gt;
34
+ &lt;editor-canvas-empty&gt;
35
+ &lt;empty-state-ui icon="square" heading="New document" description="Drop content to begin."&gt;&lt;/empty-state-ui&gt;
36
+ &lt;/editor-canvas-empty&gt;
37
+ &lt;/editor-canvas&gt;
38
+
39
+ &lt;editor-sidebar slot="trailing" collapsible&gt;
40
+ &lt;pane-ui resizable&gt;
41
+ &lt;header&gt;Inspector&lt;/header&gt;
42
+ &lt;section&gt;…properties…&lt;/section&gt;
43
+ &lt;/pane-ui&gt;
44
+ &lt;/editor-sidebar&gt;
45
+
46
+ &lt;editor-statusbar&gt;
47
+ &lt;span slot="status"&gt;Saved&lt;/span&gt;
48
+ &lt;span slot="zoom"&gt;100%&lt;/span&gt;
49
+ &lt;/editor-statusbar&gt;
50
+ &lt;/editor-shell&gt;</code-ui>
51
+ </section>
52
+
53
+ <section data-section>
54
+ <h2 variant="section">State as attribute</h2>
55
+ <code-ui language="css">/* Hide all chrome in focus mode */
56
+ editor-shell[focus-mode] :is(editor-toolbar, editor-statusbar, editor-sidebar) {
57
+ display: none;
58
+ }
59
+
60
+ /* Visual treatment for empty canvas */
61
+ editor-canvas[empty] { /* placeholder UI */ }
62
+
63
+ /* Highlight focused canvas */
64
+ editor-canvas[focused] { outline: 2px solid var(--a-accent); }</code-ui>
65
+ </section>
@@ -0,0 +1,43 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="auto">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Editor Canvas — AdiaUI</title>
7
+
8
+ <link rel="stylesheet" href="../../../web-components/styles/resets.css">
9
+ <link rel="stylesheet" href="../../../web-components/styles/tokens.css">
10
+ <link rel="stylesheet" href="../editor-shell/editor-shell.css">
11
+ <link rel="stylesheet" href="../../../web-components/components/code/code.css">
12
+ <link rel="stylesheet" href="../../../web-components/components/tag/tag.css">
13
+
14
+ <script type="module" src="../editor-shell/editor-shell.js"></script>
15
+ <script type="module" src="./editor-canvas.js"></script>
16
+ <script type="module" src="../../../web-components/components/code/code.js"></script>
17
+ <script type="module" src="../../../web-components/components/tag/tag.js"></script>
18
+
19
+ <style>
20
+ :where(html, body) { margin: 0; min-height: 100vh; background: var(--a-bg); color: var(--a-fg); font-family: var(--a-font); }
21
+ main { max-width: 960px; margin-inline: auto; padding: var(--a-space-6) var(--a-space-5); }
22
+ </style>
23
+ </head>
24
+ <body>
25
+
26
+ <main id="demo-root">
27
+ <p>Loading examples…</p>
28
+ </main>
29
+
30
+ <script type="module">
31
+ const root = document.getElementById('demo-root');
32
+ try {
33
+ const res = await fetch('./editor-canvas.examples.html');
34
+ if (!res.ok) throw new Error(`fetch failed (${res.status})`);
35
+ root.innerHTML = await res.text();
36
+ } catch (err) {
37
+ root.innerHTML = `<p style="color:var(--a-danger-strong);">Failed to load editor-canvas.examples.html — ${err.message}</p>`;
38
+ console.error('[editor-canvas.html]', err);
39
+ }
40
+ </script>
41
+
42
+ </body>
43
+ </html>
@@ -0,0 +1,103 @@
1
+ /**
2
+ * <editor-canvas focused?>
3
+ * <editor-canvas-empty>...</editor-canvas-empty> (optional empty state)
4
+ * <!-- canvas content (children) -->
5
+ * </editor-canvas>
6
+ *
7
+ * Module-tier editor canvas surface — replaces legacy <div data-canvas>
8
+ * inside <editor-shell> per ADR-0023. Owns:
9
+ *
10
+ * - [empty] reflected attribute (set when no non-stub children) —
11
+ * drives <editor-canvas-empty> visibility via parent CSS
12
+ * - [focused] reflected attribute (set when canvas claims focus
13
+ * ownership; e.g., toolbar full-screen toggle, click-to-focus)
14
+ * - Public API for zoom/pan refs (.zoom getter/setter, .resetView())
15
+ * - .focus() / .blur() programmatic — sets [focused] reflected
16
+ *
17
+ * Reflected attributes:
18
+ * [empty] — true when zero non-<editor-canvas-empty> children
19
+ * [focused] — true when canvas is the focus surface
20
+ *
21
+ * Public methods:
22
+ * .focus() — claim focus + reflect [focused]
23
+ * .blur() — release focus + clear [focused]
24
+ * .resetView() — reset zoom/pan to defaults
25
+ *
26
+ * Properties:
27
+ * .zoom — getter/setter (default 1.0)
28
+ *
29
+ * The host (<editor-shell>) reads either <editor-canvas> or
30
+ * [data-canvas] / <div data-canvas> via :is() selector for backwards
31
+ * compat.
32
+ */
33
+
34
+ import { UIElement } from '../../../web-components/core/element.js';
35
+
36
+ class EditorCanvas extends UIElement {
37
+ static properties = {
38
+ empty: { type: Boolean, default: true, reflect: true },
39
+ focused: { type: Boolean, default: false, reflect: true },
40
+ };
41
+
42
+ static template = () => null;
43
+
44
+ #zoom = 1.0;
45
+ #childObserver = null;
46
+
47
+ connected() {
48
+ this.#syncEmptyFromChildren();
49
+ this.#setupChildObserver();
50
+ }
51
+
52
+ disconnected() {
53
+ this.#childObserver?.disconnect();
54
+ this.#childObserver = null;
55
+ }
56
+
57
+ // ── Public API ──
58
+
59
+ get zoom() {
60
+ return this.#zoom;
61
+ }
62
+
63
+ set zoom(value) {
64
+ const v = parseFloat(value);
65
+ if (!isFinite(v) || v <= 0) return;
66
+ this.#zoom = v;
67
+ this.style.setProperty('--editor-canvas-zoom', String(v));
68
+ }
69
+
70
+ resetView() {
71
+ this.zoom = 1.0;
72
+ // Future: also reset pan offsets when implemented
73
+ }
74
+
75
+ focus() {
76
+ super.focus?.();
77
+ this.focused = true;
78
+ }
79
+
80
+ blur() {
81
+ super.blur?.();
82
+ this.focused = false;
83
+ }
84
+
85
+ // ── Internal: empty state from children ──
86
+
87
+ #syncEmptyFromChildren() {
88
+ const contentChildren = Array.from(this.children).filter(
89
+ (c) => c.tagName.toLowerCase() !== 'editor-canvas-empty'
90
+ );
91
+ this.empty = contentChildren.length === 0;
92
+ }
93
+
94
+ #setupChildObserver() {
95
+ this.#childObserver = new MutationObserver(() => {
96
+ this.#syncEmptyFromChildren();
97
+ });
98
+ this.#childObserver.observe(this, { childList: true });
99
+ }
100
+ }
101
+
102
+ customElements.define('editor-canvas', EditorCanvas);
103
+ export { EditorCanvas };
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import '../../../web-components/core/element.js';
3
+ import './editor-canvas.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+
7
+ function mount(html) {
8
+ const wrap = document.createElement('div');
9
+ wrap.innerHTML = html;
10
+ document.body.appendChild(wrap);
11
+ return wrap.firstElementChild;
12
+ }
13
+
14
+ beforeEach(() => {
15
+ document.body.innerHTML = '';
16
+ globalThis.MutationObserver = class {
17
+ observe() {} disconnect() {}
18
+ };
19
+ });
20
+
21
+ describe('editor-canvas', () => {
22
+ it('registers editor-canvas as a custom element', () => {
23
+ expect(customElements.get('editor-canvas')).toBeDefined();
24
+ });
25
+
26
+ it('defaults to focused=false', () => {
27
+ const c = mount('<editor-canvas></editor-canvas>');
28
+ expect(c.focused).toBe(false);
29
+ });
30
+
31
+ it('defaults to empty=true with no children', async () => {
32
+ const c = mount('<editor-canvas></editor-canvas>');
33
+ await tick();
34
+ expect(c.empty).toBe(true);
35
+ });
36
+
37
+ it('reflects [empty] via property assignment', async () => {
38
+ const c = mount('<editor-canvas></editor-canvas>');
39
+ c.empty = false;
40
+ await tick();
41
+ expect(c.hasAttribute('empty')).toBe(false);
42
+ c.empty = true;
43
+ await tick();
44
+ expect(c.hasAttribute('empty')).toBe(true);
45
+ });
46
+
47
+ it('reflects [focused] via property assignment', async () => {
48
+ const c = mount('<editor-canvas></editor-canvas>');
49
+ c.focused = true;
50
+ await tick();
51
+ expect(c.hasAttribute('focused')).toBe(true);
52
+ });
53
+
54
+ it('does not count <editor-canvas-empty> stub as a content child', () => {
55
+ const c = mount('<editor-canvas><editor-canvas-empty></editor-canvas-empty></editor-canvas>');
56
+ expect(c.empty).toBe(true);
57
+ });
58
+
59
+ it('exposes .zoom getter/setter (default 1.0)', () => {
60
+ const c = mount('<editor-canvas></editor-canvas>');
61
+ expect(c.zoom).toBe(1.0);
62
+ c.zoom = 2.0;
63
+ expect(c.zoom).toBe(2.0);
64
+ expect(c.style.getPropertyValue('--editor-canvas-zoom')).toBe('2');
65
+ });
66
+
67
+ it('rejects invalid zoom values', () => {
68
+ const c = mount('<editor-canvas></editor-canvas>');
69
+ c.zoom = 1.5;
70
+ c.zoom = 'invalid'; // should not change
71
+ expect(c.zoom).toBe(1.5);
72
+ c.zoom = -1; // should not change
73
+ expect(c.zoom).toBe(1.5);
74
+ c.zoom = 0; // should not change
75
+ expect(c.zoom).toBe(1.5);
76
+ });
77
+
78
+ it('.resetView() restores zoom to 1.0', () => {
79
+ const c = mount('<editor-canvas></editor-canvas>');
80
+ c.zoom = 2.5;
81
+ c.resetView();
82
+ expect(c.zoom).toBe(1.0);
83
+ });
84
+
85
+ it('.focus() sets [focused] reflected', async () => {
86
+ const c = mount('<editor-canvas></editor-canvas>');
87
+ c.focus();
88
+ await tick();
89
+ expect(c.focused).toBe(true);
90
+ expect(c.hasAttribute('focused')).toBe(true);
91
+ });
92
+
93
+ it('.blur() clears [focused] reflected', async () => {
94
+ const c = mount('<editor-canvas focused></editor-canvas>');
95
+ c.blur();
96
+ await tick();
97
+ expect(c.focused).toBe(false);
98
+ expect(c.hasAttribute('focused')).toBe(false);
99
+ });
100
+ });