Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 bbb4bfd860 Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:33 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
13 changed files with 404 additions and 419 deletions
+16 -26
View File
@@ -189,17 +189,13 @@ below. Nothing is installed without someone clicking.
- **+** — add any enabled module; multiple tiles of the same module are - **+** — add any enabled module; multiple tiles of the same module are
fine (e.g. two transcripts filtered to different channels). fine (e.g. two transcripts filtered to different channels).
- Drag a tile by its **title bar** to move it; **✕** removes it. - Drag a tile by its **title bar** to move it; drag either **bottom
- **Resize from any edge or bottom corner**, the way a window resizes: corner** to resize; **✕** removes it. Hover a tile and a small pill
nothing is drawn on a tile to mark the spots — the cursor over an edge is appears on its **bottom edge** — drag it to change just the height. If
what says it can be dragged. An edge moves only itself, so the tile grows another tile sits directly below, the pill moves the shared edge instead,
or shrinks into the space beside it while the opposite edge stays put, trading rows between the two so the column keeps its total height. Tiles
stopping at a neighbour or at the module's own minimum size. Two sit flush — when two share a vertical edge, hover the seam and the same
exceptions, both of which move a *shared* edge instead: with another tile kind of pill appears; drag it to trade width (macOS split-view style).
directly below, the bottom edge trades rows between the two so the column
keeps its total height; and where two tiles meet side by side, the seam
between them trades width (macOS split-view style), showing itself only
while you drag it.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its - **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 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 occupied columns stretch to the full width, and every column runs to the
@@ -209,21 +205,9 @@ below. Nothing is installed without someone clicking.
gap; alone in its columns it stays as it is. Nothing is saved — the layout 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 reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls. its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**. - A tile's actions (menus, text size, clear…) live in its **title bar**;
- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for the small **notch** on the bar's bottom edge hides/shows the whole bar
a windowed screen that should read like a wall display. A folded bar is for a clean wall-display look. The menu bar's handle is the **ProdDash
not a gone bar: 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.
The switch 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 every bar regardless, and
the same hover brings one back there too.
- The **pin** beside a tile's title keeps *that* tile's bar showing while
auto-hide is on — for the one or two tiles whose controls are wanted at
a glance. It turns the accent colour when it is on and saves with the
layout. Fullscreen still folds a pinned bar with the rest.
- The menu bar's own handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it 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 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 the bar back. It stays at the left so it never sits on top of a tile's
@@ -247,7 +231,13 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps. Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
+3 -4
View File
@@ -367,10 +367,9 @@ fallback), never a "Text size" field in `instanceSchema`; a timestamps or
newest-first toggle in the header means no such switch in the gear. The newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own. gear is for display choices that have no button of their own.
One more thing to know: the whole title bar folds away when the dashboard One more thing to know: the user can hide the whole title bar with the
auto-hides it, and always in fullscreen (a clean-view mode for wall small notch on its bottom edge (a clean-view mode for wall displays). Your
displays) — it comes back only while the pointer is at the tile's top edge. controls disappear with it, so nothing essential — status, reconnects,
Your controls fold away with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control. live data — may exist *only* as a header control.
`sse(path, handlers)` handlers: `sse(path, handlers)` handlers:
+39 -9
View File
@@ -6,6 +6,12 @@
in /admin; the tile only renders. */ in /admin; the tile only renders. */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
</div>`; </div>`;
const screenEl = root.querySelector('.nn-screen'); const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top'); const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist'); const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live'); const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text'); const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now'); const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box'); const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title'); let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta'); const nowMetaEl = root.querySelector('.nn-meta');
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list'); const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more'); const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() { function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles return moduleApi.instanceSettings;
} }
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
} }
function displayName(raw) { function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
} }
function itemTypeTag(type) { function itemTypeTag(type) {
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0; const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0; const current = state.currentSlide || 0;
if (total > 0 && current > 0) { if (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current); const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) { } else if (total > 0) {
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = ''; nowMetaEl.textContent = '';
} }
if (prefs().showProgress && total > 0) { if (on('showProgress') && total > 0) {
progressEl.hidden = false; progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`; progressFillEl.style.width = `${pct}%`;
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)'; nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl); row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'nn-count-chip'; chip.className = 'nn-count-chip';
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row); nextListEl.appendChild(row);
} }
nextLabelEl.hidden = upcoming.length === 0; nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
nextMoreEl.hidden = true; nextMoreEl.hidden = true;
markNextOverflow(); markNextOverflow();
requestAnimationFrame(markNextOverflow); requestAnimationFrame(markNextOverflow);
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
screenEl.classList.toggle('is-offline', feedOffline); screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; // Trouble always shows, whatever is switched off: a tile that has stopped
topRow.classList.toggle('hidden-row', !showTop); // updating must never look like one with nothing live.
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive(); renderLive();
renderNow(); renderNow();
renderNext(); if (!nowOnly && on('showNext')) renderNext();
} }
/* ── sizing: --unit scales the whole card to the tile ───────────── */ /* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
function applyUnit() { function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
+15 -6
View File
@@ -1,8 +1,8 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.0.1", "version": "1.1.0",
"proddash": ">=1.1.0", "proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } { "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
@@ -20,13 +20,22 @@
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "Password",
"default": "" "default": ""
} }
}, },
"instanceSchema": { "instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, "cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true },
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, "showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" },
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } "showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" },
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }
} }
} }
+37
View File
@@ -108,6 +108,43 @@ module.exports = {
}; };
}, },
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: false, columns: 2 } },
},
];
},
routes() { routes() {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
+48
View File
@@ -21,6 +21,51 @@
justify-content: center; justify-content: center;
} }
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */ /* ── top row ── */
[data-module="propresenter-now-next"] .nn-top { [data-module="propresenter-now-next"] .nn-top {
@@ -59,6 +104,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live { [data-module="propresenter-now-next"] .nn-live {
display: flex; display: flex;
@@ -71,6 +117,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot { [data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6); width: calc(var(--unit) * 0.6);
@@ -122,6 +169,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
} }
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box { [data-module="propresenter-now-next"] .nn-title-box {
flex: 1; flex: 1;
+16 -5
View File
@@ -141,13 +141,17 @@ const sendTarget = (() => {
/** /**
* Where a send goes, given the configured channels in admin order: the * Where a send goes, given the configured channels in admin order: the
* selection while it is still configured and its transcript is on the * selection while it is still configured and its transcript is on the
* page; otherwise the first configured channel. * page, else the first configured channel that IS on this page — with one
* transcript up, that one — and only if the page shows none, the first
* configured channel. A send must never quietly go to a channel the
* operator cannot see.
*/ */
resolve(configured) { resolve(configured) {
const list = Array.isArray(configured) ? configured : []; const list = Array.isArray(configured) ? configured : [];
if (!list.length) return ''; if (!list.length) return '';
if (id && list.includes(id) && this.hasTranscript(id)) return id; if (id && list.includes(id) && this.hasTranscript(id)) return id;
return list[0]; const onPage = list.find((c) => this.hasTranscript(c));
return onPage || list[0];
}, },
on(fn) { on(fn) {
bus.addEventListener('change', fn); bus.addEventListener('change', fn);
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (stopped || !state) return; if (stopped || !state) return;
if (token) { if (token) {
if (composeEls) { if (composeEls) {
composeEls.line.textContent = sendingLine(); if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
let composeEls = null; let composeEls = null;
function buildCompose() { function buildCompose() {
compose.textContent = ''; compose.textContent = '';
// Send message names the channel in the box's own placeholder and locks
// from the title bar, so a line above the field would only say both
// again. Quick replies has no placeholder — there it is the only place
// the target is named, so it stays.
let line = null;
if (mode !== 'send') {
const head = el('div', 'sl-compose-head'); const head = el('div', 'sl-compose-head');
const line = el('span', 'sl-sending', sendingLine()); line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock'); const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button'; lockLink.type = 'button';
lockLink.title = 'Lock now'; lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked')); lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink); head.append(line, lockLink);
compose.appendChild(head); compose.appendChild(head);
}
const foot = el('div', 'sl-compose-foot'); const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg'); const msg = el('div', 'sl-msg');
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock(''); if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls) { if (composeEls) {
composeEls.line.textContent = sendingLine(); if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"id": "slack", "id": "slack",
"name": "Slack", "name": "Slack",
"version": "1.2.0", "version": "1.2.1",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location", "description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js", "client": "client.js",
@@ -21,7 +21,7 @@
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location." "help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
}, },
"Channels": { "Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit." "help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
}, },
"Sending": { "Sending": {
"toggle": "sendEnabled", "toggle": "sendEnabled",
@@ -49,7 +49,7 @@
"default": [], "default": [],
"group": "Channels", "group": "Channels",
"optionsRoute": "/channels", "optionsRoute": "/channels",
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first." "help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.6.0", "version": "1.5.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
-8
View File
@@ -19,14 +19,6 @@
<span class="spacer"></span> <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"> <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"> <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"/> <path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
+92 -211
View File
@@ -24,7 +24,6 @@ function toast(msg, isError = false) {
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
} }
const autoHideBtn = document.getElementById('autohide-btn');
const modalBackdrop = document.getElementById('modal-backdrop'); const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal'); const modalEl = document.getElementById('modal');
@@ -162,25 +161,18 @@ async function loadRegistry() {
let tiles = []; let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance } 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() { function saveLayout() {
try { try {
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles })); localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
} catch { /* private mode / full — layout just won't persist */ } } catch { /* private mode / full — layout just won't persist */ }
} }
function loadLayoutFromStorage() { function loadLayoutFromStorage() {
try { try {
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null'); const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
if (raw && Array.isArray(raw.tiles)) return raw; if (raw && Array.isArray(raw.tiles)) return raw.tiles;
} catch { /* corrupted — start fresh */ } } catch { /* corrupted — start fresh */ }
return { tiles: [] }; return [];
} }
function sanitizeTile(t) { function sanitizeTile(t) {
@@ -193,7 +185,7 @@ function sanitizeTile(t) {
w, w,
h: Math.max(1, Number(t.h) || 2), h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {}, settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
headPinned: Boolean(t.headPinned), headHidden: Boolean(t.headHidden),
// set when the tile was added from a module's tile list (multi-tile modules) // set when the tile was added from a module's tile list (multi-tile modules)
variant: t.variant ? String(t.variant) : '', variant: t.variant ? String(t.variant) : '',
title: t.title ? String(t.title) : '', title: t.title ? String(t.title) : '',
@@ -257,48 +249,9 @@ function minSizeOf(tile) {
}; };
} }
/* ── title bars that fold away ──────────────────────────────────────── */
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
on and this tile is not pinned, or the screen is in fullscreen, which folds
every bar for watching whatever the tiles say. Off is never gone — the top
edge of the tile brings it back for as long as the pointer is there. */
/** 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 true;
return autoHideHeads && !tile.headPinned;
}
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 ───────────────────────────────────────────────────────── */ /* ── 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>'; 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>';
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>'; const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
function buildTile(tile) { function buildTile(tile) {
@@ -325,34 +278,7 @@ function buildTile(tile) {
const controls = document.createElement('span'); const controls = document.createElement('span');
controls.className = 'tile-controls'; controls.className = 'tile-controls';
/* Pinned tiles keep their bar through auto-hide. The switch sits right head.append(dot, title, controls);
beside the label rather than out with the actions, because it is about
this bar rather than about the tile's contents. */
const pin = document.createElement('button');
pin.className = 'tile-btn pin';
pin.innerHTML = PIN_SVG;
const paintPin = () => {
const on = Boolean(tile.headPinned);
pin.classList.toggle('is-pinned', on);
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
// Instant tooltips read `title` the first time and `data-tip` after.
pin.title = on ? 'Unpin bar' : 'Pin bar';
pin.setAttribute('data-tip', pin.title);
};
paintPin();
pin.addEventListener('click', (e) => {
e.stopPropagation();
tile.headPinned = !tile.headPinned;
paintPin();
applyHeadState(tile, el);
saveLayout();
});
// Keeps the label hugging the pin however wide the tile gets.
const gap = document.createElement('span');
gap.className = 'tile-head-gap';
head.append(dot, title, pin, gap, controls);
// Every tile has something to set: its own settings when the module // Every tile has something to set: its own settings when the module
// offers any, and the tint either way. // offers any, and the tint either way.
@@ -381,47 +307,45 @@ function buildTile(tile) {
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'tile-body'; body.className = 'tile-body';
/* The rim: both bottom corners resize in two directions, the bottom edge const resize = document.createElement('div');
moves that edge (trading rows with tiles below), and the other three resize.className = 'tile-resize';
move only themselves — the top edge changes where the tile starts, the resize.title = 'Drag to resize';
sides how wide it is — each stopping where a neighbour or the tile's own
minimum says it must. None of them draw anything: the cursor over an
edge is what says it can be dragged, as on a window. */
const grip = (cls) => {
const gripEl = document.createElement('div');
gripEl.className = cls;
return gripEl;
};
const resize = grip('tile-resize');
const resizeLeft = grip('tile-resize-left');
const resizeBottom = grip('tile-resize-bottom');
const edgeTop = grip('tile-resize-top');
const edgeRight = grip('tile-resize-right');
const edgeLeft = grip('tile-resize-side');
// A folded-away bar is still reachable: point at the tile's top edge and it const resizeLeft = document.createElement('div');
// slides in over the content, where its buttons click and its whole width resizeLeft.className = 'tile-resize-left';
// drags the tile exactly as when it is showing. resizeLeft.title = 'Drag to resize';
el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return; // pill on the bottom edge: moves that edge (trading rows with tiles below)
// Mid-drag the tile moves under the pointer, and mid-resize the edges do const resizeBottom = document.createElement('div');
// — neither is a moment to take the bar away. resizeBottom.className = 'tile-resize-bottom';
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; resizeBottom.title = 'Drag to adjust height';
const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek'); // notch on the title bar's bottom edge: hides/shows the bar
// It takes less to hold the bar open than to open it, so a bar that const notch = document.createElement('button');
// appears under the pointer cannot flicker straight back out. notch.className = 'tile-notch';
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek'); notch.title = 'Hide / show the title bar';
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek'); notch.addEventListener('click', (e) => {
e.stopPropagation();
// In fullscreen the bars are all folded away for watching; the notch
// peeks one open for a moment without touching what this tile saved.
if (document.body.classList.contains('fullscreen')) {
peekHead(tile, el, !el.classList.contains('head-peek'));
return;
}
tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden);
saveLayout();
}); });
// 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', () => { el.addEventListener('pointerleave', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
el.classList.remove('head-peek');
}); });
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft); el.append(head, body, resize, resizeLeft, resizeBottom, notch);
applyHeadState(tile, el); el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el); grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null }; const entry = { el, body, dot, titleEl: title, controls, instance: null };
@@ -432,9 +356,6 @@ function buildTile(tile) {
wireResize(tile, el, resize, false); wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true); wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom); wireBottomResize(tile, el, resizeBottom);
wireEdgeResize(tile, el, edgeTop, 'n');
wireEdgeResize(tile, el, edgeRight, 'e');
wireEdgeResize(tile, el, edgeLeft, 'w');
mountModule(tile); mountModule(tile);
return entry; return entry;
@@ -448,7 +369,7 @@ function refreshEmptyState() {
function wireDrag(tile, el, handle) { function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => { handle.addEventListener('pointerdown', (ev) => {
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return; if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return; if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
@@ -565,80 +486,6 @@ 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 ──────────────────────────────── */ /* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit /* The pill on a tile's bottom edge moves that edge. When other tiles sit
@@ -766,6 +613,7 @@ function seamPairsAt(boundary) {
function buildDivider(L, R, top, bottom) { function buildDivider(L, R, top, bottom) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'tile-divider'; el.className = 'tile-divider';
el.title = 'Drag to resize this column';
const place = () => { const place = () => {
const m = cellMetrics(); const m = cellMetrics();
@@ -778,6 +626,14 @@ function buildDivider(L, R, top, bottom) {
const boundary = () => L.x + L.w; const boundary = () => L.x + L.w;
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el); const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
// Before anything is dragged, show which seams travel with this one.
el.addEventListener('pointerenter', () => {
if (document.body.classList.contains('fullscreen')) return;
for (const other of groupEls()) other.classList.add('linked');
});
el.addEventListener('pointerleave', unlink);
el.addEventListener('pointerdown', (ev) => { el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
@@ -833,6 +689,7 @@ function buildDivider(L, R, top, bottom) {
el.removeEventListener('pointerup', onUp); el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointercancel', onUp);
for (const other of group) other.classList.remove('active'); for (const other of group) other.classList.remove('active');
unlink();
if (changed) saveLayout(); if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed rebuildDividers(); // adjacencies may have changed
}; };
@@ -870,7 +727,7 @@ function addTile(moduleId, entry = null) {
if (spec && 'default' in spec) settings[key] = spec.default; if (spec && 'default' in spec) settings[key] = spec.default;
} }
Object.assign(settings, entry?.settings || {}); Object.assign(settings, entry?.settings || {});
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false }; const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
if (entry) { if (entry) {
tile.variant = String(entry.id); tile.variant = String(entry.id);
tile.title = String(entry.name || ''); tile.title = String(entry.name || '');
@@ -886,6 +743,7 @@ function addTile(moduleId, entry = null) {
function removeTile(id) { function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id); const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return; if (idx < 0) return;
clearPeekTimer(id);
unmountModule(tiles[idx]); unmountModule(tiles[idx]);
tiles.splice(idx, 1); tiles.splice(idx, 1);
const entry = tileEls.get(id); const entry = tileEls.get(id);
@@ -1139,7 +997,7 @@ function buildModuleApi(tile, man, entry) {
/** /**
* The tile's title bar. Controls added here live next to the gear/close * The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The * buttons and are removed automatically when the instance stops. The
* whole bar folds away when the dashboard auto-hides it, so nothing * whole bar can be hidden by the user via the notch, so nothing
* essential should exist ONLY as a header control. * essential should exist ONLY as a header control.
*/ */
header: { header: {
@@ -1854,13 +1712,9 @@ function remountTile(tile) {
/* ── layout apply / reset ───────────────────────────────────────────── */ /* ── layout apply / reset ───────────────────────────────────────────── */
function renderLayout(layout) { function renderLayout(list) {
// 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(); clearAllTiles();
tiles = list.map(sanitizeTile); tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) buildTile(tile); for (const tile of tiles) buildTile(tile);
refreshEmptyState(); refreshEmptyState();
rebuildDividers(); rebuildDividers();
@@ -1940,7 +1794,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), { const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { autoHideHeads, tiles } }), body: JSON.stringify({ layout: { tiles } }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -1984,7 +1838,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it // Loading copies the layout into this browser's own state — it
// does not live-link browsers together. // does not live-link browsers together.
renderLayout(body.layout); renderLayout(body.layout.tiles || []);
saveLayout(); saveLayout();
toast(`Loaded “${l.name}”`); toast(`Loaded “${l.name}”`);
} catch (e) { } catch (e) {
@@ -1996,7 +1850,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.insertBefore(block, reset); menu.insertBefore(block, reset);
}); });
/* ── menu bar show/hide (its own notch, at the top-left) ────────────── */ /* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
const topbar = document.getElementById('topbar'); const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch'); const topbarNotch = document.getElementById('topbar-notch');
@@ -2023,6 +1877,41 @@ try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ } } catch { /* fine */ }
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
/** How long a peeked title bar stays after the pointer leaves its tile. */
const PEEK_HIDE_MS = 10000;
const peekTimers = new Map(); // tile id → timeout
function clearPeekTimer(id) {
const timer = peekTimers.get(id);
if (timer === undefined) return;
clearTimeout(timer);
peekTimers.delete(id);
}
function schedulePeekHide(id, el) {
clearPeekTimer(id);
peekTimers.set(id, setTimeout(() => {
peekTimers.delete(id);
el.classList.remove('head-peek');
}, PEEK_HIDE_MS));
}
function peekHead(tile, el, on) {
el.classList.toggle('head-peek', on);
clearPeekTimer(tile.id);
// Opened from a click the pointer is on the tile, so the countdown waits
// for it to leave — unless it already has (a keyboard or touch open).
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
}
/** Back to a plain screen: no peeks left open, no timers left running. */
function clearPeeks() {
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
}
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally /* Fullscreen is a viewing mode. The occupied columns stretch proportionally
@@ -2137,13 +2026,6 @@ 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', () => { fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {}); document.documentElement.requestFullscreen?.().catch(() => {});
@@ -2157,15 +2039,14 @@ document.addEventListener('fullscreenchange', () => {
document.body.classList.toggle('fullscreen', active); document.body.classList.toggle('fullscreen', active);
if (active) { if (active) {
closeSettingsPopovers(); closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden'); menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring // let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else { } else {
setMenuHidden(menuHiddenBeforeFs, { persist: false }); setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit(); clearFullscreenFit();
applyAllHeadStates();
} }
}); });
+118 -115
View File
@@ -126,14 +126,6 @@ body.menu-hidden #topbar {
line-height: 1; line-height: 1;
} }
.tb-btn.icon svg { display: block; } .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 ────────────────────────────────────────────────── */ /* ── dropdown menus ────────────────────────────────────────────────── */
@@ -278,33 +270,11 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease; transition: height 0.22s ease, border-bottom-width 0.22s ease;
} }
.tile.dragging .tile-head { cursor: grabbing; } .tile.dragging .tile-head { cursor: grabbing; }
/* hidden = collapsed, not removed, so the notch toggle animates */
/* Folded away: the bar leaves the flow and parks just above the tile, so the .tile.head-hidden .tile-head {
module keeps the whole body and nothing reflows as the bar comes and goes. height: 0;
Pointing at the tile's top edge slides it back in over the content border-bottom-width: 0;
(shell.js adds .head-peek) — and because it is the same element, its overflow: hidden;
buttons click and its width drags the tile just as they always did. */
.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;
}
.tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
.tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
} }
.tile-head .dot { .tile-head .dot {
@@ -320,7 +290,7 @@ body.menu-hidden #topbar {
.tile-head .dot.error { background: var(--danger); } .tile-head .dot.error { background: var(--danger); }
.tile-title { .tile-title {
flex: 0 1 auto; flex: 1;
min-width: 0; min-width: 0;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -351,33 +321,6 @@ body.menu-hidden #topbar {
.tile-btn.is-open, .tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); } .tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
/* The pin: a state, not an action, so it stays quiet until it is on and
sits closer to the label than to the buttons at the other end. It fades
in with the rest of the tile's chrome so a wall of unpinned tiles is not
a wall of pins. */
.tile-btn.pin {
width: 18px;
height: 18px;
margin-left: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-btn.pin svg { width: 12px; height: 12px; }
.tile:hover .tile-btn.pin,
.tile-btn.pin:focus-visible { opacity: 0.75; }
.tile-btn.pin:hover { opacity: 1; }
/* Pinned reads at a glance, whether or not anything is being pointed at. */
.tile-btn.pin.is-pinned,
.tile:hover .tile-btn.pin.is-pinned {
opacity: 1;
color: var(--accent);
}
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
.tile-btn.pin.is-pinned:hover { background: var(--border); }
/* Keeps the label hugging its pin however wide the tile is. */
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
/* the shell's tooltip: shown at once under the button the pointer is on */ /* the shell's tooltip: shown at once under the button the pointer is on */
#tip { #tip {
position: fixed; position: fixed;
@@ -431,6 +374,55 @@ body.menu-hidden #topbar {
} }
.tile-menu[hidden] { display: none; } .tile-menu[hidden] { display: none; }
/* notch on the title bar's bottom edge: click to hide/show the bar
(same idea as the ProdCom Listener's pull-down menubar handle) */
.tile-notch {
position: absolute;
top: 32px;
left: 50%;
transform: translateX(-50%);
width: 46px;
height: 13px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 6;
opacity: 0;
transition: opacity 0.15s ease, top 0.22s ease;
}
.tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; }
.tile.head-hidden .tile-notch { top: 0; }
.tile-notch::after {
content: "";
display: block;
width: 18px;
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
}
.tile-notch:hover::after { background: var(--text); }
/* Fullscreen is for watching, not arranging: every title bar folds away, and
a tile's own hidden/shown preference is left untouched for when the screen
comes back. The notch still peeks one open, and it folds back once the
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
body.fullscreen .tile:not(.head-peek) .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
body.fullscreen .tile.head-peek .tile-head {
height: 32px;
border-bottom-width: 1px;
}
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
.tile-body { .tile-body {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -439,67 +431,80 @@ body.menu-hidden #topbar {
overflow: hidden; overflow: hidden;
} }
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
Every edge and both bottom corners resize, so there is nothing to point
at — the grips are invisible and the cursor says what each one does, the
way a window's own edges do. Together they tile the tile's outer 8px
without a gap: the top strip runs corner to corner, the sides run between
it and the bottom corners, and the bottom strip fills between those. */
.tile-resize,
.tile-resize-left,
.tile-resize-bottom,
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 5;
touch-action: none;
}
/* the two bottom corners: both directions at once */
.tile-resize, .tile-resize,
.tile-resize-left { .tile-resize-left {
position: absolute;
bottom: 0; bottom: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
touch-action: none;
z-index: 5;
color: var(--muted);
opacity: 0; /* corner indicators only show while hovering the tile */
transition: opacity 0.15s ease;
} }
.tile-resize { right: 0; cursor: nwse-resize; } .tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; } .tile-resize-left { left: 0; cursor: nesw-resize; }
.tile:hover .tile-resize,
.tile:hover .tile-resize-left { opacity: 0.6; }
.tile .tile-resize:hover,
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
.tile-resize::after,
.tile-resize-left::after {
content: "";
position: absolute;
bottom: 4px;
width: 9px;
height: 9px;
border-bottom: 2px solid currentColor;
}
.tile-resize::after {
right: 4px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px 0;
}
.tile-resize-left::after {
left: 4px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
/* top and bottom: height only */ /* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
.tile-resize-top, the same 2px line as the corner handles it sits between, on the same
4px inset, so the three read as one set of marks along the bottom. */
.tile-resize-bottom { .tile-resize-bottom {
height: 8px; 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; cursor: ns-resize;
touch-action: none;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
} }
.tile-resize-top { .tile-resize-bottom::after {
top: 0; content: "";
left: 0; width: 44px;
right: 0; max-width: 60%;
/* Above a revealed title bar: the bar slides in under the pointer here, height: 0;
and this strip has to stay the top edge rather than become part of it. */ margin-bottom: 4px;
z-index: 8; border-bottom: 2px solid currentColor;
} }
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; } .tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover,
/* left and right: width only, between the top strip and the bottom corners */ .tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
.tile-resize-right,
.tile-resize-side {
top: 8px;
bottom: 20px;
width: 8px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; }
.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam. /* When two tiles share a vertical edge, an invisible strip sits on the seam;
Dragging it trades width between the two tiles rather than resizing one, hovering reveals a small macOS-split-view-style grab pill. Dragging it
so it sits above their own edge grips. Like them it draws nothing at rest trades width between the two tiles. */
— only while it is being dragged, to show which seam is moving. */
.tile-divider { .tile-divider {
position: absolute; position: absolute;
width: 14px; width: 14px;
@@ -523,24 +528,22 @@ body.menu-hidden #topbar {
transition: opacity 0.15s ease, background 0.15s ease; transition: opacity 0.15s ease, background 0.15s ease;
} }
.tile-divider:hover::after,
.tile-divider.linked::after { opacity: 0.7; }
.tile-divider.active::after { opacity: 1; background: var(--text); } .tile-divider.active::after { opacity: 1; background: var(--text); }
/* Drop the seams while a tile itself is being moved or resized — they're /* Hide the pills while a tile itself is being moved or corner-resized —
rebuilt for the new geometry on release. */ they're rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; } #grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */ /* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the /* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (the rim grips and the seams) are parked. */ editing affordances (corner handles, seam pills) are parked. */
body.fullscreen .tile-resize, body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left, body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom, 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 .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; } body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; } body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; } #gridwrap.no-scroll { overflow-y: hidden; }
+2 -17
View File
@@ -1394,11 +1394,7 @@ function layoutPath(name) {
function sanitizeLayout(raw) { function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : []; const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
return { return {
// Whether tile title bars auto-hide is part of how a dashboard is tiles: tiles.map((t) => ({
// 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 || ''), id: String(t?.id || ''),
module: String(t?.module || ''), module: String(t?.module || ''),
x: Number(t?.x) || 0, x: Number(t?.x) || 0,
@@ -1406,18 +1402,7 @@ function sanitizeLayout(raw) {
w: Number(t?.w) || 1, w: Number(t?.w) || 1,
h: Number(t?.h) || 1, h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {}, 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 || ''),
headPinned: Boolean(t?.headPinned),
};
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;
}),
}; };
} }