@peter.prib/renderengine 0.2.10 → 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 (39) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +123 -2
  3. package/dist/renderengine-workspace.umd.js +3015 -1221
  4. package/dist/renderengine.umd.js +1220 -176
  5. package/package.json +1 -1
  6. package/src/charting/ModelCheckDialog.js +10 -3
  7. package/src/charting/ModelComparisonDialog.js +10 -3
  8. package/src/charting/ModelFileFormat.js +4 -1
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
  10. package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
  11. package/src/charting/arrangement/layouts.js +9 -4
  12. package/src/charting/datamodel/DataModel.js +18 -71
  13. package/src/charting/datamodel/DataModelChecker.js +1 -5
  14. package/src/charting/datamodel/DataModelCompared.js +1 -31
  15. package/src/charting/datamodel/LevelSchema.js +23 -0
  16. package/src/charting/datamodel/SqlDdl.js +18 -13
  17. package/src/charting/diagramModel/ListCompared.js +35 -0
  18. package/src/charting/model/Benchmark.js +59 -0
  19. package/src/charting/model/EditableModel.js +104 -0
  20. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  21. package/src/charting/requirements/RequirementsChecker.js +76 -0
  22. package/src/charting/requirements/RequirementsCompared.js +33 -0
  23. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  24. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  25. package/src/charting/requirements/RequirementsModel.js +305 -0
  26. package/src/charting/requirements/Storyboard.js +94 -0
  27. package/src/charting/requirements/TrackerExport.js +273 -0
  28. package/src/charting/requirements/index.js +7 -0
  29. package/src/index.js +6 -0
  30. package/src/workspace/Workspace.js +329 -321
  31. package/src/workspace/index.js +2 -0
  32. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  33. package/src/workspace/parts/DataModelPart.js +668 -735
  34. package/src/workspace/parts/EditorPart.js +1 -1
  35. package/src/workspace/parts/ModelFile.js +3 -1
  36. package/src/workspace/parts/ModelHosted.js +176 -0
  37. package/src/workspace/parts/ModelsPart.js +146 -7
  38. package/src/workspace/parts/RequirementsPart.js +465 -0
  39. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -1,321 +1,329 @@
1
- import { ContextBus } from './ContextBus.js';
2
- import { PartRegistry } from './PartRegistry.js';
3
- import { createLayout } from './layout/index.js';
4
- import { ensureStyles } from './styles.js';
5
- import { BUILTIN_THEMES, SYSTEM_THEME } from './themes.js';
6
- import { render } from './dom.js';
7
-
8
- /** Bump when the serialized shape changes; `deserialize` migrates on the way in. */
9
- export const WORKSPACE_SCHEMA_VERSION = 1;
10
-
11
- /**
12
- * Workspace — the `<ws-workspace>` custom element and the shell's public API.
13
- *
14
- * Renders into **light DOM**: parts routinely carry their own stylesheets, and a
15
- * consumer stylesheet cannot cross a shadow boundary. All shell markup is
16
- * `ws-`-prefixed and themed through CSS custom properties instead.
17
- *
18
- * Typical wiring:
19
- *
20
- * const ws = document.querySelector('ws-workspace');
21
- * ws.registerParts([ListPart, DetailPart, ChartPart]);
22
- * ws.addPart({ id: 'nav', type: 'list', title: 'Tables', items: [...] });
23
- * ws.registerPerspective('default', { type: 'split', dir: 'row', children: [...] });
24
- * ws.activatePerspective('default');
25
- */
26
- export class Workspace extends HTMLElement {
27
- constructor() {
28
- super();
29
- this.bus = new ContextBus();
30
- this.registry = new PartRegistry();
31
- /** @type {Map<string, object>} name → layout JSON */
32
- this.perspectives = new Map();
33
- this.activePerspective = null;
34
- /** @type {import('./layout/LayoutNode.js').LayoutNode|null} */
35
- this.tree = null;
36
- /** @type {Map<string, object>} theme name → CSS custom-property map */
37
- this.themes = new Map(Object.entries(BUILTIN_THEMES));
38
- /** The *selection* — may be `system`, which resolves at apply time. */
39
- this.theme = 'light';
40
- /** Custom properties currently written onto the host, so they can be
41
- * cleared before a different theme is applied. */
42
- this._appliedVars = [];
43
- this._built = false;
44
- }
45
-
46
- /* ── Element lifecycle ──────────────────────────────────────────────── */
47
-
48
- connectedCallback() {
49
- if (this._built) return;
50
- this._built = true;
51
- ensureStyles(this.ownerDocument ?? document);
52
- this.classList.add('ws-workspace');
53
-
54
- // Chrome is built from element specs rather than an HTML string, and
55
- // `ref` captures the nodes directly — no querySelector round-trip.
56
- // `label`, not `title` — `title` is a global HTML attribute and would
57
- // also render a browser tooltip over the whole workspace.
58
- render(this, [
59
- { cls: 'ws-toolbar', children: [
60
- { tag: 'span', cls: 'ws-toolbar-title', text: this.getAttribute('label') ?? '' },
61
- { tag: 'span', cls: 'ws-toolbar-spacer' },
62
- { tag: 'span', cls: 'ws-theme-picker', ref: n => { this._themePickerEl = n; } },
63
- { tag: 'span', cls: 'ws-perspectives', ref: n => { this._perspectivesEl = n; } },
64
- ]},
65
- { cls: 'ws-root', ref: n => { this._rootEl = n; } },
66
- ]);
67
-
68
- this.applyTheme(this.getAttribute('theme') ?? 'light');
69
- this._renderThemePicker();
70
-
71
- // A perspective registered before the element was connected renders now.
72
- if (this.activePerspective) this._renderTree();
73
- else if (this.perspectives.size) this.activatePerspective([...this.perspectives.keys()][0]);
74
- }
75
-
76
- disconnectedCallback() {
77
- this.tree?.dispose();
78
- this.tree = null;
79
- this._watchSystemTheme(false); // a live matchMedia listener would outlive the element
80
- }
81
-
82
- /* ── Parts ──────────────────────────────────────────────────────────── */
83
-
84
- registerPart(PartClass) { this.registry.register(PartClass); return this; }
85
- registerParts(classes) { this.registry.registerAll(classes); return this; }
86
-
87
- /** Create a pooled part instance from a spec. */
88
- addPart(spec) { return this.registry.add(spec); }
89
- addParts(specs) { return specs.map(s => this.addPart(s)); }
90
- getPart(id) { return this.registry.get(id); }
91
- removePart(id) { return this.registry.remove(id); }
92
-
93
- /* ── Perspectives ───────────────────────────────────────────────────── */
94
-
95
- registerPerspective(name, layoutJson) {
96
- this.perspectives.set(name, layoutJson);
97
- this._renderPerspectiveButtons();
98
- return this;
99
- }
100
-
101
- /**
102
- * Switch to a named perspective.
103
- *
104
- * Parts are pooled by id and live for the workspace's lifetime, so a part
105
- * the incoming perspective also references is re-parented rather than
106
- * rebuilt — it keeps its scroll position, unsaved input and subscriptions.
107
- * Parts the incoming perspective doesn't reference are detached, not
108
- * destroyed, so switching back restores them intact.
109
- *
110
- * There is deliberately no legal-transition table: a framework cannot know
111
- * a consumer's perspectives. Pass `guard` to veto a switch if you need one.
112
- */
113
- activatePerspective(name) {
114
- const layout = this.perspectives.get(name);
115
- if (!layout) throw new Error(`Unknown perspective "${name}"`);
116
- const from = this.activePerspective;
117
- if (from === name) return true;
118
- if (this.guard && this.guard(from, name) === false) return false;
119
-
120
- this.tree?.dispose();
121
- this.activePerspective = name;
122
- this._renderTree();
123
-
124
- this.dispatchEvent(new CustomEvent('ws-perspective-change', {
125
- detail: { from, to: name }, bubbles: true,
126
- }));
127
- return true;
128
- }
129
-
130
- _renderTree() {
131
- if (!this._rootEl) return;
132
- const layout = this.perspectives.get(this.activePerspective);
133
- this._rootEl.textContent = '';
134
- this.tree = createLayout(layout);
135
- this.tree.render(this._rootEl, this._ctx());
136
- this.tree.activate?.();
137
- // Parts the new tree doesn't reference are deliberately NOT disposed —
138
- // they are merely detached (PartNode.dispose), keeping their DOM and
139
- // state. Disposing them would delete them from the pool, so switching
140
- // back to the perspective that used them would find them gone. Part
141
- // lifetime ends only at removePart()/deserialize().
142
- this._renderPerspectiveButtons();
143
- }
144
-
145
- _ctx() {
146
- return {
147
- bus: this.bus,
148
- registry: this.registry,
149
- workspace: this,
150
- onLayoutChange: () => this._onLayoutChange(),
151
- ...(this.services ?? {}),
152
- };
153
- }
154
-
155
- _onLayoutChange() {
156
- if (this.activePerspective) {
157
- this.perspectives.set(this.activePerspective, this.tree.serialize());
158
- }
159
- this.dispatchEvent(new CustomEvent('ws-layout-change', {
160
- detail: { perspective: this.activePerspective }, bubbles: true,
161
- }));
162
- }
163
-
164
- _renderPerspectiveButtons() {
165
- if (!this._perspectivesEl) return;
166
- render(this._perspectivesEl, [...this.perspectives.keys()].map(name => ({
167
- tag: 'button',
168
- cls: ['ws-perspective-btn', name === this.activePerspective && 'ws-active'],
169
- text: name,
170
- attrs: { type: 'button' },
171
- on: { click: () => this.activatePerspective(name) },
172
- })));
173
- }
174
-
175
- /* ── Theming ────────────────────────────────────────────────────────── */
176
-
177
- /**
178
- * Register a custom-property set under a name. It appears in the toolbar's
179
- * theme picker immediately — the picker is built from the registry, so a
180
- * consumer theme needs no CSS and no picker change.
181
- */
182
- registerTheme(name, vars) {
183
- this.themes.set(name, vars);
184
- this._renderThemePicker();
185
- return this;
186
- }
187
-
188
- /** Registered theme names, plus the reserved `system` option. */
189
- themeNames() { return [SYSTEM_THEME, ...this.themes.keys()]; }
190
-
191
- /**
192
- * Apply a theme by name, or `system` to follow the OS setting.
193
- *
194
- * The previous theme's properties are removed before the new ones are set:
195
- * a custom theme may define only some of the vars, and leftovers from the
196
- * outgoing theme would otherwise show through as a mismatched palette.
197
- */
198
- applyTheme(name) {
199
- const resolved = name === SYSTEM_THEME ? this._systemTheme() : name;
200
- const vars = this.themes.get(resolved);
201
- if (!vars && name !== SYSTEM_THEME) return this;
202
-
203
- for (const key of this._appliedVars) this.style.removeProperty(key);
204
- this._appliedVars = [];
205
- for (const [k, v] of Object.entries(vars ?? {})) {
206
- this.style.setProperty(k, v);
207
- this._appliedVars.push(k);
208
- }
209
-
210
- const previous = this.theme;
211
- this.theme = name; // the selection ('system' stays 'system')
212
- this.setAttribute('data-theme', resolved); // the resolved name, for consumer CSS hooks
213
- this._watchSystemTheme(name === SYSTEM_THEME);
214
- if (this._themeSelect) this._themeSelect.value = name;
215
-
216
- if (previous !== name) {
217
- this.dispatchEvent(new CustomEvent('ws-theme-change', {
218
- detail: { theme: name, resolved }, bubbles: true,
219
- }));
220
- }
221
- return this;
222
- }
223
-
224
- _systemTheme() {
225
- return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
226
- }
227
-
228
- /** Keep `system` live: re-apply when the OS flips between light and dark. */
229
- _watchSystemTheme(enabled) {
230
- if (enabled && !this._systemThemeQuery) {
231
- this._systemThemeQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
232
- this._onSystemThemeChange = () => { if (this.theme === SYSTEM_THEME) this.applyTheme(SYSTEM_THEME); };
233
- this._systemThemeQuery?.addEventListener('change', this._onSystemThemeChange);
234
- } else if (!enabled && this._systemThemeQuery) {
235
- this._systemThemeQuery.removeEventListener('change', this._onSystemThemeChange);
236
- this._systemThemeQuery = null;
237
- }
238
- }
239
-
240
- _renderThemePicker() {
241
- if (!this._themePickerEl) return;
242
- if (this.getAttribute('theme-picker') === 'hidden') {
243
- this._themePickerEl.textContent = '';
244
- return;
245
- }
246
- render(this._themePickerEl, [
247
- { tag: 'label', text: 'Theme', attrs: { for: 'ws-theme-select' } },
248
- {
249
- tag: 'select',
250
- cls: 'ws-theme-select',
251
- attrs: { id: 'ws-theme-select', title: 'Colour theme' },
252
- on: { change: e => this.applyTheme(e.target.value) },
253
- ref: n => { this._themeSelect = n; n.value = this.theme; },
254
- children: this.themeNames().map(name => ({
255
- tag: 'option',
256
- text: name === SYSTEM_THEME ? 'System' : name[0].toUpperCase() + name.slice(1),
257
- attrs: { value: name, selected: name === this.theme },
258
- })),
259
- },
260
- ]);
261
- }
262
-
263
- /* ── Persistence ────────────────────────────────────────────────────── */
264
-
265
- serialize() {
266
- if (this.tree && this.activePerspective) {
267
- this.perspectives.set(this.activePerspective, this.tree.serialize());
268
- }
269
- return {
270
- version: WORKSPACE_SCHEMA_VERSION,
271
- theme: this.theme,
272
- activePerspective: this.activePerspective,
273
- perspectives: Object.fromEntries(this.perspectives),
274
- parts: this.registry.serialize(),
275
- };
276
- }
277
-
278
- /**
279
- * Restore a serialized workspace. Part *types* must already be registered —
280
- * a saved document carries specs, not code.
281
- *
282
- * `version` is honoured from day one on purpose: layout schemas change, and
283
- * sqlengine's dashboard already had to retrofit exactly this migration once.
284
- */
285
- deserialize(json, { migrate } = {}) {
286
- const doc = migrate ? migrate(json) : json;
287
- if (doc.version > WORKSPACE_SCHEMA_VERSION) {
288
- throw new Error(`Workspace document version ${doc.version} is newer than this build (${WORKSPACE_SCHEMA_VERSION})`);
289
- }
290
- this.registry.disposeAll();
291
- for (const spec of doc.parts ?? []) {
292
- if (spec.adapter) continue; // AdapterPart's mount fn cannot round-trip — re-add it in code
293
- this.addPart(spec);
294
- }
295
- // Only a theme this build actually knows — a document naming one the
296
- // consumer no longer registers must not leave the shell unstyled.
297
- if (doc.theme && (doc.theme === SYSTEM_THEME || this.themes.has(doc.theme))) {
298
- this.applyTheme(doc.theme);
299
- }
300
- this.perspectives = new Map(Object.entries(doc.perspectives ?? {}));
301
- this.activePerspective = null;
302
- if (doc.activePerspective && this.perspectives.has(doc.activePerspective)) {
303
- this.activatePerspective(doc.activePerspective);
304
- }
305
- return this;
306
- }
307
- }
308
-
309
- export const WORKSPACE_TAG = 'ws-workspace';
310
-
311
- /**
312
- * Register the element. Custom element names must contain a hyphen, so
313
- * `workspace` is not a legal name — `define()` throws SyntaxError on it.
314
- * Guarded so importing this module twice does not throw on re-definition.
315
- */
316
- export function defineWorkspace(tag = WORKSPACE_TAG) {
317
- if (typeof customElements === 'undefined') return false;
318
- if (customElements.get(tag)) return false;
319
- customElements.define(tag, Workspace);
320
- return true;
321
- }
1
+ import { ContextBus } from './ContextBus.js';
2
+ import { PartRegistry } from './PartRegistry.js';
3
+ import { createLayout } from './layout/index.js';
4
+ import { ensureStyles } from './styles.js';
5
+ import { BUILTIN_THEMES, SYSTEM_THEME } from './themes.js';
6
+ import { render } from './dom.js';
7
+
8
+ /** Bump when the serialized shape changes; `deserialize` migrates on the way in. */
9
+ export const WORKSPACE_SCHEMA_VERSION = 1;
10
+
11
+ /**
12
+ * Workspace — the `<ws-workspace>` custom element and the shell's public API.
13
+ *
14
+ * Renders into **light DOM**: parts routinely carry their own stylesheets, and a
15
+ * consumer stylesheet cannot cross a shadow boundary. All shell markup is
16
+ * `ws-`-prefixed and themed through CSS custom properties instead.
17
+ *
18
+ * Typical wiring:
19
+ *
20
+ * const ws = document.querySelector('ws-workspace');
21
+ * ws.registerParts([ListPart, DetailPart, ChartPart]);
22
+ * ws.addPart({ id: 'nav', type: 'list', title: 'Tables', items: [...] });
23
+ * ws.registerPerspective('default', { type: 'split', dir: 'row', children: [...] });
24
+ * ws.activatePerspective('default');
25
+ */
26
+ export class Workspace extends HTMLElement {
27
+ constructor() {
28
+ super();
29
+ this.bus = new ContextBus();
30
+ this.registry = new PartRegistry();
31
+ /** @type {Map<string, object>} name → layout JSON */
32
+ this.perspectives = new Map();
33
+ this.activePerspective = null;
34
+ /** @type {import('./layout/LayoutNode.js').LayoutNode|null} */
35
+ this.tree = null;
36
+ /** @type {Map<string, object>} theme name → CSS custom-property map */
37
+ this.themes = new Map(Object.entries(BUILTIN_THEMES));
38
+ /** The *selection* — may be `system`, which resolves at apply time. */
39
+ this.theme = 'light';
40
+ /** Custom properties currently written onto the host, so they can be
41
+ * cleared before a different theme is applied. */
42
+ this._appliedVars = [];
43
+ this._built = false;
44
+ }
45
+
46
+ /* ── Element lifecycle ──────────────────────────────────────────────── */
47
+
48
+ connectedCallback() {
49
+ // Moved, not new (a host re-parents it: a tab dragged to another
50
+ // group): its layout was disposed when it left (disconnectedCallback),
51
+ // so it is drawn again — the parts are still in the registry, as they
52
+ // were. Without this a workspace moved in the page came back empty.
53
+ if (this._built) {
54
+ if (!this.tree && this.activePerspective) this._renderTree();
55
+ this._watchSystemTheme(this.theme === SYSTEM_THEME);
56
+ return;
57
+ }
58
+ this._built = true;
59
+ ensureStyles(this.ownerDocument ?? document);
60
+ this.classList.add('ws-workspace');
61
+
62
+ // Chrome is built from element specs rather than an HTML string, and
63
+ // `ref` captures the nodes directly — no querySelector round-trip.
64
+ // `label`, not `title` — `title` is a global HTML attribute and would
65
+ // also render a browser tooltip over the whole workspace.
66
+ render(this, [
67
+ { cls: 'ws-toolbar', children: [
68
+ { tag: 'span', cls: 'ws-toolbar-title', text: this.getAttribute('label') ?? '' },
69
+ { tag: 'span', cls: 'ws-toolbar-spacer' },
70
+ { tag: 'span', cls: 'ws-theme-picker', ref: n => { this._themePickerEl = n; } },
71
+ { tag: 'span', cls: 'ws-perspectives', ref: n => { this._perspectivesEl = n; } },
72
+ ]},
73
+ { cls: 'ws-root', ref: n => { this._rootEl = n; } },
74
+ ]);
75
+
76
+ this.applyTheme(this.getAttribute('theme') ?? 'light');
77
+ this._renderThemePicker();
78
+
79
+ // A perspective registered before the element was connected renders now.
80
+ if (this.activePerspective) this._renderTree();
81
+ else if (this.perspectives.size) this.activatePerspective([...this.perspectives.keys()][0]);
82
+ }
83
+
84
+ disconnectedCallback() {
85
+ this.tree?.dispose();
86
+ this.tree = null;
87
+ this._watchSystemTheme(false); // a live matchMedia listener would outlive the element
88
+ }
89
+
90
+ /* ── Parts ──────────────────────────────────────────────────────────── */
91
+
92
+ registerPart(PartClass) { this.registry.register(PartClass); return this; }
93
+ registerParts(classes) { this.registry.registerAll(classes); return this; }
94
+
95
+ /** Create a pooled part instance from a spec. */
96
+ addPart(spec) { return this.registry.add(spec); }
97
+ addParts(specs) { return specs.map(s => this.addPart(s)); }
98
+ getPart(id) { return this.registry.get(id); }
99
+ removePart(id) { return this.registry.remove(id); }
100
+
101
+ /* ── Perspectives ───────────────────────────────────────────────────── */
102
+
103
+ registerPerspective(name, layoutJson) {
104
+ this.perspectives.set(name, layoutJson);
105
+ this._renderPerspectiveButtons();
106
+ return this;
107
+ }
108
+
109
+ /**
110
+ * Switch to a named perspective.
111
+ *
112
+ * Parts are pooled by id and live for the workspace's lifetime, so a part
113
+ * the incoming perspective also references is re-parented rather than
114
+ * rebuilt — it keeps its scroll position, unsaved input and subscriptions.
115
+ * Parts the incoming perspective doesn't reference are detached, not
116
+ * destroyed, so switching back restores them intact.
117
+ *
118
+ * There is deliberately no legal-transition table: a framework cannot know
119
+ * a consumer's perspectives. Pass `guard` to veto a switch if you need one.
120
+ */
121
+ activatePerspective(name) {
122
+ const layout = this.perspectives.get(name);
123
+ if (!layout) throw new Error(`Unknown perspective "${name}"`);
124
+ const from = this.activePerspective;
125
+ if (from === name) return true;
126
+ if (this.guard && this.guard(from, name) === false) return false;
127
+
128
+ this.tree?.dispose();
129
+ this.activePerspective = name;
130
+ this._renderTree();
131
+
132
+ this.dispatchEvent(new CustomEvent('ws-perspective-change', {
133
+ detail: { from, to: name }, bubbles: true,
134
+ }));
135
+ return true;
136
+ }
137
+
138
+ _renderTree() {
139
+ if (!this._rootEl) return;
140
+ const layout = this.perspectives.get(this.activePerspective);
141
+ this._rootEl.textContent = '';
142
+ this.tree = createLayout(layout);
143
+ this.tree.render(this._rootEl, this._ctx());
144
+ this.tree.activate?.();
145
+ // Parts the new tree doesn't reference are deliberately NOT disposed —
146
+ // they are merely detached (PartNode.dispose), keeping their DOM and
147
+ // state. Disposing them would delete them from the pool, so switching
148
+ // back to the perspective that used them would find them gone. Part
149
+ // lifetime ends only at removePart()/deserialize().
150
+ this._renderPerspectiveButtons();
151
+ }
152
+
153
+ _ctx() {
154
+ return {
155
+ bus: this.bus,
156
+ registry: this.registry,
157
+ workspace: this,
158
+ onLayoutChange: () => this._onLayoutChange(),
159
+ ...(this.services ?? {}),
160
+ };
161
+ }
162
+
163
+ _onLayoutChange() {
164
+ if (this.activePerspective) {
165
+ this.perspectives.set(this.activePerspective, this.tree.serialize());
166
+ }
167
+ this.dispatchEvent(new CustomEvent('ws-layout-change', {
168
+ detail: { perspective: this.activePerspective }, bubbles: true,
169
+ }));
170
+ }
171
+
172
+ _renderPerspectiveButtons() {
173
+ if (!this._perspectivesEl) return;
174
+ render(this._perspectivesEl, [...this.perspectives.keys()].map(name => ({
175
+ tag: 'button',
176
+ cls: ['ws-perspective-btn', name === this.activePerspective && 'ws-active'],
177
+ text: name,
178
+ attrs: { type: 'button' },
179
+ on: { click: () => this.activatePerspective(name) },
180
+ })));
181
+ }
182
+
183
+ /* ── Theming ────────────────────────────────────────────────────────── */
184
+
185
+ /**
186
+ * Register a custom-property set under a name. It appears in the toolbar's
187
+ * theme picker immediately — the picker is built from the registry, so a
188
+ * consumer theme needs no CSS and no picker change.
189
+ */
190
+ registerTheme(name, vars) {
191
+ this.themes.set(name, vars);
192
+ this._renderThemePicker();
193
+ return this;
194
+ }
195
+
196
+ /** Registered theme names, plus the reserved `system` option. */
197
+ themeNames() { return [SYSTEM_THEME, ...this.themes.keys()]; }
198
+
199
+ /**
200
+ * Apply a theme by name, or `system` to follow the OS setting.
201
+ *
202
+ * The previous theme's properties are removed before the new ones are set:
203
+ * a custom theme may define only some of the vars, and leftovers from the
204
+ * outgoing theme would otherwise show through as a mismatched palette.
205
+ */
206
+ applyTheme(name) {
207
+ const resolved = name === SYSTEM_THEME ? this._systemTheme() : name;
208
+ const vars = this.themes.get(resolved);
209
+ if (!vars && name !== SYSTEM_THEME) return this;
210
+
211
+ for (const key of this._appliedVars) this.style.removeProperty(key);
212
+ this._appliedVars = [];
213
+ for (const [k, v] of Object.entries(vars ?? {})) {
214
+ this.style.setProperty(k, v);
215
+ this._appliedVars.push(k);
216
+ }
217
+
218
+ const previous = this.theme;
219
+ this.theme = name; // the selection ('system' stays 'system')
220
+ this.setAttribute('data-theme', resolved); // the resolved name, for consumer CSS hooks
221
+ this._watchSystemTheme(name === SYSTEM_THEME);
222
+ if (this._themeSelect) this._themeSelect.value = name;
223
+
224
+ if (previous !== name) {
225
+ this.dispatchEvent(new CustomEvent('ws-theme-change', {
226
+ detail: { theme: name, resolved }, bubbles: true,
227
+ }));
228
+ }
229
+ return this;
230
+ }
231
+
232
+ _systemTheme() {
233
+ return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
234
+ }
235
+
236
+ /** Keep `system` live: re-apply when the OS flips between light and dark. */
237
+ _watchSystemTheme(enabled) {
238
+ if (enabled && !this._systemThemeQuery) {
239
+ this._systemThemeQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
240
+ this._onSystemThemeChange = () => { if (this.theme === SYSTEM_THEME) this.applyTheme(SYSTEM_THEME); };
241
+ this._systemThemeQuery?.addEventListener('change', this._onSystemThemeChange);
242
+ } else if (!enabled && this._systemThemeQuery) {
243
+ this._systemThemeQuery.removeEventListener('change', this._onSystemThemeChange);
244
+ this._systemThemeQuery = null;
245
+ }
246
+ }
247
+
248
+ _renderThemePicker() {
249
+ if (!this._themePickerEl) return;
250
+ if (this.getAttribute('theme-picker') === 'hidden') {
251
+ this._themePickerEl.textContent = '';
252
+ return;
253
+ }
254
+ render(this._themePickerEl, [
255
+ { tag: 'label', text: 'Theme', attrs: { for: 'ws-theme-select' } },
256
+ {
257
+ tag: 'select',
258
+ cls: 'ws-theme-select',
259
+ attrs: { id: 'ws-theme-select', title: 'Colour theme' },
260
+ on: { change: e => this.applyTheme(e.target.value) },
261
+ ref: n => { this._themeSelect = n; n.value = this.theme; },
262
+ children: this.themeNames().map(name => ({
263
+ tag: 'option',
264
+ text: name === SYSTEM_THEME ? 'System' : name[0].toUpperCase() + name.slice(1),
265
+ attrs: { value: name, selected: name === this.theme },
266
+ })),
267
+ },
268
+ ]);
269
+ }
270
+
271
+ /* ── Persistence ────────────────────────────────────────────────────── */
272
+
273
+ serialize() {
274
+ if (this.tree && this.activePerspective) {
275
+ this.perspectives.set(this.activePerspective, this.tree.serialize());
276
+ }
277
+ return {
278
+ version: WORKSPACE_SCHEMA_VERSION,
279
+ theme: this.theme,
280
+ activePerspective: this.activePerspective,
281
+ perspectives: Object.fromEntries(this.perspectives),
282
+ parts: this.registry.serialize(),
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Restore a serialized workspace. Part *types* must already be registered —
288
+ * a saved document carries specs, not code.
289
+ *
290
+ * `version` is honoured from day one on purpose: layout schemas change, and
291
+ * sqlengine's dashboard already had to retrofit exactly this migration once.
292
+ */
293
+ deserialize(json, { migrate } = {}) {
294
+ const doc = migrate ? migrate(json) : json;
295
+ if (doc.version > WORKSPACE_SCHEMA_VERSION) {
296
+ throw new Error(`Workspace document version ${doc.version} is newer than this build (${WORKSPACE_SCHEMA_VERSION})`);
297
+ }
298
+ this.registry.disposeAll();
299
+ for (const spec of doc.parts ?? []) {
300
+ if (spec.adapter) continue; // AdapterPart's mount fn cannot round-trip — re-add it in code
301
+ this.addPart(spec);
302
+ }
303
+ // Only a theme this build actually knows — a document naming one the
304
+ // consumer no longer registers must not leave the shell unstyled.
305
+ if (doc.theme && (doc.theme === SYSTEM_THEME || this.themes.has(doc.theme))) {
306
+ this.applyTheme(doc.theme);
307
+ }
308
+ this.perspectives = new Map(Object.entries(doc.perspectives ?? {}));
309
+ this.activePerspective = null;
310
+ if (doc.activePerspective && this.perspectives.has(doc.activePerspective)) {
311
+ this.activatePerspective(doc.activePerspective);
312
+ }
313
+ return this;
314
+ }
315
+ }
316
+
317
+ export const WORKSPACE_TAG = 'ws-workspace';
318
+
319
+ /**
320
+ * Register the element. Custom element names must contain a hyphen, so
321
+ * `workspace` is not a legal name — `define()` throws SyntaxError on it.
322
+ * Guarded so importing this module twice does not throw on re-definition.
323
+ */
324
+ export function defineWorkspace(tag = WORKSPACE_TAG) {
325
+ if (typeof customElements === 'undefined') return false;
326
+ if (customElements.get(tag)) return false;
327
+ customElements.define(tag, Workspace);
328
+ return true;
329
+ }