Shell: tile settings in a window beside the tile; schemas per tile kind

The gear popover lived inside its tile, so a tile could be too small for its
own settings, and the settings sat on top of the thing they change. It is now
a small window: placed beside the tile (right when there is room, else left,
else over it), clamped to the viewport, moved by a grab bar, closed by
clicking away, Escape, the ✕ or the gear. Settings still apply as they
change — that is the point of putting the window beside the tile.

With it, four pieces of contract the module work needs:

- A picker entry may carry its own `instanceSchema` / `instanceGroups`:
  tiles added from it get settings that fit that tile, nothing else. A tile
  showing one ProPresenter timer has no use for a clock's format switches.
- `instanceGroups[name].toggle` puts a group's on/off switch in its title row
  and dims the body while it is off — the admin page's convention, in the
  popover.
- `select` and `multiselect` in tile settings may take `optionsRoute`;
  `multiselect` also `columns` and `allByDefault` (nothing saved until
  someone flips a switch, so options that turn up later are on too).
- `template`: a text with placeholders built by dragging or clicking the
  variables a module offers into it.

And the + picker's descriptions are tooltips now — the list stays a list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-14 15:20:38 -04:00
co-authored by Claude Fable 5.1
parent b9e895d324
commit 7a44b25d4b
4 changed files with 419 additions and 63 deletions
+27 -5
View File
@@ -104,7 +104,7 @@ the app); its settings are kept for a later reinstall.
Both schemas map field names to specs:
```
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "color" | "password" | "endpoint",
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
"label": "Shown next to the field",
"default": <value>,
"group": "Section heading", // optional; see below
@@ -128,7 +128,17 @@ default; the tile applies it as a CSS custom property — see `pco-plan`).
`multiselect` is several picks from one list — the same `options` /
`optionsRoute` as `select`, rendered as one switch per option and stored as
an array of values. A saved value the list no longer offers stays checked and
marked, so nothing is dropped behind the admin's back (admin config only).
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
chosen shows every option on and saves nothing until someone flips one, so
options that turn up later are on as well (give it no `default` then).
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
`template` (tile settings) is a text with placeholders, built by dragging or
clicking the variables the module offers into it: `"variables": [ { "value":
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
chips carry the names.
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
its newlines; one item per line is the convention (the `slack` module's quick
@@ -171,9 +181,17 @@ explanation there.
`group` and `help` work on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
Display choices belong there, per tile; admin config is for connections.
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
a module with a long show/hide checklist keeps the popover usable — see
`pco-plan`. Display choices belong there, per tile; admin config is for
connections. The popover itself is a small window beside the tile (moved by
its bar, closed by clicking away) so a tile never has to be big enough to
hold its own settings, and a change can be watched as it lands.
A picker entry (see **Presenting multiple tiles**) may carry its own
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
instead of the module's. A tile that shows one ProPresenter timer has no use
for a clock's format switches — give it a schema of what applies.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
`<key>` is on — e.g. the audio device and channel appear only once the LTC
@@ -490,6 +508,10 @@ module.exports = {
keep it fast (answer from state you already hold; don't fetch upstream on
demand). A throw or a hang (>2 s) falls back to the classic single entry,
so a dead upstream can't break the picker.
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
tiles added from it — settings that fit that tile, nothing else.
- Entry `description`s are tooltips in the + picker now, not visible lines —
one short sentence.
- `single: true` on an entry allows at most one such tile per dashboard: the
picker greys the entry out once it is placed. Use it for a control surface
that would only confuse in duplicate — a send box, a master switch.
+288 -42
View File
@@ -108,10 +108,11 @@ function wireDropdown(btnId, menuId, rebuild) {
return menu;
}
function menuItem(label, onClick, { sub = '', danger = false, disabled = false } = {}) {
function menuItem(label, onClick, { sub = '', danger = false, disabled = false, title = '' } = {}) {
const item = document.createElement('button');
item.className = 'menu-item' + (danger ? ' danger' : '');
item.disabled = disabled;
if (title) item.title = title;
const span = document.createElement('span');
span.textContent = label;
if (sub) {
@@ -277,7 +278,7 @@ function buildTile(tile) {
head.append(dot, title, controls);
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
const hasSettings = man && Object.keys(tileSchema(tile, man).schema).length;
if (hasSettings) {
const gear = document.createElement('button');
gear.className = 'tile-btn';
@@ -718,7 +719,8 @@ function addTile(moduleId, entry = null) {
};
const spot = findSpot(size.w, size.h);
const settings = {};
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
for (const [key, spec] of Object.entries(addSchema)) {
if (spec && 'default' in spec) settings[key] = spec.default;
}
Object.assign(settings, entry?.settings || {});
@@ -895,7 +897,7 @@ function buildModuleApi(tile, man, entry) {
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
get instanceSettings() {
const out = {};
for (const [key, spec] of Object.entries(man.instanceSchema || {})) {
for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) {
if (spec && 'default' in spec) out[key] = spec.default;
}
return Object.assign(out, tile.settings || {});
@@ -1087,6 +1089,20 @@ function colorHex(value, fallback) {
return fallback;
}
/** The settings a tile offers: its picker entry's own schema when it has one
— a solo timer wants none of a full tile's switches — else the module's.
Same for the groups that arrange them. */
function tileSchema(tile, man) {
const own = (v) => (v && typeof v === 'object' ? v : null);
const entry = tile?.variant && Array.isArray(man?.tiles)
? man.tiles.find((e) => String(e.id) === String(tile.variant))
: null;
return {
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
};
}
function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.();
@@ -1094,21 +1110,76 @@ function closeSettingsPopovers() {
});
}
/* ── the per-tile settings window ───────────────────────────────────── */
/* It opens beside its tile — to the right when there is room, else the
left, else over it — and always inside the viewport. A window rather than
something inside the tile, because a tile can be smaller than its own
settings, and because the point of live settings is watching the tile
change while you change them: the window must not sit on top of it. */
function placePopover(pop, anchor) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pad = 8;
const w = pop.offsetWidth;
const h = pop.offsetHeight;
let left = anchor.right + pad;
if (left + w > vw - pad) left = anchor.left - w - pad;
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
let top = anchor.top;
if (top + h > vh - pad) top = vh - h - pad;
if (top < pad) top = pad;
pop.style.left = Math.round(left) + 'px';
pop.style.top = Math.round(top) + 'px';
}
function clampPopover(pop) {
const pad = 8;
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
}
function toggleSettingsPopover(tile, el) {
const existing = el.querySelector('.tile-settings');
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
closeSettingsPopovers();
if (existing) return;
const man = registry.get(tile.module);
if (!man || !man.instanceSchema) return;
const { schema, groups: groupMeta } = tileSchema(tile, man);
if (!man || !Object.keys(schema).length) return;
const pop = document.createElement('div');
pop.className = 'tile-settings';
pop.dataset.tileId = tile.id;
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
// A bar to move it by, and the one control every window has.
const bar = document.createElement('div');
bar.className = 'tile-settings-bar';
const barTitle = document.createElement('span');
barTitle.className = 'tile-settings-title';
barTitle.textContent = tile.title || man.name;
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'tile-settings-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close settings');
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', () => closeSettingsPopovers());
bar.append(barTitle, closeBtn);
const body = document.createElement('div');
body.className = 'tile-settings-body';
pop.append(bar, body);
const inputs = new Map();
/* These are display choices — a text size, a filter, a colour — so they
apply as they are changed and save with the layout. There is nothing to
confirm, so there is no Apply. Typing is debounced because applying
means remounting the module, which shouldn't happen per keystroke. */
/* Display choices apply as they change and save with the layout — nothing
to confirm, so no Apply. Typing is debounced because applying means
remounting the module, which shouldn't happen per keystroke. */
const same = (a, b) => a === b
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
let applyTimer = null;
const commit = (delay) => {
clearTimeout(applyTimer);
@@ -1116,11 +1187,11 @@ function toggleSettingsPopover(tile, el) {
applyTimer = null;
// Only when something really moved: a text field fires `input` and then
// `change` on the way out, and a module shouldn't be torn down twice
// over for one edit.
// over for one edit. A reader answering undefined has nothing to say.
let changed = false;
for (const [key, read] of inputs) {
const value = read();
if (tile.settings[key] === value) continue;
if (value === undefined || same(tile.settings[key], value)) continue;
tile.settings[key] = value;
changed = true;
}
@@ -1132,19 +1203,31 @@ function toggleSettingsPopover(tile, el) {
const live = (input, event = 'change', delay = 0) => {
input.addEventListener(event, () => commit(delay));
};
/** Choices a module only knows at runtime, from one of its own routes. */
const fetchOptions = async (route) => {
try {
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
const data = res.ok ? await res.json() : null;
return Array.isArray(data?.options) ? data.options : null;
} catch {
return null;
}
};
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
// Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the manifest's
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
// the same conventions as the admin page's config form.
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
// subsection that consecutive same-group fields share, and the groups map
// adds { collapsed, columns, help, toggle } — the same conventions as the
// admin page's config form.
let groupName = null;
let container = pop;
let container = body;
let current = null; // the open group: { el, title, toggleKey }
for (const [key, spec] of Object.entries(man.instanceSchema)) {
for (const [key, spec] of Object.entries(schema)) {
const type = spec?.type || 'string';
const label = spec?.label || key;
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const g = spec?.group || '';
if (g !== groupName) {
@@ -1157,33 +1240,41 @@ function toggleSettingsPopover(tile, el) {
if (folds) group.open = !meta.collapsed;
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
title.textContent = g;
const titleText = document.createElement('span');
titleText.className = 'field-group-name';
titleText.textContent = g;
title.appendChild(titleText);
group.appendChild(title);
const body = document.createElement('div');
body.className = 'field-group-body';
const groupBody = document.createElement('div');
groupBody.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) {
body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols));
groupBody.classList.add('is-columns');
groupBody.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
body.appendChild(help);
groupBody.appendChild(help);
}
group.appendChild(body);
pop.appendChild(group);
group.appendChild(groupBody);
body.appendChild(group);
pop.classList.add('has-groups');
container = body;
container = groupBody;
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
} else {
container = pop;
container = body;
current = null;
}
}
const isSwitch = type === 'boolean' || type === 'switch';
const field = document.createElement('label');
// A multiselect holds several controls, so it can't be a <label>: a click
// anywhere in one would flip the first control.
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
field.className = 'field' + (isSwitch ? ' check' : '');
let switchInput = null;
if (isSwitch) {
// A switch: on is shown/enabled, off is hidden/disabled — the label
@@ -1196,27 +1287,104 @@ function toggleSettingsPopover(tile, el) {
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(current);
input.checked = Boolean(value);
input.setAttribute('aria-label', label);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
field.append(text, toggle);
inputs.set(key, () => input.checked);
live(input);
} else if (type === 'select' && Array.isArray(spec.options)) {
switchInput = input;
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
const span = document.createElement('span');
span.textContent = label;
const select = document.createElement('select');
for (const opt of spec.options) {
const saved = value === undefined || value === null ? '' : String(value);
const fill = (options) => {
const wanted = select.options.length ? select.value : saved;
select.innerHTML = '';
// What this tile saved stays choosable even when the live list has
// moved on (a source that is off right now, say).
if (saved && !options.some((o) => optValue(o) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
for (const opt of options) {
const o = document.createElement('option');
o.value = String(typeof opt === 'object' ? opt.value : opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
o.value = optValue(opt);
o.textContent = optLabel(opt);
select.appendChild(o);
}
select.value = String(current ?? '');
select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, select);
inputs.set(key, () => select.value);
live(select);
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from a list, one switch each, stored as an array. With
// `allByDefault`, a tile that has never chosen shows every option on
// and saves nothing until someone flips one — so options that turn up
// later are on as well, until there is an explicit list.
field.classList.add('multiselect-field');
const span = document.createElement('span');
span.className = 'field-label';
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
if (cols > 1) {
list.classList.add('is-columns');
list.style.setProperty('--cols', String(cols));
}
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
let touched = false;
const chosen = new Set(saved || []);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((o) => optValue(o) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('label');
row.className = 'multiselect-row';
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = optLabel(opt);
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
if (input.checked) chosen.add(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
row.append(text, toggle);
input.addEventListener('change', () => {
touched = true;
commit(0);
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, list);
inputs.set(key, () => (saved === null && !touched
? undefined
: [...list.querySelectorAll('input:checked')].map((input) => input.value)));
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
@@ -1224,16 +1392,54 @@ function toggleSettingsPopover(tile, el) {
span.textContent = label;
const input = document.createElement('input');
input.type = 'color';
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
field.append(span, input);
inputs.set(key, () => input.value);
live(input, 'input', 150); // dragging the picker fires continuously
} else if (type === 'template') {
// Text with placeholders, built by dragging (or clicking) the variables
// the module offers into it — nobody should have to remember the names.
field.classList.add('template-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'text';
input.spellcheck = false;
input.value = value === undefined || value === null ? '' : String(value);
const vars = document.createElement('div');
vars.className = 'template-vars';
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'template-var';
chip.draggable = true;
const token = optValue(v);
chip.textContent = optLabel(v);
chip.title = `Drag or click to insert ${token}`;
chip.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', token);
e.dataTransfer.effectAllowed = 'copy';
});
chip.addEventListener('click', (e) => {
e.preventDefault();
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.setRangeText(token, start, end, 'end');
input.focus();
commit(0);
});
vars.appendChild(chip);
}
field.append(span, input, vars);
inputs.set(key, () => input.value);
live(input, 'input', 400);
live(input, 'change');
} else {
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
input.value = current === undefined || current === null ? '' : String(current);
input.value = value === undefined || value === null ? '' : String(value);
field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
live(input, 'input', 400); // mid-word is not the moment to remount
@@ -1245,23 +1451,62 @@ function toggleSettingsPopover(tile, el) {
help.textContent = spec.help;
field.appendChild(help);
}
if (current && switchInput && key === current.toggleKey) {
// The group's on/off lives in its title row, never in the body, and a
// click on it must not fold a <details>.
const grp = current;
field.classList.add('group-toggle');
field.addEventListener('click', (e) => e.stopPropagation());
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
switchInput.addEventListener('change', sync);
sync();
grp.title.appendChild(field);
} else {
container.appendChild(field);
}
}
// Nothing to press: click away, press Escape, or click the gear again.
// Nothing to press: click away, press Escape, the ✕, or the gear again.
// (The gear's own click stops propagating, so it toggles rather than
// closing and reopening.)
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
const keepInView = () => clampPopover(pop);
pop._cleanup = () => {
document.removeEventListener('click', closeOnClick);
document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', keepInView);
};
document.addEventListener('click', closeOnClick);
document.addEventListener('keydown', closeOnEscape);
window.addEventListener('resize', keepInView);
// Moved by its bar.
bar.addEventListener('pointerdown', (e) => {
if (e.target === closeBtn || (e.button !== 0 && e.pointerType === 'mouse')) return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const dx = e.clientX - pop.offsetLeft;
const dy = e.clientY - pop.offsetTop;
const onMove = (ev) => {
pop.style.left = (ev.clientX - dx) + 'px';
pop.style.top = (ev.clientY - dy) + 'px';
clampPopover(pop);
};
const onUp = () => {
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
});
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
el.appendChild(pop);
document.body.appendChild(pop);
placePopover(pop, el.getBoundingClientRect());
}
/** Tear a tile's module down and start it again (settings changed). */
@@ -1324,16 +1569,17 @@ function renderAddMenu(menu) {
// An entry that allows one tile at most is offered greyed out once
// that tile exists — a second Send box would only confuse.
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
// Descriptions ride along as tooltips: the list stays a list.
menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true;
addTile(man.id, entry);
}, { sub: placed ? 'Already on this dashboard' : (entry.description || ''), disabled: placed }));
}, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
}
} else {
menu.appendChild(menuItem(man.name, () => {
menu.hidden = true;
addTile(man.id);
}, { sub: man.description || '' }));
}, { title: man.description || '' }));
}
}
}
+95 -12
View File
@@ -534,26 +534,109 @@ body.fullscreen #grid { padding-bottom: 0; }
/* ── per-tile settings popover ─────────────────────────────────────── */
/* A small window beside its tile (shell.js places and clamps it), not a
box inside the tile: a tile can be smaller than its own settings, and the
whole point of live settings is watching the tile change. */
.tile-settings {
position: absolute;
top: 36px;
right: 6px;
z-index: 45;
min-width: 220px;
max-width: calc(100% - 12px);
max-height: calc(100% - 40px);
overflow-y: auto;
position: fixed;
z-index: 80;
display: flex;
flex-direction: column;
min-width: 260px;
max-width: min(560px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
}
.tile-settings-bar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px 6px 12px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid var(--border);
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.tile-settings-bar:active { cursor: grabbing; }
.tile-settings-title {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-settings-close {
flex: none;
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
}
.tile-settings-close:hover { background: rgba(255, 255, 255, 0.08); color: var(--text); }
.tile-settings-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
/* a group's on/off sits in its title row; the body dims while it is off */
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
.tile-settings .field-group-name { flex: 1; min-width: 0; }
.tile-settings .group-toggle { margin: 0 0 0 auto; }
.tile-settings .group-toggle .check-label { display: none; }
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
/* several picks from a list (type `multiselect`) */
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
.tile-settings .multiselect.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
}
.tile-settings .multiselect-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
/* a text built from offered variables (type `template`) */
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
.tile-settings .template-var {
font: inherit;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: grab;
}
.tile-settings .template-var:hover { border-color: var(--accent); }
.tile-settings .template-var:active { cursor: grabbing; }
/* A popover with grouped settings needs room for its columns; it still
never grows past the tile (max-width above). */
.tile-settings.has-groups {
min-width: min(540px, calc(100% - 12px));
min-width: min(540px, calc(100vw - 16px));
container-type: inline-size;
}
.tile-settings .field-group {
@@ -593,7 +676,7 @@ body.fullscreen #grid { padding-bottom: 0; }
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
/* Settings apply as they change, so nothing sits under the last field — its
bottom margin would only pad the popover's own padding a second time. */
.tile-settings > :last-child { margin-bottom: 0; }
.tile-settings-body > :last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
+5
View File
@@ -466,6 +466,11 @@ function sanitizeTileEntry(raw) {
// at most one tile of this entry per dashboard (the picker greys it out)
single: raw.single === true,
};
// An entry may offer its own gear settings — a solo timer wants none of a
// full tile's switches. Same shape as the manifest's instanceSchema/Groups.
for (const key of ['instanceSchema', 'instanceGroups']) {
if (raw[key] && typeof raw[key] === 'object') out[key] = raw[key];
}
for (const key of ['minSize', 'defaultSize']) {
const size = raw[key];
if (size && Number(size.w) > 0 && Number(size.h) > 0) {