Bottom-edge height pill on tiles; fullscreen fit runs every column to the bottom

Tiles get a third resize handle: a pill on the bottom edge (hover-only,
between the two corner handles) that moves that edge. With nothing directly
below, it changes just the tile's height. When other tiles share the edge,
the drag trades rows with them so the column keeps its total height — the
vertical counterpart of the seam pill. Clamped to every tile's minimum
height; blocked by collisions like the corner handles.

Fullscreen auto-fit reworked so columns actually reach the bottom of the
screen. The old rule only stretched tiles whose bottom edge was within two
rows of the viewport bottom, which on a display taller than the browser
window the layout was designed in left a blank band below every column.
Now the bottom-most tile of each column stretches to the bottom regardless
of distance. Short tiles (h <= 2, the clock) are never stretched into a
ribbon: under another tile they drop to the bottom edge and the tile above
grows into the gap (stopping at anything else below it); alone in their
columns they stay put. The fit re-runs from a ResizeObserver on the grid
area, so the browser's animated fullscreen transition and the menubar
collapse both settle into a correct fit (replaces the resize/transitionend
listeners).

README documents the pill and the fullscreen behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-09-08 21:20:52 +00:00
parent 1292ba33ba
commit 66f45609f0
3 changed files with 198 additions and 35 deletions
+15 -4
View File
@@ -90,10 +90,21 @@ ignored from then on.
- **+** — 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 **title bar** to move it; drag the **corner handle**
to 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).
- Drag a tile by its **title bar** to move it; drag either **bottom
corner** to resize; **✕** removes it. Hover a tile and a small pill
appears on its **bottom edge** — drag it to change just the height. If
another tile sits directly below, the pill moves the shared edge instead,
trading rows between the two so the column keeps its total height. Tiles
sit flush — when two share a vertical edge, hover the seam and the same
kind of pill appears; drag it to trade width (macOS split-view style).
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides, the
occupied columns stretch to the full width, and every column runs to the
bottom of the screen — the last tile in each column is stretched to end
there. A short tile like the clock is never stretched into a ribbon: under
another tile it slides to the bottom edge and the tile above grows into the
gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**;
the small **notch** on the bar's bottom edge hides/shows the whole bar
for a clean wall-display look.
+148 -30
View File
@@ -277,6 +277,11 @@ function buildTile(tile) {
resizeLeft.className = 'tile-resize-left';
resizeLeft.title = 'Drag to resize';
// pill on the bottom edge: moves that edge (trading rows with tiles below)
const resizeBottom = document.createElement('div');
resizeBottom.className = 'tile-resize-bottom';
resizeBottom.title = 'Drag to adjust height';
// notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button');
notch.className = 'tile-notch';
@@ -288,7 +293,7 @@ function buildTile(tile) {
saveLayout();
});
el.append(head, body, resize, resizeLeft, notch);
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el);
@@ -299,6 +304,7 @@ function buildTile(tile) {
wireDrag(tile, el, head);
wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom);
mountModule(tile);
return entry;
@@ -429,6 +435,79 @@ function wireResize(tile, el, handle, fromLeft) {
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
directly below it (sharing the edge), the drag trades rows with them —
the column's total height stays the same, like the seam pill trades
columns. With nothing directly below, it simply changes this tile's
height. */
function tilesDirectlyBelow(tile) {
return tiles.filter((t) => t !== tile
&& t.y === tile.y + tile.h
&& t.x < tile.x + tile.w && tile.x < t.x + t.w);
}
function wireBottomResize(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sy = ev.clientY;
const startH = tile.h;
const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h }));
const group = [tile.id, ...below.map((b) => b.t.id)];
const minH = minSizeOf(tile).h;
// the edge can rise until this tile hits its minimum, and drop until the
// shortest neighbour below hits its own
const maxH = below.length
? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y
: Infinity;
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
const h = Math.max(minH, Math.min(maxH, startH + dRows));
if (h === tile.h || h > maxH) return;
const edge = tile.y + h;
const candSelf = { x: tile.x, y: tile.y, w: tile.w, h };
const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge }));
if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return;
tile.h = h;
applyRect(tile);
notifyResize(tile);
below.forEach((b, i) => {
b.t.y = candBelow[i].y;
b.t.h = candBelow[i].h;
applyRect(b.t);
notifyResize(b.t);
});
changed = true;
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
function notifyResize(tile) {
const entry = tileEls.get(tile.id);
try {
@@ -1079,27 +1158,68 @@ try {
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
to the full width; tiles whose bottom edge lands near the bottom of the
viewport are stretched to end exactly at it; when nothing extends below
the viewport, scrolling is disabled. All of it is visual-only — the saved
to the full width, and every column runs to the bottom of the screen: the
bottom-most tile of each column stretches to end exactly there. A small
tile (the clock) is never stretched into a ribbon — with a tile above it,
it drops to the bottom edge and the tile above grows into the gap; alone
in its columns, it stays exactly as it is. When nothing extends below the
viewport, scrolling is disabled. All of it is visual-only — the saved
layout is untouched and everything reverts on exit. */
const fsBtn = document.getElementById('fs-btn');
/** Rows this close to the viewport bottom count as "near" and get stretched. */
const FS_NEAR_ROWS = 2;
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
const FS_SMALL_ROWS = 2;
let menuHiddenBeforeFs = false;
function fsSmallExempt(tile) {
// A small tile (like the clock) alone in its columns must not become a
// full-height ribbon.
if (tile.h > 2) return false;
return !tiles.some((t) => t !== tile && t.x < tile.x + tile.w && tile.x < t.x + t.w);
function colsOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w;
}
function fsSmall(tile) {
return tile.h <= FS_SMALL_ROWS;
}
function fsAloneInColumns(tile) {
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
}
function fsBottomMost(tile) {
return !tiles.some((t) => t !== tile
&& t.x < tile.x + tile.w && tile.x < t.x + t.w
&& t.y >= tile.y + tile.h);
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
}
/**
* Fullscreen row placement for every tile, given R rows on screen:
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
* small), plus the tile directly above a dropped small one, which grows
* into the space it left — stopping at anything else below it.
*/
function fullscreenRows(R) {
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
const dropped = [];
for (const tile of tiles) {
if (!fsBottomMost(tile)) continue;
if (!fsSmall(tile)) {
rows.get(tile.id).h = R - tile.y;
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
rows.get(tile.id).y = R - tile.h;
dropped.push(tile);
}
}
for (const small of dropped) {
const gapTop = rows.get(small.id).y;
for (const above of tiles) {
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
if (above.y + above.h !== small.y) continue; // only a tile touching it
let limit = gapTop;
for (const other of tiles) {
if (other === above || other === small || !colsOverlap(above, other)) continue;
const oy = rows.get(other.id).y;
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
}
rows.get(above.id).h = Math.max(above.h, limit - above.y);
}
}
return rows;
}
function applyFullscreenFit() {
@@ -1128,16 +1248,14 @@ function applyFullscreenFit() {
const cellH = (wrapH - (R - 1) * m.gap) / R;
grid.style.gridAutoRows = cellH + 'px';
const rows = fullscreenRows(R);
for (const tile of tiles) {
applyRect(tile); // reset any previous stretch first
const bottom = tile.y + tile.h;
if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) {
const entry = tileEls.get(tile.id);
if (entry) {
entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`;
try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ }
}
}
if (!entry) continue;
const r = rows.get(tile.id);
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
}
}
@@ -1174,14 +1292,14 @@ document.addEventListener('fullscreenchange', () => {
}
});
window.addEventListener('resize', () => {
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
});
// the menubar collapse animates, so the grid's final height arrives late
topbar.addEventListener('transitionend', () => {
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
});
// The grid area's size settles late: the browser animates into fullscreen,
// then the menubar collapse animates. Re-fit whenever its size changes.
let fsRefit = 0;
new ResizeObserver(() => {
if (!document.body.classList.contains('fullscreen')) return;
cancelAnimationFrame(fsRefit);
fsRefit = requestAnimationFrame(applyFullscreenFit);
}).observe(gridwrap);
/* ── live updates from the admin page ───────────────────────────────── */
+34
View File
@@ -411,6 +411,39 @@ body.menu-hidden #topbar {
border-radius: 0 0 0 3px;
}
/* bottom-edge pill: drag to move the tile's bottom edge (height only);
sits between the two corner handles */
.tile-resize-bottom {
position: absolute;
left: 24px;
right: 24px;
bottom: 0;
height: 10px;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: ns-resize;
touch-action: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-resize-bottom::after {
content: "";
width: 44px;
max-width: 60%;
height: 5px;
margin-bottom: 2px;
border-radius: 999px;
background: var(--muted);
transition: background 0.15s ease;
}
.tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; }
.tile .tile-resize-bottom:hover::after,
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
/* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
@@ -452,6 +485,7 @@ body.menu-hidden #topbar {
editing affordances (corner handles, seam pills) are parked. */
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-divider { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }