Shell: grouped, collapsible, multi-column per-tile settings popover

The gear popover learns the admin form's conventions: an instanceSchema
field's `group` starts a labeled subsection, `help` puts a line of guidance
under a control, and a manifest `instanceGroups[name]` adds
{ collapsed, columns, help } for that group (rendered as <details> with a
grid body; container queries drop to two or one column in narrow tiles).
/api/modules now exposes instanceGroups. Lets a module keep a long
show/hide checklist per tile — where display choices belong — without
the popover turning into a scroll of thirty loose checkboxes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-10 16:20:45 -04:00
co-authored by Claude Fable 5.1
parent 20dc2deb2b
commit 01eac1bdc4
4 changed files with 121 additions and 1 deletions
+6
View File
@@ -106,6 +106,12 @@ the group name:
placeholders, where a value comes from. Keep labels short and put the placeholders, where a value comes from. Keep labels short and put the
explanation there. 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.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named `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 `<key>` is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value. listener switch is enabled. The hidden field still keeps its stored value.
+50 -1
View File
@@ -934,11 +934,54 @@ function toggleSettingsPopover(tile, el) {
pop.className = 'tile-settings'; pop.className = 'tile-settings';
const inputs = new Map(); const inputs = new Map();
// 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 : {};
let groupName = null;
let container = pop;
for (const [key, spec] of Object.entries(man.instanceSchema)) { for (const [key, spec] of Object.entries(man.instanceSchema)) {
const type = spec?.type || 'string'; const type = spec?.type || 'string';
const label = spec?.label || key; const label = spec?.label || key;
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default; const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const g = spec?.group || '';
if (g !== groupName) {
groupName = g;
if (g) {
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
const folds = meta.collapsed !== undefined;
const group = document.createElement(folds ? 'details' : 'div');
group.className = 'field-group';
if (folds) group.open = !meta.collapsed;
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
title.textContent = g;
group.appendChild(title);
const body = document.createElement('div');
body.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));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
body.appendChild(help);
}
group.appendChild(body);
pop.appendChild(group);
pop.classList.add('has-groups');
container = body;
} else {
container = pop;
}
}
const field = document.createElement('label'); const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : ''); field.className = 'field' + (type === 'boolean' ? ' check' : '');
@@ -970,7 +1013,13 @@ function toggleSettingsPopover(tile, el) {
field.append(span, input); field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
} }
pop.appendChild(field); if (spec?.help) {
const help = document.createElement('small');
help.className = 'field-help';
help.textContent = spec.help;
field.appendChild(help);
}
container.appendChild(field);
} }
const actions = document.createElement('div'); const actions = document.createElement('div');
+64
View File
@@ -529,6 +529,70 @@ body.fullscreen #grid { padding-bottom: 0; }
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
} }
/* 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));
container-type: inline-size;
}
.tile-settings .field-group {
margin: 0 0 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
}
.tile-settings .field-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 8px;
}
.tile-settings details.field-group > summary.field-group-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 0;
user-select: none;
}
.tile-settings details.field-group > summary::-webkit-details-marker { display: none; }
.tile-settings details.field-group > summary.field-group-title::before {
content: "";
width: 0; height: 0;
border-left: 5px solid var(--muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
}
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
}
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@container (max-width: 420px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
}
@container (max-width: 260px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
}
.tile-settings .field-group-help,
.tile-settings .field-help {
display: block;
font-size: 11px;
line-height: 1.4;
color: var(--muted);
}
.tile-settings .field-group-help { margin: -2px 0 6px; }
.tile-settings .field-help { margin-top: 1px; }
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+1
View File
@@ -402,6 +402,7 @@ async function clientManifest(id) {
minSize: man.minSize || { w: 1, h: 1 }, minSize: man.minSize || { w: 1, h: 1 },
defaultSize: man.defaultSize || { w: 4, h: 3 }, defaultSize: man.defaultSize || { w: 4, h: 3 },
instanceSchema: man.instanceSchema || {}, instanceSchema: man.instanceSchema || {},
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
hasServer: Boolean(man.server), hasServer: Boolean(man.server),
config: clientConfig(id), config: clientConfig(id),
tiles: await moduleTiles(id), tiles: await moduleTiles(id),