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,33 @@
1
+ /**
2
+ * loading_overlay.js — the canonical "content is loading" overlay.
3
+ *
4
+ * An absolutely-positioned spinner layer dropped over a slot while an async
5
+ * mount is in flight, with an 80ms grace period so cache hits / instant
6
+ * renders never strobe a spinner. Returns a `done()` that removes it.
7
+ *
8
+ * Uses the global `twm-tile-loading` styles (ui/css/tiling/host.css). Shared
9
+ * by the WM page-stub shell and the agent Code tab's split view.
10
+ *
11
+ * const done = makeLoadingOverlay(slot);
12
+ * try { await mount(); } finally { done(); }
13
+ */
14
+
15
+ /** Drop a loading overlay over `slot` and return a `done()` to remove it.
16
+ * The overlay stays invisible for `delay` ms (default 80) so fast renders
17
+ * don't flash a spinner. `slot` must be a positioned ancestor (or pass one
18
+ * that is); the overlay is `position:absolute; inset:0`. */
19
+ export function makeLoadingOverlay(slot, { delay = 80 } = {}) {
20
+ const overlay = document.createElement('div');
21
+ overlay.className = 'twm-tile-loading twm-tile-loading--pending';
22
+ overlay.innerHTML =
23
+ '<div class="twm-tile-loading__spinner" aria-label="Loading"></div>';
24
+ slot.appendChild(overlay);
25
+ // Promote to visible only if the mount hasn't finished within the grace.
26
+ const showTimer = setTimeout(() => {
27
+ overlay.classList.remove('twm-tile-loading--pending');
28
+ }, delay);
29
+ return () => {
30
+ clearTimeout(showTimer);
31
+ overlay.remove();
32
+ };
33
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * nav_panel.js — the left navigation panel-tile: a mode strip, a shared
3
+ * filter box, and one lazily-mounted pane per mode.
4
+ *
5
+ * ┌─[ mode ][ mode ][ mode ]─────┐ mode tabs
6
+ * │ ┌──────────────────────────┐ │
7
+ * │ │ Filter… │ │ unified filter (sidebar-controls)
8
+ * │ └──────────────────────────┘ │
9
+ * │ ┌──────────────────────────┐ │
10
+ * │ │ (pane body) │ │
11
+ * │ └──────────────────────────┘ │
12
+ *
13
+ * The panel owns the strip, the filter and the pane hosts. It owns NONE of
14
+ * the panes: the embedder passes them in. There used to be three hardcoded
15
+ * modes here (a file tree, an object tree, a git view) plus a dead category
16
+ * browser; all of that is the embedder's, and lives on its side now.
17
+ *
18
+ * A mode:
19
+ *
20
+ * {
21
+ * id, icon, label,
22
+ * mount(paneEl, { wm, eventBus }) -> {
23
+ * activate?(), // called every time the mode is selected (may be async)
24
+ * filter?(query), // the shared filter box changed
25
+ * destroy?(),
26
+ * }
27
+ * }
28
+ *
29
+ * `mount()` runs at most once, the first time its mode is selected.
30
+ * `activate()` runs on every selection, INCLUDING the first — a pane that
31
+ * has to rebuild on each visit (a git status list, say) does its work there.
32
+ * The filter query is remembered per mode.
33
+ */
34
+
35
+ export function mountNavPanel(hostEl, {
36
+ modes = [],
37
+ wm = null,
38
+ eventBus = null,
39
+ filterPlaceholder = 'Filter…',
40
+ } = {}) {
41
+ if (!Array.isArray(modes) || modes.length === 0) {
42
+ throw new Error('mountNavPanel: at least one mode is required');
43
+ }
44
+
45
+ hostEl.classList.add('twm-nav');
46
+ hostEl.innerHTML = `
47
+ <div class="twm-nav__modes" data-role="modes"></div>
48
+ <div class="twm-sidebar-controls twm-nav__filter">
49
+ <span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
50
+ <input class="twm-data-page__search twm-nav__filter-input"
51
+ type="search" placeholder="${_esc(filterPlaceholder)}"
52
+ autocomplete="off" />
53
+ </div>
54
+ <div class="twm-nav__body" data-role="body"></div>
55
+ `;
56
+ const modesEl = hostEl.querySelector('[data-role="modes"]');
57
+ const bodyEl = hostEl.querySelector('[data-role="body"]');
58
+ const filterEl = hostEl.querySelector('.twm-nav__filter-input');
59
+
60
+ modesEl.innerHTML = modes.map((m) => `
61
+ <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc(m.id)}"
62
+ data-tooltip="${_esc(m.label)}">
63
+ <span class="material-symbols-outlined">${_esc(m.icon)}</span>
64
+ <span>${_esc(m.label)}</span>
65
+ </button>
66
+ `).join('');
67
+
68
+ const byId = new Map(modes.map((m) => [m.id, m]));
69
+ const hosts = {}; // mode id -> pane host element
70
+ const panes = {}; // mode id -> the object the mode's mount() returned
71
+ const filterByMode = Object.fromEntries(modes.map((m) => [m.id, '']));
72
+ let mode = modes[0].id;
73
+
74
+ const ensureHost = (id) => {
75
+ if (hosts[id]) return hosts[id];
76
+ const el = document.createElement('div');
77
+ el.className = `twm-nav__pane twm-nav__pane--${id}`;
78
+ el.style.display = 'none';
79
+ bodyEl.appendChild(el);
80
+ hosts[id] = el;
81
+ return el;
82
+ };
83
+
84
+ const activate = async (next) => {
85
+ const def = byId.get(next);
86
+ if (!def) return;
87
+ mode = next;
88
+ for (const m of modes) {
89
+ ensureHost(m.id).style.display = (m.id === next) ? 'flex' : 'none';
90
+ }
91
+ modesEl.querySelectorAll('[data-mode]').forEach((b) =>
92
+ b.classList.toggle('twm-nav__mode--on', b.dataset.mode === next));
93
+ filterEl.value = filterByMode[next] || '';
94
+
95
+ const paneEl = ensureHost(next);
96
+ if (!panes[next]) {
97
+ try { panes[next] = def.mount(paneEl, { wm, eventBus }) || {}; }
98
+ catch (err) {
99
+ console.error('[nav] mode mount failed', next, err);
100
+ panes[next] = {};
101
+ }
102
+ }
103
+ // `activate()` runs on EVERY selection, including the first. A pane that
104
+ // must rebuild on each visit (a git status list) does its work here.
105
+ try { await panes[next].activate?.(); }
106
+ catch (err) { console.error('[nav] mode activate failed', next, err); }
107
+ _applyFilter();
108
+ };
109
+
110
+ const _applyFilter = () => {
111
+ const q = filterByMode[mode] || '';
112
+ try { panes[mode]?.filter?.(q); }
113
+ catch (err) { console.warn('[nav] filter failed', mode, err); }
114
+ };
115
+
116
+ filterEl.addEventListener('input', () => {
117
+ filterByMode[mode] = filterEl.value;
118
+ _applyFilter();
119
+ });
120
+
121
+ modesEl.addEventListener('click', (e) => {
122
+ const btn = e.target.closest('[data-mode]');
123
+ if (btn) activate(btn.dataset.mode);
124
+ });
125
+
126
+ activate(mode);
127
+
128
+ return {
129
+ refresh: () => activate(mode),
130
+ destroy: () => {
131
+ for (const p of Object.values(panes)) {
132
+ try { p?.destroy?.(); } catch { /* ignore */ }
133
+ }
134
+ },
135
+ };
136
+ }
137
+
138
+ function _esc(s) {
139
+ return String(s ?? '').replace(/[&<>"']/g, (c) => (
140
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
141
+ ));
142
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * page_factory.js — the MECHANISM behind a tile's content, with no
3
+ * knowledge of what that content is about.
4
+ *
5
+ * `createPageFactories()` returns the four wrappers an embedder uses to
6
+ * turn its own page/tab modules into content factories:
7
+ *
8
+ * tabFactory(kind, factory) breadcrumb page-shell + a mounted tab
9
+ * bareTabFactory(kind, factory) same, minus the breadcrumb (transient forms)
10
+ * stubPageFactory(kind, render) breadcrumb + a plain render fn + loading overlay
11
+ * mountTabBody / makeShim the pieces the three are built from
12
+ *
13
+ * ── makeShim: the navigation contract ─────────────────────────────────
14
+ * Mounted content receives a `workspaceTabs` object. That object IS the
15
+ * library's public navigation API in embryo:
16
+ *
17
+ * openTab open a kind in THIS tile (openFromContext)
18
+ * openInTab open it as a new TAB in this tile (openInTabFromContext)
19
+ * openInWindow open it in a fresh managed window
20
+ * updateProps persist editor sub-state into this tile's tab props
21
+ * closeTab close this tab (falls back to closing the tile)
22
+ * notifyChanged broadcast "I mutated the project" so sidebars refresh
23
+ *
24
+ * ── refreshOn / events: no bus-name literals here ─────────────────────
25
+ * This module used to hardcode five bus names (`<domain>:run:tick`, …) and
26
+ * one mutation event. Both are now INJECTED:
27
+ *
28
+ * refreshOn { [busEvent]: reasonString } fanned into tab.refresh(reason)
29
+ * events { projectChanged? } emitted by close/notifyChanged
30
+ *
31
+ * An absent key means an absent subscription / an absent emit, so an
32
+ * embedder with no run loop simply passes `{}` and nothing fires.
33
+ */
34
+
35
+ import { makeLoadingOverlay } from './loading_overlay.js';
36
+ import { mountTileBreadcrumb } from './tile_breadcrumb.js';
37
+
38
+ /**
39
+ * @param {object} cfg
40
+ * @param {object} cfg.eventBus the embedder's bus (may be null)
41
+ * @param {object} cfg.events { projectChanged?: string }
42
+ * @param {object} cfg.refreshOn { [busEventName]: reason } -> tab.refresh(reason)
43
+ */
44
+ export function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } = {}) {
45
+ const projectChanged = events?.projectChanged || null;
46
+
47
+ // The `workspaceTabs` API tile content calls to open/close tabs and
48
+ // broadcast mutations. Shared by breadcrumbed and bare tabs.
49
+ const makeShim = (ctx) => ({
50
+ // Route through openFromContext so navigation triggered from a
51
+ // split tile / window stays in that container.
52
+ openTab: ({ kind: k, entityId, label, icon, subTab }) =>
53
+ ctx.wm?.openFromContext(ctx, k, { id: entityId, label, icon, subTab }),
54
+ // Open the same content as a NEW TAB in the current tile.
55
+ openInTab: ({ kind: k, entityId, label, icon, subTab }) =>
56
+ ctx.wm?.openInTabFromContext(ctx, k, { id: entityId, label, icon, subTab }),
57
+ // Open the same content in a fresh managed window.
58
+ openInWindow: ({ kind: k, entityId, label, icon, subTab }) =>
59
+ ctx.wm?.navigate(k, { id: entityId, label, icon, subTab }, { ctx, dest: 'window' }),
60
+ // Persist editor sub-state into this tile's WM tab props.
61
+ updateProps: (patch) => {
62
+ try { ctx.wm?.updateActiveTabProps?.(ctx.leafId, patch); }
63
+ catch (err) { console.warn('[shim] updateProps failed', err); }
64
+ },
65
+ // Close just THIS tab (not the whole tile); falls back to closing
66
+ // the tile when it's the last tab. Also broadcasts so sidebars /
67
+ // landings / nav refresh against the mutated project state.
68
+ closeTab: (_id) => {
69
+ if (projectChanged) {
70
+ try { eventBus?.emit?.(projectChanged, { source: 'twm-tab-close' }); } catch {}
71
+ }
72
+ try { ctx.wm?.closeActiveTab?.(ctx.leafId); } catch (err) {
73
+ console.warn('[shim] closeTab failed', err);
74
+ }
75
+ },
76
+ // Non-closing mutations (Save/New/Rename) broadcast so listeners refresh.
77
+ notifyChanged: (domain) => {
78
+ if (!projectChanged) return;
79
+ try {
80
+ eventBus?.emit?.(projectChanged,
81
+ { source: 'tab-mutation', domain: domain || null });
82
+ } catch {}
83
+ },
84
+ registerProvider: () => {},
85
+ getActiveTab: () => null,
86
+ });
87
+
88
+ // Run a content factory inside a content slot: build the shim, mount
89
+ // the tab, drive the loading overlay, and fan the embedder's declared
90
+ // events into the tab's refresh(reason) hook (tabs that don't
91
+ // self-subscribe would otherwise freeze mid-run). Returns a teardown
92
+ // that removes listeners + disposes the tab.
93
+ const mountTabBody = (factory, contentSlot, props, ctx) => {
94
+ const shim = makeShim(ctx);
95
+ const tab = factory(contentSlot, props?.id ?? props?.entityId ?? null, {
96
+ logger: null, eventBus, workspaceTabs: shim,
97
+ wm: ctx.wm, leafId: ctx.leafId, windowId: ctx.windowId,
98
+ });
99
+ const hideLoading = makeLoadingOverlay(contentSlot);
100
+ try {
101
+ const ret = tab?.mount?.(props);
102
+ if (ret && typeof ret.then === 'function') {
103
+ ret.then(hideLoading, (err) => {
104
+ console.error('[page] mount failed', err);
105
+ hideLoading();
106
+ });
107
+ } else {
108
+ requestAnimationFrame(hideLoading);
109
+ }
110
+ } catch (err) {
111
+ console.error('[page] mount failed', err);
112
+ hideLoading();
113
+ }
114
+ const listeners = {};
115
+ if (typeof tab?.refresh === 'function' && eventBus?.on) {
116
+ for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
117
+ const fn = () => {
118
+ try { tab.refresh(reason); }
119
+ catch (err) { console.warn('[page] refresh failed', busEvent, err); }
120
+ };
121
+ listeners[busEvent] = fn;
122
+ eventBus.on(busEvent, fn);
123
+ }
124
+ }
125
+ return () => {
126
+ for (const [n, fn] of Object.entries(listeners)) {
127
+ try { eventBus?.off?.(n, fn); } catch {}
128
+ }
129
+ try { tab?.dispose?.(); tab?.unmount?.(); } catch {}
130
+ };
131
+ };
132
+
133
+ /** Wrap a tab factory in a "page shell" — breadcrumb strip at the top,
134
+ * content fills the rest. `kind` is captured at build time so the
135
+ * breadcrumb knows which crumbs to build. */
136
+ const tabFactory = (kind, factory) => (host, props, ctx) => {
137
+ host.classList.add('twm-page-shell');
138
+ host.innerHTML = '';
139
+ const contentSlot = document.createElement('div');
140
+ contentSlot.className = 'twm-page-shell__content';
141
+ // Focusable via JS so `document.activeElement` lands inside the
142
+ // page when the tile is activated.
143
+ contentSlot.tabIndex = -1;
144
+
145
+ // A breadcrumb navigates the host TILE — meaningless for an entity
146
+ // opened in a standalone managed window, where we want only the
147
+ // entity view. Skip the crumb when mounted in a window.
148
+ let crumb = null;
149
+ if (!ctx?.windowId) {
150
+ const breadcrumbSlot = document.createElement('div');
151
+ breadcrumbSlot.className = 'twm-page-shell__breadcrumb';
152
+ host.appendChild(breadcrumbSlot);
153
+ crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
154
+ breadcrumbSlot.appendChild(crumb.el);
155
+ }
156
+ host.appendChild(contentSlot);
157
+
158
+ const teardown = mountTabBody(factory, contentSlot, props, ctx);
159
+ return {
160
+ destroy: () => {
161
+ teardown();
162
+ try { crumb?.destroy(); } catch {}
163
+ },
164
+ };
165
+ };
166
+
167
+ // Like tabFactory but with NO breadcrumb chrome — for transient /
168
+ // form content (e.g. an add-row entry mask) that isn't an entity
169
+ // view. Content still gets the full ctx (wm/leafId) + shim, so it
170
+ // can self-close via wm.closeActiveTab.
171
+ const bareTabFactory = (kind, factory) => (host, props, ctx) => {
172
+ host.classList.add('twm-page-shell');
173
+ host.innerHTML = '';
174
+ const contentSlot = document.createElement('div');
175
+ contentSlot.className = 'twm-page-shell__content';
176
+ contentSlot.tabIndex = -1;
177
+ host.appendChild(contentSlot);
178
+ return { destroy: mountTabBody(factory, contentSlot, props, ctx) };
179
+ };
180
+
181
+ /** Page with a breadcrumb at the top and a plain `render(slot, props, ctx)`
182
+ * body — for content that isn't a full tab object. If `render` returns a
183
+ * `ready` promise the loading overlay waits on it. */
184
+ const stubPageFactory = (kind, render) => (host, props, ctx) => {
185
+ host.classList.add('twm-page-shell');
186
+ host.innerHTML = '';
187
+ const breadcrumbSlot = document.createElement('div');
188
+ breadcrumbSlot.className = 'twm-page-shell__breadcrumb';
189
+ const contentSlot = document.createElement('div');
190
+ contentSlot.className = 'twm-page-shell__content';
191
+ contentSlot.tabIndex = -1;
192
+ host.appendChild(breadcrumbSlot);
193
+ host.appendChild(contentSlot);
194
+ const crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
195
+ breadcrumbSlot.appendChild(crumb.el);
196
+ const hideLoading = makeLoadingOverlay(contentSlot);
197
+ const ret = render(contentSlot, props, ctx) || {};
198
+ // Most landings kick off their own async refresh after the
199
+ // synchronous render returns. If render exposes a `ready`
200
+ // promise we await it; otherwise hide on the next frame.
201
+ if (ret.ready && typeof ret.ready.then === 'function') {
202
+ ret.ready.then(hideLoading, hideLoading);
203
+ } else {
204
+ requestAnimationFrame(hideLoading);
205
+ }
206
+ return {
207
+ title: ret.title,
208
+ destroy: () => { try { crumb.destroy(); } catch {} ret.destroy?.(); },
209
+ };
210
+ };
211
+
212
+ return Object.freeze({
213
+ makeShim, mountTabBody, tabFactory, bareTabFactory, stubPageFactory,
214
+ });
215
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * panel_keys.js — central keyboard router for panel-local navigation.
3
+ *
4
+ * THE PROBLEM IT SOLVES
5
+ * ---------------------
6
+ * Several panels want plain-key navigation (↑/↓ to move a cursor, Enter to
7
+ * open, single letters for actions): the landing tables, the top-nav
8
+ * overview pages, and any future browser panel. Each one used to attach its
9
+ * OWN `document` keydown listener gated by the heuristic
10
+ *
11
+ * hostEl.contains(document.activeElement) || activeElement === <body>
12
+ *
13
+ * That heuristic is wrong as soon as there is more than one tile. When the
14
+ * user selects a different tile, focus usually rests on `<body>`, so EVERY
15
+ * mounted landing's listener still fires — the "main" panel keeps eating
16
+ * arrow keys even though another panel is selected, and two panels can move
17
+ * their cursors at once. The selected tile is never actually consulted.
18
+ *
19
+ * THE CONCEPT
20
+ * -----------
21
+ * One listener, one authority. Panels do not listen on `document`
22
+ * themselves; they REGISTER a handler bound to their host element. On each
23
+ * keydown the router asks the window manager which leaf is currently
24
+ * focused (the single source of truth — `WindowManager.focusedLeafEl()`)
25
+ * and dispatches the event to the handler whose host lives inside that leaf
26
+ * — and to no one else. A panel automatically receives keys exactly when,
27
+ * and only when, it is the selected tile.
28
+ *
29
+ * Content rendered OUTSIDE the tile tree (a promoted managed window or a
30
+ * floating shell) has no focused-leaf concept; for those hosts the router
31
+ * falls back to the focus-containment scope so they keep working.
32
+ *
33
+ * USAGE
34
+ * -----
35
+ * import { registerPanelKeys } from './panel_keys.js';
36
+ * const off = registerPanelKeys(hostEl, (ev) => { ... });
37
+ * // later, on teardown:
38
+ * off();
39
+ *
40
+ * The handler is responsible for its own key semantics (which keys, whether
41
+ * to skip editable targets, when to preventDefault). The router only decides
42
+ * WHETHER this panel is the active keyboard target. A handler that calls
43
+ * `ev.preventDefault()` stops the event from reaching any other handler in
44
+ * the same leaf.
45
+ *
46
+ * When no router has been installed (unit tests, or a landing used outside
47
+ * the WM), `registerPanelKeys` degrades to a self-contained listener using
48
+ * the legacy focus-containment scope, so callers work in isolation too.
49
+ */
50
+
51
+ let _router = null;
52
+
53
+ /** Install the singleton router. Called once by the WindowManager. */
54
+ export function installPanelKeyRouter(wm) {
55
+ if (_router) _router.destroy();
56
+ _router = new PanelKeyRouter(wm);
57
+ return _router;
58
+ }
59
+
60
+ /** The installed router, or null if the WM hasn't built one yet. */
61
+ export function getPanelKeyRouter() { return _router; }
62
+
63
+ /** Tear down the singleton (test cleanup / shell teardown). */
64
+ export function uninstallPanelKeyRouter() {
65
+ _router?.destroy();
66
+ _router = null;
67
+ }
68
+
69
+ /**
70
+ * Register `handler` as the key handler for `hostEl`'s panel. Returns an
71
+ * unregister function — call it on teardown. Routes through the installed
72
+ * router when present, otherwise falls back to a legacy scoped listener so
73
+ * the panel still works outside the WM.
74
+ */
75
+ export function registerPanelKeys(hostEl, handler) {
76
+ if (!hostEl || typeof handler !== 'function') return () => {};
77
+ if (_router) return _router.register(hostEl, handler);
78
+ return _legacyRegister(hostEl, handler);
79
+ }
80
+
81
+ export class PanelKeyRouter {
82
+ constructor(wm) {
83
+ this._wm = wm;
84
+ this._entries = []; // { hostEl, handler }
85
+ this._onKey = this._dispatch.bind(this);
86
+ document.addEventListener('keydown', this._onKey);
87
+ }
88
+
89
+ register(hostEl, handler) {
90
+ const entry = { hostEl, handler };
91
+ this._entries.push(entry);
92
+ return () => {
93
+ const i = this._entries.indexOf(entry);
94
+ if (i >= 0) this._entries.splice(i, 1);
95
+ };
96
+ }
97
+
98
+ destroy() {
99
+ document.removeEventListener('keydown', this._onKey);
100
+ this._entries.length = 0;
101
+ }
102
+
103
+ /** Is `hostEl` the panel that should receive keys right now? */
104
+ _isActiveTarget(hostEl) {
105
+ if (!hostEl?.isConnected) return false;
106
+ const leaf = hostEl.closest?.('.twm-leaf');
107
+ if (leaf) {
108
+ // Inside the tile tree: only the focused leaf is live. This is
109
+ // the whole point — compare against the WM's single source of
110
+ // truth, not against `document.activeElement`.
111
+ return leaf === this._wm?.focusedLeafEl?.();
112
+ }
113
+ // Outside any tile (managed window / floating shell): no focused
114
+ // leaf to consult, so fall back to focus containment.
115
+ return hostEl.contains(document.activeElement)
116
+ || document.activeElement === document.body;
117
+ }
118
+
119
+ _dispatch(ev) {
120
+ // Snapshot: a handler may unregister (e.g. navigate away) mid-loop.
121
+ for (const entry of [...this._entries]) {
122
+ if (!this._isActiveTarget(entry.hostEl)) continue;
123
+ try { entry.handler(ev); }
124
+ catch (err) { console.warn('[panel-keys] handler threw', err); }
125
+ // A handler that consumed the key ends the chain so a second
126
+ // handler in the same leaf (e.g. the action-footer shortcuts)
127
+ // doesn't double-fire on the same press.
128
+ if (ev.defaultPrevented) return;
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Legacy fallback used only when no router is installed. Mirrors the old
134
+ * per-panel scope check so isolated/test usage keeps its previous
135
+ * behavior. */
136
+ function _legacyRegister(hostEl, handler) {
137
+ const wrapped = (ev) => {
138
+ if (!hostEl.isConnected) return;
139
+ if (!hostEl.contains(document.activeElement)
140
+ && document.activeElement !== document.body) return;
141
+ handler(ev);
142
+ };
143
+ document.addEventListener('keydown', wrapped);
144
+ return () => document.removeEventListener('keydown', wrapped);
145
+ }