Shell: every tile can be tinted from its gear

A colour picker at the foot of every gear window washes that tile's
chrome — panel, border and title bar — in a colour of the operator's
choosing, so one screen's tiles can be told apart at a glance. "Clear"
takes it off again. The value saves with the layout under the reserved
key __tint; it is the shell's, not a module's, so picking a colour paints
the tile without remounting the module, and every tile now has a gear
even when its module offers no settings of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-15 14:39:35 -04:00
co-authored by Claude Opus 5
parent f29e43373b
commit 720e5bb590
3 changed files with 96 additions and 5 deletions
+5 -2
View File
@@ -187,7 +187,10 @@ a module with a long show/hide checklist keeps the popover usable — see
connections. The popover itself is a small window beside the tile — sized to
its content, moved by the slim bar on top, closed by clicking anywhere else
or pressing Escape — so a tile never has to be big enough to hold its own
settings, and a change can be watched as it lands.
settings, and a change can be watched as it lands. Under everything you
offer, the shell adds **Tile tint** — a colour the operator can wash that
tile's chrome in, saved with the layout. It is not yours to declare and it
never remounts you; don't offer one of your own.
A picker entry (see **Presenting multiple tiles**) may carry its own
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
@@ -315,7 +318,7 @@ export default function create({ root, moduleApi }) {
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
+59 -3
View File
@@ -261,6 +261,7 @@ function buildTile(tile) {
el.className = 'tile';
el.dataset.tileId = tile.id;
if (man) el.dataset.module = man.id;
applyTint(tile, el);
const head = document.createElement('div');
head.className = 'tile-head';
@@ -279,8 +280,9 @@ function buildTile(tile) {
head.append(dot, title, controls);
const hasSettings = man && Object.keys(tileSchema(tile, man).schema).length;
if (hasSettings) {
// Every tile has something to set: its own settings when the module
// offers any, and the tint either way.
if (man) {
const gear = document.createElement('button');
gear.className = 'tile-btn';
gear.title = 'Settings';
@@ -1133,6 +1135,23 @@ function unmountModule(tile) {
/* ── per-tile settings popover ──────────────────────────────────────── */
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
/* ── the tile tint ───────────────────────────────────────────────────
Every tile can be washed in a colour of its own — a way to tell one
screen's tiles apart at a glance (this campus green, that one amber).
It is the shell's, not a module's: the key is reserved, it is offered in
every gear window, and it saves with the layout like any other tile
setting. A tile with no tint looks exactly as before. */
const TINT_KEY = '__tint';
/** Paint (or clear) a tile's tint on its element. */
function applyTint(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const hex = colorHex(tile.settings?.[TINT_KEY], '');
if (hex) el.style.setProperty('--tile-tint', hex);
else el.style.removeProperty('--tile-tint');
el.classList.toggle('is-tinted', Boolean(hex));
}
function colorHex(value, fallback) {
const s = String(value ?? '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
@@ -1267,8 +1286,8 @@ function toggleSettingsPopover(tile, el, button = null) {
closeSettingsPopovers();
if (existing) return;
const man = registry.get(tile.module);
if (!man) return;
const { schema, groups: groupMeta } = tileSchema(tile, man);
if (!man || !Object.keys(schema).length) return;
const pop = document.createElement('div');
pop.className = 'tile-settings';
@@ -1588,6 +1607,43 @@ function toggleSettingsPopover(tile, el, button = null) {
}
}
/* The tint sits on its own at the foot of the window, under a hairline —
it is the shell's setting, not one of the module's. Unlike those, a
change needs no remount: nothing but the tile's own chrome reads it. */
{
const field = document.createElement('div');
field.className = 'field color-field tint-field'; // the swatch reads like a module's colour field
const text = document.createElement('span');
text.textContent = 'Tile tint';
const input = document.createElement('input');
input.type = 'color';
input.setAttribute('aria-label', 'Tile tint');
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'tint-clear';
clear.textContent = 'Clear';
clear.title = 'No tint';
const paint = () => {
const hex = colorHex(tile.settings[TINT_KEY], '');
// With no tint the swatch opens on the panel colour, so the picker
// starts somewhere sane rather than at black.
input.value = hex || colorHex(getComputedStyle(document.documentElement).getPropertyValue('--panel'), '#1a1d21');
clear.hidden = !hex;
};
const set = (hex) => {
if (hex) tile.settings[TINT_KEY] = hex;
else delete tile.settings[TINT_KEY];
applyTint(tile);
paint();
saveLayout();
};
input.addEventListener('input', () => set(colorHex(input.value, '')));
clear.addEventListener('click', () => set(''));
paint();
field.append(text, clear, input);
body.appendChild(field);
}
// "Short day" appears once "Date" is on: a field with showWhen:"K" follows
// K's switch, live — the same rule the admin page applies.
if (shownWhen.length) {
+32
View File
@@ -234,6 +234,18 @@ body.menu-hidden #topbar {
overflow: hidden;
}
/* A tile the user gave a colour: a wash over the panel, a stronger edge and
a tinted title bar. Module content keeps its own colours — this is the
tile's chrome, so a tinted tile still reads like every other one. */
.tile.is-tinted {
background: color-mix(in srgb, var(--tile-tint) 14%, var(--panel));
border-color: color-mix(in srgb, var(--tile-tint) 45%, var(--border));
}
.tile.is-tinted > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, transparent);
border-bottom-color: color-mix(in srgb, var(--tile-tint) 35%, var(--border));
}
.tile.dragging,
.tile.resizing {
z-index: 40;
@@ -599,6 +611,26 @@ body.fullscreen #grid { padding-bottom: 0; }
}
.tile-settings-bar:hover::before { opacity: 1; }
.tile-settings .field[hidden] { display: none; }
/* the tint, the shell's own setting, under a hairline at the foot */
.tile-settings .tint-field {
margin: 8px 0 0;
padding-top: 9px;
border-top: 1px solid var(--border);
gap: 8px;
}
.tile-settings .tint-clear {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.tile-settings .tint-clear:hover { color: var(--text); background: var(--border); }
.tile-settings .tint-clear[hidden] { display: none; }
.tile-settings-bar:active { cursor: grabbing; }
.tile-settings-body {
flex: 1;