flexdesk 0.1.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * shell.js — `createShell()`, the library's composition entry point.
3
+ *
4
+ * Requires: root, taxonomy, entities, content
5
+ * Hosts: chrome (ELEMENTS, never selectors), host port, bus, stores
6
+ * Owns: the content registry, the WM, the palette, the keymap, the
7
+ * chrome painters, the tile context menus.
8
+ * Knows: nothing about the embedder. No `document.body`, no selector it
9
+ * did not author, no bus-event string, no content kind.
10
+ *
11
+ * ── The seam ──────────────────────────────────────────────────────────
12
+ * The embedder does everything up to "hand the framework a root element and
13
+ * a content map", and everything after `createShell()` resolves:
14
+ *
15
+ * embedder: gate (is there anything to show?) -> install its own widgets
16
+ * -> build/clear the root element -> build the content map
17
+ * library: registry -> WM -> palette -> keymap -> chrome -> wm.load()
18
+ * embedder: publish the shell, install post-mount extras
19
+ *
20
+ * That order is not stylistic. The widgets decorate DOM the embedder then
21
+ * DELETES to make room for the root element, so `root` cannot exist before
22
+ * the widgets have run — which is exactly why `createShell` does not try to
23
+ * own a `prepare`/`widgets` lifecycle. It takes a root element that already
24
+ * exists and mounts into it.
25
+ *
26
+ * ── Content is a constructor argument ─────────────────────────────────
27
+ * `content` is REQUIRED, validated, and frozen into a per-shell registry
28
+ * BEFORE the WM is built. There is no module-level `register()` to call
29
+ * late. That matters: a leaf mounted against a missing factory renders a
30
+ * placeholder, and the renderer caches tile DOM per (kind, props) — a late
31
+ * registration invalidates nothing, so a whole restored session stays wrong.
32
+ * Making registration impossible after the first mount is the fix.
33
+ *
34
+ * ── Chrome is elements, not selectors ─────────────────────────────────
35
+ * A selector parameter would be the coupling wearing a hat: the framework
36
+ * would still have to know that a "top-nav host" is a thing you find by CSS
37
+ * in someone else's markup. Handing it a node is the smallest honest
38
+ * abstraction — and an embedder with no chrome passes `chrome: {}` and gets
39
+ * a working WM with none.
40
+ */
41
+
42
+ import { WindowManager } from './wm.js';
43
+ import { createContentRegistry } from './content_registry.js';
44
+ import { createCommandPalette } from './command_palette.js';
45
+ import { installKeymap } from './keymap.js';
46
+ import { openTileTabMenu } from './tile_tab_menu.js';
47
+ import { showContextMenu } from '../ui/components/context_menu.js';
48
+ import { openForm } from '../ui/components/modal.js';
49
+
50
+ /**
51
+ * @param {object} cfg
52
+ * @param {Element} cfg.root the element the WM mounts into
53
+ * @param {object} cfg.taxonomy createTaxonomy(...) — required
54
+ * @param {object} cfg.entities createEntityCatalog(...) — required
55
+ * @param {object} cfg.content { [kind]: (hostEl, props, ctx) => {...} }
56
+ * @param {object} [cfg.host] the host port (createHost)
57
+ * @param {object} [cfg.api] an opaque back-end handle, threaded into
58
+ * ctx.api for content factories. The library
59
+ * never calls a method on it; it only passes
60
+ * it through (the palette + tile tab menu
61
+ * hand it to the entity catalog's fetchers).
62
+ * @param {object} [cfg.eventBus]
63
+ * @param {object} [cfg.logger]
64
+ * @param {object} [cfg.tableStore] threaded into ctx for table persistence
65
+ * @param {object} [cfg.events] bus-event NAMES the WM listens for
66
+ * @param {object} [cfg.rootCrumb] the breadcrumb's leading segment
67
+ * @param {object} [cfg.palette] { placeholder }
68
+ * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
69
+ * panelToggles?: { left?, right?, bottom? } }
70
+ * @returns {Promise<object>} the frozen shell
71
+ */
72
+ export async function createShell({
73
+ root,
74
+ taxonomy,
75
+ entities,
76
+ content,
77
+ host = null,
78
+ api = null,
79
+ eventBus = null,
80
+ logger = null,
81
+ tableStore = null,
82
+ events = {},
83
+ rootCrumb = null,
84
+ palette: paletteCfg = {},
85
+ chrome = {},
86
+ } = {}) {
87
+ // A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
88
+ // createHost / createTaxonomy.
89
+ if (!root || typeof root.appendChild !== 'function') {
90
+ throw new TypeError('createShell: `root` must be an element');
91
+ }
92
+ if (!taxonomy) throw new Error('createShell: a taxonomy is required');
93
+ if (!entities) throw new Error('createShell: an entity catalog is required');
94
+
95
+ const log = logger ?? { info(){}, warn(){}, error(){}, debug(){} };
96
+ const registry = createContentRegistry(content); // throws on a non-function
97
+
98
+ let wm = null;
99
+ let topNavEl = null;
100
+ let desktopsEl = null;
101
+ let toggles = null;
102
+
103
+ const syncChrome = () => {
104
+ if (!wm) return;
105
+ syncPanelToggles(toggles, wm);
106
+ syncDesktopBar(desktopsEl, wm);
107
+ syncTopNav(topNavEl, wm);
108
+ };
109
+
110
+ wm = new WindowManager({
111
+ rootEl: root,
112
+ content: registry,
113
+ api,
114
+ eventBus,
115
+ host,
116
+ taxonomy,
117
+ events,
118
+ // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
119
+ // spreads it into every content factory, which is how the breadcrumb
120
+ // gets `taxonomy` + `rootCrumb` without a content factory knowing they
121
+ // exist. The two `onTile*Menu` keys are read with OPTIONAL CHAINING in
122
+ // tile_renderer — drop one and right-click silently does nothing.
123
+ ctx: {
124
+ api, eventBus, host, tableStore,
125
+ taxonomy, entities, rootCrumb, events,
126
+ onTileContextMenu: (leafId, x, y) => _tileContextMenu(wm, leafId, x, y),
127
+ onTileTabMenu: (leafId, x, y) => _tileTabMenu(wm, leafId, x, y),
128
+ },
129
+ onChange: () => syncChrome(),
130
+ });
131
+
132
+ const palette = createCommandPalette({
133
+ wm, api, taxonomy, catalog: entities, ...paletteCfg,
134
+ });
135
+ installKeymap({ wm, palette });
136
+
137
+ // Chrome — painted into the ELEMENTS the embedder handed over. An absent
138
+ // key means absent chrome, not a crash.
139
+ const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
140
+ topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
141
+ desktopsEl = mountDesktopBar(chrome.desktops, wm);
142
+ // NOTE: bindPanelToggles CLONES the buttons (to strip whatever state the
143
+ // embedder's own machinery left on them) and returns the FRESH nodes. The
144
+ // originals are detached from here on — sync against the returned map, not
145
+ // against `chrome.panelToggles`.
146
+ toggles = bindPanelToggles(chrome.panelToggles, wm);
147
+
148
+ // Belt-and-braces: anything else that mutates the WM fans out to chrome.
149
+ eventBus?.on?.('wm:changed', syncChrome);
150
+
151
+ await wm.load(); // ← FIRST MOUNT
152
+ syncChrome();
153
+
154
+ log.info?.('shell mounted');
155
+
156
+ return Object.freeze({
157
+ wm,
158
+ palette,
159
+ taxonomy,
160
+ entities,
161
+ root,
162
+ content: registry,
163
+ chrome: Object.freeze({
164
+ paletteButtonEl: paletteBtn,
165
+ topNavEl,
166
+ desktopsEl,
167
+ panelToggles: toggles,
168
+ }),
169
+ dispose: () => {
170
+ try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
171
+ },
172
+ });
173
+ }
174
+
175
+
176
+ // ══ Chrome painters ═══════════════════════════════════════════════════
177
+ // Each takes an ELEMENT (or nothing) and returns whatever the sync half
178
+ // needs. None of them queries the document.
179
+
180
+ /** One shortcut button per top-nav kind in the taxonomy, painted into the
181
+ * element the embedder gave us. Clicking opens that kind in the primary tile. */
182
+ function mountTopNav(hostEl, taxonomy, wm) {
183
+ if (!hostEl) return null;
184
+ hostEl.innerHTML = '';
185
+ hostEl.classList.add('twm-top-nav');
186
+ for (const k of taxonomy.topNavEntries()) {
187
+ const btn = document.createElement('button');
188
+ btn.className = 'twm-top-nav__btn twm-has-tooltip';
189
+ btn.dataset.kind = k.kind;
190
+ btn.dataset.tooltip = k.label;
191
+ btn.dataset.tooltipPlacement = 'bottom';
192
+ btn.setAttribute('aria-label', k.label);
193
+ btn.innerHTML = `
194
+ <span class="material-symbols-outlined twm-top-nav__icon">${k.icon}</span>
195
+ <span class="twm-top-nav__label">${k.label}</span>
196
+ `;
197
+ hostEl.appendChild(btn);
198
+ }
199
+ hostEl.addEventListener('click', (e) => {
200
+ const btn = e.target.closest('[data-kind]');
201
+ if (!btn) return;
202
+ wm.openInPrimary(btn.dataset.kind);
203
+ });
204
+ return hostEl;
205
+ }
206
+
207
+ function syncTopNav(hostEl, wm) {
208
+ if (!hostEl) return;
209
+ const tree = wm.desktops.active().tree;
210
+ const primaryId = tree.primaryLeafId();
211
+ const primaryKind = primaryId ? tree.get(primaryId)?.content?.kind : null;
212
+ // topNavFor maps an entity kind onto its owning top-nav category. Falls
213
+ // back to the primary kind so unknown kinds at least try to match a button.
214
+ const topNavKind = wm.taxonomy.topNavFor(primaryKind) || primaryKind;
215
+ hostEl.querySelectorAll('[data-kind]').forEach((b) => {
216
+ b.classList.toggle('twm-top-nav__btn--on', b.dataset.kind === topNavKind);
217
+ });
218
+ }
219
+
220
+ function mountPaletteButton(hostEl, palette) {
221
+ if (!hostEl) return null;
222
+ const existing = hostEl.querySelector('#twm-palette-btn');
223
+ if (existing) return existing;
224
+ const btn = document.createElement('button');
225
+ btn.id = 'twm-palette-btn';
226
+ btn.className = 'twm-panel-toggle-btn twm-has-tooltip';
227
+ btn.dataset.tooltip = 'Command palette (Ctrl+K)';
228
+ btn.dataset.tooltipPlacement = 'bottom';
229
+ btn.setAttribute('aria-label', 'Open command palette');
230
+ btn.innerHTML = '<span class="material-symbols-outlined">menu_open</span>';
231
+ btn.addEventListener('click', () => palette.toggle());
232
+ hostEl.insertBefore(btn, hostEl.firstChild);
233
+ return btn;
234
+ }
235
+
236
+ /**
237
+ * Take ownership of the embedder's three panel-toggle buttons.
238
+ *
239
+ * cloneNode copies any state the embedder's own machinery left on a button
240
+ * (.active/.pinned/.disabled, the disabled attr, aria-pressed). We strip it so
241
+ * the WM is the sole, clean source of truth — `syncPanelToggles` manages only
242
+ * `.panel-toggle-btn--on` from here on. The clone keeps the id, so an embedder
243
+ * that drives a toggle by `getElementById(...).click()` still works.
244
+ *
245
+ * @returns {{left?:Element, right?:Element, bottom?:Element}} the FRESH nodes
246
+ */
247
+ function bindPanelToggles(map, wm) {
248
+ const fresh = {};
249
+ for (const side of ['left', 'right', 'bottom']) {
250
+ const btn = map?.[side];
251
+ if (!btn) continue;
252
+ const el = btn.cloneNode(true);
253
+ el.classList.remove('active', 'pinned', 'disabled');
254
+ el.disabled = false;
255
+ el.removeAttribute('aria-disabled');
256
+ el.removeAttribute('aria-pressed');
257
+ btn.replaceWith(el);
258
+ el.addEventListener('click', () => wm.togglePanel(side));
259
+ fresh[side] = el;
260
+ }
261
+ return fresh;
262
+ }
263
+
264
+ function syncPanelToggles(map, wm) {
265
+ for (const side of ['left', 'right', 'bottom']) {
266
+ const btn = map?.[side];
267
+ if (!btn) continue;
268
+ btn.classList.toggle('twm-panel-toggle-btn--on', wm.isPanelOpen(side));
269
+ }
270
+ }
271
+
272
+ function mountDesktopBar(hostEl, wm) {
273
+ if (!hostEl) return null;
274
+ let el = hostEl.querySelector('#twm-desktops');
275
+ if (!el) {
276
+ el = document.createElement('div');
277
+ el.id = 'twm-desktops';
278
+ el.className = 'twm-desktops';
279
+ hostEl.appendChild(el);
280
+ }
281
+ el.addEventListener('click', (e) => {
282
+ const btn = e.target.closest('[data-desktop]');
283
+ if (!btn) return;
284
+ const v = btn.dataset.desktop;
285
+ if (v === '+') { wm.addDesktop(); syncDesktopBar(el, wm); return; }
286
+ wm.switchDesktop(Number(v));
287
+ });
288
+ // Double-click a desktop chip → inline rename. Faster than the context
289
+ // menu's Rename… for the common case. Persists via wm._persist so the new
290
+ // label survives a restart.
291
+ el.addEventListener('dblclick', async (e) => {
292
+ const btn = e.target.closest('[data-desktop]');
293
+ if (!btn || btn.dataset.desktop === '+') return;
294
+ e.preventDefault();
295
+ const idx = Number(btn.dataset.desktop);
296
+ const d = wm.desktops.desktops[idx];
297
+ if (!d) return;
298
+ const result = await openForm({
299
+ title: 'Rename desktop',
300
+ fields: [{ name: 'label', label: 'Name', type: 'text', required: true,
301
+ hint: 'Up to 24 characters. Shown in the bottom-bar chip and the Alt+N tooltip.' }],
302
+ defaults: { label: d.label },
303
+ submitLabel: 'Rename',
304
+ });
305
+ if (!result || !result.label) return;
306
+ d.label = String(result.label).slice(0, 24);
307
+ syncDesktopBar(el, wm);
308
+ wm._persist();
309
+ });
310
+ el.addEventListener('contextmenu', (e) => {
311
+ const btn = e.target.closest('[data-desktop]');
312
+ if (!btn || btn.dataset.desktop === '+') return;
313
+ e.preventDefault();
314
+ const idx = Number(btn.dataset.desktop);
315
+ const m = wm.desktops;
316
+ const d = m.desktops[idx];
317
+ const canDelete = m.desktops.length > 1;
318
+ showContextMenu(e.clientX, e.clientY, [
319
+ { label: `Switch to ${d.label}`, icon: 'desktop_windows',
320
+ action: 'switch', disabled: idx === m.activeIdx },
321
+ { label: 'Rename…', icon: 'edit', action: 'rename' },
322
+ { separator: true },
323
+ { label: 'New desktop', icon: 'add', action: 'add' },
324
+ { label: 'Delete desktop', icon: 'delete', action: 'delete',
325
+ danger: true, disabled: !canDelete },
326
+ ], async (action) => {
327
+ if (action === 'switch') wm.switchDesktop(idx);
328
+ else if (action === 'rename') {
329
+ const result = await openForm({
330
+ title: 'Rename desktop',
331
+ fields: [{ name: 'label', label: 'Name', type: 'text', required: true }],
332
+ defaults: { label: d.label },
333
+ submitLabel: 'Rename',
334
+ });
335
+ if (!result || !result.label) return;
336
+ d.label = String(result.label).slice(0, 24);
337
+ syncDesktopBar(el, wm);
338
+ wm._persist();
339
+ }
340
+ else if (action === 'add') { wm.addDesktop(); syncDesktopBar(el, wm); }
341
+ else if (action === 'delete') { wm.removeDesktop(idx); syncDesktopBar(el, wm); }
342
+ });
343
+ });
344
+ return el;
345
+ }
346
+
347
+ function syncDesktopBar(el, wm) {
348
+ if (!el) return;
349
+ const m = wm.desktops;
350
+ el.innerHTML =
351
+ m.desktops.map((d, i) => `
352
+ <button class="twm-desk__btn${i === m.activeIdx ? ' twm-desk__btn--on' : ''}"
353
+ data-desktop="${i}"
354
+ title="Desktop ${d.label} (Alt+${i + 1}) · right-click to delete">
355
+ ${d.label}
356
+ </button>`).join('')
357
+ + `<button class="twm-desk__btn twm-desk__btn--add"
358
+ data-desktop="+" title="Add desktop">+</button>`;
359
+ }
360
+
361
+
362
+ // ══ Tile menus ════════════════════════════════════════════════════════
363
+
364
+ /** Hamburger button in a tile's tab bar. Lists the same content the tile's
365
+ * home page would, with search + pagination. Clicking a row opens the entity
366
+ * in a NEW TAB on the ORIGINATING tile (the leaf id is captured at click
367
+ * time, so a focus change between click and pick doesn't reroute it). */
368
+ function _tileTabMenu(wm, leafId, x, y) {
369
+ const tree = wm.desktops.active().tree;
370
+ const leaf = tree.get(leafId);
371
+ if (!leaf) return;
372
+ const kind = leaf.content?.kind || wm.taxonomy.root;
373
+ openTileTabMenu({
374
+ x, y, leafKind: kind, api: wm.api,
375
+ taxonomy: wm.taxonomy, entities: wm.ctx.entities,
376
+ onPick: (navKind, shaped) => {
377
+ tree.appendLeafTab(leafId, {
378
+ kind: navKind,
379
+ props: { id: shaped.id, label: shaped.label },
380
+ }, shaped.label || shaped.id);
381
+ tree.focus(leafId);
382
+ wm.renderer.render();
383
+ wm._persist?.();
384
+ wm._notifyChange?.('tab-open-from-menu');
385
+ },
386
+ });
387
+ }
388
+
389
+ /** Right-click on a tile's chrome strip: structural actions on that leaf. */
390
+ function _tileContextMenu(wm, leafId, x, y) {
391
+ const tree = wm.desktops.active().tree;
392
+ const leaf = tree.get(leafId);
393
+ if (!leaf) return;
394
+ const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
395
+ tree.focus(leafId);
396
+ wm.renderer._updateFocusClasses();
397
+
398
+ const items = [
399
+ { label: 'Split horizontally', icon: 'splitscreen_vertical_add', action: 'split-h' },
400
+ { label: 'Split vertically', icon: 'splitscreen_add', action: 'split-v' },
401
+ { separator: true },
402
+ { label: 'Open in new tab', icon: 'tab',
403
+ action: 'open-tab', disabled: isPanel || !leaf.content },
404
+ { label: 'Open in new window', icon: 'open_in_full',
405
+ action: 'open-window', disabled: isPanel || !leaf.content },
406
+ { label: 'Promote to window', icon: 'open_in_new',
407
+ action: 'promote', disabled: isPanel || !leaf.content },
408
+ ];
409
+ if (wm.desktops.desktops.length > 1 && !isPanel) {
410
+ for (const [i, d] of wm.desktops.desktops.entries()) {
411
+ if (i === wm.desktops.activeIdx) continue;
412
+ items.push({ label: `Move to desktop ${d.label}`, icon: 'sweep',
413
+ action: `move:${i}` });
414
+ }
415
+ }
416
+ items.push({ separator: true });
417
+ items.push({ label: 'Close tile', icon: 'close', action: 'close',
418
+ danger: true, disabled: isPanel });
419
+
420
+ showContextMenu(x, y, items, (action) => {
421
+ if (action === 'split-h') wm.split('h');
422
+ else if (action === 'split-v') wm.split('v');
423
+ else if (action === 'open-tab') {
424
+ const c = leaf.content;
425
+ if (c) wm.openInTabFromContext({ leafId }, c.kind, c.props || {});
426
+ }
427
+ else if (action === 'open-window') {
428
+ const c = leaf.content;
429
+ if (c) wm.navigate(c.kind, c.props || {}, { target: 'window' });
430
+ }
431
+ else if (action === 'promote') wm.toggleManagedFocused();
432
+ else if (action === 'close') wm.closeFocused();
433
+ else if (action?.startsWith?.('move:')) {
434
+ wm.moveFocusedToDesktop(Number(action.slice(5)));
435
+ }
436
+ });
437
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * tile_breadcrumb.js — per-tile breadcrumb strip rendered above each
3
+ * page's content.
4
+ *
5
+ * Reuses the existing `topbar-breadcrumb__*` CSS classes so the look
6
+ * matches the rest of the app (notebook editor, etc.). Segments are
7
+ * built from the leaf's content kind + props.id, and clicking a
8
+ * non-current segment routes through wm.navigate so the user jumps to
9
+ * that level.
10
+ *
11
+ * The path is assembled from FOUR taxonomy questions, none of which this
12
+ * module answers itself:
13
+ *
14
+ * 1. `ctx.rootCrumb` the leading segment + how its label stays fresh
15
+ * 2. `taxonomy.topNavFor` which section owns the kind
16
+ * 3. `taxonomy.ancestors` the entity-level chain above ONE instance
17
+ * 4. `taxonomy.labelOf` what to call that instance
18
+ *
19
+ * (3) is the one that matters. Deriving an instance's parent from its id
20
+ * is a naming convention, and naming conventions belong to the embedder;
21
+ * this file used to hardcode two of them.
22
+ */
23
+
24
+ /** Build a breadcrumb strip element + render initial state. Returns
25
+ * `{ el, destroy }`. The strip is wired to the WM (clicks open content
26
+ * in the primary tile) and, when the embedder supplies a `rootCrumb`
27
+ * with a `subscribe` hook, to whatever keeps the root label fresh. */
28
+ export function mountTileBreadcrumb(kind, props, ctx) {
29
+ const eventBus = ctx?.eventBus;
30
+ const taxonomy = ctx?.taxonomy;
31
+ const rootCrumb = ctx?.rootCrumb || null;
32
+ if (!taxonomy) {
33
+ console.error('[breadcrumb] no taxonomy in ctx — cannot render');
34
+ return { el: document.createElement('nav'), destroy: () => {} };
35
+ }
36
+ const root = document.createElement('nav');
37
+ root.className = 'twm-topbar-breadcrumb twm-tile-breadcrumb';
38
+ root.setAttribute('aria-label', 'Tile breadcrumb');
39
+
40
+ // Look up wm late (at click time) instead of capturing it once at
41
+ // mount. ctx.wm should always be set by the renderer, but fall
42
+ // back to the global escape hatch (set by install.js after
43
+ // wm.load()) so a closure that lost ctx.wm — or a mount called
44
+ // before the renderer threaded wm through — still navigates.
45
+ const getWm = () => ctx?.wm || window.__twm?.wm || null;
46
+
47
+ // Breadcrumb segments route through the canonical `wm.navigate`
48
+ // helper so the same three-axis routing (window → replace window,
49
+ // tile → replace active tab, panel/none → primary) lives in one
50
+ // place. Critically, this is what makes the breadcrumb work in
51
+ // WINDOWED mode — passing `ctx` keeps windowId in scope so the
52
+ // segment click stays inside the floating window instead of
53
+ // jumping back to the primary tile.
54
+ const navigate = (k, p = {}) => {
55
+ const wm = getWm();
56
+ if (!wm) {
57
+ console.error('[breadcrumb] no WM available — click ignored', k);
58
+ return;
59
+ }
60
+ if (typeof wm.navigate === 'function') {
61
+ wm.navigate(k, p, { ctx, dest: 'origin' });
62
+ } else if (typeof wm.openInPrimary === 'function') {
63
+ // Fallback for older WM revisions.
64
+ wm.openInPrimary(k, p);
65
+ } else {
66
+ console.error('[breadcrumb] WM has no navigate/openInPrimary');
67
+ }
68
+ };
69
+
70
+ // The root segment's label is the embedder's to supply and to keep
71
+ // fresh (it names whatever the root entity is — a project, a
72
+ // workspace, a document). Absent `rootCrumb` ⇒ no root segment, and
73
+ // the breadcrumb simply starts at the top-nav.
74
+ let rootLabel = rootCrumb?.label || '';
75
+
76
+ const render = () => {
77
+ _renderInto(root, _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate));
78
+ };
79
+ render();
80
+
81
+ let unsubscribe = null;
82
+ if (typeof rootCrumb?.subscribe === 'function') {
83
+ try {
84
+ unsubscribe = rootCrumb.subscribe((next) => {
85
+ if (next?.label) rootLabel = next.label;
86
+ render();
87
+ }, { api: ctx?.api, host: ctx?.host, eventBus });
88
+ } catch (err) {
89
+ console.warn('[breadcrumb] rootCrumb.subscribe threw', err);
90
+ }
91
+ }
92
+
93
+ return {
94
+ el: root,
95
+ destroy: () => {
96
+ try { unsubscribe?.(); }
97
+ catch (err) { console.warn('[breadcrumb] rootCrumb teardown threw', err); }
98
+ },
99
+ };
100
+ }
101
+
102
+ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
103
+ const segs = [];
104
+ const meta = taxonomy.meta(kind);
105
+
106
+ // App-global kinds (e.g. a preferences page) aren't scoped to the
107
+ // root entity — they open with no project loaded and persist to
108
+ // app-level storage — so their breadcrumb has no root ancestor.
109
+ if (meta?.appGlobal) {
110
+ return [{ icon: meta.icon, label: meta.label, onClick: null }];
111
+ }
112
+
113
+ // 1. The root segment → clicks back to the root page.
114
+ if (rootCrumb) {
115
+ segs.push({
116
+ icon: rootCrumb.icon,
117
+ label: rootLabel || rootCrumb.label,
118
+ onClick: () => navigate(rootCrumb.navKind ?? taxonomy.root),
119
+ });
120
+ }
121
+
122
+ // 2. The top-nav segment. Skipped on the root itself (the root
123
+ // segment already names it) and when the kind isn't in the
124
+ // taxonomy at all.
125
+ const topNav = taxonomy.topNavFor(kind);
126
+ const topNavMeta = topNav ? taxonomy.meta(topNav) : null;
127
+ if (topNav && topNav !== taxonomy.root && topNavMeta) {
128
+ segs.push({
129
+ icon: topNavMeta.icon,
130
+ label: topNavMeta.label,
131
+ onClick: () => navigate(topNav),
132
+ });
133
+ }
134
+
135
+ // 3. Entity-level ancestors, root-most first — supplied by the
136
+ // taxonomy, not by string surgery on the id.
137
+ for (const anc of taxonomy.ancestors(kind, props)) {
138
+ segs.push({
139
+ icon: taxonomy.meta(anc.kind)?.icon || 'description',
140
+ label: anc.props.label ?? anc.props.id,
141
+ onClick: () => navigate(anc.kind, anc.props),
142
+ });
143
+ }
144
+
145
+ // 4. The node itself.
146
+ const hasEntity = !!props?.id;
147
+ if (meta) {
148
+ if (hasEntity) {
149
+ segs.push({
150
+ icon: meta.icon,
151
+ label: taxonomy.labelOf(kind, props),
152
+ onClick: null,
153
+ });
154
+ } else if (kind !== topNav) {
155
+ // The kind IS the top-nav (e.g. opening a landing page).
156
+ // Don't duplicate the chip — the segment above already
157
+ // names it.
158
+ segs.push({
159
+ icon: meta.icon,
160
+ label: meta.label,
161
+ onClick: null,
162
+ });
163
+ }
164
+ } else if (hasEntity) {
165
+ // Kind isn't in the taxonomy yet — still surface the entity
166
+ // name so the user sees where they are, just without an icon.
167
+ segs.push({
168
+ icon: 'description',
169
+ label: props.label || props.id,
170
+ onClick: null,
171
+ });
172
+ }
173
+ return segs;
174
+ }
175
+
176
+ function _renderInto(container, segments) {
177
+ container.innerHTML = '';
178
+ if (!segments?.length) return;
179
+ const ol = document.createElement('ol');
180
+ ol.className = 'twm-topbar-breadcrumb__list';
181
+ segments.forEach((seg, i) => {
182
+ const li = document.createElement('li');
183
+ li.className = 'twm-topbar-breadcrumb__item';
184
+ const isLast = i === segments.length - 1;
185
+ if (isLast || !seg.onClick) {
186
+ li.classList.add('twm-topbar-breadcrumb__item--current');
187
+ li.innerHTML = `
188
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc(seg.icon)}</span>
189
+ <span class="twm-topbar-breadcrumb__current">${_esc(seg.label)}</span>
190
+ `;
191
+ } else {
192
+ const btn = document.createElement('button');
193
+ btn.className = 'twm-topbar-breadcrumb__link';
194
+ btn.type = 'button';
195
+ btn.innerHTML = `
196
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc(seg.icon)}</span>
197
+ <span>${_esc(seg.label)}</span>
198
+ `;
199
+ btn.addEventListener('click', () => {
200
+ btn.classList.add('twm-tile-breadcrumb__link--flash');
201
+ setTimeout(() => {
202
+ btn.classList.remove('twm-tile-breadcrumb__link--flash');
203
+ }, 180);
204
+ try { seg.onClick(); }
205
+ catch (e) { console.error('[breadcrumb] segment click threw', e); }
206
+ });
207
+ li.appendChild(btn);
208
+ const sep = document.createElement('span');
209
+ sep.className = 'twm-topbar-breadcrumb__separator';
210
+ sep.textContent = '›';
211
+ li.appendChild(sep);
212
+ }
213
+ ol.appendChild(li);
214
+ });
215
+ container.appendChild(ol);
216
+ }
217
+
218
+ function _esc(s) {
219
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
220
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
221
+ }[c]));
222
+ }