Three things about arranging a dashboard. **Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away at once, for a windowed screen that should read like a wall display. Fullscreen already did this for watching; this is the windowed equivalent, asked for by hand. It starts off and saves with the layout, so a booth's named layout loads with the bars already tucked away. **A hidden bar is not a gone bar.** Folded away — by that switch or by a tile's own notch — the bar leaves the flow and parks just above its tile, so the module keeps the whole body and nothing reflows as the bar comes and goes. Point at the tile's top edge and it slides back in over the content, where its buttons click and its width drags the tile exactly as before. A dashboard can now be arranged with every bar out of the way, which is the point of hiding them. **Every edge resizes.** The top, left and right edges join the bottom one: each moves only itself, so the tile grows into the space beside it while the opposite edge stays put, stopping at a neighbour or at the module's own minimum. Where two tiles meet, that boundary already belongs to the seam pill between them — it moves both edges and leaves no gap — so the two tiles' side marks step aside there rather than offer a second, different control on the same pixels. One fix underneath: saving a named layout was dropping each tile's variant, title, minimum size and hidden-bar state, so a saved "Now" tile came back as the full Now/Next card and a solo timer came back with a full tile's settings. The layout file now carries them — and the new switch with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1102 lines
31 KiB
CSS
1102 lines
31 KiB
CSS
/* 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 {
|
|
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);
|
|
}
|
|
|
|
/* ── 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; }
|
|
.menu-title {
|
|
padding: 6px 10px 3px;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* ── 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. */
|
|
body:not(.fullscreen) .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;
|
|
}
|
|
body:not(.fullscreen) .tile.head-off.head-peek > .tile-head {
|
|
transform: none;
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
body:not(.fullscreen) .tile.is-tinted.head-off > .tile-head {
|
|
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
|
|
}
|
|
/* Auto-hide already reveals on hover, so the notch would be a dead control;
|
|
a bar this tile hid by itself keeps its notch, which is how it comes back. */
|
|
body.autohide-heads .tile-notch { display: none; }
|
|
|
|
.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: 1;
|
|
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 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 — fixed-positioned by the shell so the tile's
|
|
overflow:hidden can't clip it */
|
|
.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; }
|
|
|
|
/* notch on the title bar's bottom edge: click to hide/show the bar
|
|
(same idea as the ProdCom Listener's pull-down menubar handle) */
|
|
.tile-notch {
|
|
position: absolute;
|
|
top: 32px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 46px;
|
|
height: 13px;
|
|
padding: 0;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-top: none;
|
|
border-radius: 0 0 10px 10px;
|
|
cursor: pointer;
|
|
z-index: 6;
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease, top 0.22s ease;
|
|
}
|
|
.tile:hover .tile-notch { opacity: 0.6; }
|
|
.tile .tile-notch:hover { opacity: 1; }
|
|
body:not(.fullscreen) .tile.head-off .tile-notch { top: 0; }
|
|
.tile-notch::after {
|
|
content: "";
|
|
display: block;
|
|
width: 18px;
|
|
height: 3px;
|
|
margin: 4px auto 0;
|
|
border-radius: 999px;
|
|
background: var(--muted);
|
|
}
|
|
.tile-notch:hover::after { background: var(--text); }
|
|
|
|
/* Fullscreen is for watching, not arranging: every title bar folds away, and
|
|
a tile's own hidden/shown preference is left untouched for when the screen
|
|
comes back. The notch still peeks one open, and it folds back once the
|
|
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
|
|
body.fullscreen .tile:not(.head-peek) .tile-head {
|
|
height: 0;
|
|
border-bottom-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
|
|
body.fullscreen .tile.head-peek .tile-head {
|
|
height: 32px;
|
|
border-bottom-width: 1px;
|
|
}
|
|
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
|
|
|
.tile-body {
|
|
flex: 1;
|
|
position: relative;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tile-resize,
|
|
.tile-resize-left {
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 20px;
|
|
height: 20px;
|
|
touch-action: none;
|
|
z-index: 5;
|
|
color: var(--muted);
|
|
opacity: 0; /* corner indicators only show while hovering the tile */
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.tile-resize { right: 0; cursor: nwse-resize; }
|
|
.tile-resize-left { left: 0; cursor: nesw-resize; }
|
|
.tile:hover .tile-resize,
|
|
.tile:hover .tile-resize-left { opacity: 0.6; }
|
|
.tile .tile-resize:hover,
|
|
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
|
|
.tile-resize::after,
|
|
.tile-resize-left::after {
|
|
content: "";
|
|
position: absolute;
|
|
bottom: 4px;
|
|
width: 9px;
|
|
height: 9px;
|
|
border-bottom: 2px solid currentColor;
|
|
}
|
|
.tile-resize::after {
|
|
right: 4px;
|
|
border-right: 2px solid currentColor;
|
|
border-radius: 0 0 3px 0;
|
|
}
|
|
.tile-resize-left::after {
|
|
left: 4px;
|
|
border-left: 2px solid currentColor;
|
|
border-radius: 0 0 0 3px;
|
|
}
|
|
|
|
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
|
|
the same 2px line as the corner handles it sits between, on the same
|
|
4px inset, so the three read as one set of marks along the bottom. */
|
|
.tile-resize-bottom {
|
|
position: absolute;
|
|
left: 24px;
|
|
right: 24px;
|
|
bottom: 0;
|
|
height: 10px;
|
|
z-index: 5;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: center;
|
|
cursor: ns-resize;
|
|
touch-action: none;
|
|
color: var(--muted);
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.tile-resize-bottom::after {
|
|
content: "";
|
|
width: 44px;
|
|
max-width: 60%;
|
|
height: 0;
|
|
margin-bottom: 4px;
|
|
border-bottom: 2px solid currentColor;
|
|
}
|
|
/* the other three edges: the same mark, turned to face its own side. Each
|
|
stops short of the corners so the corner grips keep their full 20px. */
|
|
.tile-resize-top,
|
|
.tile-resize-right,
|
|
.tile-resize-side {
|
|
position: absolute;
|
|
z-index: 6;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
touch-action: none;
|
|
color: var(--muted);
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.tile-resize-top {
|
|
top: 0;
|
|
left: 24px;
|
|
right: 24px;
|
|
height: 10px;
|
|
align-items: flex-start;
|
|
cursor: ns-resize;
|
|
/* 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-right,
|
|
.tile-resize-side {
|
|
top: 24px;
|
|
bottom: 24px;
|
|
width: 10px;
|
|
cursor: ew-resize;
|
|
}
|
|
.tile-resize-right { right: 0; justify-content: flex-end; }
|
|
.tile-resize-side { left: 0; justify-content: flex-start; }
|
|
.tile-resize-top::after,
|
|
.tile-resize-right::after,
|
|
.tile-resize-side::after {
|
|
content: "";
|
|
border-radius: 999px;
|
|
}
|
|
.tile-resize-top::after {
|
|
width: 44px;
|
|
max-width: 60%;
|
|
height: 0;
|
|
margin-top: 4px;
|
|
border-top: 2px solid currentColor;
|
|
}
|
|
.tile-resize-right::after,
|
|
.tile-resize-side::after {
|
|
width: 0;
|
|
height: 44px;
|
|
max-height: 60%;
|
|
}
|
|
.tile-resize-right::after { margin-right: 4px; border-right: 2px solid currentColor; }
|
|
.tile-resize-side::after { margin-left: 4px; border-left: 2px solid currentColor; }
|
|
/* A side that meets another tile belongs to the seam pill between them. */
|
|
.tile.seam-left .tile-resize-side,
|
|
.tile.seam-right .tile-resize-right { display: none; }
|
|
.tile:hover .tile-resize-top,
|
|
.tile:hover .tile-resize-right,
|
|
.tile:hover .tile-resize-side { opacity: 0.6; }
|
|
.tile .tile-resize-top:hover,
|
|
.tile .tile-resize-right:hover,
|
|
.tile .tile-resize-side:hover { opacity: 1; color: var(--text); }
|
|
|
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
|
.tile .tile-resize-bottom:hover,
|
|
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
|
|
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
|
|
|
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
|
|
hovering reveals a small macOS-split-view-style grab pill. Dragging it
|
|
trades width between the two tiles. */
|
|
.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:hover::after,
|
|
.tile-divider.linked::after { opacity: 0.7; }
|
|
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
|
|
|
/* Hide the pills while a tile itself is being moved or corner-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 (corner handles, seam pills) 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.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; }
|
|
}
|
|
|
|
/* ── 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;
|
|
}
|