Shell 1.11.0: alerts — a module flashes every dashboard listening on one of six channels

A module that sees something a tech must act on now (a timer overrun, a meter
past its limit, timecode gone) raises an alert on a channel, 1–6, and every
dashboard listening to that channel flashes: the menu bar — brought out for
the duration if it was hidden — and a frame round the screen alternate between
the level's two colours, with the alert's text bold across the bar. Low fades
slowly, medium faster, critical snaps with no fade and floods the board behind
the tiles too, in a muted cut that shows faintly through the tile panels
without costing their content any contrast.

Server: the hub. One alert per channel (a new one replaces the last), timed
out server-side (1–600 s, 15 by default, 0 = held until cleared), broadcast
over the shell stream as `alert` / `alert-clear`; GET /api/alerts for a
dashboard that opens or reconnects mid-alert; POST /api/alerts and
DELETE /api/alerts/<n>, open like a layout save. Modules raise from their
server entry with shell.alert() / shell.clearAlert() or from a tile with
moduleApi.alert() / moduleApi.clearAlert(); a module's alerts end when it
stops. Colours live as alerts.colors in the data directory's proddash.json.

Dashboard: the Layouts menu gains a folded Alerts row with six numbered
toggles; the channel list saves with the layout, local and named (the server
keeps it in a saved layout). Admin: a folded Alerts section with two colour
pickers per level, a swatch running each level's cadence, and a Test row.

Docs: docs/ALERTS.md (the feature), a "Raising alerts" section in the module
guide, README. Tools: tools/alert.js raises/clears/lists from the command
line; tools/alerts.test.js drives it all end to end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-05 16:05:12 -04:00
co-authored by Claude Fable 5.1
parent a3500f98b1
commit 5ca0a672fa
13 changed files with 1572 additions and 12 deletions
+143
View File
@@ -36,6 +36,7 @@ button { font-family: inherit; }
/* ── topbar ────────────────────────────────────────────────────────── */
#topbar {
position: relative; /* the alert banner centres itself against the bar */
flex: none;
display: flex;
align-items: center;
@@ -135,6 +136,110 @@ body.menu-hidden #topbar {
color: var(--accent-text);
}
/* ── alerts ────────────────────────────────────────────────────────── */
/* An alert this dashboard listens to (shell.js sets body[data-alert] to its
level and --alert-a / --alert-b to the level's two colours, from Admin →
Alerts) flashes the shell's chrome: the menu bar's background and a frame
round the whole screen swap between the two colours, with the alert's text
bold in the middle of the bar. Low breathes slowly, medium pulses,
critical snaps between the two with no fade at all — and, alone among
them, floods the board behind the tiles too (below). Low and medium leave
the tiles alone: the frame is what carries their flash on a fullscreen
wall, where the bar (held open for the alert) is the only other chrome. */
@keyframes alert-flash {
0%, 100% { background-color: var(--alert-a); }
50% { background-color: var(--alert-b); }
}
@keyframes alert-frame {
0%, 100% { border-color: var(--alert-a); }
50% { border-color: var(--alert-b); }
}
body[data-alert="low"] { --alert-period: 3.2s; --alert-ease: ease-in-out; }
body[data-alert="medium"] { --alert-period: 1.1s; --alert-ease: ease-in-out; }
body[data-alert="critical"] { --alert-period: 0.6s; --alert-ease: steps(1, end); }
body[data-alert] #topbar {
border-bottom-color: transparent;
animation: alert-flash var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
}
body[data-alert]::after {
content: "";
position: fixed;
inset: 0;
z-index: 85; /* over tiles and menus, under modals and the toast */
pointer-events: none;
border: 6px solid var(--alert-a);
animation: alert-frame var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
}
/* The bar's buttons keep their own surface so they stay legible on either colour. */
body[data-alert] #topbar .tb-btn { border-color: transparent; }
/* Critical reaches the whole page. The board behind the tiles flashes too,
in a cut of the two colours mixed well into the page background — shifted
toward it, so a tile still stands off the board — and the tiles' own
panel thins a little to let that show through them, faintly enough that
nothing written on a tile loses contrast. On a wall where tiles cover
every pixel this is what carries the flash to the middle of the screen. */
@keyframes alert-board {
0%, 100% { background-color: color-mix(in srgb, var(--alert-a) 40%, var(--bg)); }
50% { background-color: color-mix(in srgb, var(--alert-b) 40%, var(--bg)); }
}
body[data-alert="critical"] {
animation: alert-board var(--alert-period) var(--alert-ease) infinite;
}
body[data-alert="critical"] .tile {
background: color-mix(in srgb, var(--panel) 88%, transparent);
}
body[data-alert="critical"] .tile.is-tinted {
background: color-mix(in srgb, color-mix(in srgb, var(--tile-tint) 14%, var(--panel)) 88%, transparent);
}
#alert-banner {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 10px;
/* clear of the wordmark at the left and the buttons at the right */
max-width: clamp(140px, calc(100% - 420px), 70vw);
pointer-events: none;
color: var(--alert-text, #fff);
font-weight: 800;
font-size: 17px;
letter-spacing: 0.02em;
white-space: nowrap;
}
#alert-banner[hidden] { display: none; }
#alert-banner .alert-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* which channel — small, boxed, before the text */
#alert-banner .alert-channel {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 2px solid currentColor;
border-radius: 6px;
font-size: 12px;
line-height: 1;
}
/* "+1" when more alerts are on than the bar shows */
#alert-banner .alert-more {
flex: none;
font-size: 12px;
font-weight: 700;
opacity: 0.85;
}
#alert-banner .alert-more:empty { display: none; }
/* ── dropdown menus ────────────────────────────────────────────────── */
.dropdown { position: relative; }
@@ -217,6 +322,43 @@ body.menu-hidden #topbar {
text-align: center;
font-variant-numeric: tabular-nums;
}
/* the Layouts menu's Alerts row: a fold that opens to six channel buttons */
.menu-fold-head { justify-content: space-between; }
.menu-fold-head > span:first-child { flex: 1; }
.menu-fold-caret {
flex: none;
display: inline-flex;
color: var(--muted);
transition: transform 0.15s;
}
.menu-fold-caret svg { width: 12px; height: 12px; display: block; }
.menu-fold.is-open .menu-fold-caret { transform: rotate(90deg); }
.menu-fold-body[hidden] { display: none; }
.alert-channels {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
padding: 2px 10px 10px;
}
.alert-channel-btn {
height: 30px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.alert-channel-btn:hover { border-color: var(--muted); }
.alert-channel-btn.is-on,
.alert-channel-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
.menu-title {
padding: 6px 10px 3px;
color: var(--muted);
@@ -980,6 +1122,7 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; }
#alert-banner { font-size: 14px; gap: 6px; }
}
/* ── themes ────────────────────────────────────────────────────────── */