Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 f0be37796b 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:57 -04:00
9 changed files with 259 additions and 404 deletions
+10 -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
fine (e.g. two transcripts filtered to different channels).
- Drag a tile by its **title bar** to move it; **✕** removes it.
- **Resize from any edge or bottom corner**, the way a window resizes:
nothing is drawn on a tile to mark the spots — the cursor over an edge is
what says it can be dragged. An edge moves only itself, so the tile grows
or shrinks into the space beside it while the opposite edge stays put,
stopping at a neighbour or at the module's own minimum size. Two
exceptions, both of which move a *shared* edge instead: with another tile
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.
- Drag a tile by its **title bar** to move it; drag either **bottom
corner** to resize; **✕** removes it. Hover a tile and a small pill
appears on its **bottom edge** — drag it to change just the height. If
another tile sits directly below, the pill moves the shared edge instead,
trading rows between the two so the column keeps its total height. Tiles
sit flush — when two share a vertical edge, hover the seam and the same
kind of pill appears; drag it to trade width (macOS split-view style).
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (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
@@ -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
reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**.
- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
a windowed screen that should read like a wall display. A folded bar is
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
- 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
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
+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
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
auto-hides it, and always in fullscreen (a clean-view mode for wall
displays) — it comes back only while the pointer is at the tile's top edge.
Your controls fold away with it, so nothing essential — status, reconnects,
One more thing to know: the user can hide the whole title bar with the
small notch on its bottom edge (a clean-view mode for wall displays). Your
controls disappear with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control.
`sse(path, handlers)` handlers:
+23 -12
View File
@@ -141,13 +141,17 @@ const sendTarget = (() => {
/**
* Where a send goes, given the configured channels in admin order: 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) {
const list = Array.isArray(configured) ? configured : [];
if (!list.length) return '';
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) {
bus.addEventListener('change', fn);
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (stopped || !state) return;
if (token) {
if (composeEls) {
composeEls.line.textContent = sendingLine();
if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder();
}
} else renderGate();
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
let composeEls = null;
function buildCompose() {
compose.textContent = '';
const head = el('div', 'sl-compose-head');
const line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
// 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');
line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
}
const foot = el('div', 'sl-compose-foot');
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 (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls) {
composeEls.line.textContent = sendingLine();
if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder();
}
} else renderGate();
+3 -3
View File
@@ -1,7 +1,7 @@
{
"id": "slack",
"name": "Slack",
"version": "1.2.0",
"version": "1.2.1",
"proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"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."
},
"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": {
"toggle": "sendEnabled",
@@ -49,7 +49,7 @@
"default": [],
"group": "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": {
"type": "number",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.6.0",
"version": "1.5.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
-8
View File
@@ -19,14 +19,6 @@
<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"/>
+92 -211
View File
@@ -24,7 +24,6 @@ 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');
@@ -162,25 +161,18 @@ 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, autoHideHeads, tiles }));
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, 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;
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
} catch { /* corrupted — start fresh */ }
return { tiles: [] };
return [];
}
function sanitizeTile(t) {
@@ -193,7 +185,7 @@ function sanitizeTile(t) {
w,
h: Math.max(1, Number(t.h) || 2),
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)
variant: t.variant ? String(t.variant) : '',
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 ───────────────────────────────────────────────────────── */
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>';
function buildTile(tile) {
@@ -325,34 +278,7 @@ function buildTile(tile) {
const controls = document.createElement('span');
controls.className = 'tile-controls';
/* Pinned tiles keep their bar through auto-hide. The switch sits right
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);
head.append(dot, title, controls);
// Every tile has something to set: its own settings when the module
// offers any, and the tint either way.
@@ -381,47 +307,45 @@ function buildTile(tile) {
const body = document.createElement('div');
body.className = 'tile-body';
/* The rim: both bottom corners resize in two directions, the bottom edge
moves that edge (trading rows with tiles below), and the other three
move only themselves — the top edge changes where the tile starts, the
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');
const resize = document.createElement('div');
resize.className = 'tile-resize';
resize.title = 'Drag to resize';
// 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');
const resizeLeft = document.createElement('div');
resizeLeft.className = 'tile-resize-left';
resizeLeft.title = 'Drag to resize';
// pill on the bottom edge: moves that edge (trading rows with tiles below)
const resizeBottom = document.createElement('div');
resizeBottom.className = 'tile-resize-bottom';
resizeBottom.title = 'Drag to adjust height';
// notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button');
notch.className = 'tile-notch';
notch.title = 'Hide / show the title bar';
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', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
el.classList.remove('head-peek');
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
});
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
applyHeadState(tile, el);
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el);
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, 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;
@@ -448,7 +369,7 @@ function refreshEmptyState() {
function wireDrag(tile, el, handle) {
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 (document.body.classList.contains('fullscreen')) return;
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 ──────────────────────────────── */
/* 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) {
const el = document.createElement('div');
el.className = 'tile-divider';
el.title = 'Drag to resize this column';
const place = () => {
const m = cellMetrics();
@@ -778,6 +626,14 @@ function buildDivider(L, R, top, bottom) {
const boundary = () => L.x + L.w;
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) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
@@ -833,6 +689,7 @@ function buildDivider(L, R, top, bottom) {
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
for (const other of group) other.classList.remove('active');
unlink();
if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed
};
@@ -870,7 +727,7 @@ function addTile(moduleId, entry = null) {
if (spec && 'default' in spec) settings[key] = spec.default;
}
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) {
tile.variant = String(entry.id);
tile.title = String(entry.name || '');
@@ -886,6 +743,7 @@ function addTile(moduleId, entry = null) {
function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return;
clearPeekTimer(id);
unmountModule(tiles[idx]);
tiles.splice(idx, 1);
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
* 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.
*/
header: {
@@ -1854,13 +1712,9 @@ function remountTile(tile) {
/* ── layout apply / reset ───────────────────────────────────────────── */
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 || []);
function renderLayout(list) {
clearAllTiles();
tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) buildTile(tile);
refreshEmptyState();
rebuildDividers();
@@ -1940,7 +1794,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: { autoHideHeads, tiles } }),
body: JSON.stringify({ layout: { tiles } }),
});
const body = await res.json().catch(() => ({}));
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 + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout);
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
@@ -1996,7 +1850,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
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 topbarNotch = document.getElementById('topbar-notch');
@@ -2023,6 +1877,41 @@ try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} 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 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', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
@@ -2157,15 +2039,14 @@ 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
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else {
setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit();
applyAllHeadStates();
}
});
+118 -115
View File
@@ -126,14 +126,6 @@ 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 ────────────────────────────────────────────────── */
@@ -278,33 +270,11 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease;
}
.tile.dragging .tile-head { cursor: grabbing; }
/* 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. */
.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));
/* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
.tile-head .dot {
@@ -320,7 +290,7 @@ body.menu-hidden #topbar {
.tile-head .dot.error { background: var(--danger); }
.tile-title {
flex: 0 1 auto;
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
@@ -351,33 +321,6 @@ body.menu-hidden #topbar {
.tile-btn.is-open,
.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 */
#tip {
position: fixed;
@@ -431,6 +374,55 @@ body.menu-hidden #topbar {
}
.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 {
flex: 1;
position: relative;
@@ -439,67 +431,80 @@ body.menu-hidden #topbar {
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-left {
position: absolute;
bottom: 0;
width: 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-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 */
.tile-resize-top,
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
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 {
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;
touch-action: none;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-resize-top {
top: 0;
left: 0;
right: 0;
/* 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-bottom::after {
content: "";
width: 44px;
max-width: 60%;
height: 0;
margin-bottom: 4px;
border-bottom: 2px solid currentColor;
}
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
/* left and right: width only, between the top strip and the bottom corners */
.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; }
.tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
/* ── seam divider between adjacent tiles ───────────────────────────── */
/* 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,
so it sits above their own edge grips. Like them it draws nothing at rest
— only while it is being dragged, to show which seam is moving. */
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
hovering reveals a small macOS-split-view-style grab pill. Dragging it
trades width between the two tiles. */
.tile-divider {
position: absolute;
width: 14px;
@@ -523,24 +528,22 @@ body.menu-hidden #topbar {
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); }
/* Drop the seams while a tile itself is being moved or resized — they're
rebuilt for the new geometry on release. */
/* Hide the pills while a tile itself is being moved or corner-resized —
they're rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */
/* 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-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; }
+9 -24
View File
@@ -1394,30 +1394,15 @@ function layoutPath(name) {
function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
return {
// 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,
y: Number(t?.y) || 0,
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 || ''),
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;
}),
tiles: tiles.map((t) => ({
id: String(t?.id || ''),
module: String(t?.module || ''),
x: Number(t?.x) || 0,
y: Number(t?.y) || 0,
w: Number(t?.w) || 1,
h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
})),
};
}