Files
ProdDash/public/admin/admin.css
T
Waters ChurchandClaude Fable 5.1 ac1f17b9ed Shell 1.10.0: a packaged ProdDash carries no modules
The macOS app and the Windows bundle shipped every module in the repo, on
by default, and a shell update silently added whatever new module had
landed on main — Band Lineup appeared on AB that way. A module was also
only as fresh as the last shell update. Now a packaged install (no .git —
the same test the updater uses to choose the archive over git pull)
carries no modules at all: every one arrives from the admin page's catalog
into the data directory, and the shell's own update never writes modules/.
A git checkout keeps running the modules in its tree.

Existing installs move over by themselves: at the first start, the bundled
modules that are switched on are copied into the data directory as
installed copies (a compiled helper keeps its mode), an uninstalled one is
dropped, and the app folder's modules/ is removed. Settings are keyed by
module id, so nothing changes for the operator.

Around it: the dashboard's empty state says "No modules yet — open Admin"
when the registry is empty; the admin page says "Nothing installed yet",
puts a checkbox on every compatible catalog entry and an Install selected
button (POST /api/admin/modules/install {ids}) that fetches them in one go.
The Mac build stops copying modules/ and no longer compiles or signs Swift
helpers — those ship prebuilt inside a module, per the guide.

tools/unbundled.test.js drives a packaged copy of the shell through the
move, an archive update that must not bring modules back, and a multi-install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:34:10 -04:00

584 lines
17 KiB
CSS

/* ProdDash admin page — on top of the shell's style.css */
body { overflow: auto; }
.admin-tag {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 5px;
padding: 2px 6px;
margin-left: 6px;
vertical-align: 2px;
}
#admin-main {
flex: 1;
overflow-y: auto;
width: min(720px, 100%);
margin: 0 auto;
padding: 18px 16px 60px;
}
.admin-h1 {
font-size: 16px;
margin: 22px 0 6px;
}
.admin-copy {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
margin-bottom: 14px;
}
.admin-storage { margin-top: -6px; font-size: 12px; }
.admin-storage code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11.5px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 5px;
padding: 1px 5px;
overflow-wrap: anywhere;
}
.admin-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 14px;
}
.admin-card h2 { font-size: 14px; margin-bottom: 8px; }
.login-row { display: flex; gap: 8px; }
.login-row input {
flex: 1;
padding: 8px 10px;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.login-row input:focus { outline: none; border-color: var(--accent); }
.module-head {
display: flex;
align-items: center;
gap: 10px;
}
.module-head .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
.module-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); }
.module-head .dot.connecting { background: var(--warn); }
.module-head .dot.error { background: var(--danger); }
.module-name { font-size: 14px; font-weight: 700; }
.module-version { color: var(--muted); font-size: 11px; }
.module-head .spacer { flex: 1; }
.module-desc { color: var(--muted); font-size: 12px; margin: 6px 0 2px; }
.module-health {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
min-height: 15px;
}
.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 {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--danger);
border-radius: 8px;
color: var(--danger);
font-size: 12px;
}
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.module-form .field { max-width: 420px; }
/* a multi-line string (manifest type `text`): the same box as the other
inputs (those rules live in the shell's style.css), a few lines tall */
.field textarea {
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
resize: vertical;
min-height: 72px;
}
.field textarea:focus { outline: none; border-color: var(--accent); }
/* endpoint fields: every module's upstream is entered the same way */
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
.endpoint-row .ep-host { flex: 1; min-width: 0; }
.endpoint-row .ep-port { width: 92px; flex: none; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
/* several picks from a list (manifest type `multiselect`) */
.field.multiselect-field { cursor: default; }
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.multiselect-empty { color: var(--muted); font-size: 12px; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */
.field-group {
margin: 14px 0 4px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 9px;
}
.field-group-title {
display: flex;
align-items: center;
gap: 7px;
position: relative; /* the ⓘ popover hangs off this row */
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 10px;
}
.field-group-name { min-width: 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) */
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;
}
details.field-group > summary.field-group-title::-webkit-details-marker { display: none; }
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;
}
details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
details.field-group[open] > summary.field-group-title { margin-bottom: 10px; }
details.field-group > summary.field-group-title:hover { color: var(--text); }
/* multi-column groups (configGroups[name].columns): a neat checklist grid */
.field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 4px 16px;
}
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
/* one line of guidance under a control (field `help`) */
.field-help {
display: block;
font-size: 11.5px;
line-height: 1.45;
color: var(--muted);
margin-top: 2px;
}
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
default and the field stays visible. */
.module-form .field[hidden] { display: none; }
/* a boolean rendered as a toggle (manifest type `switch`) */
.field.switch-field {
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--text);
cursor: pointer;
max-width: none;
}
.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 {
display: flex;
align-items: flex-end; /* the button stays bottom-right when a readout wraps */
gap: 10px;
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.error { color: var(--danger); }
.admin-status.ok { color: var(--accent); }
/* enable/disable switch */
.switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
user-select: none;
}
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch .track {
width: 34px;
height: 19px;
border-radius: 999px;
background: var(--border);
position: relative;
transition: background 0.2s;
flex: none;
}
.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); }
.layout-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 8px;
}
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
.layout-row .meta { color: var(--muted); font-size: 11px; }
.layouts-empty { color: var(--muted); font-size: 13px; }
/* ── version, update banner, catalog ───────────────────────────────── */
.admin-version {
font-size: 11px;
color: var(--muted);
margin-left: 4px;
vertical-align: 2px;
}
.update-card {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
background: color-mix(in srgb, var(--warn) 7%, var(--panel));
margin-top: 18px;
}
.update-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.update-head strong { font-size: 14px; }
.update-head .spacer { flex: 1; }
.update-card .admin-copy { margin: 8px 0 0; }
.btn.small { padding: 4px 9px; font-size: 12px; }
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
a.btn { text-decoration: none; }
.module-source {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
.module-update-slot:empty { display: none; }
.catalog-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.catalog-toolbar .admin-status { margin-top: 0; }
.catalog-row {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 10px;
}
.catalog-main { flex: 1; min-width: 0; }
.catalog-pick { margin: 5px 10px 0 0; flex: none; }
.catalog-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 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 */
/* ── a collapsible admin section ─────────────────────────────────────
The pattern for any section of this page that folds. The markup is a
<details class="admin-fold"> holding the section's contents, headed by a
<summary class="admin-h1 admin-fold-title">: the title keeps the same
type as every other section heading — a <summary> would otherwise come
out at the normal weight — and the caret follows the words, so a folded
section still reads as a heading first. The whole row is the hit target. */
details.admin-fold > summary.admin-fold-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
user-select: none;
}
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
/* after the title, not before it — the heading reads first, the caret says
there is more behind it */
details.admin-fold > summary.admin-fold-title::after {
content: "";
width: 0; height: 0;
border-left: 6px solid var(--muted);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform 0.15s;
}
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--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; }
/* ── the admin PIN ── */
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
.pin-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.35em;
text-align: center;
max-width: 9em;
}
.pin-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.pin-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.pin-field[hidden] { display: none; }
.pin-field input {
padding: 8px 10px;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.pin-field input:focus { outline: none; border-color: var(--accent); }
.pin-actions {
display: flex;
gap: 8px;
margin-left: auto;
}