Module contract: modules can present multiple tiles to the picker

A module may now present a list of tiles instead of a single picker
entry. A server module exports tiles({config, log}) — sync or async,
called on every picker load so the list can be live data (discovered
ProPresenter timers, admin-configured pages); a client-only module can
declare a static tiles array in module.json. The + picker groups the
entries under the module's name; adding one presets that entry's
instance settings, gives the tile the entry's name as its title, and
honors per-entry default/min sizes, all persisted with the layout.
moduleApi.variant tells the instance which entry it was added as. A
throwing or hung tiles() (2 s deadline) falls back to the classic single
entry so a dead upstream can never break the picker; modules without
tiles are byte-for-byte unaffected. Documented in docs/MODULE-GUIDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-31 17:53:06 +00:00
parent 404b840154
commit 6211538b34
3 changed files with 159 additions and 15 deletions
+51 -12
View File
@@ -142,7 +142,7 @@ function loadLayoutFromStorage() {
function sanitizeTile(t) {
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
return {
const tile = {
id: String(t.id || newId()),
module: String(t.module || ''),
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
@@ -151,7 +151,14 @@ function sanitizeTile(t) {
h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
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) : '',
};
if (t.minSize && Number(t.minSize.w) > 0 && Number(t.minSize.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
return tile;
}
function newId() {
@@ -200,10 +207,10 @@ function applyRect(tile) {
}
function minSizeOf(tile) {
const man = registry.get(tile.module);
const min = tile.minSize || registry.get(tile.module)?.minSize;
return {
w: Math.max(1, man?.minSize?.w || 1),
h: Math.max(1, man?.minSize?.h || 1),
w: Math.max(1, min?.w || 1),
h: Math.max(1, min?.h || 1),
};
}
@@ -228,7 +235,7 @@ function buildTile(tile) {
const title = document.createElement('span');
title.className = 'tile-title';
title.textContent = man ? man.name : tile.module + ' (not installed)';
title.textContent = man ? (tile.title || man.name) : tile.module + ' (not installed)';
// where moduleApi.header puts a module's own buttons and menus
const controls = document.createElement('span');
@@ -492,18 +499,30 @@ function buildDivider(L, R, top, bottom) {
/* ── add / remove tiles ─────────────────────────────────────────────── */
function addTile(moduleId) {
/**
* Add a tile. `entry` is one item of a multi-tile module's tile list
* (picker variants): its preset settings overlay the schema defaults, and
* its name/sizes stick to the tile.
*/
function addTile(moduleId, entry = null) {
const man = registry.get(moduleId);
const defaultSize = entry?.defaultSize || man?.defaultSize;
const size = {
w: Math.max(1, Math.min(COLS, man?.defaultSize?.w || 4)),
h: Math.max(1, man?.defaultSize?.h || 3),
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
h: Math.max(1, defaultSize?.h || 3),
};
const spot = findSpot(size.w, size.h);
const settings = {};
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
if (spec && 'default' in spec) settings[key] = spec.default;
}
Object.assign(settings, entry?.settings || {});
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
if (entry) {
tile.variant = String(entry.id);
tile.title = String(entry.name || '');
if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h };
}
tiles.push(tile);
buildTile(tile);
refreshEmptyState();
@@ -579,6 +598,10 @@ function buildModuleApi(tile, man, entry) {
id: man.id,
instanceId: tile.id,
/** For multi-tile modules: which entry of the module's tile list this
tile was added as ('' when added as the plain module). */
variant: tile.variant || '',
/** Admin (server-wide) config, read-only. Password fields never reach
the client. Live: reflects the latest registry after admin saves. */
get config() {
@@ -908,10 +931,26 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
return;
}
for (const man of manifests) {
menu.appendChild(menuItem(man.name, () => {
menu.hidden = true;
addTile(man.id);
}, { sub: man.description || '' }));
const entries = Array.isArray(man.tiles) ? man.tiles : [];
if (entries.length) {
// A multi-tile module: its name becomes a group heading and each of
// its tiles is added individually.
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = man.name;
menu.appendChild(title);
for (const entry of entries) {
menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true;
addTile(man.id, entry);
}, { sub: entry.description || '' }));
}
} else {
menu.appendChild(menuItem(man.name, () => {
menu.hidden = true;
addTile(man.id);
}, { sub: man.description || '' }));
}
}
});