1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
|
/**
* Pure tile-layout logic for the Win8-Start-style workspace home grid — zero
* DOM, zero Svelte, zero effects.
*
* The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order,
* col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned
* anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds
* its 2 columns onto frame column 1), so the same document renders in either
* geometry with no orientation conversion step: an anchor that doesn't map
* into the current geometry (or whose clamped span can't fit there) reads as
* UNPINNED, and unpinned tiles flow into the first free cells — holes between
* pins included. Empty cells are real: nothing ever slides backward, so gaps
* only close when a drop's displacement pushes a run of filled cells through
* them (see {@link displacePlace}).
*
* Terminology (distinct from the layout feature's nested split-tree "tile"):
* a HOME TILE is one workspace's cell in the flat start-screen grid.
*/
/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */
export type TileGeometryMode = "wide" | "tall";
/** The px size of one tile unit (one grid cell) + the grid gap. */
export interface TileUnitSize {
/**
* The unit's px width. `0` means "fluid" (the tall grid sizes its fixed
* columns in `1fr` so two tiles fill a phone's width).
*/
readonly width: number;
readonly height: number;
readonly gap: number;
}
/**
* The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name
* the FIXED track counts across the fill direction's cross axis:
* wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded);
* tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` =
* unbounded. The picker (resize) dimensions equal the span caps.
*/
export interface GridGeometry {
readonly mode: TileGeometryMode;
/** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */
readonly flowRows: number;
/** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */
readonly flowCols: number;
/** Maximum horizontal span a tile may occupy in this geometry. */
readonly maxColSpan: number;
/** Maximum vertical span a tile may occupy in this geometry. */
readonly maxRowSpan: number;
readonly unit: TileUnitSize;
/**
* The breathing room around the whole tile field — the grid never renders
* flush against the canvas edge. In wide mode the scroller pads all four
* sides by this amount (the pan arrow/scroll indicator offset inward by it
* too); in tall mode it insets the two fluid columns horizontally.
*/
readonly margin: number;
}
/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */
export interface TileLayoutEntry {
/** The workspace id (the slug). */
readonly id: string;
readonly colSpan: number;
readonly rowSpan: number;
/** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */
readonly order: number;
/**
* The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent =
* unpinned (the tile flows into the first free cells at render time). Tall
* mode normalizes its 2-column grid into this frame: render { c, r } ↔
* universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's
* fixed cross-axis reads as UNPINNED (render-time clamp — like spans).
*/
readonly col?: number;
readonly row?: number;
}
/** The persisted document (localStorage via the injected `LocalStore` adapter). */
export interface TileLayoutData {
readonly version: 1 | 2;
readonly tiles: readonly TileLayoutEntry[];
}
/** A tile's resolved footprint on the grid (1-based grid coordinates). */
export interface Placement {
readonly id: string;
readonly col: number;
readonly row: number;
readonly colSpan: number;
readonly rowSpan: number;
}
/** A requested span (pre-clamping). */
export interface Span {
readonly colSpan: number;
readonly rowSpan: number;
}
/** The viewport assumed before the canvas has been measured (a landscape desktop). */
export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const;
/**
* The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4
* fixed rows (the Start-screen feel), but never shrink below a legible small
* tile nor grow past a comfortable large one.
*/
const WIDE_UNIT_MIN = 112;
const WIDE_UNIT_MAX = 184;
/** The wide canvas' outer margin band (px) — proportional to the unit size. */
const WIDE_MARGIN_MIN = 24;
const WIDE_MARGIN_MAX = 64;
/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */
const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 };
const TALL_MARGIN = 16;
/** Round to whole pixels (fractional track sizes blur tile edges). */
function px(value: number): number {
return Math.round(value);
}
function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Resolve the grid geometry from the tile canvas' size. Landscape/desktop →
* WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL
* (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet
* measured) resolve to wide.
*
* The wide unit is DERIVED from the canvas height so the 4 rows exactly fill
* it (unit + margin + gap all proportional, clamped to their bands); the tall
* unit is fixed (its columns are fluid instead). Pure: numbers in, geometry
* out.
*/
export function geometryFor(width: number, height: number): GridGeometry {
const tall = width > 0 && height > 0 && height > width;
if (tall) {
return {
mode: "tall",
flowRows: 0,
flowCols: 2,
maxColSpan: 2,
maxRowSpan: 2,
unit: TALL_UNIT,
margin: TALL_MARGIN,
};
}
// Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin =
// unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) →
// the band's middle, not a tiny tile.
const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2;
const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX));
const gap = Math.max(10, px(unit / 9));
const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX));
return {
mode: "wide",
flowRows: 4,
flowCols: 0,
maxColSpan: 4,
maxRowSpan: 2,
unit: { width: unit, height: unit, gap },
margin,
};
}
/** Sort entries by their persisted order (stable — ties keep their relative order). */
export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] {
return [...tiles].sort((a, b) => a.order - b.order);
}
/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */
export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span {
return {
colSpan: clampUnit(span.colSpan, geometry.maxColSpan),
rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan),
};
}
function clampUnit(value: number, max: number): number {
if (!Number.isFinite(value)) return 1;
const int = Math.floor(value);
if (int < 1) return 1;
return Math.min(int, max);
}
/**
* A position's rank in the geometry's FILL ORDER — the order slots are visited
* when packing: wide fills column-wise (top-to-bottom, then the next column);
* tall fills row-wise (left-to-right, then the next row). Pure; only used for
* comparisons within one geometry.
*/
export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number {
if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1);
return (row - 1) * geometry.flowCols + (col - 1);
}
/**
* The universal frame's fixed row count (the wide grid's). Tall render cells
* map onto column 1 of this frame; wide is the identity.
*/
export const UNIVERSAL_ROWS = 4;
/** A tile's anchor cell (1-based). */
export interface Cell {
readonly col: number;
readonly row: number;
}
/**
* Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column
* 1 of the universal frame folds onto the 2 render columns (universal row u →
* render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be
* represented in tall → `null` (the tile reads as UNPINNED in tall).
* Pure.
*/
export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null {
if (geometry.mode === "wide") return anchor;
if (anchor.col !== 1) return null;
return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 };
}
/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */
export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell {
if (geometry.mode === "wide") return cell;
return { col: 1, row: 2 * (cell.row - 1) + cell.col };
}
function cellsFree(
occupied: ReadonlySet<string>,
col: number,
row: number,
colSpan: number,
rowSpan: number,
): boolean {
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
if (occupied.has(`${r}:${c}`)) return false;
}
}
return true;
}
function occupy(
occupied: Set<string>,
col: number,
row: number,
colSpan: number,
rowSpan: number,
): void {
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
occupied.add(`${r}:${c}`);
}
}
}
function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number {
let max = 0;
for (const cell of occupied) {
const value = Number.parseInt(
axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""),
10,
);
if (Number.isFinite(value) && value > max) max = value;
}
return max;
}
/**
* The first slot (in fill order) where a span-sized footprint fits without
* overlapping or exceeding the geometry's fixed cross-axis. Beyond the
* occupied region every cell is free, so the scan is bounded by the occupied
* extent — the result always exists (the flow is unbounded along its main
* axis). Pure: occupancy + span + geometry in, slot out.
*/
function firstFit(
occupied: ReadonlySet<string>,
span: Span,
geometry: GridGeometry,
): { col: number; row: number } {
const { colSpan, rowSpan } = clampSpan(span, geometry);
if (geometry.mode === "wide") {
const maxCol = maxOccupied(occupied, "col");
for (let col = 1; col <= maxCol; col++) {
for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) {
if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
}
}
return { col: maxCol + 1, row: 1 };
}
const maxRow = maxOccupied(occupied, "row");
for (let row = 1; row <= maxRow; row++) {
for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) {
if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
}
}
return { col: 1, row: maxRow + 1 };
}
/**
* THE entries → coordinates transform, v2. Two passes, one scan each:
*
* 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly
* one-ply: a displaced pin never bumps another pin): a pinned tile lands at
* its anchor (universal frame → render cell) when the anchor maps into this
* geometry, its clamped footprint fits the fixed cross-axis there, and no
* already-placed tile overlaps. Otherwise the tile reads as UNPINNED.
* 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take
* the first fitting FREE slot in fill order — holes between pins count.
*
* Requested spans clamp to the geometry (render-time only; the persisted spans
* stay raw). Pure: entries + geometry in, a NEW placements array out (entry
* order — the render order).
*/
export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] {
const occupied = new Set<string>();
const placements: (Placement | undefined)[] = [];
const flowing: { index: number; tile: TileLayoutEntry }[] = [];
const ordered = sortTiles(tiles);
for (let index = 0; index < ordered.length; index++) {
const tile = ordered[index];
if (!tile) continue;
const { colSpan, rowSpan } = clampSpan(tile, geometry);
const anchor = pinnedAnchor(tile, geometry);
if (
anchor !== null &&
fitsCrossAxis(anchor, colSpan, rowSpan, geometry) &&
cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan)
) {
occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan);
placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan };
} else {
flowing.push({ index, tile });
}
}
for (const { index, tile } of flowing) {
const { colSpan, rowSpan } = clampSpan(tile, geometry);
const slot = firstFit(occupied, { colSpan, rowSpan }, geometry);
occupy(occupied, slot.col, slot.row, colSpan, rowSpan);
placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan };
}
return placements.filter((placement) => placement !== undefined);
}
/**
* A pinned tile's render-geometry anchor, or `null` when the tile is unpinned
* (no anchor) or its anchor cannot be represented in this geometry (a wide
* column > 1 read in tall). Pure.
*/
function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null {
if (tile.col === undefined || tile.row === undefined) return null;
return toRenderCell({ col: tile.col, row: tile.row }, geometry);
}
/** The footprint fits the geometry's FIXED cross-axis at this anchor. */
function fitsCrossAxis(
anchor: Cell,
colSpan: number,
rowSpan: number,
geometry: GridGeometry,
): boolean {
if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows;
return anchor.col + colSpan - 1 <= geometry.flowCols;
}
/**
* The first fitting slot at or after a minimum stream key (the displacement
* re-anchor scan — pushed tiles only move FORWARD). Pure.
*/
function firstFitFrom(
occupied: ReadonlySet<string>,
span: Span,
geometry: GridGeometry,
minKey: number,
): Cell {
const { colSpan, rowSpan } = clampSpan(span, geometry);
if (geometry.mode === "wide") {
const maxCol = maxOccupied(occupied, "col") + 1;
for (let col = 1; col <= maxCol + 1; col++) {
for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) {
if (fillOrderKey(col, row, geometry) < minKey) continue;
if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
}
}
return { col: maxCol + 2, row: 1 };
}
const maxRow = maxOccupied(occupied, "row") + 1;
for (let row = 1; row <= maxRow + 1; row++) {
for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) {
if (fillOrderKey(col, row, geometry) < minKey) continue;
if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
}
}
return { col: 1, row: maxRow + 2 };
}
/**
* THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER
* placements (the squares exactly as the user sees them). Semantics (user-
* specified): the dragged tile leaves its slot (the gap OPENS — nothing slides
* backward, ever), anchors exactly at `cell`, and the contiguous run of filled
* cells pushes FORWARD in fill order until the first empty cell absorbs it:
*
* `X _ X` + Y at slot 1 → `Y X X`
* `_ X Z _` + Y at slot 2 → `_ Y X Z`
*
* Tiles keep their positions unless their footprint overlaps an already-placed
* one; the displaced re-anchor at the next fitting slot forward (spans ride
* along; a span that can't fit the fixed cross-axis anywhere forward wraps to
* the first fitting slot — it must land somewhere). Pure: placements + drop
* in, a NEW placements array out — in the INPUT array order (only coordinates
* change). A keyed `{#each}` over the result therefore never moves DOM nodes;
* moving a captured element drops its pointer capture — the "stuck tile" bug.
*/
export function displacePlace(
placements: readonly Placement[],
draggedId: string,
cell: Cell,
geometry: GridGeometry,
): Placement[] {
const dragged = placements.find((placement) => placement.id === draggedId);
if (!dragged) return [...placements];
const occupied = new Set<string>();
occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan);
const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }];
const others = placements
.filter((placement) => placement.id !== draggedId)
.map((placement) => ({
placement,
key: fillOrderKey(placement.col, placement.row, geometry),
}))
.sort((a, b) => a.key - b.key);
for (const { placement, key } of others) {
const { colSpan, rowSpan } = placement;
if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) {
occupy(occupied, placement.col, placement.row, colSpan, rowSpan);
placed.push(placement);
continue;
}
let slot = firstFitFrom(occupied, placement, geometry, key);
if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) {
slot = firstFit(occupied, placement, geometry);
}
occupy(occupied, slot.col, slot.row, colSpan, rowSpan);
placed.push({ ...placement, col: slot.col, row: slot.row });
}
// Return in the INPUT render order (coordinates change only): the live drag
// preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes
// — a moved captured element silently loses pointer capture (the stuck-tile
// bug). The commit path re-sorts to stream order itself (`placeAtCell`).
const byId = new Map(placed.map((placement) => [placement.id, placement]));
return placements.map((placement) => byId.get(placement.id) ?? placement);
}
/**
* The commit path: pack the persisted entries, displace at `cell`, and pin
* EVERY tile back to the entries at its resulting anchor (universal frame —
* the persisted spans stay raw, only anchors + order change; order renumbers
* in stream order). This is the drag-drop write. Pure: entries in, NEW
* entries out.
*/
export function placeAtCell(
tiles: readonly TileLayoutEntry[],
id: string,
cell: Cell,
geometry: GridGeometry,
): TileLayoutEntry[] {
// An unknown dragged id changes nothing (order-sorted copy — the op's
// canonical no-op shape, like `moveTile` before it).
if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles);
// displacePlace preserves the input RENDER order for the live preview; the
// commit pins + renumbers in STREAM order, so re-sort before pinning.
const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry)
.slice()
.sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry));
const result: TileLayoutEntry[] = [];
for (const placement of displaced) {
const entry = tiles.find((tile) => tile.id === placement.id);
if (!entry) continue;
const anchor = toUniversalCell(placement, geometry);
result.push({ ...entry, col: anchor.col, row: anchor.row });
}
return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index }));
}
/**
* The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim
* at most `extra` columns (wide) / rows (tall) past the current content edge,
* so a drag can never create an endlessly growing canvas ("hard to place if
* it just grows"): wide bound = the right-most occupied column (span-aware) +
* `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the
* first tiles may land within the first `extra` columns. The bound is measured
* on the COMMITTED placements — a drag always compares against the layout it
* started from, never the live preview, so the limit cannot ratchet mid-drag.
* Pure: placements + geometry in, the max allowed column (wide) / row (tall)
* out.
*/
export function dragAppendLimit(
placements: readonly Placement[],
geometry: GridGeometry,
extra = 3,
): number {
let extent = 0;
for (const placement of placements) {
extent = Math.max(
extent,
geometry.mode === "wide"
? placement.col + placement.colSpan - 1
: placement.row + placement.rowSpan - 1,
);
}
return extent + extra;
}
/**
* The first footprint-fitting EMPTY slot in fill order — where a NEW tile of
* the given span would go (the header's create flow). Pure: entries + span +
* geometry in, a slot placement out (always exists; may be a new column/row
* beyond the current extent).
*/
export function firstEmptySlot(
tiles: readonly TileLayoutEntry[],
span: Span,
geometry: GridGeometry,
): { col: number; row: number } {
return firstFit(packOccupancy(tiles, geometry), span, geometry);
}
/** Build the occupancy set of the tiles' packed positions. */
function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> {
const occupied = new Set<string>();
for (const placement of pack(tiles, geometry)) {
occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan);
}
return occupied;
}
/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */
function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] {
return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index }));
}
/**
* Pin a tile at the first EMPTY slot in fill order — the header-create
* placement (holes between pins count). Idempotent: an id already in the
* layout is RE-PINNED there (spans preserved), not duplicated — this closes
* the race where the list-refresh sync appended the id first. Unknown ids
* append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out
* (order preserved — anchors, not order, place tiles now).
*/
export function pinAtFirstEmpty(
tiles: readonly TileLayoutEntry[],
id: string,
geometry: GridGeometry,
): TileLayoutEntry[] {
const without = sortTiles(tiles).filter((tile) => tile.id !== id);
const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry);
const anchor = toUniversalCell(slot, geometry);
const existing = tiles.find((tile) => tile.id === id);
const entry: TileLayoutEntry = existing
? { ...existing, col: anchor.col, row: anchor.row }
: {
id,
colSpan: 1,
rowSpan: 1,
order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0),
col: anchor.col,
row: anchor.row,
};
return [...without, entry];
}
/**
* Resize one tile: set its persisted spans (sanitized to positive integers —
* NOT clamped to the current geometry; clamping is a render-time concern so a
* wide-sized tile survives an orientation switch). Unknown ids are ignored.
* Pure: entries in, a NEW entries array out.
*/
export function resizeTile(
tiles: readonly TileLayoutEntry[],
id: string,
colSpan: number,
rowSpan: number,
): TileLayoutEntry[] {
const sanitized: Span = {
colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER),
rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER),
};
return sortTiles(tiles).map((tile) =>
tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile,
);
}
/**
* Re-apply the recent-activity order (the header overflow "Sort" action) AND
* clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their
* workspace's `lastActivityAt` descending (unknown ids last, ties stable),
* orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure.
*/
export function sortByRecentActivity(
tiles: readonly TileLayoutEntry[],
lastActivityById: ReadonlyMap<string, number>,
): TileLayoutEntry[] {
const sorted = sortTiles(tiles).sort(
(a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0),
);
return renumber(
sorted.map((tile) => {
const { col: _col, row: _row, ...unpinned } = tile;
return unpinned;
}),
);
}
/**
* Merge the persisted layout with the live workspace list: workspaces unknown
* to the layout (e.g. auto-created via deep-link) APPEND at the end (default
* 1×1, order after the current max); entries for deleted workspaces DROP;
* duplicate ids dedup (first wins); the result sorts by order. When a
* geometry is given, the returned VIEW clamps spans to it (the persisted
* document keeps the raw spans — clamping is transient per geometry). Pure:
* both inputs in, a NEW entries array out.
*/
export function mergeLayout(
persisted: TileLayoutData | null | undefined,
workspaceIds: readonly string[],
geometry?: GridGeometry,
): TileLayoutEntry[] {
const known = new Set(workspaceIds);
const kept: TileLayoutEntry[] = [];
const seen = new Set<string>();
let maxOrder = 0;
for (const tile of sortTiles(persisted?.tiles ?? [])) {
if (!known.has(tile.id) || seen.has(tile.id)) continue;
seen.add(tile.id);
kept.push(tile);
if (tile.order > maxOrder) maxOrder = tile.order;
}
const appended: TileLayoutEntry[] = [];
for (const id of workspaceIds) {
if (seen.has(id)) continue;
seen.add(id);
maxOrder += 1;
appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder });
}
const merged = [...kept, ...appended];
if (!geometry) return merged;
return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) }));
}
/**
* Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load
* is untyped at the boundary). v1 documents (order + spans only) MIGRATE to
* v2 in memory: their tiles become unpinned, which packs identically to v1's
* first-fit — the layout only starts carrying anchors after the user's first
* drop. v2 entries keep their anchors; malformed entries coerce (missing
* spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything
* else yields the empty layout. Pure: unknown in, v2 data out.
*/
export function sanitizeLayout(raw: unknown): TileLayoutData {
if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT;
const version = (raw as { version?: unknown }).version;
if (version !== 1 && version !== 2) return EMPTY_LAYOUT;
const rawTiles = (raw as { tiles?: unknown }).tiles;
if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT;
const tiles: TileLayoutEntry[] = [];
const seen = new Set<string>();
for (let index = 0; index < rawTiles.length; index++) {
const rawTile = rawTiles[index];
if (typeof rawTile !== "object" || rawTile === null) continue;
const id = (rawTile as { id?: unknown }).id;
if (typeof id !== "string" || id === "" || seen.has(id)) continue;
seen.add(id);
const col = coerceAnchor((rawTile as { col?: unknown }).col);
const row = coerceAnchor((rawTile as { row?: unknown }).row);
tiles.push({
id,
colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan),
rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan),
order: coerceOrder((rawTile as { order?: unknown }).order, index),
// An anchor is only meaningful as a PAIR — a lone coordinate drops both.
...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}),
});
}
return { version: 2, tiles };
}
const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] };
function coerceSpan(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
return Math.floor(value);
}
function coerceOrder(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.floor(value);
}
/** A valid anchor coordinate (1-based positive integer) or undefined. */
function coerceAnchor(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined;
return Math.floor(value);
}
|