@grafloria/element 0.4.2 → 0.4.4

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.
@@ -51,10 +51,38 @@
51
51
  */
52
52
  import { __awaiter } from "tslib";
53
53
  import { AddToGroupCommand, BatchCommand, Command, GridPackEngine, RemoveFromGroupCommand, } from '@grafloria/engine';
54
- import { registerTool } from '@grafloria/renderer';
54
+ import { LiveRegionController, registerTool } from '@grafloria/renderer';
55
55
  import { buildCommitCommands, cellFromGridItem, cellToRect, columnUnitFor, gridItemFromCell, pointToCell, rowHeightFor, sizeToSpan, } from './grid-mapping.js';
56
56
  import { ensureDashboardKitStyles } from './styles.js';
57
- const BOARD_REGISTRY = new Map();
57
+ /**
58
+ * Binders on the same canvas find each other here. A WeakMap: a canvas that
59
+ * is gone takes its (empty) peer set with it — the strong Map it replaced kept
60
+ * every container element a dashboard had ever mounted for the life of the
61
+ * page (review D12).
62
+ */
63
+ const BOARD_REGISTRY = new WeakMap();
64
+ /**
65
+ * ONE aria-live region per canvas, shared by every board on it — the
66
+ * renderer's own controller (coalescing, de-duplicating), so a dashboard
67
+ * announces through the same channel a diagram does. WeakMap: the region
68
+ * follows the container out of memory.
69
+ */
70
+ const LIVE_REGIONS = new WeakMap();
71
+ function liveRegionFor(container) {
72
+ let live = LIVE_REGIONS.get(container);
73
+ if (!live) {
74
+ live = new LiveRegionController(container);
75
+ LIVE_REGIONS.set(container, live);
76
+ }
77
+ return live;
78
+ }
79
+ function directionName(dx, dy) {
80
+ return dx < 0 ? 'left' : dx > 0 ? 'right' : dy < 0 ? 'up' : 'down';
81
+ }
82
+ /** "column 4, row 2, 3 by 1" — the cell as a person hears it (1-based). */
83
+ function describeCell(c) {
84
+ return `column ${c.x + 1}, row ${c.y + 1}, ${c.w} by ${c.h}`;
85
+ }
58
86
  /**
59
87
  * Undoable cell+frame write for a GROUP member (the strip's slab). The engine
60
88
  * has Move/Resize commands for nodes but none for a group's frame, and slab
@@ -101,9 +129,37 @@ class SetGroupCellCommand extends Command {
101
129
  }
102
130
  const DRAG_THRESHOLD = 4;
103
131
  const GLIDE_OFF_DELAY = 400;
132
+ /** A press this close (CSS px) to a tile's border takes that edge for a resize. */
133
+ const EDGE_GRIP = 7;
134
+ const NO_EDGES = { n: false, e: false, s: false, w: false };
135
+ /** Which of a host's edges a client point is within EDGE_GRIP of (none when outside). */
136
+ function edgesNear(host, cx, cy) {
137
+ const r = host.getBoundingClientRect();
138
+ if (cx < r.left - 2 || cx > r.right + 2 || cy < r.top - 2 || cy > r.bottom + 2)
139
+ return NO_EDGES;
140
+ return {
141
+ n: cy - r.top <= EDGE_GRIP,
142
+ s: r.bottom - cy <= EDGE_GRIP,
143
+ w: cx - r.left <= EDGE_GRIP,
144
+ e: r.right - cx <= EDGE_GRIP,
145
+ };
146
+ }
147
+ const anyEdge = (E) => E.n || E.e || E.s || E.w;
148
+ /** The resize cursor for a set of edges ('' when none). */
149
+ function cursorFor(E) {
150
+ const v = E.n || E.s;
151
+ const h = E.e || E.w;
152
+ if (v && h)
153
+ return (E.n && E.w) || (E.s && E.e) ? 'nwse-resize' : 'nesw-resize';
154
+ if (v)
155
+ return 'ns-resize';
156
+ if (h)
157
+ return 'ew-resize';
158
+ return '';
159
+ }
104
160
  let binderSeq = 0;
105
161
  export function bindDashboardGrid(api, group, options = {}) {
106
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
162
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
107
163
  ensureDashboardKitStyles();
108
164
  const diagram = api.getModel();
109
165
  /** The DECLARED count — the board's authored width, and the responsive cap. */
@@ -119,12 +175,118 @@ export function bindDashboardGrid(api, group, options = {}) {
119
175
  const baseRowHeight = (_d = options.baseRowHeight) !== null && _d !== void 0 ? _d : 110;
120
176
  const minRowHeight = (_e = options.minRowHeight) !== null && _e !== void 0 ? _e : 28;
121
177
  let float = (_f = options.float) !== null && _f !== void 0 ? _f : false;
178
+ /** The AUTHORED bound — a nested strip's design (row-first push, escalation). */
122
179
  const maxRows = options.maxRows;
123
180
  const dragOut = (_g = options.dragOut) !== null && _g !== void 0 ? _g : 'cancel';
124
181
  const wantHandles = options.resizeHandles !== false;
125
- const designH = (_k = (_h = options.designHeight) !== null && _h !== void 0 ? _h : (_j = group.size) === null || _j === void 0 ? void 0 : _j.height) !== null && _k !== void 0 ? _k : 0;
126
- let sizing = (_l = options.sizing) !== null && _l !== void 0 ? _l : 'fit';
127
- let engine = new GridPackEngine([], { columns, float, maxRows });
182
+ const fluid = options.fluid === true;
183
+ const overflow = (_h = options.overflow) !== null && _h !== void 0 ? _h : 'bounded';
184
+ let isStatic = options.static === true;
185
+ /** The member the ROVING TABINDEX rests on (one tab stop per board). */
186
+ let focusedId;
187
+ const live = liveRegionFor(api.container);
188
+ /** The design height. Fluid boards re-read it from the container. */
189
+ let designH = (_l = (_j = options.designHeight) !== null && _j !== void 0 ? _j : (_k = group.size) === null || _k === void 0 ? void 0 : _k.height) !== null && _l !== void 0 ? _l : 0;
190
+ /** Fit-mode CAPACITY in rows (see `overflow`). Undefined = unbounded. */
191
+ let capacity;
192
+ let sizing = (_m = options.sizing) !== null && _m !== void 0 ? _m : 'fit';
193
+ /**
194
+ * A fresh engine holding `items` VERBATIM. The constructor add()s each item
195
+ * and settles after every one, so in gravity mode a legal layout with a gap
196
+ * — a tile dropped below free space, exactly what the placeholder promised —
197
+ * was re-packed on every rebuild: refresh(), undo, and (since the kit follows
198
+ * the history) every commit moved a tile the user had just placed. The
199
+ * persisted cells are the truth; a rebuild must not edit them. Float while
200
+ * constructing, then restore the real mode for everything that follows.
201
+ *
202
+ * `pack` is for the two moments gravity IS wanted: the first adoption of the
203
+ * authored spec (a declared cell hovering over an empty row settles — the
204
+ * kit's documented boot contract) and turning float off.
205
+ */
206
+ const engineFrom = (items, pack = false, at = columns) => {
207
+ const e = new GridPackEngine(items, { columns: at, float: pack ? float : true, maxRows, capacity });
208
+ e.float = float;
209
+ return e;
210
+ };
211
+ /** The effective row bound a gesture must respect: the strip's, else the fit capacity. */
212
+ const bound = () => maxRows !== null && maxRows !== void 0 ? maxRows : capacity;
213
+ /**
214
+ * The rows the design height can hold at the row floor — what 'bounded' fit
215
+ * enforces. A board is never bounded BELOW what it already holds: a document
216
+ * loaded with more rows than fit keeps every tile (the frame extends, see
217
+ * enforceBoardHeight) and only further growth is refused.
218
+ */
219
+ const fitCapacity = () => {
220
+ if (maxRows !== undefined || sizing !== 'fit' || overflow === 'scroll' || designH <= 0)
221
+ return undefined;
222
+ const rowsThatFit = Math.floor((designH - 2 * padding + gap) / (minRowHeight + gap));
223
+ return Math.max(1, rowsThatFit, engine.rows());
224
+ };
225
+ /** Re-derive the capacity; true when the engine must be rebuilt to carry it. */
226
+ const refreshCapacity = () => {
227
+ const next = fitCapacity();
228
+ if (next === capacity)
229
+ return false;
230
+ capacity = next;
231
+ return true;
232
+ };
233
+ /** The canvas container's CSS-pixel box (0 when unmeasurable, e.g. jsdom). */
234
+ const containerBox = () => ({
235
+ w: api.container.clientWidth || 0,
236
+ h: api.container.clientHeight || 0,
237
+ });
238
+ /**
239
+ * FLUID: make the group's frame the container's box. Width always; the
240
+ * design height follows too, so fit capacity follows the viewport (a shorter
241
+ * window holds fewer rows) and grow never shrinks below the visible area.
242
+ * Returns true when the frame changed. A container that cannot be measured
243
+ * (width 0) leaves the authored frame alone.
244
+ */
245
+ const applyFluidFrame = () => {
246
+ if (!fluid || disposed)
247
+ return false;
248
+ const box = containerBox();
249
+ if (box.w <= 0)
250
+ return false;
251
+ if (box.h > 0)
252
+ designH = box.h;
253
+ const f = frame();
254
+ const height = sizing === 'fit' && box.h > 0 ? box.h : f.height;
255
+ if (Math.abs(f.width - box.w) < 0.5 && Math.abs(f.height - height) < 0.5)
256
+ return false;
257
+ writing = true;
258
+ try {
259
+ diagram.runSystemWrite(() => group.setFrame({ x: f.x, y: f.y, width: box.w, height }));
260
+ }
261
+ finally {
262
+ writing = false;
263
+ }
264
+ return true;
265
+ };
266
+ /**
267
+ * THE PERSISTED COLUMN CACHE (D4). `toJSON()` always saved the wide layout —
268
+ * it reads the engine's widest cached count. The DOCUMENT path read the
269
+ * node's GridItemConfig, and a responsive column change writes the live,
270
+ * NARROW cells into exactly that field: a board saved on a phone reloaded as
271
+ * 1-wide tiles crammed into the left of a 12-column board, because the cache
272
+ * that knew better lived only in this closure. It now rides on the group as
273
+ * `dashboardLayouts` — the cache as plain data plus the live count the
274
+ * GridItemConfigs belong to — and `rebuild()` reads it back on a fresh bind.
275
+ * Written only when there is a cache, so a board that never changed column
276
+ * count serialises exactly as before.
277
+ */
278
+ const persistLayouts = () => {
279
+ if (disposed || engine.cachedColumns().length === 0)
280
+ return;
281
+ writing = true;
282
+ try {
283
+ diagram.runSystemWrite(() => group.setMetadata('dashboardLayouts', { columns: engine.columns, layouts: engine.getLayouts() }));
284
+ }
285
+ finally {
286
+ writing = false;
287
+ }
288
+ };
289
+ let engine = engineFrom([]);
128
290
  let gesture = null;
129
291
  let disposed = false;
130
292
  /** Reentrancy guard: our own derived frame writes must not re-project. */
@@ -154,7 +316,10 @@ export function bindDashboardGrid(api, group, options = {}) {
154
316
  padding,
155
317
  sizing,
156
318
  baseRowHeight,
157
- minRowHeight,
319
+ // BOUNDED FIT NEVER SCROLLS: the floor is a capacity for growth, not a
320
+ // reason to paint past the frame. A board already holding more rows than
321
+ // fit (a Grow→Fit switch, a loaded document) squeezes below it instead.
322
+ minRowHeight: sizing === 'fit' && overflow !== 'scroll' ? 1 : minRowHeight,
158
323
  designHeight: sizing === 'fit' ? frame().height : designH,
159
324
  rtl,
160
325
  });
@@ -171,18 +336,22 @@ export function bindDashboardGrid(api, group, options = {}) {
171
336
  * group metadata; spans falling back to metadata columnSpan/rowSpan; last
172
337
  * resort: adopt from the current pixel geometry). */
173
338
  const itemFor = (id) => {
174
- var _a, _b, _c, _d, _e;
339
+ var _a, _b, _c, _d, _e, _f, _g;
175
340
  const node = diagram.getNode(id);
176
341
  if (node) {
177
342
  const spanMeta = Number((_a = node.getMetadata) === null || _a === void 0 ? void 0 : _a.call(node, 'columnSpan')) || 0;
178
343
  const rowsMeta = Number((_b = node.getMetadata) === null || _b === void 0 ? void 0 : _b.call(node, 'rowSpan')) || 0;
179
344
  const locked = ((_c = node.state) === null || _c === void 0 ? void 0 : _c.locked) === true;
180
- const cell = cellFromGridItem((_d = node.getGridItem) === null || _d === void 0 ? void 0 : _d.call(node), {
345
+ // Per-widget size limits ride on the node as `widgetLimits` and reach the
346
+ // engine as gridstack's minW/maxW/minH/maxH.
347
+ const lim = ((_e = (_d = node.getMetadata) === null || _d === void 0 ? void 0 : _d.call(node, 'widgetLimits')) !== null && _e !== void 0 ? _e : {});
348
+ const limits = Object.assign(Object.assign(Object.assign(Object.assign({}, (lim.minSpan !== undefined ? { minW: lim.minSpan } : {})), (lim.maxSpan !== undefined ? { maxW: lim.maxSpan } : {})), (lim.minRows !== undefined ? { minH: lim.minRows } : {})), (lim.maxRows !== undefined ? { maxH: lim.maxRows } : {}));
349
+ const cell = cellFromGridItem((_f = node.getGridItem) === null || _f === void 0 ? void 0 : _f.call(node), {
181
350
  w: spanMeta || 1,
182
351
  h: rowsMeta || 1,
183
352
  });
184
353
  if (cell)
185
- return Object.assign(Object.assign({ id }, cell), { locked });
354
+ return Object.assign(Object.assign(Object.assign({ id }, cell), { locked }), limits);
186
355
  const f = frame();
187
356
  const g = geom();
188
357
  if (node.position && (node.position.x !== 0 || node.position.y !== 0)) {
@@ -191,20 +360,13 @@ export function bindDashboardGrid(api, group, options = {}) {
191
360
  // left edge into a cell.
192
361
  const s = sizeToSpan(node.size.width, node.size.height, f, g, rows());
193
362
  const p = pointToCell(node.position.x, node.position.y, f, g, rows(), spanMeta || s.w);
194
- return {
195
- id,
196
- x: Math.max(0, p.x),
197
- y: Math.max(0, p.y),
198
- w: spanMeta || s.w,
199
- h: rowsMeta || s.h,
200
- locked,
201
- };
363
+ return Object.assign({ id, x: Math.max(0, p.x), y: Math.max(0, p.y), w: spanMeta || s.w, h: rowsMeta || s.h, locked }, limits);
202
364
  }
203
- return { id, x: 0, y: 0, w: spanMeta || 1, h: rowsMeta || 1, locked, autoPosition: true };
365
+ return Object.assign({ id, x: 0, y: 0, w: spanMeta || 1, h: rowsMeta || 1, locked, autoPosition: true }, limits);
204
366
  }
205
367
  const grp = diagram.getGroup(id);
206
368
  const cell = grp
207
- ? cellFromGridItem((_e = grp.getMetadata) === null || _e === void 0 ? void 0 : _e.call(grp, 'gridItem'))
369
+ ? cellFromGridItem((_g = grp.getMetadata) === null || _g === void 0 ? void 0 : _g.call(grp, 'gridItem'))
208
370
  : null;
209
371
  // Member groups are LOCKED slabs (see the module doc).
210
372
  if (cell)
@@ -260,8 +422,14 @@ export function bindDashboardGrid(api, group, options = {}) {
260
422
  if (designH <= 0)
261
423
  return;
262
424
  const r = rows();
425
+ // FIT keeps its design height, full stop — the user's rule: "the board
426
+ // stays the same and the widgets change size so all of them fit". Only
427
+ // overflow:'scroll' lets the frame EXTEND to hold the rows at the floor
428
+ // height (and the canvas pan). Grow extends at the base row height.
263
429
  const target = sizing === 'fit'
264
- ? designH
430
+ ? overflow === 'scroll'
431
+ ? Math.max(designH, 2 * padding + r * minRowHeight + (r - 1) * gap)
432
+ : designH
265
433
  : Math.max(designH, 2 * padding + r * baseRowHeight + (r - 1) * gap);
266
434
  const f = frame();
267
435
  if (Math.abs(f.height - target) > 0.5) {
@@ -355,11 +523,61 @@ export function bindDashboardGrid(api, group, options = {}) {
355
523
  }
356
524
  };
357
525
  // -- resize handles ---------------------------------------------------------
358
- const syncHandles = () => {
526
+ /** The accessible name of a widget: its title, else its kind, else its id. */
527
+ const nameOf = (node) => {
528
+ var _a, _b, _c, _d;
529
+ const title = (_a = node.getMetadata) === null || _a === void 0 ? void 0 : _a.call(node, 'widgetTitle');
530
+ if (typeof title === 'string' && title)
531
+ return title;
532
+ // The visible header falls back to a data label (a KPI's `label`) before
533
+ // the kind — the spoken name must agree with what is painted.
534
+ const label = (_c = (_b = node.getMetadata) === null || _b === void 0 ? void 0 : _b.call(node, 'widgetSpec')) === null || _c === void 0 ? void 0 : _c.label;
535
+ if (typeof label === 'string' && label)
536
+ return label;
537
+ const kind = (_d = node.getMetadata) === null || _d === void 0 ? void 0 : _d.call(node, 'widgetKind');
538
+ return typeof kind === 'string' && kind && kind !== 'widget' ? `${kind} widget` : node.id;
539
+ };
540
+ /**
541
+ * ACCESSIBLE CHROME on every member host (WCAG 4.1.2 name/role/value): a
542
+ * group role, a widget role description, a label that carries the cell,
543
+ * and the ROVING TABINDEX — exactly one member per board is a tab stop.
544
+ * Runs with the handles, so a repainted host gets it back too.
545
+ */
546
+ const syncA11y = (only) => {
359
547
  var _a, _b;
548
+ if (disposed)
549
+ return;
550
+ const members = [...((_a = group.members) !== null && _a !== void 0 ? _a : [])].filter((id) => !!diagram.getNode(id));
551
+ if (focusedId && !members.includes(focusedId))
552
+ focusedId = undefined;
553
+ const stop = focusedId !== null && focusedId !== void 0 ? focusedId : members[0];
554
+ for (const id of members) {
555
+ if (only && !only.has(id))
556
+ continue;
557
+ const node = diagram.getNode(id);
558
+ const host = hostOf(id);
559
+ if (!node || !host)
560
+ continue;
561
+ const cell = engine.getItem(id);
562
+ const bits = [nameOf(node)];
563
+ if (cell)
564
+ bits.push(describeCell(cell));
565
+ if (((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) === true)
566
+ bits.push('pinned');
567
+ host.setAttribute('role', 'group');
568
+ host.setAttribute('aria-roledescription', 'dashboard widget');
569
+ host.setAttribute('aria-label', bits.join(', '));
570
+ host.setAttribute('tabindex', id === stop ? '0' : '-1');
571
+ }
572
+ };
573
+ const syncHandles = (only) => {
574
+ var _a, _b, _c;
575
+ syncA11y(only);
360
576
  if (!wantHandles || disposed)
361
577
  return;
362
578
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
579
+ if (only && !only.has(id))
580
+ continue;
363
581
  const node = diagram.getNode(id);
364
582
  if (!node)
365
583
  continue;
@@ -367,7 +585,7 @@ export function bindDashboardGrid(api, group, options = {}) {
367
585
  if (!host)
368
586
  continue;
369
587
  const existing = host.querySelector(':scope > .axdb-rs');
370
- if (((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) === true) {
588
+ if (((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) === true || isStatic || ((_c = node.getMetadata) === null || _c === void 0 ? void 0 : _c.call(node, 'widgetResizable')) === false) {
371
589
  existing === null || existing === void 0 ? void 0 : existing.remove();
372
590
  continue;
373
591
  }
@@ -382,7 +600,40 @@ export function bindDashboardGrid(api, group, options = {}) {
382
600
  rs.classList.toggle('axdb-rs--rtl', rtl);
383
601
  }
384
602
  };
385
- const hostObserver = new MutationObserver(() => syncHandles());
603
+ /**
604
+ * Only a HOST-LEVEL change matters: a host arriving (a mount) or a host's
605
+ * own children changing (a repaint that wiped the injected handle). A
606
+ * chart's internal churn — most of what a live dashboard mutates — targets
607
+ * deeper nodes and is ignored, and the hosts the records DO name are the
608
+ * only ones re-synced. This was members × repaints `querySelector` calls
609
+ * per wave (9,216 at 96 widgets, review D10); it is now proportional to the
610
+ * hosts that actually changed.
611
+ */
612
+ const hostObserver = new MutationObserver((records) => {
613
+ const touched = new Set();
614
+ const noteHost = (el) => {
615
+ var _a;
616
+ const e = el;
617
+ if ((_a = e === null || e === void 0 ? void 0 : e.classList) === null || _a === void 0 ? void 0 : _a.contains('grafloria-node-host')) {
618
+ const id = e.getAttribute('data-node-id');
619
+ if (id)
620
+ touched.add(id);
621
+ }
622
+ };
623
+ for (const r of records) {
624
+ // Our own re-injected handle arriving is not a change to answer — it
625
+ // would echo one more pass per repaint.
626
+ const ownEcho = r.removedNodes.length === 0 &&
627
+ r.addedNodes.length > 0 &&
628
+ Array.from(r.addedNodes).every((n) => { var _a; return (_a = n.classList) === null || _a === void 0 ? void 0 : _a.contains('axdb-rs'); });
629
+ if (ownEcho)
630
+ continue;
631
+ noteHost(r.target);
632
+ r.addedNodes.forEach((n) => noteHost(n));
633
+ }
634
+ if (touched.size)
635
+ syncHandles(touched);
636
+ });
386
637
  // -- gesture snapshot / commit ---------------------------------------------
387
638
  const snapshotAll = () => {
388
639
  const cells = new Map();
@@ -432,11 +683,21 @@ export function bindDashboardGrid(api, group, options = {}) {
432
683
  };
433
684
  // -- membership + bounds sync ----------------------------------------------
434
685
  const onMemberAdded = (id) => {
686
+ var _a;
435
687
  if (disposed)
436
688
  return;
437
689
  if (!engine.getItem(id)) {
438
690
  const item = itemFor(id);
439
- const placed = engine.add(item);
691
+ let placed = engine.add(item);
692
+ if (!placed && capacity !== undefined) {
693
+ // Membership is a document fact (an undo just restored it, say); a
694
+ // bounded board must not strand the node invisible. Lift the capacity
695
+ // for this adoption — it floors at the content on the next refresh.
696
+ capacity = undefined;
697
+ engine = engineFrom([...engine.getItems().map((i) => (Object.assign({}, i))), item]);
698
+ placed = (_a = engine.getItem(id)) !== null && _a !== void 0 ? _a : null;
699
+ refreshCapacity();
700
+ }
440
701
  if (placed)
441
702
  persistAdoptedCell(id, placed);
442
703
  }
@@ -534,6 +795,7 @@ export function bindDashboardGrid(api, group, options = {}) {
534
795
  return false;
535
796
  columns = engine.columns;
536
797
  persistLiveCells();
798
+ persistLayouts();
537
799
  armGlide();
538
800
  project();
539
801
  syncPlaceholder();
@@ -548,10 +810,17 @@ export function bindDashboardGrid(api, group, options = {}) {
548
810
  * turn, and a page that does not still wants the check to run. The binder
549
811
  * owns this — pages should never have to wire a ResizeObserver for it.
550
812
  */
551
- const containerObserver = responsive && typeof ResizeObserver !== 'undefined'
813
+ const containerObserver = (responsive || fluid) && typeof ResizeObserver !== 'undefined'
552
814
  ? new ResizeObserver(() => {
553
815
  if (disposed)
554
816
  return;
817
+ if (applyFluidFrame()) {
818
+ if (refreshCapacity())
819
+ rebuild(false);
820
+ else
821
+ project();
822
+ api.renderNow();
823
+ }
555
824
  evaluateResponsive();
556
825
  })
557
826
  : null;
@@ -695,6 +964,13 @@ export function bindDashboardGrid(api, group, options = {}) {
695
964
  commands.push(new SetGroupCellCommand(group.id, g.esc.cellBefore, g.esc.cellAfter, g.esc.frameBefore, g.esc.frameAfter));
696
965
  }
697
966
  const changed = execute(g.kind === 'resize' ? 'Resize widget' : 'Move widget', commands);
967
+ persistLayouts(); // an edit at a narrow count propagated into the wide cache
968
+ if (changed) {
969
+ const it = engine.getItem(g.id);
970
+ if (it)
971
+ live.announce(`${nameOf(g.node)} ${g.kind === 'resize' ? 'resized' : 'moved'} to ${describeCell(it)}`);
972
+ }
973
+ syncA11y();
698
974
  api.renderNow();
699
975
  (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed });
700
976
  };
@@ -723,7 +999,7 @@ export function bindDashboardGrid(api, group, options = {}) {
723
999
  const lockedNode = ((_b = (_a = diagram.getNode(id)) === null || _a === void 0 ? void 0 : _a.state) === null || _b === void 0 ? void 0 : _b.locked) === true;
724
1000
  items.push(Object.assign(Object.assign({ id }, c), { locked: lockedNode || isGroupMember(id) }));
725
1001
  }
726
- engine = new GridPackEngine(items, { columns, float, maxRows });
1002
+ engine = engineFrom(items);
727
1003
  }
728
1004
  else {
729
1005
  engine.cancelGesture();
@@ -886,23 +1162,40 @@ export function bindDashboardGrid(api, group, options = {}) {
886
1162
  }
887
1163
  // resize: fluid pixel preview on the ghost, cell-stepped live push.
888
1164
  //
889
- // RTL: the corner handle is on the BOTTOM-LEFT and the tile grows
890
- // leftwards, so the horizontal delta inverts and the tile's RIGHT edge is
891
- // what stays pinned. Everything below this line the span rounding, the
892
- // engine's resizeCheck, the maxRows escalation is in CELLS and needs no
893
- // mirroring at all.
894
- const dw = rtl ? g.downWorld.x - ev.world.x : ev.world.x - g.downWorld.x;
895
- const dh = ev.world.y - g.downWorld.y;
1165
+ // ANCHORED ON THE OPPOSITE EDGE. The gesture carries which edges the press
1166
+ // took (the corner handle is s+e s+w on an RTL board, whose corner sits
1167
+ // bottom-left and a press near any border takes that edge), and the
1168
+ // tile's rect is rebuilt from its start rect with only those edges moved.
1169
+ // A pull from the west or north keeps the right/bottom edge pinned, in
1170
+ // cells too: the target cell is anchored, and the engine is driven MOVE
1171
+ // then RESIZE when growing (so the push happens on the side that grows)
1172
+ // and RESIZE then MOVE when shrinking. The s+e case reproduces the old
1173
+ // corner maths exactly, which the scenario battery pins.
1174
+ const dx = ev.world.x - g.downWorld.x;
1175
+ const dy = ev.world.y - g.downWorld.y;
1176
+ const E = g.edges;
1177
+ let left = g.startPos.x;
1178
+ let right = g.startPos.x + g.startSize.width;
1179
+ let top = g.startPos.y;
1180
+ let bottom = g.startPos.y + g.startSize.height;
1181
+ if (E.e)
1182
+ right += dx;
1183
+ if (E.w)
1184
+ left += dx;
1185
+ if (E.s)
1186
+ bottom += dy;
1187
+ if (E.n)
1188
+ top += dy;
896
1189
  const f = frame();
897
1190
  const gg = geom();
898
1191
  const minW = Math.max(8, columnUnitFor(gg, f.width));
899
- let w = Math.max(minW, g.startSize.width + dw);
1192
+ let w = Math.max(minW, right - left);
900
1193
  // The height min-clamp is applied AFTER the escalation logic below: on a
901
1194
  // grown strip "one current row" IS the whole strip, and clamping the
902
1195
  // fluid pull to it made the de-escalation threshold unreachable — the
903
1196
  // ratchet's second disguise (grow committed; a fresh shrink gesture could
904
1197
  // never pull low enough to ask the parent for a row back).
905
- let h = g.startSize.height + dh;
1198
+ let h = bottom - top;
906
1199
  // NESTED HEIGHT ESCALATION (live report: "i cant increase height"). A
907
1200
  // bounded strip cannot grow a tile taller than itself — so pulling
908
1201
  // clearly past its bottom GROWS THE STRIP: the slab gains a row in the
@@ -950,38 +1243,71 @@ export function bindDashboardGrid(api, group, options = {}) {
950
1243
  // The fluid preview must not outrun what the board can accept: on a
951
1244
  // bounded strip an unclamped ghost ballooned to 273px while the engine
952
1245
  // (rightly) refused every cell — visually indistinguishable from the
953
- // squeeze bug it replaced. Clamp to the tile's maximum legal rect.
1246
+ // squeeze bug it replaced. Clamp to the tile's maximum legal rect: the
1247
+ // growing side can reach the board edge on ITS side — column 0 / row 0
1248
+ // for a west / north pull, the last column / the bound otherwise.
954
1249
  // (Escalation above may have just grown OR shrunk the board — re-read.)
955
1250
  const fNow = frame();
956
1251
  const ggNow = geom();
957
1252
  h = Math.max(Math.max(8, rowHeightFor(ggNow, rows())), h);
958
1253
  const itemNow = engine.getItem(g.id);
1254
+ const pullsX = E.e || E.w;
1255
+ const pullsY = E.n || E.s;
959
1256
  if (itemNow) {
960
1257
  const cuNow = columnUnitFor(ggNow, fNow.width);
961
1258
  const rhNow = rowHeightFor(ggNow, rows());
962
- w = Math.min(w, (columns - itemNow.x) * (cuNow + gap) - gap);
963
- if (maxRows !== undefined) {
964
- h = Math.min(h, Math.max(1, maxRows - itemNow.y) * (rhNow + gap) - gap);
965
- }
1259
+ const wCells = E.w ? itemNow.x + itemNow.w : columns - itemNow.x;
1260
+ w = Math.min(w, wCells * (cuNow + gap) - gap);
1261
+ const b = bound();
1262
+ const hCells = E.n ? itemNow.y + itemNow.h : b !== undefined ? Math.max(1, b - itemNow.y) : Infinity;
1263
+ if (hCells !== Infinity)
1264
+ h = Math.min(h, hCells * (rhNow + gap) - gap);
1265
+ // AN AXIS NOBODY PULLED KEEPS ITS CELLS. In fit mode a push during the
1266
+ // gesture reflows the board and the row height shrinks, so a pixel
1267
+ // height that never changed re-quantises to MORE rows (measured: a west
1268
+ // pull took a 3-row donut to 5). The un-pulled axis follows the live
1269
+ // projection of its cell span instead of the start-of-gesture pixels.
1270
+ if (!pullsX)
1271
+ w = itemNow.w * (cuNow + gap) - gap;
1272
+ if (!pullsY)
1273
+ h = itemNow.h * (rhNow + gap) - gap;
966
1274
  }
1275
+ // Anchor: the pulled edges follow w/h, the opposite ones stay put — on
1276
+ // the LIVE projection of the tile's cell, not the start-of-gesture pixels.
1277
+ // In fit mode a push during the gesture reflows every row, so the row the
1278
+ // tile sits in moves; anchoring on the start rect drew the ghost a row or
1279
+ // two below its own placeholder (visual audit, west pull on the donut).
1280
+ const liveRect = itemNow ? cellToRect(itemNow, fNow, ggNow, rows()) : null;
1281
+ const anchorLeft = liveRect && !E.w && !E.e ? liveRect.x : left;
1282
+ const anchorRight = liveRect ? liveRect.x + liveRect.width : right;
1283
+ const anchorTop = liveRect && !E.n && !E.s ? liveRect.y : top;
1284
+ const anchorBottom = liveRect ? liveRect.y + liveRect.height : bottom;
1285
+ const px = E.w ? anchorRight - w : anchorLeft;
1286
+ const py = E.n ? anchorBottom - h : anchorTop;
967
1287
  g.node.setSize(w, h, (_j = g.node.size.depth) !== null && _j !== void 0 ? _j : 0);
968
- if (rtl) {
969
- // Keep the RIGHT edge pinned: mirrored, that is the tile's origin. The
970
- // start position is recoverable from the grab offset, so this needs no
971
- // extra gesture state.
972
- const startLeft = g.downWorld.x - g.grab.dx;
973
- const right = startLeft + g.startSize.width;
974
- g.node.setPosition(right - w, g.node.position.y);
975
- ghostStyleFastPath(g, { x: right - w, width: w, height: h });
976
- }
977
- else {
978
- ghostStyleFastPath(g, { width: w, height: h });
979
- }
980
- const spanF = maxRows !== undefined ? frame() : f;
981
- const spanG = maxRows !== undefined ? geom() : gg;
1288
+ g.node.setPosition(px, py);
1289
+ ghostStyleFastPath(g, { x: px, y: py, width: w, height: h });
1290
+ const spanF = bound() !== undefined ? frame() : f;
1291
+ const spanG = bound() !== undefined ? geom() : gg;
982
1292
  const span = sizeToSpan(w, h, spanF, spanG, rows());
983
- if (engine.resizeCheck(g.id, span.w, span.h).changed)
984
- project();
1293
+ if (itemNow) {
1294
+ if (!pullsX)
1295
+ span.w = itemNow.w;
1296
+ if (!pullsY)
1297
+ span.h = itemNow.h;
1298
+ const tx = E.w ? itemNow.x + itemNow.w - span.w : itemNow.x;
1299
+ const ty = E.n ? itemNow.y + itemNow.h - span.h : itemNow.y;
1300
+ const moves = tx !== itemNow.x || ty !== itemNow.y;
1301
+ const growing = span.w > itemNow.w || span.h > itemNow.h;
1302
+ let changed = false;
1303
+ if (moves && growing)
1304
+ changed = engine.moveCheck(g.id, tx, ty, { gate: false }).changed || changed;
1305
+ changed = engine.resizeCheck(g.id, span.w, span.h).changed || changed;
1306
+ if (moves && !growing)
1307
+ changed = engine.moveCheck(g.id, tx, ty, { gate: false }).changed || changed;
1308
+ if (changed)
1309
+ project();
1310
+ }
985
1311
  syncPlaceholder();
986
1312
  };
987
1313
  const onToolUp = () => {
@@ -1039,6 +1365,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1039
1365
  cleanupGestureVisuals(g);
1040
1366
  gesture = null;
1041
1367
  enforceBoardHeight();
1368
+ persistLayouts();
1042
1369
  api.renderNow();
1043
1370
  (_c = options.onGesture) === null || _c === void 0 ? void 0 : _c.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed: true });
1044
1371
  return;
@@ -1128,8 +1455,9 @@ export function bindDashboardGrid(api, group, options = {}) {
1128
1455
  // Clamp to the TARGET board's shape: a tall tile entering a one-row strip
1129
1456
  // arrives as a strip-height tile, not a refusal.
1130
1457
  span.w = Math.max(1, Math.min(columns, span.w));
1131
- if (maxRows !== undefined)
1132
- span.h = Math.max(1, Math.min(maxRows, span.h));
1458
+ const b = bound();
1459
+ if (b !== undefined)
1460
+ span.h = Math.max(1, Math.min(b, span.h));
1133
1461
  engine.beginGesture(); // pre-entry snapshot — abort() restores it
1134
1462
  const entered = engine.add({ id: node.id, x: 0, y: engine.rows(), w: span.w, h: span.h });
1135
1463
  if (!entered) {
@@ -1262,16 +1590,55 @@ export function bindDashboardGrid(api, group, options = {}) {
1262
1590
  return insideMemberGroupFrame(ev.world.x, ev.world.y);
1263
1591
  }
1264
1592
  // Claim (and deaden) empty presses inside a member group's frame so the
1265
- // built-in group-drag cannot fight the pack layout for the KPI slab.
1266
- return insideMemberGroupFrame(ev.world.x, ev.world.y);
1593
+ // built-in group-drag cannot fight the pack layout for the KPI slab
1594
+ // and empty presses on the BOARD itself: its group is a layout
1595
+ // container, not a thing to drag around the canvas, and on a fluid
1596
+ // board there is no void outside it, so the space between tiles IS the
1597
+ // void (a click there clears the selection, see onPointerDown).
1598
+ return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
1267
1599
  },
1268
1600
  onPointerDown(ev, hit) {
1269
- var _a, _b, _c, _d, _e, _f;
1270
- if (gesture || !hit.node)
1271
- return; // dead zone (slab area), or mid-palette
1601
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1602
+ if (gesture)
1603
+ return; // mid-palette
1604
+ if (!hit.node) {
1605
+ // The board's own empty area: a void click. Nothing to drag, and the
1606
+ // selection clears exactly as a click outside any board would.
1607
+ (_b = (_a = diagram).clearSelection) === null || _b === void 0 ? void 0 : _b.call(_a);
1608
+ api.render();
1609
+ return;
1610
+ }
1272
1611
  const node = diagram.getNode(hit.node.id);
1273
- if (!node || ((_a = node.state) === null || _a === void 0 ? void 0 : _a.locked) === true)
1612
+ if (!node || ((_c = node.state) === null || _c === void 0 ? void 0 : _c.locked) === true)
1274
1613
  return; // pinned: refuse; click still focuses
1614
+ if (isStatic)
1615
+ return; // a static board: claimed and deadened, click still focuses
1616
+ // Which edges did the press take? The corner handle names its own (s+e,
1617
+ // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
1618
+ // takes that border; anywhere else is a move.
1619
+ const target = ((_e = (_d = ev.source) === null || _d === void 0 ? void 0 : _d.target) !== null && _e !== void 0 ? _e : null);
1620
+ const onHandle = !!((_f = target === null || target === void 0 ? void 0 : target.closest) === null || _f === void 0 ? void 0 : _f.call(target, '.axdb-rs'));
1621
+ const hostEl = hostOf(node.id);
1622
+ const resizable = ((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetResizable')) !== false;
1623
+ const movable = ((_h = node.getMetadata) === null || _h === void 0 ? void 0 : _h.call(node, 'widgetMovable')) !== false;
1624
+ let edges = NO_EDGES;
1625
+ if (resizable) {
1626
+ if (onHandle)
1627
+ edges = rtl ? { n: false, e: false, s: true, w: true } : { n: false, e: true, s: true, w: false };
1628
+ else if (hostEl) {
1629
+ // `ev.screen` is ELEMENT-LOCAL px; the host rect is in client px.
1630
+ // Compare like with like — the source event's clientX/Y when there
1631
+ // is one, else the container's origin plus the local offset.
1632
+ const src = ev.source;
1633
+ const cr = api.container.getBoundingClientRect();
1634
+ const cx = typeof (src === null || src === void 0 ? void 0 : src.clientX) === 'number' ? src.clientX : cr.left + ev.screen.x;
1635
+ const cy = typeof (src === null || src === void 0 ? void 0 : src.clientY) === 'number' ? src.clientY : cr.top + ev.screen.y;
1636
+ edges = edgesNear(hostEl, cx, cy);
1637
+ }
1638
+ }
1639
+ const isResize = anyEdge(edges);
1640
+ if (!isResize && !movable)
1641
+ return; // a fixed tile: refuse the drag, click still focuses
1275
1642
  // Arm the glide class NOW, a full task before any displacement can
1276
1643
  // happen: a transition defined in the same style recalc as the first
1277
1644
  // left/top write does not run (CSS transitions fire only when the
@@ -1279,8 +1646,6 @@ export function bindDashboardGrid(api, group, options = {}) {
1279
1646
  // style) — the first pushed neighbour of every gesture TELEPORTED
1280
1647
  // (measured 109px in one frame) while later ones glided.
1281
1648
  armGlide();
1282
- const target = ((_c = (_b = ev.source) === null || _b === void 0 ? void 0 : _b.target) !== null && _c !== void 0 ? _c : null);
1283
- const isResize = !!((_d = target === null || target === void 0 ? void 0 : target.closest) === null || _d === void 0 ? void 0 : _d.call(target, '.axdb-rs'));
1284
1649
  const it = engine.getItem(node.id);
1285
1650
  gesture = {
1286
1651
  kind: isResize ? 'resize' : 'move',
@@ -1296,7 +1661,9 @@ export function bindDashboardGrid(api, group, options = {}) {
1296
1661
  startCells: new Map(),
1297
1662
  startGeom: new Map(),
1298
1663
  startSize: { width: node.size.width, height: node.size.height },
1299
- spans: { w: (_e = it === null || it === void 0 ? void 0 : it.w) !== null && _e !== void 0 ? _e : 1, h: (_f = it === null || it === void 0 ? void 0 : it.h) !== null && _f !== void 0 ? _f : 1 },
1664
+ startPos: { x: node.position.x, y: node.position.y },
1665
+ edges,
1666
+ spans: { w: (_j = it === null || it === void 0 ? void 0 : it.w) !== null && _j !== void 0 ? _j : 1, h: (_k = it === null || it === void 0 ? void 0 : it.h) !== null && _k !== void 0 ? _k : 1 },
1300
1667
  removedFromBoard: false,
1301
1668
  leg: null,
1302
1669
  lastWorld: null,
@@ -1317,10 +1684,179 @@ export function bindDashboardGrid(api, group, options = {}) {
1317
1684
  },
1318
1685
  };
1319
1686
  const unregisterTool = registerTool(tool);
1687
+ /**
1688
+ * Edge affordance: the cursor says which border a press would take, the
1689
+ * way gridstack's invisible edge handles do. One passive listener on the
1690
+ * container; the corner handle keeps its own cursor from the stylesheet.
1691
+ */
1692
+ let hoverHost = null;
1693
+ const onHover = (e) => {
1694
+ var _a, _b, _c, _d, _e, _f, _g;
1695
+ if (disposed || gesture)
1696
+ return;
1697
+ // The event may target the host, its content, or (when a host's content
1698
+ // is pointer-transparent) the canvas under it — find the member host by
1699
+ // the pointer's position in that case.
1700
+ let host = (_b = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest) === null || _b === void 0 ? void 0 : _b.call(_a, '.grafloria-node-host');
1701
+ if (!host) {
1702
+ for (const id of (_c = group.members) !== null && _c !== void 0 ? _c : []) {
1703
+ const h = hostOf(id);
1704
+ if (!h)
1705
+ continue;
1706
+ const r = h.getBoundingClientRect();
1707
+ if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) {
1708
+ host = h;
1709
+ break;
1710
+ }
1711
+ }
1712
+ }
1713
+ if (hoverHost && hoverHost !== host)
1714
+ hoverHost.style.cursor = '';
1715
+ hoverHost = host;
1716
+ if (!host)
1717
+ return;
1718
+ const id = (_d = host.getAttribute('data-node-id')) !== null && _d !== void 0 ? _d : '';
1719
+ if (!((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(id))
1720
+ return;
1721
+ const node = diagram.getNode(id);
1722
+ const resizable = !!node && !isStatic && ((_f = node.state) === null || _f === void 0 ? void 0 : _f.locked) !== true && ((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetResizable')) !== false;
1723
+ host.style.cursor = resizable ? cursorFor(edgesNear(host, e.clientX, e.clientY)) : '';
1724
+ };
1725
+ api.container.addEventListener('pointermove', onHover, { passive: true });
1726
+ /**
1727
+ * KEYBOARD OPERATION (WCAG 2.1.1, and the non-drag alternative 2.5.7 asks
1728
+ * for): on a focused member, arrows move it one cell, Shift+arrows resize it
1729
+ * one cell, Home/End jump to the first/last member. Every move and resize is
1730
+ * the same programmatic gesture the API uses — one undoable step, reported
1731
+ * through onLayoutChange — and every outcome is spoken: the tile's new
1732
+ * cell, each neighbour it displaced, or why it was refused. Handled keys
1733
+ * stop here so the renderer's own pixel nudge never fights the grid.
1734
+ */
1735
+ const memberHostAt = (target) => {
1736
+ var _a, _b, _c;
1737
+ const host = (_a = target === null || target === void 0 ? void 0 : target.closest) === null || _a === void 0 ? void 0 : _a.call(target, '.grafloria-node-host');
1738
+ if (!host)
1739
+ return null;
1740
+ const id = (_b = host.getAttribute('data-node-id')) !== null && _b !== void 0 ? _b : '';
1741
+ if (!((_c = group.members) !== null && _c !== void 0 ? _c : new Set()).has(id) || !diagram.getNode(id))
1742
+ return null;
1743
+ return { id, host };
1744
+ };
1745
+ const onFocusIn = (e) => {
1746
+ const hit = memberHostAt(e.target);
1747
+ if (!hit || disposed)
1748
+ return;
1749
+ if (focusedId !== hit.id) {
1750
+ focusedId = hit.id;
1751
+ syncA11y();
1752
+ }
1753
+ };
1754
+ const onKey = (e) => {
1755
+ var _a, _b, _c, _d, _e, _f, _g;
1756
+ if (disposed || gesture)
1757
+ return;
1758
+ const hit = memberHostAt(e.target);
1759
+ if (!hit) {
1760
+ // Tab reaches the diagram's own root (the svg) before any widget. An
1761
+ // arrow or Enter there hands focus to the board's tab stop, so a
1762
+ // keyboard user is never parked on "Diagram, 8 nodes" with nowhere to go.
1763
+ const el = e.target;
1764
+ const onRoot = !!el && ((_a = el.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'svg' && ((_b = el.classList) === null || _b === void 0 ? void 0 : _b.contains('grafloria-diagram'));
1765
+ if (onRoot && (e.key.startsWith('Arrow') || e.key === 'Enter' || e.key === ' ')) {
1766
+ const members = [...((_c = group.members) !== null && _c !== void 0 ? _c : [])].filter((id) => !!diagram.getNode(id) && !!hostOf(id));
1767
+ const target = focusedId && members.includes(focusedId) ? focusedId : members[0];
1768
+ if (target && handle.focusWidget(target)) {
1769
+ e.preventDefault();
1770
+ e.stopPropagation();
1771
+ }
1772
+ }
1773
+ return;
1774
+ }
1775
+ const members = [...((_d = group.members) !== null && _d !== void 0 ? _d : [])].filter((id) => !!diagram.getNode(id) && !!hostOf(id));
1776
+ if (e.key === 'Home' || e.key === 'End') {
1777
+ const id = e.key === 'Home' ? members[0] : members[members.length - 1];
1778
+ if (id)
1779
+ handle.focusWidget(id);
1780
+ e.preventDefault();
1781
+ e.stopPropagation();
1782
+ return;
1783
+ }
1784
+ const arrow = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
1785
+ if (!arrow)
1786
+ return;
1787
+ e.preventDefault();
1788
+ e.stopPropagation();
1789
+ if (isStatic)
1790
+ return; // readable, not editable
1791
+ const node = diagram.getNode(hit.id);
1792
+ const item = engine.getItem(hit.id);
1793
+ if (!node || !item)
1794
+ return;
1795
+ const name = nameOf(node);
1796
+ if (((_e = node.state) === null || _e === void 0 ? void 0 : _e.locked) === true) {
1797
+ live.announceError(`${name} is pinned`);
1798
+ return;
1799
+ }
1800
+ const [dx, dy] = arrow;
1801
+ const before = new Map(engine.getItems().map((i) => [i.id, { x: i.x, y: i.y, w: i.w, h: i.h }]));
1802
+ const resize = e.shiftKey;
1803
+ if (resize && ((_f = node.getMetadata) === null || _f === void 0 ? void 0 : _f.call(node, 'widgetResizable')) === false) {
1804
+ live.announceError(`${name} cannot be resized`);
1805
+ return;
1806
+ }
1807
+ if (!resize && ((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetMovable')) === false) {
1808
+ live.announceError(`${name} cannot be moved`);
1809
+ return;
1810
+ }
1811
+ // A one-cell keyboard step onto a neighbour would be refused by the
1812
+ // pointer's anti-jitter gate (a 3-wide covering a third of its neighbour
1813
+ // is not "more than half"). A key press is deliberate, so when the step
1814
+ // is refused and a neighbour sits there, aim at the neighbour's far edge —
1815
+ // the same swap a full drag lands on.
1816
+ const stepOrSwap = () => __awaiter(this, void 0, void 0, function* () {
1817
+ if (yield handle.moveTo(hit.id, item.x + dx, item.y + dy))
1818
+ return true;
1819
+ const probe = { x: item.x + dx, y: item.y + dy, w: item.w, h: item.h };
1820
+ const c = engine
1821
+ .getItems()
1822
+ .find((o) => o.id !== hit.id && probe.x < o.x + o.w && o.x < probe.x + probe.w && probe.y < o.y + o.h && o.y < probe.y + probe.h);
1823
+ if (!c)
1824
+ return false;
1825
+ const tx = dx > 0 ? c.x + c.w - item.w : dx < 0 ? c.x : item.x;
1826
+ const ty = dy > 0 ? c.y + c.h - item.h : dy < 0 ? c.y : item.y;
1827
+ return handle.moveTo(hit.id, tx, ty);
1828
+ });
1829
+ const op = resize ? handle.resizeTo(hit.id, item.w + dx, item.h + dy) : stepOrSwap();
1830
+ void op.then((ok) => {
1831
+ var _a, _b;
1832
+ if (disposed)
1833
+ return;
1834
+ const after = engine.getItem(hit.id);
1835
+ if (!ok || !after) {
1836
+ live.announceError(resize ? `Cannot resize ${name} that way` : `Cannot move ${name} ${directionName(dx, dy)}`);
1837
+ return;
1838
+ }
1839
+ const parts = [`${name} ${resize ? 'resized' : 'moved'} to ${describeCell(after)}`];
1840
+ for (const other of engine.getItems()) {
1841
+ if (other.id === hit.id)
1842
+ continue;
1843
+ const was = before.get(other.id);
1844
+ if (!was || (was.x === other.x && was.y === other.y))
1845
+ continue;
1846
+ const o = diagram.getNode(other.id);
1847
+ parts.push(`${o ? nameOf(o) : other.id} moved to ${describeCell(other)}`);
1848
+ }
1849
+ live.announce(parts.join('. '), 'polite', true);
1850
+ syncA11y();
1851
+ (_b = (_a = hostOf(hit.id)) === null || _a === void 0 ? void 0 : _a.focus) === null || _b === void 0 ? void 0 : _b.call(_a, { preventScroll: true });
1852
+ });
1853
+ };
1854
+ api.container.addEventListener('focusin', onFocusIn);
1855
+ api.container.addEventListener('keydown', onKey);
1320
1856
  // -- palette drag-in --------------------------------------------------------
1321
1857
  const beginPaletteDrag = (node, spec, event) => {
1322
1858
  var _a, _b;
1323
- if (disposed || gesture)
1859
+ if (disposed || gesture || isStatic)
1324
1860
  return;
1325
1861
  const chip = (_a = spec.chip) !== null && _a !== void 0 ? _a : null;
1326
1862
  if (chip) {
@@ -1341,6 +1877,8 @@ export function bindDashboardGrid(api, group, options = {}) {
1341
1877
  startCells: new Map(),
1342
1878
  startGeom: new Map(),
1343
1879
  startSize: { width: 0, height: 0 },
1880
+ startPos: { x: 0, y: 0 },
1881
+ edges: NO_EDGES,
1344
1882
  spans: { w: Math.max(1, spec.w), h: Math.max(1, spec.h) },
1345
1883
  removedFromBoard: true,
1346
1884
  leg: null,
@@ -1386,8 +1924,12 @@ export function bindDashboardGrid(api, group, options = {}) {
1386
1924
  g.removedFromBoard = false;
1387
1925
  // Enter at the bottom edge (collision-free), then take the cursor
1388
1926
  // cell GATELESSLY — gridstack's drag-in skips the gate on entry.
1389
- engine.add({ id: g.id, x: 0, y: engine.rows(), w: g.spans.w, h: g.spans.h });
1390
- engine.moveCheck(g.id, cell.x, cell.y, { gate: false });
1927
+ // A bounded board with no room refuses the entry: the chip dims to
1928
+ // say so, and the release will snap it home.
1929
+ const entered = engine.add({ id: g.id, x: 0, y: engine.rows(), w: g.spans.w, h: g.spans.h });
1930
+ chip === null || chip === void 0 ? void 0 : chip.classList.toggle('axdb-out', !entered);
1931
+ if (entered)
1932
+ engine.moveCheck(g.id, cell.x, cell.y, { gate: false });
1391
1933
  project();
1392
1934
  }
1393
1935
  else if (engine.moveCheck(g.id, cell.x, cell.y).changed) {
@@ -1396,6 +1938,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1396
1938
  }
1397
1939
  else if (!g.removedFromBoard) {
1398
1940
  g.removedFromBoard = true;
1941
+ chip === null || chip === void 0 ? void 0 : chip.classList.remove('axdb-out');
1399
1942
  engine.remove(g.id); // displaced tiles come home (gesture memory)
1400
1943
  project();
1401
1944
  }
@@ -1423,6 +1966,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1423
1966
  cleanupGestureVisuals(g);
1424
1967
  gesture = null;
1425
1968
  void ((_a = options.onDropIn) === null || _a === void 0 ? void 0 : _a.call(options, node, cell, displaced));
1969
+ persistLayouts();
1426
1970
  (_b = options.onGesture) === null || _b === void 0 ? void 0 : _b.call(options, { type: 'drop-in', kind: 'palette', nodeId: g.id, changed: true });
1427
1971
  api.renderNow();
1428
1972
  return;
@@ -1473,35 +2017,63 @@ export function bindDashboardGrid(api, group, options = {}) {
1473
2017
  if (commands.length > 0) {
1474
2018
  yield api.getEngine().commandManager.execute(new BatchCommand(name, commands));
1475
2019
  }
2020
+ persistLayouts();
1476
2021
  api.renderNow();
1477
2022
  return true;
1478
2023
  });
1479
- const handle = {
1480
- sync() {
1481
- var _a;
1482
- if (disposed)
1483
- return;
1484
- if (gesture)
1485
- cancelActiveGesture(false);
1486
- const items = [];
1487
- for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
1488
- if (!memberEntity(id))
1489
- continue;
1490
- items.push(itemFor(id));
1491
- }
1492
- // CARRY THE PER-COLUMN CACHE ACROSS THE REBUILD. sync() runs on every
1493
- // undo, member add and refresh; without this handoff a responsive board
1494
- // would silently lose its wide layouts the first time anything else
1495
- // happened, and growing back would re-derive instead of restoring.
1496
- const carried = engine.getLayouts();
1497
- engine = new GridPackEngine(items, { columns, float, maxRows });
1498
- engine.setLayouts(carried);
2024
+ /** Rebuild the engine from the members' persisted cells. `pack` runs gravity
2025
+ * over the result (boot, float→off); a plain sync keeps the cells verbatim. */
2026
+ const rebuild = (pack) => {
2027
+ var _a, _b, _c, _d;
2028
+ if (disposed)
2029
+ return;
2030
+ if (gesture)
2031
+ cancelActiveGesture(false);
2032
+ applyFluidFrame();
2033
+ const items = [];
2034
+ for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
2035
+ if (!memberEntity(id))
2036
+ continue;
2037
+ items.push(itemFor(id));
2038
+ }
2039
+ // CARRY THE PER-COLUMN CACHE ACROSS THE REBUILD. sync() runs on every
2040
+ // undo, member add and refresh; without this handoff a responsive board
2041
+ // would silently lose its wide layouts the first time anything else
2042
+ // happened, and growing back would re-derive instead of restoring. On a
2043
+ // FRESH bind (a loaded document) the cache comes from the group instead.
2044
+ const persisted = (_b = group.getMetadata) === null || _b === void 0 ? void 0 : _b.call(group, 'dashboardLayouts');
2045
+ const carried = engine.cachedColumns().length > 0 ? engine.getLayouts() : ((_c = persisted === null || persisted === void 0 ? void 0 : persisted.layouts) !== null && _c !== void 0 ? _c : {});
2046
+ // The document's cells belong to the count it was SAVED at. When that is
2047
+ // not the count this board is bound at, build the engine at the saved
2048
+ // count — where the cells are legal — hand it the cache, and let its own
2049
+ // column change bring the board to the bound count: known items come back
2050
+ // from the cache exactly, the rest scale. (Only a fresh bind can see a
2051
+ // mismatch; every later rebuild finds cells the binder itself wrote.)
2052
+ const savedAt = engine.cachedColumns().length === 0 && typeof (persisted === null || persisted === void 0 ? void 0 : persisted.columns) === 'number' ? persisted.columns : columns;
2053
+ engine = engineFrom(items, pack, savedAt);
2054
+ engine.setLayouts(carried);
2055
+ const converted = savedAt !== columns && engine.setColumns(columns, (_d = responsive === null || responsive === void 0 ? void 0 : responsive.layout) !== null && _d !== void 0 ? _d : 'moveScale');
2056
+ // A PACKED or CONVERTED rebuild may have moved cells; write them all back
2057
+ // so the next verbatim sync reads the settled board, not the pre-pack one.
2058
+ // A verbatim rebuild only fills in cells that were never persisted.
2059
+ if (pack || converted)
2060
+ persistLiveCells();
2061
+ else
1499
2062
  for (const item of engine.getItems())
1500
2063
  persistAdoptedCell(item.id, item);
1501
- project();
1502
- syncHandles();
1503
- api.renderNow();
1504
- evaluateResponsive();
2064
+ // The capacity is derived from the design height AND floors at the content
2065
+ // just rebuilt, so it is computed here and carried into the engine.
2066
+ if (refreshCapacity())
2067
+ engine = engineFrom(engine.getItems().map((i) => (Object.assign({}, i))), false);
2068
+ persistLayouts();
2069
+ project();
2070
+ syncHandles();
2071
+ api.renderNow();
2072
+ evaluateResponsive();
2073
+ };
2074
+ const handle = {
2075
+ sync() {
2076
+ rebuild(false);
1505
2077
  },
1506
2078
  setColumns(n, layout, opts) {
1507
2079
  var _a;
@@ -1522,6 +2094,26 @@ export function bindDashboardGrid(api, group, options = {}) {
1522
2094
  api.renderNow();
1523
2095
  },
1524
2096
  getRtl: () => rtl,
2097
+ focusWidget(id) {
2098
+ var _a, _b, _c;
2099
+ if (disposed || !((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id))
2100
+ return false;
2101
+ focusedId = id;
2102
+ syncA11y();
2103
+ (_c = (_b = hostOf(id)) === null || _b === void 0 ? void 0 : _b.focus) === null || _c === void 0 ? void 0 : _c.call(_b, { preventScroll: true });
2104
+ return true;
2105
+ },
2106
+ getFocusedWidget: () => focusedId,
2107
+ setStatic(on) {
2108
+ if (on === isStatic)
2109
+ return;
2110
+ isStatic = on;
2111
+ if (gesture)
2112
+ cancelActiveGesture(false);
2113
+ syncHandles();
2114
+ api.renderNow();
2115
+ },
2116
+ getStatic: () => isStatic,
1525
2117
  saveLayout() {
1526
2118
  const saved = engine.saveLayout();
1527
2119
  return {
@@ -1533,7 +2125,11 @@ export function bindDashboardGrid(api, group, options = {}) {
1533
2125
  if (mode === sizing)
1534
2126
  return;
1535
2127
  sizing = mode;
1536
- project();
2128
+ applyFluidFrame();
2129
+ if (refreshCapacity())
2130
+ rebuild(false);
2131
+ else
2132
+ project();
1537
2133
  api.renderNow();
1538
2134
  },
1539
2135
  getSizing: () => sizing,
@@ -1541,10 +2137,9 @@ export function bindDashboardGrid(api, group, options = {}) {
1541
2137
  if (on === float)
1542
2138
  return;
1543
2139
  float = on;
1544
- // Rebuild from persisted cells under the new mode: sync() constructs a
1545
- // fresh engine with the captured `float`, and gravity (when turning
1546
- // OFF) packs immediately through its settle.
1547
- this.sync();
2140
+ // Rebuild from persisted cells under the new mode, PACKED: gravity (when
2141
+ // float turns off) applies immediately through the rebuild's settle.
2142
+ rebuild(true);
1548
2143
  api.renderNow();
1549
2144
  },
1550
2145
  getFloat: () => float,
@@ -1557,6 +2152,9 @@ export function bindDashboardGrid(api, group, options = {}) {
1557
2152
  maxColumns,
1558
2153
  rtl,
1559
2154
  responsive: !!responsive && !responsivePinned,
2155
+ fluid,
2156
+ static: isStatic,
2157
+ capacity,
1560
2158
  gap,
1561
2159
  padding,
1562
2160
  sizing,
@@ -1567,6 +2165,12 @@ export function bindDashboardGrid(api, group, options = {}) {
1567
2165
  frame: f,
1568
2166
  };
1569
2167
  },
2168
+ willItFit(w, h) {
2169
+ if (bound() === undefined)
2170
+ return true;
2171
+ const probe = engineFrom(engine.getItems().map((i) => (Object.assign({}, i))));
2172
+ return probe.add({ id: '\u0000probe', x: 0, y: 0, w: Math.max(1, w), h: Math.max(1, h), autoPosition: true }) !== null;
2173
+ },
1570
2174
  cellOf(id) {
1571
2175
  const it = engine.getItem(id);
1572
2176
  return it ? { x: it.x, y: it.y, w: it.w, h: it.h } : undefined;
@@ -1613,31 +2217,40 @@ export function bindDashboardGrid(api, group, options = {}) {
1613
2217
  },
1614
2218
  beginPaletteDrag,
1615
2219
  dispose() {
1616
- var _a;
2220
+ var _a, _b, _c, _d;
1617
2221
  if (disposed)
1618
2222
  return;
1619
2223
  cancelActiveGesture(false);
1620
2224
  disposed = true;
1621
2225
  peersOnCanvas().delete(selfPeer);
1622
2226
  unregisterTool();
2227
+ api.container.removeEventListener('pointermove', onHover);
2228
+ api.container.removeEventListener('focusin', onFocusIn);
2229
+ api.container.removeEventListener('keydown', onKey);
1623
2230
  hostObserver.disconnect();
1624
2231
  containerObserver === null || containerObserver === void 0 ? void 0 : containerObserver.disconnect();
1625
2232
  for (const off of subs)
1626
2233
  off();
2234
+ // The corner handles are THIS binder's affordance: a board re-bound as a
2235
+ // split layout must not keep showing a resize corner it cannot act on.
2236
+ for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : [])
2237
+ (_c = (_b = hostOf(id)) === null || _b === void 0 ? void 0 : _b.querySelector(':scope > .axdb-rs')) === null || _c === void 0 ? void 0 : _c.remove();
1627
2238
  placeholder === null || placeholder === void 0 ? void 0 : placeholder.remove();
1628
2239
  placeholder = null;
1629
2240
  if (glideTimer)
1630
2241
  clearTimeout(glideTimer);
1631
2242
  if (ghostTimer)
1632
2243
  clearTimeout(ghostTimer);
1633
- (_a = htmlLayer()) === null || _a === void 0 ? void 0 : _a.classList.remove('axdb-glide');
2244
+ (_d = htmlLayer()) === null || _d === void 0 ? void 0 : _d.classList.remove('axdb-glide');
1634
2245
  api.container.style.cursor = '';
1635
2246
  },
1636
2247
  };
1637
- // Boot: adopt the current members, observe host churn for handle re-injection,
1638
- // and let a responsive board settle on the count its width asks for before the
1639
- // first frame (a 400px board declared at 12 columns must not flash at 12).
1640
- handle.sync();
2248
+ // Boot: adopt the current members (PACKED a declared cell hovering over an
2249
+ // empty row settles, the documented contract), observe host churn for handle
2250
+ // re-injection, and let a responsive board settle on the count its width asks
2251
+ // for before the first frame (a 400px board declared at 12 columns must not
2252
+ // flash at 12).
2253
+ rebuild(true);
1641
2254
  const layer = htmlLayer();
1642
2255
  if (layer)
1643
2256
  hostObserver.observe(layer, { childList: true, subtree: true });