diff options
| -rw-r--r-- | backend-handoff.md | 15 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.svelte | 49 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.test.ts | 10 |
3 files changed, 22 insertions, 52 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index e37c7d0..735d2cd 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -286,13 +286,14 @@ changes, NO contract changes — the wire types and every endpoint consumed are elements to the GRID CONTAINER, which `setPointerCapture`s the pointer and never gets re-parented/`pointer-events: none`/unmounted — so move/up stream to it over empty cells and off the canvas, and capture can never be lost to a DOM move again. -- **Drag append bound + reserved canvas** (`dragAppendLimit` in `logic/tile-layout.ts`; - `cellAt` + `gridStyle` in `HomeTileGrid.svelte`): the drag target is clamped to at most 3 - columns (wide) / rows (tall) past the current content edge (span-aware, measured on the - COMMITTED layout so it can't ratchet mid-drag). While dragging, the grid's box is a FIXED - reservation — the 3 spaces (plus the dragged tile's span room) pop in at drag start and stay - constant, so the scroller never changes size mid-drag; the unused spaces vanish when the drag - ends. +- **Drag append bound** (`dragAppendLimit` in `logic/tile-layout.ts`; `cellAt` in + `HomeTileGrid.svelte`): the drop target is clamped to at most 3 columns (wide) / rows (tall) + past the committed content edge (span-aware, measured on the COMMITTED layout so it can't + ratchet mid-drag). The canvas stays content-driven while dragging — the preview extends it as + the tile reaches toward the edge and it shrinks back when the gesture ends, bounded by the + clamp — so the scrollbar grows/shrinks WITH the drag. (An earlier attempt pre-reserved the 3 + spaces at drag start as a fixed canvas; that made the view jump and created too much free + space — reverted.) - Verification: `typecheck` 0 errors/0 warnings, vitest 1689/1689 (incl. updated `tile-layout.test.ts` order assertions — coordinates unchanged), biome clean on touched files, `build` succeeds. Browser-only (pointer capture) — needs a human at the page to confirm the diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte index ccbf96b..d259aea 100644 --- a/src/features/workspaces/ui/HomeTileGrid.svelte +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -318,7 +318,7 @@ $effect(() => { // Re-measure when the geometry flips, the content changes, or a drag - // starts/ends (the reserved canvas appears then disappears). + // starts/ends (the canvas grows with the preview and shrinks back after). void geometry.mode; void placements.length; void dragging; @@ -344,54 +344,33 @@ // ── Grid + tile styles (the geometry decides; no breakpoint classes) ────── const dragging = $derived(phase.kind === "dragging"); - /** - * The canvas tracks reserved while dragging, on the growth axis: the append - * limit (committed content edge + 3) PLUS the dragged tile's span room — the - * tile may be anchored anywhere in the 3 spaces while its body stays inside - * the reserved canvas. Computed from the COMMITTED layout at drag start, so - * it is CONSTANT for the whole drag — the scroller never resizes mid-drag. - */ - const reservedExtent = $derived( - dragging && draggedPlacement - ? dragAppendLimit(placements, geometry) + - (geometry.mode === "wide" ? draggedPlacement.colSpan : draggedPlacement.rowSpan) - - 1 - : 0, - ); - const gridStyle = $derived.by(() => { const wide = geometry.mode === "wide"; - // The grid's own size on the growth axis: `max-content` (wide) / auto - // (tall) when idle — the canvas hugs the committed content. While DRAGGING - // it becomes a FIXED reservation (the 3 append spaces + span room), so the - // spaces "show up" at drag start and the scrollbar size never changes - // until the drag ends (then the unused reservation disappears). - const canvas = dragging - ? wide - ? `width: ${reservedExtent * pitchX - geometry.unit.gap}px;` - : `height: ${reservedExtent * pitchY - geometry.unit.gap}px;` - : wide - ? "width: max-content;" - : "height: auto;"; + // Content-driven canvas: the grid hugs its content (max-content / auto), + // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when + // the gesture ends — the drag sees the scrollbar move as it should. The + // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview + // can extend, so the canvas never grows past the committed edge + 3. const base = (wide ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + - ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + + ` gap: ${geometry.unit.gap}px; width: max-content;` : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + - ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;`) + - ` gap: ${geometry.unit.gap}px; ${canvas}`; + ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` + + ` gap: ${geometry.unit.gap}px;`); if (!dragging) return base; // The drop-target dots: one faint dot at each unit cell's CENTER, painted - // as a background whose tile is the cell pitch. The reserved canvas IS the - // append area, so the 3 spaces (and the dragged tile's span room) get dots - // with no extra padding. + // as a background whose tile is the cell pitch. One extra pitch of padding + // past the content end gives the append-slot a dot too. const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`; const dotX = wide ? geometry.unit.width / 2 : 0; const dot = ` background-image: radial-gradient(circle 2.5px,` + ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` + ` background-size: ${dotSize} ${pitchY}px;` + - ` background-position: ${dotX}px ${geometry.unit.height / 2}px;`; + ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` + + (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`); return base + dot; }); diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts index 9f163fd..d70c673 100644 --- a/src/features/workspaces/ui/HomeTileGrid.test.ts +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -105,18 +105,10 @@ describe("HomeTileGrid", () => { }); const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); - const grid = container.querySelector(".grid"); // The cell math resolves against the grid element's rect (jsdom: 0s). fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); - // While dragging the canvas is a FIXED reservation (content edge + 3, plus - // the dragged tile's span room) — never max-content, so the scroller can't - // resize mid-drag. - const pitchX = WIDE.unit.width + WIDE.unit.gap; - expect(grid?.getAttribute("style")).toContain(`width: ${5 * pitchX - WIDE.unit.gap}px`); - expect(grid?.getAttribute("style")).not.toContain("width: max-content"); - // The dashed landing ghost appears… const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); expect(ghost).not.toBeNull(); @@ -127,8 +119,6 @@ describe("HomeTileGrid", () => { fireEvent.pointerUp(wrapper); // The ghost + follow disappear on drop… expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); - // …and the reservation is gone too (the canvas hugs the committed content). - expect(grid?.getAttribute("style")).toContain("width: max-content"); // …and the drop committed a PLACE at the hovered cell (a lands where it // already was — the pointer hovered its own slot; a no-move drop). expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); |
