flexdesk 0.2.0 → 0.3.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 (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -43,7 +43,7 @@ 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
47
  import { showContextMenu } from '../ui/components/context_menu.js';
48
48
  import { openForm } from '../ui/components/modal.js';
49
49
 
@@ -64,7 +64,38 @@ import { openForm } from '../ui/components/modal.js';
64
64
  * @param {object} [cfg.tableStore] threaded into ctx for table persistence
65
65
  * @param {object} [cfg.events] bus-event NAMES the WM listens for
66
66
  * @param {object} [cfg.rootCrumb] the breadcrumb's leading segment
67
- * @param {object} [cfg.palette] { placeholder }
67
+ * @param {object} [cfg.palette] { placeholder, onPick } — C30. `onPick`
68
+ * is `(pick) => truthy` and CLAIMS the
69
+ * open of a picked entity, so an embedder
70
+ * with a rule about where its own entities
71
+ * belong applies that rule whichever door
72
+ * was used. Unclaimed picks still reset
73
+ * the primary tile: the behaviour every
74
+ * embedder has today.
75
+ * @param {object} [cfg.panels] C14. Which panel TILES a fresh desktop
76
+ * opens with — `{left, right, bottom}`,
77
+ * merged over all-three-open. An embedder
78
+ * whose navigator is its own chrome passes
79
+ * `{left: false, right: false}` and gets no
80
+ * tile it never registered a factory for.
81
+ * @param {boolean} [cfg.promoteInPlace] C21. Whether a window promoted out
82
+ * of a tile stays confined to that pane
83
+ * rather than floating over the whole root.
84
+ * @param {boolean} [cfg.snapPromotion] C15. Whether dragging a promoted
85
+ * window onto a tile puts it back in the
86
+ * tree. Default off — it changes what a
87
+ * drag to an edge does.
88
+ * @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
89
+ * tabs. `'bottom'` is the framework's own
90
+ * spreadsheet strip under the tile body
91
+ * and is the DEFAULT, so no existing
92
+ * embedder's panes rearrange on upgrade;
93
+ * `'top'` mounts the editor tab bar
94
+ * between the chrome and the body. The
95
+ * renderer mirrors this onto `root` as
96
+ * `data-twm-tabs` and watches it, so an
97
+ * embedder whose settings pane holds only
98
+ * the root element can change it live.
68
99
  * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
69
100
  * panelToggles?: { left?, right?, bottom? } }
70
101
  * @returns {Promise<object>} the frozen shell
@@ -82,7 +113,15 @@ export async function createShell({
82
113
  events = {},
83
114
  rootCrumb = null,
84
115
  palette: paletteCfg = {},
116
+ panels = null,
117
+ snapPromotion = false,
118
+ promoteInPlace = false,
119
+ tabLayout = null,
85
120
  chrome = {},
121
+ // An embedder that moved its sections out of the top bar — into an icon
122
+ // rail, say — passes the selector its own buttons match, and F1..F8 keep
123
+ // working. Omitted, the default top-bar selector applies.
124
+ navSelector = null,
86
125
  } = {}) {
87
126
  // A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
88
127
  // createHost / createTaxonomy.
@@ -115,6 +154,10 @@ export async function createShell({
115
154
  host,
116
155
  taxonomy,
117
156
  events,
157
+ panelDefaults: panels,
158
+ snapPromotion,
159
+ promoteInPlace,
160
+ tabLayout,
118
161
  // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
119
162
  // spreads it into every content factory, which is how the breadcrumb
120
163
  // gets `taxonomy` + `rootCrumb` without a content factory knowing they
@@ -132,7 +175,10 @@ export async function createShell({
132
175
  const palette = createCommandPalette({
133
176
  wm, api, taxonomy, catalog: entities, ...paletteCfg,
134
177
  });
135
- installKeymap({ wm, palette });
178
+ // `navSelector` lets an embedder that moved its sections out of the top
179
+ // bar keep F1..F8 working. Omitted, the default top-bar selector applies —
180
+ // the behaviour every existing embedder has today.
181
+ const disposeKeymap = installKeymap({ wm, palette, navSelector });
136
182
 
137
183
  // Chrome — painted into the ELEMENTS the embedder handed over. An absent
138
184
  // key means absent chrome, not a crash.
@@ -166,8 +212,23 @@ export async function createShell({
166
212
  desktopsEl,
167
213
  panelToggles: toggles,
168
214
  }),
215
+ // A shell that can be built can be built TWICE — an embedder that
216
+ // rebuilds on a context change (a different project, a different
217
+ // workspace) does exactly that. Everything this function installs
218
+ // outside `root` has to come off, or the second shell shares the page
219
+ // with the first one's keyboard.
169
220
  dispose: () => {
170
221
  try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
222
+ try { disposeKeymap?.(); } catch { /* ignore */ }
223
+ try { palette?.close?.(); } catch { /* ignore */ }
224
+ // Every content factory gets its `destroy()`. Clearing the root
225
+ // element would detach the DOM and tell none of them, so an ERD's
226
+ // `window` keydown listener or a pane's interval would outlive the
227
+ // shell that mounted it. This also stops the renderer painting, so
228
+ // anything still holding a reference to this wm — a callback
229
+ // captured before a rebuild, a promise that has not settled —
230
+ // cannot repaint a dead tree into a root the live shell now owns.
231
+ try { wm.renderer.destroy(); } catch (err) { log.warn?.('renderer teardown', err); }
171
232
  },
172
233
  });
173
234
  }
@@ -217,10 +278,23 @@ function syncTopNav(hostEl, wm) {
217
278
  });
218
279
  }
219
280
 
281
+ /**
282
+ * The palette button, REBOUND rather than reused.
283
+ *
284
+ * This used to return an existing `#twm-palette-btn` untouched, which is right
285
+ * only while a page mounts one shell and keeps it. An embedder that rebuilds
286
+ * its shell — Tables does, on every project switch — got its FIRST shell's
287
+ * button back, still wired to the FIRST shell's palette. Clicking it opened a
288
+ * palette over a disposed window manager, and picking a result called
289
+ * `openInPrimary` on a tree nobody could see, whose renderer then painted it
290
+ * into the root the live shell now owns.
291
+ *
292
+ * So the stale node is replaced, which is exactly what `bindPanelToggles` does
293
+ * one function below and for the same reason.
294
+ */
220
295
  function mountPaletteButton(hostEl, palette) {
221
296
  if (!hostEl) return null;
222
- const existing = hostEl.querySelector('#twm-palette-btn');
223
- if (existing) return existing;
297
+ hostEl.querySelector('#twm-palette-btn')?.remove();
224
298
  const btn = document.createElement('button');
225
299
  btn.id = 'twm-palette-btn';
226
300
  btn.className = 'twm-panel-toggle-btn twm-has-tooltip';
@@ -361,27 +435,27 @@ function syncDesktopBar(el, wm) {
361
435
 
362
436
  // ══ Tile menus ════════════════════════════════════════════════════════
363
437
 
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). */
438
+ /** Hamburger button in a tile's tab bar. Opens a compact menu anchored to
439
+ * the button that lists the leaf's OPEN TABS for quick switching (a
440
+ * browser-style tab overflow list). Clicking a row activates that tab.
441
+ * The leaf id is captured at click time, so a focus change between click
442
+ * and pick doesn't reroute it. */
368
443
  function _tileTabMenu(wm, leafId, x, y) {
369
444
  const tree = wm.desktops.active().tree;
370
445
  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);
446
+ if (!leaf || leaf.kind !== 'leaf') return;
447
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
448
+ if (tabs.length === 0) return;
449
+ openTileTabSwitcher({
450
+ x, y,
451
+ tabs,
452
+ activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)),
453
+ onPick: (idx) => {
454
+ tree.setActiveLeafTab(leafId, idx);
381
455
  tree.focus(leafId);
382
456
  wm.renderer.render();
383
457
  wm._persist?.();
384
- wm._notifyChange?.('tab-open-from-menu');
458
+ wm._notifyChange?.('tab-switch-from-menu');
385
459
  },
386
460
  });
387
461
  }
@@ -401,10 +475,36 @@ function _tileContextMenu(wm, leafId, x, y) {
401
475
  { separator: true },
402
476
  { label: 'Open in new tab', icon: 'tab',
403
477
  action: 'open-tab', disabled: isPanel || !leaf.content },
404
- { label: 'Open in new window', icon: 'open_in_full',
478
+ // TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
479
+ // window INSIDE the application — the same glyph `ManagedWindow` uses
480
+ // for itself — and an embedder that can also send content to a real
481
+ // browser window keeps `open_in_new`, which is the universal "this
482
+ // leaves the page". One glyph for both is how a user learns that the
483
+ // two commands are the same command, and then loses a window looking
484
+ // for it on the other screen.
485
+ { label: 'Open a copy in a window', icon: 'web_asset',
405
486
  action: 'open-window', disabled: isPanel || !leaf.content },
406
- { label: 'Promote to window', icon: 'open_in_new',
407
- action: 'promote', disabled: isPanel || !leaf.content },
487
+ // C20, THE OTHER HALF and it was missing while the `close` half
488
+ // below carried a paragraph explaining why it could not be.
489
+ //
490
+ // `_floatableLeaf` (`wm.js`) refuses to float content that declared
491
+ // `chrome: { promote: false }`, and every door converges there — so
492
+ // this row offered the verb, enabled, and returned null. The chrome's
493
+ // own float BUTTON does not have the problem: C20 removes it from the
494
+ // strip. That asymmetry is what hid this: the affordance the reader
495
+ // checks is correct, and the menu one layer down is not.
496
+ //
497
+ // `=== false` EXACTLY, because that is the test the verb makes
498
+ // (`wm.js`, `_floatableLeaf`: *"content that says nothing about
499
+ // `promote` stays floatable"*). A falsy test here would grey the row
500
+ // on every leaf whose content returned no `chrome` at all, which is
501
+ // most of them — a menu disagreeing with its verb in the generous
502
+ // direction is a dead control; in the mean direction it is a missing
503
+ // feature, and this file has shipped one of each.
504
+ { label: 'Float this pane as a window', icon: 'web_asset',
505
+ action: 'promote',
506
+ disabled: isPanel || !leaf.content
507
+ || wm.renderer?.leafChrome?.(leafId)?.promote === false },
408
508
  ];
409
509
  if (wm.desktops.desktops.length > 1 && !isPanel) {
410
510
  for (const [i, d] of wm.desktops.desktops.entries()) {
@@ -414,8 +514,19 @@ function _tileContextMenu(wm, leafId, x, y) {
414
514
  }
415
515
  }
416
516
  items.push({ separator: true });
517
+ // C20. THE MENU ROW CARRIES THE VETO, AND THE REASON WITH IT.
518
+ //
519
+ // `closeFocused` refuses when the content vetoed `close`, which is what
520
+ // makes the greyed × in the chrome honest — and left this row offering the
521
+ // same verb, enabled, doing nothing. A control that silently no-ops is the
522
+ // dead-control failure this file has fixed three times already. The
523
+ // tooltip is the content's own sentence, so the two doors explain the
524
+ // refusal identically rather than one explaining it and one not.
525
+ const closeVeto = wm.renderer?.leafChrome?.(leafId)?.close;
526
+ const closeVetoed = closeVeto === false || closeVeto?.disabled === true;
417
527
  items.push({ label: 'Close tile', icon: 'close', action: 'close',
418
- danger: true, disabled: isPanel });
528
+ danger: true, disabled: isPanel || closeVetoed,
529
+ title: closeVetoed ? (closeVeto?.title || undefined) : undefined });
419
530
 
420
531
  showContextMenu(x, y, items, (action) => {
421
532
  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)) {