Shell 1.6.0: bars that tuck away, and every edge resizes

Three things about arranging a dashboard.

**Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away
at once, for a windowed screen that should read like a wall display.
Fullscreen already did this for watching; this is the windowed equivalent,
asked for by hand. It starts off and saves with the layout, so a booth's
named layout loads with the bars already tucked away.

**A hidden bar is not a gone bar.** Folded away — by that switch or by a
tile's own notch — the bar leaves the flow and parks just above its tile, so
the module keeps the whole body and nothing reflows as the bar comes and
goes. Point at the tile's top edge and it slides back in over the content,
where its buttons click and its width drags the tile exactly as before. A
dashboard can now be arranged with every bar out of the way, which is the
point of hiding them.

**Every edge resizes.** The top, left and right edges join the bottom one:
each moves only itself, so the tile grows into the space beside it while the
opposite edge stays put, stopping at a neighbour or at the module's own
minimum. Where two tiles meet, that boundary already belongs to the seam
pill between them — it moves both edges and leaves no gap — so the two
tiles' side marks step aside there rather than offer a second, different
control on the same pixels.

One fix underneath: saving a named layout was dropping each tile's variant,
title, minimum size and hidden-bar state, so a saved "Now" tile came back as
the full Now/Next card and a solo timer came back with a full tile's
settings. The layout file now carries them — and the new switch with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-17 11:39:34 -04:00
co-authored by Claude Opus 5
parent 6cca6d4d1a
commit 8e579ecb34
6 changed files with 365 additions and 33 deletions
+19 -7
View File
@@ -190,12 +190,15 @@ below. Nothing is installed without someone clicking.
- **+** — 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 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).
corner** to resize; **✕** removes it. Hover a tile and a small mark
appears on each **edge** — drag one to move just that edge, so the tile
grows or shrinks into the space beside it while the opposite edge stays
put. The bottom edge is the exception: when another tile sits directly
below, it 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 a pill appears; drag it to trade width
(macOS split-view style). That seam belongs to the pill, so the two tiles'
own side marks step aside there.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the
@@ -207,7 +210,16 @@ below. Nothing is installed without someone clicking.
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. The menu bar's handle is the **ProdDash
for a clean wall-display look. A hidden bar is not gone: point at the
tile's top edge and it slides back in over the content, where its buttons
click and its width drags the tile exactly as before — so a dashboard can
be arranged with every bar out of the way.
- **Auto-hide bars** (the button left of ⛶) folds every title bar away at
once, for a windowed screen that should read like a wall display. It is
off on a fresh dashboard and saves with the layout — including a named
layout on the server, so a booth loads its screen with the bars already
tucked away. Fullscreen folds the bars away regardless; this is the
windowed equivalent, asked for by hand. The menu bar's handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+8
View File
@@ -19,6 +19,14 @@
<span class="spacer"></span>
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l3-3 3 3"/>
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
<path d="M4 14h16"/>
</svg>
</button>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
+204 -10
View File
@@ -24,6 +24,7 @@ function toast(msg, isError = false) {
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
}
const autoHideBtn = document.getElementById('autohide-btn');
const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal');
@@ -161,18 +162,25 @@ async function loadRegistry() {
let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
/* Whether tile title bars fold away until you point at them. It is part of
how a dashboard is arranged rather than a preference of this browser, so
it saves with the layout — including a named layout on the server — and a
fresh dashboard starts with the bars showing. Fullscreen folds every bar
away regardless; this is the windowed equivalent, asked for by hand. */
let autoHideHeads = false;
function saveLayout() {
try {
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
} catch { /* private mode / full — layout just won't persist */ }
}
function loadLayoutFromStorage() {
try {
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
if (raw && Array.isArray(raw.tiles)) return raw;
} catch { /* corrupted — start fresh */ }
return [];
return { tiles: [] };
}
function sanitizeTile(t) {
@@ -249,6 +257,44 @@ function minSizeOf(tile) {
};
}
/* ── title bars that fold away ──────────────────────────────────────── */
/* A bar is "off" when it is folded out of sight but the tile is still being
worked on — either this tile's own notch hid it, or the dashboard has
auto-hide switched on. Fullscreen is not one of these: there the bars fold
away for watching, and arranging is switched off entirely. */
/** How close to the tile's top edge the pointer opens a folded-away bar. */
const HEAD_REVEAL_PX = 22;
/** How far down it may wander before that bar folds away again (bar + a little). */
const HEAD_HOLD_PX = 38;
function headIsOff(tile) {
if (document.body.classList.contains('fullscreen')) return false;
return autoHideHeads || Boolean(tile.headHidden);
}
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const off = headIsOff(tile);
el.classList.toggle('head-off', off);
if (!off) el.classList.remove('head-peek');
}
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
function applyAllHeadStates() {
for (const tile of tiles) applyHeadState(tile);
}
function setAutoHideHeads(on, { save = true } = {}) {
autoHideHeads = Boolean(on);
document.body.classList.toggle('autohide-heads', autoHideHeads);
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
applyAllHeadStates();
if (save) saveLayout();
}
/* ── tile DOM ───────────────────────────────────────────────────────── */
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
@@ -320,6 +366,19 @@ function buildTile(tile) {
resizeBottom.className = 'tile-resize-bottom';
resizeBottom.title = 'Drag to adjust height';
// The other three edges move only themselves: the top edge changes where
// the tile starts, the sides change how wide it is. Each stops where a
// neighbour or the tile's own minimum says it must.
const edgeGrip = (cls, title) => {
const gripEl = document.createElement('div');
gripEl.className = cls;
gripEl.title = title;
return gripEl;
};
const edgeTop = edgeGrip('tile-resize-top', 'Drag to adjust height');
const edgeRight = edgeGrip('tile-resize-right', 'Drag to adjust width');
const edgeLeft = edgeGrip('tile-resize-side', 'Drag to adjust width');
// notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button');
notch.className = 'tile-notch';
@@ -333,19 +392,41 @@ function buildTile(tile) {
return;
}
tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden);
applyHeadState(tile, el);
saveLayout();
});
// A folded-away bar is still reachable: point at the tile's top edge and it
// slides in over the content, where its buttons click and its whole width
// drags the tile exactly as when it is showing.
el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
// — neither is a moment to take the bar away.
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that
// appears under the pointer cannot flicker straight back out.
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
});
// A peeked title bar stays while the pointer is on its tile, and starts
// counting down as soon as it leaves.
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
el.addEventListener('pointerleave', () => {
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
if (!el.classList.contains('head-peek')) return;
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
// Windowed, the bar is a hover-reveal and goes the moment the pointer
// does. In fullscreen it was opened deliberately from the notch, so it
// lingers (PEEK_HIDE_MS) before folding away again.
if (document.body.classList.contains('fullscreen')) schedulePeekHide(tile.id, el);
else el.classList.remove('head-peek');
});
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft, notch);
applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null };
@@ -356,6 +437,9 @@ function buildTile(tile) {
wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom);
wireEdgeResize(tile, el, edgeTop, 'n');
wireEdgeResize(tile, el, edgeRight, 'e');
wireEdgeResize(tile, el, edgeLeft, 'w');
mountModule(tile);
return entry;
@@ -486,6 +570,80 @@ function wireResize(tile, el, handle, fromLeft) {
});
}
/* ── drag the top, left or right edge ───────────────────────────────── */
/* One edge moves and the opposite one stays where it is, so the tile grows
or shrinks into the space beside it. Unlike the bottom-edge pill these do
not trade rows or columns with a neighbour: the edge simply stops when it
meets one, which is what "resize this tile" should mean. */
function wireEdgeResize(tile, el, handle, edge) {
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 sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startY = tile.y;
const startW = tile.w;
const startH = tile.h;
const min = minSizeOf(tile);
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
let cand;
if (edge === 'n') {
// the top edge moves; the bottom edge stays put
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
} else if (edge === 'w') {
// the left edge moves; the right edge stays put
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
} else {
cand = {
x: tile.x,
y: tile.y,
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
h: tile.h,
};
}
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
if (!isFree(cand, tile.id)) return;
tile.x = cand.x;
tile.y = cand.y;
tile.w = cand.w;
tile.h = cand.h;
applyRect(tile);
changed = true;
notifyResize(tile);
};
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);
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
@@ -594,6 +752,29 @@ function rebuildDividers() {
buildDivider(L, R, top, bottom);
}
}
markSeamSides();
}
/* Where two tiles meet, the seam pill between them already owns that
boundary — and it is the better tool there, since it moves both edges at
once and leaves no gap. A side grip on the same pixels would be a second
control doing something else, so it steps aside and the tile is resized
from that side by the pill. Free sides keep their grip. */
function markSeamSides() {
const rowsOverlap = (a, b) => Math.min(a.y + a.h, b.y + b.h) > Math.max(a.y, b.y);
for (const t of tiles) {
const el = tileEls.get(t.id)?.el;
if (!el) continue;
let left = false;
let right = false;
for (const o of tiles) {
if (o === t || !rowsOverlap(o, t)) continue;
if (o.x + o.w === t.x) left = true;
if (o.x === t.x + t.w) right = true;
}
el.classList.toggle('seam-left', left);
el.classList.toggle('seam-right', right);
}
}
/** Every pair of tiles that meets at this column boundary, in any row. */
@@ -1712,9 +1893,13 @@ function remountTile(tile) {
/* ── layout apply / reset ───────────────────────────────────────────── */
function renderLayout(list) {
function renderLayout(layout) {
// Callers pass a whole layout; an array on its own is still understood so
// "reset" can simply hand over no tiles.
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles();
tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) buildTile(tile);
refreshEmptyState();
rebuildDividers();
@@ -1794,7 +1979,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { tiles } }),
body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -1838,7 +2023,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout.tiles || []);
renderLayout(body.layout);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
@@ -2026,6 +2211,13 @@ function clearFullscreenFit() {
}
}
autoHideBtn?.addEventListener('click', () => {
setAutoHideHeads(!autoHideHeads);
toast(autoHideHeads
? 'Title bars hide until you point at a tile\u2019s top edge'
: 'Title bars stay put');
});
fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
@@ -2039,6 +2231,7 @@ document.addEventListener('fullscreenchange', () => {
document.body.classList.toggle('fullscreen', active);
if (active) {
closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring
@@ -2047,6 +2240,7 @@ document.addEventListener('fullscreenchange', () => {
setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit();
applyAllHeadStates();
}
});
+109 -6
View File
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
line-height: 1;
}
.tb-btn.icon svg { display: block; }
/* A top-bar button that is a switch rather than an action lights up the same
way an open tile button does, so "on" reads the same everywhere. */
.tb-btn.is-on,
.tb-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
/* ── dropdown menus ────────────────────────────────────────────────── */
@@ -270,12 +278,37 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease;
}
.tile.dragging .tile-head { cursor: grabbing; }
/* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
/* Folded away: the bar leaves the flow and parks just above the tile, so the
module keeps the whole body and nothing reflows as the bar comes and goes.
Pointing at the tile's top edge slides it back in over the content
(shell.js adds .head-peek) — and because it is the same element, its
buttons click and its width drags the tile just as they always did. */
body:not(.fullscreen) .tile.head-off > .tile-head {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 7;
background: color-mix(in srgb, var(--panel) 90%, transparent);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
}
body:not(.fullscreen) .tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
body:not(.fullscreen) .tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
}
/* Auto-hide already reveals on hover, so the notch would be a dead control;
a bar this tile hid by itself keeps its notch, which is how it comes back. */
body.autohide-heads .tile-notch { display: none; }
.tile-head .dot {
flex: none;
@@ -395,7 +428,7 @@ body.menu-hidden #topbar {
}
.tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; }
.tile.head-hidden .tile-notch { top: 0; }
body:not(.fullscreen) .tile.head-off .tile-notch { top: 0; }
.tile-notch::after {
content: "";
display: block;
@@ -496,6 +529,72 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
margin-bottom: 4px;
border-bottom: 2px solid currentColor;
}
/* the other three edges: the same mark, turned to face its own side. Each
stops short of the corners so the corner grips keep their full 20px. */
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-resize-top {
top: 0;
left: 24px;
right: 24px;
height: 10px;
align-items: flex-start;
cursor: ns-resize;
/* Above a revealed title bar: the bar slides in under the pointer here, and
this strip has to stay the top edge rather than become part of it. */
z-index: 8;
}
.tile-resize-right,
.tile-resize-side {
top: 24px;
bottom: 24px;
width: 10px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; justify-content: flex-end; }
.tile-resize-side { left: 0; justify-content: flex-start; }
.tile-resize-top::after,
.tile-resize-right::after,
.tile-resize-side::after {
content: "";
border-radius: 999px;
}
.tile-resize-top::after {
width: 44px;
max-width: 60%;
height: 0;
margin-top: 4px;
border-top: 2px solid currentColor;
}
.tile-resize-right::after,
.tile-resize-side::after {
width: 0;
height: 44px;
max-height: 60%;
}
.tile-resize-right::after { margin-right: 4px; border-right: 2px solid currentColor; }
.tile-resize-side::after { margin-left: 4px; border-left: 2px solid currentColor; }
/* A side that meets another tile belongs to the seam pill between them. */
.tile.seam-left .tile-resize-side,
.tile.seam-right .tile-resize-right { display: none; }
.tile:hover .tile-resize-top,
.tile:hover .tile-resize-right,
.tile:hover .tile-resize-side { opacity: 0.6; }
.tile .tile-resize-top:hover,
.tile .tile-resize-right:hover,
.tile .tile-resize-side:hover { opacity: 1; color: var(--text); }
.tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
@@ -543,7 +642,11 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-resize-top,
body.fullscreen .tile-resize-right,
body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }
+17 -2
View File
@@ -1394,7 +1394,11 @@ function layoutPath(name) {
function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
return {
tiles: tiles.map((t) => ({
// Whether tile title bars auto-hide is part of how a dashboard is
// arranged, so it travels with the layout like the tiles themselves.
autoHideHeads: Boolean(raw?.autoHideHeads),
tiles: tiles.map((t) => {
const tile = {
id: String(t?.id || ''),
module: String(t?.module || ''),
x: Number(t?.x) || 0,
@@ -1402,7 +1406,18 @@ function sanitizeLayout(raw) {
w: Number(t?.w) || 1,
h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
})),
// Which picker entry the tile came from, and what it was called.
// Without these a saved "Now" tile comes back as the full Now/Next
// card, and a solo timer comes back with a full tile's settings.
variant: String(t?.variant || ''),
title: String(t?.title || ''),
headHidden: Boolean(t?.headHidden),
};
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
return tile;
}),
};
}