From af46499d924abf3784ae0b37941fee1e9f95909c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:16:32 +0000 Subject: [PATCH] Flush tile grid + macOS-style seam pill for resizing adjacent tiles Tiles now sit with zero gap (their 1px borders are the only seam) and the grid runs edge to edge. Wherever two tiles share a vertical edge, an invisible strip sits on the seam; hovering reveals a small grab pill (split-view style) and dragging it moves the shared edge, trading width between the two tiles. The combined span is preserved, both tiles' min sizes are honored, and a third tile sitting behind part of the seam blocks the drag instead of being overlapped. Pills hide during tile drags/resizes and rebuild for the new geometry on release; seam sizes persist with the layout. Also fixes cellMetrics treating a 0px grid gap as falsy and falling back to 10px. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 4 +- public/shell.js | 110 ++++++++++++++++++++++++++++++++++++++++++++--- public/style.css | 38 +++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dab0645..8eb3267 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,9 @@ are written by the app itself. - **+ Add tile** — add any enabled module; multiple tiles of the same module are fine (e.g. two transcripts filtered to different channels). - Drag a tile by its **header** to move it; drag the **corner handle** to - resize; **✕** removes it. + resize; **✕** removes it. Tiles sit flush — when two share an edge, hover + the seam and a small grab pill appears; drag it to trade width between + the two tiles (macOS split-view style). - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. - **Layout ▾** — save the current arrangement to the server under a name, diff --git a/public/shell.js b/public/shell.js index f4a8412..4f759b5 100644 --- a/public/shell.js +++ b/public/shell.js @@ -161,7 +161,8 @@ function newId() { function cellMetrics() { const style = getComputedStyle(grid); - const gap = parseFloat(style.columnGap) || 10; + const gapRaw = parseFloat(style.columnGap); + const gap = Number.isFinite(gapRaw) ? gapRaw : 0; const cw = (grid.clientWidth - gap * (COLS - 1)) / COLS; const ch = parseFloat(style.gridAutoRows) || 72; return { cw, ch, gap }; @@ -171,9 +172,13 @@ function overlaps(a, b) { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; } -function isFree(rect, excludeId) { +function isFreeExcept(rect, excludeIds) { if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false; - return !tiles.some((t) => t.id !== excludeId && overlaps(rect, t)); + return !tiles.some((t) => !excludeIds.includes(t.id) && overlaps(rect, t)); +} + +function isFree(rect, excludeId) { + return isFreeExcept(rect, [excludeId]); } /** First free spot scanning top-to-bottom, left-to-right. */ @@ -296,6 +301,7 @@ function wireDrag(tile, el, handle) { if (!moved && (dCols || dRows)) { moved = true; el.classList.add('dragging'); + grid.classList.add('no-dividers'); } const cand = { x: Math.max(0, Math.min(COLS - tile.w, startX + dCols)), @@ -314,7 +320,11 @@ function wireDrag(tile, el, handle) { handle.removeEventListener('pointerup', onUp); handle.removeEventListener('pointercancel', onUp); el.classList.remove('dragging'); - if (moved) saveLayout(); + grid.classList.remove('no-dividers'); + if (moved) { + saveLayout(); + rebuildDividers(); + } }; handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', onUp); @@ -337,6 +347,7 @@ function wireResize(tile, el, handle) { const startH = tile.h; const min = minSizeOf(tile); el.classList.add('resizing'); + grid.classList.add('no-dividers'); let changed = false; const onMove = (e) => { @@ -361,7 +372,11 @@ function wireResize(tile, el, handle) { handle.removeEventListener('pointerup', onUp); handle.removeEventListener('pointercancel', onUp); el.classList.remove('resizing'); - if (changed) saveLayout(); + grid.classList.remove('no-dividers'); + if (changed) { + saveLayout(); + rebuildDividers(); + } }; handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', onUp); @@ -376,6 +391,88 @@ function notifyResize(tile) { } catch { /* module's problem, not the shell's */ } } +/* ── seam dividers between adjacent tiles ───────────────────────────── */ + +/* Wherever two tiles share a vertical edge, a divider sits on the seam. + Dragging its pill moves the shared edge, trading width between the two + tiles — their combined span never changes, so nothing else can collide + except via the explicit check below (a third tile can sit behind the + seam for part of its height). */ + +function rebuildDividers() { + grid.querySelectorAll('.tile-divider').forEach((el) => el.remove()); + for (const L of tiles) { + for (const R of tiles) { + if (L === R || L.x + L.w !== R.x) continue; + const top = Math.max(L.y, R.y); + const bottom = Math.min(L.y + L.h, R.y + R.h); + if (bottom <= top) continue; + buildDivider(L, R, top, bottom); + } + } +} + +function buildDivider(L, R, top, bottom) { + const el = document.createElement('div'); + el.className = 'tile-divider'; + el.title = 'Drag to resize'; + + const place = () => { + const m = cellMetrics(); + // left as a percentage so the pill tracks window resizes for free + el.style.left = ((L.x + L.w) / COLS) * 100 + '%'; + el.style.top = top * (m.ch + m.gap) + 'px'; + el.style.height = (bottom - top) * (m.ch + m.gap) - m.gap + 'px'; + }; + place(); + + el.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0 && ev.pointerType === 'mouse') return; + ev.preventDefault(); + closeSettingsPopovers(); + el.setPointerCapture(ev.pointerId); + el.classList.add('active'); + const m = cellMetrics(); + const sx = ev.clientX; + const startBoundary = L.x + L.w; + const rightEdge = R.x + R.w; // fixed for the whole drag + const minB = L.x + minSizeOf(L).w; + const maxB = rightEdge - minSizeOf(R).w; + let changed = false; + + const onMove = (e) => { + const b = Math.max(minB, Math.min(maxB, + startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap)))); + if (b === L.x + L.w) return; + const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h }; + const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h }; + if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return; + L.w = candL.w; + R.x = candR.x; + R.w = candR.w; + applyRect(L); + applyRect(R); + notifyResize(L); + notifyResize(R); + place(); + changed = true; + }; + const onUp = () => { + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + el.classList.remove('active'); + if (changed) saveLayout(); + rebuildDividers(); // adjacencies may have changed + }; + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + }); + + grid.appendChild(el); +} + /* ── add / remove tiles ─────────────────────────────────────────────── */ function addTile(moduleId) { @@ -394,6 +491,7 @@ function addTile(moduleId) { buildTile(tile); refreshEmptyState(); saveLayout(); + rebuildDividers(); } function removeTile(id) { @@ -408,6 +506,7 @@ function removeTile(id) { } refreshEmptyState(); saveLayout(); + rebuildDividers(); } function clearAllTiles() { @@ -686,6 +785,7 @@ function renderLayout(list) { tiles = list.map(sanitizeTile); for (const tile of tiles) buildTile(tile); refreshEmptyState(); + rebuildDividers(); } /* ── menus ──────────────────────────────────────────────────────────── */ diff --git a/public/style.css b/public/style.css index cb4f1e7..9500743 100644 --- a/public/style.css +++ b/public/style.css @@ -14,7 +14,7 @@ --warn: #f0b429; /* grid geometry (shell.js reads these) */ --cell-h: 72px; - --gap: 10px; + --gap: 0px; /* tiles sit flush; their 1px borders are the only seam */ } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -138,7 +138,6 @@ button { font-family: inherit; } flex: 1; overflow-y: auto; overflow-x: hidden; - padding: var(--gap); overscroll-behavior: contain; } @@ -277,6 +276,41 @@ button { font-family: inherit; } border-radius: 0 0 3px 0; } +/* ── seam divider between adjacent tiles ───────────────────────────── */ + +/* When two tiles share a vertical edge, an invisible strip sits on the seam; + hovering reveals a small macOS-split-view-style grab pill. Dragging it + trades width between the two tiles. */ +.tile-divider { + position: absolute; + width: 14px; + transform: translateX(-50%); + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + cursor: col-resize; + touch-action: none; +} + +.tile-divider::after { + content: ""; + width: 5px; + height: 44px; + max-height: 55%; + border-radius: 999px; + background: var(--muted); + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease; +} + +.tile-divider:hover::after { opacity: 0.7; } +.tile-divider.active::after { opacity: 1; background: var(--text); } + +/* Hide the pills while a tile itself is being moved or corner-resized — + they're rebuilt for the new geometry on release. */ +#grid.no-dividers .tile-divider { display: none; } + /* status message a module can show while degraded (also used by the shell when a module fails to load) */ .tile-msg {