flexdesk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -5,12 +5,258 @@
5
5
  * The renderer keeps a small per-leaf DOM cache so content factories
6
6
  * mount exactly once per (kind, props) pair — switching the focused
7
7
  * leaf or resizing doesn't re-mount tabs / pages.
8
+ *
9
+ * ── C22. TWO TAB LAYOUTS, AND THE TREE KNOWS ABOUT NEITHER ────────────────
10
+ *
11
+ * A leaf's tabs live in the TREE (`Leaf: { tabs, activeTabIdx }`) and are the
12
+ * source of truth; `content`/`title` mirror `tabs[active]`. Where the strip is
13
+ * DRAWN, and what it looks like, is therefore a rendering decision and nothing
14
+ * else — which is why both layouts share one tab model, one action vocabulary
15
+ * (`switch` / `close` / `move` / `menu` / `open-menu`, all through
16
+ * `ctx.onLeafTabAction`) and one set of tree mutations in the WM.
17
+ *
18
+ * `bottom` — the framework's own spreadsheet strip under the tile body.
19
+ * THE DEFAULT, and it stays the default: an existing embedder
20
+ * upgrading must not find its panes rearranged.
21
+ * `top` — `NotebookTabBar` (`../editor/notebook_tab_bar.js`), the editor
22
+ * tab strip, mounted BETWEEN the chrome and the body. The chrome
23
+ * is the pane's identity — its glyph, its title, its verbs — and
24
+ * the tabs are its contents, so the tabs go under it, not over.
25
+ *
26
+ * The tab bar is reused rather than reimplemented. It already has the
27
+ * behaviours a top strip is expected to have — drag-to-reorder, a scrolling
28
+ * overflow, close buttons, dirty dots — and a second implementation of them
29
+ * here is a second set of the same bugs.
30
+ *
31
+ * ── The layout is also readable and writable as an ATTRIBUTE ──────────────
32
+ *
33
+ * `tabLayout` is a `createShell` option like `snapPromotion` and
34
+ * `promoteInPlace`, but unlike those it is something a USER may reasonably
35
+ * change while looking at the thing it changes. An embedder's settings pane
36
+ * holds no renderer reference — it holds, at most, the element it handed to
37
+ * `createShell` — so the renderer mirrors the option onto its own root as
38
+ * `data-twm-tabs` and OBSERVES it. Setting that attribute on the root element
39
+ * is then a complete, reference-free way to switch layout live, and the same
40
+ * attribute is the hook a stylesheet keys off. `setTabLayout()` is the direct
41
+ * route for anyone who does hold the renderer.
42
+ *
43
+ * Switching is deliberately cheap: only the strip is rebuilt and the tab bar
44
+ * element is MOVED between its two positions. The body is never detached, so
45
+ * no content factory is unmounted and nothing mounted in a tile — a grid with
46
+ * staged edits, an editor with an undo stack — notices that it happened.
47
+ *
48
+ * ── C33. A TAB DRAGGED INTO A TILE ───────────────────────────────────────
49
+ *
50
+ * *"I would like to be able to drag & drop tabs into tiles, too."* A tab could
51
+ * already be dragged WITHIN its own strip, and a WINDOW could already be
52
+ * dropped onto a tile; between the two there was nothing.
53
+ *
54
+ * ══ TWO DRAG MECHANISMS, AND THEY CANNOT BE ONE GESTURE ═════════════════
55
+ *
56
+ * This codebase drags things in two incompatible ways, and the choice here was
57
+ * made for us. Tab reorder is NATIVE HTML5 DRAG-AND-DROP — `draggable="true"`
58
+ * plus `dragstart`/`dragover`/`drop` — in both layouts: the `top` strip gets it
59
+ * from the shared `DragReorder`, the `bottom` strip hand-rolls the same three
60
+ * events. Window snapping is POINTER EVENTS
61
+ * (`../ui/components/managed_window.js`, `_dragState` with a `pointermove`
62
+ * listener on `document`). **Once a native drag begins the browser stops
63
+ * dispatching `pointermove` for its whole duration**, so a tab drag can never
64
+ * reach the pointer-driven snap controller, and a pointer-driven tab drag would
65
+ * be a second mechanism sitting beside the reorder that already works. So: this
66
+ * is HTML5 DnD, and it extends the gesture that exists rather than competing
67
+ * with it.
68
+ *
69
+ * ══ THE GEOMETRY IS SHARED ANYWAY ═══════════════════════════════════════
70
+ *
71
+ * What could not be shared is the transport; what MUST be shared is the answer.
72
+ * `wm.tabDropProbe` (R18) runs the same zone matrix a window drop runs, through
73
+ * the same `_dropZoneFor` tail (R17) — so a 28px edge means SPLIT and a centre
74
+ * means TAB-or-FILL for both, and the vocabulary is learned once. Product
75
+ * owner, 2026-08-27: the edge drop splits, "reusing the window snap-zone
76
+ * vocabulary".
77
+ *
78
+ * ══ THE SINGLE-TAB TILE HAS NO STRIP, SO THE CHROME IS THE HANDLE ═══════
79
+ *
80
+ * Both layouts hide the bar below two tabs (`_renderTabBar`), which is the
81
+ * common case — one table in one tile — so a feature that needs a tab strip to
82
+ * grab is a feature most tiles cannot offer. Product owner, 2026-08-27: the
83
+ * tile chrome / breadcrumb is the drag source for a single-tab leaf. With one
84
+ * tab there is no ambiguity to resolve — "move this tab" and "move what is in
85
+ * this pane" name the same thing — which is exactly why it is limited to that
86
+ * case: on a multi-tab leaf the strip already names each tab individually and a
87
+ * chrome drag would have to guess which one was meant.
88
+ *
89
+ * IT DOES NOT COST THE CHROME ITS EXISTING GESTURES — BUT IT DID, AND THE
90
+ * PARAGRAPH THAT USED TO SIT HERE IS WHY. It read: the pull "loses cleanly"
91
+ * to a native drag, and "is also still reachable, on every part of the chrome
92
+ * a single-tab leaf's title does not cover". That is a description of
93
+ * `draggable` on the TITLE. `_syncChromeDragSource` sets it on the CHROME —
94
+ * the whole strip — so there was no part left over, and since both layouts
95
+ * hide the tab strip below two tabs, a single-tab leaf is the ORDINARY tile
96
+ * rather than an edge case. Pull-down-to-float was dead application-wide, and
97
+ * the sentence saying it was fine sat in this file the whole time.
98
+ *
99
+ * The two gestures are separated by DIRECTION now, through one predicate
100
+ * (`_isDownwardPull`) that both of them read: a downward grab is the float,
101
+ * and `dragstart` refuses the native drag so the pointer goes back to the
102
+ * pull; anything else is the tab drag and is untouched. The double-click
103
+ * cannot collide with either — a `dblclick` carries no movement, so no drag
104
+ * ever starts.
105
+ *
106
+ * A CLAIM ABOUT A BROWSER NEEDS A BROWSER. Nothing in this repository's suites
107
+ * can begin a native drag — jsdom dispatches only the drag events a test hands
108
+ * it — so this collision was invisible to every gate on both sides and stayed
109
+ * that way through review. It was settled by driving real mouse input at
110
+ * Edge 152 over CDP: as shipped, a 140px pull on a table tile's chrome gives
111
+ * `dragstart`, `pointercancel`, `dragend` and no window; with the refusal,
112
+ * `pointermove` resumes and the pull fires at 26px; sideways and diagonal
113
+ * grabs still drag.
114
+ *
115
+ * A pane whose content VETOED `promote` (C20) is not a drag source either, and
116
+ * that is one rule rather than two: a tab you may not lift out of its pane is a
117
+ * tab you may not drag into another one. It is what keeps an embedder's master
118
+ * tile — the ground floating windows stand on — from being dragged away.
119
+ *
120
+ * ══ THE DRAG STATE LIVES ON THE RENDERER ════════════════════════════════
121
+ *
122
+ * `_tabDrag` is an instance field, not a per-leaf closure, and that is
123
+ * structural. The bottom strip's existing reorder keeps `dragFromIdx` in a
124
+ * closure built per leaf render, so leaf B's handlers cannot see that leaf A
125
+ * started a drag — which is fine for a reorder that begins and ends in one
126
+ * strip and useless for one that crosses tiles.
127
+ *
128
+ * The identity has to live there for a second reason: under the HTML5 protected
129
+ * mode `dataTransfer.getData()` returns the empty string for every event except
130
+ * `drop`, so the source leaf CANNOT be read while deciding whether to arm.
131
+ * `TILE_TAB_MIME` is therefore a type-only marker — an admission ticket, not a
132
+ * message — and everything else is read off `_tabDrag`.
133
+ *
134
+ * ══ AND THE LISTENERS ARE BOUND ONCE, ON THE ROOT ═══════════════════════
135
+ *
136
+ * `render()` does `this.root.innerHTML = ''`, so a listener attached to a leaf
137
+ * wrap during a repaint-heavy gesture is gone by the next frame; the
138
+ * constructor's `mousedown` is the pattern and these follow it. For the same
139
+ * reason NOTHING on the `dragover` path may call `render()` — rebuilding the
140
+ * wrap that holds the drag source aborts the native drag, and the DOM
141
+ * afterwards reads perfectly correct, which is the exact shape of the
142
+ * five-times-reported `mousedown`-repaint defect one mechanism over.
143
+ *
144
+ * OUT OF SCOPE, deliberately and in writing: dragging a tab out of a FLOATING
145
+ * WINDOW's strip (its tabs live in the WM's window record, not in the tree, so
146
+ * the source policy is a different function's); dropping on empty space to
147
+ * float a window (in HTML5 DnD "dropped on nothing" is `dragend` with no
148
+ * `drop`, which is also what Escape produces, so it would float a window every
149
+ * time a user changed their mind); and crossing DESKTOPS (only the active one
150
+ * is rendered, so there is nothing to hit).
8
151
  */
9
152
 
153
+ import { NotebookTabBar } from '../editor/notebook_tab_bar.js';
154
+
10
155
  const SPLITTER_PX = 4;
156
+ /** The two tab layouts, and the framework's default. `bottom` is the default
157
+ * because it is what every existing embedder already renders. */
158
+ const TAB_LAYOUTS = ['bottom', 'top'];
159
+ const DEFAULT_TAB_LAYOUT = 'bottom';
160
+
161
+ /** Anything that is not one of the two layouts is the default rather than an
162
+ * error: this value arrives from a DOM attribute and from persisted user
163
+ * settings, and neither is a place to throw. */
164
+ function _normalizeTabLayout(value) {
165
+ return TAB_LAYOUTS.includes(value) ? value : DEFAULT_TAB_LAYOUT;
166
+ }
167
+
168
+ /**
169
+ * `NotebookTabBar`'s vocabulary is FILE PATHS, and a tile tab is not a file.
170
+ *
171
+ * The path is only ever an identity string to it — the key its reorder uses,
172
+ * the value it compares against `activeFilePath`, the selector it renames by —
173
+ * so a leaf's tab INDEX serves, and is the one identity a tab list is
174
+ * guaranteed to have (the tree gives tabs no ids and this renderer may not
175
+ * add any).
176
+ *
177
+ * The `builtin://` prefix is not decoration. `#beginInlineRename` refuses to
178
+ * start on a path with it (`notebook_tab_bar.js:402`), which is exactly what a
179
+ * tile tab needs: double-click rename would rename a TAB, and a tab is a view
180
+ * of an entity whose name lives somewhere this renderer cannot reach. A rename
181
+ * that silently does nothing is worse than no rename, and using the component's
182
+ * own guard beats disabling the gesture from outside.
183
+ */
184
+ function _tabKey(idx) { return `builtin://tab/${idx}`; }
185
+
186
+ function _tabKeyIndex(key) {
187
+ const n = Number(String(key ?? '').replace('builtin://tab/', ''));
188
+ return Number.isInteger(n) && n >= 0 ? n : -1;
189
+ }
190
+ /** How far the chrome must be pulled DOWN before the pane lifts out as a
191
+ * floating window. Far enough that focusing a tile by clicking its header
192
+ * never becomes one by accident. */
193
+ const TILE_LIFT_PX = 24;
194
+ /** WHAT IS NOT A GRIP, for both of the chrome's float gestures.
195
+ *
196
+ * A tile's chrome is a grip with controls sitting on it — the split, float
197
+ * and close buttons on the right, whatever the content contributed on the
198
+ * left, and (defensively; the strip is a sibling of the chrome today, not a
199
+ * child) a tab. Neither the downward pull nor the double-click may fire when
200
+ * the press landed on one of those.
201
+ *
202
+ * ONE CONSTANT, TWO LISTENERS, and that is the point of writing it down
203
+ * rather than repeating the selector: two doors onto one verb that disagree
204
+ * about where the door is are worse than one door. A button added to this
205
+ * strip later must become inert under BOTH gestures at once, and it cannot
206
+ * do that if each of them carries its own copy of the rule. */
207
+ const CHROME_NO_FLOAT = 'button, .twm-leaf__tab';
208
+
209
+ /**
210
+ * IS THIS GESTURE THE DOWNWARD PULL? Asked in TWO places that must not be
211
+ * allowed to disagree, which is the only reason it is a function.
212
+ *
213
+ * The pull asks it of every `pointermove`, to decide whether 24px of travel
214
+ * has become a float. `_syncChromeDragSource`'s `dragstart` asks it of the
215
+ * first few pixels, to decide whether to REFUSE the native drag and leave the
216
+ * pointer to the pull. Two answers to "which way is this going" would produce
217
+ * a gesture that is neither: the drag declines, the pull never accepts, and
218
+ * the chrome does nothing at all.
219
+ *
220
+ * `sideways <= down` rather than `<`, so a gesture exactly on the diagonal
221
+ * belongs to the pull in both readers — the pull's own test has always been
222
+ * `sideways > down` rejects, so this is the same boundary written once.
223
+ */
224
+ function _isDownwardPull(down, sideways) {
225
+ return down > 0 && sideways <= down;
226
+ }
227
+
228
+ /**
229
+ * C33. THE MARKER THAT SAYS "THIS DRAG IS ONE OF OURS", AND NOTHING ELSE.
230
+ *
231
+ * It carries the literal string `'1'` because it carries no information at all.
232
+ * Under the HTML5 protected-mode rules `dataTransfer.getData()` answers the
233
+ * empty string for every event of a drag except `drop`, and only `types` is
234
+ * readable during `dragenter`/`dragover` — which is precisely when the decision
235
+ * to arm a drop zone has to be made. So the source leaf and tab index travel in
236
+ * `TileRenderer._tabDrag` and this type is an admission ticket.
237
+ *
238
+ * Encoding the leaf id into the type NAME (`…/tile-tab/leaf-7`) would make it
239
+ * readable on dragover and is the trap: `types` would then be unbounded, every
240
+ * consumer would have to parse it, and the string would become a wire format
241
+ * nobody declared. One constant, one meaning.
242
+ */
243
+ export const TILE_TAB_MIME = 'application/x-twm-tile-tab';
244
+
245
+ /** Does this drag carry a tile tab? The ONLY admission test available during
246
+ * `dragover`, per the note on `TILE_TAB_MIME`. */
247
+ function _isTileTabDrag(e) {
248
+ try { return !!e.dataTransfer?.types?.includes(TILE_TAB_MIME); }
249
+ catch { return false; }
250
+ }
11
251
 
12
252
  export class TileRenderer {
13
- constructor({ root, tree, content, ctx, onFocusChange }) {
253
+ /** C33. Readable from a consumer that holds only the renderer — the Tables
254
+ * drop suite drives real drag events and has to build a `dataTransfer`
255
+ * stub that this renderer will admit. */
256
+ static get TAB_MIME() { return TILE_TAB_MIME; }
257
+
258
+ constructor({ root, tree, content, ctx, onFocusChange, onAfterRender,
259
+ tabLayout = null }) {
14
260
  if (!content || typeof content.mount !== 'function') {
15
261
  throw new Error('TileRenderer: a content registry is required');
16
262
  }
@@ -20,14 +266,77 @@ export class TileRenderer {
20
266
  this.content = content;
21
267
  this.ctx = ctx || {};
22
268
  this.onFocusChange = onFocusChange || (() => {});
23
- // leafId -> { wrapEl, bodyEl, chromeEl, content, kindKey }
269
+ /** Fired after every repaint, once the tree's DOM is settled.
270
+ *
271
+ * A LEAF'S WRAP IS REBUILT whenever its content key changes — a tab
272
+ * added, a tab removed, the leaf re-seeded — and anything an embedder
273
+ * parented INTO that wrap goes with the old one. A window contained in
274
+ * a pane (C21) is exactly that: promote a second tab out of a
275
+ * two-tab leaf and the first promotion's window is silently removed
276
+ * from the document, because its container was the wrap that the
277
+ * second promotion's re-render replaced.
278
+ *
279
+ * The renderer cannot know about windows, so it says "I have
280
+ * repainted" and the WM re-homes what it owns. */
281
+ this.onAfterRender = onAfterRender || (() => {});
282
+ // leafId -> { wrapEl, bodyEl, chromeEl, content, kindKey, tabStrip }
24
283
  this._leafCache = new Map();
25
284
  this._drag = null;
285
+ /** C33. The tab currently being carried: `{leafId, idx, el}`, or null.
286
+ * On the RENDERER rather than in a per-leaf closure — see the header:
287
+ * the bottom strip's `dragFromIdx` is per leaf render, and a drag that
288
+ * crosses tiles needs an identity leaf B's handlers can read. */
289
+ this._tabDrag = null;
290
+ /** The chrome pull-down's live pointer — `{startX, startY, x, y}` —
291
+ * while a press is held on a leaf chrome, else null. Read by the
292
+ * `dragstart` in `_syncChromeDragSource`, which has to decide whether
293
+ * this gesture is the float and get out of its way. Here rather than
294
+ * in the pull's own closure for `_tabDrag`'s reason: the reader is a
295
+ * method bound to a different element. */
296
+ this._chromePull = null;
297
+ /** The last `wm.tabDropProbe` answer, stashed on `dragover` because
298
+ * `drop` must not re-probe: the pointer can be one pixel outside the
299
+ * zone the preview drew, and the rectangle drawn is the rectangle the
300
+ * drop delivers (C15). */
301
+ this._tabDropProbe = null;
302
+ this._tabDropPreviewEl = null;
303
+ /** Set by `createShell().dispose()`. A renderer whose shell is gone must
304
+ * not paint: an embedder that rebuilds its shell over the same root
305
+ * would otherwise find a stale callback repainting the previous tree
306
+ * on top of the live one, and the two would fight over one element. */
307
+ this.disposed = false;
26
308
  this.root.classList.add('twm-root');
309
+ // C22. The ATTRIBUTE WINS over the config when it is already set, and
310
+ // that order is the point: an embedder that persists this per user has
311
+ // its answer before `createShell` is called, on the element it owns,
312
+ // and does not have to race the shell's construction to apply it.
313
+ this.tabLayout = _normalizeTabLayout(
314
+ this.root.dataset?.twmTabs ?? tabLayout ?? DEFAULT_TAB_LAYOUT);
315
+ if (this.root.dataset) this.root.dataset.twmTabs = this.tabLayout;
316
+ // …and stays live. `setTabLayout` writes the attribute back, so this
317
+ // fires once with nothing to do rather than looping.
318
+ this._layoutObserver = typeof MutationObserver === 'function'
319
+ ? new MutationObserver(() => this.setTabLayout(this.root.dataset?.twmTabs))
320
+ : null;
321
+ this._layoutObserver?.observe(this.root,
322
+ { attributes: true, attributeFilter: ['data-twm-tabs'] });
27
323
  this.root.addEventListener('mousedown', this._onMouseDown.bind(this));
324
+ // C33. ON THE ROOT, IN THE CONSTRUCTOR, FOR THE SAME REASON THE
325
+ // `mousedown` ABOVE IS. `render()` clears the root, so a listener bound
326
+ // to a leaf wrap is a listener that survives until the next repaint —
327
+ // and a drag is exactly the situation in which repaints happen.
328
+ this.root.addEventListener('dragover', this._onTabDragOver.bind(this));
329
+ this.root.addEventListener('drop', this._onTabDrop.bind(this));
330
+ this.root.addEventListener('dragleave', this._onTabDragLeave.bind(this));
331
+ // `dragend` FIRES ON THE SOURCE AND IS THE ONLY GUARANTEED END. A drag
332
+ // cancelled with Escape, or released over a window that refused it,
333
+ // produces `dragend` and no `drop` at all — so every scrap of painted
334
+ // state is torn down here rather than in the drop.
335
+ this.root.addEventListener('dragend', this._onTabDragEnd.bind(this));
28
336
  }
29
337
 
30
338
  render() {
339
+ if (this.disposed) return;
31
340
  // Detaching a wrap (remove() then re-append() in `_mount`) wipes
32
341
  // `scrollTop` on every scrollable descendant of that wrap.
33
342
  // Snapshot per-leaf so we can restore after re-attach — keyed
@@ -43,6 +352,26 @@ export class TileRenderer {
43
352
  savedScrolls.set(leafId, snap);
44
353
  entry.wrapEl.remove();
45
354
  }
355
+ // R13. A WINDOW PARENTED TO THE ROOT IS NOT A TILE, AND IS NOT THE
356
+ // RENDERER'S TO THROW AWAY. `innerHTML = ''` below is indiscriminate —
357
+ // it destroys every direct child — and the window manager parents
358
+ // floating windows here deliberately: FOR THE LENGTH OF EVERY DRAG
359
+ // (`dragHost`, R1), which is the live route and not a rare one, since a
360
+ // repaint during a drag needs nothing more exotic than the drag itself
361
+ // changing focus or a splitter moving. It was also permanent for a
362
+ // window snapped to fill the layer; R14 removed that mode — the top
363
+ // edge docks now — but the drag remains, and a consumer calling the
364
+ // public `toggleMaximize({claimable: false})` on a window the WM has
365
+ // left on its drag host puts one up here for good. Any repaint while a
366
+ // window was up here deleted its element silently: no error, nothing
367
+ // thrown, and a `ManagedWindow` object still perfectly alive with
368
+ // nothing on screen — the same shape of bug as the leaf wraps above,
369
+ // which is why they are detached and re-attached too rather than merely
370
+ // skipped. The tiles have to be rebuilt around them.
371
+ const floats = [...this.root.children].filter((el) =>
372
+ el.classList?.contains('twm-managed-window')
373
+ || el.classList?.contains('twm-managed-window__backdrop'));
374
+ for (const el of floats) el.remove();
46
375
  this.root.innerHTML = '';
47
376
  const tree = this.tree;
48
377
  if (!tree.rootId) {
@@ -51,11 +380,18 @@ export class TileRenderer {
51
380
  empty.textContent = 'Empty desktop — Ctrl+K to open something.';
52
381
  this.root.appendChild(empty);
53
382
  this._cleanCache(new Set());
383
+ // An empty desktop is still a desktop a window can be floating
384
+ // over, and it is the one branch that returns early.
385
+ for (const el of floats) this.root.appendChild(el);
54
386
  return;
55
387
  }
56
388
  const liveLeafIds = new Set();
57
389
  this._mount(tree.rootId, this.root, liveLeafIds);
58
390
  this._cleanCache(liveLeafIds);
391
+ // LAST, and before `onAfterRender` — which is where the WM re-homes
392
+ // the contained ones into their rebuilt wraps, and it can only move an
393
+ // element that is still in the document.
394
+ for (const el of floats) this.root.appendChild(el);
59
395
  this._updateFocusClasses();
60
396
 
61
397
  // Restore scrolls — once synchronously and once next frame so
@@ -74,12 +410,38 @@ export class TileRenderer {
74
410
  };
75
411
  restore();
76
412
  requestAnimationFrame(restore);
413
+ try { this.onAfterRender(); }
414
+ catch (err) { console.error('[tile-renderer] onAfterRender threw', err); }
415
+ }
416
+
417
+ /** Unmount everything and stop painting. `dispose()` on the shell calls
418
+ * this: `root.innerHTML = ''` detaches DOM without telling a single content
419
+ * factory, so a page module that installed a `window` listener or an
420
+ * interval keeps both, invisibly, for the life of the tab. */
421
+ destroy() {
422
+ this._layoutObserver?.disconnect();
423
+ this._layoutObserver = null;
424
+ // C33. The drop preview is parented to `document.body`, not to the
425
+ // root, so `_cleanCache` cannot reach it: a shell torn down mid-drag
426
+ // would leave a blue rectangle painted over the page with nothing left
427
+ // that knows how to remove it.
428
+ this._clearTabDropPreview();
429
+ this._tabDrag = null;
430
+ this._tabDropProbe = null;
431
+ this._cleanCache(new Set());
432
+ this.disposed = true;
77
433
  }
78
434
 
79
435
  _cleanCache(liveSet) {
80
436
  for (const [leafId, entry] of [...this._leafCache.entries()]) {
81
437
  if (!liveSet.has(leafId)) {
82
438
  try { entry.content?.destroy?.(); } catch (_) {}
439
+ // C22. The top strip is a COMPONENT, not markup: it holds a
440
+ // DragReorder with container listeners and a context menu
441
+ // parented to `document.body`. Dropping the element it lives in
442
+ // leaves both, invisibly, for the life of the page — the same
443
+ // reason `destroy()` exists on the content above it.
444
+ this._disposeTabStrip(entry);
83
445
  entry.wrapEl.remove();
84
446
  this._leafCache.delete(leafId);
85
447
  }
@@ -126,20 +488,30 @@ export class TileRenderer {
126
488
  const activeTab = (Array.isArray(leaf.tabs) && leaf.tabs.length > 0)
127
489
  ? leaf.tabs[Math.max(0, Math.min(leaf.tabs.length - 1, leaf.activeTabIdx || 0))]
128
490
  : null;
129
- const tabFingerprint = (leaf.tabs || []).map((t) =>
130
- `${t.kind}::${JSON.stringify(t.props || {})}`).join('|') + `#${leaf.activeTabIdx || 0}`;
131
- const kindKey = leaf.content
132
- ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}`
133
- : '__empty__';
491
+ const kindKey = this._leafKindKey(leaf);
134
492
 
135
493
  let entry = this._leafCache.get(leaf.id);
136
494
  if (entry && entry.kindKey === kindKey) {
137
495
  entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
496
+ // C19. Re-read the glyph beside the title, and for the same reason:
497
+ // a cached leaf is NOT re-mounted, so anything the chrome shows
498
+ // that can change while the content stays put has to be refreshed
499
+ // here or it is frozen at whatever it was when the tile was built.
500
+ _paintLeafIcon(entry.iconEl, leaf, entry.content, this.ctx);
138
501
  return entry.wrapEl;
139
502
  }
140
503
  // Build fresh.
141
504
  if (entry) {
505
+ // DELETED, NOT JUST SUPERSEDED. The map still held this entry while
506
+ // the content's own `destroy` ran, so anything reaching the
507
+ // renderer from inside that teardown — `leafChrome`, and
508
+ // `rebaselineLeaf`, which would have written a key onto a wrap
509
+ // already removed from the document — read a corpse. The rebuild
510
+ // re-registers under the same id a few lines below, so the only
511
+ // window this closes is the one nothing should be looking in.
512
+ this._leafCache.delete(leaf.id);
142
513
  try { entry.content?.destroy?.(); } catch (_) {}
514
+ this._disposeTabStrip(entry);
143
515
  entry.wrapEl.remove();
144
516
  }
145
517
  const wrap = document.createElement('div');
@@ -153,11 +525,27 @@ export class TileRenderer {
153
525
  // tile activation.
154
526
  const chrome = document.createElement('div');
155
527
  chrome.className = 'twm-leaf__chrome';
528
+ // C19. The glyph a floating window has always had, in the tile chrome
529
+ // too — `ManagedWindow._build` puts one left of its title
530
+ // (`../ui/components/managed_window.js:588-590`) and a tile, which is
531
+ // the SAME content in a different state, had nothing there. Two ways of
532
+ // looking at one table should not disagree about what a table looks
533
+ // like.
534
+ const icon = document.createElement('span');
535
+ icon.className = 'twm-leaf__icon material-symbols-outlined';
156
536
  const title = document.createElement('span');
157
537
  title.className = 'twm-leaf__title';
158
538
  title.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
159
539
  const actions = document.createElement('span');
160
540
  actions.className = 'twm-leaf__actions';
541
+ // C18. Content-contributed chrome actions land HERE, to the left of the
542
+ // structural ones, and the divider is not decoration: the buttons on
543
+ // the right act on the TILE (split it, float it, close it) and the ones
544
+ // on the left act on what is IN it (this table's settings, this table
545
+ // in a browser window). Two kinds of verb in one strip with nothing
546
+ // between them is how "close" gets read as "close the table".
547
+ const contentActions = document.createElement('span');
548
+ contentActions.className = 'twm-leaf__actions twm-leaf__actions--content';
161
549
  // Panel tiles (left nav / right / bottom) can't be promoted to
162
550
  // managed windows — they're chrome, not content. Hide the
163
551
  // promote button in their chrome.
@@ -173,29 +561,37 @@ export class TileRenderer {
173
561
  <button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
174
562
  <span class="material-symbols-outlined">splitscreen_add</span>
175
563
  </button>
176
- <button class="twm-leaf__btn" data-action="promote" title="Promote to window (Alt+F)">
177
- <span class="material-symbols-outlined">open_in_new</span>
564
+ <button class="twm-leaf__btn" data-action="promote" title="Float this pane as a window (Alt+F)">
565
+ <span class="material-symbols-outlined">web_asset</span>
178
566
  </button>`}
179
567
  <button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
180
568
  <span class="material-symbols-outlined">close</span>
181
569
  </button>
182
570
  `;
183
- chrome.append(title, actions);
571
+ chrome.append(icon, title, contentActions, actions);
572
+ // Painted BEFORE the mount as well as after it, so a kind whose factory
573
+ // supplies no icon of its own — and every kind that existed before this
574
+ // did — still wears the taxonomy's glyph from the first frame rather
575
+ // than acquiring one a mount later.
576
+ _paintLeafIcon(icon, leaf, null, this.ctx);
184
577
  const body = document.createElement('div');
185
578
  body.className = 'twm-leaf__body';
186
579
  body.tabIndex = -1; // focusable via JS so document.activeElement
187
580
  // lands inside the body when the tile is
188
581
  // activated (keyboard-handler scope check).
189
- // Bottom tab strip — only rendered when the leaf carries
190
- // more than one tab. The strip is built/rebuilt as part of
191
- // the leaf element, not the body, so it stays pinned at the
192
- // bottom of the tile regardless of body scroll.
582
+ // The tab strip — only rendered when the leaf carries more than one
583
+ // tab. It is built as part of the leaf element, not of the body, so it
584
+ // stays pinned at its edge of the tile regardless of body scroll.
193
585
  const tabBar = document.createElement('div');
194
586
  tabBar.className = 'twm-leaf__tabbar';
195
587
  if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
196
588
  tabBar.classList.add('twm-leaf__tabbar--hidden');
197
589
  }
198
- wrap.append(chrome, body, tabBar);
590
+ // C22. CHROME FIRST IN BOTH LAYOUTS. The chrome is the pane's identity
591
+ // and the tabs are what is inside it; a strip above the title would say
592
+ // the tabs own the pane rather than the other way round.
593
+ wrap.append(chrome, ...(this.tabLayout === 'top' ? [tabBar, body]
594
+ : [body, tabBar]));
199
595
 
200
596
  wrap.addEventListener('mousedown', (e) => {
201
597
  if (e.target.closest('.twm-splitter')) return;
@@ -220,6 +616,123 @@ export class TileRenderer {
220
616
  }, 0);
221
617
  }
222
618
  });
619
+ // ── PULL THE HEADER DOWN TO FLOAT THE PANE ──────────────────────
620
+ //
621
+ // The inverse of the gesture everyone already knows: a maximised window
622
+ // is un-maximised by dragging its titlebar away from the edge, and a
623
+ // tile is a pane maximised into its slot. So dragging a tile's chrome
624
+ // DOWNWARD lifts it out as a floating window — the same thing the
625
+ // promote button does, reached the way a window manager teaches you to
626
+ // reach it.
627
+ //
628
+ // Downward only, and past a real threshold. A tile's chrome is also
629
+ // where you click to focus the pane, and a click carries a few pixels
630
+ // of movement with it; anything less than a deliberate pull is a click.
631
+ // Sideways is left alone: it means nothing here, and claiming it would
632
+ // make the strip feel like it grabs the pointer.
633
+ chrome.addEventListener('pointerdown', (e) => {
634
+ if (e.button !== 0) return;
635
+ if (e.target.closest(CHROME_NO_FLOAT)) return;
636
+ const startY = e.clientY;
637
+ const startX = e.clientX;
638
+ // PUBLISHED ON THE RENDERER, for `_syncChromeDragSource` to read.
639
+ //
640
+ // The native drag beats the pull to the pointer and there is no
641
+ // threshold at which it does not: Chromium starts one after about
642
+ // 5px of travel while the pull needs 24, so the ONLY moment the
643
+ // direction of this gesture can still be acted on is inside the
644
+ // `dragstart` the drag announces itself with. That handler has no
645
+ // other way to learn it — a `dragstart`'s own `clientX`/`clientY`
646
+ // read 0 for a drag begun this way (measured, Edge 152 /
647
+ // Chromium 152), so the event cannot answer the question being
648
+ // asked of it and this record has to.
649
+ //
650
+ // On the renderer rather than in this closure for `_tabDrag`'s
651
+ // reason: the reader is a method bound to a different element.
652
+ const pull = { startX, startY, x: startX, y: startY, lifted: false };
653
+ this._chromePull = pull;
654
+ const onMove = (move) => {
655
+ pull.x = move.clientX;
656
+ pull.y = move.clientY;
657
+ if (pull.lifted) return;
658
+ const down = move.clientY - startY;
659
+ const sideways = Math.abs(move.clientX - startX);
660
+ if (down < TILE_LIFT_PX || !_isDownwardPull(down, sideways)) return;
661
+ pull.lifted = true;
662
+ // ONLY `pointermove` COMES OFF HERE, AND THE RECORD STAYS —
663
+ // `cleanup()` would take it, and taking it is a defect.
664
+ //
665
+ // A `pointermove` can be COALESCED: one event may carry the
666
+ // whole 24px and promote on its first delivery, and Blink
667
+ // hands that move to script BEFORE starting the drag it
668
+ // crossed the threshold for — so a `dragstart` still arrives
669
+ // afterwards, and `_syncChromeDragSource` has to be able to
670
+ // read the gesture to refuse it. Without the record it did
671
+ // not, and a tab drag began on the pane that had just floated
672
+ // out of the tile. `pointerup` below is what clears it.
673
+ window.removeEventListener('pointermove', onMove);
674
+ this.ctx.onLeafAction?.(leaf.id, 'promote');
675
+ };
676
+ const cleanup = () => {
677
+ if (this._chromePull === pull) this._chromePull = null;
678
+ window.removeEventListener('pointermove', onMove);
679
+ window.removeEventListener('pointerup', cleanup);
680
+ window.removeEventListener('pointercancel', cleanup);
681
+ };
682
+ window.addEventListener('pointermove', onMove);
683
+ window.addEventListener('pointerup', cleanup);
684
+ window.addEventListener('pointercancel', cleanup);
685
+ });
686
+
687
+ // ── …OR DOUBLE-CLICK IT, WHICH IS THE GESTURE THAT ALREADY HAD A
688
+ // RETURN JOURNEY AND NO OUTWARD ONE ────────────────────────────
689
+ //
690
+ // Double-clicking a FLOATING window's title bar docks it back into a
691
+ // tile — `managed_window.js:919` binds it to `toggleMaximize()`, whose
692
+ // claimed verb under this WM is `bringBackWindow` ("maximise means back
693
+ // to tile", R7 — see the `onMaximize` `wm._promote` builds). So the
694
+ // half of the toggle that PUTS a
695
+ // pane back was spelled as a double-click on a header, and the half
696
+ // that takes it out was not: a user who learned the docking gesture had
697
+ // to reach for the float button, or discover the pull above, to undo
698
+ // what a double-click had just done. One gesture, one strip, both
699
+ // directions.
700
+ //
701
+ // The pull and this are two doors onto ONE verb, not two verbs. Both go
702
+ // through `onLeafAction(leaf.id, 'promote')`, so the WM's guards apply
703
+ // to both without knowing there are two — a panel tile and a window
704
+ // placeholder are refused in `wm._floatableLeaf` and a
705
+ // second double-click on an already-floated pane finds a re-seeded
706
+ // start tile rather than the content, which is the tree telling the
707
+ // truth rather than a case to special-case here.
708
+ //
709
+ // NATIVE `dblclick`, NOT A HAND-ROLLED CLICK COUNT — and that is a
710
+ // claim about the handlers above, not a preference. C28
711
+ // (`managed_window.js:1305`) is the case that says why: its pointerdown
712
+ // re-parented the window element out of the document before the pointer
713
+ // had moved, Blink and Gecko drop the pending `click` when the pressed
714
+ // element leaves the document, and the title bar's `dblclick` therefore
715
+ // never fired once in a real browser while looking perfectly correct in
716
+ // jsdom. Nothing on this strip does that. The wrap's `mousedown`
717
+ // toggles a class and calls `onFocusChange`, which the WM turns into
718
+ // `wm._notifyChange` — a callback and two bus emits, no repaint; its
719
+ // deferred `body.focus()` moves focus and moves
720
+ // nothing else, and focus does not reset a click count. The pull's
721
+ // `pointerdown` only registers window listeners and neither
722
+ // `preventDefault`s nor captures the pointer. The chrome the first
723
+ // click lands on is still in the document, still the same element, when
724
+ // the second one lands.
725
+ //
726
+ // No `e.button` test, unlike the pull: `dblclick` is dispatched for the
727
+ // primary button only. No `preventDefault`, and NO NEW CSS: the strip
728
+ // is already `user-select: none` (`css/overrides.css:1001`, mirrored
729
+ // into the generated `css/flexdesk.css:4743`), so a double-click here
730
+ // cannot word-select the title on its way to floating the pane.
731
+ chrome.addEventListener('dblclick', (e) => {
732
+ if (e.target.closest(CHROME_NO_FLOAT)) return;
733
+ this.ctx.onLeafAction?.(leaf.id, 'promote');
734
+ });
735
+
223
736
  // Right-click on the chrome → tile context menu (split, close,
224
737
  // promote, move to desktop). Listening on `chrome` only so the
225
738
  // tile body keeps its own contextmenu (e.g. DataTable's).
@@ -256,11 +769,45 @@ export class TileRenderer {
256
769
  const activeProps = activeTab?.props ?? leaf.content.props;
257
770
  content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
258
771
  if (content.title) title.textContent = content.title;
772
+ // C19. `icon` is the natural sibling of `title` and is read the
773
+ // same way: AFTER the mount, because that is the first moment the
774
+ // content knows what it is — a canvas pane you emptied is the same
775
+ // KIND as the overview and wants a different glyph.
776
+ _paintLeafIcon(icon, leaf, content, this.ctx);
777
+ // C18. A content factory may return `chromeActions`:
778
+ // `[{icon, title, onClick}]`. Read AFTER the mount, because that is
779
+ // the first moment the content knows what it can offer — a table
780
+ // does not know it has settings until it has a definition.
781
+ _paintContentActions(contentActions, content.chromeActions);
782
+ // C20. A content factory may also VETO structural verbs:
783
+ // `chrome: { promote: false, close: false }`. Read after the mount
784
+ // for the same reason `chromeActions` is — it is the first moment
785
+ // the content knows what it is.
786
+ //
787
+ // The case that forced it: an embedder's MASTER tile. It is the
788
+ // ground floating windows stand on and the thing you are left with
789
+ // when everything else is closed, so "float this pane as a window"
790
+ // and "close this pane" are offers to destroy the only surface the
791
+ // application has. The renderer already made this exact judgement
792
+ // for `panel:*` kinds by hardcoding it; this is that judgement,
793
+ // available to anyone.
794
+ _vetoStructuralActions(actions, content.chrome);
259
795
  } else {
260
796
  body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
261
797
  }
262
798
  entry = { wrapEl: wrap, bodyEl: body, chromeEl: chrome,
263
- titleEl: title, content, kindKey, tabBarEl: tabBar };
799
+ titleEl: title, iconEl: icon, content,
800
+ // DERIVED HERE, NOT REUSED FROM ABOVE. `kindKey` was computed
801
+ // before the mount; content that records its sub-tab or its
802
+ // scroll offset WHILE mounting has already written to the
803
+ // tree by now, so the value captured earlier is stale and the
804
+ // very next repaint would tear down the tile that had just
805
+ // said where it was.
806
+ kindKey: this._leafKindKey(leaf), tabBarEl: tabBar,
807
+ // C22. The `top` layout's NotebookTabBar, and the child it
808
+ // mounts into. Null under the `bottom` layout, which is plain
809
+ // markup this file writes itself.
810
+ tabStrip: null, tabStripHostEl: null };
264
811
  this._leafCache.set(leaf.id, entry);
265
812
 
266
813
  // Render tab strip last so it has access to the cached entry.
@@ -268,25 +815,474 @@ export class TileRenderer {
268
815
  return wrap;
269
816
  }
270
817
 
271
- /** Paint a leaf's bottom tab strip. The strip is hidden for
272
- * single-tab leaves (the common case) so existing layouts read as
273
- * identical to today. Hamburger button at the start, then one
274
- * trapezoid-shaped tab per stored tab spec. */
818
+ /**
819
+ * The cache key for a leaf: everything that must change before its wrap is
820
+ * torn down and rebuilt. Extracted so `rebaselineLeaf` below computes the
821
+ * same string this does two spellings of one key is a cache that misses
822
+ * on every render or never misses at all, and both look like working code.
823
+ */
824
+ _leafKindKey(leaf) {
825
+ const tabFingerprint = (leaf.tabs || []).map((t) =>
826
+ `${t.kind}::${JSON.stringify(t.props || {})}`).join('|') + `#${leaf.activeTabIdx || 0}`;
827
+ return leaf.content
828
+ ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}`
829
+ : '__empty__';
830
+ }
831
+
832
+ /**
833
+ * The cache key a leaf has RIGHT NOW, for a caller that is about to change
834
+ * the tree and wants to say what it expected to be changing from. See
835
+ * `rebaselineLeaf`.
836
+ */
837
+ leafKey(leafId) {
838
+ const leaf = this.tree?.get?.(leafId);
839
+ return (leaf && leaf.kind === 'leaf') ? this._leafKindKey(leaf) : null;
840
+ }
841
+
842
+ /**
843
+ * C34. ACCEPT A PROPS WRITE THE CONTENT MADE ABOUT ITSELF, WITHOUT
844
+ * REBUILDING THE TILE THAT MADE IT.
845
+ *
846
+ * The cache key above includes every tab's props, and that is right for
847
+ * NAVIGATION: a `table` tab whose `id` changes is different content and the
848
+ * tile must be re-mounted. It is exactly wrong for VIEW STATE. The
849
+ * framework hands every tile a `workspaceTabs.updateProps(patch)`
850
+ * (`page_factory.js`) documented as *"persist editor sub-state into this
851
+ * tile's WM tab props"* — a scroll offset, an open section, a selected
852
+ * sub-tab. Writing one changed the fingerprint, so the NEXT repaint (a
853
+ * focus change, a tab switch, a window promoted three tiles away) missed
854
+ * the cache, destroyed the content and mounted it again. The tile was torn
855
+ * down BY the call that existed to let it remember something, and because
856
+ * the rebuild reads the props back the result looked almost right — the
857
+ * editor came back at the saved scroll position, with everything uncommitted
858
+ * in it gone.
859
+ *
860
+ * So the key is re-baselined instead: the entry keeps its live DOM and
861
+ * starts answering to the new props. The next real navigation still misses
862
+ * and still rebuilds, because that changes the kind or the tab set and this
863
+ * only ever accepts what is already on screen.
864
+ *
865
+ * ══ IT ACCEPTS ONLY THE DELTA IT WAS CALLED FOR ═══════════════════
866
+ *
867
+ * The key is re-derived from the tree as it is NOW, so a first version of
868
+ * this swallowed every difference at once — including a change the tree had
869
+ * taken and the renderer had not drawn yet. Any mutation that does not
870
+ * repaint (`updateActiveTabProps` is itself one, and an embedder writing
871
+ * through `TileTree` directly is another) followed by a props write would
872
+ * have been accepted onto a wrap still showing the OLD content, and the
873
+ * tile would never have re-mounted: one thing drawn under another's title,
874
+ * permanently, with nothing left that knows the two disagree.
875
+ *
876
+ * `expected` is the fix and it is the caller's own honesty: the key it read
877
+ * BEFORE its write. If the cached entry is not still at that key, something
878
+ * else has changed since the tile was mounted and this is not the caller's
879
+ * to accept — refuse, and let the ordinary miss rebuild it.
880
+ *
881
+ * A structural test was tried first and is not enough. *Same kind, different
882
+ * props* is a scroll offset AND it is a navigation to another table; nothing
883
+ * in the leaf can tell them apart, because the difference is which caller
884
+ * asked. `expected` asks the caller.
885
+ *
886
+ * @param {string} leafId
887
+ * @param {string} [expected] the key the caller read before its own write.
888
+ * Omitted means "accept whatever is there", which is what the first
889
+ * version did and is kept only so an older caller does not silently
890
+ * change behaviour — every caller in this tree passes one.
891
+ * @returns {boolean} whether a cached wrap was re-baselined
892
+ */
893
+ rebaselineLeaf(leafId, expected = undefined) {
894
+ const entry = this._leafCache.get(leafId);
895
+ const leaf = this.tree?.get?.(leafId);
896
+ if (!entry || !leaf || leaf.kind !== 'leaf') return false;
897
+ if (expected !== undefined && entry.kindKey !== expected) return false;
898
+ entry.kindKey = this._leafKindKey(leaf);
899
+ return true;
900
+ }
901
+
902
+ /**
903
+ * C22. Change the tab layout of a LIVE renderer.
904
+ *
905
+ * Only the strip is rebuilt. The tab bar element is MOVED between its two
906
+ * positions and the body is never detached, so no content factory is
907
+ * unmounted — which is the whole reason this is a method rather than
908
+ * "rebuild the shell with the other option". A grid holding staged edits
909
+ * must not lose them because someone changed where its tabs are drawn.
910
+ *
911
+ * Accepts anything: the value arrives from a DOM attribute and from
912
+ * persisted user settings, and an unknown one means the default.
913
+ */
914
+ setTabLayout(layout) {
915
+ const next = _normalizeTabLayout(layout);
916
+ // The attribute is written back below, which re-enters through the
917
+ // observer; this is where that stops.
918
+ if (next === this.tabLayout) return;
919
+ this.tabLayout = next;
920
+ if (this.root.dataset && this.root.dataset.twmTabs !== next) {
921
+ this.root.dataset.twmTabs = next;
922
+ }
923
+ if (this.disposed) return;
924
+ for (const [leafId, entry] of this._leafCache) {
925
+ this._placeTabBar(entry);
926
+ // The two strips are different components, not two skins of one:
927
+ // whichever was there is torn down and the other is built.
928
+ this._disposeTabStrip(entry);
929
+ entry.tabBarEl.innerHTML = '';
930
+ const leaf = this.tree.get(leafId);
931
+ if (leaf) this._renderTabBar(leaf, entry);
932
+ }
933
+ }
934
+
935
+ /** Put a leaf's tab bar on the side the current layout says. Moving an
936
+ * attached element is a re-parent, not a rebuild — the body keeps its
937
+ * DOM, its listeners and its scroll. */
938
+ _placeTabBar(entry) {
939
+ const { wrapEl, bodyEl, tabBarEl } = entry;
940
+ if (!wrapEl || !tabBarEl || !bodyEl) return;
941
+ if (this.tabLayout === 'top') wrapEl.insertBefore(tabBarEl, bodyEl);
942
+ else wrapEl.appendChild(tabBarEl);
943
+ }
944
+
945
+ /** Tear down the `top` layout's component, if this leaf has one. Safe to
946
+ * call on a leaf that never had one, and on one that already lost it. */
947
+ _disposeTabStrip(entry) {
948
+ if (!entry?.tabStrip) return;
949
+ try { entry.tabStrip.dispose(); } catch (err) {
950
+ console.error('[tile] tab strip dispose threw', err);
951
+ }
952
+ entry.tabStrip = null;
953
+ entry.tabStripHostEl = null;
954
+ }
955
+
956
+ /** Paint a leaf's tab strip in whichever layout is current. The strip is
957
+ * hidden for single-tab leaves in BOTH layouts — the common case, and the
958
+ * reason existing single-pane layouts read as identical to today. */
275
959
  _renderTabBar(leaf, entry) {
960
+ // C33. BEFORE the single-tab early return, because the single-tab leaf
961
+ // is exactly the case this answers: no strip is drawn, so the chrome is
962
+ // the only thing left to grab.
963
+ this._syncChromeDragSource(leaf, entry);
276
964
  const bar = entry.tabBarEl;
277
965
  if (!bar) return;
278
966
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
967
+ bar.classList.toggle('twm-leaf__tabbar--top', this.tabLayout === 'top');
279
968
  if (tabs.length <= 1) {
280
969
  bar.classList.add('twm-leaf__tabbar--hidden');
970
+ this._disposeTabStrip(entry);
281
971
  bar.innerHTML = '';
282
972
  return;
283
973
  }
284
974
  bar.classList.remove('twm-leaf__tabbar--hidden');
975
+ if (this.tabLayout === 'top') this._renderTopTabBar(leaf, entry, tabs);
976
+ else this._renderBottomTabBar(leaf, entry, tabs);
977
+ }
978
+
979
+ /**
980
+ * C33. THE CHROME IS THE DRAG SOURCE FOR A LEAF THAT HAS ONE TAB.
981
+ *
982
+ * Product owner, 2026-08-27. The full argument is in the file header; what
983
+ * this function owns is the THREE conditions and why each is a condition
984
+ * rather than a preference:
985
+ *
986
+ * EXACTLY ONE TAB. With two or more, the strip is drawn and names each
987
+ * tab; a chrome drag would then have to guess which one was meant, and
988
+ * guessing is what the strip exists to avoid. With exactly one, "this
989
+ * tab" and "what is in this pane" are the same thing.
990
+ *
991
+ * NOT A PANEL. Panel tiles are chrome, not content — the same exclusion
992
+ * `_floatableLeaf` and `_snapProbe` already make, and for the same reason:
993
+ * there is nothing in them that belongs anywhere else.
994
+ *
995
+ * THE CONTENT DID NOT VETO `promote` (C20). One rule instead of two: a
996
+ * tab you may not lift out of its pane is a tab you may not drag into
997
+ * another one. This is what excludes an embedder's master tile — the
998
+ * ground its floating windows stand on — whose whole reason for existing
999
+ * is that it stays where it is.
1000
+ *
1001
+ * THE LISTENERS ARE BOUND ONCE PER CHROME ELEMENT and the ATTRIBUTE is
1002
+ * re-decided on every pass. That split is deliberate: `draggable` changes
1003
+ * the moment a second tab arrives, while the chrome element itself survives
1004
+ * for as long as its wrap does, and re-adding a listener on every render
1005
+ * would stack one per repaint.
1006
+ */
1007
+ _syncChromeDragSource(leaf, entry) {
1008
+ const chrome = entry?.chromeEl;
1009
+ if (!chrome) return;
1010
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1011
+ const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
1012
+ const vetoed = this.leafChrome(leaf.id)?.promote === false;
1013
+ const on = tabs.length === 1 && !isPanel && !vetoed;
1014
+ if (on) chrome.setAttribute('draggable', 'true');
1015
+ else chrome.removeAttribute('draggable');
1016
+ if (chrome.__twmTabDragBound) return;
1017
+ chrome.__twmTabDragBound = true;
1018
+ chrome.addEventListener('dragstart', (ev) => {
1019
+ // A drag that began on a BUTTON is not a drag of the pane. Buttons
1020
+ // are not draggable on their own, but a draggable ancestor makes
1021
+ // them so — hence the same guard the two float gestures apply, from
1022
+ // the same constant, so a control added to this strip later becomes
1023
+ // inert under all three at once.
1024
+ if (ev.target?.closest?.(CHROME_NO_FLOAT)) { ev.preventDefault(); return; }
1025
+ // ══ A DOWNWARD GESTURE IS THE PULL, AND IT HAS TO BE REFUSED
1026
+ // HERE, BECAUSE NOTHING LATER CAN ═══════════════════════════
1027
+ //
1028
+ // The file header used to claim these two gestures could not
1029
+ // collide: *"the pull … loses cleanly … The pull is also still
1030
+ // reachable, on every part of the chrome a single-tab leaf's title
1031
+ // does not cover."* That describes `draggable` on the TITLE. It is
1032
+ // set on the CHROME, four lines above — the whole strip — so the
1033
+ // pull was not merely losing where the title sits, it had no
1034
+ // reachable surface left on any single-tab tile. And below two tabs
1035
+ // the strip is hidden, so single-tab is the common case: the
1036
+ // reported symptom is *"pulling down a tile at title bar does not
1037
+ // anymore turn it into a managed window"*, and it was every tile.
1038
+ //
1039
+ // MEASURED, against the running application in Edge 152 rather
1040
+ // than in jsdom, which cannot start a native drag and therefore
1041
+ // cannot see any of this: press the chrome and pull down 140px and
1042
+ // the sequence is `dragstart`, `pointercancel`, `dragend`, with
1043
+ // zero windows promoted. The drag arrives at ~5-16px of travel and
1044
+ // `pointercancel` tears the pull's listeners down at ~8, so the
1045
+ // 24px threshold is never reached and never can be.
1046
+ //
1047
+ // Refusing the drag here is what hands the pointer back: a
1048
+ // `dragstart` that is cancelled starts no drag, so no
1049
+ // `pointercancel` is fired and `pointermove` simply resumes —
1050
+ // measured, same harness, the pull then fires at 26px. That is
1051
+ // also the house idiom for declining a native drag
1052
+ // (`DragReorder._start`'s `draggableGuard`, `drag_reorder.js`).
1053
+ //
1054
+ // C33 IS UNTOUCHED BY IT. A sideways or diagonal grab reports
1055
+ // `down <= sideways`, is not refused, and drags exactly as before
1056
+ // — verified in the same browser run. What this costs is the one
1057
+ // gesture that was ambiguous by construction: you can no longer
1058
+ // start a tab drag by pulling straight DOWN out of the chrome,
1059
+ // which is the direction the float has claimed since before the
1060
+ // tab drag existed.
1061
+ //
1062
+ // `_chromePull` OUTLIVES THE PROMOTE, and that is load-bearing
1063
+ // rather than tidy: a coalesced `pointermove` can carry the whole
1064
+ // 24px and promote on its first delivery, and Blink hands that
1065
+ // move to script BEFORE starting the drag it crossed the threshold
1066
+ // for — so a `dragstart` still arrives afterwards. Clearing the
1067
+ // record on lift left this read with nothing, so the drag was not
1068
+ // refused and a tab drag began on the pane that had just floated
1069
+ // out of the tile. The record's last coordinates are the lift's,
1070
+ // which are downward by definition, so the same test answers.
1071
+ const pull = this._chromePull;
1072
+ if (pull && _isDownwardPull(pull.y - pull.startY,
1073
+ Math.abs(pull.x - pull.startX))) {
1074
+ ev.preventDefault();
1075
+ return;
1076
+ }
1077
+ // Re-read the count rather than trusting the attribute: the tree
1078
+ // can gain a tab between a render and a gesture, and a stale
1079
+ // `draggable` would carry tab 0 out of a pane that now has three.
1080
+ const live = this.tree.get(leaf.id);
1081
+ if ((live?.tabs || []).length !== 1) { ev.preventDefault(); return; }
1082
+ this._beginTabDrag(ev, leaf.id, 0, chrome, { seedPlainText: true });
1083
+ });
1084
+ chrome.addEventListener('dragend', () => this._onTabDragEnd());
1085
+ }
1086
+
1087
+ /**
1088
+ * C22. The `top` layout: `NotebookTabBar`, the editor tab strip.
1089
+ *
1090
+ * The component is mounted into a CHILD of the bar rather than into the bar
1091
+ * itself, because `mount()` assigns `container.className = 'tabs
1092
+ * notebook-tabs'` (`notebook_tab_bar.js:61`) — handing it `.twm-leaf__tabbar`
1093
+ * would take that class, and with it the strip's height, its background and
1094
+ * `--hidden`, off the element this file still controls.
1095
+ *
1096
+ * Every gesture routes through the SAME `ctx.onLeafTabAction` vocabulary the
1097
+ * bottom strip uses, so the WM's tree mutations, its persistence and its
1098
+ * change notifications are reached by one path from both layouts.
1099
+ */
1100
+ _renderTopTabBar(leaf, entry, tabs) {
1101
+ const bar = entry.tabBarEl;
1102
+ if (!entry.tabStrip) {
1103
+ bar.innerHTML = '';
1104
+ const host = document.createElement('div');
1105
+ bar.appendChild(host);
1106
+ const strip = new NotebookTabBar();
1107
+ strip.mount(host, this._topTabCallbacks(leaf.id));
1108
+ entry.tabStrip = strip;
1109
+ entry.tabStripHostEl = host;
1110
+ // ONE TAB CONTEXT MENU IN THE APPLICATION, not two.
1111
+ //
1112
+ // `NotebookTabBar` opens its own `.nb-context-menu` on right-click,
1113
+ // whose verbs are a notebook's (rename, duplicate, reveal in
1114
+ // explorer) and whose close verbs duplicate — in a different
1115
+ // typeface — the menu the WM already builds for the bottom strip
1116
+ // (`wm._showTabContextMenu`). A user who switches layout must not
1117
+ // discover that right-clicking a tab now means something else.
1118
+ //
1119
+ // CAPTURE phase, on the container: the component binds its handler
1120
+ // on each tab element in the bubble phase, so stopping the event on
1121
+ // the way DOWN is what keeps its menu from opening at all. There is
1122
+ // no callback for this — the menu is not a callback, it is the
1123
+ // component's own DOM.
1124
+ host.addEventListener('contextmenu', (ev) => {
1125
+ const tabEl = ev.target.closest?.('.tab');
1126
+ if (!tabEl) return;
1127
+ ev.preventDefault();
1128
+ ev.stopPropagation();
1129
+ const idx = this._topTabIndex(host, tabEl);
1130
+ if (idx < 0) return;
1131
+ this.ctx.onLeafTabAction?.(leaf.id, 'menu',
1132
+ { idx, x: ev.clientX, y: ev.clientY });
1133
+ }, true);
1134
+ }
1135
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
1136
+ entry.tabStrip.update(
1137
+ tabs.map((t, i) => ({
1138
+ filePath: _tabKey(i),
1139
+ label: t.title || t.kind || '',
1140
+ // The component's `fileType` picks a glyph out of a static map
1141
+ // of FILE kinds. A tile's kinds are the embedder's, and the
1142
+ // taxonomy already answers for them — see `_paintTopTabs`.
1143
+ fileType: t.kind || 'unknown',
1144
+ // Nothing sets `dirty` on a tab spec today, so the dot is never
1145
+ // drawn. Read anyway, because the day a tile can say it holds
1146
+ // unsaved work this is where it says it, and the alternative is
1147
+ // a second place to remember.
1148
+ isDirty: !!t.dirty,
1149
+ })),
1150
+ _tabKey(activeIdx));
1151
+ this._paintTopTabs(entry.tabStripHostEl, tabs);
1152
+ }
1153
+
1154
+ /**
1155
+ * The two things `NotebookTabBar` derives from a vocabulary a tile does not
1156
+ * have, corrected in one pass over the DOM it just wrote.
1157
+ *
1158
+ * Its tooltip is the file PATH (`notebook_tab_bar.js:163`) and its glyph
1159
+ * comes from a static fileType map (`:135`). Ours are `builtin://tab/3` and
1160
+ * a content kind, so left alone a tab would advertise its own array index
1161
+ * and wear the generic `description` glyph — while the tile chrome an inch
1162
+ * above it shows the taxonomy's icon for exactly the same kind.
1163
+ *
1164
+ * Reaching into a component's DOM is worth one paragraph of justification.
1165
+ * The alternative for the glyph is `NotebookTabBar.setFileTypeIcons()`,
1166
+ * which is STATIC and REPLACES the whole map — so a page that also uses the
1167
+ * editor would find its own file icons deleted by whichever of the two
1168
+ * rendered last. The class names used here are the component's published
1169
+ * contract, stated in its header comment.
1170
+ */
1171
+ _paintTopTabs(hostEl, tabs) {
1172
+ if (!hostEl) return;
1173
+ const els = hostEl.querySelectorAll('.tab');
1174
+ els.forEach((el, i) => {
1175
+ const spec = tabs[i];
1176
+ if (!spec) return;
1177
+ el.title = spec.title || spec.kind || '';
1178
+ const icon = spec.kind ? this.ctx?.taxonomy?.meta?.(spec.kind)?.icon : null;
1179
+ const glyph = el.querySelector('.tab-icon');
1180
+ if (glyph && icon) glyph.textContent = icon;
1181
+ else if (glyph && !icon) glyph.hidden = true;
1182
+ });
1183
+ }
1184
+
1185
+ /** Which tab an element in the top strip is, by DOM position. Position
1186
+ * rather than the `data-path` key because the key is only ever the index
1187
+ * and reading it back would be a second, parallel answer to the same
1188
+ * question. */
1189
+ _topTabIndex(hostEl, tabEl) {
1190
+ return Array.prototype.indexOf.call(hostEl.querySelectorAll('.tab'), tabEl);
1191
+ }
1192
+
1193
+ /** The callbacks `NotebookTabBar` calls. Everything the component offers
1194
+ * that a tile tab cannot honour is deliberately absent rather than stubbed
1195
+ * — `onRename` is refused by the `builtin://` key, and the rest
1196
+ * (`onDuplicate`, `onSplitRight`, `onRevealInExplorer`, `onCloseAll`, …)
1197
+ * are only ever reached from the context menu this renderer suppresses.
1198
+ *
1199
+ * `onDropFromOtherPane` IS NOW WIRED, AND THIS IS THE RECORD OF WHY IT WAS
1200
+ * NOT. The refusal read: *"its payload is the dragged tab's key alone,
1201
+ * which carries no source-leaf identity, and `TileTree` has no
1202
+ * move-a-tab-between-leaves operation to receive it. Wiring it would need
1203
+ * both, and both are tree changes."* Both were true and C33 built both.
1204
+ * `TileTree.moveTabToLeaf` is the tree operation; `TileRenderer._tabDrag`
1205
+ * is the source identity — held on the renderer rather than in the payload
1206
+ * because HTML5's protected mode makes the payload unreadable at the only
1207
+ * moment it would be needed (see `TILE_TAB_MIME`). The payload handed to
1208
+ * the callback is therefore still ignored, exactly as the refusal said it
1209
+ * would have to be.
1210
+ *
1211
+ * A DROP ON A FOREIGN STRIP APPENDS. `NotebookTabBar` hands the callback a
1212
+ * key and no event, so there is no pointer position to derive a slot from
1213
+ * — and inventing one for this strip and not for the bottom one would give
1214
+ * the two layouts different answers to the same gesture. "Add this tab to
1215
+ * that tile" is what was asked for; where it sits in the strip is a
1216
+ * reorder away, in the mechanism that already does reorders. */
1217
+ _topTabCallbacks(leafId) {
1218
+ return {
1219
+ // C33. `DragReorder` calls this through `NotebookTabBar`, which
1220
+ // sets its own `application/x-ecosim-tab` first and unchanged — so
1221
+ // an editor pane sharing the page cannot notice that tiles are
1222
+ // dragging tabs too.
1223
+ onDragStart: (ev, key, item) => {
1224
+ const idx = _tabKeyIndex(key);
1225
+ if (idx < 0) return;
1226
+ this._beginTabDrag(ev, leafId, idx, item || null);
1227
+ },
1228
+ // What lets THIS strip admit a tab dragged out of another tile's
1229
+ // strip: `#isExternalTabDrag` tests `TAB_MIME` plus whatever the
1230
+ // host names here, and defers while its own reorder is running.
1231
+ externalTabMimes: [TILE_TAB_MIME],
1232
+ onDropFromOtherPane: () => {
1233
+ const src = this._tabDrag;
1234
+ this._onTabDragEnd();
1235
+ if (!src || src.leafId === leafId) return;
1236
+ this.ctx.onLeafTabAction?.(src.leafId, 'drop-into', {
1237
+ idx: src.idx,
1238
+ target: { leafId, mode: 'tab', toIdx: -1 },
1239
+ });
1240
+ },
1241
+ onActivate: (key) => {
1242
+ const idx = _tabKeyIndex(key);
1243
+ if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, 'switch', { idx });
1244
+ },
1245
+ onClose: (key) => {
1246
+ const idx = _tabKeyIndex(key);
1247
+ if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, 'close', { idx });
1248
+ },
1249
+ // DRAG-TO-REORDER ARRIVES AS A PERMUTATION, and the tree moves ONE
1250
+ // tab at a time (`TileTree.moveLeafTab(leafId, from, to)`). They
1251
+ // reconcile because a drag only ever moves one element: every other
1252
+ // key shifts by exactly one place, so the element that travelled
1253
+ // furthest between the two orders IS the one that was dragged.
1254
+ onReorder: (order) => {
1255
+ const leaf = this.tree.get(leafId);
1256
+ const count = (leaf?.tabs || []).length;
1257
+ if (!Array.isArray(order) || order.length !== count) return;
1258
+ let from = -1;
1259
+ let to = -1;
1260
+ let furthest = 0;
1261
+ order.forEach((key, newIdx) => {
1262
+ const oldIdx = _tabKeyIndex(key);
1263
+ if (oldIdx < 0) return;
1264
+ const travelled = Math.abs(newIdx - oldIdx);
1265
+ if (travelled > furthest) {
1266
+ furthest = travelled;
1267
+ from = oldIdx;
1268
+ to = newIdx;
1269
+ }
1270
+ });
1271
+ if (from < 0 || from === to) return;
1272
+ this.ctx.onLeafTabAction?.(leafId, 'move', { from, to });
1273
+ },
1274
+ };
1275
+ }
1276
+
1277
+ /** The `bottom` layout — the framework's own strip, unchanged. Hamburger
1278
+ * button at the start, then one trapezoid-shaped tab per stored tab spec. */
1279
+ _renderBottomTabBar(leaf, entry, tabs) {
1280
+ const bar = entry.tabBarEl;
285
1281
  const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
286
1282
  bar.innerHTML = `
287
1283
  <button type="button" class="twm-leaf__tab-hamburger"
288
1284
  data-action="tab-menu"
289
- title="Open in new tab from this page's content">
1285
+ title="Show open tabs" aria-label="Show open tabs">
290
1286
  <span class="material-symbols-outlined">menu</span>
291
1287
  </button>
292
1288
  <ol class="twm-leaf__tabs" role="tablist">
@@ -346,6 +1342,13 @@ export class TileRenderer {
346
1342
  // drop target reads it and emits a 'move' action. We don't use
347
1343
  // HTML5 setData('text/plain') for the live state because the
348
1344
  // dragstart/drop pair runs entirely within the same tab list.
1345
+ //
1346
+ // C33. …WHICH IS EXACTLY WHY `dragFromIdx` IS NOT ENOUGH ANY MORE. It
1347
+ // is a closure built per leaf render, so leaf B's handlers cannot see
1348
+ // that leaf A started a drag. It stays, unchanged, as the in-strip
1349
+ // reorder's own state — the gesture it was written for still begins and
1350
+ // ends in one list — and the cross-tile identity is taken on the
1351
+ // renderer beside it. Two facts, two homes, and neither one guessing.
349
1352
  let dragFromIdx = null;
350
1353
  bar.querySelectorAll('.twm-leaf__tab').forEach((li) => {
351
1354
  li.addEventListener('dragstart', (ev) => {
@@ -355,7 +1358,14 @@ export class TileRenderer {
355
1358
  // called — supply a placeholder string.
356
1359
  try { ev.dataTransfer.setData('text/plain', String(dragFromIdx)); }
357
1360
  catch {}
1361
+ this._beginTabDrag(ev, leaf.id, dragFromIdx, li);
358
1362
  });
1363
+ // C33. THE DRAGGED TAB NOW LOOKS DRAGGED. `DragReorder` has put
1364
+ // `.dragging` on the top strip's tab since it was written; this
1365
+ // strip's hand-rolled reorder set no class at all, so a drag here
1366
+ // looked exactly like a click that did nothing until the tab
1367
+ // arrived somewhere else. One rule serves both strips.
1368
+ li.addEventListener('dragend', () => { dragFromIdx = null; this._onTabDragEnd(); });
359
1369
  li.addEventListener('dragover', (ev) => {
360
1370
  if (dragFromIdx == null) return;
361
1371
  ev.preventDefault();
@@ -372,6 +1382,225 @@ export class TileRenderer {
372
1382
  dragFromIdx = null;
373
1383
  });
374
1384
  });
1385
+
1386
+ // ── C33. AND A TAB FROM ANOTHER TILE, DROPPED ON THIS STRIP ──────
1387
+ //
1388
+ // The container level, not the tab level, because the useful target is
1389
+ // "this strip" rather than "this tab": the per-tab handlers above
1390
+ // return without `preventDefault` when `dragFromIdx` is null, which is
1391
+ // always true of a foreign drag, so the event reaches here by bubbling
1392
+ // and the two never contend.
1393
+ //
1394
+ // The gate is the same shape as `NotebookTabBar.#isExternalTabDrag`:
1395
+ // the type-only marker, plus a source that is not this leaf. The
1396
+ // `dragleave` is guarded with `contains(relatedTarget)` because
1397
+ // `dragleave` fires every time the pointer crosses into a CHILD, and
1398
+ // without the guard the strip would unhighlight itself the moment the
1399
+ // pointer touched a tab inside it — the same fix `editor_pane.js` and
1400
+ // `notebook_tab_bar.js` already carry.
1401
+ // BOUND ONCE PER BAR ELEMENT, not once per render. `_renderBottomTabBar`
1402
+ // rewrites `bar.innerHTML` — which takes the per-tab listeners with the
1403
+ // `<li>`s they were on — but the BAR survives, and `setTabLayout` calls
1404
+ // straight back in on the same element. Without this flag a user who
1405
+ // switched layout twice would get three copies of the drop handler and
1406
+ // three `drop-into` actions from one release.
1407
+ const foreign = (ev) => !!this._tabDrag
1408
+ && this._tabDrag.leafId !== leaf.id
1409
+ && _isTileTabDrag(ev);
1410
+ if (bar.__twmBarDropBound) return;
1411
+ bar.__twmBarDropBound = true;
1412
+ bar.addEventListener('dragover', (ev) => {
1413
+ if (!foreign(ev)) return;
1414
+ ev.preventDefault();
1415
+ try { ev.dataTransfer.dropEffect = 'move'; } catch {}
1416
+ // The tile zone and the strip zone must never be armed at once, and
1417
+ // this is the TILE zone that goes — never `_clearTabDropZone`,
1418
+ // which would take the class added on the next line straight back
1419
+ // off. See `_onTabDragOver`: this handler runs FIRST (the bar is a
1420
+ // descendant of the root, and both listeners are on the bubble
1421
+ // phase), so anything it paints has to survive what runs after it.
1422
+ this._clearTileDropZone();
1423
+ bar.classList.add('twm-leaf__tabbar--drop-target');
1424
+ });
1425
+ bar.addEventListener('dragleave', (ev) => {
1426
+ if (!bar.contains(ev.relatedTarget)) {
1427
+ bar.classList.remove('twm-leaf__tabbar--drop-target');
1428
+ }
1429
+ });
1430
+ bar.addEventListener('drop', (ev) => {
1431
+ if (!foreign(ev)) return;
1432
+ ev.preventDefault();
1433
+ bar.classList.remove('twm-leaf__tabbar--drop-target');
1434
+ const src = this._tabDrag;
1435
+ this._onTabDragEnd();
1436
+ // APPEND, like the top strip's drop does, and for the same reason:
1437
+ // one gesture must not mean two things depending on where the user
1438
+ // draws their tabs. See `_topTabCallbacks`.
1439
+ this.ctx.onLeafTabAction?.(src.leafId, 'drop-into', {
1440
+ idx: src.idx,
1441
+ target: { leafId: leaf.id, mode: 'tab', toIdx: -1 },
1442
+ });
1443
+ });
1444
+ }
1445
+
1446
+ // ══ C33. THE TAB DRAG ═══════════════════════════════════════════════
1447
+
1448
+ /** Take the identity of the tab now being carried, and mark it.
1449
+ *
1450
+ * `seedPlainText` is for the CHROME source only. The two strips already
1451
+ * set `text/plain` themselves — `DragReorder._start` writes the reorder
1452
+ * key, the bottom strip writes the index — and overwriting either would
1453
+ * hand `NotebookTabBar.#onStripDrop`'s `getData(TAB_MIME) ||
1454
+ * getData('text/plain')` fallback a number where it expects a key. The
1455
+ * chrome has no such writer and Firefox refuses to begin a drag with an
1456
+ * empty `dataTransfer`, so it supplies its own. */
1457
+ _beginTabDrag(ev, leafId, idx, el, { seedPlainText = false } = {}) {
1458
+ this._tabDrag = { leafId, idx, el: el || null };
1459
+ this._tabDropProbe = null;
1460
+ try {
1461
+ ev.dataTransfer.effectAllowed = 'move';
1462
+ ev.dataTransfer.setData(TILE_TAB_MIME, '1');
1463
+ if (seedPlainText) ev.dataTransfer.setData('text/plain', String(idx));
1464
+ } catch { /* a synthetic event, or a dataTransfer in protected mode */ }
1465
+ el?.classList?.add('dragging');
1466
+ }
1467
+
1468
+ /**
1469
+ * Arm — or refuse — the tile under the pointer.
1470
+ *
1471
+ * NOTHING HERE MAY RENDER. `render()` clears the root, which detaches the
1472
+ * element the browser is dragging, and the browser cancels the gesture the
1473
+ * moment that happens. The DOM afterwards reads perfectly correct, which is
1474
+ * what makes this failure so hard to see; it is the drag-and-drop cousin of
1475
+ * the `mousedown`-repaint defect recorded five times against the taskbar.
1476
+ *
1477
+ * `preventDefault()` is not decoration either: without it the browser
1478
+ * refuses the drop outright and `drop` never fires, which reads exactly
1479
+ * like a broken handler.
1480
+ */
1481
+ _onTabDragOver(e) {
1482
+ const src = this._tabDrag;
1483
+ if (!src || !_isTileTabDrag(e)) return;
1484
+ // A STRIP OWNS ITS OWN DROPS. Inside a tab bar the question is a
1485
+ // position in a list, not a zone of a tile, and both strips answer it
1486
+ // themselves — so this hands the event on rather than arming a zone
1487
+ // behind them.
1488
+ //
1489
+ // `_clearTileDropZone` and NOT `_clearTabDropZone`: the strip's handler
1490
+ // has ALREADY RUN by the time this one does — it is bound on a
1491
+ // descendant of the root and both are bubble-phase — so clearing the
1492
+ // strip classes here would remove the highlight it just painted, on
1493
+ // every `dragover`, and the strip would arm and disarm invisibly for
1494
+ // the whole gesture.
1495
+ if (e.target?.closest?.('.twm-leaf__tabbar')) { this._clearTileDropZone(); return; }
1496
+ const probe = this.ctx.wm?.tabDropProbe?.(e, { sourceLeafId: src.leafId }) || null;
1497
+ this._tabDropProbe = probe;
1498
+ if (!probe) { this._clearTabDropZone(); return; }
1499
+ e.preventDefault();
1500
+ try { e.dataTransfer.dropEffect = 'move'; } catch {}
1501
+ for (const [leafId, entry] of this._leafCache) {
1502
+ entry.wrapEl.classList.toggle('twm-leaf--drop-target', leafId === probe.leafId);
1503
+ }
1504
+ this._showTabDropPreview(probe.rect);
1505
+ }
1506
+
1507
+ /** Release. The zone that was ARMED is the zone that runs — the stashed
1508
+ * probe rather than a fresh one — because C15's rule is that the rectangle
1509
+ * drawn during the drag is the rectangle the drop delivers, and a pointer
1510
+ * one pixel outside the band at release must not quietly mean something
1511
+ * else. */
1512
+ _onTabDrop(e) {
1513
+ const src = this._tabDrag;
1514
+ const probe = this._tabDropProbe;
1515
+ if (!src || !_isTileTabDrag(e)) return;
1516
+ if (e.target?.closest?.('.twm-leaf__tabbar')) return;
1517
+ e.preventDefault();
1518
+ this._onTabDragEnd();
1519
+ if (!probe) return;
1520
+ this.ctx.onLeafTabAction?.(src.leafId, 'drop-into', {
1521
+ idx: src.idx,
1522
+ target: {
1523
+ leafId: probe.leafId,
1524
+ mode: probe.mode,
1525
+ // The same derivation `_snapCommit` applies to a window drop
1526
+ // (`wm.js`, the `_dock` literal) — one reading of a side, so a
1527
+ // tab and a window cannot land on opposite halves of one edge.
1528
+ dir: (probe.side === 'left' || probe.side === 'right') ? 'h' : 'v',
1529
+ before: (probe.side === 'left' || probe.side === 'top'),
1530
+ toIdx: -1,
1531
+ },
1532
+ });
1533
+ }
1534
+
1535
+ /** Leaving the root entirely disarms, and only that. The drag is still
1536
+ * live — it may come back — so `_tabDrag` survives and only the painting
1537
+ * goes. */
1538
+ _onTabDragLeave(e) {
1539
+ if (!this._tabDrag) return;
1540
+ if (this.root.contains(e.relatedTarget)) return;
1541
+ this._clearTabDropZone();
1542
+ this._tabDropProbe = null;
1543
+ }
1544
+
1545
+ /** The only guaranteed end of a drag. Escape produces this and no `drop`;
1546
+ * so does a release over a target that refused. Idempotent, because the
1547
+ * drop path calls it too and `dragend` still arrives afterwards. */
1548
+ _onTabDragEnd() {
1549
+ this._tabDrag?.el?.classList?.remove('dragging');
1550
+ this._tabDrag = null;
1551
+ this._tabDropProbe = null;
1552
+ this._clearTabDropZone();
1553
+ }
1554
+
1555
+ /** The TILE zone only — the outlined pane and the preview rectangle. Split
1556
+ * out from the whole because a strip that has just armed itself must not
1557
+ * be disarmed by the root handler running behind it. */
1558
+ _clearTileDropZone() {
1559
+ for (const [, entry] of this._leafCache) {
1560
+ entry.wrapEl.classList.remove('twm-leaf--drop-target');
1561
+ }
1562
+ this._clearTabDropPreview();
1563
+ }
1564
+
1565
+ /** Everything: the tile zone and both strips'. The end of a gesture, where
1566
+ * nothing may be left painted. */
1567
+ _clearTabDropZone() {
1568
+ this._clearTileDropZone();
1569
+ for (const [, entry] of this._leafCache) {
1570
+ entry.tabBarEl?.classList?.remove('twm-leaf__tabbar--drop-target');
1571
+ }
1572
+ }
1573
+
1574
+ /** The rectangle a release would fill.
1575
+ *
1576
+ * IT IS THE WINDOW DROP'S OWN PREVIEW ELEMENT — same two classes, same
1577
+ * stylesheet rules (`css/base.css`, `.twm-snap-preview`) — so a tab drop
1578
+ * and a window drop cannot come to disagree about what a drop looks like.
1579
+ * `--viewport` is what makes `position: fixed` apply, and that is required
1580
+ * rather than cosmetic: the rectangle came from `getBoundingClientRect` on
1581
+ * a leaf, which speaks viewport pixels.
1582
+ *
1583
+ * Parented to `document.body` and not to the root, for R13's reason:
1584
+ * `render()`'s `innerHTML = ''` takes every direct child of the root, and
1585
+ * a repaint during a drag needs nothing more exotic than the drag itself. */
1586
+ _showTabDropPreview(rect) {
1587
+ if (!rect) { this._clearTabDropPreview(); return; }
1588
+ if (!this._tabDropPreviewEl) {
1589
+ const el = document.createElement('div');
1590
+ el.className = 'twm-snap-preview twm-snap-preview--viewport';
1591
+ el.setAttribute('aria-hidden', 'true');
1592
+ this._tabDropPreviewEl = el;
1593
+ }
1594
+ const el = this._tabDropPreviewEl;
1595
+ Object.assign(el.style, {
1596
+ left: `${rect.left ?? rect.x}px`, top: `${rect.top ?? rect.y}px`,
1597
+ width: `${rect.width}px`, height: `${rect.height}px`,
1598
+ });
1599
+ if (el.parentNode !== document.body) document.body.appendChild(el);
1600
+ }
1601
+
1602
+ _clearTabDropPreview() {
1603
+ this._tabDropPreviewEl?.remove();
375
1604
  }
376
1605
 
377
1606
  _updateFocusClasses() {
@@ -388,6 +1617,43 @@ export class TileRenderer {
388
1617
  return this._leafCache.get(leafId)?.wrapEl || null;
389
1618
  }
390
1619
 
1620
+ /** C20, extended. The `chrome` veto object the mounted content declared —
1621
+ * `{ promote: false, close: false }` — or null when the leaf is not
1622
+ * rendered or its content declared nothing.
1623
+ *
1624
+ * IT EXISTS BECAUSE A VETO PAINTED ON A BUTTON IS NOT A VETO. C20 landed
1625
+ * as `_vetoStructuralActions`, which removes the button from this strip —
1626
+ * and a removed button is only the door the CONTENT can see. The verb has
1627
+ * three other doors: the tile's right-click menu (`shell.js`'s "Float this
1628
+ * pane as a window"), the chrome pull-down, and the chrome's double-click.
1629
+ * All three reach `WindowManager.floatPane` without passing this file, so
1630
+ * a master tile that declared itself unfloatable was floated by any of
1631
+ * them — reproduced: the ground pane floats, every window standing on it
1632
+ * is force-closed, and the pane is re-seeded WITHOUT its `canvas` prop.
1633
+ *
1634
+ * So the WM asks the renderer what the content said, and enforces it in
1635
+ * `_floatableLeaf` where every door already converges. The renderer stays
1636
+ * the only place that knows what was mounted; the WM stays the only place
1637
+ * that decides whether a verb runs. `closeFocused` now reads `close` the
1638
+ * same way, for the same reason and after the same defect: Alt+W, the tile
1639
+ * context menu and the tab strip's × all closed a pane whose own button
1640
+ * was greyed out with a tooltip saying it could not be.
1641
+ *
1642
+ * ══ THIS RETURNS THE FACTORY'S LIVE OBJECT, AND THAT IS A CONTRACT ══
1643
+ *
1644
+ * Not a copy and not a snapshot. `chrome` is READ ONCE, at mount — a
1645
+ * repaint of a cached leaf re-reads only the title and the glyph — so a
1646
+ * veto whose ANSWER CHANGES over the life of the tile must be kept up to
1647
+ * date by the content that stated it, by mutating the object it returned.
1648
+ * Tables' ground pane is exactly that case: its close is refused only
1649
+ * while it is the last content pane, and it re-syncs on `wm:changed`.
1650
+ * Repainting the button alone is not enough now that a verb consults this
1651
+ * — a stale `{disabled: true}` refuses a close every affordance on screen
1652
+ * says is available, which is the same class of lie as a dead control. */
1653
+ leafChrome(leafId) {
1654
+ return this._leafCache.get(leafId)?.content?.chrome || null;
1655
+ }
1656
+
391
1657
  // ── Drag-resize ────────────────────────────────────────────────
392
1658
  _onMouseDown(e) {
393
1659
  const splitter = e.target.closest('.twm-splitter');
@@ -457,3 +1723,102 @@ function _esc(s) {
457
1723
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
458
1724
  }[c]));
459
1725
  }
1726
+
1727
+ /**
1728
+ * C19. Decide, and paint, the glyph left of a tile's title.
1729
+ *
1730
+ * TWO SOURCES, IN THIS ORDER, and the order is the whole design. The content
1731
+ * factory's `content.icon` wins because only the content knows what this
1732
+ * PARTICULAR thing is — one table has a cog, the next has a calendar, and the
1733
+ * taxonomy has no opinion about either. Everything else falls back to
1734
+ * `taxonomy.meta(kind).icon`, which is the glyph the top-nav, the command
1735
+ * palette and the breadcrumb already draw for that kind — so an embedder who
1736
+ * changes nothing at all still gets a tile chrome that agrees with the three
1737
+ * surfaces around it, and an embedder who has never returned an `icon` from a
1738
+ * factory gets one for free.
1739
+ *
1740
+ * A kind with neither — a `panel:*` tile, whose kinds are the framework's own
1741
+ * and are deliberately not in anybody's taxonomy — is HIDDEN rather than blank.
1742
+ * The chrome is a flex row with a gap; an empty span would still take its gap
1743
+ * and push the title off the left margin by a glyph's worth of nothing.
1744
+ */
1745
+ function _paintLeafIcon(iconEl, leaf, content, ctx) {
1746
+ if (!iconEl) return;
1747
+ const kind = leaf?.content?.kind;
1748
+ const name = content?.icon || (kind ? ctx?.taxonomy?.meta?.(kind)?.icon : null);
1749
+ iconEl.textContent = name || '';
1750
+ iconEl.hidden = !name;
1751
+ }
1752
+
1753
+ /**
1754
+ * C18. Paint a content factory's own chrome buttons into its tile's strip.
1755
+ *
1756
+ * The framework owns the tile and its verbs; the embedder owns the content and
1757
+ * ITS verbs, and until now had nowhere to put them. Tables ended up hanging a
1758
+ * table's settings and its pop-out off the grid's own toolbar, one row further
1759
+ * down, where they read as things you do to the ROWS.
1760
+ *
1761
+ * Deliberately not a registry and not a config: the factory returns them from
1762
+ * the same object it already returns `title` and `destroy` on, so they arrive
1763
+ * with the mount and leave with it.
1764
+ */
1765
+ function _paintContentActions(hostEl, specs) {
1766
+ hostEl.innerHTML = '';
1767
+ if (!Array.isArray(specs) || specs.length === 0) {
1768
+ hostEl.hidden = true;
1769
+ return;
1770
+ }
1771
+ hostEl.hidden = false;
1772
+ for (const spec of specs) {
1773
+ if (!spec || !spec.icon) continue;
1774
+ const btn = document.createElement('button');
1775
+ btn.type = 'button';
1776
+ btn.className = 'twm-leaf__btn';
1777
+ btn.title = spec.title || '';
1778
+ btn.setAttribute('aria-label', spec.title || '');
1779
+ btn.innerHTML =
1780
+ `<span class="material-symbols-outlined">${spec.icon}</span>`;
1781
+ // `stopPropagation` so a click does not also reach the leaf's own
1782
+ // mousedown, which focuses the tile and then parks focus inside the
1783
+ // body — stealing it back from whatever the action just opened.
1784
+ btn.addEventListener('click', (e) => {
1785
+ e.stopPropagation();
1786
+ try { spec.onClick?.(); } catch (err) { console.error('[tile] action threw', err); }
1787
+ });
1788
+ hostEl.appendChild(btn);
1789
+ }
1790
+ }
1791
+
1792
+ /**
1793
+ * C20. Let a content factory say a structural verb does not apply to it.
1794
+ *
1795
+ * Three answers, and the difference between the last two is a real one:
1796
+ *
1797
+ * (absent) / true the verb applies. The default, so an embedder
1798
+ * that says nothing gets every button as before.
1799
+ * `false` the verb is MEANINGLESS here — remove it. A
1800
+ * panel tile cannot be floated; there is nothing to
1801
+ * explain, and a permanently dead control is
1802
+ * clutter that teaches nothing.
1803
+ * `{ disabled, title }` the verb applies to this KIND of pane but not
1804
+ * right now. Keep the button, grey it, and say
1805
+ * why — "you may close a pane, but not the last
1806
+ * one" is a rule the user should be able to
1807
+ * discover by pointing at the thing it governs,
1808
+ * rather than by noticing that a button they
1809
+ * remember is missing.
1810
+ */
1811
+ function _vetoStructuralActions(hostEl, chrome) {
1812
+ if (!chrome) return;
1813
+ for (const [action, rule] of Object.entries(chrome)) {
1814
+ const btn = hostEl.querySelector(`[data-action="${action}"]`);
1815
+ if (!btn) continue;
1816
+ if (rule === false) { btn.remove(); continue; }
1817
+ if (rule && typeof rule === 'object' && rule.disabled) {
1818
+ btn.disabled = true;
1819
+ btn.setAttribute('aria-disabled', 'true');
1820
+ btn.classList.add('twm-leaf__btn--disabled');
1821
+ if (rule.title) btn.title = rule.title;
1822
+ }
1823
+ }
1824
+ }