/* ============================================================
   PM-Tool design system — restrained, structured, high-contrast.
   Built for engineers: hierarchy and grouping over decoration.
   ============================================================ */

:root {
    /* Tells the browser which native-chrome variant to render unstyled parts
       of form controls in (select's option-list popup, checkbox tick,
       date/number picker chrome, scrollbars) — without this, browsers guess
       from the OS-level color-scheme preference, which can disagree with
       our own data-theme toggle and render native popups in the wrong
       theme regardless of what CSS we write for the controls themselves. */
    color-scheme: light;
    --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    --fs-xs: 10.5px; --fs-sm: 11.5px; --fs-base: 13px; --fs-lg: 14px; --fs-xl: 17px; --fs-2xl: 20px;

    --s1: 3px; --s2: 6px; --s3: 10px; --s4: 12px; --s5: 16px; --s6: 22px;

    /* Overview's resizable split (left pane's flex-basis) — set on
       :root/documentElement rather than the pane itself so a saved value can
       be restored before the pane even exists in the DOM (see the
       FOUC-prevention script in dashboard.html); this default is the
       no-JS/first-run fallback, matching the old grid's 60/40 ratio. */
    --overview-split-left: 60%;

    /* Neutrals — warm, low-chroma scale keyed to the charcoal/black brand darks */
    --bg: oklch(97.5% 0.003 60);
    --surface: #ffffff;
    --surface-sunken: oklch(96.2% 0.004 50);
    --border: oklch(90.5% 0.004 50);
    --border-strong: oklch(80% 0.005 50);
    --text: #1a1516;
    --text-muted: oklch(45% 0.006 30);
    --text-faint: oklch(62% 0.005 30);

    /* Brand & semantic — blue + green from brand palette, shades via oklch */
    --primary: oklch(52% 0.095 216.3); --primary-hover: oklch(44% 0.10 216.3); --primary-tint: oklch(95% 0.025 216.3);
    --danger: #c0392b;  --danger-tint: #fbeae8;
    --success: oklch(38% 0.13 123.4); --success-tint: oklch(93% 0.06 123.4);
    --warning: #b26f0c; --warning-tint: #fbf1e0;

    /* Status-only hues — task/project state badges, independent of brand */
    --info: oklch(45% 0.12 250); --info-tint: oklch(93% 0.04 250);
    --deferred: oklch(45% 0.09 300); --deferred-tint: oklch(94% 0.03 300);
    --suspended: oklch(40% 0.08 200); --suspended-tint: oklch(93% 0.03 200);
    /* Matches the Gantt chart's existing gray for a "blocked" bar (dashboard.js
       ganttColor's block/hold/waiting/stuck fallback, #7f8c8d) so the table
       badge and the Gantt bar read as the same status. */
    --blocked: #7f8c8d; --blocked-tint: #ecf0f1;

    /* Sidebar — brand charcoal/black with lime accent. Deliberately identical
       in both themes: the sidebar is already rendered in the brand darks, so
       switching to dark mode doesn't need to touch it at all. */
    --sidebar-bg: #1a1516; --sidebar-hover: #282625; --sidebar-label: oklch(55% 0.006 30);
    --sidebar-text: oklch(78% 0.006 30); --sidebar-accent: #aad045; --sidebar-accent-blue: #88d4e8;

    /* Text color to use on top of a --primary-colored fill (buttons, filled
       chips). White in light mode, since --primary is a mid-dark blue there;
       dark mode's --primary becomes a light brand blue, so this flips too. */
    --on-primary: #ffffff;

    /* Gantt/wsboard status colors, independent of the badge system above —
       these paint filled bars/dots/legend swatches rather than text-on-tint,
       so most read fine unchanged on a dark background; the two that don't
       (project/suspended, both very dark navy) get brightened in dark mode. */
    --gantt-project: #2c3e50; --gantt-project-tint: #e8edf2;
    --gantt-suspended: #34495e; --gantt-blocked: #7f8c8d; --gantt-active: #2980b9;
    --gantt-overdue: #e74c3c; --gantt-stalled: #f39c12;
    --ws-offline: #95a5a6;
    --milestone: #8e44ad;
    --teal: #0e7c86; --teal-tint: oklch(93% 0.05 190); --teal-fill: #16a085;
    --conflict: #e67e22;
    /* Fill used for solid buttons/borders paired with white text (Start
       Session, ghost-slot "Book this") — deliberately unchanged across
       themes, since it's already self-contained (its own bg/text pairing,
       not text-on-page), unlike --success below which text sits directly
       on the page/card background and does need to brighten for dark mode. */
    --success-fill: #27ae60; --danger-fill: #e74c3c;
    --ghost: #27ae60; --ghost-text: #145a32;
    --overview-warn-border: #f2d9a0; --overview-alert-border: #eec2bc;
    --grid-line: rgba(0,0,0,0.07); --grid-line-strong: rgba(0,0,0,0.22);
    --modal-backdrop: rgba(20,28,38,.45);

    /* legacy aliases still referenced in places */
    --primary-light: var(--primary-hover);
    --neutral: var(--text-faint);
    --radius: 6px; --radius-sm: 4px; --radius-pill: 20px;
    --shadow-sm: 0 1px 2px rgba(16,24,32,.06), 0 1px 3px rgba(16,24,32,.08);
    --shadow-modal: 0 12px 44px rgba(16,24,32,.28);

    /* Mobile-layout effort (see docs/MOBILE_UI_BUILD_PLAN.md): minimum
       tappable size for interactive elements (nav links, buttons, picker
       options) once the 900px/600px mobile breakpoints are in play. Not
       used at desktop widths, so it's additive here and only consumed
       inside @media (max-width: 900px) blocks and narrower. */
    --touch-target-min: 44px;
    /* Height of the fixed mobile top app bar (hamburger + tab title);
       .main/.sidebar/.mobile-nav-backdrop all offset by it at <=900px. */
    --mobile-topbar-h: 52px;
    /* Secondary/inline touch size: for buttons that sit inside a line of
       text or a tight widget (booking-row actions, the wsboard ghost
       button), where the full 44px floor visibly breaks the layout it
       lives in. Still comfortably tappable; primary actions keep
       --touch-target-min. */
    --touch-target-compact: 36px;
}

/* ============================================================
   Dark theme — brand darks (#1A1516 / #282625) as the base surfaces,
   brand blue/green (#88D4E8 / #AAD045) as the brightened accents. Only
   variables that actually need a different value in the dark are listed;
   anything not overridden here (spacing, fonts, sidebar) is shared as-is.
   Toggled by data-theme="dark" on <html>, set by dashboard.js/toggleTheme()
   and persisted in localStorage (see initTheme()). ============================================================ */
:root[data-theme="dark"] {
    color-scheme: dark;
    --bg: #1a1516;
    --surface: #242021;
    --surface-sunken: #141011;
    --border: #3a3435;
    --border-strong: #4d4647;
    --text: #f0ece9;
    --text-muted: #b6afaa;
    --text-faint: #8f8783;

    --primary: #88d4e8; --primary-hover: #a7e0f0; --primary-tint: #16313a;
    --on-primary: #12232b;
    --danger: #e5786d; --danger-tint: #3a1613;
    --success: #aad045; --success-tint: #26300c;
    --warning: #e8a53d; --warning-tint: #3d2b0c;

    --info: #7fa8e6; --info-tint: #1a2540;
    --deferred: #ba91d8; --deferred-tint: #2c1f3a;
    --suspended: #6fb8c9; --suspended-tint: #163038;
    --blocked: #9aa3a5; --blocked-tint: #2c2f30;

    --gantt-project: #6e8ca8; --gantt-project-tint: #202b35;
    --gantt-suspended: #6a8aa8; --gantt-blocked: #9aa3a5; --gantt-active: #5fa8dd;
    --gantt-overdue: #ef6455; --gantt-stalled: #f5ad42;
    --ws-offline: #9aa3a5;
    --milestone: #c087dd;
    --teal: #45b6c2; --teal-tint: #17323a; --teal-fill: #16a085;
    --conflict: #eb9950;
    --success-fill: #27ae60; --danger-fill: #e74c3c;
    --ghost: #27ae60; --ghost-text: #d8f7e2;
    --overview-warn-border: #6b5324; --overview-alert-border: #6b342c;
    --grid-line: rgba(255,255,255,0.08); --grid-line-strong: rgba(255,255,255,0.16);
    --modal-backdrop: rgba(0,0,0,.65);

    --shadow-sm: 0 1px 2px rgba(0,0,0,.35), 0 1px 3px rgba(0,0,0,.3);
    --shadow-modal: 0 12px 44px rgba(0,0,0,.55);
}

* { box-sizing: border-box; }

body {
    margin: 0;
    font-family: var(--font);
    font-size: var(--fs-base);
    line-height: 1.35;
    color: var(--text);
    background: var(--bg);
}

/* ---------- Sidebar ---------- */
.sidebar {
    width: 196px; background: var(--sidebar-bg); color: var(--sidebar-text);
    height: 100vh; position: fixed; top: 0; left: 0; padding: 0;
    display: flex; flex-direction: column;
}
.sidebar h2 {
    margin: 0; padding: var(--s3) var(--s5); font-size: var(--fs-lg);
    font-weight: 700; color: #fff; letter-spacing: .02em; text-align: left;
}
.sidebar-nav { flex: 1 1 auto; overflow-y: auto; padding: var(--s2) 0; }
.nav-group { margin-bottom: 0; }
/* A hairline seam between domains — whitespace + a label alone reads as
   "some gap", a rule reads as "new chapter". Home has none above it (nothing
   to separate it from). */
.nav-group:not(:first-child) { border-top: 1px solid #322d2c; margin-top: var(--s2); padding-top: var(--s1); }
.nav-group-label {
    display: flex; align-items: center; gap: 7px;
    padding: 6px var(--s4) 1px; font-size: var(--fs-xs); font-weight: 700;
    text-transform: uppercase; letter-spacing: .08em; color: var(--sidebar-label);
    border-left: 2px solid transparent;
}
.nav-group-label .group-icon { flex: none; width: 12px; height: 12px; opacity: .8; }
/* The group containing the currently-open page gets a blue edge on its own
   label — a "which chapter am I in" signal independent of exactly which
   item within it is highlighted (that's the green pill below). Sidebar
   colors are deliberately fixed brand literals in both app themes (see
   --sidebar-accent), so this uses the matching fixed blue, not var(--primary)
   (which does change with the app's light/dark theme). */
.nav-group.has-active .nav-group-label { border-left-color: var(--sidebar-accent-blue); color: var(--sidebar-text); }
.sidebar ul { list-style: none; padding: 0; margin: 0; }
.sidebar ul li {
    display: flex; align-items: center; gap: 9px;
    margin: 1px var(--s3); padding: 5px 9px; border-radius: var(--radius-sm);
    cursor: pointer; font-size: var(--fs-base); transition: background .12s;
}
.sidebar ul li .nav-icon { flex: none; width: 15px; height: 15px; opacity: .7; transition: opacity .12s; }
.sidebar ul li:hover { background: var(--sidebar-hover); color: #fff; }
.sidebar ul li:hover .nav-icon { opacity: 1; }
/* Active = a solid brand-green pill with dark text/icon, not a border tick —
   the strongest possible "you are here" signal, deliberately louder than
   hover. Kept to this one spot; everything else in the nav stays quiet. */
.sidebar ul li.active, .sidebar ul li.active:hover {
    background: var(--sidebar-accent); color: var(--sidebar-bg); font-weight: 700;
}
.sidebar ul li.active .nav-icon { opacity: 1; }
.sidebar-user { flex-shrink: 0; padding: var(--s2) var(--s5); border-top: 1px solid #33424f; font-size: var(--fs-sm); }
.sidebar-user span { display: block; margin-bottom: var(--s1); color: #eef2f5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-logout {
    width: 100%; padding: 6px 0; background: transparent; color: var(--sidebar-text);
    border: 1px solid #3c4b59; border-radius: var(--radius-sm); cursor: pointer; font: inherit; font-size: var(--fs-sm);
}
.btn-logout:hover { background: var(--sidebar-hover); color: #fff; }

/* ---------- Main / sections ---------- */
.main { margin-left: 196px; padding: var(--s5) var(--s6); max-width: 1500px; }
.form-section { margin-bottom: var(--s6); }
.form-section h3 {
    margin: 0 0 var(--s4); font-size: var(--fs-2xl); font-weight: 600;
    padding-bottom: var(--s3); border-bottom: 1px solid var(--border);
}
/* Page header: title + one primary action. Sits above the section content. */
.page-header {
    display: flex; align-items: flex-end; justify-content: space-between; gap: var(--s4);
    margin-bottom: var(--s4); padding-bottom: var(--s3); border-bottom: 1px solid var(--border);
}
.page-header h3 { margin: 0; padding: 0; border: none; }
.page-header .subtitle { margin: 2px 0 0; color: var(--text-muted); font-size: var(--fs-base); }
.page-header-actions { display: flex; gap: var(--s2); flex-wrap: wrap; }
.form-section h4 { margin: var(--s4) 0 var(--s2); font-size: var(--fs-lg); font-weight: 600; }

/* ---------- Create forms (inline grid, until modalized) ----------
   The grid/padding/border/shadow "boxed form" look is scoped to
   .inline-form specifically — NOT every <form> under .form-section —
   because plenty of other forms live inside a .form-section too
   (per-row edit/delete forms, attachment upload) and must not inherit
   a card-like box around a single button. Base input/label/button
   styling stays broad since modal create-forms rely on it too. */
.form-section form.inline-form {
    display: grid; grid-template-columns: repeat(3, max-content minmax(130px, 1fr));
    gap: var(--s3) var(--s4); align-items: center; max-width: 1300px;
    margin: 0 0 var(--s4); padding: var(--s4); background: var(--surface);
    border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm);
}
.form-section label { text-align: right; white-space: nowrap; font-size: var(--fs-sm); color: var(--text-muted); font-weight: 600; }
/* :not(td *) keeps this create-form styling out of table cells: the
   width: 100% / min-width: 0 pair would let the greedy spacer column squeeze
   in-table controls down to nothing (and it outranks the type-only td rules
   under Tables, which style in-table controls instead). Checkboxes/radios
   are excluded too — width: 100% on a checkbox outside a table (e.g.
   .complete-checkbox in the Overview card's <li> list) forced it to an
   oversized flex basis that had to be shrunk back down per-row, throwing
   off how much space was left for its siblings and shifting the whole row's
   content sideways by a different amount depending on the text length. */
.form-section :is(input, select, textarea):not(td *):not([type="checkbox"], [type="radio"]) {
    padding: 5px 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
    background: var(--surface); color: var(--text); width: 100%; min-width: 0; font: inherit; font-size: var(--fs-base);
}
.form-section input:focus, .form-section select:focus, .form-section textarea:focus {
    outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-tint);
}
.form-section form.inline-form button[type="submit"] { grid-column: 1 / -1; justify-self: start; }
.form-section button:not(.btn):not(.btn-reveal) {
    margin-top: var(--s1); padding: 7px 16px; background: var(--primary); color: var(--on-primary);
    border: 1px solid var(--primary); border-radius: var(--radius-sm); cursor: pointer; font: inherit; font-weight: 500;
}
.form-section button:not(.btn):not(.btn-reveal):hover { background: var(--primary-hover); border-color: var(--primary-hover); }

/* ---------- Button system ---------- */
.btn {
    display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px;
    border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
    color: var(--text); background: var(--surface); cursor: pointer; font: inherit; font-size: var(--fs-sm); font-weight: 500;
    white-space: nowrap; line-height: 1.3;
}
.btn:hover { background: var(--surface-sunken); border-color: var(--text-faint); }
.btn--success { background: var(--success-fill); border-color: var(--success-fill); color: #fff; }
.btn--success:hover { filter: brightness(.95); }
.btn--danger { background: var(--surface); border-color: var(--border-strong); color: var(--danger); }
.btn--danger:hover { background: var(--danger-tint); border-color: var(--danger); }
.btn--neutral { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
.btn--neutral:hover { background: var(--surface-sunken); color: var(--text); }

/* ---------- Tables ---------- */
.tableContainer {
    max-height: 70vh; overflow: auto; border: 1px solid var(--border);
    border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); margin-top: var(--s4);
}
.tableContainer h4 { margin: 0; padding: var(--s3) var(--s4); border-bottom: 1px solid var(--border); font-size: var(--fs-lg); }
/* Hue-coded tables: separates stacked tables within one tab by hue at a
   glance. Where a table's rows carry the same meaning somewhere in the
   Gantt chart, its tint reuses that exact color (project navy, phase/task
   blue, milestone purple, meeting teal) rather than inventing a new one;
   amber/green/grey cover the pending-vs-done pattern (Reminders, Todos)
   that has no Gantt equivalent. Named by color, not by table, since the
   same seven get reused across unrelated tabs — cross-tab reuse is fine
   (those views are never on screen together), only *within* one tab do
   siblings need to stay visually distinct. */
.tint-navy   { --tint-color: var(--gantt-project); }
.tint-blue   { --tint-color: var(--gantt-active); }
.tint-purple { --tint-color: var(--milestone); }
.tint-teal   { --tint-color: var(--teal); }
.tint-amber  { --tint-color: var(--warning); }
.tint-green  { --tint-color: var(--success); }
.tint-grey   { --tint-color: var(--blocked); }
/* hued-border: the lightest tier, for tabs with only one table — nothing to
   differentiate it from, so no title/header tint (that would just be
   decoration wearing the same clothes as the tables that actually need
   telling apart). A shared grey border across all of them is the point:
   the color itself says "plain reference list", the same way a distinct
   hue elsewhere says "pay attention, this one's different from its
   neighbor". */
.tableContainer.hued-table, .tableContainer.hued-title, .tableContainer.hued-border { border-left: 3px solid var(--tint-color); }
.tableContainer.hued-table h4, .tableContainer.hued-title h4 { background: color-mix(in srgb, var(--tint-color) 22%, var(--surface)); }
.tableContainer.hued-table thead th, .tableContainer.hued-title thead th { background: color-mix(in srgb, var(--tint-color) 10%, var(--surface-sunken)); }
/* Full treatment — only for tables whose rows carry no color meaning of
   their own (most of them). Also covers the meeting-card list layout,
   which has no <table>/<tbody> to tint. */
.tableContainer.hued-table tbody,
.tableContainer.hued-table .meeting-list { background: color-mix(in srgb, var(--tint-color) 4%, var(--surface)); }
.tableContainer.hued-table tbody tr:hover,
.tableContainer.hued-table .meeting-card:hover { background: color-mix(in srgb, var(--tint-color) 9%, var(--surface-sunken)); }
/* hued-title deliberately stops at the header: Existing Tasks and Todos'
   Open list already color each row by state (overdue/stalled/deferred/
   suspended tints) — tinting the body too would fight that meaning instead
   of just labeling which table it is, so only the title bar and column
   headers get the tab-differentiating hue; row coloring is untouched. */
/* Auto layout: each column hugs its widest actual content (the nowrap header
   is the floor), so data packs left as densely as the data allows. The empty
   spacer column's width: 100% makes it greedily absorb all leftover card
   width, pinning Actions to the right edge — which also squeezes every other
   column down to its minimum, so controls without an intrinsic width carry
   explicit min-width floors below. When the columns outgrow the card the
   spacer collapses and .tableContainer's overflow: auto scrolls the table. */
table { table-layout: auto; width: 100%; border-collapse: collapse; }
th.spacer, td.spacer { width: 100%; }
.tableContainer table { font-size: var(--fs-base); }
thead th {
    position: sticky; top: 0; z-index: 1; background: var(--surface-sunken);
    text-align: left; font-weight: 600; font-size: var(--fs-sm); color: var(--text-muted);
    padding: var(--s2) var(--s3); border-bottom: 1px solid var(--border-strong); white-space: nowrap;
}
.form-section thead tr { background: var(--surface-sunken); }
/* Actions is always the last column; the spacer column before it provides the
   gap, so its cells only need to keep their buttons on one line. */
thead th:last-child, td:last-child { white-space: nowrap; }
td { padding: var(--s2) var(--s3); vertical-align: middle; word-break: break-word; border-bottom: 1px solid var(--border); }
/* A row-link is a single value (name, status, date), never free text — it
   must never wrap. td's word-break: break-word (kept for genuinely long
   free text elsewhere) computes a column's minimum width as "can break
   anywhere," which collapses to nearly nothing once nothing else pins a
   floor; removing the disabled <input> that used to pin one is exactly
   what exposed columns collapsing to a sliver and wrapping mid-word. */
.row-link { white-space: nowrap; }
/* Todo content is free text, not a single value like the rest of row-link's
   users — it should wrap onto extra lines the way the Overview "My To-Dos"
   card already does, instead of forcing the whole table wider and needing a
   horizontal scroll for one long sentence. width/max-width have no effect on
   a plain inline element, so the earlier max-width-only attempt was a no-op:
   the column still had nothing forcing a real preferred width, so it
   collapsed toward td's word-break: break-word minimum (breakable anywhere)
   same as before — display: inline-block plus an actual width (not just a
   cap) is what gives table-layout a genuine size to wrap within. */
.todo-text { display: inline-block; white-space: normal; width: 420px; }
/* Wraps the Mark-done checkbox + the text it strikes through so the
   checkbox reads as leading the to-do the way a normal checklist does,
   instead of living apart from it in the actions row. align-items:
   flex-start (not center) keeps the checkbox pinned to the text's first
   line once .todo-text wraps to two or more. */
.todo-title-row { display: flex; align-items: flex-start; gap: var(--s2); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--surface-sunken); }
/* Tasks groups each task's main row + description row in their own <tbody>
   (see .edit-row in dashboard.js) so one Edit button can toggle both — which
   means tbody tr:last-child above now matches every task's description row,
   not just the table's true last row, quietly removing the separator
   between every task instead of just the final one. Restore it there and
   remove it from the main row instead, so the pair reads as one joined
   block with the real separator between tasks. Hovering either row
   highlights both, reinforcing that they're one unit. */
#tasks-table tbody tr:not(.task-description-row) td { border-bottom: none; }
#tasks-table tbody tr.task-description-row td { border-bottom: 1px solid var(--border); padding-top: 0; }
#tasks-table tbody:hover tr { background: var(--surface-sunken); }
/* Muted, wrapping preview of a task's description — overrides row-link's
   nowrap (needed for single-value columns, wrong for free text) and clamps
   to 3 lines so one unusually long description doesn't blow up every row's
   height; always-visible read-only display now (full text + editing live in
   the row's drawer form, not inline). flex-basis: 100% additionally forces it
   onto its own line wherever it sits inside a flex row (Overview's task-list
   li) — a no-op where it doesn't (the Tasks table's plain block td), so one
   rule serves both. */
.task-description {
    white-space: normal; color: var(--text-muted); font-size: var(--fs-sm); flex-basis: 100%;
    display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
td input[type="text"], td input[type="email"], td input[type="number"], td input[type="date"],
td input[type="datetime-local"], td input[type="password"], td select, td textarea {
    box-sizing: border-box; padding: 5px 7px; background: var(--surface); color: var(--text);
    border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: inherit; font-size: var(--fs-sm);
}
/* Selects hug their widest option ("worst possible entry") via their own
   intrinsic width — no JS needed, just don't let width:100% reach them.
   Date/number inputs hold their intrinsic/explicit width. Text and email
   inputs have no intrinsic content width in non-Chromium browsers, so JS
   (fitInput/fitAllInputs in dashboard.js) drives their `size` attribute;
   min-width: 6ch is only the floor for an empty field. Chromium (Brave
   included) ignores all of this via field-sizing: content below, which
   sizes to the actual value/placeholder pixel-exact and wins regardless
   of `size`. Textarea keeps its old fixed floor — it's a multi-line free
   text field (Description), where a generous fixed width is the correct
   read, not the same "hug a short value" problem as a single-line field. */
td select { width: auto; max-width: 280px; }
td input[type="text"], td input[type="email"] { width: auto; min-width: 6ch; }
td textarea { width: 100%; min-width: 200px; }
td input[type="number"] { width: 100%; min-width: 40px; }
/* field-sizing: content overrides the specified width (fits to actual
   content) but NOT min-width/max-width, which still clamp the result — so
   these floors must stay small (just enough to keep an empty field visible/
   clickable), or they silently override the exact fit for any short real
   value (a 1-2 digit Order index, a short Phase name like "Design"). */
@supports (field-sizing: content) {
    td input[type="text"], td input[type="email"], td textarea {
        field-sizing: content; width: auto; min-width: 4ch; max-width: 320px;
    }
    td input[type="number"] { field-sizing: content; width: auto; min-width: 40px; }
}
/* Disabled row-edit inputs read as plain text until Edit is pressed: no
   border/padding (which was offsetting text vs. the th by ~9px), and an
   explicit color/opacity/-webkit-text-fill-color reset since browsers grey
   out disabled control text by default (Safari/Chrome via -webkit-text-fill-
   color specifically, ignoring `color`; Firefox via opacity) rather than
   inheriting the surrounding cell's text color. Excludes checkboxes/radios:
   they have no "text" to un-dim, and appearance: none with no border/
   background left nothing at all to paint — a checked disabled checkbox
   (e.g. Blocking) rendered as a fully invisible box instead of a muted
   checkmark. Their native disabled rendering is already the right read-only
   look, so they keep their default appearance. */
td input:disabled:not([type="checkbox"], [type="radio"]), td select:disabled, td textarea:disabled {
    color: inherit; -webkit-text-fill-color: currentColor; opacity: 1;
    border: 0; padding: 0; background: transparent; font: inherit; appearance: none;
}
/* Chromium keeps reserving width for the number spin buttons even with
   appearance: none on the input itself — only removing the pseudo-elements
   directly drops that reserved space, which is what was leaving a wider gap
   after the Order column than its 1-2 digit values needed. */
td input:disabled[type="number"]::-webkit-outer-spin-button,
td input:disabled[type="number"]::-webkit-inner-spin-button {
    -webkit-appearance: none; margin: 0;
}

/* ---------- Meeting attachments (compact chip row) ---------- */
.attachment-list { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
.attachment-chip {
    display: inline-flex; align-items: center; gap: 4px; padding: 2px 4px 2px 8px;
    background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius-pill);
    font-size: var(--fs-sm); white-space: nowrap;
}
.attachment-chip form { display: inline-flex; }
.attachment-remove {
    border: none; background: none; cursor: pointer; color: var(--text-faint);
    font-size: var(--fs-base); line-height: 1; padding: 2px 4px; border-radius: 50%;
}
.attachment-remove:hover { background: var(--danger-tint); color: var(--danger); }
.attachment-upload { display: inline-flex; align-items: center; gap: var(--s2); }
.attachment-upload input[type="file"] { font-size: var(--fs-sm); max-width: 180px; }

/* Show/hide toggle for a masked secret (Clockify key row) or a currently-
   typed password field (Change password modal) — never for an existing
   password itself, which isn't stored anywhere reversible to reveal.
   Explicit fixed size + flex-shrink: 0 so a long revealed value next to it
   can never squeeze or displace the button itself. */
.btn-reveal {
    display: inline-flex; align-items: center; justify-content: center;
    flex: none; width: 24px; height: 24px; border: none; background: none;
    cursor: pointer; color: var(--text-faint); padding: 0; border-radius: var(--radius-sm);
}
.btn-reveal svg { width: 15px; height: 15px; }
.btn-reveal:hover { background: var(--surface-sunken); color: var(--text); }
/* Groups a masked/revealed value with its toggle button so they always sit
   on one line. secret-mask stays nowrap like row-link (a single value, not
   free text) — word-break/normal-whitespace was tried here and backfired:
   with no minimum content width, table-layout: auto shrank the column to
   near nothing and wrapped the *masked dots* too. nowrap gives the column
   the width it actually needs and lets .tableContainer's existing
   overflow-x handle a long revealed key, same as every other long-value
   column in this app (e.g. Files' NAS path). */
.field-with-reveal { display: flex; align-items: center; gap: 4px; }
.field-with-reveal input { flex: 1; min-width: 0; }
.field-with-reveal .secret-mask { white-space: nowrap; }

/* ---------- Meetings (card list) ----------
   Meetings carry variable-height content (attendee list, notes,
   attachments) that doesn't fit a fixed-column table without either
   truncating text or leaving ragged empty cells. A row-per-meeting card
   list gives each field the width it needs and wraps naturally. */
.meeting-list { display: flex; flex-direction: column; }
.meeting-card {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2) var(--s5);
    padding: var(--s2) var(--s4); border-bottom: 1px solid var(--border);
}
/* "No upcoming/past meetings" — had no rule at all, so it fell back to
   default <div> text with no padding/color, sitting flush against the
   header divider instead of reading as a muted placeholder like every
   other section's empty state. */
.empty-state { padding: var(--s4); color: var(--text-muted); font-size: var(--fs-sm); }
.meeting-card:last-child { border-bottom: none; }
.meeting-card:hover { background: var(--surface-sunken); }

/* Left: everything that identifies the meeting. Center: attendees/attachments.
   Right: actions — the three columns sit in a single row and only wrap on
   narrow viewports. */
.meeting-card-info { flex: 1 1 260px; min-width: 200px; }
.meeting-card-title { font-weight: 600; }
.meeting-card-title input[type="text"] { font-weight: 600; font-size: var(--fs-base); }
.meeting-card-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 2px var(--s2); font-size: var(--fs-sm); color: var(--text-muted); }
.meeting-card-sub select, .meeting-card-sub input[type="text"] { font-size: var(--fs-sm); color: var(--text-muted); width: auto; min-width: 60px; }
.meeting-card-sub input[type="datetime-local"] { font-size: var(--fs-sm); width: auto; }
.meeting-card-sub input[type="number"] { font-size: var(--fs-sm); width: 40px; text-align: right; }
.meeting-field-label { color: var(--text-faint); }
/* Meeting cards are read-only display here — editing happens through the
   Inspect drawer, like every other table — but Title/Project/Location still
   use plain .row-link spans rather than raw text so they share the same
   look. .field-pair glues a label to its value so Project and Location each
   stay one flex-wrap unit instead of wrapping mid-pair. row-link's global
   nowrap (built for narrow table cells) is overridden here since Location
   can be an arbitrarily long URL that should wrap like Past Meetings' plain
   text instead of overflowing the card. */
.field-pair { display: inline-flex; align-items: center; gap: 4px; }
.meeting-card .row-link { white-space: normal; }

/* Date/duration: native datetime-local and number inputs keep their calendar-icon
   and spinner chrome even when disabled, which reads as disjointed next to plain
   text. Show flowing text by default and only swap in the real inputs while the
   card is in edit mode — same collapse pattern as the attendee picker below. */
.meeting-when .when-input { display: none; }
.meeting-when:has(input:not(:disabled)) .when-display { display: none; }
.meeting-when:has(input:not(:disabled)) .when-input { display: inline-block; }

.meeting-card-center { flex: 1 1 220px; min-width: 160px; font-size: var(--fs-sm); color: var(--text-muted); }
.meeting-card-actions { flex: 0 0 auto; display: flex; gap: var(--s2); margin-left: auto; }

/* Read-only attendee list on the card (edit form moved into the drawer) —
   same collapsed-pill look as the .picker:has(input:disabled) pattern below,
   without needing the picker/checkbox markup since there's no inline edit
   mode on the card anymore. */
.attendee-list { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.attendee-list .row-link {
    background: var(--surface-sunken); border-radius: var(--radius-pill); padding: 1px 8px; white-space: nowrap;
}

/* Disabled (read-only) card inputs render as plain text, same pattern as table row-edit. */
.meeting-card input:disabled, .meeting-card select:disabled {
    border-color: transparent; background: transparent; color: inherit; padding: 1px 0; appearance: none; -webkit-appearance: none;
}
.meeting-card input:not(:disabled), .meeting-card select:not(:disabled) {
    border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); padding: 3px 6px;
}

/* Any multi-select picker (attendees, externals, responsible people): collapse to a
   compact read-only name list until its row/card is in edit mode. Keeps rows with
   large option counts (e.g. 50-100 externals) from permanently rendering the full
   search box + scrollable checkbox list. max-width alone is a cap, not a demand —
   table-layout:auto can still grant less than that if a narrower "min-content"
   (with flex-wrap, just the widest single pill) looks acceptable, especially with
   the greedy spacer column competing for the same leftover space. An explicit
   width forces the ~3-pills-per-row space this actually needs — tables with two
   of these columns (e.g. Offers' Responsible + Externals) were pushing well past
   1920px and forcing horizontal scroll at the wider 5-per-row size. */
.picker:has(input:disabled) { width: 230px; max-width: 230px; }
.picker:has(input:disabled) .picker-search { display: none; }
/* Wide enough to fit ~3 typical-length pills per line before wrapping to a
   second row — every name still sizes to its own content (unlike a strict
   CSS grid, where a long name in one row would force every row's same
   column position that wide too), so a customer with many externals reads
   as a compact few rows instead of the one-per-line list this used to
   collapse to at the picker's original, much narrower edit-mode width. */
.picker:has(input:disabled) .picker-list {
    flex-direction: row; flex-wrap: wrap; gap: 4px 10px; max-height: none;
    border: none; padding: 0; background: none;
}
.picker:has(input:disabled) .checkbox-group label:has(input:not(:checked)) { display: none; }
.picker:has(input:disabled) .checkbox-group input[type="checkbox"] { display: none; }
.picker:has(input:disabled) .checkbox-group label {
    background: var(--surface-sunken); border-radius: var(--radius-pill); padding: 1px 8px; white-space: nowrap;
}
.picker:has(input:disabled) .checkbox-group:not(:has(input:checked))::after {
    content: "—"; color: var(--text-faint);
}

#offers-table tbody:hover tr { background: var(--surface-sunken); }

.filter-bar {
    padding: var(--s2) var(--s4); background: var(--surface-sunken); border-bottom: 1px solid var(--border);
    font-size: var(--fs-sm); color: var(--text-muted);
}
.filter-bar label { margin-right: var(--s2); }

.is-invalid { border: 1px solid var(--danger) !important; background: var(--danger-tint); }
.error-message { color: var(--danger); font-size: var(--fs-sm); margin-top: 4px; display: none; }

/* Flash / status messages */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius-sm); margin-bottom: var(--s3); font-size: var(--fs-sm); }
.flash--ok { background: var(--success-tint); color: var(--success); }
.flash--err { background: var(--danger-tint); color: var(--danger); }

/* ---------- Satisfying "cross it off" completion (Todos, Reminders) ---------- */
/* The line sweeps the full row (not just one column's text) for a bigger,
   more decisive "crossed out" moment. .strike-row marks any row that can
   take this treatment (an open todo, an active reminder); is-striking is
   added by JS on .complete-checkbox's click, and the line finishes drawing
   before the form's real submit fires, so the sweep is the thing the user
   sees happen instead of being cut off by the reload. */
.strike-row { position: relative; }
.strike-row::after {
    content: ''; position: absolute; left: 0; top: 50%; height: 3px; width: 0;
    background: var(--success); transition: width .45s ease-out; pointer-events: none;
}
.strike-row.is-striking::after { width: 100%; }
.complete-checkbox { width: 16px; height: 16px; cursor: pointer; }

/* ---------- Badges ---------- */
.state-badge {
    display: inline-block; padding: 1px 8px; border-radius: var(--radius-pill);
    font-size: var(--fs-xs); font-weight: 600; text-transform: capitalize; white-space: nowrap;
}
.state-active    { background: var(--info-tint); color: var(--info); }
.state-finished  { background: var(--success-tint); color: var(--success); }
.state-overdue   { background: var(--danger-tint); color: var(--danger); }
.state-stalled   { background: var(--warning-tint); color: var(--warning); }
.state-deferred  { background: var(--deferred-tint); color: var(--deferred); }
.state-suspended { background: var(--suspended-tint); color: var(--suspended); }
.state-blocked   { background: var(--blocked-tint); color: var(--blocked); }

/* ---------- Checkbox list + searchable picker (unchanged behavior) ---------- */
.checkbox-group { display: flex; flex-wrap: wrap; gap: 2px 14px; }
.checkbox-group label {
    text-align: left; white-space: nowrap; font-weight: normal; min-width: 0; color: var(--text);
    display: inline-flex; align-items: center; gap: 4px; border: none; padding: 0;
}
.checkbox-group input[type="checkbox"] { width: auto; min-width: 0; margin: 0; padding: 0; }
.picker { min-width: 180px; max-width: 320px; }
.picker-search {
    width: 100%; box-sizing: border-box; padding: 6px 8px; margin-bottom: 4px;
    border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
.picker-list {
    flex-direction: column; flex-wrap: nowrap; gap: 2px; max-height: 140px; overflow-x: hidden; overflow-y: auto;
    padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface);
    box-sizing: border-box;
}

/* Single-select "checkmark list" variant of the picker, used for the master
   project filter — a list you click through (one active at a time) instead
   of a multi-select checkbox group or a native <select>. */
.project-filter-picker, .customer-filter-picker { width: 100%; max-width: none; }
/* .select-option is the same single-select "checkmark list" pattern, used
   wherever a searchable list writes into a hidden form field instead of
   driving a global filter (e.g. Customer in the New Offer form).
   .customer-filter-option is the customer-filter equivalent of
   .project-filter-option. All three share styling but are kept as separate
   classes since each is wired to different JS (form field vs. one of two
   independent global filter states) — conflating their names would confuse
   a future reader. */
.project-filter-option, .customer-filter-option, .select-option {
    cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); width: 100%; box-sizing: border-box;
    overflow: hidden; text-overflow: ellipsis;
    /* overflow: hidden zeroes out a flex item's automatic minimum size, so
       without flex-shrink: 0 the browser squeezes every item to cram them
       all into the 140px-tall list instead of respecting their natural
       height and letting the container scroll (overflow-y: auto) — the more
       items, the harder each one got crushed. */
    flex-shrink: 0;
}
/* Mobile layout (Design Principle 9, docs/MOBILE_UI_BUILD_PLAN.md §1): wrapped
   in @media (hover: hover) and (pointer: fine) so this rule now only applies
   where a real pointing device is present. Desktop is unaffected — a mouse-
   equipped browser always matches both hover:hover and pointer:fine, so this
   guard is a no-op there; it only *excludes* touch browsers, which previously
   matched the bare :hover via a synthesized/"sticky" hover state after tap
   (no real hover capability) and could show a highlight that never clears
   until something else is tapped. The touch-usable equivalent (:active, for
   in-the-moment tap feedback) is added in the Phase 1 mobile block below —
   see .select-option:active etc. */
@media (hover: hover) and (pointer: fine) {
    .project-filter-option:hover, .customer-filter-option:hover, .select-option:hover { background: var(--surface-sunken); }
}
.project-filter-option .check, .customer-filter-option .check, .select-option .check { display: inline-block; width: 14px; color: var(--primary); visibility: hidden; }
.project-filter-option.selected, .customer-filter-option.selected, .select-option.selected { font-weight: 600; }
.project-filter-option.selected .check, .customer-filter-option.selected .check, .select-option.selected .check { visibility: visible; }

/* ---------- Modal (used by "+ New" create forms) ---------- */
.modal-backdrop {
    position: fixed; inset: 0; background: var(--modal-backdrop); z-index: 100;
    display: flex; align-items: flex-start; justify-content: center; padding: 6vh var(--s4);
    /* Forces its own persistent compositing layer, so the modal always paints
       on top regardless of large/complex renders happening elsewhere on the
       page (e.g. the Gantt chart) — without this, a fixed-position element
       can occasionally fail to paint above unrelated content after a big
       synchronous DOM swap elsewhere in the document. */
    will-change: transform;
}
.modal-backdrop[hidden] { display: none; }
.modal {
    background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-modal);
    width: 100%; max-width: 680px; max-height: 88vh; display: flex; flex-direction: column;
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--s4); border-bottom: 1px solid var(--border); }
.modal-header h3 { margin: 0; border: none; padding: 0; font-size: var(--fs-lg); }
.modal-close { border: none; background: none; font-size: 22px; line-height: 1; color: var(--text-muted); cursor: pointer; }
.modal-body { padding: var(--s4); overflow-y: auto; }
/* Create forms sit in a single label|input column inside the modal, overriding
   the wide inline 3-column grid. */
.modal-body form {
    display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s3) var(--s4);
    align-items: center; max-width: none; margin: 0; padding: 0;
    background: none; border: none; box-shadow: none;
}
.modal-body form > button[type="submit"] { grid-column: 1 / -1; justify-self: end; margin-top: var(--s2); }
/* A standalone checkbox + description (e.g. "Blocking — reserves..."), as
   opposed to a short label|input pair. Spans both columns so its long text
   doesn't inflate the max-content label column for the whole form. */
.modal-body form .checkbox-field {
    grid-column: 1 / -1; display: flex; align-items: center; gap: var(--s2);
    text-align: left; font-weight: normal; color: var(--text);
}
.modal-body form textarea { min-height: 56px; }
/* Pickers default to a narrow 320px cap (right for a table row's Responsible/
   Externals column), but that leaves them looking stranded next to the
   full-width Title/Description fields in a create-modal's single label|input
   column — stretch them to match here instead. */
.modal-body form .picker { width: 100%; max-width: none; }

/* ---------- Inspector drawer (side panel, alternative to modals for record
   detail — doesn't push/dim the main content, so scroll position is kept) --- */
.drawer {
    position: fixed; top: 0; right: 0; height: 100vh; width: min(480px, 90vw);
    background: var(--surface); border-left: 1px solid var(--border-strong);
    box-shadow: -4px 0 16px rgba(0,0,0,.15);
    transform: translateX(100%); transition: transform .18s ease-out;
    /* Above ordinary page content, below .modal-backdrop (100) so the two
       overlays never fight — a modal opened while the drawer is out still
       wins. */
    z-index: 90;
    display: flex; flex-direction: column;
}
.drawer[hidden] { display: none; }
.drawer[data-open] { transform: translateX(0); }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding: var(--s4); border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.drawer-header .drawer-title { margin: 0; border: none; padding: 0; font-size: var(--fs-lg); }
.drawer-close { border: none; background: none; font-size: 22px; line-height: 1; color: var(--text-muted); cursor: pointer; }
.drawer-body { padding: var(--s4); overflow-y: auto; flex: 1 1 auto; }
/* Read-only key/value summary at the top of a drawer's content. */
.drawer-detail-grid {
    display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s2) var(--s4);
    margin-bottom: var(--s4); font-size: var(--fs-sm);
}
.drawer-detail-grid .drawer-field-label { color: var(--text-muted); font-weight: 500; }
/* Edit/action forms below the summary use the same single label|input column
   as a create-modal's form. */
.drawer-body form {
    display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s3) var(--s4);
    align-items: center; max-width: none; margin: 0 0 var(--s4); padding: 0;
    background: none; border: none; box-shadow: none;
}
.drawer-body form > button[type="submit"] { grid-column: 1 / -1; justify-self: end; margin-top: var(--s2); }
.drawer-body form textarea { min-height: 56px; }
.drawer-body form .checkbox-field {
    grid-column: 1 / -1; display: flex; align-items: center; gap: var(--s2);
    text-align: left; font-weight: normal; color: var(--text);
}
/* Same reasoning as .modal-body form .picker — stretch to the form's full
   width instead of the narrower 320px cap a table row's picker wants. */
.drawer-body form .picker { width: 100%; max-width: none; }
.drawer-list { margin: 0 0 var(--s4); padding-left: 18px; font-size: var(--fs-sm); }
.drawer-list li { margin-bottom: var(--s1); }
.drawer-section-title { margin: 0 0 var(--s2); font-size: var(--fs-lg); font-weight: 600; }

/* ---------- Overview board (cockpit) ---------- */
/* Resizable split: a flex row, a draggable splitter, two independently-
   scrolling panes. Replaces the old fixed 60/40 CSS grid — the left pane's
   flex-basis reads --overview-split-left (see :root and the drag handler in
   dashboard.js), the right pane just takes whatever's left via flex: 1,
   so only one value ever needs setting.
   align-items: flex-start (not stretch) deliberately lets each pane size to
   its own content up to a ceiling, rather than both being forced to match
   the taller one — a short timeline shouldn't leave a big blank box under
   it just because the side cards happen to run long that day. That ceiling
   is a JS-computed max-height (resizeOverviewSplit in dashboard.js, applied
   to each pane, not the row) — the space above the row, flash messages
   included, isn't a fixed quantity, so it's measured rather than assumed;
   overflow-y: auto on the pane only kicks in once real content exceeds it. */
.overview-split { display: flex; align-items: flex-start; overflow: visible; }
.overview-pane { overflow-y: auto; min-width: 0; }
.overview-pane-left { flex: 0 0 var(--overview-split-left, 60%); padding-right: var(--s4); }
.overview-pane-right { flex: 1 1 0; padding-left: var(--s4); }
/* The splitter still spans the taller of the two panes (align-self: stretch
   overrides the row's flex-start) so it always reads as one continuous
   divider, even on a day the timeline pane is much shorter than the cards
   beside it. Rest state uses the same neutral border every card already
   sits in, so it doesn't announce itself until you're near it; hover/active
   switch to --primary (never a -fill variable — those are solid button/
   swatch fills paired with white text, not meant for a bare accent line
   like this). Pure var() usage means it repaints correctly on the
   dark-theme toggle with no extra rules — no JS re-render needed here,
   unlike Gantt/wsboard's SVG-drawn elements. */
.overview-splitter {
    flex: 0 0 5px; width: 5px; cursor: col-resize; background: var(--border);
    transition: background .15s ease; align-self: stretch;
}
.overview-splitter:hover, .overview-splitter.is-dragging { background: var(--primary); }
@media (max-width: 1100px) {
    .overview-split { display: block; overflow: visible; }
    .overview-pane { overflow-y: visible; max-height: none !important; }
    .overview-pane-left { flex-basis: auto !important; padding-right: 0; }
    .overview-pane-right { padding-left: 0; }
    .overview-splitter { display: none; }
}
.overview-side { display: flex; flex-direction: column; gap: var(--s4); }
.overview-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); padding: var(--s4); }
.overview-card h4 { margin: 0 0 var(--s3); font-size: var(--fs-lg); font-weight: 600; }
.overview-card ul { margin: var(--s2) 0 0; padding-left: 18px; }
.overview-card li { margin-bottom: var(--s1); }
.stat { font-size: var(--fs-2xl); font-weight: 700; color: var(--text); }
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
.overview-card--warn { border-color: var(--overview-warn-border); background: var(--warning-tint); }
.overview-card--alert { border-color: var(--overview-alert-border); background: var(--danger-tint); }
.stat--red { color: var(--danger); }

.overview-card h4 .count {
    background: var(--surface-sunken); border: 1px solid var(--border); color: var(--text-muted);
    font-size: var(--fs-xs); font-weight: 700; border-radius: var(--radius-pill); padding: 1px 8px; margin-left: 6px; vertical-align: middle;
}
.task-list { list-style: none; margin: 6px 0 0; padding: 0; }
.task-list li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); padding: 6px 0; border-bottom: 1px solid var(--border); margin: 0; }
.task-list li:last-child { border-bottom: none; }
.task-list .task-name { margin-right: auto; font-weight: 500; }
.task-list .task-meta { white-space: nowrap; }
.prio-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.prio-high { background: var(--danger); }
.prio-medium, .prio-normal { background: var(--warning); }
.prio-low { background: var(--text-faint); }
.badge { font-size: var(--fs-xs); padding: 1px 8px; border-radius: var(--radius-pill); white-space: nowrap; font-weight: 600; }
.badge-red { background: var(--danger-tint); color: var(--danger); }
.badge-orange { background: var(--warning-tint); color: var(--warning); }
.badge-grey { background: var(--surface-sunken); color: var(--text-muted); }
/* Compact flag badge for the Tasks grid's slimmed State column — an outline
   (rather than badge-red's filled tint) so a "blocking" flag reads as
   distinct from the filled state-badge it sits beside, not a second state. */
.badge-outline-red { background: none; color: var(--danger); border: 1px solid var(--danger); padding: 0 7px; }
/* Resource Logs' Source column (M8): manual (hand-typed, muted/neutral) vs.
   machine_auto (derived from a stopped simulation session, teal). */
.source-manual { background: var(--surface-sunken); color: var(--text-muted); }
.source-machine_auto { background: var(--teal-tint); color: var(--teal); }

/* ---------- Weekly timeline (Overview left column) ---------- */
/* Continuous vertical axis: time gutter (60px) + gap (10px) puts the dot
   center at 75px, so the 2px track sits at 74px. */
.timeline { position: relative; }
.timeline::before { content: ""; position: absolute; left: 74px; top: 6px; bottom: 6px; width: 2px; background: var(--border); }
.tl-day {
    margin: var(--s3) 0 var(--s1) 92px; font-weight: 700; font-size: var(--fs-xs);
    color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em;
}
.tl-item { display: flex; align-items: baseline; gap: var(--s3); padding: 4px 0; }
.tl-time { flex: 0 0 60px; text-align: right; color: var(--text-faint); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.tl-dot { flex: 0 0 10px; height: 10px; border-radius: 50%; background: var(--text-faint); align-self: center; box-shadow: 0 0 0 2px var(--surface); z-index: 1; }
.tl-icon { flex: none; }
.tl-body { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s2); min-width: 0; }
.tl-title { font-weight: 500; }
/* Divider marking the start of the second week within the same continuous
   list — dashed so it reads as a soft split, not a section boundary like the
   card border, and the label sits over the vertical axis line uninterrupted. */
.tl-week-divider {
    position: relative; margin: var(--s5) 0 var(--s3); text-align: center;
    font-size: var(--fs-xs); color: var(--text-faint); text-transform: uppercase; letter-spacing: .06em;
}
.tl-week-divider::before {
    content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px dashed var(--border-strong);
}
.tl-week-divider span { position: relative; background: var(--surface); padding: 0 var(--s3); }
.tl-state-active    .tl-dot { background: var(--info); }
.tl-state-stalled   .tl-dot { background: var(--warning); }
.tl-state-overdue   .tl-dot { background: var(--danger); }
.tl-state-blocked   .tl-dot { background: var(--blocked); }
.tl-state-suspended .tl-dot { background: var(--suspended); }
.tl-state-deferred  .tl-dot { background: var(--deferred); }

/* ---------- Right-column pools (Overview) ---------- */
.todo-scroll { max-height: 300px; overflow-y: auto; }

.booking-form { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s2) var(--s3); margin-bottom: var(--s4); }
.booking-form label { display: flex; flex-direction: column; gap: 2px; text-align: left; white-space: normal; }
.booking-form .conflict-slot, .booking-form button { grid-column: 1 / -1; }
.booking-form .conflict-slot:empty { display: none; }
.booking-form button { justify-self: start; }

.conflict-banner {
    padding: var(--s2) var(--s3); background: var(--warning-tint); border: 1px solid var(--warning);
    border-radius: var(--radius-sm); color: var(--warning); font-size: var(--fs-sm);
}

.ws-block { border-top: 1px solid var(--border); padding: var(--s3) 0; }
.ws-bookings { list-style: none; margin: var(--s2) 0 0; padding: 0; }
.ws-bookings li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); padding: 2px 0; font-size: var(--fs-sm); }
/* A live session takes priority over the planned-booking calendar below it —
   green + filled-triangle read as "actually happening now", distinct from
   the plain muted booking rows. */
.ws-running-list { margin-top: 4px; }
.ws-running { font-size: var(--fs-sm); color: var(--success); padding: 2px 0; }
.ws-waitlist { margin-top: var(--s2); display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); font-size: var(--fs-sm); }

/* ---------- Gantt chart ---------- */
.gantt-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.gantt-tab { padding: 6px 18px; cursor: pointer; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); font-size: var(--fs-sm); }
.gantt-tab.active { background: var(--sidebar-bg); color: #fff; border-color: var(--sidebar-bg); }

/* Density toolbar (M2): hide-finished checkbox + zoom select above the
   legend. #timeline scoping is needed (not just .gantt-toolbar) so this
   width:auto beats `.form-section :is(input,select,textarea)...`'s higher
   selector specificity, which would otherwise stretch the select full-width. */
.gantt-toolbar { display: flex; align-items: center; gap: var(--s4); margin-top: var(--s3); font-size: var(--fs-sm); }
.gantt-toolbar label { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
#timeline .gantt-toolbar select { width: auto; }
/* Week zoom needs room for one column per ISO week — force a minimum content
   width on the timeline/bar-area flex items (same trick wsboard.js uses for
   its 7-day lanes) so .gantt-container's overflow-x: auto can scroll to it. */
.gantt-container.gantt-zoom-week .gantt-timeline,
.gantt-container.gantt-zoom-week .gantt-bar-area { min-width: var(--gantt-col-min-width, 700px); }

.gantt-container { overflow-x: auto; margin-top: 10px; position: relative; }
.gantt-overlay { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 5; }
.gantt-header { display: flex; border-bottom: 2px solid var(--border-strong); margin-bottom: 4px; background: var(--surface); }
.gantt-label-col {
    min-width: 180px; flex-shrink: 0; font-weight: bold; padding: 4px 8px; font-size: 0.85em;
    position: sticky; left: 0; z-index: 6; background: var(--surface);
}
.gantt-timeline { flex: 1; position: relative; }
.gantt-month-labels { display: flex; }
.gantt-month { flex: 1; text-align: center; font-size: 0.75em; color: var(--text-muted); border-left: 1px solid var(--border); padding: 2px 0; }

.gantt-row { display: flex; align-items: center; min-height: 28px; border-bottom: 1px solid var(--border); }
.gantt-row-label {
    min-width: 180px; flex-shrink: 0; padding: 4px 8px; font-size: 0.82em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    position: sticky; left: 0; z-index: 6; background: var(--surface);
}
.gantt-row-label.project-label { font-weight: bold; color: var(--gantt-project); font-size: 0.9em; background: var(--gantt-project-tint); }
.gantt-row-label.phase-label { font-weight: bold; color: var(--gantt-project); }
.gantt-row-label.task-label { padding-left: 24px; color: var(--text-muted); }
.gantt-row-label.subtask-label { color: var(--text-faint); font-style: italic; }
.gantt-row-label.milestone-label { color: var(--milestone); font-style: italic; }
.gantt-row-label.gantt-toggle-label { cursor: pointer; user-select: none; }
.gantt-row-label.gantt-toggle-label:hover { text-decoration: underline; }
.gantt-count-badge { color: var(--text-faint); font-weight: normal; font-style: normal; font-size: 0.9em; }
.gantt-bar-area { flex: 1; position: relative; height: 24px; }
.gantt-bar { position: absolute; height: 16px; top: 4px; border-radius: 3px; opacity: 0.85; }
.gantt-bar.phase  { background: var(--gantt-active); }
.gantt-bar.task   { background: var(--success-fill); height: 10px; top: 7px; }
.gantt-bar.task.subtask { height: 7px; top: 8.5px; opacity: 0.65; }
.gantt-bar.task.no-date { background: var(--blocked-tint); }
.gantt-bar.project { background: var(--gantt-project); height: 6px; top: 6px; opacity: 1; border-radius: 1px; }
.gantt-bar.project::before, .gantt-bar.project::after {
    content: ''; position: absolute; top: 100%;
    border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid var(--gantt-project);
}
.gantt-bar.project::before { left: 0; }
.gantt-bar.project::after { right: 0; }
.gantt-diamond { position: absolute; width: 14px; height: 14px; background: var(--milestone); transform: rotate(45deg); top: 5px; }
.gantt-row-label.meeting-lane-label { color: var(--teal); font-style: italic; }
.gantt-meeting-chip {
    position: absolute; top: 50%; transform: translateY(-50%); background: var(--teal); color: #fff;
    font-size: 0.7rem; font-weight: 600; line-height: 1; padding: 3px 6px; border-radius: 9px;
    white-space: nowrap; cursor: default; box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
.gantt-meeting-chip.has-conflict { background: var(--danger); box-shadow: 0 0 0 2px var(--danger-tint); }
.gantt-empty { padding: 20px; color: var(--text-faint); font-style: italic; }
.gantt-legend { display: flex; flex-wrap: wrap; gap: 14px; margin-top: var(--s5); margin-bottom: 10px; font-size: 0.8em; color: var(--text-muted); }
.gantt-legend span { display: inline-flex; align-items: center; gap: 5px; }
.gantt-legend i { width: 16px; height: 10px; border-radius: 2px; display: inline-block; }
.gantt-legend i.diamond { width: 10px; height: 10px; border-radius: 0; transform: rotate(45deg); }
.gantt-legend i.arrow { width: 16px; height: 0; border-top: 2px solid var(--danger); }
.gantt-legend i.arrow-warn { width: 16px; height: 0; border-top: 2px dashed var(--conflict); }
.gantt-legend i.row-project { width: 16px; height: 5px; background: var(--gantt-project); border-radius: 1px; }
.gantt-legend i.row-phase { width: 16px; height: 11px; background: var(--gantt-active); border-radius: 2px; }
.gantt-legend i.row-task { width: 16px; height: 7px; background: var(--success-fill); border-radius: 2px; }
.gantt-legend i.today { width: 0; height: 12px; border-left: 2px dashed var(--conflict); }

/* ---------- Workstation scheduling board (wsboard.js) ---------- */
/* Same label-col-sticky / overflow-x-auto structure as the Gantt above, so a
   narrow viewport scrolls the 7-day window without losing the hostname. */
.wsboard-container { overflow-x: auto; margin-top: 10px; position: relative; }
.wsboard-overlay { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 5; }
.wsboard-header { display: flex; border-bottom: 2px solid var(--border-strong); margin-bottom: 4px; background: var(--surface); }
.wsboard-label-col {
    min-width: 190px; flex-shrink: 0; font-weight: bold; padding: 4px 8px; font-size: 0.85em;
    position: sticky; left: 0; z-index: 6; background: var(--surface);
}
.wsboard-days { flex: 1; display: flex; min-width: 700px; }
.wsboard-day-label { flex: 1; text-align: center; font-size: 0.75em; color: var(--text-muted); border-left: 1px solid var(--border); padding: 2px 0; }

.wsboard-row { display: flex; align-items: stretch; min-height: 48px; border-bottom: 1px solid var(--border); }
.wsboard-label-col.wsboard-row-label { display: flex; flex-direction: column; justify-content: center; gap: 3px; white-space: nowrap; }
.wsboard-host-name { display: flex; align-items: center; gap: 6px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.wsboard-status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.wsboard-waiting-badge {
    align-self: flex-start; background: var(--milestone); color: #fff; font-size: 0.72em; font-weight: 600;
    padding: 1px 7px; border-radius: var(--radius-pill);
}
.wsboard-lane-area {
    flex: 1; min-width: 700px; position: relative; margin: 4px 0; cursor: pointer;
    background-image:
        repeating-linear-gradient(to right, var(--grid-line) 0 1px, transparent 1px 100%),
        repeating-linear-gradient(to right, var(--grid-line-strong) 0 1.5px, transparent 1.5px 100%);
    background-size: calc(100%/168) 100%, calc(100%/7) 100%;
}
.wsboard-block {
    position: absolute; top: 4px; height: calc(100% - 8px); min-width: 3px; border-radius: 3px;
    color: #fff; font-size: 0.72em; line-height: 1.15; padding: 2px 5px; overflow: hidden;
    white-space: nowrap; text-overflow: ellipsis; box-sizing: border-box; cursor: default;
}
.wsboard-block.wsboard-booking { background: var(--gantt-active); }
/* History layer: already-ended bookings/sessions stay on the board as
   dimmed blocks (see wsboardHost.Past/Finished in wsboard.go) instead of
   vanishing the moment they end. */
.wsboard-block.wsboard-done { opacity: 0.45; }
.wsboard-block.wsboard-session {
    background: repeating-linear-gradient(45deg, #16a085, #16a085 6px, #12876f 6px, #12876f 12px);
}
.wsboard-block.wsboard-conflict { outline: 2px solid var(--conflict); outline-offset: -1px; z-index: 2; }
.wsboard-session-marker { font-weight: bold; margin-right: 2px; }
.wsboard-conflict-chip {
    position: absolute; top: -8px; transform: translateX(-50%); background: var(--conflict); color: #fff;
    font-size: 0.68rem; font-weight: 600; line-height: 1; padding: 2px 6px; border-radius: 9px;
    white-space: nowrap; z-index: 3; box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
.wsboard-empty { padding: 20px; color: var(--text-faint); font-style: italic; }
.wsboard-week-nav {
    display: flex; align-items: center; justify-content: space-between;
    flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s2);
}
.wsboard-week-nav-controls { display: flex; align-items: center; gap: 6px; }
.wsboard-week-label { font-size: 0.85em; color: var(--text-muted); min-width: 110px; text-align: center; }
.wsboard-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: var(--s3) 0 0; font-size: 0.8em; color: var(--text-muted); }
.wsboard-legend span { display: inline-flex; align-items: center; gap: 5px; }
.wsboard-legend i { width: 16px; height: 10px; border-radius: 2px; display: inline-block; }
.wsboard-legend i.booking { background: var(--gantt-active); }
.wsboard-legend i.session { background: repeating-linear-gradient(45deg, #16a085, #16a085 4px, #12876f 4px, #12876f 8px); }
.wsboard-legend i.conflict { background: transparent; outline: 2px solid var(--conflict); outline-offset: -1px; }
.wsboard-legend i.now-line { width: 0; height: 12px; border-left: 2px dashed var(--conflict); }

/* ---------- Next-slot finder (wsboard.js findWSFitSlot) ---------- */
.wsboard-finder {
    display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
    margin: 0 0 var(--s2); font-size: 0.9em;
}
/* #workstations scoping (not just .wsboard-finder) beats `.form-section
   :is(input,select,textarea)...`'s higher selector specificity, which would
   otherwise stretch this full-width -- same reasoning/pattern as the Gantt
   toolbar's #timeline-scoped select override above. */
#workstations .wsboard-finder input[type="number"] { width: 56px; text-align: center; }
.wsboard-finder-result { font-weight: 600; color: var(--ghost); }
.wsboard-finder-result.wsboard-finder-empty { font-weight: 400; color: var(--text-faint); }
.wsboard-block.wsboard-ghost {
    background: repeating-linear-gradient(45deg, rgba(39,174,96,0.30), rgba(39,174,96,0.30) 6px, rgba(39,174,96,0.5) 6px, rgba(39,174,96,0.5) 12px);
    border: 2px dashed var(--ghost); color: var(--ghost-text); z-index: 4;
    display: flex; align-items: center; justify-content: center;
    /* Overrides .wsboard-block's overflow: hidden: a short-duration slot
       (e.g. 1 hour on a 7-day-wide board) can render narrower than the
       "Book this" button itself, which would otherwise clip it down to a
       sliver of unreadable, barely-clickable text. The button is centered
       and sized by its own content (white-space: nowrap), so it simply
       spills a little past the slot's edges instead -- same idea as the
       conflict chip above already escaping its block's bounds. */
    overflow: visible;
}
.wsboard-ghost-btn {
    background: var(--ghost); color: #fff; border: none; border-radius: 3px;
    font-size: 0.72em; font-weight: 600; padding: 2px 8px; cursor: pointer; white-space: nowrap;
}
.wsboard-ghost-btn:hover { filter: brightness(.95); }
@keyframes wsboard-flash-fade {
    from { background-color: rgba(39, 174, 96, 0.30); }
    to { background-color: transparent; }
}
.wsboard-row.wsboard-flash { animation: wsboard-flash-fade 1.6s ease-out; }

/* ---------- Clockify ---------- */
.clockify-stat { text-align: center; background: var(--surface-sunken); border-radius: var(--radius); padding: 12px 20px; min-width: 90px; border: 1px solid var(--border); }
.clockify-stat-val { font-size: 1.4em; font-weight: bold; color: var(--text); }
.clockify-stat-label { font-size: 0.8em; color: var(--text-muted); margin-top: 2px; }
/* JS-rendered report output (clockify.js): the stats row, result tables, and
   the muted loading/empty/error messages. */
.clockify-stats { display: flex; gap: 24px; margin-bottom: 16px; }
.clockify-msg { color: var(--text-muted); padding: 10px 0; }
.clockify-msg--err { color: var(--danger); }
.clockify-table { width: 100%; border-collapse: collapse; }
.clockify-table th { background: var(--surface-sunken); padding: 6px 8px; text-align: left; }
.clockify-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.clockify-table .align-right { text-align: right; }
.clockify-table .align-center { text-align: center; }
.clockify-table .nowrap { white-space: nowrap; }

/* ============================================================
   Mobile layout — Phase 0 (nav shell only)
   ============================================================
   Breakpoint convention for the whole mobile effort (see
   docs/MOBILE_UI_BUILD_PLAN.md §1). Everything below is purely additive:
   no existing selector above this comment is modified, only new rules
   scoped inside @media blocks (or genuinely new classes/elements) are
   added, mirroring how dark mode lives as one override block rather than
   a parallel stylesheet.

     900px — nav/sidebar collapse tier. Below this width the fixed
             sidebar becomes an off-canvas drawer (this Phase 0 block).
     600px — content-density tier for later phases: card-stacked tables
             (.mobile-card-table), fullscreen modals/bottom-sheets, and
             other higher-density adjustments. Just documented here as
             the shared number; no rules for it yet.

   Every color/spacing value below reuses an existing :root custom
   property; --touch-target-min is the one new variable this phase adds
   (declared in :root above), per the plan's "no literals" rule.
   ============================================================ */

/* Mobile top app bar (hamburger + current-tab title): replaces the
   original floating hamburger, which overlapped every tab's page header
   (user feedback, iteration 1). Only meaningful once the sidebar can be
   hidden off-canvas, so it's hidden entirely above the 900px breakpoint
   (must not exist visually on desktop). */
.mobile-topbar { display: none; }

/* Off-canvas backdrop: dims the page while the drawer is open. Reuses
   the same dark, semi-transparent neutral already used for modal
   overlays (--modal-backdrop) rather than a new hardcoded rgba(). Hidden
   by default and above 900px; only shown via body.nav-open below. */
.mobile-nav-backdrop {
    display: none;
    position: fixed; inset: 0;
    background: var(--modal-backdrop);
    z-index: 80; /* below the nav drawer (.sidebar, 81) and toggle (82), all of
                    which stay below the inspector drawer (90) and modal
                    overlay (100) so an open modal still dims/covers the nav. */
}

@media (max-width: 900px) {
    /* Fixed top app bar: same brand darks as the sidebar so the two read
       as one chrome surface. z-index 82 keeps it above the drawer (81)
       and backdrop (80) so the hamburger stays tappable while the drawer
       is open, and below the inspector drawer (90) / modal overlay (100). */
    .mobile-topbar {
        display: flex; align-items: center; gap: var(--s3);
        position: fixed; top: 0; left: 0; right: 0; z-index: 82;
        height: var(--mobile-topbar-h); padding: 0 var(--s3);
        background: var(--sidebar-bg); color: var(--sidebar-text);
        box-sizing: border-box; box-shadow: var(--shadow-sm);
    }
    .mobile-topbar-title {
        font-weight: 600; font-size: var(--fs-lg); color: #fff;
        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        min-width: 0;
    }
    .mobile-nav-toggle {
        display: flex; align-items: center; justify-content: center;
        flex: none;
        width: var(--touch-target-min); height: var(--touch-target-min);
        background: transparent; color: var(--sidebar-text);
        border: 1px solid var(--sidebar-hover); border-radius: var(--radius-sm);
        cursor: pointer; padding: 0;
    }
    .mobile-nav-toggle:active { background: var(--sidebar-hover); }
    @media (hover: hover) and (pointer: fine) {
        .mobile-nav-toggle:hover { background: var(--sidebar-hover); color: #fff; }
    }
    .mobile-nav-toggle svg { width: 20px; height: 20px; flex: none; }

    /* Sidebar becomes an off-canvas drawer below the top bar: still
       fixed, but translated fully off the left edge until body.nav-open
       is set. dvh (not vh) so Android/iOS browser-chrome collapse doesn't
       leave a dead strip at the bottom. */
    .sidebar {
        z-index: 81;
        top: var(--mobile-topbar-h);
        height: calc(100dvh - var(--mobile-topbar-h));
        transform: translateX(-100%);
        transition: transform .22s ease;
        box-shadow: var(--shadow-modal);
    }
    body.nav-open .sidebar { transform: translateX(0); }

    /* Backdrop starts below the top bar so the bar never dims. */
    .mobile-nav-backdrop { top: var(--mobile-topbar-h); }

    /* Main content reclaims the sidebar's reserved margin at this
       breakpoint — the drawer overlays content instead of pushing it —
       and clears the fixed top bar. */
    .main { margin-left: 0; padding-top: calc(var(--mobile-topbar-h) + var(--s4)); }

    /* Tap-outside-to-close backdrop, shown only while the drawer is open. */
    body.nav-open .mobile-nav-backdrop { display: block; }

    /* Comfortably tappable nav rows at this breakpoint, per Design
       Principle 4 (touch targets >= 44px). Desktop's existing padding-based
       sizing (dashboard.css line ~181) is left untouched; this only adds a
       min-height floor inside the mobile breakpoint. */
    .sidebar ul li { min-height: var(--touch-target-min); box-sizing: border-box; }
}

/* ============================================================
   Mobile layout — Phase 1 (shared interaction-pattern recipes)
   ============================================================
   Defines the reusable recipes Phase 2/3 templates attach classes for —
   nothing here is wired to a template yet (no data-label markup exists in
   any *.html today; that's Phase 2's job). Everything below is additive,
   scoped inside @media (max-width: 600px) (the "content-density" tier
   documented in the Phase 0 block above) except the .select-option:hover
   guard, which is the one sanctioned edit to a pre-existing rule (wrapped
   in place above, not touched here). Every value reuses an existing :root
   custom property — no new variables were needed for this phase.

     600px — content-density tier: card-stacked tables (.mobile-card-table),
             fullscreen modals/drawer, and touch-sized picker/row-edit
             controls, all defined once here so Phase 2/3 agents attach the
             same class names instead of inventing per-template recipes.
   ============================================================ */

@media (max-width: 600px) {
    /* ---------- 1. .mobile-card-table recipe ----------
       Contract for Phase 2: add class="mobile-card-table" to the existing
       <table>, and a data-label="Column Name" attribute to every <td> whose
       column should show a label on mobile (skip it on cells that shouldn't,
       e.g. a spacer/actions cell that reads fine unlabeled) — no other
       markup/structure change needed. */

    /* Design Principle 8 / build-plan §1 item 8: the sticky thead (thead th,
       line ~334 above, position: sticky/top:0/z-index:1, unchanged and still
       in effect above this breakpoint and for any table NOT carrying this
       class) becomes a pinned, meaningless bar once tr/td below switch to
       display:block "cards" — so it's hidden here, scoped strictly to
       .mobile-card-table, leaving the original rule and every other table
       untouched. */
    .mobile-card-table thead { display: none; }

    .mobile-card-table, .mobile-card-table tbody { display: block; width: 100%; }
    /* Dense card, meetings-style (user feedback, iteration 1: "way more
       dense and information packed" — .meeting-card is the benchmark).
       The first labeled cell is the card's bold title line; every other
       labeled cell flows inline as a compact "LABEL value" pair, wrapping
       like .meeting-card-sub — instead of the original one-full-width-
       block-per-field layout, which wasted a phone screen's height. */
    .mobile-card-table tr {
        display: flex; flex-wrap: wrap; align-items: baseline;
        column-gap: var(--s4); row-gap: var(--s1);
        margin-bottom: var(--s2); padding: var(--s2) var(--s3);
        background: var(--surface); border: 1px solid var(--border);
        border-radius: var(--radius); box-shadow: var(--shadow-sm);
        box-sizing: border-box; width: 100%;
    }
    /* Last card shouldn't carry a trailing gap the way table rows don't. */
    .mobile-card-table tbody tr:last-child { margin-bottom: 0; }
    /* Overrides the desktop tbody tr:hover tint — a "hover" tint that
       lingers on a touch card reads as an unintended selection state,
       so it's neutralized inside the card recipe's own scope only. */
    .mobile-card-table tbody tr:hover { background: var(--surface); }

    .mobile-card-table td {
        display: block; width: auto; box-sizing: border-box;
        padding: 0; border: none;
        text-align: left; white-space: normal;
    }
    /* First labeled cell doubles as the card title — bold, its own line,
       no label prefix (the value identifies itself). The [data-label]
       attribute selector means unlabeled cells never render a label. */
    .mobile-card-table td[data-label] { flex: 1 1 100%; font-weight: 600; }
    /* .todo-text's 420px width (desktop-only fix, see its definition above —
       a plain inline element needs a real width to wrap within under the
       table's own layout) is exactly what overflows the card here instead:
       the card is narrower than 420px, and a fixed width doesn't shrink.
       .todo-title-row is a flex row (checkbox + text) at every width, so
       here the text becomes a normal flexible flex item instead — shrinks
       and wraps within whatever's left after the checkbox, down to its
       longest word (min-width: 0 lets it shrink past its default
       content-based floor). */
    .mobile-card-table .todo-text { width: auto; flex: 1 1 auto; min-width: 0; }
    /* Every labeled cell after the first: compact inline meta pair. */
    .mobile-card-table td[data-label] ~ td[data-label] {
        flex: 0 1 auto; font-weight: 400; font-size: var(--fs-sm);
    }
    .mobile-card-table td[data-label] ~ td[data-label]::before {
        content: attr(data-label);
        font-size: var(--fs-xs); font-weight: 600; color: var(--text-muted);
        text-transform: uppercase; letter-spacing: .04em;
        margin-right: var(--s1);
    }
    /* Unlabeled cells (actions, colspan description/empty-state rows)
       take a full card row; pure spacer cells disappear. */
    .mobile-card-table td:not([data-label]) { flex: 1 1 100%; }
    .mobile-card-table td.spacer { display: none; }
    /* Row actions/buttons stay comfortably tappable inside a stacked card. */
    .mobile-card-table td button, .mobile-card-table td .row-link, .mobile-card-table td a.row-link {
        min-height: var(--touch-target-min);
    }

    /* ---------- 2. Fullscreen-on-mobile modal + inspector drawer ----------
       .modal-backdrop centers a fixed-max-width .modal box (line ~681,
       max-width:680px/max-height:88vh) — at phone widths that box expands to
       fill the viewport instead, so forms aren't cramped into a small
       floating card. Structure/z-index (100, above the drawer's 90 and the
       nav's 80/81/82 per the Phase 0 comment) is untouched, only sizing. */
    .modal-backdrop { padding: 0; align-items: stretch; }
    .modal {
        width: 100%; max-width: none; height: 100%; max-height: none;
        border-radius: 0;
    }
    .modal-body { overflow-y: auto; }

    /* Inspector drawer (#inspector.drawer, z-index 90): desktop's fixed
       min(480px, 90vw) side panel becomes a full-width panel at this
       breakpoint — same slide-in transform/behavior, just full bleed. */
    .drawer { width: 100%; }

    /* ---------- 3. Picker + row-edit touch sizing ----------
       .select-option / .picker-search (line ~632-664 above): comfortably
       tappable rows/inputs at phone widths. */
    .select-option, .picker-search { min-height: var(--touch-target-min); box-sizing: border-box; }
    .select-option { display: flex; align-items: center; }

    /* .row-edit-field marks whichever input/select is the live edit-mode
       control in a row-edit toggle (see CLAUDE.md's "Row-edit toggle"
       pattern) — sized for touch, and given --fs-xl (17px, the only --fs-*
       variable that computes to >=16px) rather than the table's usual
       --fs-base (13px) specifically to avoid iOS Safari's automatic zoom-on-
       focus for text inputs under 16px. */
    .row-edit-field input, .row-edit-field select,
    input.row-edit-field, select.row-edit-field {
        min-height: var(--touch-target-min); font-size: var(--fs-xl); box-sizing: border-box;
    }

}

/* ---------- Touch feedback for hover-gated picker rows ----------
   Equivalent of the .select-option:hover tint (guarded above by
   @media (hover: hover) and (pointer: fine) so it no longer fires on touch),
   as :active so tapping gives visible, momentary feedback without the
   "stuck" sticky-hover problem touch browsers have with plain :hover.
   Deliberately OUTSIDE any width media query: the hover guard excludes
   touch devices at every width (e.g. a 700px-wide tablet), so the :active
   fallback must apply at every width too. On desktop this is a no-op in
   practice — :hover already paints the same tint before any mouse-down. */
.project-filter-option:active, .customer-filter-option:active, .select-option:active {
    background: var(--surface-sunken);
}

/* ============================================================
   Mobile layout — Phase 3D (timeline/gantt)
   ============================================================
   Archetype C treatment (docs/MOBILE_UI_BUILD_PLAN.md §2/§3): the Gantt
   stays a horizontally scrollable widget on mobile — deliberately NOT a
   mobile-native redesign — with native touch panning and touch-sized
   controls. Everything here is additive; the matching JS change (a
   debounced window-resize re-render so rotation re-lays-out the chart)
   lives in gantt.js. Every value reuses an existing :root variable
   (--touch-target-min, --fs-xl); no new variables were needed.
   ============================================================ */

/* Touch scrolling — deliberately OUTSIDE any width media query, same
   reasoning as the :active fallback above: touch devices exist at every
   width (a landscape tablet is well past 900px), and both properties are
   inert where they don't apply. -webkit-overflow-scrolling: touch gives
   the container momentum scrolling on older iOS Safari (a no-op
   everywhere else); touch-action: manipulation explicitly keeps native
   pan + pinch-zoom while dropping the double-tap-to-zoom delay, so taps
   on the disclosure rows inside the chart respond immediately. */
.gantt-container {
    -webkit-overflow-scrolling: touch;
    touch-action: manipulation;
}

@media (max-width: 600px) {
    /* Disclosure rows (.gantt-toggle-label fills its row's label column)
       are the chart's only click-interactive elements — the row minimum
       (28px above, line ~912) rises to the shared touch floor here. The
       bars keep their desktop heights inside the row (gantt-bar-area is a
       fixed 24px box centered by the row's align-items: center, so the
       sticky row label — itself centered and taller than the bars'
       vertical extent — still paints over bars scrolling beneath it,
       exactly as on desktop), so the chart just gets taller row spacing,
       not restyled bars. */
    .gantt-row { min-height: var(--touch-target-min); }

    /* Toolbar controls (timeline.html: Hide-finished checkbox + Zoom
       select). The label IS the checkbox's hit area (the input sits
       inside it), so a min-height on the label is enough — no need to
       restyle the native checkbox. The select gets --fs-xl (17px), same
       reasoning as Phase 1's .row-edit-field: the only --fs-* variable
       >= 16px, which stops iOS Safari's automatic zoom-on-focus. */
    .gantt-toolbar label { min-height: var(--touch-target-min); }
    #timeline .gantt-toolbar select {
        min-height: var(--touch-target-min); font-size: var(--fs-xl); box-sizing: border-box;
    }

    /* Tab-style buttons (.gantt-tab — shared control class, currently
       used by the Clockify tab switcher): same touch floor. */
    .gantt-tab { min-height: var(--touch-target-min); }
}

/* ============================================================
   Mobile layout — Phase 3E (overview/workstations)
   ============================================================
   Archetype C treatment (docs/MOBILE_UI_BUILD_PLAN.md §2): the Overview
   split-pane and the workstation scheduling board keep their desktop
   structure on phones rather than getting a mobile-native rebuild.

   Overview stacking + splitter: deliberately NOT re-declared here. The
   legacy @media (max-width: 1100px) block above (the file's original
   Overview rules) already stacks the two panes vertically AND sets
   .overview-splitter { display: none } — a max-width query applies at
   every width below it, so phones (< 900px/600px) are fully covered by
   the existing rules and duplicating them would just create a second
   place to maintain. The splitter's drag logic in dashboard.js is
   harmless while the element is display:none.

   Everything below is additive and @media-scoped; every value reuses an
   existing :root variable (no new variables were needed for this phase).
   ============================================================ */

@media (max-width: 900px) {
    /* iOS momentum scrolling for the board's horizontal 7-day overflow,
       plus touch-action: manipulation to drop the double-tap-to-zoom tap
       delay on lane taps (an empty-lane tap opens the booking modal) —
       manipulation still allows panning and pinch-zoom, unlike pan-x/y. */
    .wsboard-container {
        -webkit-overflow-scrolling: touch;
        touch-action: manipulation;
    }
}

@media (max-width: 600px) {
    /* ---------- Workstations: HTML controls around the board ----------
       Week nav (Prev / Today / Next), the slot-finder's button + hours
       input: comfortably tappable per Design Principle 4. The board's
       lane rows themselves already meet the target (.wsboard-row
       min-height: 48px above), so no lane/block resizing is needed. */
    #workstations .wsboard-week-nav-controls .btn,
    #workstations .wsboard-finder .btn,
    #workstations .wsboard-finder input[type="number"] {
        min-height: var(--touch-target-min);
        box-sizing: border-box;
    }
    /* Find Slot's "Book this" ghost button is the one tap-critical element
       drawn inside the board itself — its block is only ~40px tall (lane
       height minus the block's 4px insets), so the button gets a real
       touch-height floor; .wsboard-ghost's overflow: visible (set above
       for exactly this kind of spill) lets it poke past the block edge. */
    /* No height floor at all: the button pins to exactly its ghost
       block's height (still generously wide, so the tap target stays
       comfortable) — any fixed minimum kept overflowing the ~32px lane
       block and read as broken (user feedback, iterations 4-5). */
    #workstations .wsboard-ghost-btn {
        min-height: 0; height: 100%;
        padding-top: 0; padding-bottom: 0;
    }

    /* ---------- Workstations: card-recipe cleanups ----------
       Both Workstations tables carry a decorative empty td.spacer column;
       as a stacked card cell it renders as a blank padded row with a
       stray divider. Recipe-scoped (not #workstations-scoped) on purpose:
       every .mobile-card-table from other phases has the same spacer
       pattern and the same wart. */
    .mobile-card-table td.spacer { display: none; }
    /* Several Simulation Sessions cells (Started / Duration / Actions)
       carry inline style="white-space:nowrap;", which outranks the Phase 1
       recipe's white-space: normal — !important is the only way to beat an
       inline style without editing the template's cells. Scoped to this
       tab's card tables at this breakpoint only. */
    #workstations .mobile-card-table td { white-space: normal !important; }

    /* ---------- Overview pane content ----------
       No tables/modals in overview.html, so the Phase 1 recipes don't
       attach; the pane content just needs touch-sized controls. The
       booking form's two-column grid gets cramped at phone widths —
       single column instead. */
    #overview .booking-form { grid-template-columns: 1fr; }
    #overview .booking-form input,
    #overview .booking-form select,
    #overview .booking-form button {
        min-height: var(--touch-target-min);
        box-sizing: border-box;
    }
    /* Per-workstation inline actions (Complete Early / Cancel on booking
       rows, Join/Leave waitlist) sized for touch. */
    #overview .ws-bookings .btn,
    #overview .ws-waitlist .btn {
        min-height: var(--touch-target-min);
    }
    /* Task/to-do rows get a touch-height floor so the to-do complete
       checkbox (the interactive part of the row) has room to be hit;
       the checkbox itself grows to --s6 (22px) from the browser default. */
    #overview .task-list li { min-height: var(--touch-target-min); }
    #overview .task-list .complete-checkbox {
        width: var(--s6);
        height: var(--s6);
    }
}

/* ============================================================
   Mobile layout — Phase 4 (integration fixes)
   ============================================================
   Small cross-cutting fixes surfaced by the Phase 2/3 audits, scoped to
   the same 600px content-density tier as the recipes they patch. */
@media (max-width: 600px) {
    /* files.html's Path cell: .nas-path has no rule anywhere else in this
       file — its content is one unbroken monospace token (a NAS path), so
       without this it can't wrap and pushes past the card width. */
    .mobile-card-table td .nas-path { overflow-wrap: anywhere; }

    /* Inside a stacked card every value sits under its own label, so
       column alignment loses its meaning — a right-aligned number at the
       card's far edge reads as disconnected from its label. Normalize to
       left. !important is required because the alignment it overrides
       comes from higher-specificity class rules (.clockify-table
       .align-right) and inline style="text-align:right" attributes
       (costs.html) that outrank any selector at this breakpoint; scoped
       strictly to .mobile-card-table cells, and desktop (>600px) keeps
       all alignment untouched. */
    .mobile-card-table td { text-align: left !important; }
}

/* ============================================================
   Mobile layout — Phase 4, iteration 1 (first on-device feedback)
   ============================================================
   Fixes from the first real phone test: page-scroll traps caused by
   nested scroll containers, and the Gantt's label column eating half a
   phone screen. (The hamburger-overlap fix lives up in the Phase 0
   block: the toggle became a fixed .mobile-topbar; the density rework
   lives in the Phase 1 recipe.) */
@media (max-width: 900px) {
    /* A phone gets ONE scroll axis: the page. The chart containers pan
       horizontally only — but overflow-x:auto forces computed
       overflow-y:auto, which lets the container capture vertical swipes
       and trap the page scroll (reported on Timeline: only the top two
       projects were ever visible). overflow-y:hidden makes vertical
       pans chain to the page immediately; nothing is clipped because
       both containers auto-grow to their content height. */
    .gantt-container, .wsboard-container { overflow-y: hidden; }
}
@media (max-width: 600px) {
    /* Same single-scroll-axis rule for card-stacked tables: the 70vh
       .tableContainer clamp exists for desktop's sticky-header tables;
       with the thead hidden and rows carded, a nested vertical scrollbar
       is pure gesture-trap. Let the page do the scrolling. */
    .tableContainer { max-height: none; overflow: visible; }

    /* Gantt density: 180px sticky labels are half a phone's width —
       narrow them and reclaim the difference for the chart itself.
       Labels already ellipsize, so long names degrade gracefully. */
    .gantt-label-col, .gantt-row-label { min-width: 104px; max-width: 38vw; }
    .gantt-row-label.task-label { padding-left: 12px; }

    /* tasks.html: each task is a two-row tbody (fields row + a
       task-description-row). Iteration 3 — content-first card per the
       standard mobile card convention: title → description snippet →
       meta pairs → actions LAST. Cells from both rows must interleave
       into one flow, so the TBODY becomes the flex card and the two
       rows dissolve via display:contents; td order does the rest. */
    .mobile-card-table tbody:has(> tr.task-description-row) {
        display: flex; flex-wrap: wrap; align-items: baseline;
        column-gap: var(--s4); row-gap: var(--s1);
        margin-bottom: var(--s2); padding: var(--s2) var(--s3);
        background: var(--surface); border: 1px solid var(--border);
        border-radius: var(--radius); box-shadow: var(--shadow-sm);
        box-sizing: border-box; width: 100%;
    }
    .mobile-card-table tbody:has(> tr.task-description-row) > tr { display: contents; }
    /* display:contents dissolves the rows' boxes — including the inline
       status-tint background the template writes on them — so the tint
       is re-derived onto the card by sniffing that same inline style.
       (Matches the template's fixed set of EffectiveState tints.) */
    .mobile-card-table tbody:has(> tr[style*="--danger-tint"]) { background: var(--danger-tint); }
    .mobile-card-table tbody:has(> tr[style*="--warning-tint"]) { background: var(--warning-tint); }
    .mobile-card-table tbody:has(> tr[style*="--suspended-tint"]) { background: var(--suspended-tint); }
    .mobile-card-table tbody:has(> tr[style*="--deferred-tint"]) { background: var(--deferred-tint); }

    /* Content-first order: title 0 → description 1 → meta 2 → actions 3.
       (Sibling/attribute selectors still see the real DOM rows, so the
       existing title/meta recipe rules apply unchanged.) */
    .mobile-card-table tbody:has(> tr.task-description-row) td[data-label] ~ td[data-label] { order: 2; }
    .mobile-card-table tbody:has(> tr.task-description-row) td:not([data-label]) { order: 3; }
    .mobile-card-table tbody:has(> tr.task-description-row) tr.task-description-row td { order: 1; flex: 1 1 100%; }

    /* Description = muted 2-line snippet under the title; the full text
       stays reachable via Inspect → drawer. Placeholder-only cells
       ("No description", a lone .muted span) are dropped entirely —
       dead lines cost more than they say on a phone. */
    .mobile-card-table tr.task-description-row .task-description {
        font-size: var(--fs-sm); color: var(--text-muted);
        display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
        overflow: hidden;
    }
    .mobile-card-table tbody:has(> tr.task-description-row)
        tr.task-description-row td:has(> .task-description > .muted:only-child) { display: none; }

    /* todos.html: same one-tbody-per-record structure as Tasks above, minus
       the second description row (to-dos have no analogous field) — the
       TBODY becomes the flex card and its single <tr> dissolves via
       display:contents. Needed because a plain <tr>-is-the-flex-container
       (the structure this replaces) never reliably kept the bulk-select
       checkbox and the actions cell on the same wrapped line, no matter how
       their order/flex values were tuned; this is the one structure that's
       actually proven to (Tasks, above). No title/meta/description order
       overrides are needed here — with only one row's cells to place,
       the existing unscoped title/meta recipe rules already order them
       correctly on their own. */
    .mobile-card-table tbody.todo-row {
        display: flex; flex-wrap: wrap; align-items: baseline;
        column-gap: var(--s4); row-gap: var(--s1);
        margin-bottom: var(--s2); padding: var(--s2) var(--s3);
        background: var(--surface); border: 1px solid var(--border);
        border-radius: var(--radius); box-shadow: var(--shadow-sm);
        box-sizing: border-box; width: 100%;
    }
    .mobile-card-table tbody.todo-row > tr { display: contents; }
    /* Same reasoning as the Tasks tint-sniffing rules above: display:contents
       drops the dissolved <tr>'s own inline style, so its effect is
       re-derived onto the card from that same inline style. */
    .mobile-card-table tbody.todo-row:has(> tr[style*="--warning-tint"]) { background: var(--warning-tint); }
    .mobile-card-table tbody.todo-row-completed { opacity: 0.55; }
}

/* ============================================================
   Mobile layout — Phase 4, iteration 4 (button coherence)
   ============================================================
   Grounded in Material 3's card/list guidance: supplemental actions go
   in a consistent position at the card's bottom edge across the whole
   collection; list rows put trailing actions right-aligned as separate
   targets; buttons embedded in a line of text or a tight widget use the
   compact size (--touch-target-compact) instead of the full 44px floor,
   which visibly broke the layouts they sat in. */
@media (max-width: 600px) {
    /* ---------- Meetings: one consistent bottom action row ---------- */
    /* Actions bar: full row at the card's bottom, right-aligned, same
       place on every card (upcoming and past) instead of floating
       beside whichever flex item came before it. */
    .meeting-card .meeting-card-actions {
        flex: 1 1 100%; margin-left: 0; margin-top: var(--s1);
        justify-content: flex-end; gap: var(--s2);
    }
    /* Attachments + upload get their own full row above the actions. */
    .meeting-card .meeting-card-center { flex: 1 1 100%; }
    .attachment-upload {
        display: flex; align-items: center; gap: var(--s2);
        width: 100%;
    }
    .attachment-upload input[type="file"] { flex: 1; min-width: 0; }
    /* The native "Choose file" chrome is the one element no shared
       button style reaches — restyle it via ::file-selector-button so
       it reads as the same family as .btn (mobile only; desktop keeps
       the native look it has always had). */
    .attachment-upload input[type="file"]::file-selector-button {
        background: var(--surface-sunken); color: var(--text);
        border: 1px solid var(--border); border-radius: var(--radius-sm);
        padding: var(--s1) var(--s2); margin-right: var(--s2);
        min-height: var(--touch-target-compact); cursor: pointer;
    }
    /* "Copy link" sits inside a running meta line — chip-size it so it
       stops breaking the line's rhythm. */
    .meeting-card-sub .btn-sm { padding: 2px var(--s2); min-height: 0; }

    /* ---------- Users: reveal widget stays inline with its label ---- */
    /* .field-with-reveal is display:flex (a block) — inside a card meta
       cell that dropped the dots+eye onto their own floating line.
       Inline-flex keeps "CLOCKIFY KEY ••••••• [eye]" as one line. */
    .mobile-card-table td .field-with-reveal {
        display: inline-flex; vertical-align: baseline;
    }
    /* The eye is a small inline reveal control, not a primary action —
       exempt it from the card recipe's 44px button floor that was
       inflating it into a huge square. Same for any .row-link inside the
       widget (the "—" no-key placeholder): as a child of an inline-FLEX
       box the recipe's min-height floor suddenly applies (flex items
       honor min-height where inline spans ignore it), which blew the
       meta line up into what looked like an empty row on no-key cards. */
    .mobile-card-table td .btn-reveal { min-height: 0; }
    .mobile-card-table td .field-with-reveal .row-link { min-height: 0; }

    /* ---------- Overview: booking rows as structured list items ----- */
    /* M3 list anatomy: primary line (the slot times) on its own row,
       secondary text + trailing actions on the next, actions pushed to
       the right edge as a group. Previously the li had no layout at
       all, so text and 44px buttons wrapped arbitrarily. */
    #overview .ws-bookings li {
        display: flex; flex-wrap: wrap; align-items: center;
        gap: var(--s1) var(--s2); padding: var(--s1) 0;
    }
    #overview .ws-bookings li > span:first-child { flex: 1 1 100%; }
    #overview .ws-bookings li form:first-of-type { margin-left: auto; }
    #overview .ws-bookings .btn,
    #overview .ws-waitlist .btn {
        min-height: var(--touch-target-compact);
        padding: var(--s1) var(--s2);
    }
}

/* ============================================================
   Keyboard shortcuts (hotkeys.js): "g …" pending-sequence toast + the "?"
   cheat-sheet modal's two-column key/action list. Media-query-free — the
   toast and cheat sheet are desktop-first features but harmless to leave
   unhidden on mobile. Every value reuses an existing :root variable.
   ============================================================ */
.hotkey-goto-toast {
    position: fixed; bottom: var(--s5); left: var(--s5); z-index: 200;
    background: var(--sidebar-bg); color: var(--sidebar-text);
    border: 1px solid var(--sidebar-hover); border-radius: var(--radius-sm);
    padding: var(--s2) var(--s4); font: var(--fs-lg) var(--font-mono);
    box-shadow: var(--shadow-modal);
}
.hotkey-goto-toast[hidden] { display: none; }

.hotkeys-list { display: flex; flex-direction: column; gap: var(--s2); }
.hotkeys-row {
    display: grid; grid-template-columns: max-content 1fr;
    align-items: center; gap: var(--s4);
}
.hotkeys-row kbd {
    justify-self: start; background: var(--surface-sunken); border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm); padding: var(--s1) var(--s2);
    font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text);
}
.hotkeys-row span { color: var(--text-muted); font-size: var(--fs-base); }

/* ============================================================
   QoL Phase 2B — table sort/filter (table-utils.js). Sortable headers stay
   inside the existing sticky thead (thead th's position:sticky above is
   untouched); the filter input is injected as a new row inside
   .tableContainer, above the <table>, so it scrolls with the container's own
   header rather than fighting the sticky thead underneath it. Every value
   below reuses an existing :root variable, per the mobile plan's Design
   Principle 7 (restated for this phase in QOL_BUILD_PLAN.md §1.4).
   ============================================================ */
th[data-sortable] { user-select: none; position: relative; padding-right: var(--s5); }
/* Hover affordance only where a real pointing device is present — same
   hover-gating convention as .select-option/.project-filter-option above,
   so touch browsers never get a "sticky hover" highlight after a tap. */
@media (hover: hover) and (pointer: fine) {
    th[data-sortable]:hover { cursor: pointer; color: var(--text); }
}
/* Direction arrow: a ::after glyph rather than an image/SVG, so it inherits
   color from the same --text-faint/--primary variables as everything else
   and needs no separate asset. Faint until a column is actually sorted, then
   switches to --primary so the active sort reads at a glance among a row of
   otherwise-identical headers. */
th[data-sortable]::after {
    content: '\2195'; position: absolute; right: var(--s2); color: var(--text-faint);
    font-size: var(--fs-sm);
}
th[data-sortable].sorted-asc::after { content: '\2191'; color: var(--primary); }
th[data-sortable].sorted-desc::after { content: '\2193'; color: var(--primary); }

/* Filter input row: styled after .picker-search (same border/radius/padding
   recipe) so it reads as part of the same searchable-list family instead of
   a one-off control. Sits inside .tableContainer, directly above the table,
   so it's part of the same scrolling region as the sticky thead below it
   (never fixed/sticky itself — that's reserved for the thead). */
.table-filter-row {
    padding: var(--s2) var(--s4); border-bottom: 1px solid var(--border);
    background: var(--surface);
}
.table-filter-input {
    width: 100%; max-width: 320px; box-sizing: border-box; padding: 6px 8px;
    border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
    font: inherit; font-size: var(--fs-sm); background: var(--surface); color: var(--text);
}

/* ============================================================
   QoL Phase 3 — command palette (hotkeys.js, Ctrl+K/Cmd+K). Reuses the
   standard .modal-backdrop/.modal shell (modal-palette in dashboard.html)
   and .picker-search styling for the input. Results use a distinct
   .palette-option class rather than .select-option so chooseSelectOption()/
   filterPicker() in dashboard.js never pick up palette rows — the palette
   has its own scoped input/list listeners in hotkeys.js, not those globals.
   Every value below is an existing :root variable (mobile plan Design
   Principle 7 / QOL_BUILD_PLAN.md §1.4). */
.palette-body { display: flex; flex-direction: column; gap: var(--s3); }
.palette-results {
    list-style: none; margin: 0; padding: 0; max-height: 50vh; overflow-y: auto;
    border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface);
}
.palette-option {
    cursor: pointer; padding: var(--s2) var(--s3); display: flex; align-items: baseline;
    gap: var(--s3); border-bottom: 1px solid var(--border);
}
.palette-option:last-child { border-bottom: none; }
.palette-option-label { color: var(--text); flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette-option-subtitle { color: var(--text-muted); font-size: var(--fs-sm); flex: 0 0 auto; }
.palette-option.active { background: var(--primary-tint); }
/* Hover affordance only where a real pointing device is present, same
   convention as .select-option/th[data-sortable] above; :active covers the
   touch-tap equivalent so tapping still gives feedback without a "sticky"
   hover state lingering afterward. */
@media (hover: hover) and (pointer: fine) {
    .palette-option:hover { background: var(--surface-sunken); }
}
.palette-option:active { background: var(--primary-tint); }

/* Brief highlight applied to a table row/tbody the palette scrolls to, so
   the destination reads at a glance among many identical-looking rows.
   Faded in immediately, transitioned back out via the class removal
   (hotkeys.js) after ~1.5s — a CSS transition rather than a JS-driven
   animation keeps the fade smooth without extra script. */
.palette-flash { transition: background-color 1.2s ease; background-color: var(--primary-tint); }

/* ============================================================
   QoL Phase 4 — bulk row actions (qol.js). Leading checkbox column added to
   tasks.html/todos.html; the action bar is injected by qol.js directly above
   the table (inside .tableContainer, alongside table-utils.js's own filter
   row) rather than fixed/floating, so it scrolls with the table it belongs
   to and never overlaps content on narrow screens. Hidden by default (the
   hidden attribute) until >0 rows are selected. Every value below is an
   existing :root variable (mobile plan Design Principle 7 / QOL_BUILD_PLAN.md
   §1.4). */
.bulk-action-bar {
    display: flex; align-items: center; gap: var(--s3);
    padding: var(--s2) var(--s4); border-bottom: 1px solid var(--border);
    background: var(--primary-tint);
}
.bulk-action-bar[hidden] { display: none; }
.bulk-action-count { font-size: var(--fs-sm); font-weight: 600; color: var(--text); }

/* Checkbox cells: no data-label (unlabeled cells render without a label per
   the mobile-card recipe), but they still need a comfortable tap target at
   the mobile breakpoint like every other row control in that recipe. The
   44px floor lives on the CELL (the tappable area), not the checkbox input
   itself — a 44px checkbox renders as a giant square that dominates the
   card (user feedback). The input gets --s6 (22px), same as the Overview
   task-list complete-checkbox precedent from the mobile work. */
@media (max-width: 600px) {
    .mobile-card-table td:has(> .bulk-select),
    .mobile-card-table th:has(> .bulk-select-all) {
        min-height: var(--touch-target-min); display: flex; align-items: center;
    }
    .bulk-select, .bulk-select-all {
        width: var(--s6); height: var(--s6);
    }

    /* One action row per card, Material card anatomy (selection control
       far-LEFT, supplemental actions bottom-RIGHT): the bulk checkbox and
       every unlabeled button-bearing cell share the card's last flex line
       (same order, past the interleave's title 0/description 1/meta 2/
       unlabeled 3), the checkbox pinned left, the button cluster pushed to
       the right edge via the actions cell's own flex layout. The second
       checkbox-cell selector variant repeats the tasks-interleave rule's
       structure (tbody:has(> tr.task-description-row) …) — the plain form
       ties/loses that rule's specificity, whose order: 3 stranded the
       checkbox mid-card on Tasks in the first on-device round. Todos'
       tbody.todo-row (see the card-recipe block above) needs the same
       treatment for the same reason. */
    .mobile-card-table td:has(> .bulk-select),
    .mobile-card-table tbody:has(> tr.task-description-row) td:has(> .bulk-select),
    .mobile-card-table tbody.todo-row td:has(> .bulk-select) {
        flex: 0 0 auto; order: 9; align-self: center;
    }
    .mobile-card-table tr:has(.bulk-select) td:not([data-label]):has(button),
    .mobile-card-table tbody:has(.bulk-select) td:not([data-label]):has(button) {
        flex: 1 1 auto; order: 9;
        display: flex; align-items: center; justify-content: flex-end;
        gap: var(--s2);
    }
    /* The bulk checkbox + actions group (both order: 9 above) only ends up
       on the SAME wrapped line if there happens to be enough leftover
       width after whatever the Priority/Due/Project meta pairs (or the
       Tasks description line) used on their own line — which depends on
       unrelated field text length and isn't reliable (this is what the
       four "QoL iteration" commits before this one kept fighting). An
       invisible order: 8 spacer with flex-basis: 100% sits between the
       meta group (order 0-3) and the action group (order 9) and forces a
       line break there, so the action group always starts a fresh,
       full-width line instead of packing into leftovers. */
    .mobile-card-table tr:has(.bulk-select)::after,
    .mobile-card-table tbody:has(> tr.task-description-row):has(.bulk-select)::after,
    .mobile-card-table tbody.todo-row:has(.bulk-select)::after {
        content: '';
        flex-basis: 100%;
        height: 0;
        order: 8;
    }
}

/* ============================================================
   QoL Phase 5 — soft-delete undo. The Undo button is a tiny inline <form>
   appended inside the existing .flash--ok markup for a soft-deleted
   project/task/todo/workstation booking (projects.html, tasks.html,
   todos.html, workstations.html, overview.html) — same .btn class as every
   other button, no new button variant. .flash already has display:block
   padding; this just lets the form sit on the same line as the flash text
   instead of stacking, using existing spacing variables. */
.flash-undo-form { display: inline-block; margin-left: var(--s3); }
.flash-undo-form .btn { padding: 2px var(--s3); font-size: var(--fs-sm); }
