flexdesk 0.17.18 → 0.18.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 (118) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7544 -5916
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +23 -1
  6. package/dist/charts.js +10 -6
  7. package/dist/charts.js.map +1 -1
  8. package/dist/chunk-5W4EEKSK.js +778 -0
  9. package/dist/chunk-5W4EEKSK.js.map +7 -0
  10. package/dist/chunk-DNJ3N4T4.js +82 -0
  11. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  12. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  13. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  14. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  15. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  16. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  17. package/dist/chunk-PAI2ZUWV.js +260 -0
  18. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  19. package/dist/chunk-Q7BALEPX.js +527 -0
  20. package/dist/chunk-Q7BALEPX.js.map +7 -0
  21. package/dist/chunk-QXAOTGCC.js +40 -0
  22. package/dist/chunk-QXAOTGCC.js.map +7 -0
  23. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  24. package/dist/chunk-RKVYIXDC.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  26. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  27. package/dist/chunk-UKP22RUG.js +219 -0
  28. package/dist/chunk-UKP22RUG.js.map +7 -0
  29. package/dist/chunk-VLOAI3HS.js +43 -0
  30. package/dist/chunk-VLOAI3HS.js.map +7 -0
  31. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  32. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  33. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  34. package/dist/chunk-ZL3VKINP.js.map +7 -0
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7544 -5916
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +23 -1
  46. package/dist/widgets.js +3967 -1188
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/chat-shell.md +198 -0
  53. package/docs/components/conversation.md +10 -0
  54. package/docs/components/filter-builder.md +246 -0
  55. package/docs/components/forms-and-dialogs.md +22 -0
  56. package/docs/components/git.md +355 -0
  57. package/docs/components/input-fields.md +167 -2
  58. package/docs/components/keymap.md +8 -0
  59. package/docs/components/menus.md +82 -2
  60. package/docs/components/panels-and-desktops.md +16 -5
  61. package/docs/components/shell.md +104 -1
  62. package/docs/components/workspace-picker.md +149 -0
  63. package/docs/concepts/building-and-packaging.md +2 -2
  64. package/docs/concepts/packages-and-entry-points.md +14 -13
  65. package/docs/concepts/state-guard.md +3 -3
  66. package/docs/quickstart.md +1 -1
  67. package/git.js +13 -0
  68. package/package.json +6 -1
  69. package/src/flow/kit/dom.js +28 -119
  70. package/src/flow/kit/step_picker.js +1 -1
  71. package/src/flow/kit/value_picker.js +1 -1
  72. package/src/flow/outline/menu.js +1 -1
  73. package/src/git/diff_viewer.js +212 -0
  74. package/src/git/diff_window.js +51 -0
  75. package/src/git/git_graph.js +401 -0
  76. package/src/git/git_ops.js +61 -0
  77. package/src/git/git_panel.js +755 -0
  78. package/src/git/index.js +9 -0
  79. package/src/git/line_diff.js +202 -0
  80. package/src/tiles/tile_base.js +5 -4
  81. package/src/tiling/desktops.js +10 -8
  82. package/src/tiling/keymap.js +6 -5
  83. package/src/tiling/kind_taxonomy.js +15 -2
  84. package/src/tiling/shell.js +124 -17
  85. package/src/tiling/tile_renderer.js +2 -3
  86. package/src/ui/components/action_dropdown.js +4 -78
  87. package/src/ui/components/anchored_layer.js +255 -0
  88. package/src/ui/components/chat_shell.js +920 -0
  89. package/src/ui/components/chat_state_machine.js +223 -0
  90. package/src/ui/components/choice_controls.js +45 -2
  91. package/src/ui/components/color_field.js +3 -2
  92. package/src/ui/components/color_list_field.js +3 -2
  93. package/src/ui/components/combobox.js +422 -0
  94. package/src/ui/components/conversation_view.js +17 -2
  95. package/src/ui/components/data_table.js +2 -2
  96. package/src/ui/components/filter_builder.js +766 -0
  97. package/src/ui/components/filter_model.js +614 -0
  98. package/src/ui/components/managed_window.js +24 -47
  99. package/src/ui/components/modal.js +79 -109
  100. package/src/ui/components/option_list.js +160 -0
  101. package/src/ui/components/popover.js +94 -0
  102. package/src/ui/components/recent_items.js +169 -0
  103. package/src/ui/components/select.js +322 -0
  104. package/src/ui/components/tag_input.js +77 -8
  105. package/src/ui/components/workspace_picker.js +1155 -0
  106. package/src/ui/utils/fire_event.js +17 -0
  107. package/src/ui/utils/focusable.js +25 -0
  108. package/src/ui/utils/modal_host.js +27 -0
  109. package/widgets.js +12 -2
  110. package/css/base.css +0 -10547
  111. package/css/overrides.css +0 -2596
  112. package/dist/chunk-7JI7HHSY.js +0 -107
  113. package/dist/chunk-7JI7HHSY.js.map +0 -7
  114. package/dist/chunk-MIW7D6WC.js +0 -526
  115. package/dist/chunk-MIW7D6WC.js.map +0 -7
  116. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  117. package/dist/chunk-XJNADRGR.js.map +0 -7
  118. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,9 @@
1
+ /**
2
+ * src/git — the modules behind @flexdesk/git.
3
+ */
4
+
5
+ export * from './line_diff.js';
6
+ export * from './diff_viewer.js';
7
+ export * from './diff_window.js';
8
+ export * from './git_graph.js';
9
+ export * from './git_panel.js';
@@ -0,0 +1,202 @@
1
+ /**
2
+ * line_diff.js — what changed between two texts, as lines to draw.
3
+ *
4
+ * Two ways in, one shape out:
5
+ *
6
+ * computeLineDiff(before, after) the two texts themselves, compared here
7
+ * parseUnifiedDiff(text) what `git diff` printed, read here
8
+ *
9
+ * Both answer an array of DiffLine:
10
+ *
11
+ * { kind: 'context' | 'add' | 'del', oldLine, newLine, text }
12
+ * { kind: 'gap', oldLine: null, newLine: null, text: '', hidden } unchanged lines left out
13
+ * { kind: 'hunk' | 'meta' | 'file', oldLine: null, newLine: null, text }
14
+ *
15
+ * `oldLine` and `newLine` are 1-based, and null on the side a line is not on.
16
+ * There is no DOM here and nothing about what the text is: a consumer that
17
+ * compares only part of a file says where that part starts (`beforeStart`,
18
+ * `afterStart`) and the numbers are the file's.
19
+ */
20
+
21
+ /** Past this many differing lines a side, computeLineDiff answers null rather than compare. */
22
+ export const LINE_DIFF_LIMIT = 1500;
23
+
24
+ /**
25
+ * A text as its lines. CRLF and LF are the same line end, and a final line end
26
+ * does not make a last empty line.
27
+ * @param {string|null|undefined} text
28
+ * @returns {string[]}
29
+ */
30
+ export function splitLines(text) {
31
+ const s = String(text ?? '');
32
+ if (s === '') return [];
33
+ const lines = s.split(/\r?\n/);
34
+ if (lines[lines.length - 1] === '') lines.pop();
35
+ return lines;
36
+ }
37
+
38
+ /** The edit script of two line arrays, by longest common subsequence: deletions before additions. */
39
+ function editScript(a, b) {
40
+ const n = a.length;
41
+ const m = b.length;
42
+ const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
43
+ for (let i = n - 1; i >= 0; i--) {
44
+ for (let j = m - 1; j >= 0; j--) {
45
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
46
+ }
47
+ }
48
+ const ops = [];
49
+ let i = 0;
50
+ let j = 0;
51
+ while (i < n || j < m) {
52
+ if (i < n && j < m && a[i] === b[j]) { ops.push(['context', i, j]); i++; j++; }
53
+ else if (i < n && (j >= m || lcs[i + 1][j] >= lcs[i][j + 1])) { ops.push(['del', i, -1]); i++; }
54
+ else { ops.push(['add', -1, j]); j++; }
55
+ }
56
+ return ops;
57
+ }
58
+
59
+ /**
60
+ * Compare two texts line by line.
61
+ *
62
+ * Unchanged lines at the start and the end are set aside first, so `maxLines`
63
+ * limits the part that actually differs: a long file with a small edit is
64
+ * compared, however long it is.
65
+ *
66
+ * @param {string|null|undefined} before
67
+ * @param {string|null|undefined} after
68
+ * @param {Object} [options]
69
+ * @param {number} [options.context=3] - Unchanged lines kept around each change; `Infinity` keeps them all.
70
+ * @param {number} [options.maxLines=LINE_DIFF_LIMIT]
71
+ * @param {number} [options.beforeStart=1] - The line number of `before`'s first line.
72
+ * @param {number} [options.afterStart=1] - The line number of `after`'s first line.
73
+ * @returns {Array<Object>|null} DiffLines; `[]` when the texts have the same lines, `null` when too long to compare.
74
+ */
75
+ export function computeLineDiff(before, after, {
76
+ context = 3,
77
+ maxLines = LINE_DIFF_LIMIT,
78
+ beforeStart = 1,
79
+ afterStart = 1,
80
+ } = {}) {
81
+ const a = splitLines(before);
82
+ const b = splitLines(after);
83
+
84
+ let head = 0;
85
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
86
+ let tail = 0;
87
+ while (tail < a.length - head && tail < b.length - head
88
+ && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
89
+
90
+ const midA = a.slice(head, a.length - tail);
91
+ const midB = b.slice(head, b.length - tail);
92
+ if (midA.length === 0 && midB.length === 0) return [];
93
+ if (midA.length > maxLines || midB.length > maxLines) return null;
94
+
95
+ const ops = [];
96
+ for (let k = 0; k < head; k++) ops.push(['context', k, k]);
97
+ for (const [kind, i, j] of editScript(midA, midB)) {
98
+ ops.push([kind, i < 0 ? -1 : i + head, j < 0 ? -1 : j + head]);
99
+ }
100
+ for (let k = tail; k > 0; k--) ops.push(['context', a.length - k, b.length - k]);
101
+
102
+ // A line is kept when a change is within `context` lines of it, either way.
103
+ const keep = new Uint8Array(ops.length);
104
+ let reach = -1;
105
+ for (let k = 0; k < ops.length; k++) {
106
+ if (ops[k][0] !== 'context') reach = k + context;
107
+ if (k <= reach) keep[k] = 1;
108
+ }
109
+ reach = ops.length;
110
+ for (let k = ops.length - 1; k >= 0; k--) {
111
+ if (ops[k][0] !== 'context') reach = k - context;
112
+ if (k >= reach) keep[k] = 1;
113
+ }
114
+
115
+ const out = [];
116
+ let skipped = 0;
117
+ ops.forEach(([kind, i, j], k) => {
118
+ if (!keep[k]) { skipped++; return; }
119
+ if (skipped > 0 && out.length > 0) {
120
+ out.push({ kind: 'gap', oldLine: null, newLine: null, text: '', hidden: skipped });
121
+ }
122
+ skipped = 0;
123
+ out.push({
124
+ kind,
125
+ oldLine: i < 0 ? null : beforeStart + i,
126
+ newLine: j < 0 ? null : afterStart + j,
127
+ text: j < 0 ? a[i] : b[j],
128
+ });
129
+ });
130
+ return out;
131
+ }
132
+
133
+ const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
134
+
135
+ /**
136
+ * Read a unified diff into DiffLines: `diff --git`, `index`, `---` and `+++`
137
+ * headers are left out, a `@@` line is a `hunk`, and `\ No newline at end of
138
+ * file`, `Binary files … differ` and `rename from …`/`rename to …` are `meta`,
139
+ * so that a rename with no change in it is still something to read. A diff of several files has a
140
+ * `file` line before each file's hunks; a diff of one file has none.
141
+ * @param {string|null|undefined} text
142
+ * @returns {Array<Object>}
143
+ */
144
+ export function parseUnifiedDiff(text) {
145
+ const lines = String(text ?? '').split(/\r?\n/);
146
+ const fileHeaders = lines.filter((l) => l.startsWith('diff --git ')).length;
147
+ const out = [];
148
+ const header = (kind, line) => out.push({ kind, oldLine: null, newLine: null, text: line });
149
+ let oldNo = 0;
150
+ let newNo = 0;
151
+ let oldLeft = 0;
152
+ let newLeft = 0;
153
+ for (const line of lines) {
154
+ if (oldLeft > 0 || newLeft > 0) {
155
+ const sign = line[0];
156
+ const body = line.slice(1);
157
+ if (sign === '-') {
158
+ out.push({ kind: 'del', oldLine: oldNo++, newLine: null, text: body });
159
+ oldLeft--;
160
+ } else if (sign === '+') {
161
+ out.push({ kind: 'add', oldLine: null, newLine: newNo++, text: body });
162
+ newLeft--;
163
+ } else if (sign === '\\') {
164
+ header('meta', line);
165
+ } else {
166
+ // A context line; a tool that strips trailing blanks leaves it empty.
167
+ out.push({ kind: 'context', oldLine: oldNo++, newLine: newNo++, text: body });
168
+ oldLeft--;
169
+ newLeft--;
170
+ }
171
+ continue;
172
+ }
173
+ const hunk = HUNK_RE.exec(line);
174
+ if (hunk) {
175
+ oldNo = Number(hunk[1]);
176
+ newNo = Number(hunk[3]);
177
+ oldLeft = hunk[2] === undefined ? 1 : Number(hunk[2]);
178
+ newLeft = hunk[4] === undefined ? 1 : Number(hunk[4]);
179
+ header('hunk', line);
180
+ } else if (line.startsWith('\\') || /^Binary files .* differ$/.test(line) || /^rename (from|to) /.test(line)) {
181
+ header('meta', line);
182
+ } else if (line.startsWith('diff --git ') && fileHeaders > 1) {
183
+ header('file', / b\/(.*)$/.exec(line)?.[1] ?? line.slice('diff --git '.length));
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * How many lines a diff adds and removes, for a one-glance summary.
191
+ * @param {Array<Object>} lines - DiffLines.
192
+ * @returns {{ added: number, removed: number }}
193
+ */
194
+ export function diffStats(lines) {
195
+ let added = 0;
196
+ let removed = 0;
197
+ for (const l of lines) {
198
+ if (l.kind === 'add') added++;
199
+ else if (l.kind === 'del') removed++;
200
+ }
201
+ return { added, removed };
202
+ }
@@ -10,6 +10,7 @@ import { openRawTracesWindow } from '../charts/plot_popout_window.js';
10
10
  import { formatVariableLabel } from './config_schema.js';
11
11
  import { ActionDropdown } from '../ui/components/action_dropdown.js';
12
12
  import { modalHost } from '../ui/components/modal.js';
13
+ import { fireEvent } from '../ui/utils/fire_event.js';
13
14
  // The host is INJECTED, not fetched from a global.
14
15
  //
15
16
  // This imported `appHost()` from ui/js/ecoagent/app_host.js — a tile reaching into
@@ -771,7 +772,7 @@ export class TileBase {
771
772
  });
772
773
 
773
774
  // Also dispatch DOM event for alternative handling
774
- this.element?.dispatchEvent(new CustomEvent('tile:config-request', {
775
+ if (this.element) fireEvent(this.element, 'tile:config-request', {
775
776
  bubbles: true,
776
777
  detail: {
777
778
  tileId: this.id,
@@ -779,7 +780,7 @@ export class TileBase {
779
780
  config: this.config,
780
781
  schema: this.getConfigSchema()
781
782
  }
782
- }));
783
+ });
783
784
  }
784
785
 
785
786
  /**
@@ -791,13 +792,13 @@ export class TileBase {
791
792
  const removeBtn = this.element?.querySelector('.tile-remove-btn');
792
793
 
793
794
  // Only dispatch DOM event - let the dashboard handle confirmation
794
- this.element?.dispatchEvent(new CustomEvent('tile:remove-request', {
795
+ if (this.element) fireEvent(this.element, 'tile:remove-request', {
795
796
  bubbles: true,
796
797
  detail: {
797
798
  tileId: this.id,
798
799
  anchorElement: removeBtn
799
800
  }
800
- }));
801
+ });
801
802
  }
802
803
 
803
804
  /**
@@ -217,11 +217,11 @@ export class DesktopManager {
217
217
  * nothing, and an app can put almost anything in a title.
218
218
  *
219
219
  * And strict is not hostile to an old blob. A key the loader has always given
220
- * a default may be absent: a desktop's `id`, `label` and `panels`, the keys
221
- * later releases added (`panelShares`, `group`, `fixed`), and a leaf's `tabs`
222
- * and `pageTabs`. What `serialize()` has always written and the loader cannot
223
- * do without (`desktops`, a desktop's `tree`, a split's `dir`, `children` and
224
- * `sizes`) may not.
220
+ * a default may be absent: a desktop's `id`, `label`, `tree` and `panels`, the
221
+ * keys later releases added (`panelShares`, `group`, `fixed`), and a leaf's
222
+ * `tabs` and `pageTabs`. What `serialize()` has always written and the loader
223
+ * cannot do without (`desktops`, a split's `dir`, `children` and `sizes`) may
224
+ * not.
225
225
  */
226
226
  static deserialize(blob, { seed, panelDefaults = null } = {}) {
227
227
  const m = new DesktopManager({ seed, panelDefaults });
@@ -231,9 +231,11 @@ export class DesktopManager {
231
231
  m.desktops = blob.desktops.map((raw, i) => {
232
232
  const at = `desktops[${i}]`;
233
233
  _expect(_isRecord(raw), at, 'a desktop, an object', raw);
234
- // `null` is a desktop with nothing on it (serialize() writes that);
235
- // a desktop with no `tree` key at all is not one serialize() writes.
236
- _expect(raw.tree === null || (_isRecord(raw.tree) && _isRecord(raw.tree.root)),
234
+ // `null` (serialize() writes that) and no `tree` key at all (a host
235
+ // that composes the blob writes that) are a desktop with nothing on
236
+ // it, which is seeded below. Refusing one would set its siblings
237
+ // aside with it, and the next save would overwrite them.
238
+ _expect(raw.tree == null || (_isRecord(raw.tree) && _isRecord(raw.tree.root)),
237
239
  `${at}.tree`, '{ root }, or null for an empty desktop', raw.tree);
238
240
  _expect(raw.panels == null || _isRecord(raw.panels), `${at}.panels`, 'an object', raw.panels);
239
241
  // The VALUES of `panels` and `panelShares` are not checked: the loader
@@ -126,7 +126,8 @@ export function keyboardShortcutsTopic(shortcuts = KEYMAP_SHORTCUTS) {
126
126
  // is to be left unprevented (an F-key with no Nth top-nav button, Esc). `label`
127
127
  // is the shortcuts table's words; an action without one is not listed.
128
128
 
129
- const DEFAULT_NAV_SELECTOR = '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn';
129
+ const DEFAULT_NAV_SELECTOR = '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn, '
130
+ + '.twm-fl-bar .twm-fl-item[data-kind]';
130
131
  const DIRS = ['left', 'right', 'up', 'down'];
131
132
  const _where = (d) => (d === 'up' || d === 'down' ? d : `on the ${d}`);
132
133
 
@@ -173,10 +174,10 @@ function _action(name) {
173
174
  if (m[1] === 'nav') {
174
175
  return {
175
176
  label: `Jump to top-nav page ${n + 1}`,
176
- // The selector is a CONFIG option, not a constant. An embedder that
177
- // moves its sections out of the top bar — into an icon rail, say —
178
- // would otherwise find F1..F8 silently doing nothing. Passing
179
- // `navSelector` is how such an embedder keeps the keys.
177
+ // The default finds the top bar's buttons and the page rail's. An
178
+ // embedder that draws its page buttons in markup of its own would
179
+ // otherwise find F1..F8 silently doing nothing. Passing `navSelector`
180
+ // is how such an embedder keeps the keys.
180
181
  run: ({ navSelector }) => {
181
182
  const btns = document.querySelectorAll(navSelector || DEFAULT_NAV_SELECTOR);
182
183
  if (n >= btns.length) return false;
@@ -30,6 +30,9 @@
30
30
  * after the root. Omit on the top-nav kind itself.
31
31
  * isTopNav True iff this kind owns a slot in the top-nav strip.
32
32
  * order Sort key for top-nav display (lower = leftward).
33
+ * railPin `'bottom'`: on a page rail (`chrome.rail`), this top-nav kind
34
+ * stands apart at the bottom, after every kind that is not
35
+ * pinned, whatever its `order`. The top strip ignores it.
33
36
  * appGlobal True iff the kind is not scoped to the root entity (a
34
37
  * preferences page, say). Its breadcrumb omits the root.
35
38
  * sources Entity-source ids the tile hamburger lists when a tile
@@ -104,6 +107,14 @@ export function createTaxonomy({ kinds, root } = {}) {
104
107
  throw new Error(
105
108
  `createTaxonomy: '${kind}' is both a top-nav kind and owned by one`);
106
109
  }
110
+ if (def.railPin !== undefined) {
111
+ if (def.railPin !== 'bottom') {
112
+ throw new TypeError(`createTaxonomy: '${kind}'.railPin must be 'bottom'`);
113
+ }
114
+ if (!def.isTopNav) {
115
+ throw new Error(`createTaxonomy: '${kind}' has a railPin but is not a top-nav kind`);
116
+ }
117
+ }
107
118
  if (def.panels !== undefined) {
108
119
  if (!def.panels || typeof def.panels !== 'object' || Array.isArray(def.panels)) {
109
120
  throw new TypeError(`createTaxonomy: '${kind}'.panels must be an object`);
@@ -232,8 +243,9 @@ export function createTaxonomy({ kinds, root } = {}) {
232
243
  };
233
244
 
234
245
  /** Top-nav entries in display order:
235
- * { kind, label (short), longLabel, icon }
236
- * `label` is the chip text; `longLabel` is what the palette shows. */
246
+ * { kind, label (short), longLabel, icon, railPin }
247
+ * `label` is the chip text; `longLabel` is what the palette shows;
248
+ * `railPin` is the KindDef's, or null. */
237
249
  const topNavEntries = () =>
238
250
  Object.entries(_kinds)
239
251
  .filter(([, m]) => m.isTopNav)
@@ -243,6 +255,7 @@ export function createTaxonomy({ kinds, root } = {}) {
243
255
  label: m.shortLabel || m.label,
244
256
  longLabel: m.label,
245
257
  icon: m.icon,
258
+ railPin: m.railPin ?? null,
246
259
  }));
247
260
 
248
261
  /** Whether `kind` exists once (KindDef `single`). */
@@ -138,9 +138,13 @@ import { openForm } from '../ui/components/modal.js';
138
138
  * commands, after `palette.commands`'
139
139
  * own, unless `palette: false`. Each needs
140
140
  * the other: either alone is a boot error.
141
- * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
141
+ * @param {object} [cfg.chrome] { topNav?, rail?, paletteButton?, desktops?,
142
142
  * panelToggles?: { left?, right?, bottom? },
143
143
  * zoom?, menuBar? }
144
+ * `rail` is the element that becomes the
145
+ * vertical page rail (see `mountPageRail`),
146
+ * instead of `topNav`: giving both is a boot
147
+ * error, since F1..F12 press one row of pages.
144
148
  * `zoom` is C31: the element the content
145
149
  * zoom control is painted into — see
146
150
  * zoom.js. Absent, there is no control and
@@ -185,9 +189,9 @@ export async function createShell({
185
189
  desktopBar = {},
186
190
  menuBar: menuBarCfg = null,
187
191
  chrome = {},
188
- // An embedder that moved its sections out of the top bar — into an icon
189
- // rail, say — passes the selector its own buttons match, and F1..F8 keep
190
- // working. Omitted, the default top-bar selector applies.
192
+ // An embedder that draws its page buttons in markup of its own passes the
193
+ // selector they match, and F1..F8 keep working. Omitted, the default finds
194
+ // `chrome.topNav`'s buttons and `chrome.rail`'s.
191
195
  navSelector = null,
192
196
  keymap = null,
193
197
  } = {}) {
@@ -198,6 +202,9 @@ export async function createShell({
198
202
  }
199
203
  if (!taxonomy) throw new Error('createShell: a taxonomy is required');
200
204
  if (!entities) throw new Error('createShell: an entity catalog is required');
205
+ if (chrome.topNav && chrome.rail) {
206
+ throw new TypeError('createShell: `chrome.topNav` and `chrome.rail` both paint the pages; give one');
207
+ }
201
208
  if (chrome.menuBar && !menuBarCfg?.menus) {
202
209
  throw new TypeError('createShell: `chrome.menuBar` needs `menuBar: { menus }`');
203
210
  }
@@ -223,6 +230,7 @@ export async function createShell({
223
230
 
224
231
  let wm = null;
225
232
  let topNavEl = null;
233
+ let rail = null;
226
234
  let desktopsEl = null;
227
235
  let toggles = null;
228
236
 
@@ -231,6 +239,7 @@ export async function createShell({
231
239
  syncPanelToggles(toggles, wm);
232
240
  syncDesktopBar(desktopsEl, wm);
233
241
  syncTopNav(topNavEl, wm);
242
+ rail?.sync();
234
243
  };
235
244
 
236
245
  wm = new WindowManager({
@@ -277,16 +286,18 @@ export async function createShell({
277
286
  shortcuts: keymapShortcuts(bindings),
278
287
  ...paletteCfg, commands,
279
288
  });
280
- // `navSelector` lets an embedder that moved its sections out of the top
281
- // bar keep F1..F8 working. Omitted, the default top-bar selector applies —
282
- // the behaviour every existing embedder has today. The keymap also binds
283
- // `?` to help, so `dispose()` takes the help shortcut off with it.
289
+ // `navSelector` lets an embedder with page buttons of its own keep F1..F8
290
+ // working. Omitted, the default finds `chrome.topNav`'s and `chrome.rail`'s.
291
+ // The keymap also binds `?` to help, so `dispose()` takes the help shortcut
292
+ // off with it.
284
293
  const disposeKeymap = installKeymap({ wm, palette, navSelector, bindings, logger });
285
294
 
286
295
  // Chrome — painted into the ELEMENTS the embedder handed over. An absent
287
296
  // key means absent chrome, not a crash.
288
297
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
289
- topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
298
+ const topNav = mountTopNav(chrome.topNav, taxonomy, wm);
299
+ topNavEl = topNav?.el ?? null;
300
+ rail = chrome.rail ? mountPageRail(chrome.rail, { wm }) : null;
290
301
  desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
291
302
  // C31. The content zoom. Painted with the rest of the chrome and, unlike the
292
303
  // rest, AWAITED before the first mount: its saved value is applied to `root`
@@ -320,6 +331,7 @@ export async function createShell({
320
331
  chrome: Object.freeze({
321
332
  paletteButtonEl: paletteBtn,
322
333
  topNavEl,
334
+ railEl: rail?.el ?? null,
323
335
  desktopsEl,
324
336
  panelToggles: toggles,
325
337
  // `get`/`set` so an embedder can drive the zoom from its own settings
@@ -355,6 +367,10 @@ export async function createShell({
355
367
  // persist it under the host the old shell was given. The element
356
368
  // goes; the next shell paints its own.
357
369
  try { desktopsEl?.remove(); } catch { /* ignore */ }
370
+ // The rail's and the top nav's elements are the embedder's and outlive
371
+ // this shell; their buttons and listeners close over THIS wm, so they go.
372
+ try { rail?.dispose(); } catch { /* ignore */ }
373
+ try { topNav?.dispose(); } catch { /* ignore */ }
358
374
  // Its listeners are on the document; a rebuilt shell paints its own.
359
375
  try { menuBar?.dispose(); } catch { /* ignore */ }
360
376
  },
@@ -385,27 +401,118 @@ function mountTopNav(hostEl, taxonomy, wm) {
385
401
  `;
386
402
  hostEl.appendChild(btn);
387
403
  }
388
- hostEl.addEventListener('click', (e) => {
404
+ const onClick = (e) => {
389
405
  const btn = e.target.closest('[data-kind]');
390
406
  if (!btn) return;
391
407
  wm.openInPrimary(btn.dataset.kind);
392
- });
393
- return hostEl;
408
+ };
409
+ hostEl.addEventListener('click', onClick);
410
+ return {
411
+ el: hostEl,
412
+ dispose: () => {
413
+ hostEl.removeEventListener('click', onClick);
414
+ hostEl.replaceChildren();
415
+ },
416
+ };
394
417
  }
395
418
 
396
- function syncTopNav(hostEl, wm) {
397
- if (!hostEl) return;
419
+ /** The page the primary tile is on. topNavFor maps an entity kind onto its owning
420
+ * top-nav category. Falls back to the primary kind so unknown kinds at least try
421
+ * to match a button. */
422
+ function activePageKind(wm) {
398
423
  const tree = wm.desktops.active().tree;
399
424
  const primaryId = tree.primaryLeafId();
400
425
  const primaryKind = primaryId ? tree.get(primaryId)?.content?.kind : null;
401
- // topNavFor maps an entity kind onto its owning top-nav category. Falls
402
- // back to the primary kind so unknown kinds at least try to match a button.
403
- const topNavKind = wm.taxonomy.topNavFor(primaryKind) || primaryKind;
426
+ return wm.taxonomy.topNavFor(primaryKind) || primaryKind;
427
+ }
428
+
429
+ function syncTopNav(hostEl, wm) {
430
+ if (!hostEl) return;
431
+ const topNavKind = activePageKind(wm);
404
432
  hostEl.querySelectorAll('[data-kind]').forEach((b) => {
405
433
  b.classList.toggle('twm-top-nav__btn--on', b.dataset.kind === topNavKind);
406
434
  });
407
435
  }
408
436
 
437
+ /**
438
+ * The vertical page rail: one icon button per top-nav kind, painted into the
439
+ * element it is given, in `order`, with the kinds whose KindDef says
440
+ * `railPin: 'bottom'` after the rest and held at the bottom of the bar. A button
441
+ * opens its kind in the primary tile. The active one is `.twm-fl-item--on` and
442
+ * `aria-current="page"`.
443
+ *
444
+ * `createShell` calls this for `chrome.rail`, and keeps it in step. An embedder
445
+ * that builds its own WindowManager calls it itself and calls `sync()` when the
446
+ * window manager changes.
447
+ *
448
+ * Its tooltips carry no placement: `TooltipService` places anything inside
449
+ * `.twm-fl-bar` on the right, and flips it at the window's edge.
450
+ *
451
+ * @param {Element} hostEl
452
+ * @param {object} cfg
453
+ * @param {object} cfg.wm the window manager; its taxonomy names the pages
454
+ * @param {(wm: object) => (string|null)} [cfg.activeKind] which page to light, or
455
+ * null for none. Default: the primary tile's
456
+ * page, by `taxonomy.topNavFor`.
457
+ * @param {(btn: HTMLButtonElement, entry: object) => void} [cfg.decorate] called
458
+ * once per button, with its `topNavEntries()`
459
+ * entry, to add a class or change its tooltip.
460
+ * @returns {{ el: Element, sync: () => void, dispose: () => void }}
461
+ */
462
+ export function mountPageRail(hostEl, { wm, activeKind = activePageKind, decorate = null } = {}) {
463
+ if (!hostEl || typeof hostEl.appendChild !== 'function') {
464
+ throw new TypeError('mountPageRail: `hostEl` must be an element');
465
+ }
466
+ if (!wm) throw new TypeError('mountPageRail: `wm` is required');
467
+ hostEl.classList.add('twm-fl-bar');
468
+ if (!hostEl.hasAttribute('aria-label')) hostEl.setAttribute('aria-label', 'Pages');
469
+
470
+ const entries = wm.taxonomy.topNavEntries();
471
+ const buttons = [
472
+ ...entries.filter((k) => k.railPin !== 'bottom'),
473
+ ...entries.filter((k) => k.railPin === 'bottom'),
474
+ ].map((k) => {
475
+ const btn = document.createElement('button');
476
+ btn.type = 'button';
477
+ btn.className = 'twm-fl-item twm-has-tooltip';
478
+ btn.classList.toggle('twm-fl-item--bottom', k.railPin === 'bottom');
479
+ btn.dataset.kind = k.kind;
480
+ btn.dataset.tooltip = k.longLabel;
481
+ btn.setAttribute('aria-label', k.longLabel);
482
+ const glyph = document.createElement('span');
483
+ glyph.className = 'material-symbols-outlined';
484
+ glyph.setAttribute('aria-hidden', 'true');
485
+ glyph.textContent = k.icon;
486
+ btn.appendChild(glyph);
487
+ decorate?.(btn, k);
488
+ return btn;
489
+ });
490
+ hostEl.replaceChildren(...buttons);
491
+
492
+ const onClick = (e) => {
493
+ const btn = e.target.closest('[data-kind]');
494
+ if (btn) wm.openInPrimary(btn.dataset.kind);
495
+ };
496
+ hostEl.addEventListener('click', onClick);
497
+
498
+ return Object.freeze({
499
+ el: hostEl,
500
+ sync: () => {
501
+ const active = activeKind(wm);
502
+ for (const btn of buttons) {
503
+ const on = btn.dataset.kind === active;
504
+ btn.classList.toggle('twm-fl-item--on', on);
505
+ if (on) btn.setAttribute('aria-current', 'page');
506
+ else btn.removeAttribute('aria-current');
507
+ }
508
+ },
509
+ dispose: () => {
510
+ hostEl.removeEventListener('click', onClick);
511
+ hostEl.replaceChildren();
512
+ },
513
+ });
514
+ }
515
+
409
516
  /**
410
517
  * The palette button, REBOUND rather than reused.
411
518
  *
@@ -817,8 +817,7 @@ export class TileRenderer {
817
817
  //
818
818
  // No `e.button` test, unlike the pull: `dblclick` is dispatched for the
819
819
  // primary button only. No `preventDefault`, and NO NEW CSS: the strip
820
- // is already `user-select: none` (`css/overrides.css:1001`, mirrored
821
- // into the generated `css/flexdesk.css:4743`), so a double-click here
820
+ // is already `user-select: none` (`css/flexdesk.css`), so a double-click here
822
821
  // cannot word-select the title on its way to floating the pane.
823
822
  chrome.addEventListener('dblclick', (e) => {
824
823
  if (e.target.closest(CHROME_NO_FLOAT)) return;
@@ -1783,7 +1782,7 @@ export class TileRenderer {
1783
1782
  /** The rectangle a release would fill.
1784
1783
  *
1785
1784
  * IT IS THE WINDOW DROP'S OWN PREVIEW ELEMENT — same two classes, same
1786
- * stylesheet rules (`css/base.css`, `.twm-snap-preview`) — so a tab drop
1785
+ * stylesheet rules (`css/flexdesk.css`, `.twm-snap-preview`) — so a tab drop
1787
1786
  * and a window drop cannot come to disagree about what a drop looks like.
1788
1787
  * `--viewport` is what makes `position: fixed` apply, and that is required
1789
1788
  * rather than cosmetic: the rectangle came from `getBoundingClientRect` on