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,133 @@
1
+ /**
2
+ * desktops.js — independent tile trees per virtual desktop, persisted
3
+ * through the host's `state` capability under the logical key `desktops`.
4
+ * Where that key actually lands is the embedder's business (see the host's
5
+ * `resolvePath`); this module only names it.
6
+ *
7
+ * Each desktop = { id, label, tree (TileTree), windows ([]), panels }.
8
+ * Switching desktops swaps the active tree under the renderer.
9
+ */
10
+
11
+ import { TileTree, makeLeaf } from './tile_tree.js';
12
+
13
+ const DEFAULT_PANEL_STATE = {
14
+ left: true,
15
+ right: true,
16
+ bottom: true,
17
+ };
18
+
19
+ /**
20
+ * `seed()` yields the leaf a fresh (or emptied) desktop starts with. It comes
21
+ * from the taxonomy's root kind — see WindowManager, which supplies it. The
22
+ * WM used to hardcode `{ kind: 'home', title: 'Home' }` here, which meant an
23
+ * embedder with a different ontology still got an EcoAgent Home tile it had
24
+ * never registered, and got the content_registry placeholder instead. There is
25
+ * deliberately NO default: a silent 'home' fallback is the exact bug this
26
+ * parameter exists to remove.
27
+ */
28
+ function _makeDesktop(label, seed) {
29
+ const tree = new TileTree();
30
+ tree.setRoot(makeLeaf(seed()));
31
+ return {
32
+ id: `desk-${Math.random().toString(36).slice(2, 8)}`,
33
+ label,
34
+ tree,
35
+ windows: [],
36
+ // boot default: left + right + bottom all open. Names are
37
+ // assigned by wm._canonicalize via PANEL_TITLES.
38
+ panels: { ...DEFAULT_PANEL_STATE },
39
+ };
40
+ }
41
+
42
+ export class DesktopManager {
43
+ /** @param {{seed: () => object}} opts `seed` builds the root leaf. Required. */
44
+ constructor({ seed } = {}) {
45
+ if (typeof seed !== 'function') {
46
+ throw new Error('DesktopManager: a `seed` function is required (the taxonomy root leaf)');
47
+ }
48
+ this.seed = seed;
49
+ this.desktops = [_makeDesktop('1', seed)];
50
+ this.activeIdx = 0;
51
+ }
52
+
53
+ active() { return this.desktops[this.activeIdx]; }
54
+
55
+ switchTo(idx) {
56
+ if (idx < 0 || idx >= this.desktops.length) return false;
57
+ if (idx === this.activeIdx) return false;
58
+ this.activeIdx = idx;
59
+ return true;
60
+ }
61
+
62
+ ensureCount(n) {
63
+ while (this.desktops.length < n) {
64
+ this.desktops.push(_makeDesktop(String(this.desktops.length + 1), this.seed));
65
+ }
66
+ }
67
+
68
+ addDesktop(label = null) {
69
+ const d = _makeDesktop(label || String(this.desktops.length + 1), this.seed);
70
+ this.desktops.push(d);
71
+ return d;
72
+ }
73
+
74
+ serialize() {
75
+ return {
76
+ activeIdx: this.activeIdx,
77
+ desktops: this.desktops.map((d) => ({
78
+ id: d.id, label: d.label,
79
+ tree: d.tree.serialize(),
80
+ panels: { ...d.panels },
81
+ // windows serialized at WM level since we don't own
82
+ // managed-window state in this module.
83
+ })),
84
+ };
85
+ }
86
+
87
+ static deserialize(blob, { seed } = {}) {
88
+ const m = new DesktopManager({ seed });
89
+ if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
90
+ m.desktops = blob.desktops.map((raw) => ({
91
+ id: raw.id || `desk-${Math.random().toString(36).slice(2,8)}`,
92
+ label: raw.label || '?',
93
+ tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
94
+ windows: [],
95
+ panels: { ...DEFAULT_PANEL_STATE, ...(raw.panels || {}) },
96
+ }));
97
+ // Empty tree → seed with the taxonomy root so the desktop is usable.
98
+ for (const d of m.desktops) {
99
+ if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
100
+ }
101
+ m.activeIdx = Math.min(Math.max(0, blob.activeIdx | 0), m.desktops.length - 1);
102
+ return m;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Persistence helpers. They take a host `state` capability; a host without
108
+ * one is legal and they no-op. We don't fail loudly — the WM stays functional
109
+ * against a host that cannot persist (e.g. the standalone demo).
110
+ */
111
+
112
+ const DESKTOPS_KEY = 'desktops';
113
+
114
+ export async function loadDesktops(state) {
115
+ if (!state) return null;
116
+ try {
117
+ return await state.read(DESKTOPS_KEY);
118
+ } catch (err) {
119
+ console.warn('[desktops] load failed', err);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ export async function saveDesktops(state, blob) {
125
+ if (!state) return false;
126
+ try {
127
+ await state.write(DESKTOPS_KEY, blob);
128
+ return true;
129
+ } catch (err) {
130
+ console.warn('[desktops] save failed', err);
131
+ return false;
132
+ }
133
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * entity_sources.js — the entity-source PORT.
3
+ *
4
+ * The command palette, the tile hamburger menu, and any other "find me a
5
+ * thing" surface need a list of every searchable entity. Where those
6
+ * entities come from — which endpoint returns them, what a row looks
7
+ * like, what to call it — is the EMBEDDER's knowledge, not the shell's.
8
+ *
9
+ * So the source list is INJECTED. The embedder describes its sources and
10
+ * hands them to `createEntityCatalog()` at boot. Nothing in this file
11
+ * names an entity type or a bridge route.
12
+ *
13
+ * ## EntitySource
14
+ *
15
+ * navKind The content kind used to open a row via `wm.navigate`.
16
+ * The taxonomy resolves its icon + label.
17
+ *
18
+ * list `(api) => Promise<rawList>` — per-source fetcher. Used by
19
+ * the fan-out path, and by `loadOne` when a caller wants a
20
+ * subset rather than the whole catalog.
21
+ *
22
+ * pluck? `(raw) => Array` — extract the actual list when the
23
+ * endpoint wraps it. Defaults to "raw is an array, else
24
+ * raw.items if that's an array, else []".
25
+ *
26
+ * shape `(row) => { id, label, hint? } | null` — normalise one raw
27
+ * row to the record shape every search surface consumes.
28
+ * Return `null` to skip the row.
29
+ *
30
+ * ## Aggregation
31
+ *
32
+ * An embedder whose backend can answer for every source in ONE call
33
+ * passes `aggregate: (api) => Promise<{ [navKind]: rawList } | null>`.
34
+ * The catalog prefers it and fans out only for the navKinds it failed to
35
+ * answer for. Absent ⇒ straight fan-out. Absent capability, absent key:
36
+ * the shell never probes for a named endpoint of its own accord.
37
+ *
38
+ * Errors from any single source are logged and treated as "empty", so
39
+ * one broken endpoint can't blank the entire palette.
40
+ */
41
+
42
+ export function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
43
+ if (!Array.isArray(sources)) {
44
+ throw new TypeError('createEntityCatalog: `sources` must be an array');
45
+ }
46
+ for (const s of sources) {
47
+ if (!s?.navKind || typeof s.list !== 'function' || typeof s.shape !== 'function') {
48
+ throw new TypeError(
49
+ `createEntityCatalog: source '${s?.navKind ?? '?'}' needs navKind + list() + shape()`);
50
+ }
51
+ }
52
+ if (aggregate != null && typeof aggregate !== 'function') {
53
+ throw new TypeError('createEntityCatalog: `aggregate` must be a function when present');
54
+ }
55
+
56
+ const _sources = Object.freeze(sources.map((s) => Object.freeze({ ...s })));
57
+ const _byKind = new Map(_sources.map((s) => [s.navKind, s]));
58
+ const _aliases = Object.freeze({ ...aliases });
59
+
60
+ const get = (navKind) => _byKind.get(navKind) || null;
61
+
62
+ /** Unwrap one source's raw payload into a row array. */
63
+ const _extract = (src, raw) => {
64
+ const list = src.pluck
65
+ ? src.pluck(raw)
66
+ : (Array.isArray(raw) ? raw
67
+ : Array.isArray(raw?.items) ? raw.items
68
+ : []);
69
+ return Array.isArray(list) ? list : [];
70
+ };
71
+
72
+ /** Shape a source's rows, dropping the ones `shape` rejects or
73
+ * throws on. Shared by the palette AND the tile menu so their
74
+ * semantics cannot drift apart — the menu used to re-implement
75
+ * this loop by hand. */
76
+ const shapeRows = (navKind, rows) => {
77
+ const src = _byKind.get(navKind);
78
+ if (!src || !Array.isArray(rows)) return [];
79
+ const out = [];
80
+ for (const row of rows) {
81
+ let shaped;
82
+ try { shaped = src.shape(row); }
83
+ catch (err) {
84
+ console.warn('[entity-catalog]', navKind, 'shape failed', err, row);
85
+ continue;
86
+ }
87
+ if (!shaped || !shaped.id) continue;
88
+ out.push(shaped);
89
+ }
90
+ return out;
91
+ };
92
+
93
+ /** Fetch ONE source's raw rows. Used by surfaces that only care
94
+ * about a subset of the catalog. */
95
+ const loadOne = async (navKind, api) => {
96
+ const src = _byKind.get(navKind);
97
+ if (!src || !api) return [];
98
+ try { return _extract(src, await src.list(api)); }
99
+ catch (err) {
100
+ console.warn('[entity-catalog]', navKind, 'fetch failed', err);
101
+ return [];
102
+ }
103
+ };
104
+
105
+ /** Fetch every source's raw rows, grouped by navKind.
106
+ *
107
+ * The aggregator runs first when the embedder supplied one; the
108
+ * fan-out only fires for sources it didn't answer for (it threw, it
109
+ * isn't there, or it doesn't know a newly-added navKind yet).
110
+ *
111
+ * Shape: `{ [navKind]: rawRow[] }` — every navKind is always
112
+ * present (empty on failure) so consumers can destructure safely. */
113
+ const loadGrouped = async (api) => {
114
+ const out = {};
115
+ for (const s of _sources) out[s.navKind] = [];
116
+ if (!api) return out;
117
+
118
+ let bulk = null;
119
+ if (aggregate) {
120
+ try { bulk = await aggregate(api); }
121
+ catch (err) {
122
+ console.warn('[entity-catalog] aggregate failed, falling back to fan-out', err);
123
+ }
124
+ if (bulk && typeof bulk !== 'object') bulk = null;
125
+ }
126
+
127
+ const missing = [];
128
+ for (const src of _sources) {
129
+ const raw = bulk ? bulk[src.navKind] : undefined;
130
+ if (raw == null) { missing.push(src); continue; }
131
+ out[src.navKind] = _extract(src, raw);
132
+ }
133
+ if (missing.length === 0) return out;
134
+
135
+ await Promise.all(missing.map(async (src) => {
136
+ out[src.navKind] = await loadOne(src.navKind, api);
137
+ }));
138
+ return out;
139
+ };
140
+
141
+ /** Flat-and-shaped form for search surfaces. Builds on
142
+ * `loadGrouped` so there is a single fetch path and the palette /
143
+ * explorer can't drift apart in coverage. */
144
+ const loadAll = async (api) => {
145
+ const grouped = await loadGrouped(api);
146
+ const out = [];
147
+ for (const src of _sources) {
148
+ for (const shaped of shapeRows(src.navKind, grouped[src.navKind] || [])) {
149
+ out.push({ kind: src.navKind, ...shaped });
150
+ }
151
+ }
152
+ return out;
153
+ };
154
+
155
+ /** Resolve a user-typed type prefix to a list of canonical navKinds.
156
+ * Accepts the embedder's aliases first, then navKinds verbatim,
157
+ * then the underscore spelling of a hyphenated kind. Returns an
158
+ * array of navKinds on hit, `null` otherwise. */
159
+ const resolveTypePrefix = (prefix) => {
160
+ const p = String(prefix || '').toLowerCase().trim();
161
+ if (!p) return null;
162
+ if (_aliases[p]) return _aliases[p];
163
+ for (const src of _sources) {
164
+ if (src.navKind === p) return [src.navKind];
165
+ if (src.navKind.replace(/-/g, '_') === p) return [src.navKind];
166
+ }
167
+ return null;
168
+ };
169
+
170
+ return Object.freeze({
171
+ sources: _sources,
172
+ get,
173
+ loadOne,
174
+ loadGrouped,
175
+ loadAll,
176
+ shapeRows,
177
+ resolveTypePrefix,
178
+ });
179
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * keymap.js — Alt-based shortcuts for the tiling window manager.
3
+ *
4
+ * Alt+H split focused tile horizontally (new empty pane)
5
+ * Alt+V split focused tile vertically (new empty pane)
6
+ * Alt+Shift+H open focused tile's content in a horizontal split
7
+ * Alt+Shift+V open focused tile's content in a vertical split
8
+ * Alt+T open focused tile's content in a new tab
9
+ * Alt+N open focused tile's content in a new window
10
+ * Alt+W close focused tile
11
+ * Alt+F promote focused tile <-> managed window (moves it)
12
+ * Alt+ArrowLeft/Right/Up/Down move focus
13
+ * Alt+Shift+Arrow… swap focused tile with neighbour
14
+ * Alt+1..9 switch desktop
15
+ * Alt+Shift+1..9 move focused tile to desktop N
16
+ * Ctrl+ArrowLeft/Right iterate virtual desktops
17
+ * Shift+ArrowLeft/Right iterate bottom-panel tabs (when present)
18
+ * Ctrl+Tab / Ctrl+Shift+Tab next / previous main-panel tab (focused tile)
19
+ * F1..F8 jump to the Nth top-nav page
20
+ * Ctrl+K open command palette
21
+ * ? open Help (lists every shortcut) — bound globally
22
+ * by help_modal.js, not here
23
+ * Esc close palette / managed-modal
24
+ *
25
+ * The full user-facing reference is the Help modal's "Keyboard shortcuts"
26
+ * topic (ui/js/help/help_service.js) — keep that table in sync when
27
+ * binding new chords here.
28
+ *
29
+ * The handler refuses to act when focus is in an editable field, unless
30
+ * the chord uses the Alt modifier (which is never typed into a field).
31
+ */
32
+
33
+ export function installKeymap({ wm, palette }) {
34
+ document.addEventListener('keydown', (e) => {
35
+ const inField = e.target?.closest?.(
36
+ 'input, textarea, select, [contenteditable="true"]');
37
+
38
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !e.shiftKey) {
39
+ e.preventDefault();
40
+ palette.toggle();
41
+ return;
42
+ }
43
+ // ── Ctrl+Tab / Ctrl+Shift+Tab → next / previous main-panel tab in the
44
+ // focused tile (wraps). Works everywhere (Tab isn't a text key here);
45
+ // no-op when the tile has a single tab.
46
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'Tab') {
47
+ e.preventDefault();
48
+ wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
49
+ return;
50
+ }
51
+ if (e.key === 'Escape') {
52
+ if (palette.isOpen()) { palette.close(); return; }
53
+ // Let managed-window system handle its own Esc.
54
+ }
55
+ // Backspace = walk one step up the breadcrumb hierarchy. Skips
56
+ // when the user is typing in a field so it doesn't steal
57
+ // text-deletion.
58
+ if (e.key === 'Backspace' && !inField && !e.metaKey && !e.ctrlKey) {
59
+ e.preventDefault();
60
+ wm.navigateBack?.();
61
+ return;
62
+ }
63
+
64
+ // ── F1..F8 → jump straight to the Nth top-nav page. F-keys never
65
+ // type, but we still skip while a field is focused so editing
66
+ // flows (query editor, filters) keep keyboard focus.
67
+ const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
68
+ if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
69
+ const btns = document.querySelectorAll(
70
+ '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn');
71
+ const idx = Number(fMatch[1]) - 1;
72
+ if (idx < btns.length) {
73
+ e.preventDefault();
74
+ btns[idx].click();
75
+ return;
76
+ }
77
+ }
78
+
79
+ // ── Shift+Left/Right → previous/next bottom-panel tab. No-op when
80
+ // no bottom panel is mounted. Skipped in fields so Shift+Arrow
81
+ // keeps selecting text there.
82
+ if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField
83
+ && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
84
+ const tabs = [...document.querySelectorAll('.twm-bp__tabs .twm-bp__tab')];
85
+ if (tabs.length) {
86
+ const cur = tabs.findIndex((t) => t.classList.contains('twm-bp__tab--on'));
87
+ const dir = e.key === 'ArrowRight' ? 1 : -1;
88
+ const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
89
+ e.preventDefault();
90
+ tabs[next].click();
91
+ return;
92
+ }
93
+ }
94
+
95
+ // ── Ctrl+Left/Right → iterate virtual desktops (wraps).
96
+ if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField
97
+ && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
98
+ e.preventDefault();
99
+ wm.cycleDesktop(e.key === 'ArrowRight' ? 1 : -1);
100
+ return;
101
+ }
102
+
103
+ if (!e.altKey) return;
104
+ // Browser Alt+letter triggers menu-access in some browsers — always preventDefault.
105
+ const key = e.key.toLowerCase();
106
+
107
+ // Desktop switch / move — Alt+digit
108
+ if (/^[1-9]$/.test(e.key)) {
109
+ e.preventDefault();
110
+ const idx = Number(e.key) - 1;
111
+ if (e.shiftKey) wm.moveFocusedToDesktop(idx);
112
+ else wm.switchDesktop(idx);
113
+ return;
114
+ }
115
+
116
+ switch (key) {
117
+ // Plain Alt+H/V split into an empty pane; with Shift they open
118
+ // the focused tile's own content in the new split.
119
+ case 'h':
120
+ e.preventDefault();
121
+ e.shiftKey ? wm.splitFocusedWith('h') : wm.split('h');
122
+ return;
123
+ case 'v':
124
+ e.preventDefault();
125
+ e.shiftKey ? wm.splitFocusedWith('v') : wm.split('v');
126
+ return;
127
+ case 't': e.preventDefault(); wm.openFocusedInTab(); return;
128
+ case 'n': e.preventDefault(); wm.openFocusedInWindow(); return;
129
+ case 'w': e.preventDefault(); wm.closeFocused(); return;
130
+ case 'f': e.preventDefault(); wm.toggleManagedFocused(); return;
131
+ case 'arrowleft':
132
+ e.preventDefault();
133
+ e.shiftKey ? wm.moveFocused('left') : wm.focusDir('left');
134
+ return;
135
+ case 'arrowright':
136
+ e.preventDefault();
137
+ e.shiftKey ? wm.moveFocused('right') : wm.focusDir('right');
138
+ return;
139
+ case 'arrowup':
140
+ e.preventDefault();
141
+ e.shiftKey ? wm.moveFocused('up') : wm.focusDir('up');
142
+ return;
143
+ case 'arrowdown':
144
+ e.preventDefault();
145
+ e.shiftKey ? wm.moveFocused('down') : wm.focusDir('down');
146
+ return;
147
+ }
148
+
149
+ // Suppress accidental browser menu-access on bare Alt+letter
150
+ // chords we don't bind (so the user doesn't get a Firefox/Chromium
151
+ // menu popping open mid-flow).
152
+ if (key.length === 1 && /[a-z]/.test(key) && !inField) {
153
+ e.preventDefault();
154
+ }
155
+ });
156
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * kind_taxonomy.js — the taxonomy PORT.
3
+ *
4
+ * A "kind" is a content type the user can navigate to. The tiling shell
5
+ * needs to know four things about one: what to call it, what icon it
6
+ * wears, which top-nav slot owns it, and what sits above it. It must
7
+ * NOT know what any of them mean.
8
+ *
9
+ * So the taxonomy is INJECTED, not imported. The embedder describes its
10
+ * own ontology and hands it to `createTaxonomy()` at boot; the shell
11
+ * closes over the result. Nothing in this file names a kind, and the
12
+ * framework runs unchanged on a completely different vocabulary.
13
+ *
14
+ * Consumed by:
15
+ *
16
+ * - install.js → top-nav buttons + active-state highlight
17
+ * - tile_breadcrumb.js → breadcrumb segments
18
+ * - wm.js navigateBack → Backspace parent
19
+ * - command_palette.js → palette chip strip + row icons
20
+ * - tile_tab_menu.js → which sources a tile's hamburger lists
21
+ *
22
+ * ## KindDef
23
+ *
24
+ * label Long form, used in breadcrumb segments + palette.
25
+ * shortLabel Optional ≤4-char chip text for the top-nav.
26
+ * Falls back to `label` when omitted.
27
+ * icon Material Symbols name.
28
+ * topNav Top-nav category this kind belongs to. Determines which
29
+ * button lights up + which segment the breadcrumb inserts
30
+ * after the root. Omit on the top-nav kind itself.
31
+ * isTopNav True iff this kind owns a slot in the top-nav strip.
32
+ * order Sort key for top-nav display (lower = leftward).
33
+ * appGlobal True iff the kind is not scoped to the root entity (a
34
+ * preferences page, say). Its breadcrumb omits the root.
35
+ * sources Entity-source ids the tile hamburger lists when a tile
36
+ * is on this page. Only meaningful on top-nav kinds; the
37
+ * root's list is the fallback.
38
+ *
39
+ * ancestors? `(props) => [{ kind, id, label? }]` — the entity-level
40
+ * ancestors of ONE instance, root-most first, EXCLUDING
41
+ * the top-nav segment and the node itself. This is the
42
+ * hook that replaces every hardcoded "split the id to
43
+ * find its parent" branch: naming conventions are the
44
+ * embedder's business, not the shell's.
45
+ * labelOf? `(props) => string` — display label for ONE instance.
46
+ * Defaults to `props.label`, then `props.id`.
47
+ */
48
+
49
+ /** Build a taxonomy from an embedder's kind map. Validated eagerly and
50
+ * frozen — a typo in a `topNav` pointer is a boot error, not a chrome
51
+ * surface that silently renders nothing six clicks later. */
52
+ export function createTaxonomy({ kinds, root } = {}) {
53
+ if (!kinds || typeof kinds !== 'object') {
54
+ throw new TypeError('createTaxonomy: `kinds` must be an object of KindDefs');
55
+ }
56
+ if (!root || !kinds[root]) {
57
+ throw new Error(`createTaxonomy: root kind '${root}' is not in the taxonomy`);
58
+ }
59
+ for (const [kind, def] of Object.entries(kinds)) {
60
+ if (!def || typeof def !== 'object') {
61
+ throw new TypeError(`createTaxonomy: '${kind}' is not a KindDef`);
62
+ }
63
+ if (def.topNav && !kinds[def.topNav]) {
64
+ throw new Error(
65
+ `createTaxonomy: '${kind}' hangs off unknown topNav '${def.topNav}'`);
66
+ }
67
+ if (def.isTopNav && def.topNav) {
68
+ throw new Error(
69
+ `createTaxonomy: '${kind}' is both a top-nav kind and owned by one`);
70
+ }
71
+ }
72
+ const _kinds = Object.freeze({ ...kinds });
73
+
74
+ /** Lookup. `undefined` for unknown kinds — chrome code treats that
75
+ * as "render the kind name verbatim". */
76
+ const meta = (kind) => _kinds[kind];
77
+
78
+ /** Top-nav category id that owns `kind`. For a top-nav kind itself,
79
+ * returns the same kind. `undefined` when the kind isn't in the
80
+ * taxonomy (caller renders a fallback). */
81
+ const topNavFor = (kind) => {
82
+ const m = _kinds[kind];
83
+ if (!m) return undefined;
84
+ return m.isTopNav ? kind : m.topNav;
85
+ };
86
+
87
+ /** Backspace target — walk one step up the KIND hierarchy. A
88
+ * top-nav page goes to the root; everything else goes to its
89
+ * top-nav. The root itself returns `null`. */
90
+ const parentKindFor = (kind) => {
91
+ if (kind === root) return null;
92
+ const m = _kinds[kind];
93
+ if (!m) return null;
94
+ if (m.isTopNav) return root;
95
+ return m.topNav || root;
96
+ };
97
+
98
+ /** Entity-level ancestors of one instance, root-most first. Empty
99
+ * unless the KindDef supplies an `ancestors` hook. Normalised to
100
+ * `{ kind, props }` so callers hand the result straight to
101
+ * `wm.navigate`. A throwing hook is a warning, never a crash: the
102
+ * breadcrumb degrades to its generic form. */
103
+ const ancestors = (kind, props) => {
104
+ const fn = _kinds[kind]?.ancestors;
105
+ if (typeof fn !== 'function') return [];
106
+ let out;
107
+ try { out = fn(props || {}); }
108
+ catch (err) {
109
+ console.warn('[taxonomy] ancestors() threw for', kind, err);
110
+ return [];
111
+ }
112
+ if (!Array.isArray(out)) return [];
113
+ return out
114
+ .filter((a) => a && a.kind)
115
+ .map((a) => ({ kind: a.kind, props: { id: a.id, label: a.label ?? a.id } }));
116
+ };
117
+
118
+ /** The nearest entity-level ancestor, or `null`. */
119
+ const parentOf = (kind, props) => {
120
+ const chain = ancestors(kind, props);
121
+ return chain.length ? chain[chain.length - 1] : null;
122
+ };
123
+
124
+ /** Display label for one instance of `kind`. */
125
+ const labelOf = (kind, props) => {
126
+ const fn = _kinds[kind]?.labelOf;
127
+ if (typeof fn === 'function') {
128
+ try {
129
+ const s = fn(props || {});
130
+ if (s) return String(s);
131
+ } catch (err) {
132
+ console.warn('[taxonomy] labelOf() threw for', kind, err);
133
+ }
134
+ }
135
+ if (props?.label) return String(props.label);
136
+ return props?.id != null ? String(props.id) : '';
137
+ };
138
+
139
+ /** Entity-source ids a tile on `kind` should offer. Falls back to
140
+ * the root's list, which is the "everything" default. */
141
+ const sourcesFor = (kind) => {
142
+ const s = _kinds[kind]?.sources ?? _kinds[root]?.sources;
143
+ return Array.isArray(s) ? s : [];
144
+ };
145
+
146
+ /** Top-nav entries in display order:
147
+ * { kind, label (short), longLabel, icon }
148
+ * `label` is the chip text; `longLabel` is what the palette shows. */
149
+ const topNavEntries = () =>
150
+ Object.entries(_kinds)
151
+ .filter(([, m]) => m.isTopNav)
152
+ .sort((a, b) => (a[1].order ?? 1000) - (b[1].order ?? 1000))
153
+ .map(([kind, m]) => ({
154
+ kind,
155
+ label: m.shortLabel || m.label,
156
+ longLabel: m.label,
157
+ icon: m.icon,
158
+ }));
159
+
160
+ return Object.freeze({
161
+ root,
162
+ kinds: _kinds,
163
+ meta,
164
+ topNavFor,
165
+ parentKindFor,
166
+ ancestors,
167
+ parentOf,
168
+ labelOf,
169
+ sourcesFor,
170
+ topNavEntries,
171
+ });
172
+ }