Merge pull request #15 from michaelmcgary2008/claude/dashboard-round-2026-09

Dashboard round: themes, admin ⓘ and group toggles, switches, add-tile fix, Timers overhaul, PCO service timing
This commit is contained in:
Michael McGary
2026-09-15 15:24:44 -04:00
committed by GitHub
20 changed files with 4172 additions and 951 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "start",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start"
],
"port": 3000
}
]
}
+17
View File
@@ -99,6 +99,7 @@ with this machine's shell settings — the same keys as the checked-in
| --- | --- | | --- | --- |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
@@ -111,6 +112,22 @@ so and falls back to the app's `config/` folder.
directory, so nothing has to be re-entered. The old files are left in place and directory, so nothing has to be re-entered. The old files are left in place and
ignored from then on. ignored from then on.
### Themes
The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. Five presets ship: **Booth**
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
keeps the status colours apart — the accent is never near the danger red or
the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules
style with the shell's colour variables, which is why they follow; a colour
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
## Installing modules and updating ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub Everything below happens in the **admin page**; ProdDash talks to its GitHub
+95 -7
View File
@@ -92,6 +92,7 @@ the app); its settings are kept for a later reinstall.
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
| `description` | no | Shown in the admin page / picker. | | `description` | no | Shown in the admin page / picker. |
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | | `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
| `client` | yes | Client entry file, loaded as an ES module. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. | | `style` | no | A stylesheet the shell injects once per module. |
@@ -115,8 +116,13 @@ Both schemas map field names to specs:
} }
``` ```
`boolean` renders as a checkbox; `switch` is the same true/false value shown `boolean` and `switch` are the same true/false value. In a tile's gear popover
as a toggle (use it for a feature on/off that other fields depend on). both render as a switch; in the admin page `switch` is the toggle and
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
fields depend on). **Label a switch with the noun of what it turns on** —
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
shown or enabled, off means hidden or disabled, and the label shouldn't say
so twice.
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex `color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
default; the tile applies it as a CSS custom property — see `pco-plan`). default; the tile applies it as a CSS custom property — see `pco-plan`).
@@ -141,7 +147,15 @@ the group name:
still empty. Omit it and the group is a plain, always-open block. still empty. Omit it and the group is a plain, always-open block.
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
long checklist of booleans compact. long checklist of booleans compact.
- `help`: a line of guidance at the top of the group. - `help`: guidance for the whole group. The admin page shows it as an
ⓘ beside the group title (click or hover to read), so a group can carry a
paragraph without pushing its fields down the page. Say where a value comes
from, not what a field is.
- `toggle`: the key of a `switch` field in this group. It renders in the
group's title row, at the right, as the group's on/off; the rest of the group
dims while it is off, and the field is not repeated in the body. This is how
an integration is disabled without unmounting the module — ProPresenter
timers, say, on a Sunday when ProPresenter isn't running.
`help` on a field puts one line of guidance under its control — allowed `help` on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the placeholders, where a value comes from. Keep labels short and put the
@@ -363,9 +377,15 @@ own in `stop()` anyway.
2. **Style with the shell's CSS variables — no hardcoded colors.** The 2. **Style with the shell's CSS variables — no hardcoded colors.** The
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with `--muted`, `--accent`, `--accent-text` (text that sits on the accent —
dark on most themes, white where the accent is deep), `--danger`,
`--warn`. Derive tints with
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
every module look native on the dark, high-contrast booth theme. every module look native — and what makes the dashboard's **themes**
(Admin → Theme) reach into every tile: a theme swaps those variables and
nothing else. A colour a tile is configured with (a `color` instance
setting, applied as a custom property on the tile) is set closer than the
theme and wins over it, by design.
3. **No external network calls from the client.** Browsers talk only to 3. **No external network calls from the client.** Browsers talk only to
ProdDash. Anything upstream (your device, service, API) goes through your ProdDash. Anything upstream (your device, service, API) goes through your
@@ -385,7 +405,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
```js ```js
module.exports = { module.exports = {
init({ config, log }) { init({ config, log, shell }) {
// Called on startup / re-init, BEFORE routes(). Start your upstream // Called on startup / re-init, BEFORE routes(). Start your upstream
// connection here. Return a handle: // connection here. Return a handle:
return { return {
@@ -394,7 +414,7 @@ module.exports = {
}; };
}, },
routes({ config, log }) { routes({ config, log, shell }) {
// Route table, mounted under /api/modules/<id>/ // Route table, mounted under /api/modules/<id>/
return { return {
'GET /state': (req, res) => { … }, 'GET /state': (req, res) => { … },
@@ -410,6 +430,9 @@ module.exports = {
- `config` is your admin config: `configSchema` defaults overlaid with what - `config` is your admin config: `configSchema` defaults overlaid with what
the admin page saved — **including password fields**. the admin page saved — **including password fields**.
- `log(...)` prefixes output with your module id. - `log(...)` prefixes output with your module id.
- `shell.port` is the port ProdDash is serving on — what you need to read
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
modules/<id>/…`). Never guess it.
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
the matched remainder (leading `/` included) and `req.search` is the query the matched remainder (leading `/` included) and `req.search` is the query
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
@@ -496,6 +519,71 @@ per-client `res.write` in try/catch (drop the client on failure). The
`propresenter-now-next` module is the reference implementation of this `propresenter-now-next` module is the reference implementation of this
pattern. pattern.
## Working with other modules
Modules don't import each other. When one needs what another knows, it reads
that module's **own server routes over loopback**, exactly as a browser would:
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
rather than talking to ProPresenter a second time. A consumer must survive the
provider being disabled, uninstalled, or restarting mid-service (404s and
dropped connections are normal; retry with backoff and say so in `health()`).
### Offering timers to the Timers module
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
clock, and any module that offers some. A module offers timers by:
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
forwards `provides` in `/api/modules`, which is how the Timers module finds
providers without a list anyone maintains.
2. Serving `GET /timers` from its server routes, returning a snapshot:
```json
{
"source": "pco-plan",
"label": "PCO Plan",
"updatedAt": 1757800000000,
"timers": [
{
"id": "service",
"label": "Service",
"kind": "elapsed",
"state": "running",
"startedAt": 1757799000000,
"elapsedMs": 1000000,
"remainingMs": null,
"targetMs": 4500000,
"status": { "text": "Welcome & Announcements", "tone": "ok" },
"detail": "Item 3 of 12"
}
]
}
```
| Field | Meaning |
| --- | --- |
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
| `detail` | Optional second line. |
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
must be cheap to answer (serve from state you already hold; never call
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
(`event: timers`, `data:` the same object, on every change plus a heartbeat
comment every 15 s); the consumer uses the stream when it exists and polls
`/timers` otherwise. Only offer timers a production tech would want on a
wall — the Timers module lets them hide any of them per tile, but every entry
you add is a decision someone has to make.
## Complete minimal example ## Complete minimal example
A working clock — copy-paste this as your starter. (A slightly fancier A working clock — copy-paste this as your starter. (A slightly fancier
+3 -4
View File
@@ -1,9 +1,8 @@
{ {
"id": "clock", "id": "clock",
"name": "Clock", "name": "Clock",
"version": "1.0.0", "version": "1.0.1",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Big booth clock — the smallest possible ProdDash module",
"client": "client.js", "client": "client.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
@@ -18,7 +17,7 @@
{ "value": "12h", "label": "12-hour" } { "value": "12h", "label": "12-hour" }
] ]
}, },
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, "showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
"showDate": { "type": "boolean", "label": "Show date", "default": false } "showDate": { "type": "boolean", "label": "Date", "default": false }
} }
} }
+35 -16
View File
@@ -209,36 +209,55 @@ export default function create({ root, moduleApi }) {
renderClock(); renderClock();
} }
/**
* The header clock follows the service the server says owns the clock
* (state.service: idle → running → stopped, by schedule or by the admin's
* ProPresenter document cues): a countdown before it, the running time
* while it runs (with ahead/behind when following ProPresenter), the final
* time once it has ended.
*/
function renderClock() { function renderClock() {
const plan = state?.plan; const plan = state?.plan;
const live = state?.live || {}; const live = state?.live || {};
const svc = state?.service || {};
clockLabelEl.textContent = ''; clockLabelEl.textContent = '';
clockValueEl.textContent = ''; clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value'; clockValueEl.className = 'pp-clock-value';
if (!plan || headEl.hidden) return; if (!plan || headEl.hidden) return;
const now = serverNow(); const now = serverNow();
if (on('showRunningClock') && live.following && live.serviceStartedAt) { const startedAt = svc.startedAt || live.serviceStartedAt || 0;
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
clockLabelEl.textContent = 'Running'; clockLabelEl.textContent = 'Running';
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000); clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take. if (live.following) {
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt); // Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const drift = actual - planned; const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
if (planned && Math.abs(drift) >= 30) { const drift = actual - planned;
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; if (planned && Math.abs(drift) >= 30) {
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
}
} }
return; return;
} }
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) { if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
clockLabelEl.textContent = 'Starts in'; clockLabelEl.textContent = 'Ended';
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
return; return;
} }
if (on('showRunningClock') && plan.serviceStartsAt) { const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
clockLabelEl.textContent = 'Since start'; if (on('showCountdown') && startsAt > now) {
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000); clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((startsAt - now) / 1000);
return;
}
if (on('showRunningClock') && startsAt) {
// Past the scheduled start and not started: waiting for the admin's
// start document to go live in ProPresenter (or its failsafe).
clockLabelEl.textContent = 'Scheduled';
clockValueEl.textContent = fmtClock(startsAt);
} }
} }
+85
View File
@@ -0,0 +1,85 @@
'use strict';
/**
* Title matching for the PCO Plan module — the one answer to "is this
* ProPresenter document that Planning Center thing?", used twice:
*
* - mapping the document ProPresenter has live to a plan item (the
* highlight and the item runtimes), and
* - recognising the documents an admin picked as the service start / end
* cues (see service-timing.js).
*
* A ProPresenter name and a Planning Center title rarely agree letter for
* letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides
* are tidied the way propresenter-core.cleanItemName does (tags in brackets,
* leading date codes and dash separators dropped), lower-cased, stripped of
* punctuation and compared:
*
* 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop"
* 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements"
* Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67
*
* Filler words ("the", "of", "and"…) are ignored. The thresholds:
*
* MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because
* the item that scores best *and* is next in the
* plan wins, so a near miss lands on the right row.
* TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service
* clock, so one wrong word must not fire it: the
* pre-service loop (0.67 against "Post Service Loop")
* stays a non-match, while a renamed
* "Post-Service Loop", a tagged "Post Service Loop
* [Full]" (1.0) or a shortened "Post Service" (0.85)
* still trigger.
*/
const MATCH_THRESHOLD = 0.6;
const TRIGGER_THRESHOLD = 0.75;
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
function cleanTitle(raw) {
const original = String(raw || '').trim();
let s = original;
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
s = s.replace(/\s[-–—]+\s/g, ' ');
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
s = s.replace(/\s{2,}/g, ' ').trim();
return s || original;
}
function normalize(raw) {
return cleanTitle(raw)
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[’'`]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
function tokens(norm) {
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
}
/** 0..1 similarity between two titles. */
function similarity(a, b) {
const na = normalize(a);
const nb = normalize(b);
if (!na || !nb) return 0;
if (na === nb) return 1;
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
const ta = tokens(na);
const tb = tokens(nb);
if (!ta.size || !tb.size) return 0;
let common = 0;
for (const t of ta) if (tb.has(t)) common += 1;
return (2 * common) / (ta.size + tb.size); // Dice coefficient
}
/** Does a live ProPresenter document count as the cue document an admin picked? */
function isCue(liveName, cueDocument) {
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
}
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
+65 -13
View File
@@ -1,9 +1,10 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.3.0", "version": "1.4.0",
"proddash": ">=1.3.0", "proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services, following ProPresenter live", "description": "Order of service from Planning Center Services",
"provides": ["timers"],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
@@ -12,13 +13,16 @@
"configGroups": { "configGroups": {
"Planning Center credentials": { "Planning Center credentials": {
"collapsed": "whenSet", "collapsed": "whenSet",
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers." "help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
},
"Service timing": {
"help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts."
} }
}, },
"configSchema": { "configSchema": {
"appId": { "appId": {
"type": "string", "type": "string",
"label": "Application ID", "label": "Client ID",
"default": "", "default": "",
"group": "Planning Center credentials" "group": "Planning Center credentials"
}, },
@@ -30,7 +34,7 @@
}, },
"accessToken": { "accessToken": {
"type": "password", "type": "password",
"label": "OAuth 2.0 access token (instead of Application ID + Secret)", "label": "OAuth 2.0 access token (instead of Client ID + Secret)",
"default": "", "default": "",
"group": "Planning Center credentials" "group": "Planning Center credentials"
}, },
@@ -38,8 +42,7 @@
"type": "select", "type": "select",
"label": "Service type", "label": "Service type",
"default": "", "default": "",
"optionsRoute": "/service-types", "optionsRoute": "/service-types"
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
}, },
"planId": { "planId": {
"type": "select", "type": "select",
@@ -47,18 +50,67 @@
"default": "", "default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }], "options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
"optionsRoute": "/plans", "optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId", "optionsDependsOn": "serviceTypeId"
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",
"label": "Re-read the plan every N seconds", "label": "Refresh Interval (s)",
"default": 30 "default": 30
}, },
"followProPresenter": { "followProPresenter": {
"type": "switch", "type": "switch",
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it", "label": "Follow ProPresenter Module",
"default": true "default": true
},
"preRollMinutes": {
"type": "number",
"label": "Pre-roll (min)",
"default": 30,
"group": "Service timing",
"help": "How long before a service's scheduled start the clock, item timing and countdown switch to it."
},
"useDocumentTriggers": {
"type": "switch",
"label": "ProPresenter document triggers",
"default": false,
"group": "Service timing"
},
"serviceStartTrigger": {
"type": "select",
"label": "Service start",
"default": "schedule",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"options": [
{ "value": "schedule", "label": "Scheduled service time" },
{ "value": "document", "label": "ProPresenter document" }
]
},
"serviceStartDocument": {
"type": "select",
"label": "Start document",
"default": "",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"optionsRoute": "/documents",
"help": "The document that goes live as the service begins."
},
"startGraceMinutes": {
"type": "number",
"label": "Start failsafe (min)",
"default": 10,
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway."
},
"serviceEndDocument": {
"type": "select",
"label": "End document",
"default": "",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"optionsRoute": "/documents",
"help": "The document that goes live as the service ends; it freezes the service clock."
} }
}, },
"instanceGroups": { "instanceGroups": {
@@ -72,7 +124,7 @@
"Show / hide": { "Show / hide": {
"collapsed": true, "collapsed": true,
"columns": 3, "columns": 3,
"help": "Untick anything this tile should not show." "help": "Switch off anything this tile should not show."
} }
}, },
"instanceSchema": { "instanceSchema": {
+629 -120
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
'use strict';
/**
* Service timing for the PCO Plan module — pure functions, no I/O, no
* timers. server.js feeds them the plan's service times, the documents
* ProPresenter puts live and the clock; service-timing.test.js calls them
* directly. Two questions are answered here:
*
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
* 2. When does that service count as started, and as ended? → advance
*
* ── Service windows ────────────────────────────────────────────────────
*
* A plan can have several services (plan_times of type "service": 9:00 and
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
* timing and the countdown at any moment, and moving into the next
* service's window resets all three. Each service's window runs from the
* end of the previous one to a boundary the next service's pre-roll sets:
*
* boundary(i → i+1) = min( start[i+1],
* max( start[i+1] − preRoll, endOf[i] ) )
*
* endOf[i] = the moment service i ended (its end cue fired), else its
* scheduled end (plan_time ends_at, else start + the planned
* length of the "during" items, else 90 min).
*
* In words: the next service takes over `preRoll` before its scheduled
* start (30 min by default; admin "Pre-roll"), but never before the current
* one has ended or was due to end, and never later than its own start. A
* service still running at the boundary is stopped there — that is the
* "next service's start" failsafe the end cue has. The first window opens at
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
* countdown running, service idle); the last window never closes — the last
* service stays "stopped" until the plan itself changes (the next day's plan
* takes over through the plan picker). The last service also has a failsafe
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
*
* ── Start and end of a service ─────────────────────────────────────────
*
* phase: idle → running → stopped, never backwards within one window.
*
* start, schedule mode (default) at the scheduled start; startedAt = it.
* start, document mode when the start document goes live
* (edge: it has to *become* live, from
* pre-roll before the scheduled start
* onwards — a rehearsal an hour earlier
* that shows the same document is not the
* service). Failsafe: if the document has
* not gone live by start + grace (admin
* "Start failsafe", 10 min default), the
* service starts anyway, counting from
* the scheduled start — the best estimate
* of when it really began.
* end, document when the end document goes live while
* the service is running (an end document
* live before the start — the previous
* service's loop — is ignored). Failsafe
* either way: the window boundary (see
* above), or, for the last service, its
* scheduled end + LAST_SERVICE_OVERRUN_MS.
*
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
* matching.js for the tolerance), so "Post-Service Loop" still ends the
* service when ProPresenter calls it "Post Service Loop".
*/
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
const MINUTE = 60000;
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
/** A service with no end time and no planned item lengths is assumed this long. */
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
function serviceTimes(planTimes) {
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
const services = list.filter((t) => t.timeType === 'service');
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
}
/**
* The window each service owns (see the file comment).
*
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
* @param {object} [opts]
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
*/
function serviceWindows(planTimes, opts = {}) {
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
const services = serviceTimes(planTimes);
const windows = services.map((svc, i) => {
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
const next = services[i + 1];
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
return {
id: String(svc.id),
name: String(svc.name || ''),
index: i,
count: services.length,
startsAt: svc.startsAt,
endsAt: scheduledEnd,
windowFrom: -Infinity, // filled in below
windowUntil,
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
isLast: !next,
};
});
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
return windows;
}
/** The window that holds `now` — null only when there are no services. */
function selectService(windows, now) {
if (!Array.isArray(windows) || !windows.length) return null;
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
}
/** Timing state for a service that has just come into its window. */
function freshService(window) {
return {
id: window ? String(window.id) : '',
phase: window ? 'idle' : 'none',
startedAt: 0,
endedAt: 0,
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
cue: '', // the document that started / ended it
};
}
function fmtTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/**
* Move a service's timing on by a clock tick or by a document going live.
* Pure: returns a new state and a one-line description of what happened
* (for the log), or `changed: false`.
*
* @param {object} svc state from freshService(window) / a previous advance()
* @param {object} window from serviceWindows()
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
* @param {number} now ms epoch
* @param {string} [cue] the document that just went live (omit on a plain tick)
* @returns {{ svc: object, changed: boolean, event: string }}
*/
function advance(svc, window, opts, now, cue) {
const o = opts || {};
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
const cueName = String(cue || '').trim();
const same = { svc, changed: false, event: '' };
if (!svc || !window || svc.id !== String(window.id)) return same;
const at = fmtTime(window.startsAt);
if (svc.phase === 'idle') {
const documentMode = o.startMode === 'document' && o.startDocument;
if (documentMode) {
if (cueName && now >= window.cueFrom) {
const score = similarity(cueName, o.startDocument);
if (score >= TRIGGER_THRESHOLD) {
return {
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
changed: true,
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
};
}
}
if (now >= window.startsAt + grace) {
return {
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
changed: true,
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
};
}
return same;
}
if (now >= window.startsAt) {
return {
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
changed: true,
event: `started — scheduled ${at}`,
};
}
return same;
}
if (svc.phase === 'running') {
if (o.endDocument && cueName) {
const score = similarity(cueName, o.endDocument);
if (score >= TRIGGER_THRESHOLD) {
return {
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
changed: true,
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
};
}
}
if (now >= window.failsafeEndAt) {
const endedAt = Math.min(now, window.failsafeEndAt);
return {
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
changed: true,
event: window.isLast
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
};
}
return same;
}
return same;
}
module.exports = {
MINUTE,
DEFAULT_PRE_ROLL_MS,
DEFAULT_START_GRACE_MS,
LAST_SERVICE_OVERRUN_MS,
DEFAULT_SERVICE_LENGTH_MS,
serviceTimes,
serviceWindows,
selectService,
freshService,
advance,
fmtTime,
};
+229
View File
@@ -0,0 +1,229 @@
'use strict';
/**
* Unit tests for the PCO Plan module's pure service-timing logic.
*
* node modules/pco-plan/service-timing.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Times are
* built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min
* pre-roll) so the expectations read like the day would.
*/
const assert = require('assert');
const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching');
const {
MINUTE,
LAST_SERVICE_OVERRUN_MS,
serviceWindows,
selectService,
freshService,
advance,
} = require('./service-timing');
const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight
const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE;
const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const PLAN_TIMES = [
{ id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) },
{ id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) },
{ id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) },
];
const PRE_ROLL = 30 * MINUTE;
const OPTS = { preRollMs: PRE_ROLL };
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
throw err;
}
}
console.log('service windows');
test('rehearsal is not a service; windows are contiguous and ordered', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']);
assert.strictEqual(w[0].windowFrom, -Infinity);
assert.strictEqual(w[0].windowUntil, w[1].windowFrom);
assert.strictEqual(w[1].windowUntil, Infinity);
assert.strictEqual(w[0].count, 2);
});
test('the boundary is 30 min before the next start once the first was due to end', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
// 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30)));
assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary');
});
test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => {
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE });
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)');
const [tight] = serviceWindows(
[
{ id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) },
{ id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) },
],
OPTS
);
assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start');
});
test('an early actual end lets the next window open at pre-roll', () => {
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } });
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05');
const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } });
assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30');
});
test('a service without ends_at gets the planned length, else 90 min', () => {
const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE });
assert.strictEqual(hm(w.endsAt), hm(at(10, 10)));
const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {});
assert.strictEqual(hm(w90.endsAt), hm(at(10, 30)));
assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS);
});
test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
assert.strictEqual(selectService(w, at(8, 50)).id, 's1');
assert.strictEqual(selectService(w, at(9, 30)).id, 's1');
assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary');
assert.strictEqual(selectService(w, at(10, 55)).id, 's2');
assert.strictEqual(selectService(w, at(11, 5)).id, 's2');
assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service');
assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service');
assert.strictEqual(selectService([], at(9, 0)), null);
});
console.log('service start');
const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE };
const DOCUMENT = {
startMode: 'document',
startDocument: 'Welcome & Announcements',
endDocument: 'Post Service Loop',
startGraceMs: 10 * MINUTE,
};
test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
let s = freshService(w1);
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode');
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false);
const out = advance(s, w1, SCHEDULE, at(9, 0, 30));
assert.strictEqual(out.changed, true);
s = out.svc;
assert.strictEqual(s.phase, 'running');
assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed');
assert.strictEqual(s.startTrigger, 'schedule');
});
test('document mode: the start document going live starts the service at that moment', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it');
const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]');
assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts');
assert.strictEqual(out.svc.phase, 'running');
assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start');
assert.strictEqual(out.svc.startTrigger, 'document');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing');
});
test('document mode: the start document is only a cue from pre-roll before the start', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service');
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts');
});
test('document mode: failsafe at start + grace counts from the scheduled start', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false);
const out = advance(s, w1, DOCUMENT, at(9, 10));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'running');
assert.strictEqual(out.svc.startedAt, at(9, 0));
assert.strictEqual(out.svc.startTrigger, 'failsafe');
assert.match(out.event, /failsafe/);
});
test('document mode: a cue and the failsafe in the same tick — the cue wins', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements');
assert.strictEqual(out.svc.startTrigger, 'document');
assert.strictEqual(out.svc.startedAt, at(9, 12));
});
console.log('service end');
test('the end document freezes a running service; a second start document is ignored', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)');
const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop');
assert.strictEqual(out.changed, true, 'a hyphenated end document still counts');
s = out.svc;
assert.strictEqual(s.phase, 'stopped');
assert.strictEqual(s.endedAt, at(10, 12));
assert.strictEqual(s.endTrigger, 'document');
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped');
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false);
});
test('failsafe: a service still running when the next window opens stops at the boundary', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc;
assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed');
const out = advance(s, w1, SCHEDULE, at(10, 31));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'stopped');
assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick');
assert.strictEqual(out.svc.endTrigger, 'next-service');
});
test('failsafe: the last service stops an hour after its scheduled end', () => {
const [, w2] = serviceWindows(PLAN_TIMES, OPTS);
const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc;
assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false);
const out = advance(s, w2, SCHEDULE, at(13, 15));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'stopped');
assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15)));
assert.strictEqual(out.svc.endTrigger, 'plan-end');
});
test('the end document does not stop a service in the wrong window', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together');
});
console.log('matching');
test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => {
assert.strictEqual(MATCH_THRESHOLD, 0.6);
assert.strictEqual(TRIGGER_THRESHOLD, 0.75);
assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1);
assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1);
assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85);
assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001);
assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false);
assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true);
assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true);
assert.strictEqual(isCue('Anything', ''), false);
});
console.log(`\n${passed} tests passed`);
File diff suppressed because it is too large Load Diff
+71 -9
View File
@@ -1,9 +1,10 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.2.1", "version": "1.3.0",
"proddash": ">=1.2.0", "proddash": ">=1.4.0",
"description": "Live ProPresenter timers and LTC timecode", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": ["timers"],
"permissions": [ "permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, { "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } { "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
@@ -12,8 +13,28 @@
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 2 }, "defaultSize": { "w": 4, "h": 3 },
"configGroups": {
"ProPresenter": {
"toggle": "ppEnabled",
"help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled."
},
"LTC Timecode": {
"toggle": "ltcEnabled",
"help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)."
},
"PCO Plan": {
"toggle": "pcoEnabled",
"help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side."
}
},
"configSchema": { "configSchema": {
"ppEnabled": {
"type": "switch",
"label": "ProPresenter timers",
"group": "ProPresenter",
"default": true
},
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
@@ -22,13 +43,14 @@
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "API password",
"group": "ProPresenter", "group": "ProPresenter",
"default": "" "default": "",
"help": "Only if a password is set in ProPresenter's network preferences."
}, },
"ltcEnabled": { "ltcEnabled": {
"type": "switch", "type": "switch",
"label": "LTC listener — decode timecode from an audio input on this machine", "label": "LTC timecode",
"group": "LTC Timecode", "group": "LTC Timecode",
"default": false "default": false
}, },
@@ -42,10 +64,17 @@
}, },
"ltcChannel": { "ltcChannel": {
"type": "number", "type": "number",
"label": "Channel on that device (1-based)", "label": "Channel",
"group": "LTC Timecode", "group": "LTC Timecode",
"showWhen": "ltcEnabled", "showWhen": "ltcEnabled",
"default": 1 "default": 1,
"help": "The channel on that device carrying the timecode, counted from 1."
},
"pcoEnabled": {
"type": "switch",
"label": "PCO Plan timers",
"group": "PCO Plan",
"default": true
}, },
"timerFont": { "timerFont": {
"type": "select", "type": "select",
@@ -57,5 +86,38 @@
{ "value": "monospace", "label": "Monospace — steadier changing digits" } { "value": "monospace", "label": "Monospace — steadier changing digits" }
] ]
} }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 },
"Clock": { "collapsed": false },
"Colours": {
"collapsed": true,
"columns": 2,
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
}
},
"instanceSchema": {
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" },
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
"clockFormat": {
"type": "select",
"label": "Clock format",
"default": "24h",
"group": "Clock",
"options": [
{ "value": "24h", "label": "24-hour" },
{ "value": "12h", "label": "12-hour" }
]
},
"clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" },
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" },
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" },
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" },
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" },
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" }
} }
} }
File diff suppressed because it is too large Load Diff
+210 -116
View File
@@ -1,11 +1,16 @@
/* ProPresenter Timers module — scoped to its tiles, shell variables only. /* Timers module — scoped to its tiles, shell variables only.
The API exposes no per-timer color, so a card's color IS its state: A card's colour IS its state: running / warning / overrun / idle. The four
accent = running, muted = stopped, warn = complete, danger = overrun. tones are custom properties on the tile root: they default to the shell's
Overrun must be unmistakable at a distance — danger fill + pulse. */ palette (so a dashboard theme reaches them) and the tile's own colour
settings, when changed, are set inline on .tm-wrap and win — the
documented mechanism. Overrun must be unmistakable across the room:
danger fill + pulse. */
[data-module="propresenter-timers"] .tm-wrap { [data-module="propresenter-timers"] .tm-wrap {
--tm-time-size: 32px; --tm-ok: var(--accent);
--tm-ltc-size: 26px; --tm-warn: var(--warn);
--tm-danger: var(--danger);
--tm-muted: var(--muted);
--tm-meta-size: 11px; --tm-meta-size: 11px;
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -15,7 +20,7 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */ /* ── degraded-state banner (the ProdDash feed itself is down) ── */
[data-module="propresenter-timers"] .tm-banner { [data-module="propresenter-timers"] .tm-banner {
flex: none; flex: none;
@@ -30,12 +35,11 @@
} }
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
/* Values still on screen while the feed is down are stale — say so visually. /* Values still on screen while their source is down are stale — say so
Per-card, because the reader-fed LTC card is not stale when only visually, per group: the LTC card keeps ticking when only ProPresenter
ProPresenter is unreachable; it dims only when the ProdDash feed itself is unreachable. When the ProdDash feed is down everything is old. */
is down (is-feed-down) and everything on screen really is old. */ [data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; } [data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; }
[data-module="propresenter-timers"] .tm-hint { [data-module="propresenter-timers"] .tm-hint {
flex: 1; flex: 1;
@@ -49,7 +53,69 @@
} }
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; } [data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
/* ── card grid (column count set by client.js fit()) ── */ /* ── source groups: heading + card grid (column count set by fit()) ── */
[data-module="propresenter-timers"] .tm-groups {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
}
[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-group {
min-height: 0;
display: flex;
flex-direction: column;
flex: 1 1 0;
}
[data-module="propresenter-timers"] .tm-head {
flex: none;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
margin: 0 2px 4px;
font-size: 11px;
line-height: 16px;
color: var(--muted);
}
[data-module="propresenter-timers"] .tm-head[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-title {
flex: none;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* The source's own word while it isn't OK ("Unreachable at …"). */
[data-module="propresenter-timers"] .tm-note {
flex: 1;
min-width: 0;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); }
/* Headings off: a source in trouble, or with nothing to show, still says so. */
[data-module="propresenter-timers"] .tm-empty {
flex: none;
padding: 2px 4px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-grid { [data-module="propresenter-timers"] .tm-grid {
flex: 1; flex: 1;
@@ -57,16 +123,16 @@
display: grid; display: grid;
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: var(--tm-gap, 8px); gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
} }
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } [data-module="propresenter-timers"] .tm-grid:empty { display: none; }
/* Compact cards (short tiles): the state line goes, its color stays. /* Compact cards (short tiles) and "Status lines" off: the lines under the
The one exception is the LTC card with no timecode API behind it — digits go, the colour stays. A note that explains a missing value (no
that message must never be hidden. */ LTC input, a solo timer no longer offered) must never be hidden. */
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; } [data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; } [data-module="propresenter-timers"] .tm-compact .tm-detail,
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; } [data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; }
/* ── one timer card ── */ /* ── one timer card ── */
@@ -98,13 +164,20 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
}
/* The label is its own box so a long name ends in an ellipsis (a flex
container's bare text won't). */
[data-module="propresenter-timers"] .tm-label {
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
[data-module="propresenter-timers"] .tm-time { [data-module="propresenter-timers"] .tm-time {
flex: none; flex: none;
font-size: var(--tm-time-size); font-size: 32px; /* fit() sets the real size per card */
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -113,14 +186,14 @@
white-space: nowrap; white-space: nowrap;
} }
/* Admin "Timer font: Monospace" — steadier digits as the numbers change. /* Admin "Timer font: Monospace" — steadier digits as the numbers change. */
Applies to timer and LTC values alike (both are .tm-time). */
[data-module="propresenter-timers"] .tm-mono .tm-time { [data-module="propresenter-timers"] .tm-mono .tm-time {
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace; font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
letter-spacing: 0; letter-spacing: 0;
} }
[data-module="propresenter-timers"] .tm-state { [data-module="propresenter-timers"] .tm-status,
[data-module="propresenter-timers"] .tm-detail {
flex: none; flex: none;
max-width: 100%; max-width: 100%;
font-size: var(--tm-meta-size); font-size: var(--tm-meta-size);
@@ -132,102 +205,98 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-timers"] .tm-status[hidden],
[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; }
/* ── state looks ── */ [data-module="propresenter-timers"] .tm-detail {
font-weight: 600;
[data-module="propresenter-timers"] .tm-card.is-running { letter-spacing: 0.04em;
border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); text-transform: none;
background: color-mix(in srgb, var(--accent) 10%, transparent); opacity: 0.85;
}
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card.is-complete {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
background: color-mix(in srgb, var(--warn) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
/* A solo tile whose timer was deleted in ProPresenter. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
/* Overrun: readable across the room even on a wall display. */
[data-module="propresenter-timers"] .tm-card.is-overrun {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 22%, transparent);
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
}
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
color: var(--danger);
font-weight: 800;
} }
@keyframes tm-overrun-pulse { /* A provider's status line carries its own tone ("ahead 1:20" in green). */
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); } [data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); }
} [data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
/* ── the LTC card: visually its own thing ── */ /* Explanatory notes read as sentences, not badges. */
[data-module="propresenter-timers"] .tm-status.is-note {
[data-module="propresenter-timers"] .tm-card-ltc {
border-style: dashed;
}
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
font-size: var(--tm-ltc-size);
}
[data-module="propresenter-timers"] .tm-ltc-badge {
flex: none;
padding: 0.15em 0.55em;
border-radius: 5px;
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--text);
font-size: 0.95em;
letter-spacing: 0.12em;
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
color: var(--warn);
animation: tm-nosignal-blink 1.6s steps(2) infinite;
}
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors,
stopping the clock is usually somebody doing their job. */
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); }
/* No timecode API on this ProPresenter: say so, quietly. */
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
text-transform: none; text-transform: none;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: normal; white-space: normal;
text-align: center; text-align: center;
} }
/* ── state looks (feedbacks) ── */
[data-module="propresenter-timers"] .tm-card.is-ok {
border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border));
background: color-mix(in srgb, var(--tm-ok) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); }
[data-module="propresenter-timers"] .tm-card.is-warn {
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
background: color-mix(in srgb, var(--tm-warn) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); }
/* The wall clock is information, not a state — plain text colour. */
[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); }
/* Overrun: readable across the room even on a wall display. */
[data-module="propresenter-timers"] .tm-card.is-danger {
border-color: var(--tm-danger);
background: color-mix(in srgb, var(--tm-danger) 22%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-card.is-overrun {
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
}
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; }
@keyframes tm-overrun-pulse {
50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); }
}
/* A solo tile whose timer is no longer offered. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
/* ── the LTC card: visually its own thing ── */
[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; }
[data-module="propresenter-timers"] .tm-ltc-badge {
flex: none;
padding: 0.15em 0.55em;
border-radius: 5px;
background: color-mix(in srgb, var(--tm-ok) 24%, transparent);
color: var(--text);
font-size: 0.95em;
letter-spacing: 0.12em;
}
/* No signal: muted digits, a blinking warning line. */
[data-module="propresenter-timers"] .tm-card.is-nosignal {
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
}
[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status {
animation: tm-nosignal-blink 1.6s steps(2) infinite;
}
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item { [data-module="propresenter-timers"] .tm-menu-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 14px;
width: 100%; width: 100%;
padding: 6px 9px; padding: 6px 9px;
background: none; background: none;
@@ -241,16 +310,41 @@
white-space: nowrap; white-space: nowrap;
} }
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); } [data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
[data-module="propresenter-timers"] .tm-menu-item .check {
width: 14px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; } [data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
properties don't reach it — the shell's palette does.) */
[data-module="propresenter-timers"] .tm-switch {
flex: none;
position: relative;
width: 26px;
height: 14px;
border-radius: 7px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--muted) 25%, transparent);
transition: background 0.15s ease, border-color 0.15s ease;
}
[data-module="propresenter-timers"] .tm-switch::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text);
transition: transform 0.15s ease;
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
border-color: var(--accent);
background: var(--accent);
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
transform: translateX(12px);
background: var(--bg);
}
[data-module="propresenter-timers"] .tm-menu-note { [data-module="propresenter-timers"] .tm-menu-note {
padding: 6px 9px; padding: 6px 9px;
+184 -6
View File
@@ -94,6 +94,35 @@ body { overflow: auto; }
} }
.module-health.error { color: var(--danger); } .module-health.error { color: var(--danger); }
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
"no ProPresenter host configured"): a readout, so it sits in a bordered
monospace box a shade lighter than the card; ok / connecting / error
show as the text (and border) colour, the box itself never changes */
.module-status {
display: inline-block;
max-width: 100%;
margin-top: 8px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px;
line-height: 1.45;
color: var(--muted);
overflow-wrap: anywhere;
}
.module-status:empty { display: none; }
.module-status.ok { color: var(--text); }
.module-status.connecting {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
}
.module-status.error {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
}
.module-mount-error { .module-mount-error {
margin-top: 8px; margin-top: 8px;
padding: 8px 10px; padding: 8px 10px;
@@ -121,6 +150,10 @@ body { overflow: auto; }
border-radius: 9px; border-radius: 9px;
} }
.field-group-title { .field-group-title {
display: flex;
align-items: center;
gap: 7px;
position: relative; /* the ⓘ popover hangs off this row */
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -128,8 +161,82 @@ body { overflow: auto; }
color: var(--muted); color: var(--muted);
margin-bottom: 10px; margin-bottom: 10px;
} }
.field-group-name { min-width: 0; }
.field-group .field:last-child { margin-bottom: 0; } .field-group .field:last-child { margin-bottom: 0; }
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
focus peeks at the text; a click pins it open until the next click. */
.group-info { display: inline-flex; align-items: center; }
.group-info-btn {
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--muted);
border-radius: 50%;
background: transparent;
color: var(--muted);
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
text-transform: none;
letter-spacing: 0;
cursor: help;
}
.group-info-btn:hover,
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.group-info-pop {
display: none;
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 5;
max-width: min(440px, 100%);
padding: 8px 10px;
background: color-mix(in srgb, var(--text) 6%, var(--panel));
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
color: var(--text);
font-size: 12px;
font-weight: 400;
line-height: 1.5;
letter-spacing: normal;
text-transform: none;
white-space: normal;
cursor: auto;
}
/* keeps the popover open while the pointer crosses the gap down to it */
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
.group-info:hover .group-info-pop,
.group-info-btn:focus-visible + .group-info-pop,
.group-info.is-open .group-info-pop { display: block; }
/* group toggle (configGroups[name].toggle): the named switch sits at the
right of the title row as the group's on/off; the body dims while it is
off but stays editable */
.field-group-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
flex: none;
}
.field-group-toggle .field {
margin: 0;
max-width: none;
font-size: 12px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text);
}
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
/* collapsible groups (manifest configGroups[name].collapsed) */ /* collapsible groups (manifest configGroups[name].collapsed) */
details.field-group > summary.field-group-title { details.field-group > summary.field-group-title {
cursor: pointer; cursor: pointer;
@@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
gap: 4px 16px; gap: 4px 16px;
} }
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } .field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } } @media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
.field-group-help, /* one line of guidance under a control (field `help`) */
.field-help { .field-help {
display: block; display: block;
font-size: 11.5px; font-size: 11.5px;
line-height: 1.45; line-height: 1.45;
color: var(--muted); color: var(--muted);
margin-top: 2px;
} }
.field-group-help { margin: -4px 0 8px; }
.field-help { margin-top: 2px; }
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s /* showWhen: a hidden dependent field must actually collapse — `.field`'s
@@ -190,12 +295,19 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
} }
.field.switch-field .switch-label { font-size: 13px; } .field.switch-field .switch-label { font-size: 13px; }
/* the card's one action row: status on the left, Configure / Apply on the right */
.module-actions { .module-actions {
display: flex; display: flex;
align-items: center; align-items: flex-end; /* the button stays bottom-right when a readout wraps */
gap: 10px; gap: 10px;
margin-top: 4px; margin-top: 10px;
} }
.module-name.is-toggle { cursor: pointer; }
.module-name.is-toggle:hover,
.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; }
.module-actions .admin-status { flex: 1; margin-top: 0; }
.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; }
.module-actions .btn { flex: none; margin-left: auto; }
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; } .admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
.admin-status.error { color: var(--danger); } .admin-status.error { color: var(--danger); }
@@ -312,3 +424,69 @@ a.btn { text-decoration: none; }
.catalog-title { display: flex; align-items: center; gap: 8px; } .catalog-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 2px; } .catalog-row .module-health { margin-top: 2px; }
/* ── theme ──────────────────────────────────────────────────────────── */
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */
.theme-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.theme-swatch {
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font: inherit;
font-size: 12px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.theme-swatch:hover { border-color: var(--muted); }
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-preview {
position: relative;
display: block;
height: 46px;
border-radius: 6px;
background: var(--sw-bg);
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
overflow: hidden;
}
.theme-preview::before {
content: "";
position: absolute;
left: 8px; right: 8px; top: 10px; bottom: 8px;
border-radius: 4px;
background: var(--sw-panel);
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
}
.theme-preview::after {
content: "";
position: absolute;
left: 14px; top: 16px;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--sw-accent);
box-shadow: 0 0 6px var(--sw-accent);
}
.theme-name { display: flex; align-items: center; gap: 6px; }
.theme-swatch.is-current .theme-name::after {
content: "current";
margin-left: auto;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
#theme-status { margin-top: 4px; }
+281 -20
View File
@@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status');
const updateBtn = document.getElementById('update-btn'); const updateBtn = document.getElementById('update-btn');
const updateChanges = document.getElementById('update-changes'); const updateChanges = document.getElementById('update-changes');
const shellVersionEl = document.getElementById('shell-version'); const shellVersionEl = document.getElementById('shell-version');
const themeSection = document.getElementById('theme-section');
const themeSwatches = document.getElementById('theme-swatches');
const themeStatus = document.getElementById('theme-status');
/** The shell version the page loaded with — the update flow watches it change. */ /** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = ''; let shellVersion = '';
@@ -58,6 +61,7 @@ function showLogin() {
availableSection.hidden = true; availableSection.hidden = true;
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true;
passcodeInput.focus(); passcodeInput.focus();
} }
@@ -277,6 +281,99 @@ function groupHasValues(fields, mod) {
}); });
} }
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
let groupInfoSeq = 0;
function closeGroupInfos(except) {
for (const open of document.querySelectorAll('.group-info.is-open')) {
if (open === except) continue;
open.classList.remove('is-open');
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
}
}
/**
* configGroups[name].help as an ⓘ beside the group title: click toggles a
* small popover with the text (hover and keyboard focus show it too), so a
* group can carry a paragraph without pushing its fields down the page. A
* real button, labelled, so it works from the keyboard; inside a <summary>
* it must never fold the group.
*/
function groupInfo(groupName, text) {
const wrap = document.createElement('span');
wrap.className = 'group-info';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'group-info-btn';
btn.textContent = 'i';
btn.setAttribute('aria-label', `About ${groupName}`);
btn.setAttribute('aria-expanded', 'false');
const pop = document.createElement('span');
pop.className = 'group-info-pop';
pop.id = `group-info-${++groupInfoSeq}`;
pop.setAttribute('role', 'note');
pop.textContent = text;
btn.setAttribute('aria-describedby', pop.id);
wrap.append(btn, pop);
const setOpen = (open) => {
wrap.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
btn.addEventListener('click', (e) => {
e.preventDefault(); // a click in a <summary> would otherwise fold the group
e.stopPropagation();
const open = !wrap.classList.contains('is-open');
closeGroupInfos(wrap);
setOpen(open);
});
pop.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
});
wrap.addEventListener('keydown', (e) => {
// Enter / Space activate the button (a click); they must not reach a
// <summary> above as well. Escape closes and hands focus back.
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
e.stopPropagation();
setOpen(false);
btn.focus();
}
});
return wrap;
}
// A click anywhere else, or Escape, closes an open popover.
document.addEventListener('pointerdown', (e) => {
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeGroupInfos();
});
/**
* configGroups[name].toggle: the named switch/boolean field is the group's
* on/off. It moves into the title row, right-aligned, and the body dims
* while it is off (fields stay editable). The field itself is untouched —
* it is still read on save and still drives showWhen dependents.
*/
function mountGroupToggle(group, field, control) {
const slot = document.createElement('span');
slot.className = 'field-group-toggle';
slot.appendChild(field);
// Inside a <summary>, flipping the switch must not fold the group.
slot.addEventListener('click', (e) => e.stopPropagation());
slot.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
});
group.title.appendChild(slot);
group.el.classList.add('has-toggle');
const sync = () => group.el.classList.toggle('is-off', !control.checked);
control.addEventListener('change', sync);
sync();
}
function buildModuleCard(mod) { function buildModuleCard(mod) {
const card = document.createElement('section'); const card = document.createElement('section');
card.className = 'admin-card'; card.className = 'admin-card';
@@ -351,8 +448,11 @@ function buildModuleCard(mod) {
desc.textContent = mod.description; desc.textContent = mod.description;
card.appendChild(desc); card.appendChild(desc);
// The module's own words about its upstream ("polling timers on
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
// and the state (ok / connecting / error) is its text colour.
const healthEl = document.createElement('div'); const healthEl = document.createElement('div');
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); healthEl.className = 'module-status ' + healthClass(mod);
healthEl.textContent = healthText(mod); healthEl.textContent = healthText(mod);
card.appendChild(healthEl); card.appendChild(healthEl);
@@ -395,7 +495,9 @@ function buildModuleCard(mod) {
const collapsibles = []; // { el, meta, fields } groups that fold const collapsibles = []; // { el, meta, fields } groups that fold
// Fields render in schema order; a `group` starts a labeled subsection // Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it. The manifest's optional // and consecutive same-group fields share it. The manifest's optional
// `configGroups[name]` adds { collapsed, columns, help } for that group. // `configGroups[name]` adds { collapsed, columns, help, toggle } for
// that group: help is an ⓘ beside the title, toggle names the switch
// that is the group's on/off and lives in the title row.
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
let groupName = null; let groupName = null;
let container = form; let container = form;
@@ -412,7 +514,11 @@ function buildModuleCard(mod) {
group.className = 'field-group'; group.className = 'field-group';
const title = document.createElement(folds ? 'summary' : 'div'); const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title'; title.className = 'field-group-title';
title.textContent = g; const titleName = document.createElement('span');
titleName.className = 'field-group-name';
titleName.textContent = g;
title.appendChild(titleName);
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
group.appendChild(title); group.appendChild(title);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'field-group-body'; body.className = 'field-group-body';
@@ -421,16 +527,16 @@ function buildModuleCard(mod) {
body.classList.add('is-columns'); body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols)); 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); group.appendChild(body);
form.appendChild(group); form.appendChild(group);
container = body; container = body;
currentGroup = { el: group, meta, fields: [] }; currentGroup = {
el: group,
title,
meta,
fields: [],
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
};
if (folds) collapsibles.push(currentGroup); if (folds) collapsibles.push(currentGroup);
} else { } else {
container = form; container = form;
@@ -444,7 +550,13 @@ function buildModuleCard(mod) {
else if (refetch) reloaders.push(() => refetch()); else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
if (currentGroup) currentGroup.fields.push({ key, spec }); if (currentGroup) currentGroup.fields.push({ key, spec });
container.appendChild(field); if (currentGroup && control && key === currentGroup.toggleKey) {
// The group's on/off: title row, not the body (never both). A
// toggle naming a non-boolean field is ignored and renders normally.
mountGroupToggle(currentGroup, field, control);
} else {
container.appendChild(field);
}
} }
// A field with showWhen:"K" is visible only while K's toggle is on. // A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) { for (const dep of dependents) {
@@ -473,26 +585,52 @@ function buildModuleCard(mod) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'module-actions'; actions.className = 'module-actions';
const save = document.createElement('button'); const save = document.createElement('button');
save.className = 'btn primary';
save.textContent = 'Save & re-init';
const status = document.createElement('span'); const status = document.createElement('span');
status.className = 'admin-status'; status.className = 'admin-status';
actions.append(save, status); actions.append(status, save);
form.appendChild(actions); // A card is a status line until someone wants to change something: the
// form starts folded, and the row at the bottom holds one button that
// does both jobs — "Configure" opens the form and becomes "Apply", which
// saves without closing it. The name is the other way in and out.
card.appendChild(form); card.appendChild(form);
card.appendChild(actions);
const openKey = 'proddash:admin:open:' + mod.id;
const setOpen = (open) => {
form.hidden = !open;
status.hidden = !open;
save.textContent = open ? 'Apply' : 'Configure';
save.className = open ? 'btn primary' : 'btn';
// Folded, the readout shares the bottom row with Configure instead of
// taking a line of its own; open, it goes back above the form.
if (open) card.insertBefore(healthEl, mountError);
else actions.insertBefore(healthEl, status);
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ }
};
let wasOpen = false;
try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ }
setOpen(wasOpen);
name.classList.add('is-toggle');
name.title = 'Show or hide this module\'s settings';
name.tabIndex = 0;
name.setAttribute('role', 'button');
name.addEventListener('click', () => setOpen(form.hidden));
name.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); }
});
save.addEventListener('click', async () => { save.addEventListener('click', async () => {
if (form.hidden) return setOpen(true);
const config = {}; const config = {};
for (const [key, read] of readers) config[key] = read(); for (const [key, read] of readers) config[key] = read();
save.disabled = true; save.disabled = true;
status.textContent = 'Saving…'; status.textContent = 'Applying…';
status.className = 'admin-status'; status.className = 'admin-status';
try { try {
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, { await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ config }), body: JSON.stringify({ config }),
}); });
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.';
status.className = 'admin-status ok'; status.className = 'admin-status ok';
refreshStatus(); refreshStatus();
// What the module can list may have changed (credentials just // What the module can list may have changed (credentials just
@@ -531,7 +669,7 @@ async function refreshStatus() {
if (!els) continue; if (!els) continue;
els.dot.className = 'dot ' + healthClass(mod); els.dot.className = 'dot ' + healthClass(mod);
els.healthEl.textContent = healthText(mod); els.healthEl.textContent = healthText(mod);
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); els.healthEl.className = 'module-status ' + healthClass(mod);
if (!mod.incompatible) { if (!mod.incompatible) {
els.mountError.hidden = !mod.mountError; els.mountError.hidden = !mod.mountError;
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
@@ -543,7 +681,7 @@ async function refreshStatus() {
} }
} }
/* ── named layouts ──────────────────────────────────────────────────── */ /* ── saved layouts ──────────────────────────────────────────────────── */
async function loadLayouts() { async function loadLayouts() {
let body; let body;
@@ -559,7 +697,7 @@ async function loadLayouts() {
if (!layouts.length) { if (!layouts.length) {
const none = document.createElement('div'); const none = document.createElement('div');
none.className = 'layouts-empty'; none.className = 'layouts-empty';
none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.'; none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.';
layoutsList.appendChild(none); layoutsList.appendChild(none);
return; return;
} }
@@ -640,10 +778,133 @@ async function loadState() {
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
loadTheme();
loadLayouts(); loadLayouts();
loadCatalog(false); loadCatalog(false);
} }
/* ── theme ──────────────────────────────────────────────────────────── */
/* The dashboard palette is a server-wide choice. The server lists the
presets (GET /api/theme — public, dashboards need it too) and the admin
page shows them as swatches; picking one POSTs it, applies it here at
once, and the server's `theme` shell event switches every open dashboard
— and any other admin page — live. The palettes themselves are
html[data-theme="…"] variable sets in the shell's style.css, which this
page shares, so the admin page follows its own choice. */
const LS_THEME = 'proddash:theme';
let themeList = [];
let currentTheme = '';
function applyTheme(id) {
const theme = String(id || '');
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
}
// The last-known theme first (shared with the dashboard, same origin), so
// the page doesn't flash the default palette before the server answers.
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
function themeName(id) {
return themeList.find((t) => t.id === id)?.name || id;
}
function renderThemes() {
themeSwatches.innerHTML = '';
for (const t of themeList) {
const current = t.id === currentTheme;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', current ? 'true' : 'false');
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
const preview = document.createElement('span');
preview.className = 'theme-preview';
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
const name = document.createElement('span');
name.className = 'theme-name';
name.textContent = t.name;
btn.append(preview, name);
btn.addEventListener('click', () => chooseTheme(t.id));
themeSwatches.appendChild(btn);
}
}
/**
* Fetch the server's theme and list. Applied to this page either way (the
* endpoint is public, so even the login card wears the right palette); the
* swatch section itself is only revealed for an unlocked page.
*/
async function loadTheme(reveal = true) {
let body;
try {
body = await api('/api/theme');
} catch {
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
return;
}
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
currentTheme = String(body.theme || '');
applyTheme(currentTheme);
renderThemes();
if (reveal) themeSection.hidden = !themeList.length;
}
loadTheme(false);
async function chooseTheme(id) {
if (id === currentTheme) return;
const previous = currentTheme;
currentTheme = id;
applyTheme(id); // at once, here — the server's event confirms it everywhere else
renderThemes();
themeStatus.textContent = '';
themeStatus.className = 'admin-status';
try {
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
themeStatus.className = 'admin-status ok';
} catch (e) {
currentTheme = previous;
applyTheme(previous);
renderThemes();
themeStatus.textContent = e.message;
themeStatus.className = 'admin-status error';
if (e.authRequired) showLogin();
}
}
// Follow a theme change made elsewhere (another admin page) live.
let themeEvents = null;
let themeEventsRetry = null;
function subscribeThemeEvents() {
try { themeEvents?.close(); } catch { /* not open */ }
themeEvents = new EventSource('/api/events');
themeEvents.addEventListener('theme', (e) => {
let theme = '';
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
if (!theme || theme === currentTheme) return;
currentTheme = theme;
applyTheme(theme);
renderThemes();
});
themeEvents.onerror = () => {
if (themeEvents.readyState === EventSource.CLOSED) {
clearTimeout(themeEventsRetry);
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
}
};
}
subscribeThemeEvents();
/* ── the repo: available modules, module updates, shell update ──────── */ /* ── the repo: available modules, module updates, shell update ──────── */
function fmtTime(ms) { function fmtTime(ms) {
+7 -9
View File
@@ -39,11 +39,14 @@
<div class="admin-status" id="update-status"></div> <div class="admin-status" id="update-status"></div>
</section> </section>
<section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
<h1 class="admin-h1">Modules</h1> <h1 class="admin-h1">Modules</h1>
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
live in each tile's gear menu instead.</p>
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at <p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p> <code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
<div id="modules-list"></div> <div id="modules-list"></div>
@@ -51,9 +54,6 @@
<section id="available-section" hidden> <section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1> <h1 class="admin-h1">Available modules</h1>
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
newer ProdDash can't be installed until the shell is updated.</p>
<div class="catalog-toolbar"> <div class="catalog-toolbar">
<button class="btn small" id="catalog-refresh">Check again</button> <button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span> <span class="admin-status" id="catalog-status"></span>
@@ -62,9 +62,7 @@
</section> </section>
<section id="layouts-section" hidden> <section id="layouts-section" hidden>
<h1 class="admin-h1">Named layouts</h1> <h1 class="admin-h1">Saved layouts</h1>
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
copies it into that browser; deleting one here does not touch any browser's current layout.</p>
<div id="layouts-list"></div> <div id="layouts-list"></div>
</section> </section>
</main> </main>
+227 -73
View File
@@ -71,12 +71,34 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
/* ── dropdown menus ─────────────────────────────────────────────────── */ /* ── dropdown menus ─────────────────────────────────────────────────── */
/* A menu opens the moment its button is clicked, drawn from what the page
already knows; whatever its rebuild has to fetch is slotted in when it
lands. It used to open only once the rebuild had finished, which made the
+ picker hostage to a network round trip — one that never completes when
the tiles' streams hold every connection the browser allows (see the
shared-stream pool below). rebuild(menu, stillOpen) may be async;
stillOpen() says whether the open it was started for is still current,
so a late fetch can't repaint a menu that has since closed or reopened. */
function wireDropdown(btnId, menuId, rebuild) { function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
btn.addEventListener('click', async () => { let generation = 0;
if (menu.hidden) await rebuild(menu); btn.addEventListener('click', () => {
menu.hidden = !menu.hidden; if (!menu.hidden) {
menu.hidden = true;
return;
}
const gen = ++generation;
const stillOpen = () => gen === generation && !menu.hidden;
menu.hidden = false;
// Whatever the rebuild does, the menu is on screen; a failure (now or
// later) leaves its previous contents rather than an invisible menu.
const failed = (err) => console.error(`[shell] ${menuId} could not be rebuilt:`, err);
try {
Promise.resolve(rebuild(menu, stillOpen)).catch(failed);
} catch (err) {
failed(err);
}
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) { if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
@@ -106,11 +128,22 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) {
let registry = new Map(); // module id -> manifest from GET /api/modules let registry = new Map(); // module id -> manifest from GET /api/modules
/** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything
awaiting it. */
function fetchWithTimeout(url, ms, opts = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), ms);
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
}
/** Refresh the enabled-module list. On failure the last known list is kept /** Refresh the enabled-module list. On failure the last known list is kept
(the server may just be restarting mid-service). Returns true on success. */ (the server may just be restarting mid-service). Returns true on success. */
async function loadRegistry() { async function loadRegistry() {
try { try {
const res = await fetch('/api/modules'); // Slow modules' tiles() are cut off server-side at 2 s, so a healthy
// answer is well inside this.
const res = await fetchWithTimeout('/api/modules', 8000);
if (!res.ok) throw new Error('modules ' + res.status); if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json(); const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m])); registry = new Map((body.modules || []).map((m) => [m.id, m]));
@@ -753,6 +786,84 @@ function tileMessage(entry, text) {
entry.body.appendChild(msg); entry.body.appendChild(msg);
} }
/* ── shared event streams ───────────────────────────────────────────── */
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
EventSource holds one of them for as long as it lives. One stream per
tile plus the shell's own /api/events reaches that ceiling at five or six
tiles, and from then on every further request — the + picker's module
list, a new tile's /state, its stream — sits in the browser's queue
waiting for a connection that is never handed back. So tiles that ask
for the same stream share one connection: the pool keeps one EventSource
per URL, fans its events out to every subscriber, and reconnects it (on
the same 3-second timer the per-tile version used) while anyone is still
listening. A tile joining an already-open stream is told `open` straight
away, so its backfill runs exactly as if the connection were its own. */
const ssePool = new Map(); // url -> shared source
function sseSubscribe(url, handlers = {}) {
let shared = ssePool.get(url);
if (!shared) {
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() };
const fanOut = (kind, e) => {
for (const sub of [...shared.subs]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
}
};
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
shared.connect = () => {
clearTimeout(shared.retryTimer);
shared.retryTimer = null;
try { shared.es?.close(); } catch { /* already closed */ }
const es = new EventSource(url);
shared.es = es;
es.onopen = (e) => fanOut('open', e);
es.onmessage = (e) => fanOut('message', e);
es.onerror = (e) => {
fanOut('error', e);
// EventSource retries transient drops itself but gives up for good on
// a completed non-SSE response (a 502 while the upstream is down).
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
clearTimeout(shared.retryTimer);
shared.retryTimer = setTimeout(shared.connect, 3000);
}
};
for (const name of shared.names) shared.listen(name);
};
ssePool.set(url, shared);
}
const sub = { handlers };
shared.subs.add(sub);
for (const name of Object.keys(handlers.events || {})) {
if (shared.names.has(name)) continue;
shared.names.add(name);
if (shared.es) shared.listen(name);
}
if (!shared.es || shared.es.readyState === EventSource.CLOSED) {
// first subscriber, or the stream had given up while nobody listened
shared.connect();
} else if (shared.es.readyState === EventSource.OPEN) {
// Joining mid-flight: report the open the way a fresh connection would
// have, a moment later, so the caller's own setup finishes first.
setTimeout(() => {
if (!shared.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0);
}
return {
close() {
if (!shared.subs.delete(sub) || shared.subs.size) return;
clearTimeout(shared.retryTimer);
try { shared.es?.close(); } catch { /* already closed */ }
ssePool.delete(url);
},
};
}
/** /**
* The API handed to each module instance — the whole surface a module may * The API handed to each module instance — the whole surface a module may
* touch outside its root element. See docs/MODULE-GUIDE.md. * touch outside its root element. See docs/MODULE-GUIDE.md.
@@ -798,40 +909,17 @@ function buildModuleApi(tile, man, entry) {
}, },
/** /**
* EventSource scoped the same way, with auto-reconnect: EventSource * EventSource scoped the same way, with auto-reconnect: a stream that
* retries transient drops itself but gives up for good when a retry gets * gives up (a 502 while the upstream is down) is recreated on a timer
* a completed non-SSE response (a 502 while the upstream is down), so a * until it works again. Tiles asking for the same stream share one
* closed stream is recreated on a timer until it works again. * connection — see the pool above; a module sees no difference.
* handlers: { open(e), error(e), message(e), events: { name: fn } } * handlers: { open(e), error(e), message(e), events: { name: fn } }
*/ */
sse(subPath, handlers = {}) { sse(subPath, handlers = {}) {
const url = '/api/modules/' + man.id + subPath; const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
let es = null;
let retryTimer = null;
let closed = false;
const connect = () => {
if (closed) return;
try { es?.close(); } catch { /* already closed */ }
es = new EventSource(url);
if (handlers.open) es.onopen = handlers.open;
if (handlers.message) es.onmessage = handlers.message;
for (const [name, fn] of Object.entries(handlers.events || {})) {
es.addEventListener(name, fn);
}
es.onerror = (e) => {
try { handlers.error?.(e); } catch { /* module's problem */ }
if (es.readyState === EventSource.CLOSED) {
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, 3000);
}
};
};
connect();
const handle = { const handle = {
close() { close() {
closed = true; shared.close();
clearTimeout(retryTimer);
try { es?.close(); } catch { /* already closed */ }
sseHandles.delete(handle); sseHandles.delete(handle);
}, },
}; };
@@ -1088,14 +1176,26 @@ function toggleSettingsPopover(tile, el) {
} }
} }
const isSwitch = type === 'boolean' || type === 'switch';
const field = document.createElement('label'); const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : ''); field.className = 'field' + (isSwitch ? ' check' : '');
if (type === 'boolean') { if (isSwitch) {
// A switch: on is shown/enabled, off is hidden/disabled — the label
// names what it turns on. A real checkbox stays underneath (keyboard,
// label clicks and `change` all keep working); the track is the look.
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = label;
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = Boolean(current); input.checked = Boolean(current);
field.append(input, document.createTextNode(label)); const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
field.append(text, toggle);
inputs.set(key, () => input.checked); inputs.set(key, () => input.checked);
live(input); live(input);
} else if (type === 'select' && Array.isArray(spec.options)) { } else if (type === 'select' && Array.isArray(spec.options)) {
@@ -1182,8 +1282,21 @@ function renderLayout(list) {
/* ── menus ──────────────────────────────────────────────────────────── */ /* ── menus ──────────────────────────────────────────────────────────── */
wireDropdown('add-btn', 'add-menu', async (menu) => { /* What the + picker shows, as a string: repainting a menu the user is
await loadRegistry(); already pointing at is only worth it when something in it changed. */
function addMenuSignature() {
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
}
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
// Painted from the list already in hand, then refreshed: a module's tile
// list is live data, and Admin may have enabled or disabled something.
const shown = addMenuSignature();
renderAddMenu(menu);
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
});
function renderAddMenu(menu) {
menu.innerHTML = ''; menu.innerHTML = '';
const manifests = [...registry.values()]; const manifests = [...registry.values()];
if (!manifests.length) { if (!manifests.length) {
@@ -1215,9 +1328,9 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
}, { sub: man.description || '' })); }, { sub: man.description || '' }));
} }
} }
}); }
wireDropdown('layout-btn', 'layout-menu', async (menu) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
menu.appendChild(menuItem('Save as named layout…', async () => { menu.appendChild(menuItem('Save as named layout…', async () => {
@@ -1250,43 +1363,46 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
divider.className = 'menu-divider'; divider.className = 'menu-divider';
menu.appendChild(divider); menu.appendChild(divider);
// named layouts stored on the server const reset = menuItem('Reset layout', () => {
let layouts = [];
try {
const res = await fetch('/api/layouts');
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (layouts.length) {
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
menu.appendChild(title);
for (const l of layouts) {
menu.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
menu.appendChild(divider.cloneNode());
}
menu.appendChild(menuItem('Reset layout', () => {
menu.hidden = true; menu.hidden = true;
renderLayout([]); renderLayout([]);
saveLayout(); saveLayout();
toast('Layout cleared'); toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' })); }, { danger: true, sub: 'Remove every tile from this browser' });
menu.appendChild(reset);
// Named layouts stored on the server slot in above Reset once they
// arrive; until then (or if they never do) the menu is already usable.
let layouts = [];
try {
const res = await fetchWithTimeout('/api/layouts', 8000);
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (!layouts.length || !stillOpen()) return;
const block = document.createDocumentFragment();
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
block.appendChild(title);
for (const l of layouts) {
block.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
block.appendChild(divider.cloneNode());
menu.insertBefore(block, reset);
}); });
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */ /* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
@@ -1511,6 +1627,7 @@ function subscribeEvents() {
try { eventsSource?.close(); } catch { /* not open */ } try { eventsSource?.close(); } catch { /* not open */ }
eventsSource = new EventSource('/api/events'); eventsSource = new EventSource('/api/events');
eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); eventsSource.addEventListener('modules-changed', () => handleModulesChanged());
eventsSource.addEventListener('theme', handleThemeEvent);
eventsSource.onerror = () => { eventsSource.onerror = () => {
if (eventsSource.readyState === EventSource.CLOSED) { if (eventsSource.readyState === EventSource.CLOSED) {
clearTimeout(eventsRetry); clearTimeout(eventsRetry);
@@ -1547,6 +1664,43 @@ async function handleModulesChanged() {
} }
} }
/* ── theme ──────────────────────────────────────────────────────────── */
/* The dashboard's palette is a server-wide choice (Admin → Theme). Five
preset variable sets live in style.css as html[data-theme="…"]; applying
one is setting that attribute. The last-known theme goes on synchronously
from localStorage so a reload never flashes the default palette, then the
server's answer (GET /api/theme) wins, and a `theme` shell event on
/api/events switches every open dashboard the moment an admin picks
another. Modules style with the shell variables, so they follow; a colour
a tile is configured with is set on the tile itself and stays as chosen. */
const LS_THEME = 'proddash:theme';
function applyTheme(id) {
const theme = String(id || '');
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
}
/** `theme` shell event from /api/events (wired in subscribeEvents). */
function handleThemeEvent(e) {
try {
applyTheme(JSON.parse(e.data).theme);
} catch { /* a malformed frame changes nothing */ }
}
function initTheme() {
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
fetch('/api/theme', { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body?.theme) applyTheme(body.theme); })
.catch(() => { /* offline: the remembered theme stands */ });
}
initTheme();
/* ── boot ───────────────────────────────────────────────────────────── */ /* ── boot ───────────────────────────────────────────────────────────── */
async function boot() { async function boot() {
+120 -5
View File
@@ -10,6 +10,7 @@
--text: #e6edf3; --text: #e6edf3;
--muted: #8b98a5; --muted: #8b98a5;
--accent: #2ee59a; --accent: #2ee59a;
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
--danger: #f4433c; --danger: #f4433c;
--warn: #f0b429; --warn: #f0b429;
/* grid geometry (shell.js reads these) */ /* grid geometry (shell.js reads these) */
@@ -589,12 +590,15 @@ body.fullscreen #grid { padding-bottom: 0; }
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); } .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 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 .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 .field-group-body.is-columns { .tile-settings .field-group-body.is-columns {
display: grid; display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px; gap: 6px 14px; /* the grid gap is the whole row spacing: no per-field margin */
} }
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; } .tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } .tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@container (max-width: 420px) { @container (max-width: 420px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } .tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
@@ -636,14 +640,64 @@ body.fullscreen #grid { padding-bottom: 0; }
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
} }
/* boolean / switch settings: label on the left, switch on the right (help,
when there is any, wraps under both) */
.field.check { .field.check {
flex-direction: row; flex-direction: row;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 4px 10px;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
} }
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); } .field.check .check-label { flex: 1 1 0; min-width: 0; }
.field.check .field-help { flex-basis: 100%; }
/* The switch: the admin page's module switch, same track, knob and colours,
so a toggle reads the same on both surfaces. On = shown/enabled (accent),
off = hidden/disabled (border track, muted knob). The real checkbox lies
invisibly over the track — still focusable, so Tab reaches it and Space
flips it — and the label around it makes the text clickable too. */
.switch {
position: relative;
display: inline-flex;
align-items: center;
flex: none;
cursor: pointer;
}
.switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
pointer-events: none;
}
.switch .track {
display: block;
position: relative;
width: 34px;
height: 19px;
border-radius: 999px;
background: var(--border);
transition: background 0.2s;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.2s, background 0.2s;
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
/* color fields: label on the left, swatch on the right */ /* color fields: label on the left, swatch on the right */
.field.color-field { .field.color-field {
@@ -709,7 +763,7 @@ body.fullscreen #grid { padding-bottom: 0; }
.btn.primary { .btn.primary {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
color: #06281a; color: var(--accent-text);
font-weight: 600; font-weight: 600;
} }
@@ -756,3 +810,64 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) { @media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; } #topbar { padding: 0 8px; gap: 6px; }
} }
/* ── themes ────────────────────────────────────────────────────────── */
/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the
admin page) as html[data-theme="…"]. Each swaps exactly the eight palette
variables the module guide names — nothing else — so every module and
every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js
(GET /api/theme). All four are booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable. */
/* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] {
--bg: #06101f;
--panel: #0c1a30;
--border: #1a2b48;
--text: #e6eefb;
--muted: #8ea4c8;
--accent: #2ad4ee;
--danger: #ff5a5f;
--warn: #f7b53b;
}
/* Graphite — neutral charcoal, electric-blue accent */
html[data-theme="graphite"] {
--bg: #0f1012;
--panel: #17191d;
--border: #2a2d33;
--text: #f0f2f4;
--muted: #9ca3ad;
--accent: #6cb4ff;
--danger: #ff4d4f;
--warn: #ffb224;
}
/* Ember — warm broadcast black, orange accent (danger stays a cool red,
warn a pale yellow, so the three status colours never blur) */
html[data-theme="ember"] {
--bg: #120c0a;
--panel: #1d1411;
--border: #35251e;
--text: #f6ede6;
--muted: #b39b8c;
--accent: #ff8f3a;
--danger: #f03247;
--warn: #ffd86b;
}
/* Daylight — the light one, for a front-of-house desk in daytime */
html[data-theme="daylight"] {
--bg: #eef1f5;
--panel: #ffffff;
--border: #cfd6e0;
--text: #14181f;
--muted: #5b6675;
--accent: #0b8a5f;
--accent-text: #ffffff; /* dark text drowns on this accent */
--danger: #d1242f;
--warn: #b26a00;
}
+83 -3
View File
@@ -373,14 +373,17 @@ function mountModule(id) {
// Re-require fresh config on every (re)mount; the module code itself stays cached. // Re-require fresh config on every (re)mount; the module code itself stays cached.
const mod = require(serverPath); const mod = require(serverPath);
const config = effectiveConfig(id); const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
// routes over loopback, chiefly — without guessing.
const shell = { port: PORT };
if (typeof mod.init === 'function') { if (typeof mod.init === 'function') {
entry.handle = mod.init({ config, log }) || null; entry.handle = mod.init({ config, log, shell }) || null;
} }
if (typeof mod.routes === 'function') { if (typeof mod.routes === 'function') {
entry.routes = parseRouteTable(mod.routes({ config, log })); entry.routes = parseRouteTable(mod.routes({ config, log, shell }));
} }
if (typeof mod.tiles === 'function') { if (typeof mod.tiles === 'function') {
entry.tiles = () => mod.tiles({ config, log }); entry.tiles = () => mod.tiles({ config, log, shell });
} }
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
} catch (err) { } catch (err) {
@@ -510,6 +513,8 @@ async function clientManifest(id) {
instanceSchema: man.instanceSchema || {}, instanceSchema: man.instanceSchema || {},
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
hasServer: Boolean(man.server), hasServer: Boolean(man.server),
// capabilities other modules consume ("timers") — see the module guide
provides: Array.isArray(man.provides) ? man.provides.map(String) : [],
config: clientConfig(id), config: clientConfig(id),
tiles: await moduleTiles(id), tiles: await moduleTiles(id),
}; };
@@ -541,6 +546,60 @@ setInterval(() => {
} }
}, 25000).unref(); }, 25000).unref();
/* ── themes ─────────────────────────────────────────────────────────── */
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
variable sets themselves live in public/style.css (html[data-theme="…"]);
this list is the one authority on ids and names, and carries the three
colours the admin page previews a theme with. The choice is a shell
setting — `theme` in the data directory's proddash.json — and reaches open
dashboards live as a `theme` shell event. */
const THEMES = [
{ id: 'booth', name: 'Booth', colors: { bg: '#0b0e12', panel: '#131920', accent: '#2ee59a' } },
{ id: 'harbor', name: 'Harbor', colors: { bg: '#06101f', panel: '#0c1a30', accent: '#2ad4ee' } },
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
];
const DEFAULT_THEME = THEMES[0].id;
function themeExists(id) {
return THEMES.some((t) => t.id === id);
}
let currentTheme = themeExists(shellConfig.theme) ? shellConfig.theme : DEFAULT_THEME;
/** What GET /api/theme returns — public, since dashboards need it before anyone logs in. */
function themeView() {
return {
theme: currentTheme,
themes: THEMES.map((t) => ({ id: t.id, name: t.name, colors: { ...t.colors } })),
};
}
/**
* Persist the theme into this machine's proddash.json (every other key there
* — port, adminPasscode, repo… — is kept), remember it, and tell every open
* dashboard and admin page. Throws an httpError for an unknown id or an
* unreadable settings file; the route turns that into the response.
*/
function setTheme(id) {
if (!themeExists(id)) throw httpError(400, `Unknown theme "${id}".`);
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the theme.`);
}
}
writeJson(file, { ...existing, theme: id });
currentTheme = id;
shellConfig.theme = id;
broadcastShellEvent('theme', { theme: id });
return themeView();
}
/* ── the repo: module catalog and shell updates ──────────────────────── */ /* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the /* The admin page installs modules from, and updates the shell against, the
@@ -1109,6 +1168,22 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
} }
/* — theme: { "theme": "<id>" }, validated against THEMES — */
if (urlPath === '/api/admin/theme' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, ...setTheme(String(body.theme ?? '')) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' }); sendJson(res, 404, { error: 'Unknown admin endpoint.' });
} }
@@ -1352,6 +1427,11 @@ function handleRequest(req, res) {
return; return;
} }
/* — theme: public (dashboards need it without a passcode); changed via POST /api/admin/theme — */
if (urlPath === '/api/theme' && req.method === 'GET') {
return sendJson(res, 200, themeView());
}
/* — shell events: open dashboards learn about admin changes live — */ /* — shell events: open dashboards learn about admin changes live — */
if (urlPath === '/api/events' && req.method === 'GET') { if (urlPath === '/api/events' && req.method === 'GET') {
res.writeHead(200, { res.writeHead(200, {