flexdesk 0.2.0 → 0.4.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 (58) hide show
  1. package/README.md +26 -0
  2. package/css/base.css +2243 -872
  3. package/css/flexdesk.css +1375 -14
  4. package/css/overrides.css +44 -0
  5. package/css/tokens.css +45 -0
  6. package/dist/charts.js +5 -3
  7. package/dist/charts.js.map +1 -1
  8. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  9. package/dist/chunk-ELXVW542.js.map +7 -0
  10. package/dist/chunk-LH5TSOZW.js +1237 -0
  11. package/dist/chunk-LH5TSOZW.js.map +7 -0
  12. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  13. package/dist/chunk-O5OHMWBB.js.map +7 -0
  14. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  15. package/dist/chunk-QIU5S2RU.js.map +7 -0
  16. package/dist/chunk-QNQHQ24V.js +408 -0
  17. package/dist/chunk-QNQHQ24V.js.map +7 -0
  18. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  19. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  20. package/dist/editor.js +3 -380
  21. package/dist/editor.js.map +3 -3
  22. package/dist/flexdesk.css +1375 -14
  23. package/dist/tiles.js +168 -41
  24. package/dist/tiles.js.map +2 -2
  25. package/dist/tokens.css +45 -0
  26. package/dist/widgets.js +44 -14
  27. package/dist/widgets.js.map +2 -2
  28. package/dist/wm.js +3140 -157
  29. package/dist/wm.js.map +4 -4
  30. package/package.json +3 -2
  31. package/src/charts/chart_types.js +167 -0
  32. package/src/charts/plotly_wrapper.js +178 -10
  33. package/src/editor/notebook_tab_bar.js +39 -3
  34. package/src/tiles/tile_base.js +143 -35
  35. package/src/tiles/tile_grid.js +52 -1
  36. package/src/tiling/command_palette.js +71 -18
  37. package/src/tiling/desktops.js +36 -12
  38. package/src/tiling/keymap.js +24 -4
  39. package/src/tiling/shell.js +156 -25
  40. package/src/tiling/tab_strip.js +184 -0
  41. package/src/tiling/tile_breadcrumb.js +34 -2
  42. package/src/tiling/tile_renderer.js +1386 -21
  43. package/src/tiling/tile_tab_menu.js +101 -0
  44. package/src/tiling/tile_tree.js +115 -11
  45. package/src/tiling/wm.js +2375 -84
  46. package/src/tiling/zoom.js +248 -0
  47. package/src/ui/components/action_dropdown.js +34 -3
  48. package/src/ui/components/autocomplete_field.js +65 -13
  49. package/src/ui/components/context_menu.js +79 -8
  50. package/src/ui/components/data_table.js +508 -84
  51. package/src/ui/components/managed_window.js +928 -36
  52. package/src/ui/components/modal.js +214 -8
  53. package/dist/chunk-CT4YXXLP.js.map +0 -7
  54. package/dist/chunk-DRYCDMEG.js.map +0 -7
  55. package/dist/chunk-DVU44T77.js.map +0 -7
  56. package/dist/chunk-TLZUUFOE.js.map +0 -7
  57. package/dist/chunk-UCJ2WD4D.js +0 -625
  58. package/dist/chunk-UCJ2WD4D.js.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,7 +57,8 @@
57
57
  },
58
58
  "scripts": {
59
59
  "build": "node build.mjs",
60
- "check": "node build.mjs --check"
60
+ "check": "node build.mjs --check",
61
+ "test": "node tests/run.mjs"
61
62
  },
62
63
  "devDependencies": {
63
64
  "esbuild": "^0.28.1"
@@ -17,6 +17,15 @@ export const CHART_TYPES_2D = Object.freeze([
17
17
  { value: 'area', label: 'Area' },
18
18
  { value: 'area-stacked', label: 'Area (Stacked %)' },
19
19
  { value: 'phase', label: 'Phase' },
20
+ // Categorical part-of-whole. They belong in this list because the list is
21
+ // what a consumer renders as its chart-type dropdown — but they are NOT
22
+ // (x[], y[]) charts, and `buildTrace` leaves for them before the switch:
23
+ // read the early return there before adding a fourth. `indicator` is
24
+ // deliberately NOT here, because it takes a SCALAR — a dropdown offering it
25
+ // beside these would offer a rendering of data the chart is not bound to.
26
+ { value: 'pie', label: 'Pie' },
27
+ { value: 'donut', label: 'Donut' },
28
+ { value: 'funnel', label: 'Funnel' },
20
29
  ]);
21
30
 
22
31
  /**
@@ -76,6 +85,7 @@ const INTERPOLATION_MAP = {
76
85
  * @param {string} [options.yAxisId] - Y-axis identifier (y, y2, y3, etc.)
77
86
  * @param {boolean} [options.showMarkers] - Show data point markers
78
87
  * @param {number} [options.seriesIndex] - Index for default color selection
88
+ * @param {Array<string>} [options.colors] - Per-CATEGORY colors, pie/donut/funnel only
79
89
  * @returns {Object} Plotly trace object
80
90
  */
81
91
  export function buildTrace(options) {
@@ -93,10 +103,62 @@ export function buildTrace(options) {
93
103
  showMarkers = false,
94
104
  seriesIndex = 0,
95
105
  stackGroup = null,
106
+ colors = null,
96
107
  } = options;
97
108
 
98
109
  const seriesColor = color || getSeriesColor(seriesIndex);
99
110
 
111
+ // ── categorical part-of-whole: pie, donut, funnel ────────────────────
112
+ // These leave here, ABOVE the switch, and that placement is the whole
113
+ // point rather than a tidiness. Everything below is written against the
114
+ // base trace built at the end of the switch — `{type, name, x, y}` — and a
115
+ // pie has neither an x nor a y: Plotly reads `labels` and `values`, and a
116
+ // stray `x` rides along ignored right up until someone puts the trace on a
117
+ // shared axis and it is not ignored any more.
118
+ //
119
+ // The marker block further down is the sharper edge. It fires on
120
+ // `showMarkers` for ANY chart type and assigns `marker = {color, size}` —
121
+ // which would overwrite `marker.colors`, the per-slice palette, with one
122
+ // scalar colour and turn a seven-slice pie into a single blue disc. A
123
+ // `case` in the switch cannot avoid either hazard; leaving before both can.
124
+ if (chartType === 'pie' || chartType === 'donut' || chartType === 'funnel') {
125
+ // `seriesIndex` is not consulted: a pie IS the chart, so its slices
126
+ // start at the top of the palette rather than continuing someone
127
+ // else's series numbering.
128
+ const sliceColors = Array.isArray(colors) && colors.length
129
+ ? colors
130
+ : y.map((_, i) => getSeriesColor(i));
131
+
132
+ if (chartType === 'funnel') {
133
+ // A funnel is bar-shaped, so it is `marker.color` (singular, and
134
+ // an array is legal) — NOT the `marker.colors` a pie wants. The
135
+ // two spellings are not interchangeable and neither errors.
136
+ return {
137
+ type: 'funnel',
138
+ name,
139
+ y: x, // stages read down the categorical axis
140
+ x: y, // ...and the measure runs across
141
+ marker: { color: sliceColors },
142
+ textinfo: 'value+percent initial',
143
+ };
144
+ }
145
+
146
+ return {
147
+ type: 'pie',
148
+ name,
149
+ labels: x,
150
+ values: y,
151
+ marker: { colors: sliceColors },
152
+ // Plotly sorts slices descending by default. The caller has
153
+ // already ordered its rows — a dashboard binding carries its own
154
+ // `sort` — and a second, invisible reordering here makes the chart
155
+ // disagree with the table beside it and with the config that
156
+ // produced both.
157
+ sort: false,
158
+ ...(chartType === 'donut' ? { hole: 0.55 } : {}),
159
+ };
160
+ }
161
+
100
162
  // Determine Plotly trace type and mode
101
163
  let plotlyType, mode;
102
164
  switch (chartType) {
@@ -697,3 +759,108 @@ export function buildHeatmapTrace(options) {
697
759
 
698
760
  return trace;
699
761
  }
762
+
763
+ // Delta sense, in the palette's own idiom (Solarized, per COLOR_PALETTE). A
764
+ // caller whose metric is better when it falls — cost, latency, churn — passes
765
+ // its own two colours rather than expecting a flag here: "up is good" is a
766
+ // property of the measure, and this file has never been told what the measure
767
+ // is.
768
+ const DELTA_UP = '#859900'; // Solarized green
769
+ const DELTA_DOWN = '#dc322f'; // Solarized red
770
+
771
+ /**
772
+ * Create a Plotly `indicator` trace — a big number, optionally with a gauge
773
+ * arc under it and a delta against a reference.
774
+ *
775
+ * This is NOT reachable through `buildTrace`, and `indicator` is not in
776
+ * `CHART_TYPES_2D`: `buildTrace`'s contract is two parallel arrays and an
777
+ * indicator takes a single scalar, so offering it in the same dropdown would
778
+ * offer a rendering that cannot consume what the dropdown's chart is bound to.
779
+ *
780
+ * The returned object is a plain literal and yours to extend — `domain` for
781
+ * two indicators in one div, `number.prefix` / `number.valueformat` for
782
+ * currency. Number formatting is deliberately not a parameter: a consumer that
783
+ * already owns a value formatter and hands Plotly a second one shows the
784
+ * currency symbol twice, and neither side knows the other did it.
785
+ *
786
+ * @param {Object} options
787
+ * @param {number} options.value - The number to show
788
+ * @param {number} [options.min] - Gauge floor (default 0)
789
+ * @param {number} [options.max] - Gauge ceiling; null draws NO gauge
790
+ * @param {string} [options.title] - Label above the number
791
+ * @param {Array<{range: number[], color: string}>} [options.steps] - Banded background
792
+ * @param {number|{value: number, color: string, width: number, thickness: number}} [options.threshold]
793
+ * @param {string} [options.color] - The gauge bar colour
794
+ * @param {number} [options.seriesIndex] - Palette index when `color` is absent
795
+ * @param {number|{reference: number, relative: boolean, increasing: string, decreasing: string}} [options.delta]
796
+ * @returns {Object} Plotly indicator trace
797
+ */
798
+ export function buildIndicatorTrace(options) {
799
+ const {
800
+ value,
801
+ min = 0,
802
+ max = null,
803
+ title = '',
804
+ steps = null,
805
+ threshold = null,
806
+ color = null,
807
+ seriesIndex = 0,
808
+ delta = null,
809
+ } = options;
810
+
811
+ // A gauge with no ceiling has no arc to draw. Inventing one — twice the
812
+ // value, the next round number — draws a chart that is quietly wrong about
813
+ // the only thing a gauge communicates, so an absent `max` degrades to the
814
+ // bare number instead.
815
+ const hasGauge = typeof max === 'number' && Number.isFinite(max);
816
+
817
+ // `0` is an ordinary reference — last month's total was zero — so the test
818
+ // is `typeof`, never truthiness. Under a truthiness test `delta: 0` drops
819
+ // the comparison the tile exists to draw, silently and only sometimes.
820
+ const deltaSpec = typeof delta === 'number' ? { reference: delta }
821
+ : (delta && typeof delta === 'object') ? delta
822
+ : null;
823
+
824
+ const parts = ['number'];
825
+ if (hasGauge) parts.unshift('gauge');
826
+ if (deltaSpec) parts.push('delta');
827
+
828
+ const trace = {
829
+ type: 'indicator',
830
+ mode: parts.join('+'),
831
+ value,
832
+ };
833
+
834
+ if (title) trace.title = { text: title };
835
+
836
+ if (deltaSpec) {
837
+ trace.delta = {
838
+ reference: deltaSpec.reference,
839
+ relative: deltaSpec.relative === true,
840
+ increasing: { color: deltaSpec.increasing || DELTA_UP },
841
+ decreasing: { color: deltaSpec.decreasing || DELTA_DOWN },
842
+ };
843
+ }
844
+
845
+ if (hasGauge) {
846
+ trace.gauge = {
847
+ axis: { range: [min, max] },
848
+ bar: { color: color || getSeriesColor(seriesIndex) },
849
+ };
850
+ // Passed through rather than derived. A band means something to the
851
+ // consumer — a target, an SLA, a red zone — and this file cannot guess
852
+ // where it sits.
853
+ if (Array.isArray(steps) && steps.length) trace.gauge.steps = steps;
854
+
855
+ if (threshold !== null && threshold !== undefined) {
856
+ const t = typeof threshold === 'number' ? { value: threshold } : threshold;
857
+ trace.gauge.threshold = {
858
+ line: { color: t.color || DELTA_DOWN, width: t.width ?? 3 },
859
+ thickness: t.thickness ?? 0.9,
860
+ value: t.value,
861
+ };
862
+ }
863
+ }
864
+
865
+ return trace;
866
+ }
@@ -184,36 +184,204 @@ export async function ensurePlotly() {
184
184
  + 'or call setPlotlySource("/path/to/plotly.min.js") before rendering a chart.');
185
185
  }
186
186
 
187
- // Start loading — temporarily hide AMD define so Plotly sets window.Plotly
188
- // instead of registering itself as an AMD module (conflicts with Monaco loader).
189
- _plotlyLoadPromise = new Promise((resolve, reject) => {
187
+ // Start loading — make Plotly's UMD header take its browser-global branch
188
+ // WITHOUT asking any other script on the page to give up AMD, even for a
189
+ // moment.
190
+ //
191
+ // ══ WHY THE SCRIPT TAG IS NOT USED FIRST ═══════════════════════════════
192
+ //
193
+ // Plotly's header asks one question — `typeof define === 'function' &&
194
+ // define.amd` — and takes the AMD branch if the answer is yes, leaving
195
+ // `window.Plotly` unset because nothing calls the anonymous module back. A
196
+ // `<script>` tag therefore has to change what that question answers, and a
197
+ // global is the only place to change it. That is what this function used to
198
+ // do: it hid `define.amd` for the length of the fetch (see
199
+ // `loadThroughMaskedDefine` below for the two ways a cruder version of that
200
+ // breaks).
201
+ //
202
+ // MASKING IS STILL WRONG, AND MONACO IS THE PROOF. The window lasts as long
203
+ // as a 4.4 MB download, and any OTHER script that executes inside it asks
204
+ // the same question and gets the answer meant for Plotly. Monaco's
205
+ // `editor.main.js` — 3.7 MB, fetched on demand, so it lands wherever it
206
+ // lands — asks it twice:
207
+ //
208
+ // typeof define=="function"&&define.amd ? define(ne[447], …) // marked
209
+ // … || typeof define=="function"&&define.amd) && (globalThis.monaco = m)
210
+ //
211
+ // The first is the copy of `marked` bundled INSIDE `editor.main.js`. Told
212
+ // there is no AMD, it registers itself as `globalThis.marked` instead of as
213
+ // the module `vs/base/common/marked/marked` — so Monaco's loader goes
214
+ // looking for that module as a FILE, which the `min` distribution does not
215
+ // ship (it is bundled, precisely here). The 404 fails the whole
216
+ // `vs/editor/editor.main` require:
217
+ //
218
+ // Loading "vs/base/common/marked/marked" failed
219
+ // Here are the modules that depend on it: vs/base/browser/markdownRenderer
220
+ //
221
+ // The second means `window.monaco` is never set even if the first is
222
+ // survived. Monaco's `monacoReady` has no reject path, so neither shows up
223
+ // as a failure: the editor simply never arrives, for the life of the page,
224
+ // and every consumer sits in its textarea fallback. Measured on the running
225
+ // deployment on 2026-09-04: 5 boots in 8 lost Monaco this way, 0 in 8 with
226
+ // the Plotly warm-up removed.
227
+ //
228
+ // ══ SO THE GLOBALS ARE NOT TOUCHED AT ALL ══════════════════════════════
229
+ //
230
+ // Fetch the source, then evaluate it in a function whose PARAMETERS are
231
+ // named `define`, `module` and `exports`. Inside that scope `typeof define`
232
+ // is "undefined" — the answer Plotly needs — while `window.define` is never
233
+ // read, written or deleted, so no other script can observe anything. The
234
+ // window is not a window at all: it is one synchronous evaluation, and
235
+ // nothing else can run inside it by construction.
236
+ //
237
+ // The masked-tag path stays as the FALLBACK for a source this page cannot
238
+ // fetch — a CDN without CORS is the case that matters, and it is exactly the
239
+ // case where a `<script>` tag still works.
240
+ _plotlyLoadPromise = (async () => {
241
+ let code;
242
+ try {
243
+ const response = await fetch(_plotlySrc, { credentials: 'same-origin' });
244
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
245
+ code = await response.text();
246
+ } catch (err) {
247
+ // The FETCH failed, which a tag may still survive. An evaluation
248
+ // failure is not caught here: it would fail identically through a
249
+ // tag, and swallowing it would replace a real message with a
250
+ // misleading one.
251
+ return loadThroughMaskedDefine();
252
+ }
253
+ return evaluateWithoutAmd(code, _plotlySrc);
254
+ })();
255
+
256
+ return _plotlyLoadPromise;
257
+ }
258
+
259
+ /**
260
+ * Run Plotly's bundle with the three UMD globals shadowed as local names.
261
+ *
262
+ * `new Function` is not strict, so `this` is the global object inside the body —
263
+ * which is what Plotly's header is invoked with (`}(self, …)` in 2.x, `this` in
264
+ * older builds), and is why the browser-global branch still lands `Plotly` on
265
+ * the window rather than on a scope that disappears.
266
+ *
267
+ * @param {string} code the bundle's source
268
+ * @param {string} src where it came from, for the debugger's file list
269
+ * @returns {object} the `Plotly` global
270
+ */
271
+ function evaluateWithoutAmd(code, src) {
272
+ // eslint-disable-next-line no-new-func
273
+ const run = new Function('define', 'module', 'exports',
274
+ `${code}\n//# sourceURL=${src}`);
275
+ run(undefined, undefined, undefined);
276
+ if (typeof Plotly === 'undefined') {
277
+ throw new Error(`Plotly.js was evaluated from ${src} but did not define a `
278
+ + 'Plotly global — the file may not be a Plotly UMD bundle.');
279
+ }
280
+ return Plotly;
281
+ }
282
+
283
+ /**
284
+ * The `<script>` tag path, for a source this page cannot fetch.
285
+ *
286
+ * ══ WHY `define` IS MASKED AND NOT REMOVED ═════════════════════════════════
287
+ *
288
+ * Hiding `window.define` outright breaks the page two ways, both observed on a
289
+ * running deployment on 2026-08-31:
290
+ *
291
+ * 1. `window.define = undefined`, restored on load. Monaco's `loader.js`
292
+ * installs an AMD loader DURING the window, Plotly's header then sees
293
+ * `typeof define === 'function' && define.amd`, registers as an anonymous
294
+ * module nothing calls back, and the load "succeeds" with `window.Plotly`
295
+ * still undefined:
296
+ * This chart could not be drawn: Plotly.js loaded but Plotly global
297
+ * not found
298
+ * — true, unhelpful, and it sends the reader hunting for a missing file
299
+ * that is being served correctly.
300
+ *
301
+ * 2. The mirror. Monaco loads in TWO stages: `loader.js` sets `define`, and
302
+ * `editor.main.js` arrives later and calls it. If stage two lands while
303
+ * `define` is hidden:
304
+ * editor.main.js:5 Uncaught TypeError: globalDefine is not a function
305
+ * One race costs the chart; the other costs the editor.
306
+ *
307
+ * Masking the PROPERTY rather than the function fixes both — `define` stays
308
+ * callable, `amd` is withheld — and it is still not safe for anybody else who
309
+ * asks the `define.amd` question inside the window. `ensurePlotly` says what
310
+ * that costs; this path is taken only when there is no alternative.
311
+ *
312
+ * @returns {Promise<object>} the `Plotly` global
313
+ */
314
+ function loadThroughMaskedDefine() {
315
+ return new Promise((resolve, reject) => {
316
+ const hadDefine = 'define' in window;
190
317
  const savedDefine = window.define;
191
- window.define = undefined;
318
+ let arrivedDefine; // what somebody installed mid-window
319
+ let arrived = false;
320
+ let guarded = false;
321
+
322
+ /** The same function with `amd` withheld. Plotly reads `define.amd` and
323
+ * stops; everyone else calls it and it behaves. */
324
+ const maskAmd = (value) => {
325
+ if (typeof value !== 'function') return undefined;
326
+ const masked = function define(...args) { return value.apply(this, args); };
327
+ for (const key of Object.getOwnPropertyNames(value)) {
328
+ if (key === 'amd' || key === 'length' || key === 'name') continue;
329
+ try { masked[key] = value[key]; } catch { /* getters that throw */ }
330
+ }
331
+ masked.amd = undefined;
332
+ return masked;
333
+ };
334
+
335
+ try {
336
+ Object.defineProperty(window, 'define', {
337
+ configurable: true,
338
+ enumerable: true,
339
+ get: () => maskAmd(arrived ? arrivedDefine : savedDefine),
340
+ set: (value) => { arrivedDefine = value; arrived = true; },
341
+ });
342
+ guarded = true;
343
+ } catch {
344
+ // A host that refuses the redefinition still gets the old
345
+ // behaviour, which is right far more often than it is wrong.
346
+ window.define = undefined;
347
+ }
348
+
349
+ /** Put the REAL `define` back, exactly once, preferring what arrived. */
350
+ const release = () => {
351
+ const value = arrived ? arrivedDefine : savedDefine;
352
+ if (guarded) {
353
+ delete window.define;
354
+ guarded = false;
355
+ }
356
+ // `hadDefine` matters: assigning `undefined` leaves an own property
357
+ // whose value is undefined, and `'define' in window` then answers
358
+ // true for a global that was never there. Some loaders test that.
359
+ if (arrived || hadDefine) window.define = value;
360
+ };
192
361
 
193
362
  const script = document.createElement('script');
194
363
  script.src = _plotlySrc;
195
364
  script.async = true;
196
365
 
197
366
  script.onload = () => {
198
- window.define = savedDefine;
367
+ release();
199
368
  if (typeof Plotly !== 'undefined') {
200
- console.log('[PlotlyWrapper] Plotly.js loaded successfully');
201
369
  resolve(Plotly);
202
370
  } else {
203
- reject(new Error('Plotly.js loaded but Plotly global not found'));
371
+ reject(new Error('Plotly.js loaded but Plotly global not found. '
372
+ + 'Something defined an AMD loader while it was loading, so '
373
+ + 'Plotly registered as a module instead of a global.'));
204
374
  }
205
375
  };
206
376
 
207
377
  script.onerror = () => {
208
- window.define = savedDefine;
378
+ release();
209
379
  reject(new Error(`Failed to load Plotly.js from ${_plotlySrc} — `
210
380
  + 'call setPlotlySource() with the path to your copy.'));
211
381
  };
212
382
 
213
383
  document.head.appendChild(script);
214
384
  });
215
-
216
- return _plotlyLoadPromise;
217
385
  }
218
386
 
219
387
  /**
@@ -71,9 +71,25 @@ export class NotebookTabBar {
71
71
  const title = e.target.closest('.tab')?.querySelector('.twm-tab-title');
72
72
  return title?.getAttribute('contenteditable') !== 'true';
73
73
  },
74
- onDragStart: (e, key) => {
74
+ onDragStart: (e, key, item) => {
75
75
  // Carry the cross-pane MIME so other panes accept this tab.
76
76
  try { e.dataTransfer.setData(TAB_MIME, key); } catch (_) {}
77
+ // C33. …AND LET THE HOST ADD ITS OWN, which is the whole of
78
+ // what a second consumer of this strip needs from it.
79
+ //
80
+ // `DragReorder` publishes this hook for exactly this purpose
81
+ // ("host hook to augment dataTransfer (e.g. add a cross-pane
82
+ // MIME)", `../ui/components/drag_reorder.js`), and this class
83
+ // consumed it and stopped there. The tile renderer mounts one
84
+ // of these per tile and needs to mark the drag as one of ITS
85
+ // tabs — a different question from "is this an editor tab",
86
+ // asked of the same gesture.
87
+ //
88
+ // ADDITIVE BY CONSTRUCTION: `TAB_MIME` is set first and
89
+ // unchanged, so `EditorPane`'s existing cross-pane drop cannot
90
+ // notice that anyone else is listening.
91
+ try { this.#callbacks.onDragStart?.(e, key, item); }
92
+ catch (err) { console.error('[tab-bar] onDragStart threw', err); }
77
93
  },
78
94
  onReorder: (order) => this.#callbacks.onReorder?.(order),
79
95
  });
@@ -226,10 +242,30 @@ export class NotebookTabBar {
226
242
  this.#container.removeEventListener('dragleave', this.#onStripDragLeave);
227
243
  }
228
244
 
229
- /** Check whether a drag event carries a cross-pane tab (not from this bar). */
245
+ /** Check whether a drag event carries a cross-pane tab (not from this bar).
246
+ *
247
+ * C33. A HOST MAY NAME A SECOND MIME, at mount, as
248
+ * `callbacks.externalTabMimes` — the tile renderer's tabs are not editor
249
+ * tabs and do not carry `application/x-ecosim-tab`, so without this the
250
+ * strip in tile B would refuse a tab dragged out of tile A's strip. Empty
251
+ * by default, so every existing consumer keeps exactly today's admission
252
+ * test.
253
+ *
254
+ * THE `isActive()` EARLY RETURN STAYS FIRST AND STAYS UNCHANGED. It is the
255
+ * one line that keeps this bar's own reorder from being read as a foreign
256
+ * drop, and no widening of the MIME list may be allowed to reach past it.
257
+ *
258
+ * Only `types` is consulted, never `getData` — under the HTML5 protected
259
+ * mode `getData` returns the empty string for every event except `drop`,
260
+ * so a payload read here would find nothing and arm nothing. */
230
261
  #isExternalTabDrag(e) {
231
262
  if (this.#dragReorder?.isActive()) return false; // same-pane drag is active
232
- try { return e.dataTransfer.types.includes(TAB_MIME); } catch { return false; }
263
+ const extra = Array.isArray(this.#callbacks.externalTabMimes)
264
+ ? this.#callbacks.externalTabMimes : [];
265
+ try {
266
+ const types = e.dataTransfer.types;
267
+ return types.includes(TAB_MIME) || extra.some((m) => types.includes(m));
268
+ } catch { return false; }
233
269
  }
234
270
 
235
271
  // Same-pane reorder is handled by the shared DragReorder module (set