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,717 @@
1
+ /**
2
+ * landing_table.js — shared helpers for the "navigation table"
3
+ * pattern used by SFC / Markets / Scenarios / KPIs landings (and the
4
+ * market-archetype instances table). Three responsibilities:
5
+ *
6
+ * 1. Single-click row → open the entity (handled by callers).
7
+ * 2. Right-click row → custom context menu with Open / Edit / Delete
8
+ * (replaces DataTable's default copy-as-TSV menu for these tables;
9
+ * the default still applies to data tables outside this helper).
10
+ * 3. Renders an "actions" column with inline edit + delete icons so
11
+ * the user can act on a row without opening it first.
12
+ *
13
+ * Callers configure the per-kind behaviour via the `entityActions`
14
+ * object: `{ open(row), edit(row), delete(row) }` — any of which may be
15
+ * omitted to hide the corresponding action.
16
+ */
17
+
18
+ import { showContextMenu } from '../ui/components/context_menu.js';
19
+ import { registerPanelKeys } from './panel_keys.js';
20
+
21
+ /** Attach the navigation-table interactions to a host element that
22
+ * contains a `<table>` already rendered by DataTable. Returns a
23
+ * teardown function.
24
+ *
25
+ * `entityActions` maps action names to handlers. The canonical names
26
+ * are `open` / `edit` / `delete` (plus `openInTab` / `openInWindow`),
27
+ * but ANY `data-twm-action="<name>"` button or context-menu item
28
+ * dispatches to `entityActions[<name>]` — so tree landings can wire
29
+ * `toggle`, `contract`, etc. through the same robust path instead of
30
+ * hand-rolling their own tbody listeners (which is how the Agents /
31
+ * Markets pages used to drift out of sync with this helper).
32
+ *
33
+ * `options.buildMenu(row) => items[]` overrides the default
34
+ * Open / Edit / Delete right-click menu for tree-shaped rows that need
35
+ * conditional entries (expand/collapse, contract detail). Each item's
36
+ * `action` is dispatched the same way as a button. */
37
+ export function attachLandingTableBehavior(host, getRow, entityActions, options = {}) {
38
+ if (!host) return () => {};
39
+
40
+ // Delegate from the stable `host` container, NOT the <tbody>. DataTable
41
+ // rebuilds its <tbody> on every internal re-render — pagination, filter,
42
+ // sort, and the post-layout column-width sync — and each re-render
43
+ // re-installs ITS OWN row listeners (data_table.js `_installInteractions`)
44
+ // but knows nothing about ours. A listener bound directly to the tbody
45
+ // therefore goes silently dead after the first re-render: events still
46
+ // reach the live tbody (DataTable's copy menu still pops) while our
47
+ // handlers sit orphaned on a detached node. Binding on `host` and
48
+ // resolving the row at event time survives any number of tbody rebuilds.
49
+
50
+ // Dispatch an action name to its handler. The `open-tab` /
51
+ // `open-window` menu aliases map onto the `openInTab` /
52
+ // `openInWindow` handlers; every other name (including `edit`,
53
+ // `delete`, and tree-only verbs like `toggle` / `contract`) routes
54
+ // straight to `entityActions[name]`.
55
+ const dispatch = (action, row) => {
56
+ if (!row) return;
57
+ const fn = action === 'open-tab' ? entityActions.openInTab
58
+ : action === 'open-window' ? entityActions.openInWindow
59
+ : entityActions[action];
60
+ try { fn?.(row); }
61
+ catch (e) { console.warn(`[landing] action "${action}" failed`, e); }
62
+ };
63
+
64
+ // The BODY <tr> for an event, re-found live each time. Excludes the
65
+ // header / filter row (it lives in the separate non-scrolling header
66
+ // wrap, not inside `.preview-table-wrap`'s tbody).
67
+ const bodyRowFor = (ev) => {
68
+ const tr = ev.target.closest('tr');
69
+ if (!tr || !tr.closest('tbody')) return null;
70
+ const wrap = tr.closest('.twm-preview-table-wrap');
71
+ if (!wrap || !host.contains(wrap)) return null;
72
+ return tr;
73
+ };
74
+
75
+ const onClick = (ev) => {
76
+ // Inline action buttons short-circuit the row-open default and
77
+ // suppress every other listener (DataTable's row-selection
78
+ // listener, etc.). `stopImmediatePropagation` is load-bearing.
79
+ const actionBtn = ev.target.closest('[data-twm-action]');
80
+ if (actionBtn && host.contains(actionBtn)) {
81
+ ev.preventDefault();
82
+ ev.stopPropagation();
83
+ ev.stopImmediatePropagation();
84
+ const tr = actionBtn.closest('tr');
85
+ dispatch(actionBtn.dataset.twmAction, tr ? getRow(tr.__rowIndex) : null);
86
+ return;
87
+ }
88
+ // Plain row click → open. Ignore clicks on interactive controls
89
+ // (buttons, filter inputs, links) and anything outside a body row
90
+ // (pagination strip, header, empty space).
91
+ if (ev.target.closest('button, a, input, select, textarea')) return;
92
+ const tr = bodyRowFor(ev);
93
+ if (tr) dispatch('open', getRow(tr.__rowIndex));
94
+ };
95
+
96
+ const defaultMenu = (row) => {
97
+ const items = [];
98
+ if (entityActions.open) {
99
+ items.push({ label: 'Open', icon: 'open_in_new', action: 'open' });
100
+ if (entityActions.openInTab) {
101
+ items.push({ label: 'Open in new tab', icon: 'tab', action: 'open-tab' });
102
+ }
103
+ if (entityActions.openInWindow) {
104
+ items.push({ label: 'Open in new window', icon: 'open_in_full',
105
+ action: 'open-window' });
106
+ }
107
+ }
108
+ if (entityActions.edit) items.push({ label: 'Rename / edit', icon: 'edit', action: 'edit' });
109
+ if (entityActions.delete) {
110
+ if (items.length) items.push({ separator: true });
111
+ items.push({ label: 'Delete', icon: 'delete', action: 'delete', danger: true });
112
+ }
113
+ return items;
114
+ };
115
+ const buildMenu = typeof options.buildMenu === 'function'
116
+ ? options.buildMenu
117
+ : defaultMenu;
118
+
119
+ const onContextMenu = (ev) => {
120
+ const tr = bodyRowFor(ev);
121
+ if (!tr) return;
122
+ ev.preventDefault();
123
+ // Bypass DataTable's own copy-as-TSV/CSV menu by stopping
124
+ // propagation — the right-click for a navigation row is about the
125
+ // *entity*, not the cell text. Capture phase on `host` runs before
126
+ // DataTable's bubble-phase contextmenu on the tbody.
127
+ ev.stopPropagation();
128
+ const row = getRow(tr.__rowIndex);
129
+ if (!row) return;
130
+ const items = buildMenu(row) || [];
131
+ if (items.length === 0) return;
132
+ showContextMenu(ev.clientX, ev.clientY, items, (action) => dispatch(action, row));
133
+ };
134
+
135
+ // Click in bubble phase (after DataTable's row-selection); contextmenu
136
+ // in CAPTURE phase so ours runs before DataTable's bubble copy menu.
137
+ host.addEventListener('click', onClick);
138
+ host.addEventListener('contextmenu', onContextMenu, true);
139
+ return () => {
140
+ host.removeEventListener('click', onClick);
141
+ host.removeEventListener('contextmenu', onContextMenu, true);
142
+ };
143
+ }
144
+
145
+ /** Wire pane-focus highlighting. SfcLandingTab gives each
146
+ * `.ea-sfc-landing__pane` an `--focused` modifier when it's the
147
+ * active pane — that's what paints the blue head + accent title.
148
+ * Other landings need the same treatment to read as consistent.
149
+ *
150
+ * Single-pane landings can call this once: the only pane stays
151
+ * focused at all times. Multi-pane landings get mousedown wiring
152
+ * per pane and a reflectFocus() helper they can call from keyboard
153
+ * navigation if they implement it. */
154
+ export function wireLandingPaneFocus(hostEl) {
155
+ const panes = Array.from(hostEl.querySelectorAll('.twm-landing__pane'));
156
+ if (panes.length === 0) return { focus: () => {}, panes };
157
+ let focused = panes[0];
158
+ const reflect = () => {
159
+ for (const p of panes) {
160
+ p.classList.toggle('twm-landing__pane--focused', p === focused);
161
+ }
162
+ };
163
+ reflect();
164
+ for (const p of panes) {
165
+ p.addEventListener('mousedown', () => {
166
+ if (focused === p) return;
167
+ focused = p;
168
+ reflect();
169
+ });
170
+ }
171
+ return {
172
+ focus: (pane) => { if (pane && panes.includes(pane)) { focused = pane; reflect(); } },
173
+ panes,
174
+ };
175
+ }
176
+
177
+ /** Arrow-key navigation for a landing page's DataTable.
178
+ *
179
+ * - ArrowDown / ArrowUp moves a row cursor within `host`.
180
+ * - Enter (and the optional `activateKey`, e.g. `o`) opens the row.
181
+ * - Home / End jump to the first / last row.
182
+ * - Captures the keys at the document level so the user doesn't have
183
+ * to click inside the table first — same UX as sfc_landing_tab.
184
+ *
185
+ * The visible cursor is the `ea-row-cursor` CSS class on the active
186
+ * `<tr>`; the row also `scrollIntoView({block:'nearest'})` so the
187
+ * selection stays visible during keyboard scroll-through.
188
+ *
189
+ * Returns `{ teardown, refresh, focusFirst }`. Call `refresh()` after
190
+ * the table re-renders so the cursor lands on the right row again.
191
+ */
192
+ export function attachLandingKeyboardNav(host, getRows, onActivate, options = {}) {
193
+ if (!host) return { teardown: () => {}, refresh: () => {}, focusFirst: () => {} };
194
+
195
+ let cursor = 0;
196
+ const tbody = () => host.querySelector('tbody');
197
+
198
+ const repaintCursor = () => {
199
+ const tb = tbody();
200
+ if (!tb) return;
201
+ const rows = getRows() || [];
202
+ if (rows.length === 0) { cursor = 0; return; }
203
+ if (cursor >= rows.length) cursor = rows.length - 1;
204
+ if (cursor < 0) cursor = 0;
205
+ let active = null;
206
+ tb.querySelectorAll('tr').forEach((tr) => {
207
+ const on = tr.__rowIndex === cursor;
208
+ tr.classList.toggle('twm-row-cursor', on);
209
+ if (on) active = tr;
210
+ });
211
+ if (active) active.scrollIntoView({ block: 'nearest' });
212
+ };
213
+
214
+ const move = (delta) => {
215
+ const rows = getRows() || [];
216
+ if (rows.length === 0) return;
217
+ cursor = Math.max(0, Math.min(rows.length - 1, cursor + delta));
218
+ repaintCursor();
219
+ };
220
+
221
+ const open = () => {
222
+ const rows = getRows() || [];
223
+ const row = rows[cursor];
224
+ if (row) onActivate(row);
225
+ };
226
+
227
+ const onKey = (ev) => {
228
+ if (!host.isConnected) return;
229
+ // Stay out of the way of inputs and unrelated focus targets.
230
+ const t = ev.target;
231
+ const editable = t && (
232
+ t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
233
+ t.tagName === 'SELECT' || t.isContentEditable);
234
+ if (editable) return;
235
+ // Scope (which tile is selected) is owned by the panel-key router.
236
+
237
+ if (ev.key === 'ArrowDown') { ev.preventDefault(); move(+1); }
238
+ else if (ev.key === 'ArrowUp') { ev.preventDefault(); move(-1); }
239
+ else if (ev.key === 'Home') { ev.preventDefault(); cursor = 0; repaintCursor(); }
240
+ else if (ev.key === 'End') {
241
+ ev.preventDefault();
242
+ const rows = getRows() || [];
243
+ cursor = Math.max(0, rows.length - 1);
244
+ repaintCursor();
245
+ }
246
+ else if (ev.key === 'Enter') { ev.preventDefault(); open(); }
247
+ else if (options.activateKey && ev.key === options.activateKey
248
+ && !ev.metaKey && !ev.ctrlKey && !ev.altKey) {
249
+ ev.preventDefault(); open();
250
+ }
251
+ };
252
+
253
+ const offKey = registerPanelKeys(host, onKey);
254
+ // Re-paint immediately so the cursor is visible from first render.
255
+ setTimeout(repaintCursor, 0);
256
+
257
+ return {
258
+ teardown: () => offKey(),
259
+ refresh: () => repaintCursor(),
260
+ focusFirst: () => { cursor = 0; repaintCursor(); },
261
+ };
262
+ }
263
+
264
+
265
+ /** Unified landing-page keyboard shell.
266
+ *
267
+ * Wires the four behaviors every landing should share, in one call:
268
+ *
269
+ * - Pane focus highlight (`.ea-sfc-landing__pane--focused`) — click
270
+ * on a pane or Tab to it, the header band lights up.
271
+ * - ArrowUp / ArrowDown moves the row cursor within the focused
272
+ * pane's table; Home / End jump to first / last. The visible cursor
273
+ * is `.ea-row-cursor` on the active `<tr>`.
274
+ * - Tab / Shift+Tab cycles between panes. Single-pane landings just
275
+ * no-op the Tab — the behaviour is a property of the shell, not
276
+ * the page.
277
+ * - Enter activates the cursor row via `onActivate(row)`.
278
+ * - ArrowRight / ArrowLeft expand / collapse the cursor row when
279
+ * the pane provides `onExpandToggle` + `isExpanded` (tree-shaped
280
+ * tables; harmless on flat tables that don't define them).
281
+ *
282
+ * Each pane spec:
283
+ * {
284
+ * paneEl, // .ea-sfc-landing__pane element
285
+ * getRows, // () => Array<row>
286
+ * onActivate, // (row) => void — Enter
287
+ * onExpandToggle?, // (row, expand:bool) => void
288
+ * isExpanded?, // (row) => bool
289
+ * isExpandable?, // (row) => bool — gates Left/Right
290
+ * tableHost?, // override pane.querySelector('.twm-landing__table')
291
+ * }
292
+ *
293
+ * Returns `{ teardown, refresh, focusPane(idx) }`. Call `refresh()` after
294
+ * the pane tables re-render so the cursor lands on the right row again.
295
+ *
296
+ * Backspace is intentionally **not** handled here — `keymap.js` owns
297
+ * it globally and walks `wm.navigateBack()` regardless of which
298
+ * landing is showing. */
299
+ export function attachLandingShell(hostEl, { panes, initialPane = 0 } = {}) {
300
+ if (!hostEl || !Array.isArray(panes) || panes.length === 0) {
301
+ return { teardown: () => {}, refresh: () => {}, focusPane: () => {} };
302
+ }
303
+
304
+ const state = panes.map((spec) => ({
305
+ spec,
306
+ paneEl: spec.paneEl,
307
+ tableHost: spec.tableHost
308
+ || spec.paneEl?.querySelector('.twm-landing__table')
309
+ || spec.paneEl,
310
+ cursor: 0,
311
+ }));
312
+ let focusedIdx = Math.max(0, Math.min(panes.length - 1, initialPane));
313
+
314
+ const reflectPaneFocus = () => {
315
+ state.forEach((s, i) => {
316
+ s.paneEl?.classList.toggle(
317
+ 'twm-landing__pane--focused', i === focusedIdx);
318
+ });
319
+ };
320
+
321
+ const repaintCursor = (idx = focusedIdx) => {
322
+ const s = state[idx];
323
+ if (!s) return;
324
+ const tbody = s.tableHost?.querySelector('tbody');
325
+ if (!tbody) return;
326
+ const rows = s.spec.getRows?.() || [];
327
+ if (rows.length === 0) { s.cursor = 0; return; }
328
+ if (s.cursor >= rows.length) s.cursor = rows.length - 1;
329
+ if (s.cursor < 0) s.cursor = 0;
330
+ let active = null;
331
+ tbody.querySelectorAll('tr').forEach((tr) => {
332
+ const on = tr.__rowIndex === s.cursor;
333
+ tr.classList.toggle('twm-row-cursor', on);
334
+ if (on) active = tr;
335
+ });
336
+ if (active && idx === focusedIdx) {
337
+ active.scrollIntoView({ block: 'nearest' });
338
+ }
339
+ };
340
+
341
+ const move = (delta) => {
342
+ const s = state[focusedIdx];
343
+ if (!s) return;
344
+ const rows = s.spec.getRows?.() || [];
345
+ if (rows.length === 0) return;
346
+ s.cursor = Math.max(0, Math.min(rows.length - 1, s.cursor + delta));
347
+ repaintCursor();
348
+ };
349
+
350
+ const jump = (toEnd) => {
351
+ const s = state[focusedIdx];
352
+ if (!s) return;
353
+ const rows = s.spec.getRows?.() || [];
354
+ s.cursor = toEnd ? Math.max(0, rows.length - 1) : 0;
355
+ repaintCursor();
356
+ };
357
+
358
+ const currentRow = () => {
359
+ const s = state[focusedIdx];
360
+ if (!s) return null;
361
+ const rows = s.spec.getRows?.() || [];
362
+ return rows[s.cursor] ?? null;
363
+ };
364
+
365
+ const activate = () => {
366
+ const row = currentRow();
367
+ if (!row) return;
368
+ try { state[focusedIdx].spec.onActivate?.(row); }
369
+ catch (err) { console.warn('[landing-shell] activate failed', err); }
370
+ };
371
+
372
+ /** Open the current row in a new tab on the tile. Pane spec opts in
373
+ * by providing `onActivateInTab(row)`; the default-Enter handler
374
+ * remains `onActivate`. Falls back to `onActivate` so panes that
375
+ * haven't been migrated still respond to Ctrl+Enter. */
376
+ const activateInTab = () => {
377
+ const row = currentRow();
378
+ if (!row) return;
379
+ const spec = state[focusedIdx].spec;
380
+ const fn = spec.onActivateInTab || spec.onActivate;
381
+ try { fn?.(row); }
382
+ catch (err) { console.warn('[landing-shell] activate-tab failed', err); }
383
+ };
384
+
385
+ /** Open the current row in a fresh managed window. Same opt-in
386
+ * pattern as activateInTab — panes provide `onActivateInWindow`
387
+ * for Shift+Enter, fall back to onActivate when absent. */
388
+ const activateInWindow = () => {
389
+ const row = currentRow();
390
+ if (!row) return;
391
+ const spec = state[focusedIdx].spec;
392
+ const fn = spec.onActivateInWindow || spec.onActivate;
393
+ try { fn?.(row); }
394
+ catch (err) { console.warn('[landing-shell] activate-window failed', err); }
395
+ };
396
+
397
+ const expandToggle = (expand) => {
398
+ const s = state[focusedIdx];
399
+ if (!s?.spec.onExpandToggle) return false;
400
+ const row = currentRow();
401
+ if (!row) return false;
402
+ if (s.spec.isExpandable && !s.spec.isExpandable(row)) return false;
403
+ const currentlyExpanded = s.spec.isExpanded
404
+ ? !!s.spec.isExpanded(row)
405
+ : false;
406
+ if (expand === currentlyExpanded) return false;
407
+ try { s.spec.onExpandToggle(row, expand); }
408
+ catch (err) { console.warn('[landing-shell] expand failed', err); }
409
+ return true;
410
+ };
411
+
412
+ const focusPane = (idx) => {
413
+ if (idx < 0 || idx >= state.length) return;
414
+ if (idx === focusedIdx) return;
415
+ focusedIdx = idx;
416
+ reflectPaneFocus();
417
+ repaintCursor();
418
+ };
419
+
420
+ const cyclePane = (dir) => {
421
+ if (state.length < 2) return;
422
+ focusPane((focusedIdx + dir + state.length) % state.length);
423
+ };
424
+
425
+ const onMouseDown = (ev) => {
426
+ // Find which pane the click landed in. Inline action buttons
427
+ // and the like still focus the surrounding pane.
428
+ const paneEl = ev.target.closest?.('.twm-landing__pane');
429
+ if (!paneEl) return;
430
+ const idx = state.findIndex((s) => s.paneEl === paneEl);
431
+ if (idx < 0 || idx === focusedIdx) return;
432
+ focusedIdx = idx;
433
+ reflectPaneFocus();
434
+ repaintCursor();
435
+ };
436
+
437
+ const onKey = (ev) => {
438
+ if (!hostEl.isConnected) return;
439
+ // Scope is owned by the panel-key router: this handler only runs
440
+ // when our tile is the selected one. We still skip editable
441
+ // targets so typing in a field inside the tile isn't hijacked.
442
+ const t = ev.target;
443
+ const editable = t && (
444
+ t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
445
+ t.tagName === 'SELECT' || t.isContentEditable);
446
+ if (editable) return;
447
+ // Browser-style activation chords (caught BEFORE the modifier
448
+ // guard so the WM keymap doesn't swallow them):
449
+ // Ctrl/Cmd + Enter → open in new TAB on this tile
450
+ // Shift + Enter → open in new managed WINDOW
451
+ if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey) && !ev.altKey) {
452
+ ev.preventDefault();
453
+ activateInTab();
454
+ return;
455
+ }
456
+ if (ev.key === 'Enter' && ev.shiftKey && !ev.altKey
457
+ && !ev.metaKey && !ev.ctrlKey) {
458
+ ev.preventDefault();
459
+ activateInWindow();
460
+ return;
461
+ }
462
+ // Other modifier chords belong to the WM / browser.
463
+ if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey) return;
464
+
465
+ switch (ev.key) {
466
+ case 'ArrowDown':
467
+ ev.preventDefault(); move(+1); return;
468
+ case 'ArrowUp':
469
+ ev.preventDefault(); move(-1); return;
470
+ case 'Home':
471
+ ev.preventDefault(); jump(false); return;
472
+ case 'End':
473
+ ev.preventDefault(); jump(true); return;
474
+ case 'Enter':
475
+ ev.preventDefault(); activate(); return;
476
+ case 'Tab':
477
+ // Cycle panes; single-pane case still preventDefault so
478
+ // focus doesn't escape into the browser chrome.
479
+ ev.preventDefault();
480
+ cyclePane(ev.shiftKey ? -1 : +1);
481
+ return;
482
+ case 'ArrowRight':
483
+ if (expandToggle(true)) ev.preventDefault();
484
+ return;
485
+ case 'ArrowLeft':
486
+ if (expandToggle(false)) ev.preventDefault();
487
+ return;
488
+ }
489
+ };
490
+
491
+ hostEl.addEventListener('mousedown', onMouseDown);
492
+ const offKey = registerPanelKeys(hostEl, onKey);
493
+ reflectPaneFocus();
494
+ // Initial cursor paint runs after the synchronous mount returns so
495
+ // tables that render asynchronously already have their tbody.
496
+ setTimeout(() => state.forEach((_, i) => repaintCursor(i)), 0);
497
+
498
+ // Park native focus on hostEl when our leaf is the active one. The
499
+ // panel-key router no longer depends on this for arrow routing (it
500
+ // keys off the focused leaf, not document.activeElement), but parking
501
+ // focus keeps native focus semantics — scroll-into-view, :focus-within
502
+ // styling, and screen-reader context — anchored to the live tile.
503
+ //
504
+ // Guarded by the WM's `.twm-leaf--focused` class so we don't steal
505
+ // focus when another tile owns the user's attention. The
506
+ // requestAnimationFrame second pass catches the case where the
507
+ // focus class is applied a frame after mount.
508
+ const parkFocusIfActive = () => {
509
+ const leaf = hostEl.closest?.('.twm-leaf');
510
+ // Page is rendered outside a tile (managed window / floating
511
+ // shell) — there is no focused-leaf concept; safe to take focus.
512
+ const inTile = !!leaf;
513
+ if (inTile && !leaf.classList.contains('twm-leaf--focused')) return;
514
+ if (hostEl.contains(document.activeElement)) return;
515
+ if (hostEl.tabIndex == null || hostEl.tabIndex < -1) hostEl.tabIndex = -1;
516
+ try { hostEl.focus({ preventScroll: true }); } catch {}
517
+ };
518
+ parkFocusIfActive();
519
+ requestAnimationFrame(parkFocusIfActive);
520
+
521
+ return {
522
+ teardown: () => {
523
+ hostEl.removeEventListener('mousedown', onMouseDown);
524
+ offKey();
525
+ },
526
+ refresh: () => state.forEach((_, i) => repaintCursor(i)),
527
+ focusPane,
528
+ currentRow,
529
+ };
530
+ }
531
+
532
+
533
+ /** Standardized action-footer renderer.
534
+ *
535
+ * Each action: `{ id, label, icon, shortcut?, title?, onClick }`.
536
+ *
537
+ * When `shortcut` is set (single letter), the button label gets a
538
+ * `[N]` suffix and pressing that key (without modifiers, outside any
539
+ * text input, while focus is in `hostEl` or on the body) fires the
540
+ * action. The visible chip + the keydown handler are the same source
541
+ * of truth, so users can never wonder which letter triggers what.
542
+ *
543
+ * Returns `{ teardown, refresh(actions) }`. */
544
+
545
+
546
+ // ─── #143 — shared landing shell ────────────────────────────────────
547
+ /** Mount the boilerplate every landing repeats: a detail-header with
548
+ * title + stat fields, a single `.ea-sfc-landing__pane` with title +
549
+ * keyboard-shortcut hint, the DataTable host, and a footer slot for
550
+ * `mountLandingActions` to fill.
551
+ *
552
+ * Callers receive `{paneEl, tableHost, footerEl, setStat(key, value)}`
553
+ * — `paneEl` feeds straight into `attachLandingShell({panes: [{paneEl,
554
+ * …}]})`; `tableHost` is where `new DataTable(host, …)` mounts;
555
+ * `footerEl` is the second arg to `mountLandingActions`; `setStat`
556
+ * patches a single stat by `data-role` without rebuilding the header.
557
+ *
558
+ * HTML output is identical to what each landing built by hand before
559
+ * this helper existed — only the source location moves.
560
+ *
561
+ * @param {HTMLElement} hostEl
562
+ * @param {object} opts
563
+ * @param {string} opts.title — `<h2>` text on the left of the header.
564
+ * @param {Array<{key,label,initial?}>} [opts.stats] — header chip per stat. `key` is the `data-role` for setStat(); `label` is the muted label above the value; `initial` defaults to `0`.
565
+ * @param {object} opts.pane
566
+ * @param {string} opts.pane.key — the `data-pane="…"` value (also doubles as the table-host's `data-role="<key>-host"`).
567
+ * @param {string} opts.pane.title — pane-head title.
568
+ * @param {string} opts.pane.hint — pane-head keyboard-shortcut hint.
569
+ * @returns {{headerEl: HTMLElement, paneEl: HTMLElement, tableHost: HTMLElement, footerEl: HTMLElement, setStat: (key: string, value: any) => void}}
570
+ */
571
+ export function mountLandingShell(hostEl, opts) {
572
+ const {
573
+ title = '',
574
+ stats = [],
575
+ pane = { key: 'rows', title: '', hint: '' },
576
+ } = opts || {};
577
+ const paneKey = String(pane.key || 'rows');
578
+
579
+ const _esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
580
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
581
+ }[c]));
582
+
583
+ const statsHtml = (stats || []).map((s) => `
584
+ <span class="twm-landing__stat twm-entity-header__field">
585
+ <span>${_esc(s.label || '')}</span>
586
+ <strong data-role="${_esc(s.key)}">${_esc(s.initial != null ? s.initial : 0)}</strong>
587
+ </span>
588
+ `).join('');
589
+
590
+ hostEl.classList.add('twm-landing');
591
+ hostEl.innerHTML = `
592
+ <header class="twm-entity-header">
593
+ <h2>${_esc(title)}</h2>
594
+ ${statsHtml}
595
+ <span class="twm-entity-header__spacer"></span>
596
+ </header>
597
+ <div class="twm-landing__split twm-landing__split--single">
598
+ <section class="twm-landing__pane" data-pane="${_esc(paneKey)}">
599
+ <div class="twm-landing__pane-head">
600
+ <span class="twm-landing__pane-title">${_esc(pane.title || '')}</span>
601
+ <span class="twm-landing__pane-hint">${_esc(pane.hint || '')}</span>
602
+ </div>
603
+ <div class="twm-landing__table" data-role="${_esc(paneKey)}-host"></div>
604
+ </section>
605
+ </div>
606
+ <footer class="twm-landing__actions" data-role="actions"></footer>
607
+ `;
608
+
609
+ const headerEl = hostEl.querySelector('.twm-entity-header');
610
+ const paneEl = hostEl.querySelector(`[data-pane="${paneKey}"]`);
611
+ const tableHost = hostEl.querySelector(`[data-role="${paneKey}-host"]`);
612
+ const footerEl = hostEl.querySelector('[data-role="actions"]');
613
+
614
+ const setStat = (key, value) => {
615
+ const el = hostEl.querySelector(`[data-role="${key}"]`);
616
+ if (el) el.textContent = String(value);
617
+ };
618
+
619
+ return { headerEl, paneEl, tableHost, footerEl, setStat };
620
+ }
621
+
622
+
623
+ export function mountLandingActions(footerEl, actions, hostEl) {
624
+ if (!footerEl) return { teardown: () => {}, refresh: () => {} };
625
+ let current = [];
626
+ const renderInto = (list) => {
627
+ current = (list || []).filter(Boolean);
628
+ footerEl.innerHTML = '';
629
+ for (const a of current) {
630
+ const btn = document.createElement('button');
631
+ btn.type = 'button';
632
+ btn.className = 'twm-action-btn';
633
+ if (a.id) btn.dataset.action = a.id;
634
+ if (a.title) btn.title = a.title;
635
+ const icon = a.icon
636
+ ? `<span class="material-symbols-outlined">${_esc(a.icon)}</span>`
637
+ : '';
638
+ const sc = a.shortcut
639
+ ? ` <span class="twm-action-btn__kbd">[${_esc(a.shortcut.toUpperCase())}]</span>`
640
+ : '';
641
+ btn.innerHTML = `${icon}<span>${_esc(a.label || '')}</span>${sc}`;
642
+ btn.addEventListener('click', (ev) => {
643
+ ev.preventDefault();
644
+ try { a.onClick?.(ev); }
645
+ catch (err) { console.warn('[landing-actions] click failed', err); }
646
+ });
647
+ footerEl.appendChild(btn);
648
+ }
649
+ };
650
+
651
+ const onKey = (ev) => {
652
+ if (!footerEl.isConnected) return;
653
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
654
+ // Scope is owned by the panel-key router; we only guard editable
655
+ // targets so single-letter shortcuts don't fire while typing.
656
+ const t = ev.target;
657
+ const editable = t && (
658
+ t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
659
+ t.tagName === 'SELECT' || t.isContentEditable);
660
+ if (editable) return;
661
+ const k = ev.key.length === 1 ? ev.key.toLowerCase() : ev.key;
662
+ const hit = current.find((a) =>
663
+ a.shortcut && String(a.shortcut).toLowerCase() === k);
664
+ if (!hit) return;
665
+ ev.preventDefault();
666
+ try { hit.onClick?.(ev); }
667
+ catch (err) { console.warn('[landing-actions] shortcut failed', err); }
668
+ };
669
+
670
+ renderInto(actions);
671
+ const scopeEl = hostEl || footerEl.closest('.twm-landing') || footerEl;
672
+ const offKey = registerPanelKeys(scopeEl, onKey);
673
+ return {
674
+ teardown: () => offKey(),
675
+ refresh: renderInto,
676
+ };
677
+ }
678
+
679
+
680
+ /** A DataTable `renderCell` hook that paints inline Edit + Delete icon
681
+ * buttons in the configured action column. Use as:
682
+ *
683
+ * renderCell: actionsCellRenderer(actionsColIdx, { edit: true, delete: true })
684
+ *
685
+ * Other columns fall through to the default text renderer. */
686
+ export function actionsCellRenderer(actionsColIdx, options = { edit: true, delete: true }) {
687
+ return (td, _value, colIdx) => {
688
+ if (colIdx !== actionsColIdx) return false;
689
+ const parts = [];
690
+ if (options.edit) {
691
+ parts.push(`<button type="button" class="twm-row-action"
692
+ data-twm-action="edit"
693
+ title="Edit"
694
+ aria-label="Edit">
695
+ <span class="material-symbols-outlined">edit</span>
696
+ </button>`);
697
+ }
698
+ if (options.delete) {
699
+ parts.push(`<button type="button" class="twm-row-action twm-row-action--danger"
700
+ data-twm-action="delete"
701
+ title="Delete"
702
+ aria-label="Delete">
703
+ <span class="material-symbols-outlined">delete</span>
704
+ </button>`);
705
+ }
706
+ td.classList.add('twm-row-actions-cell');
707
+ td.innerHTML = parts.join('');
708
+ return true;
709
+ };
710
+ }
711
+
712
+
713
+ function _esc(s) {
714
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
715
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
716
+ }[c]));
717
+ }