:root {
    --bg: #f4f4f9;
    --card-bg: #ffffff;
    --text: #333333;
    --accent: #2563eb;
    --border: #e5e7eb;
    --success: #10b981;
    --danger: #ef4444;
}

body {
    font-family: system-ui, -apple-system, sans-serif;
    background-color: var(--bg);
    color: var(--text);
    margin: 0;
    padding: 2rem;
    display: flex;
    justify-content: center;
}

/* Card & Layout Styles */
.container { width: 100%; max-width: 1200px; }
.card {
    background-color: var(--card-bg);
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
    margin-bottom: 1.5rem;
    /* Addresses, file names and object ids are the long unbreakable
       words on this site and every one of them is somebody's data, so
       they are allowed to break mid-word rather than push a card wider
       than the screen. The places that would rather cut a line than
       wrap it — .file-name, .record-purpose, .booking-card-facts — say
       so themselves and are unaffected. */
    overflow-wrap: break-word;
}
/* A config key or a file name quoted mid-sentence. `break-word` above
   breaks a long word only when it is on a line of its own; `anywhere`
   also lets the line before it end early, which is what a 40-character
   `<code>` in a paragraph needs on a phone. */
code { overflow-wrap: anywhere; }

/* Forms & Buttons */
.form-group { margin-bottom: 1rem; display: flex; flex-direction: column; }
label { margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9rem; }
input, select {
    padding: 0.75rem;
    border: 1px solid var(--border);
    border-radius: 4px;
    font-size: 1rem;
}
button {
    padding: 0.75rem 1.5rem;
    background-color: var(--accent);
    color: white;
    border: none;
    border-radius: 4px;
    font-weight: bold;
    cursor: pointer;
    transition: opacity 0.2s;
}
button:hover { opacity: 0.9; }
button.approve { background-color: var(--success); }
button.deny { background-color: var(--danger); }

/* Tables */
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
th { color: #666; background: #f8fafc; }

/* --- Dashboard layout ----------------------------------------------- */
/*
 * The machine on the left, the paperwork about it on the right — in the
 * markup first, and the markup is the only order there is. Reading
 * order, the keyboard's tab order and what a phone stacks are then the
 * same thing, and none of it is a CSS reordering that a screen reader
 * would not follow.
 *
 * `2fr 3fr` and not `3fr 2fr`: the columns swapped sides, and each kept
 * the width it had. The records are the wider of the two because a
 * booking card is a line of facts, member chips and a row of buttons,
 * and the machine's card is a 4:3 picture over a column of readings.
 */
.dashboard-grid {
    display: grid;
    grid-template-columns: 2fr 3fr;
    gap: 2rem;
    align-items: start;
}
/* Load-bearing on a phone. A grid track is `min-width: auto` by
   default, which means "never narrower than the widest thing in it" —
   and the widest thing in the records column is a booking card's facts
   line, which is `white-space: nowrap` on purpose. So one booking with
   a long purpose made the column 600px wide inside a 350px screen, the
   whole page scrolled sideways, and every card below the strip was
   drawn 600px while the strip itself was drawn 350: the "some items are
   a different width" that this looked like from the outside. The cards
   inside already carry `min-width: 0` where they need it; this is the
   one place the chain was broken. */
.dashboard-grid > * { min-width: 0; }

/* Status Panel */
.status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem; }
.metric { font-size: 1.5rem; font-weight: bold; color: var(--accent); }
.label { font-size: 0.875rem; color: #666; }
.progress-container { background-color: var(--border); border-radius: 999px; height: 1.5rem; overflow: hidden; position: relative; }
.progress-bar { height: 100%; background-color: var(--success); width: 0%; transition: width 0.3s ease; }
/* Two copies of the same number, one per background: the track layer is
   dark for the grey, the fill layer white for the green and clipped to
   the filled width by `--progress` (written by `progressBar`). Both are
   the full width of the container, because `inset()` is a percentage of
   the element's own box. */
.progress-text { position: absolute; top: 50%; left: 0; right: 0; transform: translateY(-50%); text-align: center; font-size: 0.875rem; font-weight: bold; }
.progress-text--track { color: #1e293b; }
.progress-text--fill { color: white; text-shadow: 1px 1px 2px rgba(0,0,0,0.5); clip-path: inset(0 calc(100% - var(--progress, 0%)) 0 0); transition: clip-path 0.3s ease; }

/* Printer Controls Panel */
.control-section { margin-bottom: 1rem; }
.control-label { font-size: 0.8rem; font-weight: 600; color: #666; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.03em; }
.control-row { display: flex; gap: 0.5rem; }
.control-btn {
    flex: 1;
    padding: 0.5rem 0.75rem;
    font-size: 0.85rem;
    font-weight: 600;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    background: var(--accent);
    color: white;
    transition: opacity 0.2s, filter 0.2s;
}
.control-btn:hover:not(:disabled) { opacity: 0.9; }
.control-btn:disabled { opacity: 0.35; cursor: not-allowed; filter: grayscale(0.5); }
.control-btn--secondary { background: #64748b; }
.control-btn--warning { background: #f59e0b; }
.control-btn--success { background: var(--success); }
.control-btn--danger { background: var(--danger); }
.control-btn--full { width: 100%; flex: unset; }
.control-hint { font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 0.5rem; }

/* Header English / Japanese switch, rendered by renderLanguageToggle().
   Deliberately quieter than the User / Admin switch beside it: it is a
   preference, not a place to be. */
.lang-toggle { display: flex; }
.lang-btn {
    padding: 0.35rem 0.7rem;
    font-size: 0.8rem;
    font-weight: 600;
    background: #fff;
    color: #475569;
    border: 1px solid var(--border);
    border-radius: 6px;
    cursor: pointer;
    /* Japanese and "EN" are different widths; a floor stops the header
       shuffling sideways each time it is pressed. */
    min-width: 3.6rem;
}
.lang-btn:hover { background: #f1f5f9; }

/* --- Fleet editor (admin "Printers" tab) ---------------------------- */
/* One column since the workshop plan next to the table was removed; the
   wrapper stays so the section keeps its own spacing. */
.editor-layout { display: grid; gap: 1.5rem; align-items: start; }
/* Same `min-width: auto` trap as .dashboard-grid: the fleet table is
   eight columns and is deliberately wrapped in an `overflow-x: auto`
   box, but that box cannot be narrower than this track, and this track
   could not be narrower than the table. So the scroller never scrolled
   and the settings page ran off the side of a phone instead. */
.editor-layout > * { min-width: 0; }

/* Add / Revert / Save, beside the heading on a laptop and under it on a
   phone. Wrapping rather than shrinking: Save is the press that
   rewrites config.toml and it is not going to be a sliver. */
.editor-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    align-items: center;
}

.editor-heading {
    margin: 0 0 0.5rem;
    font-size: 0.8rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: #64748b;
}

.editor-table { width: 100%; font-size: 0.85rem; border-collapse: collapse; }
.editor-table th {
    text-align: left;
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: #64748b;
    padding: 0 0.3rem 0.35rem;
    white-space: nowrap;
}
.editor-table td { padding: 0.15rem 0.3rem; }
.editor-row--selected { background: #eff6ff; }

.editor-input {
    width: 100%;
    min-width: 6.5rem;
    padding: 0.3rem 0.4rem;
    font-size: 0.82rem;
    font-family: inherit;
    border: 1px solid var(--border);
    border-radius: 4px;
    background: #fff;
}
.editor-input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
/* A new printer with no access code yet cannot be saved; say so before
   the operator presses Save. */
.editor-input--needed { border-color: var(--danger); background: #fef2f2; }
/* A column with nothing to offer — no smart plugs configured — rather
   than an empty dropdown that looks broken. */
.editor-cell--muted { color: #94a3b8; font-size: 0.85rem; }

.editor-delete {
    padding: 0.3rem 0.6rem;
    font-size: 0.78rem;
    background: #fff;
    color: var(--danger);
    border: 1px solid var(--danger);
    border-radius: 4px;
    cursor: pointer;
}
.editor-delete:hover { background: var(--danger); color: #fff; }

.editor-status {
    margin: 0.75rem 0 1rem;
    padding: 0.5rem 0.75rem;
    font-size: 0.85rem;
    border-radius: 4px;
    border-left: 3px solid transparent;
}
.editor-status--info  { background: #f8fafc; color: #475569; border-left-color: #cbd5e1; }
.editor-status--warn  { background: #fffbeb; color: #92400e; border-left-color: #f59e0b; }
.editor-status--ok    { background: #f0fdf4; color: #166534; border-left-color: var(--success); }
.editor-status--error { background: #fef2f2; color: #991b1b; border-left-color: var(--danger); }

/* --- Page header ---------------------------------------------------- */
.page-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 1rem;
    flex-wrap: wrap;
    margin-bottom: 1rem;
}
.page-header-right { display: flex; align-items: center; gap: 0.75rem; }
.identity-bar { font-size: 0.85rem; color: #64748b; }

/* --- The row of tabs ------------------------------------------------- */
/*
 * Three on the student page, four on the admin one. It was a row of
 * inline styles in both files, which meant it could only ever be one
 * row: four buttons at `flex: 1` with 1.5rem of padding each cannot be
 * narrower than about 450px however little room they are given, so on a
 * phone the last tab simply hung off the side of the screen.
 *
 * `flex-wrap` plus a basis rather than a bare `flex: 1`: on a wide page
 * the four still share the row equally, and on a narrow one they fold
 * into two columns of two instead of four unreadable slivers. The
 * per-tab `background` stays in the markup, where switchTab() and
 * switchUserTab() also write it — the open tab's colour has one
 * definition and it is not here.
 */
.tab-row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 1rem;
}
.tab-row > button {
    flex: 1 1 9rem;
    min-width: 0;
}

/* --- A ledger's filters ---------------------------------------------- */
/*
 * Search, printer, status and page size, over the reservation ledger and
 * the fault ledger on both pages — five copies of the same three
 * elements, written inline in the markup five times. Now one rule, so
 * the five cannot drift and a change reaches all of them.
 *
 * The fields wrap: four boxes side by side is right on a laptop and is
 * four 70px stumps on a phone. `9rem` is the basis at which the label
 * above a box still fits on one line in both languages.
 */
.filter-bar {
    background: #f8fafc;
    padding: 1rem;
    border-radius: 6px;
    border: 1px solid var(--border);
    margin-bottom: 1rem;
}
.filter-row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}
.filter-field {
    flex: 1 1 9rem;
    min-width: 0;
    display: flex;
    flex-direction: column;
}
/* "Per page" is a two-digit number, so it never earns a full share of
   the row — but it still wraps onto its own line rather than being
   squeezed under its own label. */
.filter-field--narrow { flex: 0 1 6rem; }
.filter-label {
    font-size: 0.75rem;
    font-weight: bold;
    margin-bottom: 2px;
}
.filter-input {
    padding: 0.4rem;
    font-size: 0.85rem;
    min-width: 0;
}

.panel-heading { margin: 0 0 0.75rem; font-size: 1.05rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.panel-model { font-size: 0.75rem; color: #94a3b8; font-weight: 500; }
.accent { color: var(--accent); }
.subheading { margin: 2rem 0 0; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
.muted { color: #666; }
.full-width { width: 100%; }
.form-hint { color: #94a3b8; font-size: 0.75rem; margin-top: 0.3rem; }
#form-printer-id { background: #f8fafc; cursor: not-allowed; }

/* --- Status badge --------------------------------------------------- */
/* One vocabulary for the badge, the map tile and the alerts, so a colour
   means the same thing wherever it appears. */
.badge {
    font-size: 0.7rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding: 0.15rem 0.5rem;
    border-radius: 999px;
    color: #fff;
    white-space: nowrap;
}
.badge--printing  { background: var(--success); }
.badge--preparing { background: #0ea5e9; }
.badge--paused    { background: #f59e0b; }
.badge--finished  { background: #6366f1; }
.badge--error     { background: var(--danger); }
.badge--idle      { background: #94a3b8; }
.badge--offline   { background: #475569; }

/* --- Notices (offline, HMS, print errors) --------------------------- */
.notice {
    font-size: 0.82rem;
    line-height: 1.45;
    padding: 0.6rem 0.75rem;
    border-radius: 6px;
    border-left: 4px solid;
    margin: 0 0 0.75rem;
}
.notice a { color: inherit; }
.notice--offline { background: #f1f5f9; border-color: #475569; color: #334155; }
.notice--fatal,
.notice--serious { background: #fef2f2; border-color: var(--danger); color: #7f1d1d; }
.notice--common  { background: #fffbeb; border-color: #f59e0b; color: #78350f; }
.notice--info    { background: #eff6ff; border-color: var(--accent); color: #1e3a8a; }
/* The code itself, kept at the end of a notice: worth having when
   somebody searches for it or quotes it to Bambu, but it is not the
   part a student standing at the machine needs to read. */
.notice-code {
    display: inline-block;
    margin-left: 0.5rem;
    font-size: 0.72rem;
    opacity: 0.65;
    white-space: nowrap;
}

/* --- The dialog the printer is showing on its own screen ------------- */
/* Deliberately louder than a notice: it is the one thing on the panel
   the machine is actually waiting for, and a filament change that is
   never answered never finishes. */
.prompt {
    border-radius: 6px;
    border-left: 4px solid;
    padding: 0.75rem 0.85rem;
    display: flex;
    flex-direction: column;
    gap: 0.65rem;
}
.prompt--error   { background: #fef2f2; border-color: var(--danger); color: #7f1d1d; }
.prompt--warning { background: #fffbeb; border-color: #f59e0b; color: #78350f; }
.prompt--info    { background: #eff6ff; border-color: var(--accent); color: #1e3a8a; }
.prompt-text {
    margin: 0;
    font-size: 0.88rem;
    line-height: 1.5;
    font-weight: 500;
}
.prompt-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.prompt-meta { margin: 0; font-size: 0.72rem; opacity: 0.65; }

/* The line on a filament warning that says where its buttons are. Part
   of the sentence, so it reads as one; weighted so the eye lands on it
   when the rest of the notice has already been read once. */
.notice-pointer { font-weight: 600; }

/* The feed window. It is open for seconds, not minutes, and it is the
   one thing on the page that is worth interrupting somebody for — so it
   is the printer's warning colours plus a pulse, which is as loud as
   this stylesheet gets. */
.filament-feed .prompt-text { font-weight: 700; }
.filament-feed { animation: filament-feed-pulse 1.1s ease-in-out infinite; }

@keyframes filament-feed-pulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
    50%      { box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); }
}

/* A pulse is an attention-getter, not information: the wording carries
   that on its own. */
@media (prefers-reduced-motion: reduce) {
    .filament-feed { animation: none; }
}

/* A hint that belongs under a row of controls rather than centred under
   a whole panel. */
.control-hint--left { text-align: left; margin-top: 0.25rem; }

/* --- Telemetry values ----------------------------------------------- */
.status-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
    gap: 0.75rem;
    margin-bottom: 1rem;
}
.metric-cell { min-width: 0; }
.metric { font-size: 1.15rem; font-weight: bold; color: var(--accent); white-space: nowrap; }
.metric-sub { font-size: 0.72rem; color: #94a3b8; }

.job-line { display: flex; gap: 0.5rem; align-items: baseline; margin-bottom: 0.4rem; }
/* A sliced file name is long and must not push the panel wide. */
.job-name {
    font-size: 0.85rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

.tray-row { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-bottom: 0.75rem; }
.tray-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.75rem;
    padding: 0.2rem 0.5rem;
    border: 1px solid var(--border);
    border-radius: 999px;
}
/* The slot the extruder is currently fed from. */
.tray-chip--active {
    border-color: var(--accent);
    font-weight: 600;
}
.tray-swatch {
    width: 0.75rem;
    height: 0.75rem;
    border-radius: 50%;
    /* A white or unset filament would otherwise be invisible on a white
       card, so every swatch keeps an outline. */
    border: 1px solid #94a3b8;
    flex: none;
}

/* The footer of the status panel: nozzle, Wi-Fi, firmware. A rule above
   it separates the machine's specification from its live readings. */
.hardware-line {
    font-size: 0.75rem;
    color: #94a3b8;
    margin: 0;
    padding-top: 0.6rem;
    border-top: 1px solid var(--border);
}

/* An address to paste elsewhere. Shown only where this server cannot
   play the stream itself, so it has to survive being long without
   dragging the panel wide. */
.stream-url {
    display: inline-block;
    font-size: 0.72rem;
    background: rgba(255, 255, 255, 0.6);
    border-radius: 3px;
    padding: 0.05rem 0.25rem;
    overflow-wrap: anywhere;
    user-select: all;
}

/* --- Control groups ------------------------------------------------- */
.control-group { border-top: 1px solid var(--border); }
.control-group:first-of-type { border-top: none; }
.control-group-header {
    width: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.6rem 0;
    background: none;
    border: none;
    color: #475569;
    font-size: 0.78rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    cursor: pointer;
}
.control-group-header:hover { opacity: 1; color: var(--accent); }
.control-group-header .chevron { transition: transform 0.15s ease; font-size: 1rem; }
.control-group-header[aria-expanded="true"] .chevron { transform: rotate(180deg); }
.control-body { padding-bottom: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
/* `display: flex` above is an author rule and outranks the user agent's
   `[hidden] { display: none }`, so setting the hidden attribute on this
   element does nothing. Collapse with an explicit class instead. */
.control-body--collapsed { display: none; }

/* And a backstop, so the same trap does not catch the next person: any
   element carrying `hidden` really is hidden, whatever else it is. */
[hidden] { display: none !important; }

/* --- Toggles (lights, AI detection) --------------------------------- */
/* One control per thing, showing the state the printer reports and
   sending the opposite. Two buttons labelled On and Off make the reader
   work out which one is currently true; this does not. */
.toggle-btn {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    padding: 0.5rem 0.75rem;
    font-size: 0.85rem;
    font-weight: 600;
    text-align: left;
    color: #334155;
    background: #f8fafc;
    border: 1px solid var(--border);
    border-radius: 4px;
    cursor: pointer;
    transition: background 0.15s ease, border-color 0.15s ease;
}
.toggle-btn:hover:not(:disabled) { background: #f1f5f9; }
.toggle-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.toggle-btn .toggle-label { flex: 1; }
.toggle-btn .toggle-state { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.toggle-dot {
    width: 0.7rem;
    height: 0.7rem;
    border-radius: 50%;
    background: #cbd5e1;
    border: 1px solid #94a3b8;
    flex: none;
    transition: background 0.15s ease, box-shadow 0.15s ease;
}
.toggle-btn--on { background: #ecfdf5; border-color: var(--success); color: #065f46; }
.toggle-btn--on:hover:not(:disabled) { background: #d1fae5; }
.toggle-btn--on .toggle-state { color: #047857; }
.toggle-btn--inline {
    /* The mains switch rides on the telemetry panel's heading row rather
       than in a control group, so it shrinks to its content. */
    width: auto;
    padding: 0.3rem 0.6rem;
    font-size: 0.8rem;
    gap: 0.45rem;
}

/* --- Confirm dialog ------------------------------------------------- */
/* The question a press asks when it cannot be taken back. Built on the
   booking form's shell; see confirmDialog() in js/shared.js. */
.confirm-dialog { width: min(28rem, calc(100vw - 2rem)); }
.confirm-body { padding: 1.5rem 1.5rem 0; }
.confirm-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
/* The strings were written for confirm(), so some carry their own line
   breaks — a fault code on its own line, for one. See askConfirm(). */
.confirm-text { margin: 0 0 0.75rem; color: #475569; line-height: 1.5; white-space: pre-line; }
.confirm-warning {
    margin: 0 0 1rem;
    padding: 0.7rem 0.85rem;
    border-radius: 6px;
    border-left: 3px solid var(--warning, #f59e0b);
    background: #fffbeb;
    color: #78350f;
    line-height: 1.5;
}
/* The tick-box is the question, so it is a target rather than a
   decoration: the whole row is the label. */
.confirm-ack {
    display: flex;
    gap: 0.6rem;
    align-items: flex-start;
    margin: 0 0 0.25rem;
    padding: 0.7rem 0.85rem;
    border: 1px solid var(--border);
    border-radius: 6px;
    cursor: pointer;
    line-height: 1.45;
    font-weight: 600;
    color: #334155;
}
.confirm-ack:hover { background: #f8fafc; }
.confirm-check { margin-top: 0.15rem; width: 1.05rem; height: 1.05rem; flex: none; }
.confirm-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
    padding: 1.25rem 1.5rem 1.5rem;
}
.confirm-actions .control-btn { width: auto; }

/* Its slot is a permanent child of the card, pinned to the heading row,
   because everything else in that card is rebuilt on every telemetry
   frame and a focused button removed from the document loses focus.
   See showPowerSwitch() in js/shared.js. */
#telemetry-panel { position: relative; }
.panel-power-slot {
    position: absolute;
    top: 2rem;
    right: 2rem;
    z-index: 1;
}
/* Room for it, so the status badge wraps rather than running underneath. */
#telemetry-panel.has-power .panel-heading { padding-right: 11rem; }

@media (max-width: 480px) {
    /* Too narrow for both on one line: the switch takes its own row. */
    .panel-power-slot { position: static; margin-bottom: 0.75rem; }
    #telemetry-panel.has-power .panel-heading { padding-right: 0; }
}

.toggle-btn--on .toggle-dot {
    background: var(--success);
    border-color: var(--success);
    box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}

.control-row { display: flex; gap: 0.5rem; align-items: center; }
.control-row--labelled { flex-wrap: wrap; }
.control-row-label { flex: 0 0 5.5rem; font-size: 0.8rem; color: #475569; }
.control-row-unit { font-size: 0.8rem; color: #94a3b8; }
.control-hint--warn { color: #b45309; background: #fffbeb; border-radius: 6px; padding: 0.5rem; text-align: left; }

.control-btn--accent { background: var(--accent); }
.fan-slider { flex: 1; min-width: 6rem; padding: 0; }
.fan-readout { flex: 0 0 2.8rem; text-align: right; font-size: 0.8rem; color: #475569; }
.temp-input { flex: 0 0 5rem; padding: 0.35rem 0.5rem; font-size: 0.85rem; }

/* --- The fleet's legend --------------------------------------------- */
/* The swatches under the week strip. Their colours are the machine
   column's, set once further down where both base rules are in scope. */
.week-legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem 0.9rem;
    align-items: center;
    font-size: 0.72rem;
    color: #64748b;
    margin: 0.6rem 0 0;
}
/* One mark and the word for it. The row wraps between items and never
   inside one, so a swatch cannot end a line with its label starting the
   next — which is what a phone's width did to a row of loose spans. */
.legend-item { display: inline-flex; align-items: center; white-space: nowrap; }
.legend-swatch {
    display: inline-block;
    width: 0.7rem;
    height: 0.7rem;
    border-radius: 2px;
    border: 2px solid;
    margin-right: 0.2rem;
    vertical-align: -1px;
}
/* Two legends under the strip: the machine column's telemetry colours
   and the grid's booking marks. Stacked, each labelled, because they
   are two vocabularies and a single row of swatches reads as one. */
/* The strip's card is the grid and a line of chrome; the 2rem every
   other card carries was mostly air once the heading went. */
#week-panel { padding: 1.1rem 1.25rem; }
.week-legends { margin: 0.6rem 0 0; }
.week-legends .week-legend { margin: 0 0 0.15rem; }
.week-legend-title {
    flex: 0 0 5.5rem;
    font-size: 0.62rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #94a3b8;
}
/* A booking swatch is the bar itself at legend size. It wears the same
   `.week-bar--*` classes — every one of them is a standalone selector,
   so nothing here has to restate a hatch or an outline — and only the
   positioning `.week-bar` carries is left behind. */
.week-key {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 0.8rem;
    border-radius: 3px;
    margin-right: 0.2rem;
    vertical-align: -2px;
    box-sizing: border-box;
    background: var(--accent);
}
.week-key .week-bar-count { font-size: 0.55rem; padding: 0 0.15rem; }

/* --- Toast ---------------------------------------------------------- */
/* Control refusals are routine here ("that printer is not connected",
   "a job is on the plate"), so they get a corner notice rather than a
   modal alert() that has to be dismissed each time. */
/* It is a `popover` (see showToast), which is how it reaches the top
   layer and so paints over an open <dialog>. That brings the popover UA
   sheet with it — `inset: 0`, a margin, a border, a background — all of
   which is undone here before the toast's own placement is stated, or
   the notice stretches to the whole viewport. `z-index` is left in for
   the browser that has no popover: there it is an ordinary fixed
   element and has to clear `.week-popup`'s 60 at least. */
.toast {
    position: fixed;
    inset: auto;
    margin: 0;
    border: 0;
    overflow: visible;
    left: 50%;
    bottom: 1.5rem;
    transform: translate(-50%, 200%);
    max-width: min(90vw, 34rem);
    padding: 0.7rem 1.1rem;
    border-radius: 8px;
    font-size: 0.85rem;
    color: #fff;
    background: #334155;
    box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
    transition: transform 0.2s ease, opacity 0.2s ease;
    opacity: 0;
    z-index: 200;
    pointer-events: none;
}
.toast--visible { transform: translate(-50%, 0); opacity: 1; }
.toast--ok    { background: #334155; }
.toast--error { background: var(--danger); }

@media (prefers-reduced-motion: reduce) {
    .toast, .control-group-header .chevron, .progress-bar, .progress-text--fill { transition: none; }
}

/* --- Narrow screens -------------------------------------------------- */
/* The dashboard is two columns of fixed proportion, which stops being
   usable well before phone width. */
@media (max-width: 860px) {
    body { padding: 1rem; }
    .dashboard-grid { grid-template-columns: 1fr; gap: 1rem; }
    .card { padding: 1.25rem; }
    .control-row-label { flex-basis: 100%; }
    /* Stacked, the machine still comes before the paperwork, and
       nothing here has to say so: the two columns are in that order in
       the markup, so one column is simply what the markup already
       said. The week strip stays above both — it is what selects the
       machine they are about. */
}

/* --- Admin tables ---------------------------------------------------- */
.row-sub { font-size: 0.8em; color: #666; }
.status-select { padding: 0.2rem; font-size: 0.8rem; width: 100px; }

/* --- Sending a file to a printer ------------------------------------ */
/*
 * Built for a phone held next to a machine: targets are at least 44 px
 * high, the plate's own picture does the identifying, and nothing needs
 * a second hand.
 */

.print-group { display: flex; flex-direction: column; gap: 0.6rem; }

.visually-hidden {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
}

/* The upload target. A label wrapping a hidden input, so a tap anywhere
   opens the picker and a drag-and-drop still works on a desktop. */
.dropzone {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.15rem;
    padding: 1rem 0.75rem;
    border: 2px dashed var(--border);
    border-radius: 8px;
    background: #f8fafc;
    cursor: pointer;
    text-align: center;
    margin-bottom: 0;
}
.dropzone:hover, .dropzone--over { border-color: var(--accent); background: #eff6ff; }
.dropzone-icon { font-size: 1.4rem; line-height: 1; color: var(--accent); }
.dropzone-text { font-size: 0.9rem; font-weight: 600; color: #475569; }
.dropzone-hint { font-size: 0.72rem; color: #94a3b8; }

/* What is on its way to a printer's SD card. The transfer runs in a
   background task on the Pi, so this line — not the button — is what
   says whether anything is happening. */
.transfer-status {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    padding: 0.5rem 0.6rem;
    border: 1px solid var(--border);
    border-left-width: 4px;
    border-radius: 6px;
    background: #f8fafc;
    margin-bottom: 0.5rem;
}
.transfer-status--busy   { border-left-color: var(--accent); }
.transfer-status--ok     { border-left-color: var(--success); }
.transfer-status--failed { border-left-color: var(--danger); background: #fef2f2; }
.transfer-label { font-size: 0.82rem; font-weight: 600; color: #334155; }
.transfer-detail { font-size: 0.75rem; color: #64748b; }

.upload-progress { display: flex; flex-direction: column; gap: 0.3rem; }
.upload-label { font-size: 0.78rem; color: #475569; }
.upload-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.upload-bar-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.2s; }

/* The library. */
.file-list { display: flex; flex-direction: column; gap: 0.35rem; }
.file-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.4rem;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: #fff;
    cursor: pointer;
    min-height: 3rem;
}
.file-row:hover { border-color: #cbd5e1; background: #f8fafc; }
.file-row--selected { border-color: var(--accent); background: #eff6ff; }
/* `.file-row:hover` is (0,2,0) and `.file-row--selected` is (0,1,0), so
   without this the hover wins and pointing at the chosen file repaints
   it as an ordinary one — the row loses the only thing that said it was
   selected, at exactly the moment somebody is looking at it. Hovering
   the selection deepens it instead. */
.file-row--selected:hover { border-color: var(--accent); background: #dbeafe; }
.file-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.file-thumb {
    flex: 0 0 3rem;
    width: 3rem; height: 3rem;
    object-fit: contain;
    border-radius: 4px;
    background: #f1f5f9;
}
.file-thumb--empty {
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.62rem;
    font-weight: 700;
    color: #94a3b8;
}

/* The row's picture of the job, and the only preview there is: clicking
   it opens the lightbox, exactly as the thumbnail on a booking card
   does. Sized and framed to match .booking-card-thumb so the same
   picture in the two places reads as the same control. */
.file-thumb-zoom {
    flex: 0 0 3rem;
    width: 3rem; height: 3rem;
    padding: 0;
    background: #f1f5f9;
    border: 1px solid var(--border);
    border-radius: 4px;
    cursor: zoom-in;
    overflow: hidden;
}
.file-thumb-zoom:hover { border-color: var(--accent); }
.file-thumb-zoom:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.file-thumb-zoom .file-thumb { display: block; width: 100%; height: 100%; }

.file-meta { flex: 1; min-width: 0; }
.file-name {
    font-size: 0.85rem;
    font-weight: 600;
    color: #334155;
    /* File names are long and the panel is narrow; one line, cut. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.file-facts { font-size: 0.72rem; color: #94a3b8; }

.file-delete {
    flex: 0 0 auto;
    width: 2rem; height: 2rem;
    padding: 0;
    font-size: 1.1rem;
    line-height: 1;
    background: transparent;
    color: #94a3b8;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
.file-delete:hover { background: #fef2f2; color: var(--danger); }

/* Choosing a plate inside a multi-plate file. */
.print-form { display: flex; flex-direction: column; gap: 0.6rem; }
.plate-picker { display: flex; gap: 0.4rem; overflow-x: auto; padding-bottom: 0.2rem; }
.plate-btn {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.2rem;
    padding: 0.3rem;
    background: #fff;
    border: 1px solid var(--border);
    border-radius: 6px;
    cursor: pointer;
}
.plate-btn--selected { border-color: var(--accent); background: #eff6ff; }
.plate-thumb { width: 3.5rem; height: 3.5rem; object-fit: contain; background: #f1f5f9; border-radius: 4px; }
.plate-caption { font-size: 0.7rem; color: #475569; }
.plate-facts { margin: 0; font-size: 0.78rem; color: #475569; text-align: center; }

/* Which spool feeds which slicer filament. */
.ams-map { display: flex; flex-direction: column; gap: 0.35rem; }
.ams-row { display: flex; align-items: center; gap: 0.5rem; }
.ams-swatch {
    flex: 0 0 auto;
    width: 0.9rem; height: 0.9rem;
    border-radius: 50%;
    border: 1px solid var(--border);
}
.ams-label { flex: 1; font-size: 0.78rem; color: #475569; }
.ams-select { flex: 0 0 9rem; padding: 0.3rem 0.4rem; font-size: 0.78rem; }

.print-options { font-size: 0.8rem; color: #475569; }
.print-options summary { cursor: pointer; padding: 0.3rem 0; }
.print-check {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0 0 0.3rem;
    font-weight: 400;
    font-size: 0.8rem;
    cursor: pointer;
}
.print-check input { width: 1.05rem; height: 1.05rem; padding: 0; }

/* What is already on the machine's card. */

/* --- Chamber camera ---------------------------------------------------
   The <img> is a multipart/x-mixed-replace response, i.e. a request that
   stays open. It is only in the document while somebody is looking at
   that printer; see the camera panel in js/shared.js.

   It sits inside the telemetry card rather than in one of its own, so it
   is separated from the readings above it by a rule and a gap instead of
   by a second card's 2rem of padding. Empty when there is no camera, and
   `display: none` then, so neither is drawn. */
#camera-panel {
    margin-top: 1.25rem;
    padding-top: 1.25rem;
    border-top: 1px solid var(--border);
}
.camera-frame {
    /* Reserve the shape before the first frame lands, so the column
       does not jump when the picture arrives. */
    aspect-ratio: 4 / 3;
    width: 100%;
    background: #0f172a;
    border: 1px solid var(--border);
    border-radius: 4px;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
}
/* Both players — the <img> for MJPEG and the <video> for fragmented
   MP4 — sit in the same box and are sized the same way. */
.camera-image { width: 100%; height: 100%; object-fit: contain; display: block; }
video.camera-image { background: #0f172a; }
.camera-status { font-size: 0.78rem; margin: 0.4rem 0 0; }
.camera-status--error { color: var(--danger); }

/* --- Reservations ----------------------------------------------------- */
/*
 * Everything to do with who has which machine and when lives in the
 * records column, beside the machine itself, on both pages. Making a
 * booking is a dialog rather than a form parked on the page: it is
 * something you do occasionally, and reading the schedule is something
 * you do every time you open the page.
 */

.records-column { display: flex; flex-direction: column; gap: 1.5rem; }
/* The cards carry their own bottom margin for the older single-column
   layouts; inside a gapped flex column that would double the spacing. */
.records-column > .card { margin-bottom: 0; }

.panel-header-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
}
.panel-header-row .panel-heading { margin-bottom: 0; }

.reserve-section { margin-top: 1.25rem; }
.reserve-subheading {
    margin: 0 0 0.5rem;
    font-size: 0.8rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: #64748b;
}

/* One of your own bookings. Big enough to read at arm's length, because
   the question it answers — "do I have the machine right now?" — is
   usually asked while walking towards it. */
.booking-list { display: flex; flex-direction: column; gap: 0.5rem; }
.booking-card {
    position: relative;   /* the corner × hangs off this */
    display: flex;
    align-items: flex-start;
    gap: 0.6rem;
    border: 1px solid var(--border);
    border-left: 3px solid #cbd5e1;
    border-radius: 6px;
    padding: 0.55rem 0.7rem;
    background: #fff;
}
.booking-card-body { flex: 1; min-width: 0; }
/* What this slot is for, as the slicer drew it. Removes itself when the
   file has aged out from under the booking. */
.booking-card-thumb {
    flex: 0 0 auto;
    width: 3rem; height: 3rem;
    padding: 0;
    background: #f1f5f9;
    border: 1px solid var(--border);
    border-radius: 4px;
    cursor: zoom-in;
    overflow: hidden;
}
.booking-card-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
.booking-card-thumb:hover { border-color: var(--accent); }
.booking-card--running { border-left-color: var(--success); background: #f0fdf4; }
.booking-card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.35rem;
    /* A booking shared with you carries a second badge. Wrapping rather
       than squeezing keeps the printer's name readable however narrow
       the column gets, which is the one thing on the card that must
       be. */
    flex-wrap: wrap;
}
.booking-card-head > strong { flex: 1 1 auto; min-width: 0; }
.booking-card-when { font-size: 0.85rem; color: #475569; margin-top: 0.15rem; }
.booking-card-facts {
    font-size: 0.72rem;
    color: #94a3b8;
    margin-top: 0.15rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* --- Managing a booking you already have ------------------------------ */
/*
 * Cancel, extend and share, in place on the card. The panels open under
 * the buttons rather than in a dialog: they are one field each, and a
 * modal for "add thirty minutes" is more ceremony than the action.
 */
.booking-card-pending {
    font-size: 0.75rem;
    color: #b45309;
    margin-top: 0.25rem;
}
/* How long this booking has before the system takes it back, with the
   button that puts it back to the top beside it. Drawn only while
   something is actually counting down — `release_at` is null otherwise —
   so it is a warning rather than a standing readout, and it sits above
   the at-the-machine buttons because it is about to take them away. */
.booking-release {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-top: 0.5rem;
    padding: 0.25rem 0.4rem;
    border-radius: 4px;
    background: rgba(180, 83, 9, 0.08);
    font-size: 0.75rem;
    color: #b45309;
}
.booking-release .link-button { font-size: 0.75rem; }

.booking-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-top: 0.5rem;
}
.booking-actions .control-btn {
    flex: 0 1 auto;
    padding: 0.3rem 0.55rem;
    font-size: 0.75rem;
}
/* The button whose panel is open, so it is obvious which of the three
   the field below belongs to. */
.booking-actions .control-btn.is-open { background: var(--accent); }

/* The corner × that deletes a ledger row (`cardDeleteRow` in js/admin.js).
   A grey glyph with no background until it is pointed at, because it is
   the rarest press on the card and was the loudest: a full-width danger
   button reading "Delete from the ledger" on every booking and every
   fault report.

   Absolutely positioned against the card, not laid out in it: as a row
   of its own it made every booking and every fault report a glyph
   taller for a press almost nobody makes, and the corner of a card is
   space that already exists. `.record-body` and `.booking-card` are the
   positioned ancestors; it sits in the padding they already carry, past
   the right-hand end of the last column's text.

   On a fault report it hangs in the bottom right, where the card ends.
   On an open ledger card it is the **top** right of the body (the rule
   under `.record-body`): the body is four columns, so its bottom right
   is wherever the longest of them happens to end — level with a plate
   preview on one card and with a member chip on the next — while its
   top right is the corner beside the ACTIONS heading, the same place on
   every row of the table, and directly above Void. */
.card-delete-row {
    position: absolute;
    right: 0.25rem;
    bottom: 0.15rem;
    display: flex;
}
/* The resolve row on an open fault report ends with a button at the
   card's right-hand edge, which is the corner the × hangs in. The row
   gives up that much width rather than the glyph moving: it is the same
   corner on every card on the tab. */
.booking-card--deletable .admin-add { padding-right: 1.9rem; }

.card-delete {
    padding: 0;
    width: 1.6rem; height: 1.6rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 1.1rem;
    line-height: 1;
    background: transparent;
    /* `button { color: white }` at the top of this file, so the colour
       is stated here or the glyph is invisible on the card. */
    color: #94a3b8;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
.card-delete:hover, .card-delete:focus-visible {
    background: rgba(239, 68, 68, 0.12);
    color: var(--danger);
}

.booking-panel {
    margin-top: 0.45rem;
    padding: 0.5rem 0.6rem;
    border: 1px solid var(--border);
    border-radius: 5px;
    background: #f8fafc;
}
.booking-panel-heading { display: block; font-size: 0.8rem; margin-bottom: 0.3rem; }
.booking-panel-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin: 0.35rem 0 0.25rem;
}
.booking-panel-row .approve { padding: 0.3rem 0.6rem; font-size: 0.75rem; }
.booking-minutes { width: 5rem; padding: 0.3rem; font-size: 0.85rem; }
.booking-email { flex: 1 1 10rem; min-width: 0; padding: 0.3rem; font-size: 0.85rem; }

/* Who else is on this booking. A chip rather than a list row: these are
   names, usually one or two of them, and they sit inside a card that is
   already dense. */
.booking-members {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem;
    margin-top: 0.35rem;
}
.booking-members-label { font-size: 0.72rem; color: #94a3b8; }
.member-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.2rem;
    padding: 0.1rem 0.4rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: #fff;
    font-size: 0.72rem;
    color: #334155;
    max-width: 100%;
}
/* Somebody the directory has never seen, so the chip carries an address
   rather than a name. Dashed rather than red: the share is in force
   either way, and this is the ordinary state for a first-year. */
.member-chip--unknown { border-style: dashed; color: #64748b; }
.member-chip-remove {
    border: none;
    background: none;
    padding: 0 0.1rem;
    cursor: pointer;
    color: #94a3b8;
    font-size: 0.9rem;
    line-height: 1;
}
.member-chip-remove:hover { color: var(--danger); }

/* --- One reservation, in full ----------------------------------------- */
/*
 * The Reservations tab. A card per booking rather than a table row: what
 * an operator needs in front of them is the job, the plate, who is on
 * it, whether anybody turned up and what went wrong — which is more than
 * three columns can carry.
 *
 * The head is a summary line that stays readable closed, so the list is
 * still scannable; opening one reveals the sections.
 */
.record-list { display: flex; flex-direction: column; gap: 0.4rem; }

/* Previous / next under the ledger. Empty — and so invisible, since it
   has no padding of its own — while one page holds every match. */
.record-pager {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    margin-top: 0.75rem;
}
.pager-button {
    padding: 0.35rem 0.9rem;
    font-size: 0.85rem;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: #fff;
    color: var(--text);
    cursor: pointer;
}
.pager-button:hover:not([disabled]) { background: #f1f5f9; }
.pager-button[disabled] { opacity: 0.45; cursor: default; }
.pager-position { font-size: 0.8rem; color: #64748b; }

.record {
    /* The reveal ring and the card's own stacking; the corner × hangs
       off `.record-body` below, which is the only part of the card that
       exists while it is open. */
    position: relative;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: #fff;
    overflow: hidden;
}
.record--running { border-left: 3px solid var(--success); }

/* A card arrived at from the calendar's *Open in the ledger* button.
   Twenty-five cards look alike, so scrolling one into the middle of the
   view says nothing on its own about which one was asked for — the ring
   says it, and fades, because it is an answer to a press and not a state
   the card is in. An `outline`, not a border: a border would move the
   card's own content by a pixel as it came and went. */
.record--revealed { animation: record-reveal 1.8s ease-out; }
@keyframes record-reveal {
    from { outline: 2px solid var(--accent); outline-offset: 2px; }
    70%  { outline: 2px solid var(--accent); outline-offset: 2px; }
    to   { outline: 2px solid transparent; outline-offset: 2px; }
}
@media (prefers-reduced-motion: reduce) {
    .record--revealed { animation: none; outline: 2px solid var(--accent); outline-offset: 2px; }
}

/* The head of a card, and — on a pending one — the two answers to it.
   A button cannot hold a button, so they are siblings; `min-width: 0`
   lets the head be narrower than its own content, which is what stops a
   long purpose pushing the ✓/✗ off the end of the card. */
.record-row { display: flex; align-items: stretch; min-width: 0; }
.record-row > .record-head { flex: 1 1 auto; min-width: 0; }
.record-decide {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0 0.6rem 0 0.2rem;
}
/* Small, square and the same two colours the deleted approval table
   used — the glyph is the whole label, so the padding is what makes it
   a target. */
.record-decide-btn {
    padding: 0.2rem 0.5rem;
    font-size: 0.85rem;
    line-height: 1.3;
    border-radius: 4px;
}

/* The head of a ledger card is a <button>, so it inherits the accent
   background and white text every button on this page gets — it clears
   the background but used to leave the colour, which drew the machine's
   name in white on white. Everything else on the row sets its own
   colour, so #4 was the one thing on the card nobody could read. */
.record-head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.45rem;
    width: 100%;
    padding: 0.5rem 0.6rem;
    border: none;
    background: none;
    color: var(--text);
    font: inherit;
    text-align: left;
    cursor: pointer;
}
.record-head:hover { background: #f8fafc; }
.record-head--open { background: #f1f5f9; }
.record-caret { color: #94a3b8; width: 0.8rem; }
.record-printer { font-weight: 600; }
.record-when { font-size: 0.8rem; color: #475569; }
.record-who { font-size: 0.8rem; color: #64748b; }
/* What the machine is for, in the booker's words. Allowed to be the
   long thing on the line, and to be cut off when it is too long — the
   card below it carries the whole sentence. */
.record-purpose {
    font-size: 0.8rem;
    color: #334155;
    font-style: italic;
    max-width: 24ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.record-chip {
    font-size: 0.7rem;
    padding: 0.05rem 0.35rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    color: #475569;
}

.record-body {
    position: relative;   /* the corner × hangs off this, not off the card */
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
    gap: 0.75rem;
    padding: 0.2rem 0.6rem 0.7rem;
    border-top: 1px solid var(--border);
}
/* The × is the body's top right corner, not the card's bottom one.
   The head above it is a <button> and on a pending card already ends in
   ✓/✗, so the glyph cannot go there; the body's first row is a
   short uppercase heading, which leaves that corner empty on every
   card. */
.record-body > .card-delete-row { top: 0.1rem; bottom: auto; }
.record-section { min-width: 0; }
.record-section-heading {
    margin: 0.5rem 0 0.3rem;
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #94a3b8;
}
.record-fact {
    display: flex;
    gap: 0.4rem;
    align-items: baseline;
    font-size: 0.82rem;
    margin-bottom: 0.2rem;
    /* A purpose or a note can be a sentence; it wraps rather than
       stretching the column. */
    overflow-wrap: anywhere;
}
.record-fact-label {
    flex: 0 0 auto;
    min-width: 5.5rem;
    font-size: 0.72rem;
    color: #94a3b8;
}
.record-sub { font-size: 0.78rem; color: #64748b; margin: 0.15rem 0; }
/* A record line that is a thing still outstanding rather than a thing
   that simply is not there. Same colour as `.form-hint--warn`, which is
   the student card's word for the same fact. */
.record-sub--warn { color: #b45309; }

/* Status and Void on one row: the dropdown asks the question, the button
   is its dangerous answer, and the width beside a 9rem <select> was
   empty while Void had a row of its own under it. `margin-left: auto`
   rather than `justify-content`, so the label and the dropdown keep the
   left-hand edge every other fact on the card lines up on. */
.record-status-row { align-items: center; }
.record-status-row .record-action-btn { margin-left: auto; }

/* The two buttons in the ACTIONS column — Void and Save — are one size:
   same padding, same type size, same floor, so they read as the pair
   they are on the column's right-hand edge. `flex: 0 0 auto` undoes
   `.control-btn`'s `flex: 1`, which inside `.record-times` took the
   row's spare width and wrapped the label over two lines. The floor is
   3.5rem and not more, because Save's column comes out of the width a
   datetime box has: every rem of button is a rem the box gives up, and
   below ~180px Firefox starts cutting the calendar glyph off the end of
   it. A longer word still widens its own button — the floor is there so
   *Save* and 保存 are not two different shapes. */
.record-action-btn {
    flex: 0 0 auto;
    min-width: 3.5rem;
    padding: 0.3rem 0.6rem;
    font-size: 0.75rem;
    text-align: center;
}
/* The times editor. A row of label-over-box pairs that wraps, rather
   than the one-line label/value the other facts use — two datetime
   boxes and a button do not fit a 5.5rem label column on a narrow
   screen. */
.record-fact--wide { display: block; }
/* Two label-over-box pairs and a button, and the button belongs beside
   the End box rather than under it — a third row for one press, in the
   tallest section of the card.

   A **grid, not a flex row**, because flex could only get this by
   wrapping and wrapping is what put Save on its own line: a datetime
   box is ~180px of intrinsic width and the ACTIONS column is 15rem, so
   box + gap + button does not fit at max-content and the row broke. The
   grid instead gives column 1 whatever is left (`minmax(0, 1fr)`, and
   the boxes take it with `width: 100%`) and column 2 the button. So the
   two boxes are always the same width as each other, they shrink
   instead of wrapping, and Save is placed on row 2 by hand — the only
   part of this layout that is not automatic, since both labels
   auto-place into column 1. `align-items: end` is what makes the
   button's bottom edge sit level with the End box.

   `box-sizing: border-box` on the box, or `width: 100%` plus its own
   padding and border overflows the track it was measured into. There is
   no `*` rule for this in the file — it is said on the element. */
.record-times {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: end;
    gap: 0.3rem 0.4rem;
    margin-top: 0.2rem;
}
.record-times > .record-time-label { grid-column: 1; min-width: 0; }
.record-times > .record-action-btn { grid-column: 2; grid-row: 2; }
.record-time-label {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    /* `label { margin-bottom: 0.5rem }` at the top of this file, which
       here was 8px of grid row under the box: the row came out 8px
       taller than the pair in it, so Save — aligned to the row's end —
       sat that much below the End box it is meant to be level with.
       Two of them is also 16px of card height under a section that is
       already the tallest one on it. */
    margin: 0;
    font-size: 0.72rem;
    color: #94a3b8;
}
.record-time {
    width: 100%;
    min-width: 0;
    box-sizing: border-box;
    font: inherit;
    font-size: 0.8rem;
    padding: 0.2rem 0.3rem;
    border: 1px solid var(--border);
    border-radius: 4px;
    background: #fff;
    color: var(--text);
}
.record-file { display: flex; gap: 0.5rem; align-items: flex-start; margin-top: 0.4rem; }
.record-extension {
    padding: 0.4rem 0.5rem;
    margin-bottom: 0.4rem;
    border: 1px solid #fcd34d;
    border-radius: 5px;
    background: #fffbeb;
}

/* --- Closing a slot out, and reporting a fault ------------------------ */
/*
 * The completion form (requirements 1.3) and the fault report (1.4) open
 * in the same in-card panel the extend and share forms use, so all four
 * are the same shape and only one is ever open at a time.
 */
.form-label {
    display: block;
    font-size: 0.72rem;
    color: #64748b;
    margin: 0.45rem 0 0.2rem;
}
.compact-input,
.compact-textarea {
    width: 100%;
    box-sizing: border-box;
    padding: 0.3rem;
    font-size: 0.85rem;
    font-family: inherit;
    border: 1px solid var(--border);
    border-radius: 4px;
    background: #fff;
}
.compact-textarea { resize: vertical; }
.compact-file { font-size: 0.75rem; max-width: 100%; }

/* Three buttons, one of which is pressed. A radio group would be the
   semantic choice, but these are three short verdicts and reading them
   as a row is the point. */
.outcome-picker { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.outcome-btn {
    flex: 1 1 auto;
    padding: 0.3rem 0.5rem;
    font-size: 0.75rem;
    border: 1px solid var(--border);
    border-radius: 4px;
    background: #fff;
    color: #334155;
    cursor: pointer;
}
.outcome-btn--selected { color: #fff; border-color: transparent; }
.outcome-btn--success.outcome-btn--selected { background: var(--success); }
.outcome-btn--failed.outcome-btn--selected  { background: var(--danger); }
.outcome-btn--partial.outcome-btn--selected { background: #f59e0b; }

.booking-panel-actions { margin-top: 0.6rem; }
.booking-panel-actions .approve { padding: 0.3rem 0.7rem; font-size: 0.78rem; }

/* A word in a line of facts that happens to be pressable — the photo on
   a completed booking. Styled as text, because it sits mid-sentence. */
.link-button {
    border: none;
    background: none;
    padding: 0;
    font: inherit;
    color: var(--accent);
    text-decoration: underline;
    cursor: pointer;
}

/* --- The check-in label ---------------------------------------------- */
/*
 * The QR code that goes on the front of a machine (1.2). Big, black on
 * white, and printable: what an operator wants from this dialog is a
 * sheet of paper.
 */
.qr-body { text-align: center; padding: 0.5rem 0; }
.qr-image {
    width: min(20rem, 60vw);
    height: auto;
    background: #fff;
}
.qr-caption {
    margin: 0.3rem 0 0;
    font-size: 1.1rem;
    font-weight: 600;
    letter-spacing: 0.02em;
}

/* Printing the page while the code is open prints the code and nothing
   else. `visibility` rather than `display`, because a <dialog> in the
   top layer is not in the flow its ancestors are. */
@media print {
    body > *:not(.qr-dialog) { display: none !important; }
    .qr-dialog {
        position: static;
        display: block;
        border: none;
        box-shadow: none;
        width: auto;
        max-width: none;
    }
    .qr-dialog .reserve-close,
    .qr-dialog .reserve-actions,
    .qr-dialog .form-hint { display: none !important; }
    .qr-image { width: 60mm; }
}

/* --- The booking dialog ---------------------------------------------- */
/*
 * A native <dialog>, so Escape closes it, focus is trapped in it and the
 * page behind it is inert without a line of JavaScript for any of that.
 */
.reserve-dialog {
    width: min(34rem, calc(100vw - 2rem));
    max-height: calc(100vh - 3rem);
    padding: 0;
    border: none;
    border-radius: 10px;
    background: var(--card-bg);
    color: var(--text);
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
}
.reserve-dialog::backdrop { background: rgba(15, 23, 42, 0.5); }
/* reservations.allow_custom_duration is off: the slot is the print. The
   number still has to be readable — it says how long the machine will be
   held — so this is `readonly` styled as fixed, not a greyed-out
   `disabled` field skipped by the tab order. */
.reserve-minutes--fixed {
    background: #f1f5f9;
    color: #475569;
    cursor: default;
}

.reserve-form { display: flex; flex-direction: column; max-height: inherit; }

.reserve-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 1rem;
    padding: 1.25rem 1.5rem 0.75rem;
    border-bottom: 1px solid var(--border);
}
.reserve-head .panel-heading { margin: 0; }
.reserve-close {
    padding: 0;
    width: 2rem; height: 2rem;
    font-size: 1.3rem;
    line-height: 1;
    background: transparent;
    color: #94a3b8;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}
.reserve-close:hover { background: #f1f5f9; color: var(--text); }

/* The body scrolls, the heading and the buttons do not: on a phone the
   file list is long and the two things you need — what this is and how
   to confirm it — should never scroll off. */
.reserve-body { padding: 1.25rem 1.5rem; overflow-y: auto; }
.reserve-body .form-group:last-child { margin-bottom: 0; }

.reserve-file-mount { display: flex; flex-direction: column; gap: 0.5rem; }
/* The library inside the dialog is a picker, not a manager: deleting is
   done from the print controls, where the consequences are visible. */
.reserve-file-mount .file-list { max-height: 14rem; overflow-y: auto; }

/* The start row: the picker and the "start now" box beside it.
   The box is on the same line because it is an answer to the same
   question, and a tick that lives under a field it switches off reads
   as a separate setting. */
.reserve-start-row { display: flex; align-items: center; gap: 0.75rem; }
.reserve-start-row input[type="datetime-local"] { flex: 1 1 auto; min-width: 0; }
.reserve-start-now { flex: 0 0 auto; margin: 0; white-space: nowrap; }

/* Switched off, not hidden: the row still has to read as being about
   the start time. Matches .reserve-minutes--fixed, which greys the
   duration for the same kind of reason. */
.reserve-start--off {
    background: #f1f5f9;
    color: #94a3b8;
    cursor: default;
}

.reserve-duration { display: flex; align-items: center; gap: 0.5rem; }
.reserve-duration input { width: 6rem; }
.reserve-unit { font-size: 0.85rem; color: #64748b; }
.reserve-match { flex: 0 1 auto; margin-left: auto; white-space: nowrap; }

.form-hint--warn { color: #b45309; }

.reserve-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
    padding: 0.9rem 1.5rem 1.25rem;
    border-top: 1px solid var(--border);
}
.reserve-actions .control-btn { flex: 0 0 auto; padding: 0.75rem 1.25rem; }

/* --- The bulk booking dialog ----------------------------------------
 *
 * The same native <dialog> as the booking form, a little wider: it holds
 * a checkbox per printer and one per weekday side by side, and at the
 * booking dialog's width those wrap into columns of one.
 */
.bulk-dialog { width: min(42rem, calc(100vw - 2rem)); }

/* Nine machines in two or three columns rather than a column of nine —
   the list is scanned for what is missing, and a tall thin one has to be
   scrolled to be scanned. */
.bulk-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
    gap: 0.25rem 0.75rem;
    margin-top: 0.35rem;
}
/* Seven days on one line wherever there is room for them: a week reads
   as a week, and wrapped after the fifth it does not. */
.bulk-days { display: flex; flex-wrap: wrap; gap: 0.25rem 0.9rem; margin-top: 0.35rem; }
.bulk-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.bulk-row input[type="number"] { width: 6rem; }
/* A machine out of service is shown and not offered — an operator
   ticking nine boxes should see which one cannot be booked before the
   press, not afterwards in a list of skipped slots. */
.print-check--off { opacity: 0.55; }

@media (max-width: 860px) {
    .records-column { gap: 1rem; }
    .reserve-dialog { width: 100vw; max-width: 100vw; max-height: 100vh; border-radius: 0; }
    .reserve-start-row { flex-wrap: wrap; }
    .reserve-duration { flex-wrap: wrap; }
    .reserve-match { margin-left: 0; width: 100%; }
}

/* The printer controls, withheld.
 *
 * Shown in place of the whole control panel to somebody with no booking
 * on the selected machine. A greyed-out row of dead buttons reads as
 * broken software; this reads as a rule, which is what it is. The
 * telemetry and camera panels above are untouched — reading is not
 * gated. */
.control-locked {
    border: 1px dashed var(--border);
    border-radius: 8px;
    background: #f8fafc;
    padding: 1.1rem;
    text-align: center;
}
.control-locked-title { margin: 0 0 0.5rem; font-weight: 600; color: #475569; }
.control-locked-body {
    margin: 0 auto 0.4rem;
    max-width: 44ch;
    font-size: 0.85rem;
    color: #64748b;
}
.control-locked-body:last-child { margin-bottom: 0; }

/* --- The settings page ------------------------------------------------ */
/*
 * Administrators, the booking rules and every uploaded file. Each panel
 * is a .card built by js/admin-settings.js; these are the pieces that
 * are particular to it.
 */

/* The sentence under a panel heading that says what the panel is for.
   Deliberately readable width rather than full: a paragraph the width of
   a 1200px page is a paragraph nobody reads. */
.settings-blurb {
    margin: 0 0 1rem;
    font-size: 0.85rem;
    color: #64748b;
    max-width: 62ch;
}

.settings-switch { margin-bottom: 1.25rem; }
/* A rule whose switch carries a number: the tick-box and the minutes on
   one line, the explanation under both. It wraps, because "Give an
   unused booking back" plus a spinner plus "minutes" does not fit a
   phone's settings column on one line, and the number belongs under the
   label it qualifies rather than off the side of the card. */
.settings-switch-line {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem 0.75rem;
}
.settings-switch:last-child { margin-bottom: 0; }
/* What the setting does, in its current position — so the reader learns
   the consequence before flipping it rather than after. */
.settings-switch-note {
    margin: 0.35rem 0 0 1.75rem;
    font-size: 0.82rem;
    color: #64748b;
    max-width: 62ch;
    /* A note may carry its own line break. The idle-release rule is what
       the setting does and then how to switch it off, and those read as
       two lines rather than one paragraph. `pre-line` honours the \n and
       leaves ordinary wrapping alone, so the notes stay one string per
       key and the other four are unaffected. */
    white-space: pre-line;
}

.admin-list {
    list-style: none;
    margin: 0 0 1rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}
.admin-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.5rem 0.6rem;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: #fff;
}
.admin-row-main { flex: 1; min-width: 0; }
.admin-row-head {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-wrap: wrap;
}
/* The address is the entry — it is what was typed, what is in
   config.toml, and what removing this row would remove. So it leads,
   and the person the directory knows it by sits underneath. */
.admin-email { font-weight: 600; word-break: break-all; }
.admin-row-sub { font-size: 0.8rem; color: #64748b; margin-top: 0.15rem; }
/* Nobody by that address has been here. Lighter, not louder: it is the
   normal state for somebody who has just been added. */
.admin-row-sub--unknown { font-style: italic; }

/* The same, under any box that takes an address. */
.form-hint--unknown { font-style: italic; }

.admin-badge {
    font-size: 0.68rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding: 0.1rem 0.4rem;
    border-radius: 999px;
    border: 1px solid;
    white-space: nowrap;
}
/* "you" is the only badge left on this list. The confirmed/pending pair
   that used to sit here is gone: the row's status is now the person's
   name, from the directory, and a name needs no badge to explain it. */
.admin-badge--you       { background: #eff6ff; border-color: var(--accent); color: #1d4ed8; }

.admin-add { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.admin-add-input {
    flex: 1;
    min-width: 14rem;
    padding: 0.6rem;
}

/* The ledger card's "share with somebody" row. The column is narrow and
   the input's 14rem floor wrapped the button onto a line of its own, so
   the rarest field on a booking card cost every card a whole button of
   height. One row instead: the box takes what is left (`min-width: 0`
   beats the floor) and the button is a square `+`, whose sentence lives
   in `title`/`aria-label` the way `.card-delete`'s does. `stretch` is
   what keeps it exactly as tall as the box at both font sizes, and
   2.5rem is the finger target the 560px block asks for, so it needs no
   entry there. */
.admin-add--inline { flex-wrap: nowrap; align-items: stretch; }
.admin-add--inline .admin-add-input { min-width: 0; }
.admin-add-glyph {
    flex: 0 0 auto;
    width: 2.5rem;
    min-height: 2.5rem;
    padding: 0;
    font-size: 1.3rem;
    line-height: 1;
}

/* Who a file belongs to, in the settings page's listing. Nowhere else:
   the per-printer list only ever shows your own files. */
.file-owner {
    font-size: 0.78rem;
    color: #475569;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* The settings listing is a list, not a picker — nothing here is
   selectable, so it must not offer the hover of something that is. */
.file-row--static { cursor: default; }
.file-row--static:hover { border-color: var(--border); background: #fff; }

/* Full size, over everything. Same native <dialog> as the booking form,
   so Escape closes it and the backdrop is free. */
.lightbox {
    max-width: 100vw;
    max-height: 100vh;
    padding: 1rem;
    border: none;
    background: transparent;
    cursor: zoom-out;
}
.lightbox::backdrop { background: rgba(15, 23, 42, 0.8); }
.lightbox-image {
    display: block;
    max-width: min(90vw, 60rem);
    max-height: 82vh;
    margin: 0 auto;
    border-radius: 8px;
    background: #fff;
}
/* Its own chip, because what is behind it is whatever the picture was
   opened from — a white dialog as often as the dimmed page. */
.lightbox-caption {
    width: fit-content;
    max-width: 90vw;
    margin: 0.75rem auto 0;
    padding: 0.35rem 0.85rem;
    border-radius: 999px;
    background: rgba(15, 23, 42, 0.85);
    color: #f1f5f9;
    text-align: center;
    font-size: 0.85rem;
}

/* The credit line at the foot of both pages. Built by renderFooter()
   in js/shared.js, not written into either page's markup. */
.page-footer {
    margin: 2.5rem 0 1rem;
    text-align: center;
    font-size: 0.85rem;
    color: var(--text-muted, #888);
}

/* --- The week view -----------------------------------------------------
   One row per printer, seven days across, a booking drawn as a bar the
   width of its own slot. Everything horizontal is a percentage of the
   week, because js/week.js positions the bars from real timestamps and
   the two have to agree; the only fixed sizes here are heights and the
   name column. */
.week-range {
    color: #64748b;
    font-size: 0.85rem;
}
.week-nav { display: flex; align-items: center; gap: 0.35rem; }
.week-nav-button {
    padding: 0.3rem 0.7rem;
    font-size: 0.8rem;
    background: #64748b;
}
/* "Today" and "This week" are not the same length, and neither are
   "Day" and "Week" — so pressing one moved the other out from under
   the pointer, and the arrows with it. Every control in this row is
   sized for its widest label rather than for its current one. The
   Japanese did not show it: 今日/今週 and 1日/1週間 are near enough the
   same width, which is exactly why it has to be a rule and not an eye
   test. */
.week-nav-today { min-width: 6.5rem; }
/* The grid is one object, so it is drawn with one edge all the way
   round — the same 2px #cbd5e1 as the rule under the day names and the
   one down the side of the machine column, which are the same frame
   seen from the inside. Without it the bars, the hour rules and the
   legend under them all faded off into the card with nothing saying
   where the calendar stopped. */
.week-body {
    display: flex;
    align-items: stretch;
    border: 2px solid #cbd5e1;
    border-radius: 6px;
    overflow: hidden;   /* so the corners actually cut the grid */
}
/* The frame is the bottom edge now: the last row's own hairline would
   sit a pixel inside it and read as a double rule. */
.week-rows .week-row:last-child { border-bottom: 0; }
.week-names .week-name:last-child { border-bottom: 0; }
/* Sized to its own widest cell, not to a number picked in advance: the
   column is a dot, "#10" and a model, and every pixel it does not take
   is a pixel of week. Capped, because `label` is free text and a long
   one must not eat the grid. */
.week-names {
    flex: 0 0 auto;
    width: max-content;
    max-width: 11rem;
    /* The two headers are frames, not more of the grid. Every other rule
       in the strip is a hairline in #f1f5f9 or var(--border); these two
       are heavier and darker, or the column of machines reads as the
       first day of the week and the day names as the first booking. */
    border-right: 2px solid #cbd5e1;
}
/* The corner cell: what the column under it is for. */
.week-names-head {
    height: 2.4rem;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    padding: 0 0.4rem 0 0.1rem;
    border-bottom: 2px solid #cbd5e1;
    font-size: 0.62rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #94a3b8;
    white-space: nowrap;
}
/* The machine column is also the fleet: a state colour, the name, the
   model and the progress of whatever is running. It replaced the SVG
   plan of the room that used to sit above this strip. */
/* Each machine is a button, drawn as one. It is inside the calendar's
   header, so it cannot be a <button> in a row of its own — the cell has
   to stay exactly as tall as the grid row beside it or the two columns
   drift apart. So the chip is a `::before` inset inside the cell: it
   costs no layout at all, and the cell keeps `height: 2.4rem` plus the
   1px that `.week-row` also adds. */
.week-name {
    position: relative;
    height: 2.4rem;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0 0.6rem 0 0.35rem;
    font-size: 0.8rem;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    border-bottom: 1px solid transparent;   /* keeps the row rhythm */
    cursor: pointer;
}
.week-name::before {
    content: '';
    position: absolute;
    inset: 2px 5px 3px 0;
    border: 1px solid var(--border);
    border-radius: 5px;
    background: #f8fafc;
}
/* The in-flow contents sit over the chip: an absolutely-positioned
   pseudo-element paints above ordinary content, so without this the chip
   would cover the name. Listed rather than `> *`, because the progress
   sliver is also a child and is positioned absolutely itself. */
.week-name > .week-dot,
.week-name > .week-name-text { position: relative; }
.week-name:hover::before { background: #eef2f7; border-color: #cbd5e1; }
.week-name:active::before { background: #e2e8f0; }
.week-name--selected::before {
    background: #eff6ff;
    border-color: var(--accent);
    box-shadow: inset 0 0 0 1px var(--accent);
}
.week-name--selected { color: var(--accent); }
.week-name:focus-visible { outline: none; }
.week-name:focus-visible::before { border-color: var(--accent); box-shadow: 0 0 0 2px #bfdbfe; }
.week-dot {
    flex: 0 0 auto;
    width: 0.7rem;
    height: 0.7rem;
    border-radius: 2px;
    border: 2px solid;
    box-sizing: content-box;
}
.week-name-text {
    display: flex;
    align-items: baseline;
    gap: 0.35rem;
    min-width: 0;
    overflow: hidden;
}
.week-name-label { overflow: hidden; text-overflow: ellipsis; }
/* The model identifies the machine; it does not label it. */
.week-name-model {
    font-size: 0.68rem;
    font-weight: 500;
    color: #94a3b8;
    letter-spacing: 0.02em;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* Open fault reports on this machine. A count, not the text: the words
   are free-form and belong on the admin page. */
/* The depth of the queue behind a tab, on the button that opens it —
   the Reservations tab's waiting decisions and the Fault reports tab's
   open reports. On the button, so it inherits white on the accent and on
   the grey of an unselected tab alike; a translucent white ground rather
   than a colour of its own, or it would read as a third tab state. */
.tab-badge {
    display: inline-block;
    margin-left: 0.4rem;
    min-width: 1.35em;
    padding: 0 0.35em;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.25);
    font-size: 0.8em;
    line-height: 1.5;
    text-align: center;
    vertical-align: baseline;
}

/* A sliver along the bottom of the cell, exactly where the plan drew it
   along the bottom of a tile. */
/* The full inner width of the button, along its bottom edge. The column
   is narrow, so a bar inset on either side is a bar nobody can read a
   percentage off. */
.week-name-progress {
    position: absolute;
    left: 1px;
    right: 6px;
    bottom: 4px;
    height: 3px;
    background: #e2e8f0;
    overflow: hidden;
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
}
.week-name-progress-fill { display: block; height: 100%; background: #94a3b8; }
.week-name-progress-fill--printing  { background: var(--success); }
.week-name-progress-fill--preparing { background: #0ea5e9; }
.week-name-progress-fill--paused    { background: #f59e0b; }
.week-name-progress-fill--finished  { background: #6366f1; }
.week-name-progress-fill--error     { background: var(--danger); }

/* One colour table for the machine column and the legend under it, and
   it has to sit **after** both base rules: `.week-dot`'s `border`
   shorthand resets `border-color` to currentColor, so a variant declared
   earlier in the file would be overwritten by it. `statusKind()` in
   js/shared.js is where these words come from. */
.week-dot--printing,  .legend-swatch--printing  { background: #bbf7d0; border-color: var(--success); }
.week-dot--preparing, .legend-swatch--preparing { background: #bae6fd; border-color: #0ea5e9; }
.week-dot--paused,    .legend-swatch--paused    { background: #fde68a; border-color: #f59e0b; }
.week-dot--finished,  .legend-swatch--finished  { background: #c7d2fe; border-color: #6366f1; }
.week-dot--error,     .legend-swatch--error     { background: #fecaca; border-color: var(--danger); }
.week-dot--idle,      .legend-swatch--idle      { background: #e2e8f0; border-color: #94a3b8; }
/* Offline is deliberately drab and dashed rather than red: an unreachable
   printer is not a fault on the printer's part, and painting it as an
   alarm would drown out the machines that really are in trouble. */
.week-dot--offline,   .legend-swatch--offline   { background: #f1f5f9; border-color: #94a3b8; border-style: dashed; }
.week-dot--unknown { background: #f8fafc; border-color: #cbd5e1; border-style: dotted; }
/* Out of service (KUAS_3D_Printer_Requirements 1.4). A ring around
   whatever the telemetry colour is, rather than instead of it: a machine
   being repaired is still reporting, and "it is broken and it is
   printing" is worth being able to see. */
.week-dot--maintenance {
    border-color: #b45309;
    border-style: dashed;
    box-shadow: 0 0 0 2px #fef3c7;
}
/* The grid takes what is left beside the names and no more. It used to
   be 105rem with this scrolling sideways under it, which put most of
   the week off screen and made "where the reader is" a thing every
   repaint had to carry by hand. `min-width: 0` is what lets a flex item
   be narrower than its content wants to be. */
.week-scroll { flex: 1 1 auto; min-width: 0; overflow: hidden; }
.week-track { position: relative; }
.week-head {
    position: relative;
    height: 2.4rem;
    box-sizing: border-box;
    border-bottom: 2px solid #cbd5e1;
}
.week-day-head {
    position: absolute;
    top: 0;
    bottom: 0;
    box-sizing: border-box;   /* same reason as .week-bar: the width is a day */
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    border-left: 1px solid var(--border);
    font-size: 0.75rem;
    line-height: 1.2;
}
/* Today, in a week of seven columns that all look alike. Loud on
   purpose: the now line is a day-view thing, so in the week this
   shading is the only mark saying where the reader is standing. */
.week-day-head--today {
    background: #dbeafe;
    box-shadow: inset 0 -3px 0 var(--accent);
}
.week-day-head--today .week-day-name { color: #1d4ed8; }
.week-day-head--today .week-day-date { color: #3b82f6; }
/* A day's heading is the way into that day. */
.week-day-head--link { cursor: pointer; }
.week-day-head--link:hover { background: #f1f5f9; }
.week-day-head--link.week-day-head--today:hover { background: #bfdbfe; }
.week-day-head--link:focus-visible { outline: 2px solid #1e293b; outline-offset: -2px; }
/* The day view's clock. Lighter than a weekday name — it is a scale,
   not a heading anybody clicks. */
.week-hour-head { justify-content: center; color: #64748b; font-weight: 600; }
.week-day-name { font-weight: 700; }
.week-day-date { color: #94a3b8; }
.week-rows { position: relative; }
.week-row {
    position: relative;
    height: 2.4rem;   /* keep in step with .week-name */
    border-bottom: 1px solid #f1f5f9;
    cursor: copy;   /* clicking a free stretch opens the booking dialog */
}
.week-row--selected { background: #f8fafc; }
/* Day boundaries, six-hourly ticks and the "now" line, over every row
   at once and transparent to the mouse so a click still reaches the row
   underneath. */
.week-overlay { position: absolute; inset: 0; pointer-events: none; }
.week-line { position: absolute; top: 0; bottom: 0; width: 0; }
.week-line--day { border-left: 1px solid var(--border); }
.week-line--hour { border-left: 1px dotted #eef2f7; }
.week-now { position: absolute; top: 0; bottom: 0; border-left: 2px solid var(--danger); }

/* The instant under the pointer. Runs the height of the track so the
   hairline meets the day heading it names, and is transparent to the
   mouse so it never eats the click it is describing. */
.week-cursor {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 0;
    border-left: 1px dashed #1e293b;
    pointer-events: none;
    z-index: 2;
}
.week-cursor--flip .week-cursor-time { left: auto; right: 0.25rem; }
.week-cursor-time {
    position: absolute;
    top: 0.15rem;
    left: 0.25rem;
    padding: 0.05rem 0.3rem;
    border-radius: 3px;
    background: #1e293b;
    color: #fff;
    font-size: 0.66rem;
    font-weight: 700;
    white-space: nowrap;
}
.week-bar {
    position: absolute;
    top: 0.2rem;
    bottom: 0.2rem;
    /* Load-bearing: the bar's width is a percentage of the week and its
       right edge is an instant. Content-box would add the padding (and
       any border) on top of that, which across a week was 8-10px == the
       best part of an hour of overshoot past the now line. */
    box-sizing: border-box;
    border-radius: 3px;
    padding: 0 0.25rem;
    display: flex;
    align-items: center;
    overflow: hidden;
    cursor: pointer;
    background: var(--accent);
    color: #ffffff;
}
.week-bar--running { background: var(--success); }
.week-bar--pending { background: #f59e0b; }
.week-bar--past { background: #cbd5e1; color: #475569; }
.week-bar--mine { outline: 2px solid #1e293b; outline-offset: -2px; }
/* Several bookings in one block, because at a week's scale they would
   be drawn on top of each other. It carries how many and nothing else:
   a count is honest at four pixels a booking where a name is not, and
   clicking it opens the day where the names fit. Ridged so it does not
   read as one long booking. */
.week-bar--group {
    justify-content: center;
    background-image: repeating-linear-gradient(
        90deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.45) 2px,
        transparent 2px, transparent 6px);
    box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.35);
}
.week-bar-count {
    font-size: 0.7rem;
    font-weight: 800;
    line-height: 1;
    padding: 0.1rem 0.25rem;
    border-radius: 3px;
    background: rgba(15, 23, 42, 0.72);
    color: #ffffff;
}
.week-bar-label {
    font-size: 0.7rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.week-bar:focus-visible { outline: 2px solid #1e293b; outline-offset: 1px; }

/* The details of one booking, opened by tapping or clicking its bar.
   Fixed and parented to <body>, because the week scroller clips its own
   overflow and the panel is rebuilt every thirty seconds. */
.week-popup {
    position: fixed;
    z-index: 60;
    max-width: min(22rem, calc(100vw - 1rem));
    padding: 0.7rem 2rem 0.7rem 0.8rem;
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    box-shadow: 0 8px 20px -6px rgba(0, 0, 0, 0.3);
    font-size: 0.8rem;
    line-height: 1.45;
}
.week-popup-title { display: block; margin-bottom: 0.2rem; }
.week-popup-line { color: #475569; overflow-wrap: anywhere; }
/* One button, and it is the only pressable thing in the popup besides
   the × — so it gets a line of its own under the facts rather than
   competing with them. */
.week-popup-link {
    margin-top: 0.5rem;
    padding: 0.25rem 0.6rem;
    font-size: 0.78rem;
}
.week-popup-close {
    position: absolute;
    top: 0.15rem;
    right: 0.15rem;
    padding: 0.1rem 0.4rem;
    background: none;
    color: #94a3b8;
    font-size: 1.1rem;
    line-height: 1;
}

/* Day | Week, and the date picker under it. */
.week-modes {
    display: inline-flex;
    margin-right: 0.35rem;
    border: 1px solid var(--border);
    border-radius: 5px;
    overflow: hidden;
}
.week-mode {
    min-width: 3.6rem;
    padding: 0.2rem 0.6rem;
    text-align: center;
    border: 0;
    border-radius: 0;
    background: #ffffff;
    color: #475569;
    font-size: 0.75rem;
    font-weight: 700;
    cursor: pointer;
}
.week-mode + .week-mode { border-left: 1px solid var(--border); }
.week-mode:hover { background: #f1f5f9; }
.week-mode--on { background: var(--accent); color: #ffffff; }
.week-mode--on:hover { background: var(--accent); }
/* The strip's whole chrome, on one line: where you are on the left,
   which span and how to move through it on the right. It replaced a
   heading, a nav row and a date row stacked three deep over the only
   thing on the card that wanted the height. */
.week-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem 1rem;
    flex-wrap: wrap;
    margin: 0 0 0.5rem;
}
.week-where {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}
.week-date {
    padding: 0.15rem 0.35rem;
    border: 1px solid var(--border);
    border-radius: 5px;
    background: #ffffff;
    color: #334155;
    font: inherit;
    font-size: 0.75rem;
}

@media (max-width: 900px) {
    .week-names { max-width: 8rem; }
}

/* --- Phones -----------------------------------------------------------
 *
 * This site is read standing at a machine at least as often as it is
 * read at a desk — check-in is a QR label on the front of a printer, a
 * fault is reported while looking at the fault, and the completion
 * report is filed with the plate still in your other hand. So a phone
 * is a first-class width here, not a degradation of the laptop.
 *
 * Three rules, and everything below is one of them:
 *
 *   1. **Nothing scrolls sideways.** A page that does is a page where
 *      half of every card is off the screen and the reader has no way
 *      of knowing it. Where something genuinely cannot be narrower —
 *      the fleet table is eight columns — it scrolls *inside its own
 *      box*, and the page does not.
 *   2. **Every field is at least 16px.** Safari zooms the whole page in
 *      when a smaller one is focused and never zooms back out, so a
 *      single 13px search box is what breaks the layout of every page
 *      after it. This is a mobile browser rule and not a taste.
 *   3. **Every target a finger presses is at least 40px tall.** The
 *      buttons on this site were sized for a pointer that is one pixel
 *      across.
 *
 * The breakpoint is width and not `pointer: coarse`: a phone in a hand
 * is the case being sized for, and width is what the test harness can
 * actually put a browser at. */
@media (max-width: 560px) {
    /* 2rem of body padding plus 2rem of card padding is a quarter of a
       390px screen spent on air before a word is drawn. */
    body { padding: 0.75rem; }
    .card { padding: 1rem; }
    #week-panel { padding: 0.75rem; }

    /* Four tabs at ~9rem cannot share one row; two rows of two is the
       shape, and each is then wide enough to read. */
    .tab-row > button { flex: 1 1 calc(50% - 0.25rem); padding: 0.7rem 0.5rem; }

    /* Rule 2. Listed by class as well as by element because every one
       of these sets its own size further up the file, and an element
       selector loses to a class however late it comes. */
    input, select, textarea,
    .filter-input,
    .compact-input, .compact-textarea,
    .editor-input,
    .booking-minutes, .booking-email,
    .record-time,
    .ams-select,
    .temp-input,
    .admin-add-input,
    .week-date { font-size: 16px; }

    /* Rule 3. `min-height` rather than padding, so nothing that is
       already big enough grows and no label re-wraps.
       Listed by class rather than as a bare `button`, on purpose: a
       plain <button> on this site already stands 43px tall from the
       padding at the top of this file, and the bare selector would also
       have caught `.link-button` — which is a word in the middle of a
       sentence — and every glyph that rides beside the thing it acts
       on. What is listed here is exactly the set that overrides that
       padding to something smaller. */
    .control-btn,
    .pager-button,
    .outcome-btn,
    .record-decide-btn,
    .editor-delete,
    .lang-btn,
    .week-mode,
    .week-nav-button,
    .toggle-btn,
    /* Two full-width rows rather than small targets, but both are a
       press — opening a control group and opening a ledger card — and
       at 32 and 36px they were the shortest things on the page a thumb
       has to land on. */
    .control-group-header,
    .record-head { min-height: 40px; }
    /* The × on a dialog. It is a corner glyph on a laptop and the way
       out of a full-screen form on a phone, so it is square and it is
       the same size as everything else a thumb lands on. */
    .reserve-close { width: 2.5rem; height: 2.5rem; }

    /* The corner × on a ledger card, by the same rule: it is a finger
       target here, not a mouse target. */
    .card-delete { width: 2.5rem; height: 2.5rem; font-size: 1.3rem; }
    .booking-card--deletable .admin-add { padding-right: 2.7rem; }

    /* The buttons on a booking card are its whole vocabulary — cancel,
       extend, share, check in, report — and at 0.75rem beside a 40px
       target they read as a footnote. */
    .booking-actions .control-btn { font-size: 0.8rem; padding: 0.3rem 0.7rem; }
    /* Void and Save, by the same rule. `.control-btn` is in the 40px
       list above, so only the type size is stated here. */
    .record-action-btn { font-size: 0.8rem; padding: 0.3rem 0.7rem; }

    /* The strip. The machine column takes what it needs out of a grid
       that has about 250px to give, so it is capped harder here, and
       the clock along the top drops its always-`:00` minutes (see
       hourHeadCells in js/week.js) rather than being cut mid-digit. */
    .week-names { max-width: 6.5rem; }
    .week-name { font-size: 0.72rem; padding: 0 0.35rem 0 0.25rem; }
    .week-hour-minutes { display: none; }
    .week-day-head { font-size: 0.68rem; }
    /* The two legends under it are labelled apart on a line each; at
       this width the 5.5rem label ate a third of the row and the
       swatches broke across it, so "finished / stopped" and "error"
       ended up on different lines of the same vocabulary. The label
       takes its own line instead and the swatches get the whole
       width. */
    .week-legend-title { flex-basis: 100%; }
    /* The toolbar is a date on the left and four controls on the right.
       At this width it is two rows, and the nav row spreads rather than
       bunching against the edge it happened to wrap to. */
    .week-toolbar { gap: 0.5rem; }
    .week-where, .week-nav { width: 100%; }
    /* Wrapping, not shrinking: "Today"/"This week" and "Day"/"Week" are
       still sized for their widest label here, because the reason for
       that floor is a phone's reason too — pressing one control must
       not move the next one out from under the thumb. What gives at
       320px is the row, not the button. */
    .week-nav { flex-wrap: wrap; justify-content: space-between; }
    .week-modes { margin-right: 0; }

    /* A heading and a button that belong to it: side by side while
       there is room, and stacked with the button full width when there
       is not — a 40px target squeezed into the corner of a wrapped row
       is worse than one that owns its line. */
    .panel-header-row { gap: 0.5rem; }
    .panel-header-row .booking-actions { width: 100%; }
    .panel-header-row .booking-actions .control-btn { flex: 1 1 auto; }

    /* The dialogs already go full-screen at 860px; their footers are
       what is left. Confirm and Cancel each own half the row rather
       than huddling at the right-hand end. */
    .reserve-actions, .confirm-actions { padding-left: 1rem; padding-right: 1rem; }
    .reserve-actions .control-btn, .confirm-actions .control-btn { flex: 1 1 auto; }
    .reserve-head, .reserve-body, .confirm-body { padding-left: 1rem; padding-right: 1rem; }

    /* One column of facts, not two of 15rem in 250px of card. */
    .record-body { grid-template-columns: 1fr; }
    /* Which puts BOOKED BY, not ACTIONS, under the corner ×, and the ×
       is a 2.5rem finger target here rather than a 1.6rem glyph — tall
       enough to reach the first line of the column under its heading.
       So the first section gives up that much width, the way the
       resolve row on a fault report does. */
    .record-body > .record-section:first-child { padding-right: 2.7rem; }

    /* An ellipsis is a promise that the rest is somewhere else. On a
       laptop it is: the line is cut to keep a narrow column narrow and
       the whole sentence is a hover or a card away. On a phone the
       narrow column *is* the screen, there is no hover, and a fault
       report's answer — "cleared the nozzle, back in service" — was
       being cut after four words with nowhere else to read it. So the
       two that carry sentences wrap here instead.
       Deliberately not `.record-purpose` or `.week-name-label`: the
       first is a summary line with the whole purpose in the card under
       it, and the second has to stay one grid row tall or the machine
       column and the calendar beside it stop lining up. */
    .booking-card-facts, .file-name {
        white-space: normal;
        overflow: visible;
        text-overflow: clip;
    }

    /* A label column of 5.5rem in front of a value is a third of the
       screen spent on the word "Printer". */
    .record-fact { flex-wrap: wrap; }
    .record-fact-label { min-width: 0; flex-basis: 100%; }

    /* Nine machines and seven weekdays, one column each: at this width
       `minmax(11rem, 1fr)` was already one column, and saying so stops
       the grid rounding to a second half-column. */
    .bulk-grid { grid-template-columns: 1fr; }

    /* The header is a heading, who you are, and the language switch.
       Stacked, with the identity and the switch on one line under the
       heading rather than three things fighting for 390px. */
    .page-header { gap: 0.5rem; }
    .page-header h2 { margin: 0; font-size: 1.25rem; }
    .page-header-right { width: 100%; justify-content: space-between; }

    /* The one table on the site. It is eight columns wide and is not
       going to be anything else, so it keeps its own scroller — and
       the scroller is now allowed to be narrower than the table (see
       `.editor-layout > *`), which is what makes it scroll instead of
       dragging the page sideways with it. */
    .editor-table { font-size: 0.8rem; }
}

