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:
@@ -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
@@ -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 ───────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user