diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 0a88081..acaafe1 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -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//state`. | | `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | diff --git a/public/shell.js b/public/shell.js index a7f33df..d9d24d8 100644 --- a/public/shell.js +++ b/public/shell.js @@ -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 ──────────────────────────────────────── */ /** `` 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) { diff --git a/public/style.css b/public/style.css index b10f060..dfb8491 100644 --- a/public/style.css +++ b/public/style.css @@ -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;