Files
Waters ChurchandClaude Fable 5.1 7481d1d9e2 Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each
A tile whose module stopped offering its picker entry — Slack's Send message
and Quick replies since the Send Message module took them over — kept its
place in the layout and drew nothing, as designed, but still held its grid
cells with no control anywhere to let them go: a dead area the operator
could not place anything in.

The Layouts menu now has a Not shown section, present only while the layout
holds such a tile. One row per tile: its title (or the module's name plus
the variant, or the module id when it is not installed), why in a few words
— "not offered right now", "module disabled", "module needs a newer
ProdDash", "module not installed" — and a drawn ✕ with a tooltip that
removes it, saves the layout and frees the cells, leaving the menu open as
the Grid Size stepper and Alerts do. The section repaints while the menu is
open whenever tiles become offered or stop being, and only when the list
changed. A tile that merely waits for its module keeps its place as before.

GET /api/modules also returns `inactive` — the installed modules that are
not running, id, name and reason only — so a dashboard can tell a disabled
module from one that is not installed. The toast after loading a named
layout now says the Layouts menu can remove the tiles it did not show.
README and the module guide updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:46:50 -04:00

1314 lines
38 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ProdDash shell — dark, high-contrast, production-booth friendly.
Visual language carried over from ProdCom Listener (same variable names, so
module CSS written against these looks native everywhere). Modules must
style with these variables only — no hardcoded colors. */
:root {
--bg: #0b0e12;
--panel: #131920;
--border: #232b35;
--text: #e6edf3;
--muted: #8b98a5;
--accent: #2ee59a;
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
--danger: #f4433c;
--warn: #f0b429;
/* grid geometry (shell.js reads these) */
--cell-h: 72px;
--gap: 0px; /* tiles sit flush; their 1px borders are the only seam */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
}
button { font-family: inherit; }
/* ── topbar ────────────────────────────────────────────────────────── */
#topbar {
position: relative; /* the alert banner centres itself against the bar */
flex: none;
display: flex;
align-items: center;
gap: 10px;
height: 46px;
padding: 0 12px;
background: var(--panel);
border-bottom: 1px solid var(--border);
user-select: none;
-webkit-user-select: none;
transition: height 0.22s ease, border-bottom-width 0.22s ease;
}
/* collapsed by its notch (and automatically in fullscreen) */
body.menu-hidden #topbar {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
/* The bar's show/hide handle: a small accent lip on its bottom edge, the
same idea as a tile's notch. It sits at the left — never screen-centre,
where a tile's own (centred) tab lands once both bars are hidden — and
grows under the pointer so it is easy to hit without being loud at rest.
It used to be the ProdDash wordmark on a pill hanging past the bar, which
read as a step cut out of the bar, and animating its font-size on the way
out was the jerkiest thing on the page in Safari. Everything here moves by
transform, opacity and its own fixed size: no text metrics, no reflow. */
#topbar-notch {
position: fixed;
top: 0;
left: 10px;
width: 28px;
height: 5px;
padding: 0;
border: none;
border-radius: 0 0 5px 5px;
background: var(--accent);
opacity: 0.5;
cursor: pointer;
z-index: 60;
/* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so
the lip rides the bar's own collapse instead of running a second,
competing animation against it. */
transform: translateY(var(--topbar-h, 46px));
transition: width 0.16s ease, height 0.16s ease,
opacity 0.16s ease, border-radius 0.16s ease;
}
#topbar-notch:hover,
#topbar-notch:focus-visible {
width: 60px;
height: 10px;
opacity: 1;
border-radius: 0 0 8px 8px;
}
/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top
edge without a rule of its own. */
#brand {
font-weight: 800;
font-size: 15px;
letter-spacing: 0.02em;
margin-right: 6px;
}
#brand .accent { color: var(--accent); }
#topbar .spacer { flex: 1; }
.tb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
.tb-btn:hover { border-color: var(--muted); }
.tb-btn:active { background: var(--border); }
.tb-btn.icon {
padding: 6px 8px;
min-width: 30px;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.tb-btn.icon svg { display: block; }
/* A top-bar button that is a switch rather than an action lights up the same
way an open tile button does, so "on" reads the same everywhere. */
.tb-btn.is-on,
.tb-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
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; }
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
min-width: 210px;
max-height: 70vh;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
.menu[hidden] { display: none; }
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover { background: var(--border); }
.menu-item .sub {
display: block;
color: var(--muted);
font-size: 11px;
margin-top: 2px;
white-space: normal;
}
.menu-item[disabled] { opacity: 0.45; cursor: default; }
.menu-item.danger { color: var(--danger); }
.menu-note {
padding: 8px 10px;
color: var(--muted);
font-size: 12px;
white-space: normal;
}
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
/* the Layouts menu's grid-density row: Grid Size − 12 + */
.menu-stepper {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 8px;
font-size: 13px;
}
.menu-stepper-label { flex: 1; }
.menu-stepper-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
}
.menu-stepper-btn:hover { border-color: var(--muted); }
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
.menu-stepper-value {
min-width: 28px;
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);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
is not drawing, why in a word or two, and the ✕ that removes it */
.menu-unshown[hidden] { display: none; }
.menu-unshown-row { cursor: default; }
.menu-unshown-row:hover { background: none; }
.menu-unshown-row > span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-x {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 5px;
color: var(--muted);
cursor: pointer;
}
.menu-x:hover { background: var(--border); color: var(--danger); }
.menu-x svg { width: 12px; height: 12px; display: block; }
/* ── grid ──────────────────────────────────────────────────────────── */
#gridwrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
#grid {
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-auto-rows: var(--cell-h);
gap: var(--gap);
/* Always leave room to drop/grow a tile below the last row. */
padding-bottom: calc(3 * (var(--cell-h) + var(--gap)));
}
#empty-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding-top: 12vh;
color: var(--muted);
font-size: 14px;
pointer-events: none;
}
#empty-state[hidden] { display: none; }
/* ── tiles ─────────────────────────────────────────────────────────── */
.tile {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
/* A tile the user gave a colour: a wash over the panel, a stronger edge and
a tinted title bar. Module content keeps its own colours — this is the
tile's chrome, so a tinted tile still reads like every other one. */
.tile.is-tinted {
background: color-mix(in srgb, var(--tile-tint) 14%, var(--panel));
border-color: color-mix(in srgb, var(--tile-tint) 45%, var(--border));
}
.tile.is-tinted > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, transparent);
border-bottom-color: color-mix(in srgb, var(--tile-tint) 35%, var(--border));
}
.tile.dragging,
.tile.resizing {
z-index: 40;
border-color: var(--muted);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
opacity: 0.92;
}
.tile-head {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 32px; /* fixed so the notch below can sit at a known offset */
padding: 0 8px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border);
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
transition: height 0.22s ease, border-bottom-width 0.22s ease;
}
.tile.dragging .tile-head { cursor: grabbing; }
/* Folded away: the bar leaves the flow and parks just above the tile, so the
module keeps the whole body and nothing reflows as the bar comes and goes.
Pointing at the tile's top edge slides it back in over the content
(shell.js adds .head-peek) — and because it is the same element, its
buttons click and its width drags the tile just as they always did. */
.tile.head-off > .tile-head {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 7;
background: color-mix(in srgb, var(--panel) 90%, transparent);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
.tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
}
.tile-head .dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
transition: background 0.3s;
}
.tile-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); }
.tile-head .dot.connecting { background: var(--warn); }
.tile-head .dot.error { background: var(--danger); }
.tile-title {
flex: 0 1 auto;
min-width: 0;
font-size: 12px;
font-weight: 700;
color: var(--muted);
letter-spacing: 0.03em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 5px;
color: var(--muted);
font-size: 13px;
cursor: pointer;
}
.tile-btn:hover { background: var(--border); color: var(--text); }
.tile-btn.close:hover { color: var(--danger); }
.tile-btn.active { color: var(--accent); }
.tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
/* The pin: a state, not an action, so it stays quiet until it is on and
sits closer to the label than to the buttons at the other end. It fades
in with the rest of the tile's chrome so a wall of unpinned tiles is not
a wall of pins. */
.tile-btn.pin {
width: 18px;
height: 18px;
margin-left: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-btn.pin svg { width: 12px; height: 12px; }
.tile:hover .tile-btn.pin,
.tile-btn.pin:focus-visible { opacity: 0.75; }
.tile-btn.pin:hover { opacity: 1; }
/* Pinned reads at a glance, whether or not anything is being pointed at. */
.tile-btn.pin.is-pinned,
.tile:hover .tile-btn.pin.is-pinned {
opacity: 1;
color: var(--accent);
}
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
.tile-btn.pin.is-pinned:hover { background: var(--border); }
/* Keeps the label hugging its pin however wide the tile is. */
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
/* the shell's tooltip: shown at once under the button the pointer is on */
#tip {
position: fixed;
z-index: 200;
pointer-events: none;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--panel);
color: var(--text);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
#tip[hidden] { display: none; }
.tile-btn svg { width: 13px; height: 13px; display: block; }
/* controls a module adds to its own title bar (moduleApi.header) */
.tile-controls {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.tile-btn.module-btn {
width: auto;
min-width: 22px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.tile-dd { position: relative; display: inline-flex; }
/* a module header menu — hosted on the body (shell.js addMenu) so neither
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
.tile-menu-host { display: contents; }
.tile-menu {
position: fixed;
z-index: 70;
min-width: 180px;
max-height: 60vh;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
.tile-menu[hidden] { display: none; }
.tile-body {
flex: 1;
position: relative;
min-height: 0;
min-width: 0;
overflow: hidden;
}
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
Every edge and both bottom corners resize, so there is nothing to point
at — the grips are invisible and the cursor says what each one does, the
way a window's own edges do. Together they tile the tile's outer 8px
without a gap: the top strip runs corner to corner, the sides run between
it and the bottom corners, and the bottom strip fills between those. */
.tile-resize,
.tile-resize-left,
.tile-resize-bottom,
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 5;
touch-action: none;
}
/* the two bottom corners: both directions at once */
.tile-resize,
.tile-resize-left {
bottom: 0;
width: 20px;
height: 20px;
}
.tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; }
/* top and bottom: height only */
.tile-resize-top,
.tile-resize-bottom {
height: 8px;
cursor: ns-resize;
}
.tile-resize-top {
top: 0;
left: 0;
right: 0;
/* Above a revealed title bar: the bar slides in under the pointer here,
and this strip has to stay the top edge rather than become part of it. */
z-index: 8;
}
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
/* left and right: width only, between the top strip and the bottom corners */
.tile-resize-right,
.tile-resize-side {
top: 8px;
bottom: 20px;
width: 8px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; }
.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam.
Dragging it trades width between the two tiles rather than resizing one,
so it sits above their own edge grips. Like them it draws nothing at rest
— only while it is being dragged, to show which seam is moving. */
.tile-divider {
position: absolute;
width: 14px;
transform: translateX(-50%);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
touch-action: none;
}
.tile-divider::after {
content: "";
width: 5px;
height: 44px;
max-height: 55%;
border-radius: 999px;
background: var(--muted);
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease;
}
.tile-divider.active::after { opacity: 1; background: var(--text); }
/* Drop the seams while a tile itself is being moved or resized — they're
rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (the rim grips and the seams) are parked. */
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-resize-top,
body.fullscreen .tile-resize-right,
body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }
/* status message a module can show while degraded (also used by the shell
when a module fails to load) */
.tile-msg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 12px;
color: var(--muted);
font-size: 13px;
background: var(--panel);
z-index: 4;
}
/* ── per-tile settings popover ─────────────────────────────────────── */
/* A small window beside its tile (shell.js places and clamps it), not a
box inside the tile: a tile can be smaller than its own settings, and the
whole point of live settings is watching the tile change. */
.tile-settings {
position: fixed;
z-index: 80;
display: flex;
flex-direction: column;
/* as wide as its content wants and no wider: a couple of switches make a
small window, a three-column checklist a wide one */
width: max-content;
min-width: 150px;
max-width: min(560px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
}
/* the grab bar: a slim accent strip with a grip mark, nothing to read or
press — the gear that opened the window wears the same colour meanwhile */
.tile-settings-bar {
flex: none;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.tile-settings-bar::before {
content: "";
width: 34px;
height: 4px;
border-radius: 2px;
background: var(--accent-text);
opacity: 0.7;
transition: opacity 0.12s;
}
.tile-settings-bar:hover::before { opacity: 1; }
.tile-settings .field[hidden] { display: none; }
/* the tint, the shell's own setting, under a hairline at the foot */
.tile-settings .tint-field {
margin: 8px 0 0;
padding-top: 9px;
border-top: 1px solid var(--border);
gap: 8px;
}
.tile-settings .tint-clear {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.tile-settings .tint-clear:hover { color: var(--text); background: var(--border); }
.tile-settings .tint-clear[hidden] { display: none; }
.tile-settings-bar:active { cursor: grabbing; }
.tile-settings-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
/* Help lines never widen the window: they take no room of their own when the
content is measured, then wrap to whatever width the fields settled on. */
.tile-settings .field-help,
.tile-settings .field-group-help,
.tile-settings .multiselect-empty { width: 0; min-width: 100%; }
/* a group's on/off sits in its title row; the body dims while it is off */
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
.tile-settings .field-group-name { flex: 1; min-width: 0; }
.tile-settings .group-toggle { margin: 0 0 0 auto; }
.tile-settings .group-toggle .check-label { display: none; }
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
/* several picks from a list (type `multiselect`) */
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
.tile-settings .multiselect.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
}
.tile-settings .multiselect-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
/* a text built from offered variables (type `template`) */
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
.tile-settings .template-var {
font: inherit;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: grab;
}
.tile-settings .template-var:hover { border-color: var(--accent); }
.tile-settings .template-var:active { cursor: grabbing; }
.tile-settings .field-group {
margin: 0 0 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
}
.tile-settings .field-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 8px;
}
.tile-settings details.field-group > summary.field-group-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 0;
user-select: none;
}
.tile-settings details.field-group > summary::-webkit-details-marker { display: none; }
.tile-settings details.field-group > summary.field-group-title::before {
content: "";
width: 0; height: 0;
border-left: 5px solid var(--muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
}
.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 .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-body > :last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
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: 0; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
/* a list of switches or a template with its chips is a row of its own, however
many columns the group lays its other fields out in */
.tile-settings .field-group-body.is-columns .multiselect-field,
.tile-settings .field-group-body.is-columns .template-field { grid-column: 1 / -1; }
.tile-settings .field-group-help,
.tile-settings .field-help {
display: block;
font-size: 11px;
line-height: 1.4;
color: var(--muted);
}
.tile-settings .field-group-help { margin: -2px 0 6px; }
.tile-settings .field-help { margin-top: 1px; }
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
font-size: 12px;
color: var(--muted);
}
.field input[type="text"],
.field input[type="number"],
.field input[type="password"],
.field select {
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.field input:focus, .field select:focus {
outline: none;
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 {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px 10px;
color: var(--text);
cursor: pointer;
}
.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 */
.field.color-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.field.color-field > span { flex: 1; min-width: 0; }
.field.color-field input[type="color"] {
width: 44px;
height: 26px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
}
.field.color-field input[type="color"]:focus { outline: none; border-color: var(--accent); }
.field.color-field .field-help { flex-basis: 100%; }
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
#modal-backdrop {
position: fixed;
inset: 0;
z-index: 90;
background: rgba(4, 6, 8, 0.65);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 18vh;
}
#modal-backdrop[hidden] { display: none; }
#modal {
width: min(360px, calc(100vw - 32px));
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
#modal h2 { font-size: 14px; margin-bottom: 12px; }
#modal .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.btn {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.btn:hover { border-color: var(--muted); }
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
/* toast */
#toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 95;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
font-size: 13px;
border-radius: 999px;
padding: 8px 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
transition: opacity 0.25s ease;
}
#toast[hidden] { display: none; }
#toast button {
margin-left: 12px;
padding: 3px 10px;
border: 1px solid var(--accent);
border-radius: 5px;
background: none;
color: var(--accent);
font: inherit;
font-weight: 700;
cursor: pointer;
}
#toast button:hover { background: var(--accent); color: var(--accent-text); }
#modal .modal-copy {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
margin: 6px 0 14px;
}
#toast.error { border-color: var(--danger); }
/* ── scrollbars ────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
border: 2px solid transparent; /* inset gutter */
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #364253;
background-clip: padding-box;
}
::-webkit-scrollbar-corner { background: transparent; }
/* Firefox (webkit pseudo-elements unsupported there) */
@supports (-moz-appearance: none) {
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
}
@media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; }
#alert-banner { font-size: 14px; 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). Every one is booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable.
Each sets --accent-text too when the default (a near-black green, for
booth's mint) would look wrong against its own accent. */
/* 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;
}
/* Midnight — indigo night, violet accent */
html[data-theme="midnight"] {
--bg: #0a0b18;
--panel: #14162b;
--border: #272a4a;
--text: #e8e9fb;
--muted: #9a9cc8;
--accent: #9d8cff;
--accent-text: #12103a;
--danger: #ff5a72;
--warn: #f5b93a;
}
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
and the amber never read as one another) */
html[data-theme="moss"] {
--bg: #08120c;
--panel: #101e15;
--border: #1f3728;
--text: #e9f4ea;
--muted: #8fb09a;
--accent: #8fdb3f;
--accent-text: #15250a;
--danger: #ff5a52;
--warn: #ff9f1c;
}
/* Plum — aubergine, purple accent */
html[data-theme="plum"] {
--bg: #110a17;
--panel: #1d1228;
--border: #332042;
--text: #f2e9f8;
--muted: #b195c2;
--accent: #c084fc;
--accent-text: #260a37;
--danger: #ff4d4f;
--warn: #f7b53b;
}
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
Ember, which is warm throughout */
html[data-theme="sepia"] {
--bg: #12100b;
--panel: #1d1913;
--border: #33291d;
--text: #f4ede0;
--muted: #b3a48b;
--accent: #5eead4;
--accent-text: #06231e;
--danger: #ff5a52;
--warn: #ffc247;
}
/* Blackout — true black for a dark room, where any glow off the screen
carries; everything else is as bright as it can be against it */
html[data-theme="blackout"] {
--bg: #000000;
--panel: #0a0a0a;
--border: #1f1f1f;
--text: #ffffff;
--muted: #9e9e9e;
--accent: #00e5ff;
--accent-text: #00212a;
--danger: #ff3b30;
--warn: #ffcc00;
}
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
darken, as they must on a light background, and the indigo accent takes
white text. */
html[data-theme="paper"] {
--bg: #f5f1e8;
--panel: #fffdf8;
--border: #ddd5c4;
--text: #1c1a15;
--muted: #6b6455;
--accent: #4f46e5;
--accent-text: #ffffff;
--danger: #c8102e;
--warn: #9a6700;
}
/* Iron — no colour of its own: the accent is near-white, so the only hues on
screen are a module's own and the red/amber that mean something */
html[data-theme="iron"] {
--bg: #0c0c0d;
--panel: #161617;
--border: #2b2b2d;
--text: #f2f2f3;
--muted: #9a9a9d;
--accent: #d8d8dc;
--accent-text: #131314;
--danger: #ff4d4f;
--warn: #ffb224;
}