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 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 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 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 A picker entry (see **Presenting multiple tiles**) may carry its own
`instanceSchema` and `instanceGroups`: tiles added from that entry get those `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. | | `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**). | | `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. | | `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). | | `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`. | | `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()`. | | `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.className = 'tile';
el.dataset.tileId = tile.id; el.dataset.tileId = tile.id;
if (man) el.dataset.module = man.id; if (man) el.dataset.module = man.id;
applyTint(tile, el);
const head = document.createElement('div'); const head = document.createElement('div');
head.className = 'tile-head'; head.className = 'tile-head';
@@ -279,8 +280,9 @@ function buildTile(tile) {
head.append(dot, title, controls); head.append(dot, title, controls);
const hasSettings = man && Object.keys(tileSchema(tile, man).schema).length; // Every tile has something to set: its own settings when the module
if (hasSettings) { // offers any, and the tint either way.
if (man) {
const gear = document.createElement('button'); const gear = document.createElement('button');
gear.className = 'tile-btn'; gear.className = 'tile-btn';
gear.title = 'Settings'; gear.title = 'Settings';
@@ -1133,6 +1135,23 @@ function unmountModule(tile) {
/* ── per-tile settings popover ──────────────────────────────────────── */ /* ── per-tile settings popover ──────────────────────────────────────── */
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */ /** `<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) { function colorHex(value, fallback) {
const s = String(value ?? '').trim(); const s = String(value ?? '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
@@ -1267,8 +1286,8 @@ function toggleSettingsPopover(tile, el, button = null) {
closeSettingsPopovers(); closeSettingsPopovers();
if (existing) return; if (existing) return;
const man = registry.get(tile.module); const man = registry.get(tile.module);
if (!man) return;
const { schema, groups: groupMeta } = tileSchema(tile, man); const { schema, groups: groupMeta } = tileSchema(tile, man);
if (!man || !Object.keys(schema).length) return;
const pop = document.createElement('div'); const pop = document.createElement('div');
pop.className = 'tile-settings'; 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 // "Short day" appears once "Date" is on: a field with showWhen:"K" follows
// K's switch, live — the same rule the admin page applies. // K's switch, live — the same rule the admin page applies.
if (shownWhen.length) { if (shownWhen.length) {
+32
View File
@@ -234,6 +234,18 @@ body.menu-hidden #topbar {
overflow: hidden; 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.dragging,
.tile.resizing { .tile.resizing {
z-index: 40; z-index: 40;
@@ -599,6 +611,26 @@ body.fullscreen #grid { padding-bottom: 0; }
} }
.tile-settings-bar:hover::before { opacity: 1; } .tile-settings-bar:hover::before { opacity: 1; }
.tile-settings .field[hidden] { display: none; } .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-bar:active { cursor: grabbing; }
.tile-settings-body { .tile-settings-body {
flex: 1; flex: 1;