Companion Buttons 1.1.0: pages in the + picker, buttons chosen on the tile

The picker row (page list, Clear, Done) is gone. Each Companion page is now
its own + entry, titled with the page's name, showing the whole page minus
page up/down. The grid icon switches the tile to choosing: click buttons to
take them out or put them back, click the icon again when done. The gear
holds one switch, Page layout (Companion's rows and columns, or packed).

- Tiles follow their page by Companion's page id and buttons by row/column,
  so inserted pages and re-imported pages keep showing.
- A tile remounted after a settings change asks GET /state for the snapshot
  the already-open stream sent, instead of waiting blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:48:24 -04:00
co-authored by Claude Opus 5.5
parent 415453ef5f
commit 0a6cec88aa
8 changed files with 642 additions and 804 deletions
+241 -353
View File
@@ -1,37 +1,24 @@
/* Companion Buttons — the tile.
Shows the Companion buttons this tile was given, as Companion draws them,
as large as the tile allows. One button fills the tile; several share it in
whatever grid makes each one biggest. Every picture is live: the server
forwards each redraw (a status button turning red, a tally lighting up).
Shows one Companion page, or the buttons of it chosen for this tile, every
button as Companion draws it and as large as the tile allows. Every
picture is live: the server forwards each redraw (a status button turning
red, a tally lighting up).
Buttons are chosen in the tile itself — the grid icon in the title bar
opens a picker that shows any Companion page as it is laid out, and a tap
adds or removes a button. The choice is the tile's own (saved with the
layout), so two tiles can show two different sets. Each choice is kept as
the button's id and its page/row/column; the server follows whichever
still holds, and the tile saves back what it resolved to. */
The page comes from the + picker entry the tile was added as (or, for a
tile added without one, the gear's Page). The grid icon in the title bar
switches the tile to choosing: the whole page is shown, the buttons this
tile leaves out faded, and a click puts a button in or takes it out. The
icon again goes back. "Page layout" in the gear keeps Companion's own rows
and columns; off, the buttons are packed into whatever grid makes each
one biggest. */
const GRID_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg>';
const GAP_PX = 6;
const CAPTION_PX = 18;
const PAGES_RETRY_MS = 5000;
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/;
const MAX_BUTTONS = 64;
/** "bank:QOPCkIuUp5U9" → "QOPCkIuUp5U9": what the server's URLs carry. */
const urlId = (id) => id.slice(5);
/** A chosen button as the server's stream path names it: page.row.column.id */
const spotKey = (s) => `${s.page}.${s.row}.${s.column}.${urlId(s.id)}`;
function validSpot(s) {
return Boolean(s) && ID_RE.test(String(s.id))
&& Number.isInteger(s.page) && s.page >= 1 && s.page <= 9999
&& Number.isInteger(s.row) && Math.abs(s.row) <= 999
&& Number.isInteger(s.column) && Math.abs(s.column) <= 999;
}
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
const MAX_SPOTS = 64;
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -41,13 +28,11 @@ function el(tag, className, text) {
}
/** The column count that makes `n` square cells biggest inside w × h. */
function bestGrid(n, w, h, captionPx) {
function packedGrid(n, w, h) {
let best = { cols: 1, size: 0 };
for (let cols = 1; cols <= n; cols += 1) {
const rows = Math.ceil(n / cols);
const byWidth = (w - GAP_PX * (cols - 1)) / cols;
const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx;
const size = Math.floor(Math.min(byWidth, byHeight));
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
if (size > best.size) best = { cols, size };
}
return best;
@@ -55,56 +40,232 @@ function bestGrid(n, w, h, captionPx) {
export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {};
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []);
const save = (spots) => moduleApi.saveInstanceSettings({
buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })),
});
const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`;
const pageId = () => {
const p = String(prefs().page || '');
return PAGE_RE.test(p) ? p : '';
};
/** The chosen buttons as "row.column", or null for the whole page (never chosen). */
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.map(String).filter((k) => SPOT_RE.test(k)).slice(0, MAX_SPOTS) : null);
const keepLayout = () => choosing || prefs().layout !== false;
const data = new Map(); // spot key → the server's latest word on it
let stream = null; // one stream for every button on the tile
const pictures = new Map(); // spot key → { image, seen }
let page = null; // the server's latest `page` event
let stream = null;
let streamPath = '';
let lost = false;
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
let view = null;
let banner = null;
let pickerEl = null;
let pickerPage = 0;
let pickerPages = [];
let pickerTimer = null;
let pickerToken = 0;
let choosing = false;
let pickButton = null;
let wrap = null;
let banner = null;
let grid = null;
let note = null;
let cells = new Map(); // spot key → { face, img }
let observer = null;
let stopped = false;
/* ── live buttons ─────────────────────────────────────────────────── */
/* ── status ───────────────────────────────────────────────────────── */
function syncStream() {
const keys = chosen().map(spotKey);
const path = keys.length ? `/stream/${keys.join('/')}` : '';
function applyStatus() {
if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen');
if (lost) return offline('Lost the connection to ProdDash');
if (!page) return moduleApi.setStatus('connecting', 'Connecting');
if (page.status === 'error') return offline(page.message || 'Companion is not reachable');
setOffline(false);
if (page.status !== 'ok') return moduleApi.setStatus('connecting', page.message || 'Connecting to Companion');
if (!page.found) return moduleApi.setStatus('connecting', 'This page is no longer in Companion');
if (page.spots.some((s) => !pictures.get(s.key)?.seen)) return moduleApi.setStatus('connecting', 'Waiting for Companion');
const where = `${page.message} · page ${page.number}`;
return moduleApi.setStatus('ok', page.spots.length === 1 ? where : `${where} · ${page.spots.length} buttons`);
}
function offline(message) {
setOffline(true);
moduleApi.setStatus('error', message);
}
function setOffline(on) {
if (!banner || banner.hidden === !on) return;
banner.hidden = !on;
wrap.classList.toggle('is-offline', on);
layout(); // the banner takes room from the buttons
}
/* ── drawing ──────────────────────────────────────────────────────── */
function build() {
root.textContent = '';
wrap = el('div', 'cb-root');
banner = el('div', 'cb-banner', 'Companion offline');
banner.hidden = true;
grid = el('div', 'cb-grid');
note = el('div', 'cb-empty');
note.hidden = true;
wrap.append(banner, grid, note);
root.append(wrap);
}
function showNote(text) {
note.textContent = text;
note.hidden = !text;
grid.hidden = Boolean(text);
}
/** Lay out the cells for the spots in the latest `page` event. */
function drawPage() {
const spots = page ? page.spots : [];
wrap.classList.toggle('is-choosing', choosing);
if (!pageId()) return showNote('No page chosen');
if (!choosing && Array.isArray(chosen()) && !chosen().length) return showNote('No buttons chosen');
if (page && !page.found) return showNote(''); // the page is gone: draw nothing, the shell folds the tile away
if (page && page.status === 'ok' && !spots.length) return showNote('No buttons on this page');
showNote('');
const keys = new Set(spots.map((s) => s.key));
for (const [key, c] of cells) {
if (!keys.has(key)) {
c.face.remove();
cells.delete(key);
}
}
const picked = chosen();
for (const s of spots) {
let c = cells.get(s.key);
if (!c) {
const face = el('div', 'cb-face is-waiting');
const img = el('img', 'cb-img');
img.alt = '';
img.draggable = false;
img.hidden = true;
face.append(img);
face.addEventListener('click', () => toggle(s.key));
c = { face, img };
cells.set(s.key, c);
}
c.face.title = `${page.name} · ${page.number}/${s.row}/${s.column}`;
c.face.classList.toggle('is-off', choosing && picked !== null && !picked.includes(s.key));
grid.append(c.face); // re-appending keeps them in the server's order
paint(s.key);
}
layout();
}
function paint(key) {
const c = cells.get(key);
if (!c) return;
const p = pictures.get(key);
if (p && p.image) {
if (c.img.getAttribute('src') !== p.image) c.img.src = p.image;
c.img.hidden = false;
} else {
c.img.hidden = true;
}
c.face.classList.toggle('is-waiting', !p || !p.seen);
c.face.classList.toggle('is-empty', Boolean(p && p.seen && !p.image));
}
function layout() {
if (!grid || !page || !cells.size) return;
const spots = page.spots.filter((s) => cells.has(s.key));
const w = grid.clientWidth;
const h = grid.clientHeight;
if (keepLayout()) {
// Companion's own rows and columns, cropped to the buttons shown.
const minRow = Math.min(...spots.map((s) => s.row));
const minCol = Math.min(...spots.map((s) => s.column));
const rows = Math.max(...spots.map((s) => s.row)) - minRow + 1;
const cols = Math.max(...spots.map((s) => s.column)) - minCol + 1;
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
grid.style.setProperty('--cb-cols', String(cols));
grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`);
for (const s of spots) {
const face = cells.get(s.key).face;
face.style.gridRow = String(s.row - minRow + 1);
face.style.gridColumn = String(s.column - minCol + 1);
}
} else {
const { cols, size } = packedGrid(spots.length, w, h);
grid.style.setProperty('--cb-cols', String(cols));
grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`);
for (const s of spots) {
const face = cells.get(s.key).face;
face.style.gridRow = '';
face.style.gridColumn = '';
}
}
}
/* ── choosing ─────────────────────────────────────────────────────── */
function setChoosing(on) {
choosing = on;
pickButton?.classList.toggle('active', on);
connect();
drawPage();
}
/** Put a button in or take it out. Every button in again means the whole page, new buttons included. */
function toggle(key) {
if (!choosing || !page) return;
const all = page.spots.map((s) => s.key);
const now = chosen() || all;
const next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key];
const whole = all.every((k) => next.includes(k));
moduleApi.saveInstanceSettings({ buttons: whole ? null : next.slice(0, MAX_SPOTS) });
drawPage();
}
/* ── the stream ───────────────────────────────────────────────────── */
function apply(kind, body) {
if (kind === 'page') {
page = body;
lost = false;
drawPage();
} else {
pictures.set(body.key, { image: body.image || null, seen: Boolean(body.seen) });
paint(body.key);
}
applyStatus();
}
/** Open the stream for what the tile shows now: the whole page while choosing, else the chosen buttons. */
function connect() {
const id = pageId();
const keys = choosing ? null : chosen();
const path = !id || (keys && !keys.length) ? '' : `${id}${keys ? `/${keys.join('/')}` : ''}`;
if (path === streamPath) return;
if (stream) stream.close();
stream = null;
streamPath = path;
lost = false;
for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key);
if (!path) return;
stream = moduleApi.sse(path, {
stream = moduleApi.sse(`/stream/${path}`, {
// The page's connection may already carry this stream (another tile, or
// this one before a remount): ask for what it sent on connect.
async open() {
try {
const res = await moduleApi.fetch(`/state/${path}`);
if (!res.ok || path !== streamPath) return;
const snap = await res.json();
if (path !== streamPath) return;
apply('page', snap.page);
for (const b of snap.buttons) apply('button', b);
} catch {
/* the stream itself will catch up */
}
},
events: {
button(e) {
let msg;
page(e) {
try {
msg = JSON.parse(e.data);
apply('page', JSON.parse(e.data));
} catch {
return;
/* not JSON */
}
},
button(e) {
try {
apply('button', JSON.parse(e.data));
} catch {
/* not JSON */
}
const index = chosen().findIndex((s) => spotKey(s) === msg.key);
if (index < 0) return;
data.set(msg.key, msg);
lost = false;
paintCell(index);
applyStatus();
adopt(index, msg);
},
},
error() {
@@ -114,307 +275,34 @@ export default function create({ root, moduleApi }) {
});
}
/** Save what the server resolved a button to — a new id after a re-import, a new place after a move. */
function adopt(index, msg) {
const where = msg.location;
if (!msg.controlId || !where) return;
const spots = chosen();
const s = spots[index];
if (s.id === msg.controlId && s.page === where.page && s.row === where.row && s.column === where.column) return;
if (spots.some((o, i) => i !== index && o.id === msg.controlId)) return; // already shown here
spots[index] = { id: msg.controlId, page: where.page, row: where.row, column: where.column };
data.set(spotKey(spots[index]), msg); // keep the picture up while the stream is re-made
save(spots);
syncStream();
}
function applyStatus() {
const spots = chosen();
if (pickerEl && !spots.length) return; // the picker reports while it is the whole tile
if (!spots.length) return moduleApi.setStatus('ok', 'No buttons chosen');
if (lost) return showOffline('Lost the connection to ProdDash');
const list = spots.map((s) => data.get(spotKey(s)));
const bad = list.find((d) => d && d.status === 'error');
if (bad) return showOffline(bad.message || 'Companion is not reachable');
setBanner('');
if (list.some((d) => !d || d.status !== 'ok' || !d.seen)) {
const waiting = list.find((d) => d && d.status !== 'ok');
return moduleApi.setStatus('connecting', waiting ? waiting.message : 'Waiting for Companion');
}
const missing = list.filter((d) => !d.image).length;
if (missing) {
return moduleApi.setStatus('connecting', `${missing} of ${list.length} button${list.length === 1 ? ' is' : 's are'} no longer in Companion`);
}
const msg = list[0].message || 'Companion';
return moduleApi.setStatus('ok', list.length > 1 ? `${msg} · ${list.length} buttons` : msg);
}
function showOffline(message) {
setBanner('Companion offline');
moduleApi.setStatus('error', message);
}
function setBanner(text) {
if (!banner || banner.textContent === text) return;
banner.hidden = !text;
banner.textContent = text;
if (view) view.classList.toggle('is-offline', Boolean(text));
layout(); // the banner takes room from the buttons
}
/* ── drawing the chosen buttons ───────────────────────────────────── */
function buildView() {
root.textContent = '';
cells = [];
const spots = chosen();
const wrap = el('div', 'cb-root');
banner = el('div', 'cb-banner');
banner.hidden = true;
wrap.append(banner);
if (!spots.length) {
view = null;
const empty = el('div', 'cb-empty');
empty.append(el('div', 'cb-empty-line', 'No buttons chosen'));
const choose = el('button', 'cb-action', 'Choose buttons');
choose.type = 'button';
choose.addEventListener('click', openPicker);
empty.append(choose);
wrap.append(empty);
root.append(wrap);
return;
}
view = el('div', 'cb-grid');
view.classList.toggle('has-captions', prefs().captions === true);
spots.forEach((s, index) => {
const cell = el('div', 'cb-cell');
const face = el('div', 'cb-face is-waiting');
const img = el('img', 'cb-img');
img.alt = '';
img.draggable = false;
img.hidden = true;
const note = el('div', 'cb-note');
face.append(img, note);
const caption = el('div', 'cb-caption');
cell.append(face, caption);
view.append(cell);
cells.push({ cell, face, img, note, caption });
paintCell(index);
});
wrap.append(view);
root.append(wrap);
layout();
}
function paintCell(index) {
const c = cells[index];
const s = chosen()[index];
if (!c || !s) return;
const d = data.get(spotKey(s));
const where = d && d.location;
const image = d && d.image;
if (image) {
if (c.img.getAttribute('src') !== image) c.img.src = image;
c.img.hidden = false;
c.note.textContent = '';
} else {
c.img.hidden = true;
c.note.textContent = d && d.seen ? 'Not in Companion' : '';
}
c.face.classList.toggle('is-waiting', !d || !d.seen);
c.face.classList.toggle('is-missing', Boolean(d && d.seen && !image));
c.caption.textContent = where ? where.pageName : '';
const title = where ? `${where.pageName} · ${where.page}/${where.row}/${where.column}` : '';
c.cell.title = title;
c.img.alt = title;
}
function layout() {
if (!view || !cells.length) return;
const captionPx = prefs().captions === true ? CAPTION_PX : 0;
const { cols, size } = bestGrid(cells.length, view.clientWidth, view.clientHeight, captionPx);
view.style.setProperty('--cb-cols', String(cols));
view.style.setProperty('--cb-size', `${Math.max(16, size)}px`);
}
/* ── the picker ───────────────────────────────────────────────────── */
function openPicker() {
if (pickerEl) return;
pickButton?.classList.add('active');
pickerEl = el('div', 'cb-picker');
const head = el('div', 'cb-pick-head');
const select = el('select', 'cb-pick-page');
select.setAttribute('aria-label', 'Page');
const clear = el('button', 'cb-pick-btn', 'Clear');
clear.type = 'button';
const done = el('button', 'cb-pick-btn cb-pick-done', 'Done');
done.type = 'button';
head.append(select, clear, done);
const body = el('div', 'cb-pick-body');
pickerEl.append(head, body);
root.textContent = '';
root.append(pickerEl);
select.addEventListener('change', () => {
pickerPage = Number(select.value) || 0;
drawPage();
});
clear.addEventListener('click', () => {
save([]);
markPicked();
});
done.addEventListener('click', closePicker);
loadPages();
}
function closePicker() {
if (!pickerEl) return;
clearTimeout(pickerTimer);
pickerToken += 1;
pickerEl = null;
pickButton?.classList.remove('active');
syncStream();
buildView();
applyStatus();
}
async function loadPages() {
const token = ++pickerToken;
clearTimeout(pickerTimer);
let reply = null;
try {
const res = await moduleApi.fetch('/pages');
reply = await res.json();
if (!res.ok) throw new Error(reply.error || `ProdDash answered ${res.status}`);
} catch (err) {
reply = { status: 'error', message: err.message || 'ProdDash is not answering', pages: [] };
}
if (stopped || !pickerEl || token !== pickerToken) return;
pickerPages = Array.isArray(reply.pages) ? reply.pages : [];
if (!chosen().length) {
moduleApi.setStatus(reply.status === 'ok' ? 'ok' : reply.status === 'connecting' ? 'connecting' : 'error', reply.message || '');
}
if (!pickerPages.length) {
const body = pickerEl.querySelector('.cb-pick-body');
body.textContent = '';
body.append(el('div', 'cb-pick-note', reply.status === 'ok' ? 'Companion has no pages' : reply.message || 'Companion is not reachable'));
pickerTimer = setTimeout(loadPages, PAGES_RETRY_MS);
return;
}
if (!pickerPages.some((p) => p.number === pickerPage)) pickerPage = startPage();
const select = pickerEl.querySelector('.cb-pick-page');
select.textContent = '';
for (const p of pickerPages) {
const opt = el('option', '', p.name);
opt.value = String(p.number);
select.append(opt);
}
select.value = String(pickerPage);
drawPage();
}
/** The page of the first chosen button, else the first page that has buttons. */
function startPage() {
const first = chosen()[0];
if (first && pickerPages.some((p) => p.number === first.page)) return first.page;
return (pickerPages.find((p) => p.buttons.length) || pickerPages[0]).number;
}
function drawPage() {
if (!pickerEl) return;
const body = pickerEl.querySelector('.cb-pick-body');
body.textContent = '';
const page = pickerPages.find((p) => p.number === pickerPage);
if (!page || !page.buttons.length) {
body.append(el('div', 'cb-pick-note', 'No buttons on this page'));
return;
}
// Companion's grid may start below zero; lay the page out from its own corner.
const rows = page.buttons.map((b) => b.row);
const cols = page.buttons.map((b) => b.column);
const minRow = Math.min(0, ...rows);
const minCol = Math.min(0, ...cols);
const grid = el('div', 'cb-pick-grid');
grid.style.setProperty('--cb-pick-cols', String(Math.max(...cols) - minCol + 1));
for (const b of page.buttons) {
const key = el('button', 'cb-pick-key');
key.type = 'button';
key.dataset.id = b.controlId;
key.style.gridRow = String(b.row - minRow + 1);
key.style.gridColumn = String(b.column - minCol + 1);
key.title = `${page.name} · ${page.number}/${b.row}/${b.column}`;
const img = el('img', 'cb-pick-img');
img.alt = key.title;
img.loading = 'lazy';
img.draggable = false;
img.src = imageUrl(b.controlId);
img.addEventListener('error', () => img.classList.add('is-broken'));
key.append(img, el('span', 'cb-pick-badge'));
key.addEventListener('click', () => toggle({ id: b.controlId, page: page.number, row: b.row, column: b.column }));
grid.append(key);
}
body.append(grid);
markPicked();
}
function toggle(spot) {
const spots = chosen();
const at = spots.findIndex((s) => s.id === spot.id);
if (at >= 0) spots.splice(at, 1);
else if (spots.length < MAX_BUTTONS) spots.push(spot);
save(spots);
markPicked();
}
function markPicked() {
if (!pickerEl) return;
const ids = chosen().map((s) => s.id);
for (const key of pickerEl.querySelectorAll('.cb-pick-key')) {
const at = ids.indexOf(key.dataset.id);
key.classList.toggle('is-picked', at >= 0);
key.querySelector('.cb-pick-badge').textContent = at >= 0 ? String(at + 1) : '';
}
}
/* ── lifecycle ────────────────────────────────────────────────────── */
return {
start() {
stopped = false;
moduleApi.setStatus('connecting', 'Connecting');
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => (pickerEl ? closePicker() : openPicker()),
});
observer = new ResizeObserver(() => layout());
observer.observe(root);
syncStream();
if (chosen().length) {
buildView();
applyStatus();
} else {
openPicker();
build();
if (pageId()) {
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => setChoosing(!choosing),
});
}
observer = new ResizeObserver(() => layout());
observer.observe(grid);
drawPage();
applyStatus();
connect();
},
stop() {
stopped = true;
clearTimeout(pickerTimer);
pickerToken += 1;
if (stream) stream.close();
stream = null;
streamPath = '';
data.clear();
if (observer) observer.disconnect();
observer = null;
pickerEl = null;
view = null;
cells = [];
pictures.clear();
cells = new Map();
page = null;
root.textContent = '';
},
};