flexdesk 0.2.0 → 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 (58) hide show
  1. package/README.md +26 -0
  2. package/css/base.css +2243 -872
  3. package/css/flexdesk.css +1375 -14
  4. package/css/overrides.css +44 -0
  5. package/css/tokens.css +45 -0
  6. package/dist/charts.js +5 -3
  7. package/dist/charts.js.map +1 -1
  8. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  9. package/dist/chunk-ELXVW542.js.map +7 -0
  10. package/dist/chunk-LH5TSOZW.js +1237 -0
  11. package/dist/chunk-LH5TSOZW.js.map +7 -0
  12. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  13. package/dist/chunk-O5OHMWBB.js.map +7 -0
  14. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  15. package/dist/chunk-QIU5S2RU.js.map +7 -0
  16. package/dist/chunk-QNQHQ24V.js +408 -0
  17. package/dist/chunk-QNQHQ24V.js.map +7 -0
  18. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  19. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  20. package/dist/editor.js +3 -380
  21. package/dist/editor.js.map +3 -3
  22. package/dist/flexdesk.css +1375 -14
  23. package/dist/tiles.js +168 -41
  24. package/dist/tiles.js.map +2 -2
  25. package/dist/tokens.css +45 -0
  26. package/dist/widgets.js +44 -14
  27. package/dist/widgets.js.map +2 -2
  28. package/dist/wm.js +3140 -157
  29. package/dist/wm.js.map +4 -4
  30. package/package.json +3 -2
  31. package/src/charts/chart_types.js +167 -0
  32. package/src/charts/plotly_wrapper.js +178 -10
  33. package/src/editor/notebook_tab_bar.js +39 -3
  34. package/src/tiles/tile_base.js +143 -35
  35. package/src/tiles/tile_grid.js +52 -1
  36. package/src/tiling/command_palette.js +71 -18
  37. package/src/tiling/desktops.js +36 -12
  38. package/src/tiling/keymap.js +24 -4
  39. package/src/tiling/shell.js +156 -25
  40. package/src/tiling/tab_strip.js +184 -0
  41. package/src/tiling/tile_breadcrumb.js +34 -2
  42. package/src/tiling/tile_renderer.js +1386 -21
  43. package/src/tiling/tile_tab_menu.js +101 -0
  44. package/src/tiling/tile_tree.js +115 -11
  45. package/src/tiling/wm.js +2375 -84
  46. package/src/tiling/zoom.js +248 -0
  47. package/src/ui/components/action_dropdown.js +34 -3
  48. package/src/ui/components/autocomplete_field.js +65 -13
  49. package/src/ui/components/context_menu.js +79 -8
  50. package/src/ui/components/data_table.js +508 -84
  51. package/src/ui/components/managed_window.js +928 -36
  52. package/src/ui/components/modal.js +214 -8
  53. package/dist/chunk-CT4YXXLP.js.map +0 -7
  54. package/dist/chunk-DRYCDMEG.js.map +0 -7
  55. package/dist/chunk-DVU44T77.js.map +0 -7
  56. package/dist/chunk-TLZUUFOE.js.map +0 -7
  57. package/dist/chunk-UCJ2WD4D.js +0 -625
  58. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -43,7 +43,8 @@ import { WindowManager } from './wm.js';
43
43
  import { createContentRegistry } from './content_registry.js';
44
44
  import { createCommandPalette } from './command_palette.js';
45
45
  import { installKeymap } from './keymap.js';
46
- import { openTileTabMenu } from './tile_tab_menu.js';
46
+ import { openTileTabSwitcher } from './tile_tab_menu.js';
47
+ import { mountZoomControl } from './zoom.js';
47
48
  import { showContextMenu } from '../ui/components/context_menu.js';
48
49
  import { openForm } from '../ui/components/modal.js';
49
50
 
@@ -64,9 +65,45 @@ import { openForm } from '../ui/components/modal.js';
64
65
  * @param {object} [cfg.tableStore] threaded into ctx for table persistence
65
66
  * @param {object} [cfg.events] bus-event NAMES the WM listens for
66
67
  * @param {object} [cfg.rootCrumb] the breadcrumb's leading segment
67
- * @param {object} [cfg.palette] { placeholder }
68
+ * @param {object} [cfg.palette] { placeholder, onPick } — C30. `onPick`
69
+ * is `(pick) => truthy` and CLAIMS the
70
+ * open of a picked entity, so an embedder
71
+ * with a rule about where its own entities
72
+ * belong applies that rule whichever door
73
+ * was used. Unclaimed picks still reset
74
+ * the primary tile: the behaviour every
75
+ * embedder has today.
76
+ * @param {object} [cfg.panels] C14. Which panel TILES a fresh desktop
77
+ * opens with — `{left, right, bottom}`,
78
+ * merged over all-three-open. An embedder
79
+ * whose navigator is its own chrome passes
80
+ * `{left: false, right: false}` and gets no
81
+ * tile it never registered a factory for.
82
+ * @param {boolean} [cfg.promoteInPlace] C21. Whether a window promoted out
83
+ * of a tile stays confined to that pane
84
+ * rather than floating over the whole root.
85
+ * @param {boolean} [cfg.snapPromotion] C15. Whether dragging a promoted
86
+ * window onto a tile puts it back in the
87
+ * tree. Default off — it changes what a
88
+ * drag to an edge does.
89
+ * @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
90
+ * tabs. `'bottom'` is the framework's own
91
+ * spreadsheet strip under the tile body
92
+ * and is the DEFAULT, so no existing
93
+ * embedder's panes rearrange on upgrade;
94
+ * `'top'` mounts the editor tab bar
95
+ * between the chrome and the body. The
96
+ * renderer mirrors this onto `root` as
97
+ * `data-twm-tabs` and watches it, so an
98
+ * embedder whose settings pane holds only
99
+ * the root element can change it live.
68
100
  * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
69
- * panelToggles?: { left?, right?, bottom? } }
101
+ * panelToggles?: { left?, right?, bottom? },
102
+ * zoom? }
103
+ * `zoom` is C31: the element the content
104
+ * zoom control is painted into — see
105
+ * zoom.js. Absent, there is no control and
106
+ * nothing is ever scaled.
70
107
  * @returns {Promise<object>} the frozen shell
71
108
  */
72
109
  export async function createShell({
@@ -82,7 +119,15 @@ export async function createShell({
82
119
  events = {},
83
120
  rootCrumb = null,
84
121
  palette: paletteCfg = {},
122
+ panels = null,
123
+ snapPromotion = false,
124
+ promoteInPlace = false,
125
+ tabLayout = null,
85
126
  chrome = {},
127
+ // An embedder that moved its sections out of the top bar — into an icon
128
+ // rail, say — passes the selector its own buttons match, and F1..F8 keep
129
+ // working. Omitted, the default top-bar selector applies.
130
+ navSelector = null,
86
131
  } = {}) {
87
132
  // A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
88
133
  // createHost / createTaxonomy.
@@ -115,6 +160,10 @@ export async function createShell({
115
160
  host,
116
161
  taxonomy,
117
162
  events,
163
+ panelDefaults: panels,
164
+ snapPromotion,
165
+ promoteInPlace,
166
+ tabLayout,
118
167
  // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
119
168
  // spreads it into every content factory, which is how the breadcrumb
120
169
  // gets `taxonomy` + `rootCrumb` without a content factory knowing they
@@ -132,13 +181,23 @@ export async function createShell({
132
181
  const palette = createCommandPalette({
133
182
  wm, api, taxonomy, catalog: entities, ...paletteCfg,
134
183
  });
135
- installKeymap({ wm, palette });
184
+ // `navSelector` lets an embedder that moved its sections out of the top
185
+ // bar keep F1..F8 working. Omitted, the default top-bar selector applies —
186
+ // the behaviour every existing embedder has today.
187
+ const disposeKeymap = installKeymap({ wm, palette, navSelector });
136
188
 
137
189
  // Chrome — painted into the ELEMENTS the embedder handed over. An absent
138
190
  // key means absent chrome, not a crash.
139
191
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
140
192
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
141
193
  desktopsEl = mountDesktopBar(chrome.desktops, wm);
194
+ // C31. The content zoom. Painted with the rest of the chrome and, unlike the
195
+ // rest, AWAITED before the first mount: its saved value is applied to `root`
196
+ // as a CSS variable, and a shell that mounted first would paint every tile at
197
+ // 100% and then visibly jump to the user's zoom a moment later. A host with no
198
+ // saved zoom — or no `state` at all — resolves immediately to the default.
199
+ const zoom = mountZoomControl(chrome.zoom, { root, host });
200
+ if (zoom) await zoom.ready;
142
201
  // NOTE: bindPanelToggles CLONES the buttons (to strip whatever state the
143
202
  // embedder's own machinery left on them) and returns the FRESH nodes. The
144
203
  // originals are detached from here on — sync against the returned map, not
@@ -165,9 +224,31 @@ export async function createShell({
165
224
  topNavEl,
166
225
  desktopsEl,
167
226
  panelToggles: toggles,
227
+ // `get`/`set` so an embedder can drive the zoom from its own settings
228
+ // pane, or read it, without reaching into the control's DOM.
229
+ zoom,
168
230
  }),
231
+ // A shell that can be built can be built TWICE — an embedder that
232
+ // rebuilds on a context change (a different project, a different
233
+ // workspace) does exactly that. Everything this function installs
234
+ // outside `root` has to come off, or the second shell shares the page
235
+ // with the first one's keyboard.
169
236
  dispose: () => {
170
237
  try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
238
+ try { disposeKeymap?.(); } catch { /* ignore */ }
239
+ try { palette?.close?.(); } catch { /* ignore */ }
240
+ // Every content factory gets its `destroy()`. Clearing the root
241
+ // element would detach the DOM and tell none of them, so an ERD's
242
+ // `window` keydown listener or a pane's interval would outlive the
243
+ // shell that mounted it. This also stops the renderer painting, so
244
+ // anything still holding a reference to this wm — a callback
245
+ // captured before a rebuild, a promise that has not settled —
246
+ // cannot repaint a dead tree into a root the live shell now owns.
247
+ try { wm.renderer.destroy(); } catch (err) { log.warn?.('renderer teardown', err); }
248
+ // The zoom lives on `root` as a variable and a class. A rebuilt shell on
249
+ // the same root must not inherit the old one's scale with no control on
250
+ // screen to change it, so dispose puts the root back to 100%.
251
+ try { zoom?.dispose(); } catch { /* ignore */ }
171
252
  },
172
253
  });
173
254
  }
@@ -217,10 +298,23 @@ function syncTopNav(hostEl, wm) {
217
298
  });
218
299
  }
219
300
 
301
+ /**
302
+ * The palette button, REBOUND rather than reused.
303
+ *
304
+ * This used to return an existing `#twm-palette-btn` untouched, which is right
305
+ * only while a page mounts one shell and keeps it. An embedder that rebuilds
306
+ * its shell — Tables does, on every project switch — got its FIRST shell's
307
+ * button back, still wired to the FIRST shell's palette. Clicking it opened a
308
+ * palette over a disposed window manager, and picking a result called
309
+ * `openInPrimary` on a tree nobody could see, whose renderer then painted it
310
+ * into the root the live shell now owns.
311
+ *
312
+ * So the stale node is replaced, which is exactly what `bindPanelToggles` does
313
+ * one function below and for the same reason.
314
+ */
220
315
  function mountPaletteButton(hostEl, palette) {
221
316
  if (!hostEl) return null;
222
- const existing = hostEl.querySelector('#twm-palette-btn');
223
- if (existing) return existing;
317
+ hostEl.querySelector('#twm-palette-btn')?.remove();
224
318
  const btn = document.createElement('button');
225
319
  btn.id = 'twm-palette-btn';
226
320
  btn.className = 'twm-panel-toggle-btn twm-has-tooltip';
@@ -361,27 +455,27 @@ function syncDesktopBar(el, wm) {
361
455
 
362
456
  // ══ Tile menus ════════════════════════════════════════════════════════
363
457
 
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). */
458
+ /** Hamburger button in a tile's tab bar. Opens a compact menu anchored to
459
+ * the button that lists the leaf's OPEN TABS for quick switching (a
460
+ * browser-style tab overflow list). Clicking a row activates that tab.
461
+ * The leaf id is captured at click time, so a focus change between click
462
+ * and pick doesn't reroute it. */
368
463
  function _tileTabMenu(wm, leafId, x, y) {
369
464
  const tree = wm.desktops.active().tree;
370
465
  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);
466
+ if (!leaf || leaf.kind !== 'leaf') return;
467
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
468
+ if (tabs.length === 0) return;
469
+ openTileTabSwitcher({
470
+ x, y,
471
+ tabs,
472
+ activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)),
473
+ onPick: (idx) => {
474
+ tree.setActiveLeafTab(leafId, idx);
381
475
  tree.focus(leafId);
382
476
  wm.renderer.render();
383
477
  wm._persist?.();
384
- wm._notifyChange?.('tab-open-from-menu');
478
+ wm._notifyChange?.('tab-switch-from-menu');
385
479
  },
386
480
  });
387
481
  }
@@ -401,10 +495,36 @@ function _tileContextMenu(wm, leafId, x, y) {
401
495
  { separator: true },
402
496
  { label: 'Open in new tab', icon: 'tab',
403
497
  action: 'open-tab', disabled: isPanel || !leaf.content },
404
- { label: 'Open in new window', icon: 'open_in_full',
498
+ // TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
499
+ // window INSIDE the application — the same glyph `ManagedWindow` uses
500
+ // for itself — and an embedder that can also send content to a real
501
+ // browser window keeps `open_in_new`, which is the universal "this
502
+ // leaves the page". One glyph for both is how a user learns that the
503
+ // two commands are the same command, and then loses a window looking
504
+ // for it on the other screen.
505
+ { label: 'Open a copy in a window', icon: 'web_asset',
405
506
  action: 'open-window', disabled: isPanel || !leaf.content },
406
- { label: 'Promote to window', icon: 'open_in_new',
407
- action: 'promote', disabled: isPanel || !leaf.content },
507
+ // C20, THE OTHER HALF and it was missing while the `close` half
508
+ // below carried a paragraph explaining why it could not be.
509
+ //
510
+ // `_floatableLeaf` (`wm.js`) refuses to float content that declared
511
+ // `chrome: { promote: false }`, and every door converges there — so
512
+ // this row offered the verb, enabled, and returned null. The chrome's
513
+ // own float BUTTON does not have the problem: C20 removes it from the
514
+ // strip. That asymmetry is what hid this: the affordance the reader
515
+ // checks is correct, and the menu one layer down is not.
516
+ //
517
+ // `=== false` EXACTLY, because that is the test the verb makes
518
+ // (`wm.js`, `_floatableLeaf`: *"content that says nothing about
519
+ // `promote` stays floatable"*). A falsy test here would grey the row
520
+ // on every leaf whose content returned no `chrome` at all, which is
521
+ // most of them — a menu disagreeing with its verb in the generous
522
+ // direction is a dead control; in the mean direction it is a missing
523
+ // feature, and this file has shipped one of each.
524
+ { label: 'Float this pane as a window', icon: 'web_asset',
525
+ action: 'promote',
526
+ disabled: isPanel || !leaf.content
527
+ || wm.renderer?.leafChrome?.(leafId)?.promote === false },
408
528
  ];
409
529
  if (wm.desktops.desktops.length > 1 && !isPanel) {
410
530
  for (const [i, d] of wm.desktops.desktops.entries()) {
@@ -414,8 +534,19 @@ function _tileContextMenu(wm, leafId, x, y) {
414
534
  }
415
535
  }
416
536
  items.push({ separator: true });
537
+ // C20. THE MENU ROW CARRIES THE VETO, AND THE REASON WITH IT.
538
+ //
539
+ // `closeFocused` refuses when the content vetoed `close`, which is what
540
+ // makes the greyed × in the chrome honest — and left this row offering the
541
+ // same verb, enabled, doing nothing. A control that silently no-ops is the
542
+ // dead-control failure this file has fixed three times already. The
543
+ // tooltip is the content's own sentence, so the two doors explain the
544
+ // refusal identically rather than one explaining it and one not.
545
+ const closeVeto = wm.renderer?.leafChrome?.(leafId)?.close;
546
+ const closeVetoed = closeVeto === false || closeVeto?.disabled === true;
417
547
  items.push({ label: 'Close tile', icon: 'close', action: 'close',
418
- danger: true, disabled: isPanel });
548
+ danger: true, disabled: isPanel || closeVetoed,
549
+ title: closeVetoed ? (closeVeto?.title || undefined) : undefined });
419
550
 
420
551
  showContextMenu(x, y, items, (action) => {
421
552
  if (action === 'split-h') wm.split('h');
@@ -0,0 +1,184 @@
1
+ /**
2
+ * tab_strip.js — ONE tab strip, over one ordered list of tab specs.
3
+ *
4
+ * ── Why this is a module and not a method ────────────────────────────────
5
+ *
6
+ * C22 taught `tile_renderer.js` to draw a leaf's tabs with `NotebookTabBar`,
7
+ * the editor's strip, and that code is not really about leaves: it is about
8
+ * translating between a component whose vocabulary is FILE PATHS and a list of
9
+ * `{kind, props, title}` that has no identity of its own. R8 needs exactly that
10
+ * translation a second time — a pane floated into a window takes its tab strip
11
+ * with it, because the pane IS its tabs and a window showing only one of them
12
+ * is not the pane — and the strip in the window is not attached to a leaf at
13
+ * all. Its tabs live in the WM's window record.
14
+ *
15
+ * So the translation moves here, where neither side owns it, and both sides
16
+ * reach it the same way. A second implementation of it in `wm.js` would be a
17
+ * second copy of the four corrections below, and they would drift apart
18
+ * silently: none of them is a syntax error and none throws at import.
19
+ *
20
+ * ── The four corrections, and why each one exists ────────────────────────
21
+ *
22
+ * 1. IDENTITY. `NotebookTabBar` keys everything — its reorder, its active
23
+ * comparison, its rename selector — on a file path. A tab list has no ids
24
+ * (the tree gives tabs none and neither does a window record), so the
25
+ * INDEX serves, and it is the one identity such a list is guaranteed to
26
+ * have. The `builtin://` prefix is not decoration: `#beginInlineRename`
27
+ * refuses to start on a path carrying it (`notebook_tab_bar.js:402`),
28
+ * which is what a tab needs — double-click rename would rename a TAB, and
29
+ * a tab is a view of an entity whose name lives somewhere neither caller
30
+ * can reach.
31
+ * 2. THE GLYPH. The component picks one out of a static map of FILE kinds.
32
+ * Ours are the embedder's kinds and the taxonomy already answers for them.
33
+ * `setFileTypeIcons()` is not the way: it is STATIC and REPLACES the whole
34
+ * map, so a page that also uses the editor would find its own file icons
35
+ * deleted by whichever of the two rendered last.
36
+ * 3. THE TOOLTIP. Its title attribute is the file PATH — ours would read
37
+ * `builtin://tab/3`, i.e. a tab advertising its own array index.
38
+ * 4. ONE TAB CONTEXT MENU IN THE APPLICATION, not two. The component opens
39
+ * its own `.nb-context-menu` whose verbs are a notebook's (rename,
40
+ * duplicate, reveal in explorer). The caller has a menu already; the
41
+ * component's is suppressed in the CAPTURE phase, because its handler is
42
+ * bound on each tab element in the bubble phase and stopping the event on
43
+ * the way DOWN is what keeps its menu from opening at all.
44
+ *
45
+ * The class names touched here are the component's published contract, stated
46
+ * in its own header comment.
47
+ */
48
+
49
+ import { NotebookTabBar } from '../editor/notebook_tab_bar.js';
50
+
51
+ /** A tab's identity, for a component that only understands paths. See (1). */
52
+ export function tabKey(idx) { return `builtin://tab/${idx}`; }
53
+
54
+ /** …and back. `-1` for anything that is not one of ours. */
55
+ export function tabKeyIndex(key) {
56
+ const n = Number(String(key ?? '').replace('builtin://tab/', ''));
57
+ return Number.isInteger(n) && n >= 0 ? n : -1;
58
+ }
59
+
60
+ /**
61
+ * DRAG-TO-REORDER ARRIVES AS A PERMUTATION, and every tab model here moves ONE
62
+ * tab at a time (`TileTree.moveLeafTab(leafId, from, to)`, and the window
63
+ * record's own splice). They reconcile because a drag only ever moves one
64
+ * element: every other key shifts by exactly one place, so the element that
65
+ * travelled furthest between the two orders IS the one that was dragged.
66
+ *
67
+ * @returns {{from: number, to: number}|null} null when the order is not a
68
+ * permutation of `count` tabs, or when nothing actually moved.
69
+ */
70
+ export function reorderToMove(order, count) {
71
+ if (!Array.isArray(order) || order.length !== count) return null;
72
+ let from = -1;
73
+ let to = -1;
74
+ let furthest = 0;
75
+ order.forEach((key, newIdx) => {
76
+ const oldIdx = tabKeyIndex(key);
77
+ if (oldIdx < 0) return;
78
+ const travelled = Math.abs(newIdx - oldIdx);
79
+ if (travelled > furthest) { furthest = travelled; from = oldIdx; to = newIdx; }
80
+ });
81
+ if (from < 0 || from === to) return null;
82
+ return { from, to };
83
+ }
84
+
85
+ /**
86
+ * Mount a tab strip into `hostEl` and drive it from a plain tab list.
87
+ *
88
+ * Every gesture leaves through ONE callback with the SAME vocabulary the tile
89
+ * renderer's two layouts already use — `switch` / `close` / `move` / `menu` —
90
+ * so a caller wires one function and the WM's tree mutations, its persistence
91
+ * and its change notifications stay reachable by one path from every strip in
92
+ * the application.
93
+ *
94
+ * @param {object} cfg
95
+ * @param {HTMLElement} cfg.hostEl the bar element. The strip mounts into a
96
+ * CHILD of it, because `mount()` assigns `container.className = 'tabs
97
+ * notebook-tabs'` (`notebook_tab_bar.js:61`) — handing it the bar would take
98
+ * that class, and with it the bar's height, background and `--hidden`, off
99
+ * the element the caller still controls.
100
+ * @param {object} [cfg.taxonomy] asked for each kind's glyph. Absent ⇒ the
101
+ * glyph is hidden rather than wrong.
102
+ * @param {(action: 'switch'|'close'|'move'|'menu', data: object) => void} cfg.onAction
103
+ * @returns {{update: (tabs: object[], activeIdx: number) => void, dispose: () => void}}
104
+ */
105
+ export function createTabStrip({ hostEl, taxonomy = null, onAction }) {
106
+ const host = document.createElement('div');
107
+ hostEl.appendChild(host);
108
+ const strip = new NotebookTabBar();
109
+ let tabs = [];
110
+
111
+ strip.mount(host, {
112
+ onActivate: (key) => {
113
+ const idx = tabKeyIndex(key);
114
+ if (idx >= 0) onAction?.('switch', { idx });
115
+ },
116
+ onClose: (key) => {
117
+ const idx = tabKeyIndex(key);
118
+ if (idx >= 0) onAction?.('close', { idx });
119
+ },
120
+ onReorder: (order) => {
121
+ const move = reorderToMove(order, tabs.length);
122
+ if (move) onAction?.('move', move);
123
+ },
124
+ // Everything else the component offers that a tab here cannot honour is
125
+ // deliberately absent rather than stubbed — `onRename` is refused by the
126
+ // `builtin://` key, and the rest (`onDuplicate`, `onSplitRight`,
127
+ // `onRevealInExplorer`, `onCloseAll`, …) are only ever reached from the
128
+ // context menu suppressed below.
129
+ });
130
+
131
+ // See (4). Capture phase, on the container.
132
+ const onContextMenu = (ev) => {
133
+ const tabEl = ev.target.closest?.('.tab');
134
+ if (!tabEl) return;
135
+ ev.preventDefault();
136
+ ev.stopPropagation();
137
+ const idx = Array.prototype.indexOf.call(host.querySelectorAll('.tab'), tabEl);
138
+ if (idx < 0) return;
139
+ onAction?.('menu', { idx, x: ev.clientX, y: ev.clientY });
140
+ };
141
+ host.addEventListener('contextmenu', onContextMenu, true);
142
+
143
+ /** See (2) and (3) — one pass over the DOM the component just wrote. */
144
+ const repaint = () => {
145
+ host.querySelectorAll('.tab').forEach((el, i) => {
146
+ const spec = tabs[i];
147
+ if (!spec) return;
148
+ el.title = spec.title || spec.kind || '';
149
+ const icon = spec.kind ? taxonomy?.meta?.(spec.kind)?.icon : null;
150
+ const glyph = el.querySelector('.tab-icon');
151
+ if (!glyph) return;
152
+ if (icon) { glyph.textContent = icon; glyph.hidden = false; }
153
+ else glyph.hidden = true;
154
+ });
155
+ };
156
+
157
+ return {
158
+ /** @param {Array<{kind: string, props?: object, title?: string, dirty?: boolean}>} next */
159
+ update(next, activeIdx) {
160
+ tabs = Array.isArray(next) ? next : [];
161
+ const active = Math.max(0, Math.min(tabs.length - 1, activeIdx || 0));
162
+ strip.update(
163
+ tabs.map((t, i) => ({
164
+ filePath: tabKey(i),
165
+ label: t.title || t.kind || '',
166
+ fileType: t.kind || 'unknown',
167
+ // Nothing sets `dirty` on a tab spec today, so the dot is
168
+ // never drawn. Read anyway, because the day a tab can say it
169
+ // holds unsaved work this is where it says it, and the
170
+ // alternative is a second place to remember.
171
+ isDirty: !!t.dirty,
172
+ })),
173
+ tabKey(active));
174
+ repaint();
175
+ },
176
+ dispose() {
177
+ host.removeEventListener('contextmenu', onContextMenu, true);
178
+ try { strip.dispose(); } catch (err) {
179
+ console.error('[tab-strip] dispose threw', err);
180
+ }
181
+ host.remove();
182
+ },
183
+ };
184
+ }
@@ -73,8 +73,17 @@ export function mountTileBreadcrumb(kind, props, ctx) {
73
73
  // the breadcrumb simply starts at the top-nav.
74
74
  let rootLabel = rootCrumb?.label || '';
75
75
 
76
+ // Read at RENDER time, not captured: the trail grows with every
77
+ // click-through, and a breadcrumb that showed the trail as it was when the
78
+ // tile mounted would be describing a journey the user has since continued.
79
+ const trailOf = () => {
80
+ if (typeof ctx?.trailSegments !== 'function') return [];
81
+ try { return ctx.trailSegments() || []; }
82
+ catch (err) { console.warn('[breadcrumb] trailSegments threw', err); return []; }
83
+ };
76
84
  const render = () => {
77
- _renderInto(root, _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate));
85
+ _renderInto(root,
86
+ _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trailOf()));
78
87
  };
79
88
  render();
80
89
 
@@ -92,6 +101,10 @@ export function mountTileBreadcrumb(kind, props, ctx) {
92
101
 
93
102
  return {
94
103
  el: root,
104
+ /** Repaint. A trail-driven breadcrumb changes without the tile
105
+ * remounting — following a lookup replaces the active tab's content in
106
+ * place — so the embedder that grew the trail says when. */
107
+ refresh: render,
95
108
  destroy: () => {
96
109
  try { unsubscribe?.(); }
97
110
  catch (err) { console.warn('[breadcrumb] rootCrumb teardown threw', err); }
@@ -99,7 +112,7 @@ export function mountTileBreadcrumb(kind, props, ctx) {
99
112
  };
100
113
  }
101
114
 
102
- function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
115
+ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trail) {
103
116
  const segs = [];
104
117
  const meta = taxonomy.meta(kind);
105
118
 
@@ -132,6 +145,25 @@ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
132
145
  });
133
146
  }
134
147
 
148
+ // 2b. THE TRAIL. An embedder whose navigation is a walk rather than a
149
+ // descent — click a customer, follow a lookup to its region, follow
150
+ // that to a country — has a real path that the taxonomy cannot know,
151
+ // because none of those is an ANCESTOR of the next. The tree already
152
+ // records it: every in-tile navigation pushes the outgoing content
153
+ // onto the active tab's `history` stack, which is what Backspace pops.
154
+ //
155
+ // `ctx.trailSegments` hands that stack over, already shaped. Absent, or
156
+ // empty, this is a no-op and the breadcrumb is exactly the ancestor
157
+ // walk it has always been.
158
+ for (const step of (trail || [])) {
159
+ if (!step?.kind) continue;
160
+ segs.push({
161
+ icon: taxonomy.meta(step.kind)?.icon || 'description',
162
+ label: step.title || step.props?.label || step.props?.id || step.kind,
163
+ onClick: () => navigate(step.kind, step.props || {}),
164
+ });
165
+ }
166
+
135
167
  // 3. Entity-level ancestors, root-most first — supplied by the
136
168
  // taxonomy, not by string surgery on the id.
137
169
  for (const anc of taxonomy.ancestors(kind, props)) {