/* ═══════════════════════════════════════════════════════════
   Ponente — "Warm Studio"
   A tactile, inviting workspace that feels like a shared
   corkboard in a sunlit room. No hard edges, no cold
   surfaces. Friendly, approachable, human.
   ═══════════════════════════════════════════════════════════ */

/* Self-hosted variable fonts (latin subsets, vendored — no CDN, no IP leak
   to Google, works offline). Source: Google Fonts, SIL OFL. */
@font-face {
    font-family: 'Bricolage Grotesque';
    font-style: normal;
    font-weight: 400 800;
    font-display: swap;
    src: url('fonts/bricolage-grotesque.woff2') format('woff2');
}

@font-face {
    font-family: 'Figtree';
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
    src: url('fonts/figtree.woff2') format('woff2');
}

@font-face {
    font-family: 'Figtree';
    font-style: italic;
    font-weight: 300 900;
    font-display: swap;
    src: url('fonts/figtree-italic.woff2') format('woff2');
}

@font-face {
    font-family: 'JetBrains Mono';
    font-style: normal;
    font-weight: 400 500;
    font-display: swap;
    src: url('fonts/jetbrains-mono.woff2') format('woff2');
}

/* ── Design tokens ─────────────────────────────────────── */
/* Every color is written ONCE as light-dark(light, dark) — which side
   renders is steered purely by color-scheme below. This is the single
   source of truth; never add a separate per-theme token block (the old
   duplicated blocks drifted apart). */
:root {
    --font-display: 'Bricolage Grotesque', 'Georgia', serif;
    --font-body: 'Figtree', 'Segoe UI', system-ui, sans-serif;
    --font-mono: 'JetBrains Mono', ui-monospace, monospace;

    /* Brand purple — kept warm */
    --brand: light-dark(#7c3aed, #b59cff);
    --brand-bright: light-dark(#ab7ef6, #d2c4ff);
    --brand-dim: light-dark(#6d2ad9, #8b6cff);
    --brand-glow: light-dark(rgba(124, 58, 237, 0.12), rgba(181, 156, 255, 0.10));
    --brand-surface: light-dark(#f3eeff, #2a2340);
    /* Text/icons sitting ON a --brand fill. Dark brand is a light lavender,
       so it needs ink, not white. */
    --brand-contrast: light-dark(#ffffff, #221733);

    /* Warm neutrals — fg-2 carries the Warm Studio sage tone.
       Surfaces climb with elevation: page (bg-0) < column/inset (bg-2)
       < card (bg-1); bg-3 is for pressed/raised accents. */
    --fg: light-dark(#2c2418, #f2ece2);
    --fg-2: light-dark(#6b7e6f, #b5a896);
    --fg-3: light-dark(#a39684, #7d6f5e);
    --bg-0: light-dark(#f7f6ef, #161311);
    --bg-1: light-dark(#ffffff, #2e2823);
    --bg-2: light-dark(#f2eee5, #221d1a);
    --bg-3: light-dark(#e6e2d6, #3a342e);

    --border-1: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.05));
    --border-2: light-dark(rgba(0, 0, 0, 0.10), rgba(255, 255, 255, 0.09));
    --border-3: light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.16));

    --success: light-dark(#16a36a, #36d399);
    --success-bg: light-dark(#eefff5, #142b20);
    --danger: light-dark(#fc2626, #f87171);
    --danger-bg: light-dark(#fef2f2, #2d1515);
    /* Text/icons sitting ON a --danger fill — dark danger is a light red. */
    --danger-contrast: light-dark(#ffffff, #2d0f0f);
    --info: light-dark(#2563eb, #7eb8ff);

    /* Toasts stay dark in light theme (intentional contrast pop) but
       lift to a raised surface in dark, where near-black would vanish. */
    --toast-bg: light-dark(#1a1613, #3a342e);
    --toast-fg: #f2ede6;

    --radius: 20px;
    --radius-lg: 24px;
    --radius-sm: 14px;
    --radius-xs: 10px;
    --radius-btn: 12px;
    --radius-pill: 999px;

    --shadow-xs: 0 1px 2px light-dark(rgba(44, 36, 24, 0.04), rgba(0, 0, 0, 0.4));
    --shadow-sm: 0 2px 8px light-dark(rgba(44, 36, 24, 0.06), rgba(0, 0, 0, 0.3)), 0 1px 2px light-dark(rgba(44, 36, 24, 0.04), rgba(0, 0, 0, 0.2));
    --shadow-md: 0 6px 24px light-dark(rgba(44, 36, 24, 0.08), rgba(0, 0, 0, 0.35)), 0 2px 6px light-dark(rgba(44, 36, 24, 0.04), rgba(0, 0, 0, 0.2));
    --shadow-lg: 0 16px 48px light-dark(rgba(44, 36, 24, 0.10), rgba(0, 0, 0, 0.4)), 0 4px 12px light-dark(rgba(44, 36, 24, 0.05), rgba(0, 0, 0, 0.25));
    --shadow-xl: 0 24px 64px light-dark(rgba(44, 36, 24, 0.14), rgba(0, 0, 0, 0.5)), 0 8px 20px light-dark(rgba(44, 36, 24, 0.06), rgba(0, 0, 0, 0.3));
    --shadow-card: 0 2px 8px light-dark(rgba(44, 36, 24, 0.05), rgba(0, 0, 0, 0.25)), 0 0 0 1px light-dark(rgba(44, 36, 24, 0.03), rgba(255, 255, 255, 0.035));
    --shadow-card-hover: 0 14px 36px light-dark(rgba(44, 36, 24, 0.10), rgba(0, 0, 0, 0.45)), 0 3px 8px light-dark(rgba(44, 36, 24, 0.04), rgba(0, 0, 0, 0.25));
    --shadow-brand: 0 8px 24px light-dark(rgba(124, 58, 237, 0.18), rgba(181, 156, 255, 0.22));

    /* The phone's bottom sheets, and the one shadow in the file that only
       casts upwards. A sheet rests *on* the nav bar, so the downward half of
       `--shadow-lg` lands on the bar and nowhere else — which reads as the bar
       being tucked under the sheet rather than as the sheet being the thing
       that lifted. Same weight, no `y` below zero. */
    --shadow-sheet: 0 -14px 40px light-dark(rgba(44, 36, 24, 0.13), rgba(0, 0, 0, 0.45)), 0 -2px 8px light-dark(rgba(44, 36, 24, 0.06), rgba(0, 0, 0, 0.3));

    --ease: cubic-bezier(0.22, 0.68, 0, 1);
    --ease-out: cubic-bezier(0, 0, 0.2, 1);
    --duration: 200ms;

    /* The column's rail: the inset the column header's label, the hairline
       under it, every card in the list and the add-card button all start
       from. It is one number because those four have to line up — a card at
       a different inset from the label above it reads as the column having
       two left edges — and the phone block near the foot of this file is the
       one place that changes it, where a rail this wide was a quarter of the
       screen spent before the first word. */
    --column-rail: 1.15rem;

    /* The card's own inset, which is the column's *second* vertical line: the
       rail is where the tiles start, this is where the words inside them do.
       A token because the column header has to be able to sit on that second
       line — its label is text, and the text above a stack of card titles
       reads as loose unless it starts where they do. */
    --card-inset: 1.15rem;

    /* The phone's nav bar, which is fixed to the *bottom* edge — see the phone
       block near the end of this file for why. Everything that has to stop
       where the bar starts (the board, the calendar, the pagination pill, the
       toasts, the two sheets) subtracts this one value rather than repeating
       the number, and the safe-area inset is inside it because the bar is the
       thing sitting in it. Unused above 599px, where the header is a header. */
    --phone-nav-h: calc(56px + env(safe-area-inset-bottom, 0px));

    /* The board's own bar over its columns on a phone (`.board-phone-bar`),
       which the board and its columns subtract as they do the bottom bar. */
    --phone-board-title-h: 56px;

    /* The strip the pagination pill stands in, between the bottom of the
       column and the top of that bar. The pill is `position: fixed`, so the
       column has to subtract the whole strip — pill plus one gap on each side
       — while the pill itself is placed one gap above the bar. That is what
       makes the space above the pill and the space below it the same by
       construction: they are one number, not two that have to agree. (They
       used to be 0.4px and 9.6px, which read as the pill resting on the
       column rather than sitting between two things.) The height is the
       collapsed pill's: an 8px dot, 0.5rem of padding and a 1px border, twice
       over. `mobile.spec.js` measures both gaps, which is the guard against
       this number drifting from the pill it describes. */
    --phone-pager-h: 1.625rem;
    --phone-pager-gap: 0.3125rem;

    /* Auto: follow the OS preference */
    color-scheme: light dark;
}

/* Explicit user choice pins the scheme — every light-dark() above follows,
   as do UA-rendered parts (scrollbars, form controls, <dialog> backdrops). */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }

/* Switching the theme changes every colour token at once, and a good part of
   the page carries a transition — so without this the swap is several hundred
   elements each animating background, colour, border and shadow for 200 ms.
   That is the lag on a phone, and it is animating a change nobody asked to see
   animated. The head script in App.razor sets this attribute, flushes the new
   colours under it, and drops it again in the same frame, which turns the swap
   into one repaint. It is never on for longer than that, so this is not a
   general-purpose "no transitions" switch. */
:root[data-theme-switching] *,
:root[data-theme-switching] *::before,
:root[data-theme-switching] *::after {
    transition: none !important;
}

/* ── Base reset ────────────────────────────────────────── */

*, *::before, *::after {
    box-sizing: border-box;
}

/* Ticks and dots stay the browser's own — a hand-drawn checkbox is a control
   assistive technology has to be told about twice — but they are the app's
   colour and the app's size everywhere, rather than the 14px default sitting
   beside 44px rows and reading as an artefact. Said once here because the
   three places that had it were saying only half of it and drifting: the
   access page's ticks, its radios, and the sign-in form's remember box. */
input[type="checkbox"],
input[type="radio"] {
    accent-color: var(--brand);
    width: 1.05rem;
    height: 1.05rem;
    flex: 0 0 auto;
    cursor: pointer;
}

h1:focus { outline: none; }

body {
    font-family: var(--font-body);
    margin: 0;
    background: var(--bg-0);
    color: var(--fg);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.6;
    font-size: 15px;
}

code, pre, .admin-did code, .member-preview-did {
    font-family: var(--font-mono);
}

::selection {
    background: var(--brand);
    color: var(--brand-contrast);
}

/* ── Blazor scaffolding ────────────────────────────────── */

.valid.modified:not([type=checkbox]) {
    outline: 2px solid var(--success);
    outline-offset: -1px;
}

.invalid {
    outline: 2px solid var(--danger);
    outline-offset: -1px;
}

.validation-message {
    color: var(--danger);
    font-size: 0.85rem;
    margin-top: 0.25rem;
}

.blazor-error-boundary {
    background: var(--danger);
    padding: 1rem 1.25rem;
    color: white;
    border-radius: var(--radius-sm);
    margin: 1rem;
    font-weight: 600;
}

.blazor-error-boundary::after {
    content: "An error has occurred."
}

.darker-border-checkbox.form-check-input {
    border-color: var(--border-3);
}

/* ── Shared button system ──────────────────────────────── */

.btn-primary,
.btn-danger,
.btn-secondary {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.4rem;
    border-radius: var(--radius-pill);
    padding: 0.65rem 1.5rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.9rem;
    font-weight: 600;
    transition: all var(--duration) var(--ease);
    position: relative;
    white-space: nowrap;
    letter-spacing: 0.01em;
    /* Several of these are <a> rather than <button> — the profile page's way
       back through sign-in, the standalone pages' one action. There is no
       global reset on `a`, so without this one of them arrives underlined and
       reads as a link wearing a button rather than as a button. */
    text-decoration: none;
}

.btn-primary {
    background: var(--brand);
    border: none;
    color: var(--brand-contrast);
    box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.2);
}

.btn-primary:hover:not(:disabled) {
    background: var(--brand-bright);
    box-shadow: var(--shadow-brand);
    transform: translateY(-2px);
}

.btn-primary:active:not(:disabled) {
    transform: translateY(0);
    box-shadow: var(--shadow-xs);
}

.btn-primary:disabled,
.btn-danger:disabled,
.btn-secondary:disabled {
    opacity: 0.35;
    cursor: not-allowed;
    transform: none;
    box-shadow: none;
}

.btn-danger {
    background: transparent;
    border: 2px solid var(--danger);
    color: var(--danger);
}

.btn-danger:hover:not(:disabled) {
    background: var(--danger);
    color: var(--danger-contrast);
    box-shadow: 0 6px 16px rgba(220, 38, 38, 0.2);
    transform: translateY(-2px);
}

.btn-secondary {
    background: var(--bg-2);
    border: 1px solid var(--border-2);
    color: var(--fg);
}

.btn-secondary:hover:not(:disabled) {
    background: var(--bg-3);
    border-color: var(--border-3);
}

/* ── Board header ──────────────────────────────────────── */

.board-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 1.75rem;
    height: 64px;
    background: var(--bg-1);
    color: var(--fg);
    position: sticky;
    top: 0;
    z-index: 100;
    box-shadow: 0 1px 0 var(--border-1);
}

.board-header h1 {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.4rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--fg);
}

.board-user {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    font-size: 0.88rem;
    color: var(--fg-2);
    position: relative;
}

/* There is deliberately no default style for buttons inside .board-user any
   more. There used to be one — a grey pill — and every control here then had to
   out-specify it from its own block, which is what the `:not(.nav-hamburger)`
   in it was for and what left the theme pills one selector away from all
   reading as chosen. Each of the four buttons that live here (the menu trigger,
   the admin toggle, the pref pills, the menu's own rows) says what it looks
   like, once, and nothing has to beat anything. */

.board-nav-link {
    color: var(--fg-2);
    text-decoration: none;
    font-size: 0.9rem;
    font-weight: 500;
    padding: 0.45rem 0.85rem;
    border-radius: var(--radius-pill);
    transition: all var(--duration) var(--ease);
}

.board-nav-link:hover {
    background: var(--brand-glow);
    color: var(--brand);
    text-decoration: none;
}

.board-nav-link-active {
    background: var(--brand-surface);
    color: var(--brand);
    font-weight: 600;
    text-decoration: none;
}

.board-title-link {
    text-decoration: none;
    color: inherit;
}

.board-title-link:hover { opacity: 0.8; }

/* The heading stands over the column it heads. It is a separate block from
   `.admin-page` because the sticky header sits between them, so it has to
   repeat that column's width and padding rather than inherit it — left at the
   page's own left edge it was ~280px away from the first panel on a laptop,
   which is the alignment thing you notice on every admin page at once. Both
   numbers are `.admin-page`'s, in both the desktop and the phone block. */
.admin-page-title {
    max-width: 880px;
    margin: 0 auto;
    padding: 1.5rem 1.75rem 0;
}

.admin-page-title h2 {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.4rem;
    font-weight: 700;
    color: var(--fg);
    letter-spacing: -0.02em;
}

/* ── Board layout ──────────────────────────────────────── */

.board-phone-bar { display: none; }

.board {
    display: flex;
    /* Stretch, not flex-start: columns sized to their content ended at three
       different heights, and the emptiest column — the one you most want to
       drop onto — offered the smallest drop target. */
    align-items: stretch;
    /* Once the columns hit their max width, whatever is still left over gets
       split either side rather than pooling on the right. `safe` matters: a
       plain `center` on an overflowing scroll container pushes the first
       column past the scroll origin and puts it out of reach. */
    justify-content: safe center;
    gap: 1.25rem;
    padding: 1.5rem;
    overflow-x: auto;
    overflow-y: hidden;
    /* Definite height (not min-height): columns scroll internally, so the
       page itself must never gain a scrollbar. dvh where supported. */
    height: calc(100vh - 64px);
    height: calc(100dvh - 64px);
    scroll-behavior: smooth;
    /* Sky wash: a whisper of brand at the top, like high haze. */
    background: linear-gradient(180deg, var(--brand-glow), transparent 45%);
    scrollbar-width: thin;
    scrollbar-color: var(--border-2) transparent;
}

.column {
    background: var(--bg-2);
    border: none;
    border-radius: var(--radius);
    padding: 0;
    width: 320px;
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    /* Fill the board's content box — automatically accounts for its padding
       and the horizontal scrollbar, unlike a hand-computed vh offset. */
    max-height: 100%;
    box-shadow: none;
    overflow: hidden;
}


.column-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1rem var(--column-rail);
    border-bottom: none;
    background: transparent;
}

.column-name {
    margin: 0;
    font-family: var(--font-display);
    font-size: 0.95rem;
    font-weight: 700;
    color: var(--fg);
    letter-spacing: -0.01em;
}

/* Count and filter button travel together at the header's right end. Pointedly
   *not* positioned: the flyout anchors on .column-header instead, because a
   percentage width here would resolve against these two chips — see
   .column-filter-panel. */
.column-tools {
    display: flex;
    align-items: center;
    gap: 0.3rem;
}

.column-count {
    font-size: 0.78rem;
    color: var(--fg-3);
    background: var(--bg-1);
    padding: 0.2rem 0.6rem;
    border-radius: var(--radius-pill);
    font-weight: 600;
    border: none;
    box-shadow: var(--shadow-xs);
    min-width: 1.7rem;
    text-align: center;
}

/* Filtered, the badge counts what is on screen rather than what the column
   holds — two different facts that must not look like the same one. */
.column-count-filtered {
    color: var(--brand);
    background: var(--brand-glow);
    box-shadow: none;
}

/* ── Column tag filter (button + flyout) ───────────────── */

.column-filter-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: none;
    border-radius: var(--radius-pill);
    background: transparent;
    color: var(--fg-3);
    cursor: pointer;
    transition: background var(--duration) var(--ease),
                color var(--duration) var(--ease);
}

.column-filter-toggle svg {
    width: 0.95rem;
    height: 0.95rem;
}

.column-filter-toggle:hover {
    background: var(--bg-1);
    color: var(--fg);
}

/* Filtering is a state the column is left in, and the flyout that says so is
   shut by then — so the button carries it, in the same brand tint the count
   badge takes. */
.column-filter-toggle-active,
.column-filter-toggle-active:hover {
    background: var(--brand-glow);
    color: var(--brand);
}

/* Invisible full-screen click-catcher, as the nav menu has. It covers the
   button that opened it too, so that button is lifted over it while the panel
   is open — otherwise the way you opened the flyout is the one place a click
   cannot close it again. */
.column-filter-backdrop {
    position: fixed;
    inset: 0;
    z-index: 190;
    background: transparent;
}

.column-filter-toggle[aria-expanded="true"] {
    position: relative;
    z-index: 200;
}

/* Anchored on .column-header — which is already positioned, for the hairline —
   rather than on .column-tools, so a percentage width resolves against the
   column rather than against the count badge and the button. It is deliberately
   sized to fit *inside* the column: .column clips at its own rounded edges, so
   a panel that outgrew it would be sliced rather than overflow. Hence the width
   under the 320px column's content box, the max-width for the narrower column a
   tablet gets, and a tag list that scrolls rather than growing. It hangs off
   the rail like everything else in the column, so the max-width is two of them
   rather than a hard-coded double. */
.column-filter-panel {
    position: absolute;
    top: calc(100% + 0.55rem);
    right: var(--column-rail);
    z-index: 200;
    width: 15rem;
    max-width: calc(100% - 2 * var(--column-rail));
    background: var(--bg-1);
    border: 1px solid var(--border-1);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-lg);
    padding: 0.7rem;
    animation: column-filter-in var(--duration) var(--ease) both;
}

@keyframes column-filter-in {
    from { opacity: 0; transform: translateY(-6px); }
    to   { opacity: 1; transform: none; }
}

.column-filter-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.55rem;
}

.column-filter-title {
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--fg-3);
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.column-filter-clear {
    border: none;
    background: transparent;
    color: var(--brand);
    font-family: var(--font-body);
    font-size: 0.72rem;
    font-weight: 600;
    padding: 0.2rem 0.35rem;
    margin: -0.2rem -0.35rem;
    border-radius: var(--radius-xs);
    cursor: pointer;
    transition: background var(--duration) var(--ease);
}

.column-filter-clear:hover {
    background: var(--brand-glow);
}

/* Wraps rather than scrolling sideways: inside a panel the height is free, and
   a wrapped row shows every tag at once instead of hiding some off an edge. */
.column-filter-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    max-height: min(18rem, 40vh);
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--border-2) transparent;
}

/* The inline padding is the column's rail, which is why it is the token and
   not a number: it puts the card edges on the same line as the header label
   and the hairline under it — a line the cards used to miss by 8px, having
   been given their own inset — and leaves the hover shadow room before the
   clip. */
.column-cards {
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    padding: 0.5rem var(--column-rail) 0.75rem;
    flex: 1;
    gap: 0.7rem;
    scrollbar-width: thin;
    scrollbar-color: var(--border-2) transparent;
}

/* Auto block margins centre it: the columns are full height now, so pinned to
   the top the doodle sat alone above a lot of empty panel. */
.column-empty {
    margin: auto 0;
    padding: 2.5rem 1.25rem;
    color: var(--fg-3);
    font-size: 0.88rem;
    text-align: center;
    font-style: italic;
    line-height: 1.6;
}

/* A column only renders the placeholder when it holds no cards, so a card in
   here is one being dragged in — and the placeholder has to get out of its way.
   It is not a card, but it *is* the container's only child, and SortableJS
   inserts relative to whatever is last in the list: the card being dragged in
   landed after a block that `margin: auto` centres in a full-height column,
   i.e. pinned to the very bottom, nowhere near the pointer.
   `display: none` and not `opacity`/`visibility` on purpose — the library's own
   lastChild() skips display:none children, so with the placeholder hidden it
   finds nothing to insert after and treats the column as the empty list it is.
   The column keeps its size either way (.column-cards is `flex: 1`), so the
   drop target does not move out from under the finger. */
.column-cards:has(.card) .column-empty {
    display: none;
}

/* ── Cards — borderless, shadow-elevated ──────────────── */

.card {
    background: var(--bg-1);
    border: none;
    border-radius: var(--radius-sm);
    padding: 1rem var(--card-inset);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
    position: relative;
    box-shadow: var(--shadow-card);
}

/* The lift is for a pointer that hovers. On a touchscreen a tap leaves :hover
   stuck on the card it landed on, and that tap is what opens the card dialog —
   so the lift and its shadow played out *under the sheet*, during its entrance.
   A box-shadow is not a compositor property: it repainted the board on every
   frame of the slide, and that is most of why the dialog alone was sluggish on
   a phone. A finger gets a press instead, on `scale`, which the compositor can
   run without the main thread. Before `.card-chosen` on purpose: a press held
   long enough to pick the card up grows it, and the later rule has to win. */
@media (hover: hover) {
    .card:hover {
        box-shadow: var(--shadow-card-hover);
        transform: translateY(-3px);
    }

    .card:active {
        transform: translateY(-1px);
        box-shadow: var(--shadow-sm);
    }
}

@media (hover: none) {
    .card:active {
        scale: 0.98;
    }
}

/* The due pill rides the title row (see CardView), so the two half-empty
   rows a full card used to stack — due alone, tags alone — fold into one.
   Baseline alignment keeps the pill on the title's first line when the
   title wraps; the pill itself never shrinks or wraps. */
.card-title-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.5rem;
}

.card-title-row .card-due { flex: 0 0 auto; }

.card-title {
    margin: 0;
    font-family: var(--font-display);
    font-size: 0.95rem;
    font-weight: 600;
    line-height: 1.45;
    color: var(--fg);
    letter-spacing: -0.01em;
    /* A pasted URL is one unbreakable word and would run past the card. */
    overflow-wrap: anywhere;
    /* In the title row's flex context a long word refuses to wrap without this. */
    min-width: 0;
}

/* Clamp on a line boundary. A max-height cut wherever it landed — 3.2rem
   over a 1.55 line-height showed 2.43 lines, slicing the third row of
   letters through the x-height on every card that had one. */
.card-description {
    margin: 0.45rem 0 0;
    font-size: 0.85rem;
    color: var(--fg-2);
    line-height: 1.55;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    line-clamp: 3;
    overflow: hidden;
    overflow-wrap: anywhere;
}

.column-add {
    /* Inline on the column's rail with the cards above it; the bottom inset
       matches the header's top one, so the column is padded the same at both
       ends. Its own padding is not on the rail and does not shrink with it —
       this is a 44px touch target and stays one. */
    margin: 0.35rem var(--column-rail) 1rem;
    border: 2px dashed var(--border-2);
    background: transparent;
    color: var(--fg-3);
    text-align: center;
    padding: 0.75rem;
    border-radius: var(--radius-sm);
    cursor: pointer;
    font-size: 0.9rem;
    font-weight: 600;
    font-family: var(--font-body);
    transition: all var(--duration) var(--ease);
}

.column-add:hover {
    border-color: var(--brand);
    color: var(--brand);
    background: var(--brand-glow);
    border-style: solid;
}

/* ── Modal ─────────────────────────────────────────────── */

/* The layer that holds a dialog is not the layer that fades. The scrim — its
   colour, its blur and its fade — is the ::before, *behind* the dialog rather
   than around it. It used to be the backdrop element itself, and an opacity
   animated on the dialog's own parent is an opacity on the dialog: the browser
   has to draw the whole dialog into an offscreen surface and blend that, every
   frame, for as long as the fade runs. On a desk that is a small box. On a
   phone it is a full-screen sheet at three times the pixels, on the weakest
   GPU the app runs on, during the one animation that felt sluggish there. */
.modal-backdrop {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    padding: 1rem;
}

/* -1 inside the backdrop's own stacking context: under the dialog, over the
   page. */
.modal-backdrop::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: rgba(25, 21, 19, 0.45);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    animation: fadeIn var(--duration) var(--ease-out);
}

@keyframes fadeIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}

@keyframes slideUp {
    from { opacity: 0; transform: translateY(20px) scale(0.96); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}

.modal {
    background: var(--bg-1);
    border: none;
    border-radius: var(--radius);
    width: min(720px, 100%);
    max-height: calc(100vh - 2rem);
    display: flex;
    flex-direction: column;
    box-shadow: var(--shadow-xl);
    animation: slideUp 300ms var(--ease-out);
}

.modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1.25rem 1.5rem;
    border-bottom: 1px solid var(--border-1);
    flex-shrink: 0;
}

.modal-header h2 {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.15rem;
    font-weight: 700;
    letter-spacing: -0.02em;
}

.modal-close {
    background: none;
    border: none;
    font-size: 1.4rem;
    cursor: pointer;
    color: var(--fg-3);
    width: 2.5rem;
    height: 2.5rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: all var(--duration) var(--ease);
}

.modal-close:hover {
    background: var(--bg-2);
    color: var(--fg);
}

.modal-body {
    padding: 1.35rem 1.5rem 1.5rem;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 1.1rem;
    flex: 1;
    min-height: 0;
    scrollbar-width: thin;
    scrollbar-color: var(--border-2) transparent;
}

.field {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.field-label {
    font-size: 0.88rem;
    font-weight: 600;
    color: var(--fg-2);
}

.field input,
.field textarea,
.field select {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.65rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    resize: vertical;
    background: var(--bg-0);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.field input:focus,
.field textarea:focus,
.field select:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
    background: var(--bg-1);
}

.field input::placeholder,
.field textarea::placeholder {
    color: var(--fg-3);
    opacity: 0.7;
}

/* .atproto-login-error is the SDK LoginForm's own error block. It ships with
   Bootstrap class names we don't have, so give it the same look as ours. */
.modal-error,
.atproto-login-error {
    margin: 0;
    color: var(--danger);
    font-size: 0.88rem;
    font-weight: 500;
    padding: 0.75rem 1rem;
    background: var(--danger-bg);
    border-radius: var(--radius-xs);
    border: none;
    box-shadow: var(--shadow-xs);
}

.modal-footer {
    display: flex;
    gap: 0.5rem;
    align-items: center;
    padding: 1rem 1.5rem;
    border-top: 1px solid var(--border-1);
    flex-shrink: 0;
}

.modal-footer-spacer { flex: 1; }

.delete-confirm-label {
    font-size: 0.88rem;
    font-weight: 600;
    color: var(--danger);
}

.modal-footer button {
    border: 1px solid var(--border-2);
    background: var(--bg-1);
    border-radius: var(--radius-pill);
    padding: 0.5rem 1.15rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.88rem;
    font-weight: 500;
    color: var(--fg-2);
    transition: all var(--duration) var(--ease);
}

.modal-footer button:hover:not(:disabled) {
    background: var(--bg-2);
    border-color: var(--border-3);
}

.modal-footer button:disabled {
    opacity: 0.35;
    cursor: not-allowed;
}

.modal-footer .btn-primary {
    background: var(--brand);
    border-color: transparent;
    color: var(--brand-contrast);
}

.modal-footer .btn-primary:hover:not(:disabled) {
    background: var(--brand-bright);
    box-shadow: var(--shadow-brand);
    transform: translateY(-1px);
}

.modal-footer .btn-danger {
    background: transparent;
    border-color: var(--danger);
    color: var(--danger);
}

.modal-footer .btn-danger:hover:not(:disabled) {
    background: var(--danger);
    color: var(--danger-contrast);
}

/* ── Card modal: composer / details / discussion ───────── */
/* The card dialog is three groups, not six stacked form rows: what the card
   says (composer), what it is filed under (details), and what people said
   about it (discussion). Group headings reuse the column header's language —
   display face plus a pill count — so the dialog reads like the board it
   belongs to, and the labelled-input list disappears: the title is set in the
   type it will be read in, and the description carries its own Write/Preview
   switch instead of a second, permanently-open preview block below it. */

.section-head {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    min-height: 2rem;
}

.section-label {
    font-family: var(--font-display);
    font-size: 0.9rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--fg);
}

.section-count {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--fg-3);
    background: var(--bg-2);
    border-radius: var(--radius-pill);
    padding: 0.1rem 0.55rem;
    box-shadow: var(--shadow-xs);
}

.section-note {
    margin: 0;
    color: var(--fg-3);
    font-size: 0.88rem;
}

/* — Composer — */

.composer {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    padding: 1.1rem;
    background: var(--bg-2);
    border-radius: var(--radius-sm);
}

/* The headline is an input that looks like the heading it becomes. It carries
   no box of its own; the underline only shows up on hover and focus, which is
   also the whole focus indicator (the generic ring covers buttons, not
   fields). */
.composer-title {
    width: 100%;
    border: none;
    border-bottom: 2px solid transparent;
    background: transparent;
    padding: 0.1rem 0 0.25rem;
    font-family: var(--font-display);
    font-size: 1.4rem;
    font-weight: 700;
    letter-spacing: -0.025em;
    line-height: 1.3;
    color: var(--fg);
    transition: border-color var(--duration) var(--ease);
}

.composer-title::placeholder {
    color: var(--fg-3);
    opacity: 0.6;
}

.composer-title:hover:not(:focus) {
    border-bottom-color: var(--border-2);
}

.composer-title:focus {
    outline: none;
    border-bottom-color: var(--brand);
}

.composer-error {
    padding: 0.55rem 0.85rem;
    font-size: 0.85rem;
}

/* The headline of a card you may read and not change. Same face, same size,
   same place — it is the card's title either way — but with none of the field
   affordances above, because there is nothing here to type into. */
.composer-title-static {
    margin: 0;
    padding: 0.1rem 0 0.25rem;
    border-bottom: 2px solid transparent;
}

.composer-desc {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
}

/* — A board's settings: which of the four pages — */

/* The segmented control the card dialog and the calendar already switch with,
   as links: one row under the page's heading, the page on screen filled. It
   shipped with no rule at all (#193), and four bare blue underlined links under
   a display heading read as a page that had not finished loading. */
.settings-tabs {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 2px;
    margin: 0 0 1.25rem;
    padding: 3px;
    background: var(--bg-1);
    border-radius: var(--radius-pill);
    box-shadow: var(--shadow-xs);
}

.settings-tab {
    border-radius: var(--radius-pill);
    padding: 0.36rem 1rem;
    font-family: var(--font-body);
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--fg-3);
    text-decoration: none;
    transition: color var(--duration) var(--ease), background var(--duration) var(--ease);
}

.settings-tab:hover:not(.settings-tab-active) {
    color: var(--fg);
}

.settings-tab:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
}

.settings-tab-active {
    background: var(--brand);
    color: var(--brand-contrast);
    box-shadow: var(--shadow-xs);
}

/* — Write / Preview switch — */

.segmented {
    margin-left: auto;
    display: inline-flex;
    gap: 2px;
    padding: 3px;
    background: var(--bg-1);
    border-radius: var(--radius-pill);
    box-shadow: var(--shadow-xs);
}

.segmented-tab {
    border: none;
    background: transparent;
    border-radius: var(--radius-pill);
    padding: 0.28rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--fg-3);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.segmented-tab:hover:not(.segmented-tab-on) {
    color: var(--fg);
}

.segmented-tab-on {
    background: var(--brand);
    color: var(--brand-contrast);
    box-shadow: var(--shadow-xs);
}

/* The calendar's range control sits beside the view control rather than after
   everything else, so it gives up the auto margin the segmented control uses
   where it is the only thing on its row. Quieter too — which view you are in is
   the primary choice, how wide it reaches is a setting within it. */
.segmented-scale {
    margin-left: 0;
    background: var(--bg-2);
}

.segmented-scale .segmented-tab {
    padding: 0.28rem 0.65rem;
    font-size: 0.76rem;
}

/* Both tabs render on the same footprint, so flipping between them never
   resizes the dialog under the pointer. */
.composer-panel {
    display: flex;
    flex-direction: column;
    min-height: 12rem;
}

.composer-body,
.composer-preview,
.composer-empty {
    flex: 1;
    border-radius: var(--radius-xs);
    background: var(--bg-1);
    padding: 0.75rem 0.9rem;
    min-height: 12rem;
}

.composer-body {
    width: 100%;
    border: 1px solid var(--border-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    line-height: 1.65;
    color: var(--fg);
    resize: vertical;
    transition: all var(--duration) var(--ease);
}

.composer-body:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

.composer-body::placeholder {
    color: var(--fg-3);
    opacity: 0.7;
}

.composer-preview {
    border: 1px solid transparent;
    overflow-wrap: anywhere;
}

.composer-empty {
    margin: 0;
    border: 1px dashed var(--border-2);
    color: var(--fg-3);
    font-size: 0.88rem;
}

/* — Details: tags and assignees, two up — */

.card-details {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
    gap: 0.75rem;
}

.detail-panel {
    display: flex;
    flex-direction: column;
    gap: 0.65rem;
    align-content: start;
    padding: 0.9rem 1rem 1rem;
    background: var(--bg-2);
    border-radius: var(--radius-sm);
}

/* — Discussion — */

/* The rule separates the card from the talk about it; everything above it is
   the card itself, everything below is other people. */
.discussion {
    display: flex;
    flex-direction: column;
    gap: 0.7rem;
    padding-top: 1.1rem;
    border-top: 1px solid var(--border-2);
}

/* ── Markdown ──────────────────────────────────────────── */

.markdown {
    font-size: 0.93rem;
    line-height: 1.7;
    color: var(--fg);
}

.markdown p { margin: 0 0 0.5rem; }
.markdown p:last-child { margin-bottom: 0; }
.markdown ul, .markdown ol { padding-left: 1.25rem; margin: 0 0 0.5rem; }

.markdown code {
    font-family: var(--font-mono);
    background: var(--bg-2);
    padding: 0.15rem 0.45rem;
    border-radius: 6px;
    font-size: 0.82em;
    border: none;
}

.markdown pre {
    background: var(--bg-2);
    padding: 1rem;
    border-radius: var(--radius-xs);
    overflow-x: auto;
    border: none;
}

.markdown pre code {
    background: transparent;
    padding: 0;
}

.markdown a {
    color: var(--brand);
    text-decoration: underline;
    text-decoration-color: color-mix(in srgb, var(--brand) 35%, transparent);
    text-underline-offset: 2px;
}

.markdown a:hover {
    text-decoration-color: var(--brand);
}

/* ── Sharing: the button, and what a shared link renders as ─ */

/* One button on several surfaces, so it carries no layout of its own beyond
   being a round icon target — the modal header and the nav menu each place it.
   Neutral rather than brand-tinted: sharing is a thing you do to something on
   screen, not something the app is offering you. */
.share-button {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    background: none;
    border: none;
    color: var(--fg-3);
    cursor: pointer;
    padding: 0.5rem;
    border-radius: 50%;
    font: inherit;
    font-family: var(--font-body);
    transition: all var(--duration) var(--ease);
}

.share-button:hover:not(:disabled) {
    background: var(--bg-2);
    color: var(--fg);
}

.share-button:disabled {
    opacity: 0.55;
    cursor: default;
}

.share-button svg {
    flex-shrink: 0;
}

/* In the card dialog it sits beside the close button, so the heading has to
   stop claiming the whole row — the header is a space-between flex box and a
   third child would otherwise be spread across the middle of it. */
.modal-header h2 {
    margin-right: auto;
}

/* In the nav menu it is one of the menu's rows and takes their shape from
   `.nav-menu .nav-menu-row` — the only thing left to say here is that it is not
   the small square icon button the card dialog's header wants. */
.nav-menu-share {
    justify-content: flex-start;
}

/* ── Link cards: a Ponente link, drawn as the thing it points at ── */

/* The counterpart of the share button: what a link handed to somebody lands as
   when they paste it back into a comment or a description. It is deliberately
   the board's own card — same surface, same radius, same shadow — because that
   is what it is a picture of. */
.link-card {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    background: var(--bg-1);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-card);
    padding: 0.7rem 0.85rem;
    margin: 0.5rem 0;
    text-decoration: none;
    color: var(--fg);
    transition: box-shadow var(--duration) var(--ease), transform var(--duration) var(--ease);
    /* An anchor in a flex column still has to be told not to stretch its text. */
    align-items: stretch;
}

.link-card:hover {
    box-shadow: var(--shadow-card-hover);
    transform: translateY(-1px);
    text-decoration: none;
}

/* Where the thing lives, in the small type a breadcrumb is read in: the board
   and then the column, which together are the whole of "where is this". */
.link-card-kind {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--fg-3);
    min-width: 0;
}

.link-card-board,
.link-card-column {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.link-card-sep {
    flex-shrink: 0;
    opacity: 0.7;
}

.link-card-title {
    font-family: var(--font-display);
    font-size: 0.98rem;
    font-weight: 600;
    line-height: 1.3;
    color: var(--fg);
}

/* One line, clamped — the link card is a glance, and the card itself is one
   click away for anybody who wants the rest. */
.link-card-excerpt {
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--fg-2);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.link-card-meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin-top: 0.15rem;
}

.link-card-count {
    font-size: 0.78rem;
    color: var(--fg-3);
}

/* The fallback: a link we could not resolve — no such card, or a board this
   reader was not given — is the link, unadorned. */
.link-card-plain {
    margin: 0.5rem 0;
    word-break: break-word;
}

.link-card-plain a {
    color: var(--brand);
    text-decoration: underline;
    text-decoration-color: color-mix(in srgb, var(--brand) 35%, transparent);
    text-underline-offset: 2px;
}

/* ── SortableJS drag states ────────────────────────────── */

.card-ghost {
    opacity: 0.25;
    background: var(--brand-surface);
    box-shadow: none;
}

.card-chosen {
    cursor: grabbing;
    box-shadow: var(--shadow-lg);
}

/* The card has been picked up and has not moved yet, and on a finger that is a
   state worth seeing: the pick-up is now the end of a 200ms press rather than
   the moment the finger lands (`delay`, in sortable-interop.js), so this is the
   only thing that answers "am I holding this or about to scroll past it" — and
   it has to answer before the card has travelled a pixel. The lift is `scale`
   rather than a `transform` because `.card:hover` and `.card:active` already
   own that property, and it inherits `.card`'s `transition: all`, so it grows
   in over the same duration as everything else on the board. A finger's
   `.card:active` presses on `scale` too (see `.card`): that rule is earlier
   in the file on purpose, so a held press ends here, grown, not shrunk.
   `:not(.card-ghost)` because SortableJS strips `chosenClass` from the clone in
   flight but not from the element left behind: the hole in the list is the one
   place this is wrong, since a gap wider than the card that will fill it reads
   as the column shifting under the drop. */
.card-chosen:not(.card-ghost) {
    scale: 1.03;
}

/* On a touch device there is no native drag image: this element is a clone the
   library moves under the finger by writing a `transform` on every touchmove.
   `.card`'s `transition: all` turned each of those writes into a 200ms
   animation — and SortableJS reads the element's *current* matrix back and adds
   the frame's delta to it, so the lag compounds instead of catching up and the
   card crawls after the finger while the drop lands correctly (the drop is
   computed from the touch point, not from the clone). The transition has to go,
   and it has to go from the transform in particular; the box-shadow and rotate
   below are set once when the class lands, so nothing is lost.

   `will-change` for the other half of the same fact: a transform rewritten
   every frame on an element that is not on its own compositor layer is a
   repaint every frame, and this one carries two soft shadows to re-raster with
   it. Promoting it moved a measured 26–30% of the drag's paint time off the
   main thread. The usual warning about `will-change` does not apply here — the
   class exists only while a card is actually in the air. */
.card-dragging {
    box-shadow: var(--shadow-brand), var(--shadow-lg);
    z-index: 10;
    rotate: 2.5deg;
    transition: none;
    will-change: transform;
}

/* ── Tag chips — solid, pill-shaped ──────────────────── */

/* Lives in .card-meta now (tags lead the row, faces and the count follow),
   so the row spacing is the meta row's — no margin of its own. */
.card-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}

.tag-chip {
    --tag-color: #888;
    display: inline-flex;
    align-items: center;
    padding: 0.22rem 0.65rem;
    border-radius: var(--radius-pill);
    border: none;
    background: var(--tag-color);
    color: #fff;
    font-size: 0.72rem;
    font-weight: 600;
    cursor: pointer;
    line-height: 1.3;
    transition: all var(--duration) var(--ease);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
    letter-spacing: 0.02em;
}

.tag-chip-static { cursor: default; }

.tag-picker {
    display: flex;
    flex-wrap: wrap;
    gap: 0.45rem;
}

.tag-chip:not(.tag-chip-selected):not(.tag-chip-static) {
    background: color-mix(in srgb, var(--tag-color) 15%, transparent);
    /* Tag colors are user-picked and often mid-bright: ink them down on
       light paper, use them straight on dark. */
    color: light-dark(color-mix(in srgb, var(--tag-color) 80%, #2c2418), var(--tag-color));
    text-shadow: none;
    border: 1px solid color-mix(in srgb, var(--tag-color) 25%, transparent);
}

.tag-chip-selected {
    background: var(--tag-color);
    color: #fff;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
    border: none;
}

/* Picked vs. not-picked was carried by fill alone, which in a row of
   user-chosen tag colours reads as two more colours. The tick says which is
   which. Cards get no tick — their chips are labels, not choices, and the
   filter flyout's are choices again. The assignee menu's rows are choices
   too; the chips in the panel above it are not, since every one of them is
   on the card, and they carry a × to take off instead. */
.tag-picker .tag-chip-selected::before,
.column-filter-tags .tag-chip-selected::before,
.calendar-legend .tag-chip-selected::before,
.assignee-option-on::after {
    content: "";
    display: inline-block;
    flex: 0 0 auto;
    width: 0.85em;
    height: 0.85em;
    background: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round' d='M3 8.5 6.5 12 13 4.5'/%3E%3C/svg%3E") no-repeat center / contain;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round' d='M3 8.5 6.5 12 13 4.5'/%3E%3C/svg%3E") no-repeat center / contain;
}

.tag-picker .tag-chip-selected::before,
.column-filter-tags .tag-chip-selected::before,
.calendar-legend .tag-chip-selected::before {
    margin-right: 0.35em;
    vertical-align: -0.05em;
}

.assignee-option-on::after {
    background: var(--brand);
    width: 0.95rem;
    height: 0.95rem;
    margin-left: auto;
}

.tag-picker-empty {
    margin: 0;
    color: var(--fg-3);
    font-size: 0.88rem;
}

/* ── Assignees ─────────────────────────────────────────── */

.card-meta {
    display: flex;
    align-items: center;
    margin-top: 0.6rem;
    gap: 0.4rem;
    /* Tags share this row now; a tag-heavy card wraps the row rather than
       squeezing the faces out of it. */
    flex-wrap: wrap;
}

/* A stack, not a list: the faces overlap, and the 2px --bg-1 ring every
   .assignee-avatar already wears is what keeps each one legible against the
   one beneath. DOM order puts the later face on top; CardView caps the stack
   at three and folds the rest into .assignee-overflow. */
.card-assignees {
    display: flex;
}

.card-assignees .assignee-avatar:not(:first-child) {
    margin-left: -0.45rem;
}

/* Always trails the row. Under `space-between` the badge sat hard left on a
   card with no assignees and hard right on one with them, so the same badge
   changed sides card to card. */
.card-meta .card-comment-count {
    margin-left: auto;
}

.card-comment-count {
    font-size: 0.78rem;
    color: var(--fg-3);
    background: var(--bg-2);
    padding: 0.18rem 0.55rem;
    border-radius: var(--radius-pill);
    border: none;
    white-space: nowrap;
    box-shadow: var(--shadow-xs);
}

/* Speech-bubble glyph drawn with the same currentColor-mask technique as
   the wind doodles — crisp and identical on every platform, unlike emoji. */
.card-comment-count::before {
    content: "";
    display: inline-block;
    width: 0.85em;
    height: 0.85em;
    margin-right: 0.35em;
    vertical-align: -0.1em;
    background: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='black' stroke-width='1.8' stroke-linejoin='round' d='M14 7.4c0 2.9-2.7 5.2-6 5.2-.7 0-1.4-.1-2-.3L2.6 13.5l.9-2.6C2.6 10 2 8.8 2 7.4 2 4.5 4.7 2.2 8 2.2s6 2.3 6 5.2Z'/%3E%3C/svg%3E") no-repeat center / contain;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='black' stroke-width='1.8' stroke-linejoin='round' d='M14 7.4c0 2.9-2.7 5.2-6 5.2-.7 0-1.4-.1-2-.3L2.6 13.5l.9-2.6C2.6 10 2 8.8 2 7.4 2 4.5 4.7 2.2 8 2.2s6 2.3 6 5.2Z'/%3E%3C/svg%3E") no-repeat center / contain;
}

.assignee-avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.7rem;
    height: 1.7rem;
    border-radius: 50%;
    background: var(--brand-surface);
    color: var(--brand);
    font-size: 0.65rem;
    font-weight: 700;
    border: 2px solid var(--bg-1);
    box-shadow: 0 0 0 1px var(--border-1);
}

.assignee-avatar-img {
    object-fit: cover;
    background: var(--bg-2);
    padding: 0;
}

/* The "+N" bubble closing a card's face stack. After the .assignee-avatar
   base on purpose: same specificity, and the base's brand-surface fill would
   win from below. Brand fill takes the contrast token for ink — dark-theme
   fills are light and take ink, not white. */
.assignee-overflow {
    background: var(--brand);
    color: var(--brand-contrast);
}

/* The card's own people and a way to add one — never the membership. The
   panel used to be every member as a chip, which grew with the deployment
   rather than with the card; see AssigneePicker. */
.assignee-picker {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.45rem;
}

.assignee-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    padding: 0.3rem 0.6rem 0.3rem 0.3rem;
    border-radius: var(--radius-pill);
    border: 1px solid var(--border-2);
    background: var(--bg-0);
    color: var(--fg);
    font: inherit;
    font-family: var(--font-body);
    cursor: pointer;
    font-size: 0.82rem;
    transition: all var(--duration) var(--ease);
}

.assignee-chip:hover {
    border-color: var(--brand);
    background: var(--brand-glow);
    box-shadow: var(--shadow-xs);
}

/* What pressing the chip does, said by the one mark that changes colour. */
.assignee-chip-x {
    color: var(--fg-3);
    font-size: 1rem;
    line-height: 1;
    transition: color var(--duration) var(--ease);
}

.assignee-chip:hover .assignee-chip-x {
    color: var(--danger);
}

/* The picker's chip with nothing to press — a label, like .tag-chip-static. */
.assignee-chip-static {
    cursor: default;
    background: var(--bg-2);
}

.assignee-chip-static:hover {
    background: var(--bg-2);
    border-color: var(--border-2);
    box-shadow: none;
}

.assignee-chip .assignee-name {
    color: var(--fg);
    font-weight: 500;
}

/* "+ Assign…" and, on somebody else's card, "+ Assign me". Dashed, because it
   is a place a chip could go rather than a chip; stretched to the row, so it
   stands as tall as the chips beside it. */
.assignee-add {
    display: inline-flex;
    align-items: center;
    align-self: stretch;
    gap: 0.3rem;
    padding: 0.3rem 0.85rem;
    border-radius: var(--radius-pill);
    border: 1.5px dashed var(--brand);
    background: transparent;
    color: var(--brand);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 600;
    cursor: pointer;
    transition: background var(--duration) var(--ease);
}

/* A glyph in the markup rather than a ::before, like the chips' ×: the canvas
   tracer (design/trace) reads text from elements, and a frame without the
   plus would be a spec the app does not match. */
.assignee-add-plus {
    font-size: 1rem;
    line-height: 1;
}

.assignee-add:hover,
.assignee-add[aria-expanded="true"] {
    background: var(--brand-glow);
    border-style: solid;
}

/* The menu hangs off the panel rather than off the button: the button moves
   along the row as chips are added, and a menu that followed it would slide
   out past the dialog's edge. */
.detail-panel-assignees {
    position: relative;
}

/* Invisible, like the column filter's: a click anywhere else shuts the menu
   rather than doing whatever was under it. */
.assignee-menu-catcher {
    position: fixed;
    inset: 0;
    z-index: 20;
}

.assignee-menu {
    position: absolute;
    top: calc(100% - 0.5rem);
    left: 0.5rem;
    right: 0.5rem;
    z-index: 30;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    padding: 0.5rem;
    background: var(--bg-1);
    border: 1px solid var(--border-1);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-lg);
    animation: column-filter-in var(--duration) var(--ease) both;
}

.assignee-search {
    width: 100%;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.5rem 0.75rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.9rem;
    background: var(--bg-2);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.assignee-search::placeholder { color: var(--fg-3); }

.assignee-search:focus {
    outline: none;
    border-color: var(--brand);
    background: var(--bg-1);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

.assignee-options {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 1px;
}

.assignee-option {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    padding: 0.35rem 0.5rem;
    border: none;
    border-radius: var(--radius-xs);
    background: transparent;
    color: var(--fg);
    font: inherit;
    font-family: var(--font-body);
    text-align: left;
    cursor: pointer;
    transition: background var(--duration) var(--ease);
}

.assignee-option:hover { background: var(--bg-2); }

.assignee-option:focus-visible {
    outline: none;
    box-shadow: inset 0 0 0 2px var(--brand);
}

.assignee-option-on,
.assignee-option-on:hover {
    background: var(--brand-glow);
}

/* The name the app shows people by, with the handle under it — one person, one
   name, everywhere a member is picked. */
.assignee-option-text {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
}

.assignee-option-name,
.assignee-option-handle {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.assignee-option-name {
    font-size: 0.86rem;
    font-weight: 500;
}

.assignee-option-handle {
    font-size: 0.74rem;
    color: var(--fg-3);
}

.assignee-menu-note {
    margin: 0.15rem 0.5rem 0.1rem;
    font-size: 0.78rem;
    color: var(--fg-3);
}

/* ── Due dates on a card ──────────────────────────────── */

/* Sits in .card-meta ahead of the assignees. Same pill as the comment count,
   because it is the same kind of thing — a fact about the card, not a control.
   Overdue is the one state that earns colour: it is the only one that arrives
   without anyone touching the card. */
.card-due {
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--fg-2);
    background: var(--bg-2);
    padding: 0.18rem 0.55rem;
    border-radius: var(--radius-pill);
    white-space: nowrap;
    box-shadow: var(--shadow-xs);
}

.card-due::before {
    content: "";
    display: inline-block;
    width: 0.85em;
    height: 0.85em;
    margin-right: 0.35em;
    vertical-align: -0.1em;
    background: currentColor;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.2' y='3.2' width='11.6' height='10.6' rx='2'/%3E%3Cpath d='M2.2 6.4h11.6M5.6 2v2.4M10.4 2v2.4'/%3E%3C/g%3E%3C/svg%3E") no-repeat center / contain;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.2' y='3.2' width='11.6' height='10.6' rx='2'/%3E%3Cpath d='M2.2 6.4h11.6M5.6 2v2.4M10.4 2v2.4'/%3E%3C/g%3E%3C/svg%3E") no-repeat center / contain;
}

.card-due-overdue {
    color: var(--danger);
    background: var(--danger-bg);
}

/* — The due-date field in the card dialog — */

.due-picker {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.due-input {
    flex: 1 1 auto;
    min-width: 0;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.55rem 0.8rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    background: var(--bg-1);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.due-input:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

.due-clear {
    border: none;
    background: transparent;
    color: var(--fg-3);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 600;
    padding: 0.3rem 0.5rem;
    border-radius: var(--radius-btn);
    cursor: pointer;
    transition: color var(--duration) var(--ease);
}

.due-clear:hover { color: var(--danger); }

.due-none {
    font-size: 0.82rem;
    color: var(--fg-3);
}

/* — Moving a card to another board — */
/* Shaped like the due-date field above it and not like the footer's buttons:
   which board a card is on is one of the things a card *is*, so it is picked
   in the details row where its tags and its date are, rather than sitting next
   to Delete as a verdict on the card. */

.move-picker {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.move-select {
    flex: 1 1 8rem;
    min-width: 0;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.55rem 0.8rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    background: var(--bg-1);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.move-select:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

.move-go {
    border: 1px solid var(--border-2);
    background: var(--bg-1);
    border-radius: var(--radius-pill);
    padding: 0.45rem 1rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--fg-2);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.move-go:hover:not(:disabled) {
    border-color: var(--border-3);
    background: var(--bg-2);
}

.move-go:disabled {
    opacity: 0.35;
    cursor: not-allowed;
}

/* What the move would do, said in the panel rather than in a dialog over it:
   it is a sentence to read before pressing, not a decision to be interrupted
   for. The line about what will not come along is its own, and coloured, since
   it is the only part of it that loses something. */
.move-note {
    margin: 0;
    font-size: 0.82rem;
    line-height: 1.45;
    color: var(--fg-3);
}

.move-note-drops {
    display: block;
    color: var(--danger);
}

/* ── Calendar (the board read by date) ────────────────── */

.calendar-page {
    /* The chip row height the day cell fits whole copies of. Read by
       .calendar-day-cards' auto-fill track, so the two cannot drift. */
    --cal-chip-h: 1.4rem;
    --cal-chip-gap: 0.15rem;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    padding: 1.25rem 1.75rem 1.25rem;
    /* Wider than the reading measure the rest of the app is held to: this is a
       grid of seven columns and a lane chart, and both answer their question by
       how much of the month you can take in at once. Still capped, because a
       day cell on an ultrawide would otherwise become a panel you have to scan
       rather than a square you can see. */
    max-width: 1800px;
    margin: 0 auto;
    /* Definite height — whatever the toolbar leaves is what the month gets, and
       the page itself never gains a scrollbar. A calendar you have to scroll to
       reach the end of the month is a calendar that has stopped being one view
       of it. This is the fallback; the rule below replaces it with a height
       that is *derived* rather than guessed. */
    height: calc(100vh - 64px);
    height: calc(100dvh - 64px);
    overflow: hidden;
}

/* The page takes what the header left, measured rather than subtracted.
   `calc(100dvh - 64px)` above encodes the header's height as a constant, and
   the two only agree if every one of them is right: the header really is 64px
   (56 on a phone — a second constant), and `dvh` resolves to what the page is
   actually painted into. It fits with *no slack*, so a browser that disagrees by
   a pixel — Firefox's responsive-design mode, at the time of writing — paints
   the last row of the month under the bottom edge with nothing to scroll to.
   Body is a flex column here and the page is its flexible child, so the height
   is whatever is left over by construction and there is no number to be wrong.
   Scoped with :has() so no other page's layout changes; a browser without it
   keeps the calc above, which is what shipped before this. */
html:has(.calendar-page) { height: 100%; }

body:has(.calendar-page) {
    height: 100%;
    display: flex;
    flex-direction: column;
    /* Every other child is position:fixed or display:none, so nothing else
       becomes a flex item that was not one before. */
    overflow: hidden;
}

body:has(.calendar-page) > .calendar-page {
    height: auto;
    flex: 1;
    min-height: 0;
    /* Both floors have to go. A flex item's min-width is `auto`, i.e. its
       content — so the timeline's computed min-width (a month of columns is
       wider than a laptop) pushed the page itself past the viewport instead of
       scrolling inside .calendar-timeline-scroll, and body's overflow:hidden
       then clipped the toolbar's last button with nothing to scroll to. As a
       block element before this, the page took the container's width and the
       overflow stayed where it belonged. */
    min-width: 0;
    width: 100%;
}

/* Everything the toolbar left. `min-height: 0` is the load-bearing half: a flex
   item's floor is its content, so without it the grid pushes the page taller
   instead of dividing what it was given. */
.calendar-body {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
}

.calendar-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
    /* The chrome keeps its own height and the month takes the rest; a toolbar
       allowed to shrink would give its second row back to the grid and wrap. */
    flex: 0 0 auto;
}

.calendar-nav {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

/* The heading holds its width so the arrows either side of it do not shuffle as
   the month name changes — and so "All dates" and "Jul – Sep 2026" put the next
   control in the same place. */
.calendar-step:disabled {
    opacity: 0.35;
    cursor: default;
    box-shadow: none;
}

.calendar-step:disabled:hover { color: var(--fg-2); }


.calendar-heading {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.4rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--fg);
    /* The month name changes width as you page through the year; a fixed
       minimum keeps the two arrows either side of it from shuffling. */
    min-width: 10.5rem;
    text-align: center;
}

.calendar-step {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.1rem;
    height: 2.1rem;
    border: none;
    border-radius: var(--radius-pill);
    background: var(--bg-1);
    color: var(--fg-2);
    cursor: pointer;
    box-shadow: var(--shadow-xs);
    transition: all var(--duration) var(--ease);
}

.calendar-step svg { width: 16px; height: 16px; }

.calendar-step:hover {
    color: var(--brand);
    box-shadow: var(--shadow-sm);
}

.calendar-today,
.calendar-legend-clear {
    border: none;
    background: transparent;
    color: var(--brand);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.84rem;
    font-weight: 600;
    padding: 0.3rem 0.6rem;
    border-radius: var(--radius-btn);
    cursor: pointer;
}

.calendar-today:hover,
.calendar-legend-clear:hover { background: var(--brand-glow); }

.calendar-actions {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

/* The two ways out with the dates. They share a look because they are the same
   kind of offer, but not a class name — .calendar-export identifies the
   download, and a selector that matched both would be one no test could use to
   mean either. */
.calendar-export,
.calendar-subscribe {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    text-decoration: none;
    border: none;
    border-radius: var(--radius-pill);
    padding: 0.4rem 0.9rem;
    background: var(--bg-1);
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 600;
    cursor: pointer;
    /* "Export .ics" is one label with a space in it; left to wrap it becomes two
       lines and makes the whole toolbar row taller on a phone. */
    white-space: nowrap;
    box-shadow: var(--shadow-xs);
    transition: all var(--duration) var(--ease);
}

.calendar-export svg,
.calendar-subscribe svg { width: 15px; height: 15px; flex: 0 0 auto; }

.calendar-export:hover,
.calendar-subscribe:hover {
    color: var(--brand);
    box-shadow: var(--shadow-sm);
}

/* Legend and filter are one control: what the colours mean is also what you
   narrow by, so there is nothing to keep in step between them. */
/* The legend and the count on one row — see the note in Calendar.razor. */
.calendar-meta {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex: 0 0 auto;
    min-height: 1.5rem;
}

.calendar-legend {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.45rem;
    min-width: 0;
}

/* Phone only — the legend is an inline row at every other width, so there is
   nothing here to collapse or to open. */
.calendar-filter-toggle,
.calendar-filter-backdrop { display: none; }

.calendar-summary {
    margin: 0;
    margin-left: auto;
    font-size: 0.85rem;
    color: var(--fg-3);
    flex: 0 0 auto;
    white-space: nowrap;
}

/* Both classes, and it has to be both. `.empty-state` sets
   `min-height: calc(100vh - 64px)` and `padding: 2rem`, and it is declared
   *later* in this file — at equal specificity that wins, so a lone
   `.calendar-empty` rule silently lost its `min-height` and its padding. The
   box was then taller than the area it sits in and its centred contents were
   centred against that overflow, which is why the message sat well below the
   middle of the page while measuring as perfectly centred in its own box.
   In here the viewport has already had a header, a toolbar and a legend taken
   out of it, so the only right height is what the flex parent gives. */
.empty-state.calendar-empty {
    flex: 1;
    min-height: 0;
    justify-content: center;
    /* Lifted off geometric centre. For a block that is a small mark over two
       lines of text the eye takes the *heading* for the middle, and the hint
       under it drags the apparent centre down. Taken off the bottom rather than
       added to the top so the block still centres when there is nothing spare,
       and capped in vh so a short viewport cannot be pushed into overflow. */
    /* The lift is the *difference* between the two, halved — a matching top
       padding would cancel it exactly, which is most of why the first attempt
       moved almost nothing. */
    padding: 1rem 2rem min(6rem, 14vh);
}

/* — Month grid — */

/* A grid, not a flex column, and that is the fix for the last row being cut off.
   Six flex children each taking `flex: 1` of a container are sized
   independently and each rounded to the device pixel, so the six rounded
   heights can add up to a pixel or two *more* than the container they came out
   of — which `overflow-y: auto` then turned into a scrollbar and a last week
   sliced along the bottom edge. Grid divides one track list across one known
   height instead, so the rows fit by construction whatever the rounding.
   `minmax(0, 1fr)` and not `1fr`: a track's floor is its content, and a day
   cell holding four chips would otherwise push its row past its share.

   `overflow: clip` with a **clip margin**, not `hidden`, and that is the fix
   for the last row looking shaved. A day cell paints outside its own box:
   `--shadow-card` is `0 2px 8px`, so ~10px below it, and today's cell adds a
   `0 0 0 2px` ring. `hidden` clips at the padding edge, which took the bottom
   two pixels off the last week's ring and the whole of its shadow — the cells
   still measured as fitting perfectly, because nothing had overflowed: it was
   the paint that was cut, not the layout, which is why every geometric check
   came back clean while it plainly looked cut off. `clip` is still a backstop
   against a window too short for .calendar-week's floor; the margin just lets
   the decoration finish. `hidden` ignores `overflow-clip-margin` entirely, so
   the two properties have to change together. */
.calendar-grid {
    display: grid;
    grid-template-rows: auto repeat(var(--weeks, 6), minmax(0, 1fr));
    gap: 0.35rem;
    flex: 1;
    min-height: 0;
    overflow: clip;
    overflow-clip-margin: 12px;
}

.calendar-weekdays,
.calendar-week {
    display: grid;
    grid-template-columns: repeat(7, minmax(0, 1fr));
    gap: 0.35rem;
}

.calendar-weekdays { flex: 0 0 auto; }

/* Each week is one row of the grid's track list, so a five-row month uses the
   same space a six-row one does instead of leaving a band of empty page. The
   floor is what stops a short viewport squashing cells to a colour band — and
   it is deliberately low. At 5.25rem a six-week month overflowed a 760px window
   by 25px and the last row sat half-drawn along the bottom edge. The cell adapts
   to whatever it gets (see .calendar-day-cards), so the floor only has to keep
   the day number and its count legible; it is not where the chips' room comes
   from. */
.calendar-week {
    /* Low on purpose, and lower than it looks like it needs to be. `1fr` tracks
       cannot shrink past this, so if six of them plus the weekday row exceed
       what the grid was given, the grid overflows — and since it clips rather
       than scrolls, the last week would go missing with nothing to say so. That
       is reachable without a small window: browser zoom shrinks the viewport in
       CSS pixels while this floor stays the same number of them, so 175% zoom
       on an ordinary laptop is enough. At 2rem it takes a viewport no one has.
       The cell adapts to whatever height it gets (see .calendar-day-cards), so
       this only has to keep the day number and its count legible. */
    min-height: 2rem;
}

.calendar-weekday {
    padding: 0.2rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--fg-3);
}

.calendar-weekend { color: var(--brand); }

.calendar-day {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    /* No min-height: the cell is exactly its share of the week's row now, and a
       floor here would fight the one on .calendar-week and win. */
    min-height: 0;
    padding: 0.45rem;
    border-radius: var(--radius-sm);
    background: var(--bg-1);
    box-shadow: var(--shadow-card);
    /* A day that got more cards than its share holds clips them rather than
       stretching its row — .calendar-day-more is how the rest is reached. */
    overflow: hidden;
}

/* Chips render whole or not at all.
   `repeat(auto-fill, …)` creates only as many *complete* rows as the cell's
   height allows, and every chip past them lands in an implicit row — which
   `grid-auto-rows: 0` collapses to nothing. So a cell one chip short of the
   next row shows one chip fewer, instead of slicing the last one through the
   middle of its text, and no number has to be guessed on the server for it.
   The day's own count sits beside the day number and stays true either way. */
.calendar-day-cards {
    display: grid;
    grid-template-rows: repeat(auto-fill, var(--cal-chip-h));
    grid-auto-rows: 0;
    row-gap: var(--cal-chip-gap);
    align-content: start;
    flex: 1;
    min-height: 0;
    overflow: hidden;
}

.calendar-day-cards .cal-card {
    /* The chip is exactly its row: height from the track, and *no vertical
       padding*, which is the part that matters. A chip in a collapsed 0px row
       still drew its own 4px top and bottom padding — 8px of coloured bar with
       a sliver of text in it, which is the sliced chip this was meant to
       prevent. Vertical centring comes from the flex box instead. */
    min-height: 0;
    height: 100%;
    padding-block: 0;
    display: flex;
    align-items: center;
}

.calendar-day-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.25rem;
    flex: 0 0 auto;
}

/* What the cell could not say by drawing it. Always present when the day has
   cards, because "how many fit" is a CSS answer that changes with the window
   and "how many there are" is the only number that is true at every height. */
.calendar-day-count {
    font-size: 0.68rem;
    font-weight: 700;
    line-height: 1;
    padding: 0.15rem 0.35rem;
    border-radius: var(--radius-pill);
    background: var(--bg-2);
    color: var(--fg-3);
}

/* The cell is the way into the day — see the note in Calendar.razor. Only the
   ones with something to open say so. */
.calendar-day-open { cursor: pointer; }

.calendar-day-open:hover {
    box-shadow: var(--shadow-card), 0 0 0 1px var(--border-2);
}

.calendar-day-today.calendar-day-open:hover {
    box-shadow: var(--shadow-card), 0 0 0 2px var(--brand);
}

/* Phones only — see the phone block at the end of this section. */
.calendar-day-dots { display: none; }
.calendar-agenda { display: none; }

/* — One day in full, for the cards the cell could not fit — */

/* Centring is the overlay's job, not the panel's. `slideUp` animates
   `transform`, so a panel that centred itself with `translate(-50%, -50%)` had
   that translate replaced for the length of the animation — it grew out of the
   middle of the screen and snapped into place at the end. Flexbox centring
   leaves the panel's own transform free, which is how .modal-backdrop has
   always done it.
   Below the card dialog's 1000: opening a card from here replaces the sheet
   rather than stacking on it, but the two exist together for the frame in which
   that swap happens. */
.calendar-overlay {
    position: fixed;
    inset: 0;
    background: rgba(25, 21, 19, 0.45);
    z-index: 900;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    animation: fadeIn var(--duration) var(--ease-out);
}

.calendar-day-sheet {
    width: min(380px, 100%);
    max-height: min(70vh, 32rem);
    display: flex;
    flex-direction: column;
    background: var(--bg-1);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lg);
    padding: 1rem;
    gap: 0.75rem;
    animation: slideUp var(--duration) var(--ease-out);
}

.calendar-day-sheet-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    flex: 0 0 auto;
}

.calendar-day-sheet-head h3 {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1rem;
    font-weight: 700;
    color: var(--fg);
}

.calendar-day-sheet-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.9rem;
    height: 1.9rem;
    border: none;
    border-radius: var(--radius-pill);
    background: var(--bg-2);
    color: var(--fg-2);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.calendar-day-sheet-close svg { width: 14px; height: 14px; }
.calendar-day-sheet-close:hover { color: var(--brand); }

/* The whole point of the sheet is that nothing here is hidden, so this is the
   one place a list of chips is allowed to scroll. */
.calendar-day-sheet-cards {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--border-2) transparent;
}

/* Room to read, unlike the one-line cut a day cell forces. */
.calendar-day-sheet-cards .cal-card {
    white-space: normal;
    font-size: 0.85rem;
    padding: 0.45rem 0.6rem;
}

/* — The subscription link — */

.calendar-subscribe-panel {
    /* Centred by .calendar-overlay — see the note there for why not a transform. */
    width: min(460px, 100%);
    max-height: calc(100dvh - 2rem);
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    background: var(--bg-1);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lg);
    padding: 1.25rem;
    animation: slideUp var(--duration) var(--ease-out);
}

.calendar-subscribe-blurb {
    margin: 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--fg-2);
}

/* What the link *is*, beside what to do with it. A chip rather than the tail
   of the sentence above: "read-only" is the one thing somebody wants settled
   before they paste a URL into the app that holds their week, and a clause
   four lines into a paragraph is not where that gets settled. */
.calendar-subscribe-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin: 0.6rem 0 0;
}

.calendar-subscribe-tag {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.2rem 0.6rem;
    border-radius: var(--radius-pill);
    background: var(--bg-2);
    color: var(--fg-2);
    font-size: 0.75rem;
    font-weight: 600;
}

.calendar-subscribe-tag svg {
    width: 13px;
    height: 13px;
    flex: 0 0 auto;
    color: var(--fg-3);
}

.calendar-subscribe-loading {
    display: flex;
    justify-content: center;
    padding: 1.5rem 0;
}

/* Monospace and small: this is a string to be recognised and copied, not read.
   `text-overflow` rather than wrapping keeps the panel one predictable height. */
.calendar-subscribe-url {
    width: 100%;
    font-family: var(--font-mono, ui-monospace, monospace);
    font-size: 0.75rem;
    padding: 0.55rem 0.65rem;
    border: 1px solid var(--border-1);
    border-radius: var(--radius-sm);
    background: var(--bg-2);
    color: var(--fg-2);
    text-overflow: ellipsis;
}

.calendar-subscribe-url:focus {
    outline: 2px solid var(--brand);
    outline-offset: 1px;
}

.calendar-subscribe-actions {
    display: flex;
    align-items: stretch;
    gap: 0.6rem;
}

/* One size for both, set here rather than inherited from .btn-primary: they are
   the same offer made two ways, and a full-size pill next to a smaller
   secondary read as two unrelated controls that happened to land side by side.
   `align-items: stretch` on the row is the other half — a link and a button
   with the same padding still differ by a hair of line-box. */
.calendar-subscribe-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: none;
    text-decoration: none;
    border-radius: var(--radius-pill);
    padding: 0.5rem 1.1rem;
    background: var(--bg-2);
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.85rem;
    font-weight: 600;
    line-height: 1.2;
    cursor: pointer;
    white-space: nowrap;
    transition: all var(--duration) var(--ease);
}

.calendar-subscribe-action-primary {
    background: var(--brand);
    color: var(--brand-contrast);
}

.calendar-subscribe-action:hover { color: var(--brand); }
.calendar-subscribe-action-primary:hover {
    color: var(--brand-contrast);
    filter: brightness(1.08);
}

/* Not .modal-error's red — nothing has gone wrong. It is a standing property of
   the link, and it has to be read before the link is pasted anywhere. */
.calendar-subscribe-warning {
    margin: 0;
    font-size: 0.78rem;
    line-height: 1.45;
    color: var(--fg-3);
    padding: 0.5rem 0.65rem;
    border-radius: var(--radius-sm);
    background: var(--brand-glow);
}

.calendar-subscribe-regen {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    flex-wrap: wrap;
    border-top: 1px solid var(--border-1);
    padding-top: 0.75rem;
}

.calendar-subscribe-hint {
    font-size: 0.75rem;
    color: var(--fg-3);
}

/* The days either side of the month are context, not content — dimmed rather
   than dropped, because a grid with holes in it stops reading as weeks. */
.calendar-day-outside {
    background: var(--bg-2);
    box-shadow: none;
}

.calendar-day-outside .calendar-daynum { color: var(--fg-3); }

.calendar-day-today {
    box-shadow: var(--shadow-card), 0 0 0 2px var(--brand);
}

.calendar-daynum {
    font-size: 0.78rem;
    font-weight: 700;
    color: var(--fg-2);
}

/* — A card as one line in a day cell — */

.cal-card {
    --tag-color: var(--fg-3);
    display: block;
    width: 100%;
    text-align: left;
    border: none;
    border-left: 3px solid var(--tag-color);
    border-radius: var(--radius-xs);
    background: color-mix(in srgb, var(--tag-color) 12%, transparent);
    color: var(--fg);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.78rem;
    font-weight: 600;
    line-height: 1.35;
    padding: 0.25rem 0.4rem;
    cursor: pointer;
    /* One line, always: a day cell holds several of these and a wrapping title
       would push the rest of the week's rows apart. */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: all var(--duration) var(--ease);
}

.cal-card:hover {
    background: color-mix(in srgb, var(--tag-color) 22%, transparent);
}

.cal-card-overdue {
    border-left-color: var(--danger);
}

/* Which board a card came from, on the general calendar. Only rendered where a
   chip has a second line to give it — the day cell's chips are one line by
   design and CSS fits whole ones into whatever height the cell ended up with, so
   a second line there would be a chip sliced through the middle of its text. The
   colour already says which board; this is what says it in words. */
.cal-card-board {
    display: block;
    font-size: 0.7rem;
    font-weight: 600;
    color: var(--fg-2);
    overflow: hidden;
    text-overflow: ellipsis;
}

/* The sheet is a list, not a grid of cells, so its chips may wrap to hold it. */
.calendar-day-sheet-cards .cal-card { white-space: normal; }

/* — Timeline: one lane per category, days across — */

.calendar-timeline-scroll {
    flex: 1;
    min-height: 0;
    overflow: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--border-2) transparent;
    padding-bottom: 0.5rem;
}

.calendar-timeline {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    /* The whole range on one screen is the point of this view — where a card
       sits relative to the others is what it answers, and that is lost the
       moment half of it is off the edge. So a column is sized to fit a desktop
       and the block scrolls only when the viewport is narrower than that.
       Never `width: max-content`: with 1fr tracks that resolves every column to
       the widest chip's own width, which blew the month out to six screens. */
    min-width: calc(var(--label-w) + var(--cols) * var(--col-w));
    --label-w: 9.5rem;
    --col-w: 2.6rem;
}

/* A coarser axis has fewer columns and more to say in each, so the column grows
   with the unit rather than the range being crammed into a month's worth of
   hairlines. This is what makes a quarter and the whole board legible at all. */
.calendar-timeline-quarter { --col-w: 4.5rem; }
.calendar-timeline-all { --col-w: 5rem; }

.timeline-head,
.timeline-row {
    display: grid;
    grid-template-columns: var(--label-w) 1fr;
    gap: 0.5rem;
    align-items: start;
}

.timeline-days,
.timeline-track {
    display: grid;
    /* minmax(0, 1fr), not auto: a chip is a nowrap title, and a track allowed
       to size to its content lets one long card widen that day for every lane. */
    grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
    gap: 2px;
}

.timeline-day {
    text-align: center;
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--fg-3);
    padding: 0.15rem 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.timeline-day-today {
    color: var(--brand-contrast);
    background: var(--brand);
    border-radius: var(--radius-pill);
}

/* The month or year a run of columns belongs to. Only on the coarser scales —
   at month scale the page's own header already says which month this is. */
.timeline-head-groups .timeline-days { gap: 2px; }

.timeline-group {
    text-align: center;
    font-family: var(--font-display);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--fg-2);
    padding: 0.1rem 0 0.25rem;
    border-bottom: 1px solid var(--border-1);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.timeline-track {
    /* One row per sub-row the packer needed. Chips carry an explicit grid-row,
       so this only has to be tall enough to hold them. */
    grid-auto-rows: minmax(2.15rem, auto);
    min-height: 2.5rem;
    padding: 0.25rem 0;
    border-radius: var(--radius-xs);
    background:
        repeating-linear-gradient(to right,
            var(--border-1) 0 1px,
            transparent 1px calc(100% / var(--cols)));
}

.timeline-label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    /* Sticks to the left edge while the columns scroll past — a lane whose label
       has scrolled away is a row of chips with nothing to say what they are. */
    position: sticky;
    left: 0;
    z-index: 1;
    background: var(--bg-0);
    padding: 0.5rem 0.3rem;
    font-size: 0.82rem;
    font-weight: 700;
    color: var(--fg);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.timeline-label-empty { background: transparent; }

.timeline-swatch {
    flex: 0 0 auto;
    width: 0.7rem;
    height: 0.7rem;
    border-radius: var(--radius-pill);
    background: var(--tag-color);
}

/* A chip reaches past its own column so its title has somewhere to go — see
   CalendarTimeline.Pack for how far. The solid left border is what keeps that
   honest: the card is anchored there and the fill trails off to the right, so
   it reads as a label pinned to a date rather than as a bar covering a span.
   A due date is a point, and a chip that looked like five days would be a lie. */
.timeline-card {
    align-self: center;
    /* No horizontal margin — the track's 2px gap already separates the columns,
       and a margin on top of it rounded the last chip one pixel past the axis,
       which is a horizontal scrollbar across the whole view. */
    padding: 0.35rem 0.5rem;
    background: linear-gradient(
        to right,
        color-mix(in srgb, var(--tag-color) 20%, transparent),
        color-mix(in srgb, var(--tag-color) 7%, transparent));
}

.timeline-card:hover {
    background: linear-gradient(
        to right,
        color-mix(in srgb, var(--tag-color) 32%, transparent),
        color-mix(in srgb, var(--tag-color) 14%, transparent));
}

/* On the horizontal timeline the column *is* the date, so printing it again in
   the chip would be the axis said twice. The phone's vertical one has no axis
   above the chip to read it off, and shows it. */
.timeline-card-date { display: none; }

/* ── The calendar on a phone ───────────────────────────────
   Not the desktop layout made smaller. A day cell here is about 45px wide,
   which is narrower than the word "Wildcard" — a chip in one can only ever be a
   coloured smear with a couple of letters on it, and seven of those across is a
   wall of noise rather than a month. So the cell shows what a cell that size
   can actually carry: the date, how many cards are on it, and a dot per card in
   its category's colour. Tapping the cell opens the day in full, which is the
   same gesture the desktop has and the only one there is room for here.
   The toolbar goes with it: labels come off the export and subscribe buttons,
   leaving the icons, because two words each is most of a row on a 390px screen. */
@media (max-width: 599px) {
    .calendar-page {
        padding: 0.75rem 0.75rem 0.6rem;
        /* Whatever the bottom bar leaves — same reckoning as .board. */
        height: calc(100vh - var(--phone-nav-h));
        height: calc(100dvh - var(--phone-nav-h));
        gap: 0.5rem;
    }

    /* Heading and arrows on their own line, the controls under them, both
       centred — at 390px the two groups cannot share a row without one of them
       wrapping mid-control. */
    .calendar-toolbar {
        flex-direction: column;
        align-items: stretch;
        gap: 0.5rem;
    }

    .calendar-nav { justify-content: center; }

    /* Left, not centred. The view switch is the first choice on the page and
       reads as the start of a row; centred it floated between the heading above
       and the filter below with nothing to line up against.
       `justify-content` alone does not do it: `.segmented` carries
       `margin-left: auto` for the rows where it is the only control, and an auto
       margin on the first flex item swallows all the free space and pushes the
       whole group right whatever the justification says. */
    .calendar-actions {
        justify-content: flex-start;
        gap: 0.5rem;
    }

    .calendar-actions > .segmented { margin-left: 0; }

    /* Which view you are in is a choice about this page; the two exports are
       ways off it. Splitting them to opposite ends says which is which — and
       the auto margin on the first of the pair carries its neighbour along. */
    .calendar-actions > .calendar-export { margin-left: auto; }

    .calendar-heading {
        font-size: 1.1rem;
        min-width: 0;
        flex: 1;
    }

    /* Labels stay. An icon alone asks the reader to guess, and "Export" and
       "Subscribe" are the two things on this page that do something they cannot
       undo by looking — they are worth the width. Tightened instead. */
    .calendar-export,
    .calendar-subscribe {
        padding: 0.4rem 0.7rem;
        font-size: 0.76rem;
        gap: 0.3rem;
    }

    .calendar-export svg,
    .calendar-subscribe svg { width: 14px; height: 14px; }

    .segmented-tab { padding: 0.28rem 0.7rem; font-size: 0.76rem; }

    /* Range control and the filter button share this row — one line of chrome
       instead of three, and the tags themselves are behind the button. */
    .calendar-meta {
        flex-wrap: nowrap;
        gap: 0.5rem;
        justify-content: flex-start;
        overflow: visible;
    }

    .calendar-meta > .segmented-scale { flex: 0 0 auto; }

    /* A row of tag chips is the widest thing on this page and the least urgent
       — the same reasoning that folded the board's filter into its column
       header. Same pattern too: a button that carries how many are picked, and
       a transparent layer that dismisses on a click anywhere else. */
    .calendar-filter-toggle {
        display: inline-flex;
        align-items: center;
        gap: 0.35rem;
        flex: 0 0 auto;
        border: none;
        border-radius: var(--radius-pill);
        padding: 0.4rem 0.7rem;
        background: var(--bg-1);
        color: var(--fg-2);
        font: inherit;
        font-family: var(--font-body);
        font-size: 0.76rem;
        font-weight: 600;
        cursor: pointer;
        box-shadow: var(--shadow-xs);
        white-space: nowrap;
    }

    .calendar-filter-toggle svg { width: 14px; height: 14px; }

    .calendar-filter-toggle-on {
        background: var(--brand-glow);
        color: var(--brand);
    }

    .calendar-filter-count {
        min-width: 1.05rem;
        text-align: center;
        border-radius: var(--radius-pill);
        background: var(--brand);
        color: var(--brand-contrast);
        font-size: 0.68rem;
        line-height: 1.35;
        padding: 0 0.25rem;
    }

    .calendar-filter-backdrop {
        display: block;
        position: fixed;
        inset: 0;
        z-index: 880;
        background: rgba(25, 21, 19, 0.35);
        animation: fadeIn var(--duration) var(--ease-out);
    }

    /* Fixed, so .calendar-page's overflow:hidden cannot slice it — the trap the
       board's column filter panel is written around. Fixed coordinates are
       viewport coordinates and where the button lands depends on how the
       toolbar above it wrapped, so `top`/`left` are written by
       calendar-interop.js's anchorFlyout; these are only the fallback for a
       frame where that has not run yet. Centring it instead put the panel in
       the middle of the month with nothing to say which control opened it. */
    .calendar-legend {
        display: none;
        position: fixed;
        z-index: 881;
        top: 8rem;
        left: 0.75rem;
        width: min(20rem, calc(100vw - 1.5rem));
        max-height: 60dvh;
        overflow-y: auto;
        flex-wrap: wrap;
        gap: 0.4rem;
        padding: 0.75rem;
        border-radius: var(--radius);
        background: var(--bg-1);
        box-shadow: var(--shadow-lg);
    }

    /* Grows out of the button that opened it. Safe to animate `transform` here,
       unlike the dialogs: this panel is placed with `top`/`left`, so an
       animation on transform has nothing to fight. The origin is the top-left
       because that is the corner sitting on the button. */
    .calendar-legend-open {
        display: flex;
        transform-origin: top left;
        animation: flyoutIn 140ms var(--ease-out);
    }

    @keyframes flyoutIn {
        from { opacity: 0; transform: translateY(-6px) scale(0.96); }
        to   { opacity: 1; transform: none; }
    }

    @media (prefers-reduced-motion: reduce) {
        .calendar-legend-open { animation: none; }
    }

    .calendar-legend::-webkit-scrollbar { display: none; }
    .calendar-legend .tag-chip { flex: 0 0 auto; }

    /* The agenda below lists them, so a count on top of that is a line of text
       for something already on screen. */
    .calendar-summary { display: none; }
    /* Nothing left on the row: no tags to filter by and the grid's own view. */
    .calendar-meta:not(:has(.calendar-filter-toggle)):not(:has(.segmented-scale)) { display: none; }

    .calendar-weekdays,
    .calendar-week { gap: 0.25rem; }

    .calendar-weekday {
        padding: 0.15rem 0;
        text-align: center;
        font-size: 0.62rem;
    }

    /* The grid stops stretching here and keeps its own compact height; the
       agenda below takes the rest. Six rows of empty white boxes is what
       filling the viewport buys you on a phone. `grid-template-rows: none`
       drops the equal-share track list so the rows size to their content. */
    .calendar-grid {
        flex: 0 0 auto;
        grid-template-rows: none;
        overflow: visible;
    }

    .calendar-week { min-height: 2.6rem; }

    .calendar-day {
        padding: 0.25rem 0.15rem;
        gap: 0.15rem;
        align-items: center;
    }

    /* The count would be a second number in a cell that has room for one, and
       the dots already say how many. */
    .calendar-day-head { justify-content: center; }
    .calendar-day-count { display: none; }
    .calendar-daynum { font-size: 0.8rem; }

    /* Chips off, dots on. */
    .calendar-day-cards { display: none; }

    .calendar-day-dots {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        align-content: flex-start;
        gap: 0.15rem;
        flex: 1;
        min-height: 0;
        overflow: hidden;
    }

    .calendar-day-dot {
        width: 0.35rem;
        height: 0.35rem;
        border-radius: var(--radius-pill);
        background: var(--tag-color, var(--fg-3));
    }

    /* What is actually due this month, under the grid that says when. Scrolls
       on its own so the month above it stays put — the grid is the index and
       this is the page it points at. */
    .calendar-agenda {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
        flex: 1;
        min-height: 0;
        overflow-y: auto;
        margin-top: 0.35rem;
        padding-top: 0.6rem;
        border-top: 1px solid var(--border-1);
        scrollbar-width: none;
    }

    .calendar-agenda::-webkit-scrollbar { display: none; }

    .calendar-agenda-day {
        display: grid;
        grid-template-columns: 2.4rem 1fr;
        gap: 0.5rem;
        align-items: start;
    }

    .calendar-agenda-date {
        display: flex;
        flex-direction: column;
        align-items: center;
        line-height: 1.1;
        padding-top: 0.15rem;
    }

    .calendar-agenda-weekday {
        font-size: 0.6rem;
        font-weight: 700;
        letter-spacing: 0.04em;
        text-transform: uppercase;
        color: var(--fg-3);
    }

    .calendar-agenda-daynum {
        font-family: var(--font-display);
        font-size: 1.05rem;
        font-weight: 700;
        color: var(--fg);
    }

    .calendar-agenda-today .calendar-agenda-daynum,
    .calendar-agenda-today .calendar-agenda-weekday { color: var(--brand); }

    .calendar-agenda-cards {
        display: flex;
        flex-direction: column;
        gap: 0.3rem;
        min-width: 0;
    }

    /* Styled here in full rather than by borrowing .cal-card — see the note in
       Calendar.razor for why the agenda's chips carry their own class. Room to
       read: this is the one place on a phone where a card's title has a whole
       line to itself. */
    .agenda-card {
        --tag-color: var(--fg-3);
        display: block;
        width: 100%;
        text-align: left;
        border: none;
        border-left: 3px solid var(--tag-color);
        border-radius: var(--radius-xs);
        background: color-mix(in srgb, var(--tag-color) 12%, transparent);
        color: var(--fg);
        font: inherit;
        font-family: var(--font-body);
        font-size: 0.82rem;
        font-weight: 600;
        line-height: 1.35;
        white-space: normal;
        padding: 0.45rem 0.6rem;
        cursor: pointer;
    }

    .agenda-card-overdue { border-left-color: var(--danger); }

    /* The sheet is the phone's real card list, so it gets the room a phone
       dialog should have rather than the desktop's 380px box. */
    .calendar-day-sheet {
        width: 100%;
        max-height: 80dvh;
    }

    .calendar-day-sheet-cards .cal-card { font-size: 0.9rem; }

    /* ── The timeline runs down the page here, not across it ──
       A month of day columns is three phone screens wide; a quarter is nine.
       Sideways, the one thing the view exists to show — where a card sits
       relative to the others — is always mostly off the edge, and the chips are
       too narrow to read besides. So the axis becomes the scroll direction the
       phone already has: lanes stack, each lane lists its cards in date order,
       and every chip carries the date the column used to stand for.
       Same markup, same lanes, same ordering as the desktop — only the axis
       turns, so nothing about which cards are in scope changes with it. */
    .calendar-timeline-scroll { overflow-x: hidden; }

    .calendar-timeline {
        min-width: 0;
        gap: 0;
    }

    /* The day numbers and the month/year headings are the horizontal axis, and
       there is no horizontal axis any more. */
    .timeline-head { display: none; }

    .timeline-row {
        display: block;
        padding-bottom: 0.75rem;
    }

    .timeline-label {
        position: static;
        padding: 0.4rem 0 0.3rem;
        background: transparent;
        font-size: 0.85rem;
    }

    /* No columns, no gridlines: a lane is a list now. */
    .timeline-track {
        display: flex;
        flex-direction: column;
        gap: 0.3rem;
        background: none;
        min-height: 0;
        padding: 0;
        /* The chips still carry grid-column/grid-row inline from the desktop
           placement. Flex ignores both, so nothing has to be re-rendered — but
           the width has to be taken back, or a chip that spanned five columns
           would still be asking for five columns' worth of room. */
        width: 100%;
    }

    .timeline-card {
        grid-column: auto !important;
        grid-row: auto !important;
        width: 100%;
        display: flex;
        align-items: baseline;
        gap: 0.5rem;
        padding: 0.45rem 0.6rem;
        font-size: 0.82rem;
        background: color-mix(in srgb, var(--tag-color) 12%, transparent);
    }

    /* Fixed width so the titles line up down the lane and the dates read as a
       column of their own — which is what is left of the axis. Wide enough for
       the longest form ("Nov 2, 2027", which the whole-board range produces):
       a flex item's min-width is auto, so a basis alone lets a longer date grow
       its own row and knock that title out of line with the one above it. */
    .timeline-card-date {
        display: block;
        flex: 0 0 5rem;
        min-width: 0;
        overflow: hidden;
        font-size: 0.7rem;
        font-weight: 700;
        color: var(--fg-3);
        white-space: nowrap;
    }

    .timeline-card-title {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    .calendar-subscribe-panel { padding: 1rem; }

    /* Stacked: side by side at this width they wrap mid-label. */
    .calendar-subscribe-actions { flex-direction: column; }
    .calendar-subscribe-action { width: 100%; }
}

/* ── Comments ──────────────────────────────────────────── */

.comment-list {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
}

/* Same inset surface as the composer and the detail panels — inside the
   dialog, anything that groups content sits on bg-2 and anything you type
   into sits on bg-1. */
.comment {
    border: none;
    border-radius: var(--radius-sm);
    padding: 0.8rem 1rem;
    background: var(--bg-2);
}

.comment-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.45rem;
}

.comment-author {
    font-weight: 600;
    font-size: 0.88rem;
    color: var(--brand);
}

.comment-time {
    color: var(--fg-3);
    font-size: 0.78rem;
    margin-left: auto;
}

/* Pushed to the end of the header row by .comment-time's auto margin, so the
   timestamp stays between the author and the buttons rather than being shoved
   against them. */
.comment-actions {
    display: flex;
    align-items: center;
    gap: 0.15rem;
}

/* Quiet until the comment is hovered or one of them is focused: two buttons on
   every comment in a long thread is a lot of ink for something most readers
   never press, and a delete that is loud is a delete that gets pressed by
   accident. Never hidden, though — display:none would take them off the
   keyboard and off touch, which has no hover at all. */
.comment-action {
    border: none;
    background: transparent;
    color: var(--fg-3);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.78rem;
    font-weight: 600;
    padding: 0.2rem 0.4rem;
    border-radius: var(--radius-btn);
    cursor: pointer;
    opacity: 0.55;
    transition: opacity var(--duration) var(--ease), color var(--duration) var(--ease);
}

.comment:hover .comment-action,
.comment-action:focus-visible { opacity: 1; }

.comment-action:hover { color: var(--fg); }
.comment-action-danger:hover { color: var(--danger); }

@media (hover: none) {
    /* No hover to reveal them with. */
    .comment-action { opacity: 1; }
}

/* Small, next to the time, and never louder than the words it is about. */
.comment-edited {
    color: var(--fg-3);
    font-size: 0.72rem;
    font-style: italic;
}

.comment-edit {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.comment-edit textarea {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.6rem 0.8rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    line-height: 1.6;
    resize: vertical;
    background: var(--bg-1);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.comment-edit textarea:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

.comment-edit-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
}

.comment-delete-confirm {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-top: 0.6rem;
    font-size: 0.85rem;
    color: var(--fg-2);
}

/* Wherever you write in this dialog it is the same object: a bg-1 field on a
   bg-2 panel. Composer and comment box are built from that one rule. */
.comment-form {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    margin-top: 0.2rem;
    padding: 0.75rem;
    background: var(--bg-2);
    border-radius: var(--radius-sm);
}

.comment-form textarea {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.65rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    line-height: 1.6;
    resize: vertical;
    background: var(--bg-1);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.comment-form textarea:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
    background: var(--bg-1);
}

.comment-form .btn-primary {
    align-self: flex-end;
}

/* ── Login page ────────────────────────────────────────── */

.login-page {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    min-height: 100dvh;
    padding: 2rem;
    /* Warm sun glow upper-right, brand haze lower-left — echoes the mark. */
    background:
        radial-gradient(480px circle at 86% 8%, rgba(255, 198, 107, 0.12), transparent 70%),
        radial-gradient(640px circle at 8% 96%, var(--brand-glow), transparent 70%),
        var(--bg-0);
    position: relative;
}

.login-card {
    background: var(--bg-1);
    border: none;
    border-radius: var(--radius-lg);
    padding: 2.25rem 2.25rem 2rem;
    width: min(456px, 100%);
    box-shadow: var(--shadow-lg);
    text-align: center;
    position: relative;
}

.login-card h1 {
    margin: 0 0 0.5rem;
    font-family: var(--font-display);
    font-size: 1.75rem;
    font-weight: 800;
    letter-spacing: -0.03em;
    background: linear-gradient(135deg, var(--brand-dim), var(--brand-bright));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
}

.login-card > p {
    color: var(--fg-2);
    font-size: 0.93rem;
    margin: 0 0 1.5rem;
    line-height: 1.55;
}

/* The gap here separates whole fields; label, input and hint sit closer
   together inside their .field, so the form reads as groups rather than as
   one evenly spaced ladder. */
.login-card form {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    text-align: left;
}

.login-card .field {
    gap: 0.3rem;
}

.login-card form label {
    font-size: 0.88rem;
    font-weight: 600;
    color: var(--fg-2);
}

.login-card form input:not([type="checkbox"]):not([type="radio"]),
.login-card form textarea {
    width: 100%;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.7rem 0.9rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 1rem;
    background: var(--bg-0);
    color: var(--fg);
    box-sizing: border-box;
    /* Named properties, not `all`: password managers write inline styles on
       these inputs (Proton Pass reserves room for its icon with padding), and
       `all` animated every one of those writes. */
    transition: border-color var(--duration) var(--ease),
                box-shadow var(--duration) var(--ease),
                background var(--duration) var(--ease);
}

.login-card form input:not([type="checkbox"]):not([type="radio"]):focus,
.login-card form textarea:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
    background: var(--bg-1);
}

.login-card form input::placeholder,
.login-card form textarea::placeholder {
    color: var(--fg-3);
    opacity: 0.6;
}

/* The handle field's placeholder cycles through example handles
   (js/handle-placeholder.js). The text on screen is a ghost laid over the
   field, because only part of it moves — the shared "alice." holds still while
   the domain rolls over. The real placeholder stays put underneath for
   assistive tech, painted transparent so the two don't stack up. */
.handle-ghost-field {
    position: relative;
}

.login-card form input.has-handle-ghost::placeholder {
    color: transparent;
}

.handle-ghost {
    position: absolute;
    display: flex;
    align-items: center;
    /* Clips the roll at the field's edges, exactly like a real placeholder. */
    overflow: hidden;
    pointer-events: none;
    white-space: nowrap;
    color: var(--fg-3);
    opacity: 0.6;
}

.handle-ghost.is-hidden {
    display: none;
}

.handle-ghost-reel {
    position: relative;
}

.handle-ghost-domain {
    display: inline-block;
    /* The incoming domain waits out most of the outgoing one's fade before
       showing up, so the two are never both legible. 420ms has to match
       SWAP_MS in js/handle-placeholder.js. */
    transition: transform 420ms var(--ease), opacity 280ms var(--ease) 140ms;
}

.handle-ghost-domain.is-entering {
    transform: translateY(0.75em);
    opacity: 0;
}

.handle-ghost-domain.is-leaving {
    position: absolute;
    left: 0;
    top: 0;
    transform: translateY(-0.75em);
    opacity: 0;
    transition: transform 420ms var(--ease), opacity 200ms var(--ease);
}

@media (prefers-reduced-motion: reduce) {
    .handle-ghost-domain {
        transition: none;
    }
}

.login-card form textarea {
    resize: vertical;
    min-height: 6rem;
    line-height: 1.5;
}

.login-card form button[type="submit"] {
    margin-top: 0.35rem;
    background: var(--brand);
    border: none;
    color: var(--brand-contrast);
    border-radius: var(--radius-pill);
    padding: 0.9rem 1.25rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 1rem;
    font-weight: 600;
    transition: all var(--duration) var(--ease);
    box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.2);
    letter-spacing: 0.01em;
}

.login-card form button[type="submit"]:hover {
    background: var(--brand-bright);
    box-shadow: var(--shadow-brand);
    transform: translateY(-2px);
}

.login-card form button[type="submit"]:active {
    transform: translateY(0);
}

.login-card form small,
.login-card form .hint {
    font-size: 0.82rem;
    color: var(--fg-3);
    text-align: left;
}

.login-card form input[type="checkbox"] {
    margin-right: 0.3rem;
}

/* ── Standalone pages (Access Denied, Not Found, Error) ── */

.standalone-page {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    padding: 2rem;
    text-align: center;
}

.standalone-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 72px;
    height: 72px;
    border-radius: 50%;
    background: var(--brand-glow);
    color: var(--brand);
    margin-bottom: 1.25rem;
}

.standalone-icon svg {
    width: 32px;
    height: 32px;
    stroke-width: 1.75;
}

.standalone-icon.standalone-icon-danger {
    background: var(--danger-bg);
    color: var(--danger);
}

.standalone-page h1 {
    font-family: var(--font-display);
    font-size: 1.6rem;
    font-weight: 700;
    margin: 0 0 0.6rem;
    color: var(--fg);
    letter-spacing: -0.02em;
}

.standalone-page p {
    color: var(--fg-2);
    font-size: 0.95rem;
    margin: 0 0 0.35rem;
    max-width: 380px;
    line-height: 1.6;
}

.standalone-page button,
.standalone-page a.btn {
    margin-top: 1.5rem;
    background: var(--brand);
    border: none;
    color: var(--brand-contrast);
    border-radius: var(--radius-pill);
    padding: 0.7rem 1.5rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    font-weight: 600;
    text-decoration: none;
    transition: all var(--duration) var(--ease);
    box-shadow: var(--shadow-sm);
}

.standalone-page button:hover,
.standalone-page a.btn:hover {
    background: var(--brand-bright);
    box-shadow: var(--shadow-brand);
    transform: translateY(-2px);
}

/* ── Home: the boards you can see ─────────────────────── */

/* A launchpad, not a dashboard. Everything on a tile is structure — a count,
   a picture of the columns, who else is on it — because structure is what can
   be drawn without decrypting a board's contents on every visit. */
.home {
    padding: 2.5rem 1.75rem 3rem;
    max-width: 1140px;
    margin: 0 auto;
}

.home-title {
    margin: 0 0 1.25rem;
    font-family: var(--font-display);
    font-size: 1.45rem;
    font-weight: 700;
    letter-spacing: -0.02em;
}

/* auto-fill and not auto-fit: with two boards, auto-fit stretches them across
   the whole page and a tile becomes a banner. They stay tile-sized and the row
   fills up as boards arrive. */
.home-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
    gap: 1.5rem;
}

.board-tile {
    position: relative;
    display: flex;
    flex-direction: column;
    background: var(--bg-1);
    border: none;
    border-radius: var(--radius);
    padding: 1.25rem;
    box-shadow: var(--shadow-card);
    transition: box-shadow var(--duration) var(--ease), transform var(--duration) var(--ease);
}

.board-tile:hover {
    box-shadow: var(--shadow-card-hover);
    transform: translateY(-2px);
}

/* The whole tile is the link. An <a> around the controls as well would have to
   contain the settings button, and an anchor inside an anchor is markup the
   browser silently rewrites — so the link covers the tile from underneath and
   the controls sit above it. The picture, the name and the foot are inside it;
   the spacing between them is margins rather than a gap, so the phone's grid
   below can place them without undoing one. */
.board-tile-link {
    display: flex;
    flex-direction: column;
    text-decoration: none;
    color: inherit;
}

.board-tile-link::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
}

.board-tile-name {
    margin-top: 0.9rem;
    font-family: var(--font-display);
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    /* A long board name is cut, not wrapped to a second line: the tiles are a
       grid and one two-line name would set the height of its whole row. The
       floor of a flex item is its content, so the phone's row needs the
       min-width for the cut to happen at all. */
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The board itself, small: a track per column and a dash per card, in the
   colour BoardPalette already gives that board on the general calendar. It is
   the one thing on the tile that says where the work is piled up. */
.board-mini {
    display: flex;
    gap: 6px;
    height: 56px;
}

/* The gap is what a full column has room for: five dashes and the faded one
   for the rest, 6 × 2px + 5 gaps, inside the 40px left of the track once its
   padding is off (38px on a phone). At 6px it needed 42, and flex took the
   difference out of the dashes rather than the gaps — every one became 1.67px
   at a fractional offset, which the screen rounds to one pixel or two
   depending on where it lands, so the lines came out visibly uneven.

   Centred across the track: a dash hugging the left edge left every column
   lopsided, with its spare width all on one side. */
.board-mini-column {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
    padding: 8px 7px;
    border-radius: var(--radius-xs);
    background: var(--bg-2);
    overflow: hidden;
}

/* Short of the track's width, so a dash reads as a card lying in a column
   rather than as a stripe across it. */
.board-mini-card {
    flex: none;
    width: 72%;
    height: 2px;
    border-radius: 1px;
    /* Mid-tone board colours, the same treatment a tag chip gets: inked down
       on light paper, used straight on dark. */
    background: light-dark(
        color-mix(in srgb, var(--board-color, var(--fg-3)) 55%, transparent),
        color-mix(in srgb, var(--board-color, var(--fg-3)) 75%, transparent));
}

.board-mini-card-more {
    opacity: 0.4;
}

/* Above the link's cover (it is inside the link now, and the cover comes
   later in it), so a face's name still shows on hover. */
.board-tile-foot {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-top: 0.75rem;
}

.board-tile-count {
    font-size: 0.82rem;
    color: var(--fg-2);
}

.board-tile-who {
    font-size: 0.78rem;
    color: var(--fg-3);
}

/* "Everyone" wears a dot and "Only you" does not, because only one of the two
   is a statement about other people. */
.board-tile-everyone::before {
    content: "";
    display: inline-block;
    width: 6px;
    height: 6px;
    margin-right: 0.4rem;
    border-radius: 50%;
    background: currentColor;
    vertical-align: 0.05em;
}

/* The tile's two controls, above its own link: the pin (every tile) and the cog
   (only a board you run). One absolutely positioned row rather than two boxes
   with hand-computed offsets, so adding or dropping one does not move the other. */
.board-tile-controls {
    position: absolute;
    top: 0.85rem;
    right: 0.85rem;
    display: flex;
    align-items: center;
    gap: 0.25rem;
}

/* Only drawn once the pointer is on the tile — a cog on every tile in a grid is
   six controls nobody is looking for. Always there for the keyboard, which is
   what :focus-visible is doing. */
.board-tile-settings,
.board-tile-pin {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border: none;
    padding: 0;
    cursor: pointer;
    border-radius: 50%;
    color: var(--fg-3);
    background: var(--bg-2);
    opacity: 0;
    transition: opacity var(--duration) var(--ease), color var(--duration) var(--ease);
}

.board-tile:hover .board-tile-settings,
.board-tile:hover .board-tile-pin,
.board-tile-settings:focus-visible,
.board-tile-pin:focus-visible {
    opacity: 1;
}

.board-tile-settings:hover,
.board-tile-pin:hover {
    color: var(--brand);
}

/* A pinned board is where it is *because* of the pin, so the pin has to say so
   without being hovered — it is the only thing on the tile that explains the
   order. */
.board-tile-pin-on {
    opacity: 1;
    color: var(--brand);
    /* The one control on the tile that is drawn unhovered, so it is the one that
       has to hold its own against the little picture of the board underneath it. */
    background: var(--brand-surface);
}

/* The way to one more board, as a tile in the row it adds to. */
.board-tile-new {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    gap: 0.6rem;
    min-height: 168px;
    background: transparent;
    border: 2px dashed var(--border-2);
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    font-weight: 600;
    cursor: pointer;
}

.board-tile-new:hover {
    border-color: var(--brand);
    color: var(--brand);
    box-shadow: none;
}

.board-tile-plus {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: var(--brand-surface);
    color: var(--brand);
}

/* The form is a tile in the grid rather than a dialog over it: making a board
   is adding one to this row, and a dialog would cover the thing it adds to. */
.board-tile-form {
    gap: 0.6rem;
    justify-content: center;
    min-height: 168px;
}

.board-tile-input {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.6rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    background: var(--bg-0);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.board-tile-input:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
    background: var(--bg-1);
}

.board-tile-hint {
    margin: 0;
    font-size: 0.78rem;
    color: var(--fg-2);
    line-height: 1.5;
}

.board-tile-actions {
    display: flex;
    gap: 0.5rem;
}

.board-tile-actions button {
    padding: 0.5rem 1.1rem;
    font-size: 0.85rem;
}

/* The empty state carries a button, which no other one in the app does: having
   no boards is something to *do* something about rather than something to be
   told. */
.home-empty-new {
    margin-top: 1.5rem;
}

.home-empty-note {
    margin: 1.75rem 0 0;
    font-size: 0.82rem;
    color: var(--fg-3);
}

/* ── Admin: a board's record ───────────────────────────── */

/* A term and its value per line, two columns wide enough to read down: what a
   super-admin is shown of a board they are not on. Quiet on purpose — it is a
   record to check a report against, not a page to browse. */
.board-record-name {
    margin: 0 0 0.25rem;
    overflow-wrap: anywhere;
}

.board-record-facts {
    display: grid;
    grid-template-columns: minmax(8rem, max-content) 1fr;
    gap: 0.45rem 1.25rem;
    margin: 1rem 0 0;
    font-size: 0.9rem;
}

.board-record-facts dt {
    color: var(--fg-3);
}

.board-record-facts dd {
    margin: 0;
    color: var(--fg);
}

.board-record-admins {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem 0.75rem;
}

/* ── Members: a banned row ────────────────────────────── */

/* A banned member stays in the list — the row is what the ban is recorded on,
   and the way back is its button — but reads as out: dimmed, with one small
   word beside the name, and the reason on hover for whoever wonders why. */
.member-row-banned td:not(.admin-row-actions) {
    color: var(--fg-3);
}

.member-banned {
    display: inline-block;
    margin-left: 0.5rem;
    padding: 0.05rem 0.45rem;
    border-radius: 999px;
    background: var(--bg-2);
    color: var(--fg-2);
    font-size: 0.72rem;
    font-weight: 600;
    vertical-align: 0.1em;
}

.member-ban-reason {
    min-width: 10rem;
    padding: 0.35rem 0.55rem;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    background: var(--bg-1);
    color: var(--fg);
    font: inherit;
    font-size: 0.85rem;
}

/* ── Home: recently deleted ───────────────────────────── */

/* Under the tiles, and deliberately the quietest thing on the page: a line per
   board that can still be brought back, its name, until when, and the one
   button. It shares the page's column rather than the tiles' grid — these are
   not boards to open, and a tile shape would say they were. */
.home-deleted {
    padding-top: 0;
}

.home-deleted-title {
    margin: 0 0 0.6rem;
    font-size: 0.82rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--fg-3);
}

.home-deleted-list {
    list-style: none;
    margin: 0;
    padding: 0;
    border-top: 1px solid var(--border-1);
}

.home-deleted-row {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.55rem 0;
    border-bottom: 1px solid var(--border-1);
    font-size: 0.88rem;
}

.home-deleted-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--fg-2);
}

.home-deleted-until {
    flex: none;
    color: var(--fg-3);
    font-size: 0.8rem;
}

.home-deleted-restore {
    flex: none;
}

/* ── Home: the "for you" strip ─────────────────────────── */

/* What is waiting for one member, above the boards: the cards they are on and
   the ones that are due. The tiles below are structure; this is the one place on
   the page that reads a card's own words, which is why it is capped at six and
   why the read behind it is two passes (ForYouService). */
.home-foryou {
    margin-bottom: 2rem;
}

/* A row that scrolls rather than a grid that wraps: the strip is a glance and a
   second line of it would be a dashboard. Snap points so a thumb lands on a
   whole card. */
.foryou-strip {
    display: flex;
    gap: 0.75rem;
    overflow-x: auto;
    scroll-snap-type: x proximity;
    /* Room on every side for a hovered card's lift and its larger shadow. A
       row that scrolls sideways clips on every side — overflow-x forces
       overflow-y to clip too — so with room only underneath, the lift was cut
       off at the top and the shadow at every edge. The negative margins hand
       the room back, so the row sits exactly where it did; the scroll padding
       keeps a snapped card on the page's column rather than on the room. */
    padding: 1rem 1rem 1.75rem;
    margin: -1rem -1rem -1.4rem;
    scroll-padding-inline: 1rem;
    /* A thin bar, and only while it is wanted. The browser's own was a grey
       rail under the strip from the first frame, there before the cards it
       scrolls had even come in; the half-card at the row's edge already says
       there is more. `thin` keeps the same room whether the thumb shows or
       not, so it appearing never moves the row. */
    scrollbar-width: thin;
    scrollbar-color: transparent transparent;
}

.foryou-strip:hover,
.foryou-strip:focus-within {
    scrollbar-color: var(--border-3) transparent;
}

.foryou-card {
    flex: 0 0 auto;
    scroll-snap-align: start;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    width: 190px;
    padding: 0.7rem 0.8rem;
    background: var(--bg-1);
    /* The board's own colour, as a tick down the left edge rather than a wash:
       which board a card is on is a hint here — the name below says it — and six
       washed cards in a row would be a paint chart. */
    border-left: 3px solid var(--board-color);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-card);
    text-decoration: none;
    color: inherit;
    transition: box-shadow var(--duration) var(--ease), transform var(--duration) var(--ease);
}

.foryou-card:hover {
    box-shadow: var(--shadow-card-hover);
    transform: translateY(-2px);
}

/* The date and, on a card that is this member's own, their face. Both are
   optional, so the row collapses instead of leaving a gap. */
.foryou-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.4rem;
    min-height: 20px;
}

/* The face is always in the top-right corner. On its own — a card that is
   yours but has no date — `space-between` put it on the left, where the date
   sits on the card beside it, so the same mark stood in two places along one
   row of cards. */
.foryou-top > .assignee-avatar { margin-left: auto; }

.foryou-title {
    font-weight: 600;
    font-size: 0.9rem;
    line-height: 1.3;
    /* Two lines and then cut: the strip is one row of equal cards, and a title
       that wrapped freely would set the height of all of them. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.foryou-board {
    font-size: 0.75rem;
    color: var(--fg-3);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* On a phone the grid is one column, and a tile becomes a row: the picture
   moves beside the name instead of over it, which is what makes six boards a
   list you can thumb through rather than six screens of scrolling. */
@media (max-width: 599px) {
    /* The page simply scrolls, so the bar's own inset is already on the body
       (`body:has(.board-header)`) — this only needs the gutters. */
    .home {
        padding: 1.25rem 1rem 2rem;
    }

    .home-tiles {
        grid-template-columns: 1fr;
        gap: 0.75rem;
    }

    /* The strip keeps its row where the tiles become one: it is six cards read
       by scrolling sideways, which is the one shape a phone is better at than a
       desktop. Narrower, so a second card is always half in view and the row
       reads as scrollable without a scrollbar to say so. */
    .home-foryou {
        margin-bottom: 1.25rem;
    }

    .foryou-card {
        width: 168px;
    }

    .board-tile {
        padding: 0.9rem;
    }

    /* The picture on the left across both lines, and the words in one column
       beside it: the name, and under it the count and who else sees it. The
       foot used to run under the picture instead, so the name started beside
       the picture and the count under it — two left edges in one small row.
       The name keeps clear of the pin and the cog, which sit on its line. */
    .board-tile-link {
        display: grid;
        grid-template-columns: 104px minmax(0, 1fr);
        grid-template-areas: "mini name" "mini foot";
        column-gap: 0.9rem;
        row-gap: 0.3rem;
        align-items: center;
    }

    .board-mini {
        grid-area: mini;
        height: 52px;
    }

    .board-mini-column { padding: 7px 5px; }

    .board-tile-name {
        grid-area: name;
        align-self: end;
        margin-top: 0;
        padding-right: 4rem;
    }

    .board-tile-foot {
        grid-area: foot;
        align-self: start;
        margin-top: 0;
    }

    .board-tile-new,
    .board-tile-form {
        min-height: 0;
    }

    /* Nothing to hover with, so both controls are simply there — and out of the
       way of the row's own tap target. */
    .board-tile-controls {
        top: 0.9rem;
        right: 0.9rem;
    }

    .board-tile-settings,
    .board-tile-pin {
        opacity: 1;
    }
}

/* ── Admin pages ───────────────────────────────────────── */

.admin-page {
    padding: 1.5rem 1.75rem;
    max-width: 880px;
    margin: 0 auto;
}

.admin-section {
    background: var(--bg-1);
    border: none;
    border-radius: var(--radius);
    padding: 1.75rem;
    margin-bottom: 1.5rem;
    box-shadow: var(--shadow-card);
}

.admin-section h2 {
    margin: 0 0 1.15rem;
    font-family: var(--font-display);
    font-size: 1.1rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.admin-form {
    display: flex;
    gap: 0.65rem;
    align-items: center;
}

.admin-form input[type="text"] {
    flex: 1;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.6rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    background: var(--bg-0);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.admin-form input[type="text"]:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
    background: var(--bg-1);
}

.admin-form input[type="text"]::placeholder {
    color: var(--fg-3);
    opacity: 0.6;
}

.admin-form select {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.6rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    background: var(--bg-0);
    color: var(--fg);
}

/* A tag's colour, in the add form and on every row of the table. Left alone
   this is the one control in the app still wearing the browser's own chrome —
   a grey bevelled rectangle beside a page of soft rounded surfaces — and it
   was the first thing anybody noticed on the tags page. The three vendor
   pseudo-elements are the whole trick: the input keeps its border and radius,
   and the swatch inside it is stripped of its own so the colour fills the
   shape rather than sitting in a box inside it. */
.admin-form input[type="color"],
.admin-table input[type="color"] {
    -webkit-appearance: none;
    appearance: none;
    flex: 0 0 auto;
    width: 3rem;
    height: 2.5rem;
    padding: 3px;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    background: var(--bg-0);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.admin-form input[type="color"]:hover,
.admin-table input[type="color"]:hover {
    border-color: var(--border-3);
}

.admin-form input[type="color"]:focus-visible,
.admin-table input[type="color"]:focus-visible {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }

input[type="color"]::-webkit-color-swatch {
    border: none;
    border-radius: calc(var(--radius-xs) - 4px);
}

input[type="color"]::-moz-color-swatch {
    border: none;
    border-radius: calc(var(--radius-xs) - 4px);
}

.admin-table {
    width: 100%;
    border-collapse: separate;
    border-spacing: 0;
    font-size: 0.9rem;
}

.admin-table th,
.admin-table td {
    padding: 0.75rem 0.65rem;
    border-bottom: 1px solid var(--border-1);
    text-align: left;
}

.admin-table th {
    color: var(--fg-3);
    font-size: 0.78rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding-bottom: 0.6rem;
}

.admin-table tbody tr {
    transition: background var(--duration) var(--ease);
}

.admin-table tbody tr:hover {
    background: var(--brand-glow);
}

.admin-table tbody tr:last-child td {
    border-bottom: none;
}

/* A column of fixed-size controls — a swatch, a pair of buttons — takes its
   content's width and no more, so the spare width goes to the field a member
   actually types into. Left to the table's own layout it was shared out
   evenly, and a tag's row was a name, a swatch and a gap before its buttons
   that was wider than either. */
.admin-table .admin-cell-fit {
    width: 1%;
    white-space: nowrap;
}

.admin-table input[type="text"] {
    width: 100%;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.4rem 0.65rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.9rem;
    background: var(--bg-0);
    color: var(--fg);
}

/* `align-items: center` and not the default `stretch`: the row mixes links
   with buttons, and a stretched <a> is a taller box whose text stays at the
   top of it — so the words on a board's row sat a couple of pixels above the
   words on the buttons beside them, on every row of the boards list. */
.admin-row-actions {
    display: flex;
    align-items: center;
    gap: 0.45rem;
    justify-content: flex-end;
}

/* As a table cell this class must stay a real cell: display:flex detaches
   the cell from the row box, so its border-bottom stops lining up with the
   row separator whenever a sibling cell wraps to two lines. */
td.admin-row-actions {
    display: table-cell;
    text-align: right;
    white-space: nowrap;
    vertical-align: middle;
}

td.admin-row-actions button + button {
    margin-left: 0.45rem;
}

/* A board's row is one ladder of pills. The links into the board take the
   buttons' type size and box (transparent border included, so the two are the
   same height to the pixel) — at the header's 0.9rem they were visibly larger
   than the Save beside them and the row read as two sets of controls sharing
   a line. */
.admin-row-actions .board-nav-link {
    font-size: 0.82rem;
    padding: 0.38rem 0.85rem;
    border: 1px solid transparent;
}

.admin-row-actions button {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-pill);
    padding: 0.38rem 0.85rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 600;
    background: var(--bg-0);
    color: var(--fg-2);
    transition: all var(--duration) var(--ease);
}

.admin-row-actions button:hover:not(:disabled) {
    background: var(--bg-2);
    border-color: var(--border-3);
}

.admin-row-actions .btn-primary {
    background: var(--brand);
    border-color: transparent;
    color: var(--brand-contrast);
}

.admin-row-actions .btn-primary:hover:not(:disabled) {
    background: var(--brand-bright);
    box-shadow: var(--shadow-brand);
}

.admin-row-actions .btn-danger {
    background: transparent;
    border-color: var(--danger);
    color: var(--danger);
}

.admin-row-actions .btn-danger:hover:not(:disabled) {
    background: var(--danger);
    color: var(--danger-contrast);
}

.admin-empty {
    margin: 0;
    color: var(--fg-3);
    font-style: italic;
}

/* A note says something about the *state* of what it sits above; a hint below
   says what a control does. They are not the same job and they no longer look
   the same: a note is a mark and one line on a tinted strip, so which of the
   two an admin is looking at is settled before the sentence is read. The mark
   is also why the copy could get shorter — a paragraph that has to open by
   establishing "this is fine, actually" is a paragraph doing an icon's work. */
.admin-note {
    display: flex;
    /* Top rather than centre: one of these wraps to two lines on a phone (and
       in German on a laptop), and a mark floating between them reads as a
       bullet for both. */
    align-items: flex-start;
    gap: 0.55rem;
    margin: 0 0 0.9rem;
    padding: 0.6rem 0.85rem;
    border-radius: var(--radius-xs);
    background: var(--bg-0);
    color: var(--fg-2);
    font-size: 0.87rem;
    line-height: 1.45;
}

.admin-note svg {
    width: 17px;
    height: 17px;
    flex: 0 0 auto;
    /* Half the difference between the mark and a line of this type, so it sits
       on the first line rather than above it. */
    margin-top: 0.1rem;
    color: var(--fg-3);
}

/* Not an error — nothing has gone wrong and nothing has failed — but the one
   state on the access page a board should not be left in. */
.admin-note-warn {
    background: var(--danger-bg);
    color: var(--fg-2);
}

.admin-note-warn svg { color: var(--danger); }

/* A line of explanation under a control, for the things a form does that it
   cannot show — what a new board arrives with, which board "/" lands on. */
.admin-hint {
    margin: 0.5rem 0 0;
    color: var(--fg-3);
    font-size: 0.85rem;
}

/* ── A choice between two things ───────────────────────── */

/* Options as cards rather than a select, each with a line saying what it means
   underneath its own label. Worth the room where the choice is one somebody has
   to understand before making it — who may read a board, and which profile a
   name is being written to — and both of those now wear it, which is why the
   shape is here rather than under either page. */
.choice-list {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    /* Usable as a <fieldset> without looking like one: the legend is the
       question and the browser's box around it is noise. */
    margin: 0;
    padding: 0;
    border: 0;
}

.choice-list legend {
    padding: 0;
    margin-bottom: 0.5rem;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--fg-2);
}

.choice-option {
    display: flex;
    align-items: flex-start;
    gap: 0.7rem;
    padding: 0.8rem 0.9rem;
    background: var(--bg-0);
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    cursor: pointer;
    transition: border-color var(--duration) var(--ease), background var(--duration) var(--ease);
}

.choice-option:hover { border-color: var(--border-3); }

.choice-option-on {
    border-color: var(--brand);
    background: var(--brand-glow);
}

/* An option this member cannot pick today — the sentence saying why is the
   paragraph under the list. Dimmed rather than removed: what is on offer here
   is easier to judge against what is not. */
.choice-option-off {
    opacity: 0.55;
    cursor: not-allowed;
}

.choice-option-off:hover { border-color: var(--border-2); }

.choice-option input { margin-top: 0.15rem; }

.choice-option-text { display: flex; flex-direction: column; gap: 0.15rem; }

.choice-option-label { font-weight: 600; color: var(--fg); }

.choice-option-hint { color: var(--fg-3); font-size: 0.85rem; }

/* ── The profile page ──────────────────────────────────── */

/* An <a> wearing a button needs the block layout the button elements around it
   get for free. */
.profile-reauthorize {
    display: inline-block;
    margin-bottom: 1.25rem;
}

/* The choice sits above everything it governs, and the sentence explaining what
   was picked comes straight after it. */
.profile-target {
    margin-bottom: 1rem;
    max-width: 34rem;
}

/* One sentence above the fields rather than at the top of the page: it is a
   warning about what saving does, and it is worth reading where the saving
   happens. */
.profile-network {
    margin: 0 0 1.25rem;
    max-width: 60ch;
}

/* Label over control, which the admin pages have no shape for — theirs are a
   box and a button on one line, and this is two fields somebody reads before
   typing in. */
.profile-field {
    margin-bottom: 1.25rem;
}

.profile-field label {
    display: block;
    margin-bottom: 0.4rem;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--fg-2);
}

.profile-field input[type="text"] {
    width: 100%;
    max-width: 28rem;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.6rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    background: var(--bg-0);
    color: var(--fg);
    transition: all var(--duration) var(--ease);
}

.profile-field input[type="text"]:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
    background: var(--bg-1);
}

/* The picker, wearing the app's own button instead of the browser's. Only the
   button half of a file input can be styled — the file name beside it is the
   browser's text and stays that way — so the shape has to come from
   ::file-selector-button, which is why this is the one control here that
   restates the secondary button rather than reusing the class. */
.profile-file {
    display: block;
    max-width: 28rem;
    font-size: 0.9rem;
    color: var(--fg-3);
}

.profile-file::file-selector-button {
    margin-right: 0.75rem;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-pill);
    padding: 0.5rem 1.1rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.85rem;
    font-weight: 600;
    background: var(--bg-2);
    color: var(--fg);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.profile-file::file-selector-button:hover {
    background: var(--bg-3);
    border-color: var(--border-3);
}

.profile-file:disabled { opacity: 0.5; }

.profile-file:disabled::file-selector-button { cursor: not-allowed; }

/* "Remove picture" sits under the picker's own hints, so it needs the gap a
   sibling paragraph would have given it. */
.profile-field .btn-secondary {
    margin-top: 0.6rem;
}

.profile-actions {
    display: flex;
    align-items: center;
    gap: 0.85rem;
    flex-wrap: wrap;
    margin-top: 1rem;
}

.profile-actions .admin-hint {
    margin: 0;
}

/* Why the two fields are not there. A note rather than an error: nothing the
   member has done is wrong, and on most deployments this is simply how it is
   configured. */
.profile-locked {
    margin: 0 0 1rem;
    max-width: 60ch;
    padding: 0.85rem 1rem;
    border-radius: var(--radius-xs);
    background: var(--brand-surface);
    color: var(--fg-2);
    font-size: 0.9rem;
    line-height: 1.5;
}

/* What this deployment asked of their account, under the button that uses it. */
.profile-permission {
    margin-top: 1.25rem;
    max-width: 60ch;
}

/* Which board an admin page is editing. Above the sections rather than inside
   one: it is not part of any of the forms, it decides what all of them are
   about. */
.admin-did code {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--fg-3);
    word-break: break-all;
}

/* ── Board access ──────────────────────────────────────── */

/* A grid rather than a list: a deployment's membership is short enough to read
   at a glance on a laptop and long enough to be a lot of scrolling in one
   column, and auto-fill collapses it to one on a phone without a media query. */
.access-members {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    gap: 0.5rem;
    margin-top: 0.75rem;
}

/* Open to everyone: the ticks are still there and still editable, they just do
   not decide anything today. Dimmed rather than hidden or disabled — hiding
   them loses the work of setting them up, and disabling them means restricting
   a board again is two passes over the page instead of one. */
.access-members-moot { opacity: 0.55; }

.access-member {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.55rem 0.6rem 0.55rem 0.7rem;
    background: var(--bg-0);
    border-radius: var(--radius-xs);
    box-shadow: var(--shadow-xs);
    min-width: 0;
}

.access-member:hover { box-shadow: var(--shadow-sm); }

.access-member-pick {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex: 1;
    min-width: 0;
    cursor: pointer;
}

/* The board's admin role, as a chip at the row's end rather than a second
   checkbox floating over its word: lit while it is on, faded while the member
   is not on the board at all, since the role lives on the grant. */
.access-member-admin {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    flex: none;
    padding: 0.2rem 0.55rem 0.2rem 0.4rem;
    border-radius: var(--radius-pill);
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--fg-3);
    white-space: nowrap;
    cursor: pointer;
    transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

.access-member-admin:hover { color: var(--fg-2); }

.access-member-admin:has(input:checked) {
    background: var(--brand-surface);
    color: var(--brand);
}

.access-member-admin:has(input:disabled) {
    opacity: 0.45;
    cursor: default;
}

.access-member-admin input { margin: 0; }

.access-member-avatar {
    width: 28px;
    height: 28px;
    border-radius: var(--radius-pill);
    object-fit: cover;
    flex: 0 0 auto;
}

.access-member-avatar-fallback {
    display: grid;
    place-items: center;
    background: var(--brand-surface);
    color: var(--brand);
    font-size: 0.7rem;
    font-weight: 700;
}

.access-member-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.access-member-name {
    font-size: 0.9rem;
    color: var(--fg);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.access-member-note {
    font-size: 0.75rem;
    color: var(--fg-3);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.access-save { margin-top: 1rem; align-items: center; }

/* ── Admin sortable list (column reorder) ──────────────── */

.admin-sortable-list {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.admin-sortable-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.7rem 0.85rem;
    background: var(--bg-0);
    border: none;
    border-radius: var(--radius-xs);
    transition: all var(--duration) var(--ease);
    box-shadow: var(--shadow-xs);
}

.admin-sortable-item:hover {
    box-shadow: var(--shadow-sm);
}

.admin-sortable-handle {
    cursor: grab;
    color: var(--fg-3);
    font-size: 1.2rem;
    line-height: 1;
    user-select: none;
    padding: 0 0.2rem;
}

.admin-sortable-handle:active { cursor: grabbing; }

.admin-sortable-pos {
    font-size: 0.82rem;
    color: var(--fg-3);
    min-width: 1.5rem;
    text-align: center;
    font-weight: 600;
}

/* `min-width: 0` because a flex item's automatic minimum is its content's, and
   an <input>'s content minimum is the browser's default field width — so on a
   row whose buttons happened to be wide (a restricted board names a count) the
   name field stopped shrinking and pushed Delete past the row's own right
   edge, leaving one row of the list hanging a few pixels out of line. */
.admin-sortable-input {
    flex: 1;
    min-width: 0;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.4rem 0.65rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.9rem;
    background: var(--bg-1);
    color: var(--fg);
}

.admin-sortable-input:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-glow);
}

.admin-sortable-ghost {
    opacity: 0.3;
    background: var(--brand-surface);
}

.admin-sortable-chosen {
    box-shadow: var(--shadow-md);
}

/* ── Member preview (handle lookup) ────────────────────── */

.member-preview {
    margin-top: 1.25rem;
    border: none;
    border-radius: var(--radius);
    padding: 1.5rem;
    background: var(--bg-0);
    box-shadow: var(--shadow-sm);
}

.member-preview-card {
    display: flex;
    align-items: flex-start;
    gap: 1rem;
}

.member-preview-avatar {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    object-fit: cover;
    flex-shrink: 0;
    border: 3px solid var(--bg-1);
    box-shadow: var(--shadow-sm);
}

.member-preview-avatar-fallback {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    background: var(--brand-surface);
    color: var(--brand);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 1.15rem;
    font-weight: 700;
    flex-shrink: 0;
    border: 3px solid var(--bg-1);
}

.member-preview-info {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    min-width: 0;
}

.member-preview-info strong {
    font-family: var(--font-display);
    font-size: 1.05rem;
    font-weight: 700;
    color: var(--fg);
}

.member-preview-handle {
    font-size: 0.88rem;
    color: var(--fg-2);
}

.member-preview-did {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--fg-3);
    word-break: break-all;
    margin-top: 0.15rem;
}

.member-preview-bio {
    margin: 0.4rem 0 0;
    font-size: 0.88rem;
    color: var(--fg-2);
    line-height: 1.6;
}

.member-preview-note {
    margin: 1rem 0 0;
    font-size: 0.88rem;
    color: var(--fg-3);
    padding: 0.65rem 0.9rem;
    background: var(--brand-glow);
    border-radius: var(--radius-xs);
    border: none;
    font-style: italic;
}

.member-preview-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-top: 1rem;
}

.member-preview-actions select {
    border: 1px solid var(--border-2);
    border-radius: var(--radius-xs);
    padding: 0.55rem 0.85rem;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.88rem;
    background: var(--bg-1);
    color: var(--fg);
}

/* ── Scrollbars ────────────────────────────────────────── */

.column-cards::-webkit-scrollbar {
    width: 5px;
}

.column-cards::-webkit-scrollbar-track {
    background: transparent;
}

.column-cards::-webkit-scrollbar-thumb {
    background: var(--border-2);
    border-radius: 3px;
}

.column-cards::-webkit-scrollbar-thumb:hover {
    background: var(--border-3);
}

/* ── Reconnect modal ───────────────────────────────────── */

/* The whole of it, and the only place: there is no ReconnectModal.razor.css.
   The component shipped with the template's scoped stylesheet beside it, and a
   scoped selector carries an extra attribute — `#components-reconnect-modal
   [b-…]` outranks `#components-reconnect-modal` — so the template won every
   collision with this section and none of the styling here ever reached the
   screen. What members actually got was the framework's white box with a blue
   button and blue expanding rings, in *both* themes: in dark that is --fg text
   (near-white) on hard white, i.e. a dialog you cannot read, on the one screen
   that appears when something has already gone wrong. */

/* Nothing in this section may decide whether the dialog is on screen. That is
   showModal()'s job, and ReconnectModal.razor.js holds it back so a reconnect
   that lands quickly — every return to a backgrounded PWA — shows nothing at
   all. The template's sheet turned two of the framework's state classes into
   `display: block` on the <dialog> itself, which overrides the UA's
   `display: none` for a dialog nobody opened: the second reconnect attempt
   (`retrying`) or a spent one (`failed`) painted the dialog into the top of
   the page, out of the top layer, with no backdrop and no focus, while it was
   deliberately being kept off screen — and since showModal() was never called,
   no test watching for it could see that happen. It also carried
   `transition: display 0.5s allow-discrete`, which keeps a *closed* dialog
   painted for a beat after close(), which is the same flash from the other
   end. The state classes below only ever choose which line an open dialog
   shows. */

#components-reconnect-modal {
    /* A modal <dialog> is in the top layer and centres itself on auto margins.
       Never centre it with a transform: the entrance animates transform, so
       the box would grow out of the middle of the screen and snap into place
       at the end — the reason .modal-backdrop centres with flexbox. */
    margin: auto;
    width: min(23rem, calc(100vw - 2rem));
    padding: 0;
    border: none;
    border-radius: var(--radius);
    background: var(--bg-1);
    color: var(--fg);
    box-shadow: var(--shadow-xl);
    font-family: var(--font-body);
}

#components-reconnect-modal[open] {
    animation: slideUp 300ms var(--ease-out);
}

/* The browser focuses the dialog itself when it opens, because in the state it
   usually opens in there is nothing else to focus — the buttons only exist
   once there is something to press. Keeping that focus is the point (it is
   what a screen reader announces and what Escape acts on); painting a focus
   ring around a sentence of text is not, and it was a hard black rectangle
   drawn over the rounded corners. The buttons keep their own. */
#components-reconnect-modal:focus,
#components-reconnect-modal:focus-visible {
    outline: none;
}

#components-reconnect-modal::backdrop {
    /* Literal rather than a token: ::backdrop is rendered outside the element
       tree, and inheriting custom properties from the originating element is
       recent enough that a token here would be a colourless backdrop on an
       older engine. .modal-backdrop uses the same value. */
    background: rgba(25, 21, 19, 0.45);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    animation: fadeIn var(--duration) var(--ease-out);
}

.components-reconnect-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.9rem;
    padding: 2rem 1.75rem;
    text-align: center;
}

/* Every line starts hidden and the state class picks one. Keyed on the id so
   it outranks .btn-primary, which the two buttons also wear. */
#components-reconnect-modal .components-reconnect-container > * {
    display: none;
}

#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible {
    display: block;
}

/* The buttons are the app's pill button, and that is a flex box. */
#components-reconnect-modal.components-reconnect-failed button.components-reconnect-failed-visible,
#components-reconnect-modal.components-reconnect-paused button.components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed button.components-resume-failed-visible {
    display: inline-flex;
}

#components-reconnect-modal p {
    margin: 0;
    font-size: 0.95rem;
    line-height: 1.5;
    color: var(--fg);
}

/* What to do about it, under what happened — the same two-tier voice the
   empty states use, rather than one paragraph of equal weight. */
#components-reconnect-modal .reconnect-hint {
    display: inline-block;
    margin-top: 0.15rem;
    font-size: 0.85rem;
    color: var(--fg-2);
}

@media (max-width: 599px) {
    /* No blur on a phone, for .modal-backdrop's reason: backdrop-filter
       re-filters the whole viewport, which at a phone's pixel ratio is
       millions of pixels — and this particular dialog only ever appears on a
       device that is already struggling to reach the server. The scrim just
       darkens a little further instead. */
    #components-reconnect-modal::backdrop {
        background: rgba(25, 21, 19, 0.6);
        backdrop-filter: none;
        -webkit-backdrop-filter: none;
    }
}

@media (prefers-reduced-motion: reduce) {
    #components-reconnect-modal[open],
    #components-reconnect-modal::backdrop {
        animation: none;
    }
}

/* ── Nav links (views + admin) ────────────────────────── */

.nav-admin-links {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-shrink: 0;
}

/* The two readings of one board, held together so they read as one choice. */
.nav-views {
    display: inline-flex;
    align-items: center;
    gap: 0.15rem;
}

/* ── The phone's tabs ─────────────────────────────────── */

/* The bar's destinations, and they exist only on a phone — above 599px the two
   views are `.nav-views` in the row and there is nothing to fold away. Hidden
   in CSS rather than left out of the render, because `_isPhone` is a question
   the circuit has to ask the browser: a *bar* that arrives a round trip after
   the page is a bar that rearranges itself under a thumb, which is not a price
   the menu's own phone group pays (it is behind a closed door when it changes).
   The word under each mark is the same class on all four, including the two
   that are not `.nav-tab` at all — the search's trigger and the account's. */
.nav-tabs { display: none; }

.nav-tab-label { display: none; }

/* ── Header lead: the brand, then which board ──────────── */

/* The flexible half of the header. `min-width: 0` is the load-bearing bit: a
   flex item's floor is its content, so without it a long board name pushes the
   account and the hamburger off the row instead of being cut — which is what
   happened between 600 and 960px, where a landscape phone got a header with no
   menu on it at all. Above 960px the search box's block overrides both this and
   the flex below — the two sides split the row there, and that needs a floor
   rather than none. */
.nav-lead {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    min-width: 0;
    flex: 1 1 auto;
}

.nav-lead-sep {
    width: 1px;
    height: 22px;
    background: var(--border-2);
    flex: 0 0 auto;
}

/* ── Board switcher ───────────────────────────────────── */

/* The anchor for the flyout, which is absolutely positioned. Without this the
   menu would hang off the header instead of off the button. */
.nav-boards {
    position: relative;
    display: flex;
    min-width: 0;
}

/* The board is the page title, so it is set like one — the display face, next
   to the wordmark rather than among the nav links. Board names are
   user-supplied and some of them are sentences, so it truncates. */
.nav-board-title,
.nav-boards-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    min-width: 0;
    max-width: 24rem;
    font-family: var(--font-display);
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: -0.015em;
    color: var(--fg);
}

.nav-board-title {
    padding: 0.3rem 0.2rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav-boards-toggle {
    padding: 0.3rem 0.5rem 0.3rem 0.7rem;
    background: transparent;
    border: 1px solid transparent;
    border-radius: var(--radius-pill);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.nav-boards-toggle:hover {
    background: var(--bg-2);
    border-color: var(--border-1);
}

/* Over the click-catcher (which sits at z-index -1 in the header's stacking
   context), so the button that opened the flyout can also close it — the same
   arrangement as the column filter's toggle. */
.nav-boards-toggle[aria-expanded="true"] {
    background: var(--brand-surface);
    border-color: transparent;
    color: var(--brand);
    position: relative;
    z-index: 1;
}

.nav-boards-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav-boards-toggle > svg {
    flex: 0 0 auto;
    opacity: 0.6;
}

/* ── The header's flyout ──────────────────────────────── */

/* One of them now: the board switcher. The `Admin` flyout beside it opened onto
   a single row once making a board moved to the home page, and a door in front
   of a door is not a fold — it is the link. */
.nav-boards-menu {
    position: absolute;
    top: calc(100% + 0.4rem);
    z-index: 200;
    min-width: 15rem;
    max-height: 60vh;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.4rem;
    background: var(--bg-1);
    border: 1px solid var(--border-1);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lg);
    animation: flyoutIn 160ms var(--ease-out);
}

.nav-boards-menu { left: 0; }

@keyframes flyoutIn {
    from { opacity: 0; transform: translateY(-6px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* Says what the list is — worth two lines of type in the switcher, where the
   rows are board names and nothing else on screen names the category. */
.nav-flyout-head {
    margin: 0.15rem 0.35rem 0.3rem;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--fg-3);
}

.nav-board-option {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.7rem;
    border-radius: var(--radius-xs);
    color: var(--fg);
    text-decoration: none;
    font-size: 0.92rem;
    font-weight: 500;
    transition: all var(--duration) var(--ease);
}

.nav-board-option:hover {
    background: var(--brand-glow);
    color: var(--brand);
    text-decoration: none;
}

.nav-board-option-name {
    flex: 1 1 auto;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav-board-option-active {
    background: var(--brand-surface);
    color: var(--brand);
    font-weight: 600;
}

/* A tick as well as the fill: the rows are user-named boards, and "which one am
   I on" should not rest on a tint alone. */
.nav-board-option-tick {
    flex: 0 0 auto;
    opacity: 0;
}

.nav-board-option-active .nav-board-option-tick { opacity: 1; }

/* The general calendar is a row in the same list, because picking it is the same
   kind of choice — but it is a choice of *all* of the rows under it rather than
   one of them, so a hairline says where the list proper starts. */
.nav-board-option-all {
    border-bottom: 1px solid var(--border);
    border-radius: var(--radius-xs) var(--radius-xs) 0 0;
    margin-bottom: 0.15rem;
    padding-bottom: 0.55rem;
}

/* Below a laptop the row is tight, and the two things it can lose without
   losing meaning are the wordmark — the mark itself is still there, and the
   board's name is the more useful title — and the handle, which the avatar
   beside it already stands for. Not in the phone/tablet blocks at the bottom of
   this file, because the width that matters here is one span across both. */
@media (max-width: 959px) {
    .board-header h1 { display: none; }

    .board-user-handle { display: none; }
}

/* ── The account menu: one door, one shape ────────────── */

/* The trigger. There used to be three controls in this corner — a sparkle
   button, an account chip that looked pressable and was not, and a hamburger —
   and between 600 and 960px they were most of the row. The avatar is the
   button now: it says whose menu it is and opens it, which is one thing rather
   than two beside each other. */
.nav-menu-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    position: relative;
    padding: 0.25rem 0.6rem 0.25rem 0.3rem;
    background: var(--bg-2);
    border: 1px solid var(--border-1);
    border-radius: var(--radius-pill);
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.88rem;
    font-weight: 500;
    cursor: pointer;
    flex-shrink: 0;
    transition: all var(--duration) var(--ease);
}

.nav-menu-toggle:hover {
    background: var(--bg-3);
    border-color: var(--border-2);
    color: var(--fg);
}

/* Over the click-catcher (z-index -1 in the header's stacking context) so the
   button that opened the menu can also close it — the switcher's arrangement. */
.nav-menu-toggle[aria-expanded="true"] {
    background: var(--bg-3);
    border-color: var(--border-2);
    position: relative;
    z-index: 1;
}

.nav-menu-toggle-caret {
    flex: 0 0 auto;
    opacity: 0.6;
    transition: transform var(--duration) var(--ease);
}

.nav-menu-toggle[aria-expanded="true"] .nav-menu-toggle-caret {
    transform: rotate(180deg);
}

.nav-avatar {
    width: 28px;
    height: 28px;
    border-radius: 50%;
    object-fit: cover;
    background: var(--brand-surface);
    border: none;
    flex-shrink: 0;
    box-shadow: 0 0 0 1px var(--border-2);
}

.nav-avatar-fallback {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--brand);
    font-size: 0.72rem;
    font-weight: 700;
    font-family: var(--font-display);
}

/* The same face, at the size a heading takes: in the menu it is identity
   rather than a target. */
.nav-avatar-lg {
    width: 38px;
    height: 38px;
}

.nav-avatar-lg.nav-avatar-fallback {
    font-size: 0.92rem;
}

.board-user-handle {
    color: var(--fg);
    font-weight: 500;
    font-size: 0.88rem;
    line-height: 1;
}

/* The whole advertisement, for everything in here that can be new: the install
   offer and a release nobody has read about. One dot rather than a dot and a
   button, because two nudges a thumb apart are a question about which is which
   — the menu's own rows say what it was. */
/* Inside the pill's curve, not hanging off its corner: at (0,0) a dot on a
   40px-tall pill is outside the radius and reads as a stray mark. */
.nav-menu-dot {
    position: absolute;
    top: 2px;
    right: 2px;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: var(--brand);
    /* Punches a ring out of the button so the dot reads at any header colour. */
    box-shadow: 0 0 0 2px var(--bg-1);
}

/* ── The search box ───────────────────────────────────── */

/* One box over every board, and the middle of the header on purpose: it is
   about all of them, so it belongs to neither the board on the left nor the
   account on the right. It is a flex item between the two rather than an
   absolutely-centred overlay: an overlay would sit *on top* of a long board
   name at exactly the widths where the row is already tight. Being between them
   is not by itself the middle, though — the block below is what makes it one. */
.nav-search {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 1 26rem;
    min-width: 0;
    margin: 0 1rem;
}

/* Middle of the row means the middle of the *row*, not the middle of whatever
   is left over between the two sides — and those two are never the same width,
   so being the item between them centres nothing. The account side carries the
   two view links, a board's Settings and the avatar; the board side carries a name
   somebody chose. At 1440px that put the box 56px right of centre, and at
   1100px 109px left of it: it drifted with the board name, which is the one
   thing in the header the box is *not* about.

   Both sides get `flex: 1 1 0`, so they split the leftover in half and the item
   between the halves lands on the middle. The `0` basis is the whole trick —
   with `auto` the split starts from each side's content width and so inherits
   exactly the difference this is here to cancel.

   Still three items in flow rather than an absolutely-centred overlay, for the
   reason above: an overlay would sit on top of a long board name. Two prices,
   both deliberate. A long board name now runs out of room at half the row
   rather than at the account's edge — half the row is what "centred" *means*,
   and the name has an ellipsis. And where the account side needs more than its
   half — a small laptop with a board's Settings out — its content floor wins, it
   takes what it needs and the box drifts left again rather than the links
   wrapping: an off-centre box beats a broken row.

   `min-width` is the other half of that last one. A basis of 0 leaves the lead
   with no floor at all, so at 960px the account's floor ate the row and the
   *wordmark* was clipped mid-letter — the header can be off-centre but it
   cannot stop saying where you are. 13rem is the mark, the wordmark and enough
   board name to tell which board it is; under that the search shrinks instead,
   which is what it did before any of this. */
@media (min-width: 960px) {
    .nav-lead {
        flex: 1 1 0;
        min-width: 13rem;
    }

    .board-user {
        flex: 1 1 0;
        justify-content: flex-end;
    }
}

/* The compact trigger is the small-viewport shape and nothing else here: above
   960px the field is already on screen and a second way in beside it would be
   the header saying the same thing twice. */
.nav-search-compact { display: none; }

.nav-search-field {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    min-width: 0;
    padding: 0 0.7rem;
    height: 38px;
    background: var(--bg-2);
    border: 1px solid transparent;
    border-radius: var(--radius-pill);
    transition: background var(--duration) var(--ease), border-color var(--duration) var(--ease);
}

.nav-search-open .nav-search-field {
    background: var(--bg-1);
    border-color: var(--border-2);
    box-shadow: var(--shadow-sm);
}

.nav-search-mark {
    display: flex;
    flex: 0 0 auto;
    color: var(--fg-3);
}

.nav-search-mark svg { width: 16px; height: 16px; }

.nav-search-input {
    flex: 1 1 auto;
    min-width: 0;
    border: 0;
    background: none;
    color: var(--fg);
    font: inherit;
    font-size: 0.9rem;
    outline: none;
    padding: 0;
}

.nav-search-input::placeholder { color: var(--fg-3); }

.nav-search-clear {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-pill);
    background: var(--bg-3);
    color: var(--fg-2);
    cursor: pointer;
}

.nav-search-clear:hover { color: var(--fg); }

/* Only the phone's full-screen shape shows a way out in words: on a desktop the
   click-catcher and Escape are both there, and neither exists once the panel
   covers the screen. */
.nav-search-close { display: none; }

.nav-search-panel {
    position: absolute;
    top: calc(100% + 0.5rem);
    left: 0;
    right: 0;
    z-index: 200;
    max-height: min(60vh, 30rem);
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.4rem;
    background: var(--bg-1);
    border: 1px solid var(--border-1);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lg);
    animation: flyoutIn 160ms var(--ease-out);
}

/* The teaching tip: four operators, the syntax and what it finds. A row and not
   a paragraph, because what somebody has to learn here is the shape of the
   thing they type. */
.nav-search-tips {
    margin: 0;
    padding: 0 0.35rem 0.3rem;
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.nav-search-tips li {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    font-size: 0.85rem;
    color: var(--fg-2);
}

.nav-search-tips code {
    flex: 0 0 auto;
    min-width: 8.5rem;
    font-family: var(--font-mono);
    font-size: 0.78rem;
    color: var(--brand);
}

.nav-search-empty {
    margin: 0;
    padding: 0.9rem 0.7rem;
    font-size: 0.88rem;
    color: var(--fg-2);
    text-align: center;
}

/* A hit is three lines that answer three questions in the order they are asked:
   what is it, what does it say, and where is it. The place matters more here
   than it ever does on a board — the whole point of this box is that the card
   is somewhere you are not looking. */
.nav-search-hit {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    padding: 0.5rem 0.7rem;
    border-radius: var(--radius-xs);
    color: var(--fg);
    text-decoration: none;
}

.nav-search-hit:hover,
.nav-search-hit-active {
    background: var(--brand-glow);
    text-decoration: none;
}

.nav-search-hit-title {
    font-size: 0.92rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav-search-hit-preview {
    font-size: 0.82rem;
    color: var(--fg-2);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav-search-hit-meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.4rem;
    font-size: 0.74rem;
    color: var(--fg-3);
}

.nav-search-hit-place {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.nav-search-hit-tag {
    padding: 0.05rem 0.4rem;
    border-radius: var(--radius-pill);
    background: var(--bg-2);
    color: var(--fg-2);
}

/* Only on a row that came back for something written in its discussion, where
   neither the title nor the preview holds the word that was typed. Tinted
   rather than another grey pill: it is the answer to "why is this here", not a
   third piece of metadata. */
.nav-search-hit-comment {
    padding: 0.05rem 0.4rem;
    border-radius: var(--radius-pill);
    background: var(--brand-surface);
    color: var(--brand);
}

/* Between the field and the magnifier: the board name and the account still own
   the row, and a 26rem box between them is what pushed the admin links off it.
   Same threshold the admin links already fold at. */
@media (max-width: 959px) {
    .nav-search {
        flex: 0 0 auto;
        margin: 0;
    }

    /* The trigger wears the account's surface — a filled pill with a hairline —
       rather than a bare glyph. Beside a face in a pill it read as a decoration
       of the row instead of the third thing in it you can press, and it is the
       only one of the three that is not also a word, so it had nothing else to
       say it was pressable. Same box as `.nav-menu-toggle` next to it, so the
       two read as a pair. */
    .nav-search-compact {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 46px;
        height: 40px;
        padding: 0;
        border: 1px solid var(--border-1);
        border-radius: var(--radius-pill);
        background: var(--bg-2);
        color: var(--fg-2);
        cursor: pointer;
        transition: all var(--duration) var(--ease);
    }

    /* The mark grows with the button it is alone in: at 18px inside 46 it was a
       glyph adrift in a pill. The one inside the field stays 16px — there it is
       a label beside text, not the control. */
    .nav-search-compact svg { width: 20px; height: 20px; }

    .nav-search-compact:hover { background: var(--bg-3); color: var(--fg); }

    /* Over the click-catcher, so the button that opened the box also closes it
       — the board switcher's arrangement, for the board switcher's reason. */
    .nav-search-compact[aria-expanded="true"] {
        background: var(--brand-surface);
        border-color: transparent;
        color: var(--brand);
        position: relative;
        z-index: 1;
    }

    .nav-search-field { display: none; }

    /* Open, the box is a flyout under the header — the shape the board switcher
       and the admin fold already are at this width, and the shape it has on a
       desktop. It was a full-screen mode instead, and two things were wrong
       with that. Eight rows of results is a flyout's worth of content and not a
       page's, so most of what it covered the board with was blank. And a
       takeover is a heavier promise than the thing it is for: you left the
       board rather than glanced away from it, and the only way back was one
       word in a corner.

       Right-aligned to the window and not hung off the trigger, which is the
       one thing that cannot be done here: what is between the trigger and the
       edge is the two view links and the Members link, ~320px of them, so a
       26rem panel anchored to the trigger's right edge starts past the left
       edge of a 620px window. Anchored to the window instead it always fits,
       and the trigger is always inside its span. Fixed rather than absolute for
       that, which costs the header's height as a constant: 64px, the same one
       the board and the calendar are already sized against. */
    .nav-search-open .nav-search-field {
        display: flex;
        position: fixed;
        inset: calc(64px + 0.5rem) 0.75rem auto auto;
        z-index: 300;
        width: min(26rem, calc(100vw - 1.5rem));
        animation: flyoutIn 160ms var(--ease-out);
    }
}

/* ── The menu ─────────────────────────────────────────── */

/* Four groups, hairlines between them, and one row shape inside: who you are,
   where to go (phone only), what this menu does, what you can set — then the
   way out. It used to be two stacked pill blocks and three differently-shaped
   full-width buttons in no particular order. */
.nav-menu {
    position: absolute;
    top: calc(100% + 0.5rem);
    right: 1rem;
    background: var(--bg-1);
    border: 1px solid var(--border-1);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lg);
    padding: 0.4rem;
    display: flex;
    flex-direction: column;
    transform: translateY(-8px);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity var(--duration) var(--ease),
                transform var(--duration) var(--ease),
                visibility 0s var(--duration);
    z-index: 200;
    min-width: 19rem;
}

/* Invisible full-screen click-catcher. Lives inside the sticky header's
   stacking context (z 100): negative z keeps it below the header's own
   children — so nav links stay one-click while the menu is open (navigation
   closes it) — yet the header context still floats above all page content,
   so clicks anywhere else land here and dismiss. The menu itself is z 200. */
.nav-menu-backdrop {
    position: fixed;
    inset: 0;
    z-index: -1;
    background: transparent;
}

.nav-menu-open {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
    pointer-events: auto;
    transition: opacity var(--duration) var(--ease),
                transform var(--duration) var(--ease),
                visibility 0s 0s;
}

/* Only the phone's sheet shows one. */
.nav-menu-sheet-handle { display: none; }

/* A group is a run of rows; the hairline is what says the next run answers a
   different question. Drawn between rather than around, so the menu is one
   surface and not a stack of cards. */
.nav-menu-group {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.3rem 0;
    border-top: 1px solid var(--border-1);
}

.nav-menu-account {
    display: flex;
    align-items: center;
    gap: 0.65rem;
    padding: 0.5rem 0.6rem 0.7rem;
    min-width: 0;
}

/* The identity block is also the door to /profile. It borrows the rows' hover
   rather than inventing one — it is the same gesture, on the block that happens
   to be a picture of the member instead of an icon and a word. */
a.nav-menu-account {
    color: inherit;
    text-decoration: none;
    border-radius: var(--radius-xs);
    transition: background var(--duration) var(--ease);
}

a.nav-menu-account:hover { background: var(--brand-glow); }

.nav-menu-account-go {
    flex: 0 0 auto;
    margin-left: auto;
    color: var(--fg-3);
    transition: color var(--duration) var(--ease);
}

a.nav-menu-account:hover .nav-menu-account-go { color: var(--brand); }

.nav-menu-account-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.nav-menu-account-name {
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--fg);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Said for an admin only: it is why the pages below it are there, and the word
   for everybody else would be a label saying "ordinary". */
.nav-menu-account-role {
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--brand);
}

/* ── Menu rows: one shape for everything the menu does ── */

/* Icon, word, and whatever the row has to say on the right. Every action in
   here is this shape now — the install offer was a two-line brand-filled block,
   sharing was a bordered pill and the release notes a third thing, which is
   three answers to one question stacked on top of each other. */
.nav-menu .nav-menu-row {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    width: 100%;
    min-height: 40px;
    padding: 0.5rem 0.6rem;
    border: none;
    background: none;
    color: var(--fg);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.92rem;
    font-weight: 500;
    text-align: left;
    border-radius: var(--radius-xs);
    cursor: pointer;
    transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

.nav-menu .nav-menu-row:hover:not(:disabled) {
    background: var(--brand-glow);
    color: var(--brand);
}

.nav-menu .nav-menu-row:disabled {
    opacity: 0.55;
    cursor: default;
}

.nav-menu .nav-menu-row svg {
    flex: 0 0 auto;
    color: var(--fg-3);
    transition: color var(--duration) var(--ease);
}

/* One icon column down the whole sheet, whatever each row's own markup says.
   `ShareButton` ships a 17px mark because it is also a square icon button in
   the card dialog, and one pixel of icon is one pixel of indent — enough to
   put its word out of line with every other word in the menu. `:first-child`
   so the fold's caret, which is the row's *other* svg, keeps its size. */
.nav-menu .nav-menu-row > svg:first-child,
.nav-menu-link > svg:first-child {
    width: 18px;
    height: 18px;
}

.nav-menu .nav-menu-row:hover:not(:disabled) svg { color: var(--brand); }

.nav-menu-row-label,
.nav-menu .share-button-label {
    flex: 1 1 auto;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The sparkle is the one icon here that is a picture of its own row rather than
   a verb, so it keeps its colour whether or not the row is being pointed at. */
.nav-menu .nav-menu-whatsnew svg {
    fill: currentColor;
    color: var(--brand);
}

/* What the dot on the trigger was about, in words, beside the row that answers
   it. This is the only badge in the menu: a second one would make the first a
   decoration. */
.nav-menu-badge {
    flex: 0 0 auto;
    font-size: 0.65rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    padding: 0.15rem 0.45rem;
    border-radius: var(--radius-pill);
    background: var(--brand);
    color: var(--brand-contrast);
}

/* The install offer keeps a tint, because it is the one row that is an offer
   rather than a thing you came here to do — but a tint on the icon, not a
   filled block: it was the loudest surface in the menu for something most
   members do once. */
.nav-menu .nav-menu-install svg { color: var(--brand); }

.nav-install-hint {
    margin: 0;
    padding: 0.15rem 0.6rem 0.35rem 1.9rem;
    font-size: 0.78rem;
    line-height: 1.45;
    color: var(--fg-2);
}

/* ── Where to go: the Members row ─────────────────────── */

/* Deliberately the same box as `.nav-menu-row` above, gap included: the row is
   where-to-go rather than what-this-menu-does, but it is a row in the same
   sheet, and one list with two left margins is what a sheet with only half its
   rows carrying an icon looks like. */
.nav-menu-link {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    min-height: 40px;
    padding: 0.5rem 0.6rem;
    border-radius: var(--radius-xs);
    color: var(--fg);
    text-decoration: none;
    font-size: 0.92rem;
    font-weight: 500;
    transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

.nav-menu-link > svg {
    flex: 0 0 auto;
    color: var(--fg-3);
    transition: color var(--duration) var(--ease);
}

.nav-menu-link:hover {
    background: var(--brand-glow);
    color: var(--brand);
    text-decoration: none;
}

.nav-menu-link:hover > svg,
.nav-menu-link-active > svg { color: var(--brand); }

.nav-menu-link-active {
    background: var(--brand-surface);
    color: var(--brand);
    font-weight: 600;
}

/* ── Prefs (theme + language) ─────────────────────────── */

/* One line each: label on the left, the choice on the right. Stacked
   label-over-pills they were four rows and the tallest thing in the menu, for
   the two controls in here nobody opens the menu to find. */
.nav-menu-prefs {
    gap: 0.35rem;
    padding: 0.45rem 0;
}

.nav-pref-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    padding: 0 0.6rem;
}

.nav-pref-label {
    font-size: 0.88rem;
    font-weight: 500;
    color: var(--fg-2);
    white-space: nowrap;
}

.nav-pref-options {
    display: flex;
    gap: 0.2rem;
    background: var(--bg-2);
    padding: 0.2rem;
    border-radius: var(--radius-pill);
    border: 1px solid var(--border-1);
    flex: 0 0 auto;
}

.nav-pref-options button {
    border: none;
    background: transparent;
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.8rem;
    font-weight: 500;
    padding: 0.32rem 0.6rem;
    border-radius: var(--radius-pill);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
    white-space: nowrap;
}

.nav-pref-options button:hover {
    background: var(--bg-1);
    color: var(--fg);
}

/* The language pill is told which one is active by the render that translated
   it. The theme pill is not: the theme is a browser-only concern, and reading
   it back off :root is what lets the tap land without waiting for a round-trip
   — no data-theme at all is "auto", the attribute's value is a pinned choice. */
.nav-pref-options button.active,
:root:not([data-theme]) .nav-pref-options button[data-theme-set="auto"],
:root[data-theme="light"] .nav-pref-options button[data-theme-set="light"],
:root[data-theme="dark"] .nav-pref-options button[data-theme-set="dark"] {
    background: var(--brand);
    color: var(--brand-contrast);
    box-shadow: var(--shadow-xs);
}

/* ── The foot: leaving, and which version this is ─────── */

.nav-menu-foot {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.3rem 0 0;
    border-top: 1px solid var(--border-1);
}

.nav-menu-signout {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    flex: 1 1 auto;
    min-height: 40px;
    border: none;
    background: none;
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.92rem;
    font-weight: 500;
    padding: 0.5rem 0.6rem;
    text-align: left;
    cursor: pointer;
    border-radius: var(--radius-xs);
    transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

.nav-menu-signout svg {
    flex: 0 0 auto;
    color: var(--fg-3);
    transition: color var(--duration) var(--ease);
}

.nav-menu-signout:hover {
    background: var(--danger-bg);
    color: var(--danger);
}

.nav-menu-signout:hover svg { color: var(--danger); }

/* The one place in the app that names the running version, and a footnote on
   purpose: it is what a bug report needs, not what the What's new row is for —
   which is where it used to live, as a second line under a label that already
   said everything. */
.nav-menu-version {
    flex: 0 0 auto;
    padding-right: 0.6rem;
    font-size: 0.72rem;
    font-weight: 500;
    color: var(--fg-3);
    font-variant-numeric: tabular-nums;
}

/* ── What's new: the dialog ───────────────────────────── */

.changelog-modal {
    width: min(560px, 100%);
}

.changelog-modal .modal-header h2 {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.changelog-sparkle {
    fill: var(--brand);
    flex-shrink: 0;
}

.changelog-body {
    gap: 1rem;
}

.changelog-intro {
    margin: 0;
    font-size: 0.88rem;
    color: var(--fg-2);
}

/* The rail. Every release used to be its own filled panel, which made nine of
   them nine equal announcements — and the newest, the only one this reader is
   here for, a tint away from the eight they had already read. A line down the
   left with a mark per release says the same thing the panels were trying to:
   these are one story, newest first. */
.changelog-timeline {
    display: flex;
    flex-direction: column;
    position: relative;
    padding-left: 1.15rem;
}

/* Drawn on the container rather than per release, so it runs behind the marks
   instead of restarting at each one — and stops at the last, which is what
   makes the bottom of the list read as the end rather than as a cut. */
.changelog-timeline::before {
    content: "";
    position: absolute;
    left: 3px;
    top: 0.6rem;
    bottom: 0.6rem;
    width: 2px;
    border-radius: 1px;
    background: var(--border-1);
}

.changelog-release {
    display: flex;
    flex-direction: column;
    gap: 0.45rem;
    position: relative;
    padding: 0.15rem 0 1.15rem;
}

.changelog-release:last-child { padding-bottom: 0.15rem; }

.changelog-dot {
    position: absolute;
    left: -1.15rem;
    top: 0.5rem;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--border-3);
    box-shadow: 0 0 0 3px var(--bg-1);
}

/* The one at the top of the story is filled; the ones behind it are markers.
   The unreleased block heads the story where there is one, and is drawn hollow
   instead — said here rather than left to whichever rule is declared last. */
.changelog-release-top:not(.changelog-release-unreleased) .changelog-dot { background: var(--brand); }

/* The block that has not been released yet. It sits at the top of the story on
   the dev stack and is simply absent everywhere else, so it is marked as a
   heading rather than as a date and its mark on the rail is hollow: this is
   what is coming, not something that happened on a day. */
.changelog-release-unreleased .changelog-dot {
    background: var(--bg-1);
    border: 2px solid var(--brand);
}

.changelog-release-unreleased .changelog-release-date {
    color: var(--fg-2);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-size: 0.78rem;
}

/* The entry this reader has not been shown, marked while they read it — the
   dot on the menu said there was something new and this is where it is. A tint
   and a ring, not a panel: it has to be the loudest thing in the dialog and
   there is nothing else painted for it to compete with now. */
.changelog-release-new {
    background: var(--brand-surface);
    border-radius: var(--radius-sm);
    padding: 0.7rem 0.9rem 0.9rem;
    margin: -0.1rem -0.9rem 0.4rem;
}

.changelog-release-new .changelog-dot {
    left: -0.25rem;
    top: 1.05rem;
    box-shadow: 0 0 0 3px var(--brand-surface);
}

.changelog-release-head {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.changelog-release-date {
    margin: 0;
    font-family: var(--font-display);
    font-size: 0.95rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--fg);
}

/* Behind the head of the rail, the date is a marker rather than a headline. */
.changelog-release:not(.changelog-release-top) .changelog-release-date {
    font-size: 0.88rem;
    color: var(--fg-2);
}

.changelog-badge {
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    padding: 0.12rem 0.5rem;
    border-radius: var(--radius-pill);
    background: var(--brand);
    color: var(--brand-contrast);
}

.changelog-version {
    margin-left: auto;
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--fg-3);
    font-variant-numeric: tabular-nums;
}

.changelog-list {
    margin: 0;
    padding-left: 1.1rem;
    display: flex;
    flex-direction: column;
    gap: 0.45rem;
}

.changelog-list li {
    font-size: 0.92rem;
    line-height: 1.55;
    color: var(--fg-2);
}

.changelog-list li::marker {
    color: var(--brand);
}

/* The archive, behind one press. Styled as a quiet row rather than a button
   with a fill: it is the end of the list, not the thing the dialog is for. */
.changelog-more {
    align-self: flex-start;
    margin-left: 1.15rem;
    padding: 0.4rem 0.75rem;
    border: 1px solid var(--border-1);
    border-radius: var(--radius-pill);
    background: none;
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 500;
    cursor: pointer;
    transition: all var(--duration) var(--ease);
}

.changelog-more:hover {
    background: var(--brand-glow);
    border-color: transparent;
    color: var(--brand);
}

/* ── Report a problem: the dialog ─────────────────────── */
/* Narrower than the card dialog and than What's new, because it is one box:
   a 720px modal around a single textarea reads as a form with the rest of the
   fields missing. Everything else is the shared .modal / .field kit — nothing
   here is a second look for a dialog somebody opens once. */

.feedback-modal {
    width: min(480px, 100%);
}

.feedback-text {
    min-height: 8rem;
    line-height: 1.55;
}

/* The only prose in the dialog, and it is one line: where it goes and whose
   name is on it. Under the box rather than above, so it is read on the way to
   Send and not on the way in. */
.feedback-note {
    margin: 0;
    font-size: 0.78rem;
    color: var(--fg-3);
    line-height: 1.5;
}

.feedback-thanks {
    margin: 0;
    font-size: 0.95rem;
    line-height: 1.55;
}

/* ── Loading spinner ──────────────────────────────────── */

.spinner {
    position: relative;
    width: 2.6rem;
    height: 1.5rem;
}

.spinner {
    --windbar: var(--brand);
}

.spinner::before,
.spinner::after {
    content: "";
    position: absolute;
    left: 0;
    height: 4px;
    border-radius: 999px;
    background: var(--windbar);
    animation: wind-drift 1.1s ease-in-out infinite;
}

.spinner::before {
    top: 0;
    width: 100%;
}

.spinner::after {
    top: 10px;
    width: 70%;
    left: 8%;
    opacity: 0.7;
    animation-delay: 0.18s;
}

@keyframes wind-drift {
    0%, 100% { transform: translateX(0); opacity: 0.35; }
    50%      { transform: translateX(-7px); opacity: 1; }
}

.board-loading {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 1.25rem;
    min-height: calc(100vh - 64px);
    color: var(--fg-3);
    font-size: 0.93rem;
}

/* ── Empty states ─────────────────────────────────────── */

.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: calc(100vh - 64px);
    padding: 2rem;
    text-align: center;
}

.empty-state-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 72px;
    height: 72px;
    border-radius: 50%;
    background: var(--brand-glow);
    color: var(--brand);
    margin-bottom: 1.25rem;
}

.empty-state-icon svg {
    width: 32px;
    height: 32px;
    stroke-width: 1.75;
}

.empty-state-title {
    margin: 0 0 0.5rem;
    font-family: var(--font-display);
    font-size: 1.3rem;
    font-weight: 700;
    color: var(--fg-2);
    letter-spacing: -0.02em;
}

/* The one empty state with a button in it besides the home page's own: a
   deleted board's Restore, between the two lines that say what happened. */
.empty-state-action {
    margin: 1rem 0 0.25rem;
}

.empty-state-hint {
    margin: 0;
    font-size: 0.93rem;
    color: var(--fg-3);
    max-width: 360px;
    line-height: 1.6;
}

/* ── Focus-visible ────────────────────────────────────── */
/* One ring for everything — see the generic rule near the end of the file. */

/* ── Toast notifications ──────────────────────────────── */

.toast-container {
    position: fixed;
    bottom: 1.75rem;
    left: 50%;
    transform: translateX(-50%);
    z-index: 2000;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    align-items: center;
    pointer-events: none;
}

.toast {
    display: inline-flex;
    align-items: center;
    gap: 0.6rem;
    background: var(--toast-bg);
    color: var(--toast-fg);
    padding: 0.75rem 1.35rem 0.75rem 1.1rem;
    border-radius: var(--radius-pill);
    font-size: 0.9rem;
    font-weight: 600;
    box-shadow: var(--shadow-lg);
    border: 1px solid rgba(255, 255, 255, 0.06);
    animation: toastIn 380ms cubic-bezier(0.34, 1.56, 0.64, 1);
    /* Click-through, like the container: a toast has nothing to press, and
       taking clicks only made it a lid over whatever it floats above for its
       three seconds — on a board that is the middle column's "+ Add card",
       straight after the card that raised it. */
    pointer-events: none;
    white-space: nowrap;
}

.toast-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex: 0 0 8px;
    box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.16);
}

.toast-success .toast-dot {
    background: var(--success);
    box-shadow: 0 0 0 3px rgba(54, 211, 153, 0.18);
}

.toast-danger .toast-dot {
    background: #f87171;
    box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.18);
}

.toast-exit {
    animation: toastOut 200ms var(--ease) forwards;
}

@keyframes toastIn {
    from { opacity: 0; transform: translateY(16px) scale(0.92); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes toastOut {
    from { opacity: 1; transform: translateY(0) scale(1); }
    to   { opacity: 0; transform: translateY(-10px) scale(0.92); }
}

/* ── Drag state: disable scroll-snap during drag ──────── */

.board-dragging {
    scroll-snap-type: none !important;
}

/* ── Board pagination dots (phone only) ──────────────── */

.board-pagination {
    display: none;
}

.board-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    border: none;
    padding: 0;
    background: var(--fg-3);
    opacity: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    box-sizing: border-box;
    transition: background 240ms var(--ease-out),
                width 320ms var(--ease-out),
                height 320ms var(--ease-out),
                flex 320ms var(--ease-out),
                min-height 320ms var(--ease-out),
                border-radius 320ms var(--ease-out),
                padding 240ms var(--ease-out);
}

.board-dot::before {
    content: attr(data-label);
    display: block;
    max-width: 0;
    max-height: 0;
    opacity: 0;
    font-size: 0.9rem;
    font-weight: 600;
    color: var(--fg);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    pointer-events: none;
    transition: opacity 200ms var(--ease-out) 80ms,
                max-width 320ms var(--ease-out),
                max-height 320ms var(--ease-out);
}

.board-dot-active {
    background: var(--brand);
    width: 24px;
    border-radius: 4px;
}

/* ══════════════════════════════════════════════════════════
   RESPONSIVE — Phone (max-width: 599px)
   ══════════════════════════════════════════════════════════ */

@media (max-width: 599px) {

    /* ── The nav is a bar on the *bottom* edge ──
       Both of the things in it open a sheet on the bottom edge, and both of
       them used to sit at the top: you reached the far corner of a one-handed
       device to open something that then appeared under your thumb, and
       reached back up to change your mind. The whole bar comes down to where
       its sheets are — one bar rather than a header plus a tab strip, so
       nothing is named twice and the 56px is still spent once. Taking it out
       of flow costs the pages that size themselves nothing, because they were
       already subtracting exactly this much; `body` pads for the ones that
       simply scroll. */
    .board-header {
        position: fixed;
        inset: auto 0 0 0;
        height: var(--phone-nav-h);
        padding: 0 0.5rem env(safe-area-inset-bottom, 0px);
        gap: 0.25rem;
        /* The hairline changes sides with the bar: it divides it from the page
           above it now, not from the page below. */
        box-shadow: 0 -1px 0 var(--border-1);
    }

    /* ── The bar is where you can go, and it is the same bar everywhere ──
       It held a wordmark, the board's name and a magnifier, and left most of
       the widest slot on the device empty — while Board and Calendar, the two
       readings every member came for, were two taps down inside the account
       sheet. The emptiness went to four destinations, each a mark with the
       word under it: Home, Calendar, Search and the account.

       The whole lead goes, board switcher included. The wordmark leads where
       the Home cell does, and the switcher was the one thing that made the bar
       a different bar on a board — it took the front of the row, and the Home
       cell became a Board tab, so the way to every other board was a row in
       the account sheet. Home is one tap to every board, and the board page
       names its own board in a bar of its own (`.board-phone-bar`). */
    .nav-lead { display: none; }

    /* One shape, four cells — and two of them are not `.nav-tab`: the search's
       trigger and the account's take the same box, so the row is four marks
       with four words under them rather than two tabs standing beside two
       pills. 48px tall inside a 56px bar, which is the touch target with the
       label inside it rather than beside it. */
    .nav-tabs {
        display: flex;
        align-items: stretch;
    }

    .nav-tab,
    .nav-search-compact,
    .nav-menu-toggle {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 1px;
        width: 100%;
        min-width: 52px;
        height: 48px;
        padding: 0;
        background: none;
        border: 0;
        border-radius: var(--radius-sm);
        color: var(--fg-3);
        text-decoration: none;
        font-family: var(--font-body);
        font-size: 0.625rem;
        font-weight: 500;
        letter-spacing: -0.005em;
        line-height: 1;
        cursor: pointer;
        transition: color var(--duration) var(--ease);
    }

    /* The four cells split whatever the switcher leaves. So a deployment with
       one board — where the name is not a choice, and is hidden below — gets
       four tabs spread across the whole width, and one with six gets a switcher
       and four narrower tabs. Either way nothing in the row is left empty,
       which is the half of this it kept getting wrong.

       `1 1 0` on every cell and not `1 1 auto`: an `auto` basis splits the
       leftover in proportion to how long each word happens to be, so "Calendar"
       would get a wider target than "Board" for no reason a thumb cares about.
       `.nav-tabs` takes two shares because it holds two of the four; the search
       and the account are wrappers with one cell each inside them (the wrapper
       is what their sheet hangs off), so the share goes to the wrapper and the
       button fills it — otherwise the cell is wide and the thing you can press
       inside it is 52px. These come *after* the shape above, because that rule
       names `.nav-tab` too and a later `flex` on one class beats an earlier one
       on three. */
    .nav-tabs { flex: 2 1 0; }

    .nav-tab,
    .nav-search,
    .board-user { flex: 1 1 0; }

    .nav-tab svg,
    .nav-search-compact svg {
        width: 20px;
        height: 20px;
        flex: 0 0 auto;
    }

    .nav-tab-label {
        display: block;
        max-width: 100%;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    /* The page you are on, said in the brand colour on both halves of the cell.
       No pill behind it: a filled cell in a row of four is a fifth surface to
       read, and the colour is already the loudest thing in the bar. */
    .nav-tab-active,
    .nav-search-compact[aria-expanded="true"],
    .nav-menu-toggle[aria-expanded="true"] {
        background: none;
        border: 0;
        color: var(--brand);
        font-weight: 600;
    }

    /* The trigger drops the filled pill it wears at tablet widths, and the word
       under it is why that is safe. The pill was there because a bare glyph
       between a board name and a face in a pill reads as the row's decoration
       rather than as the third thing in it you can press — in a row of four
       labelled marks there is nothing for it to be decoration of. */
    .nav-search-compact:hover { background: none; }

    /* The caret goes with the pill. It said "this opens something" for a face
       that had no other way of saying it; the word does that now, and a chevron
       in a tab cell is a third line of content in 48px. */
    .nav-menu-toggle-caret { display: none; }

    /* The face is a mark in a row of marks, so it is a mark's size — 20px like
       the other three, and not the 34px the pill used to hold. Every cell's
       word then sits on one line across the bar; four pixels of extra face is
       four pixels of label that does not line up with its neighbours, which is
       the sheet's "one left margin" rule stood on its side.

       Scoped to the trigger, and it has to be: `.nav-avatar-lg` in the sheet
       below is one class too, so an unscoped rule this far down the file wins
       against it and shrinks the face at the top of the menu as well. */
    .nav-menu-toggle .nav-avatar {
        width: 20px;
        height: 20px;
    }

    .nav-menu-toggle .nav-avatar-fallback { font-size: 0.6rem; }

    /* Beside the face rather than in the cell's corner: the cell is as wide as
       the row lets it be, so a dot pinned to its edge is a mark floating in the
       gap between two tabs. The face is centred, so the dot is measured from
       the middle out. */
    .nav-menu-dot {
        top: 6px;
        right: auto;
        left: calc(50% + 5px);
        width: 8px;
        height: 8px;
    }

    /* Only where the bar exists. Sign-in, sign-up and /request render no
       header, and 56px of dead space under a centred card is a scrollbar for
       nothing. */
    body:has(.board-header) { padding-bottom: var(--phone-nav-h); }

    .nav-admin-links { display: none; }

    /* On a phone the flyout is a sheet on the bar — the shape the two menus
       beside it already are. It covered the bar for one release, on the card
       dialog's reasoning, and the exception cost more than it bought: standing
       *on* the bar leaves the magnifier lit under your thumb, so a second tap
       on the same spot is the way back out, and the strip of board above the
       sheet stays visible rather than being replaced by a page of white — you
       can still see the thing you are searching.

       The four children swap rows here: the field is at the *bottom* of the
       sheet and the results grow upward above it. A sheet is anchored at its
       bottom edge, so with the field at the top every result that arrives
       shoves it upward — you type one letter and the box you are typing into
       jumps a couple of hundred pixels away from your finger, mid-word. This
       way it does not move at all, and it sits where the thumb and the keyboard
       already are. The price is that the listbox is above the combobox on
       screen while the markup keeps them the other way round; the markup is the
       order a screen reader follows and it is the one that should be right. */
    .nav-search-open .nav-search-field {
        /* On the bar, or on the keyboard once it is up. The sheet is fixed to
           the *layout* viewport and the keyboard is not part of it, so without
           this the field a member is typing into is drawn underneath the thing
           they are typing on. `search-interop.js` measures it; unset — no
           `visualViewport`, or the module failed to load — the fallback is the
           bar and nothing moves. */
        --search-sheet-bottom: max(var(--phone-nav-h), var(--keyboard-inset, 0px));
        display: grid;
        position: fixed;
        inset: auto 0 var(--search-sheet-bottom) 0;
        z-index: 200;
        width: auto;
        grid-template-columns: auto minmax(0, 1fr) auto auto;
        grid-template-rows: minmax(0, auto) auto;
        align-items: center;
        column-gap: 0.5rem;
        row-gap: 0.4rem;
        height: auto;
        max-height: calc(100dvh - var(--search-sheet-bottom) - 2rem);
        padding: 0.5rem 0.75rem 0.7rem;
        border-color: var(--border-1);
        border-bottom: none;
        border-radius: var(--radius-lg) var(--radius-lg) 0 0;
        /* Upwards only — see --shadow-sheet. */
        box-shadow: var(--shadow-sheet);
        animation: sheetRise 200ms var(--ease-out);
    }

    .nav-search-open .nav-search-mark { grid-area: 2 / 1; }

    /* `flex: 0 0 auto` is not enough on its own here — the base rule's
       `flex: 1 1 auto` means nothing to a grid item, but `align-items: center`
       does, and without a height this would be as tall as its track. */
    .nav-search-open .nav-search-input {
        grid-area: 2 / 2;
        height: 44px;
        padding: 0 1rem;
        font-size: 1rem;
        background: var(--bg-2);
        border: 1px solid var(--border-2);
        border-radius: var(--radius-pill);
    }

    .nav-search-open .nav-search-clear {
        grid-area: 2 / 3;
        width: 30px;
        height: 30px;
    }

    /* The one way out that is still on screen with the keyboard up: the sheet
       then stands on the keyboard rather than on the bar, so the trigger that
       would otherwise close it is behind one. Wider viewports keep the
       click-catcher and Escape and need no word. */
    .nav-search-open .nav-search-close {
        display: block;
        grid-area: 2 / 4;
        height: 44px;
        padding: 0 0.4rem;
        border: 0;
        background: none;
        color: var(--brand);
        font: inherit;
        font-size: 0.92rem;
        font-weight: 600;
        cursor: pointer;
    }

    /* Above the field and scrolling on its own, so the thing being typed into
       never moves while the list over it grows. */
    .nav-search-open .nav-search-panel {
        grid-area: 1 / 1 / 2 / -1;
        /* The row below is centred in its track, which is what the field wants
           and the opposite of what this does: it fills its track, and its
           content starts at the top of it. `min-height: 0` is what lets it be
           *smaller* than its content when the sheet has hit its ceiling — a
           grid item's floor is its content otherwise, and the scroll below
           would never come into play. */
        align-self: stretch;
        min-height: 0;
        position: static;
        max-height: min(58dvh, 26rem);
        overflow-y: auto;
        padding: 0.2rem 0 0.1rem;
        background: none;
        border: 0;
        border-radius: 0;
        box-shadow: none;
        animation: none;
    }

    /* The menu is a sheet standing on the bar, the shape the search's is.
       Same markup either way — fixed to the bar's top edge below 600px,
       with the grab handle as the one element the desktop menu does not show.
       It rises on the transform `.nav-menu-open` already animates rather than
       on an animation, because the menu is always in the DOM and an animation
       would play on load. The rise is short rather than a slide up from under
       the bar: this sheet sits *above* the bar, and a translateY(100%) start
       would be drawn straight over it — the menu is inside the header's
       stacking context, and nothing inside one can paint below that context's
       own background. */
    .nav-menu {
        position: fixed;
        inset: auto 0 var(--phone-nav-h) 0;
        top: auto;
        min-width: 0;
        /* The bar's height comes off the old ceiling rather than being
           added to it: the sheet starts where it always started, and the strip
           of dimmed board above it — the only place left to click to dismiss —
           is the same strip it was. */
        max-height: calc(85dvh - var(--phone-nav-h));
        overflow-y: auto;
        padding: 0.5rem 0.75rem 0.85rem;
        border-radius: var(--radius-lg) var(--radius-lg) 0 0;
        border-bottom: none;
        /* Upwards only — see --shadow-sheet. */
        box-shadow: var(--shadow-sheet);
        transform: translateY(10px);
    }

    .nav-menu-open { transform: translateY(0); }

    .nav-menu-sheet-handle {
        display: block;
        width: 36px;
        height: 4px;
        flex: 0 0 auto;
        margin: 0.1rem auto 0.5rem;
        border-radius: 2px;
        background: var(--border-3);
    }

    /* The sheet covers the board, so unlike the header's other click-catchers
       this one is worth seeing: it says the page behind it is out of play. The
       switcher's backdrop carries this class too, and one of the two dimming
       and the other not would read as a mistake.

       It stops at the bar, and that is not only taste. The layer sits at
       z-index -1 *inside* the header's stacking context, which paints it above
       the header's own background but below the header's children — so a
       full-height dim would tint the bar and leave its buttons crisp on top of
       the tint. Ending it at the bar keeps the one control that is still live
       looking live, and the page above is still dismissable everywhere. */
    .nav-menu-backdrop {
        inset: 0 0 var(--phone-nav-h) 0;
        background: rgba(25, 21, 19, 0.45);
        animation: fadeIn var(--duration) var(--ease-out);
    }

    /* Thumb-sized rows, and the account at the top of the sheet reads as its
       heading rather than as a row. */
    .nav-menu-account { padding: 0.35rem 0.6rem 0.75rem; }

    .nav-menu .nav-menu-row,
    .nav-menu-link,
    .nav-menu-signout {
        min-height: 44px;
        font-size: 0.95rem;
    }

    .nav-menu-group { padding: 0.2rem 0; }

    .nav-pref-options button {
        min-height: 38px;
        padding: 0.4rem 0.75rem;
        font-size: 0.85rem;
    }

    /* ── Board: single column with scroll-snap ── */

    /* Three insets stack between the edge of the screen and the first letter
       of a card — the board's padding, the column's rail, the card's own
       padding — and at desktop widths each of them is worth about 1.15rem.
       On a 412px phone that is 98px, a quarter of the device, spent before a
       word is drawn; the desktop pays the same 98px out of a 320px column
       that has a page around it to sit in, which is why it never looked like
       this there. Each of the three is cut here rather than one of them being
       cut hard: the reason a card is legible is that the steps between page,
       column and card are *visible*, and removing one step entirely is what
       makes a board read as crowded. Roughly two thirds of each, in the same
       proportion, keeps all three steps and gives back 32px of line. */
    .board {
        --column-rail: 0.7rem;
        --card-inset: 0.85rem;
        padding: 0.5rem;
        gap: 0.5rem;
        scroll-snap-type: x mandatory;
        scroll-padding-inline: 0.5rem;
        -webkit-overflow-scrolling: touch;
        /* Whatever the two bars leave. `body` pads by the bottom one, so the
           three have to agree or the board gains a scrollbar it never has. */
        height: calc(100vh - var(--phone-nav-h) - var(--phone-board-title-h));
        height: calc(100dvh - var(--phone-nav-h) - var(--phone-board-title-h));
    }

    /* The board's own bar (`BoardPhoneBar`), since the one at the bottom no
       longer names it: the way back, the board, and its settings for whoever
       runs it. The bottom bar's height, surface and hairline, so the page is
       framed by one pair of bars rather than having a heading float over its
       first column — which is what the name was as bare text: a second bold
       heading at nearly the size of the column's own, right above it. */
    .board-phone-bar {
        display: flex;
        align-items: center;
        gap: 0.35rem;
        height: var(--phone-board-title-h);
        padding: 0 0.4rem;
        background: var(--bg-1);
        box-shadow: 0 1px 0 var(--border-1);
    }

    /* The board's mark: its home-page tile in miniature — three columns in its
       own colour, on a tint of it — so which board this is reads before its
       name does, and in the same colour the home page and the calendar give
       it everywhere else. */
    .board-phone-mark {
        display: flex;
        align-items: flex-start;
        justify-content: center;
        gap: 2px;
        flex: none;
        width: 30px;
        height: 30px;
        padding-top: 8px;
        border-radius: 9px;
        background: color-mix(in srgb, var(--board-color) 16%, transparent);
    }

    .board-phone-mark > span {
        width: 4px;
        border-radius: 2px;
        background: var(--board-color);
    }

    .board-phone-mark > span:nth-child(1) { height: 13px; }
    .board-phone-mark > span:nth-child(2) { height: 9px; }
    .board-phone-mark > span:nth-child(3) { height: 5px; }

    .board-phone-title {
        flex: 1;
        min-width: 0;
        margin: 0 0 0 0.15rem;
        font-family: var(--font-display);
        font-size: 1.1rem;
        font-weight: 700;
        letter-spacing: -0.015em;
        color: var(--fg);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    /* A 36px disc on the surface the tiles' pin and cog wear, inside a 44px
       target: the app's round control, a thumb's worth to hit. The disc is a
       gradient rather than the box so the target can be larger than what is
       drawn. Marks rather than words — the chevron and the cog are the two
       shapes a phone already reads as "back" and "settings". */
    .board-phone-action {
        display: grid;
        place-items: center;
        flex: none;
        width: 44px;
        height: 44px;
        border-radius: 50%;
        color: var(--fg-2);
        background: radial-gradient(circle closest-side, var(--bg-2) calc(100% - 4px), transparent calc(100% - 3.5px));
        transition: color var(--duration) var(--ease);
    }

    .board-phone-action:active { color: var(--brand); }

    .column {
        width: calc(100vw - 1rem);
        min-width: calc(100vw - 1rem);
        flex: 0 0 auto;
        scroll-snap-align: start;
        /* The board's own top padding, then the strip the pagination pill
           stands in — the pill is `position: fixed`, so without that strip it
           is drawn on top of the last card rather than under the column. (It
           is what the bare `3rem` here used to be: 0.75rem of padding plus
           2.25rem of pill. Written out so shrinking the padding above cannot
           quietly eat the pill's clearance with it, and so the pill's two
           gaps come from the same token the pill is placed with.) */
        max-height: calc(100dvh - var(--phone-nav-h) - var(--phone-board-title-h) - 0.5rem
                         - var(--phone-pager-h) - 2 * var(--phone-pager-gap));
    }

    /* The header is the label, a count and a 2.25rem filter button, and the
       button is what sets the height — so the 1rem above and below it was
       padding around padding. Tighter at the bottom than the top: the cards
       below carry their own inset and the label does not.

       The left inset is the rail *plus* the card's own, because the label is
       the only text on this line and what it has to agree with is the card
       titles under it, not the tiles they sit in — starting it at the tile
       edge left it hanging 14px out to the left of every title on the board.
       The right stays on the rail: there is no text line over there to join,
       only the tiles' right edge, and the count and the filter button are
       already on it. */
    .column-header {
        padding: 0.6rem var(--column-rail) 0.4rem
                 calc(var(--column-rail) + var(--card-inset));
    }

    .column-cards {
        padding: 0.35rem var(--column-rail) 0.5rem;
        gap: 0.5rem;
    }

    /* Not on the rail — this is the card's own step, and the one that has to
       stay clear of the shadow. The inline padding stays the larger of the
       two so a wrapped title still has a margin to breathe into; it is the
       token because the column header above lines up with it. */
    .card { padding: 0.7rem var(--card-inset); }

    /* Matches the header's top inset above, as it does at every width. */
    .column-add { margin: 0.3rem var(--column-rail) 0.6rem; }

    /* Both are sized against the desktop header. On a phone the bar is at the
       bottom and `body` already pads for it, so the unadjusted number is a page
       that scrolls by exactly one bar and nothing else. */
    .board-loading,
    .empty-state { min-height: calc(100dvh - var(--phone-nav-h)); }

    /* Thumb-sized, and the panel widens to the column it hangs in: a phone
       column is most of the viewport, so there is no reason to keep the
       desktop panel's width. */
    .column-filter-toggle {
        width: 2.25rem;
        height: 2.25rem;
    }

    .column-filter-toggle svg {
        width: 1.05rem;
        height: 1.05rem;
    }

    .column-filter-panel {
        width: 17rem;
    }

    .board-pagination {
        display: flex;
        position: fixed;
        /* One gap above the bar, and the column above stops one gap higher —
           see `--phone-pager-gap`. */
        bottom: calc(var(--phone-pager-gap) + var(--phone-nav-h));
        left: 50%;
        transform: translateX(-50%);
        gap: 0.4rem;
        padding: 0.5rem 0.75rem;
        background: var(--bg-1);
        border: 1px solid var(--border-1);
        border-radius: 999px;
        box-shadow: var(--shadow-md);
        z-index: 20;
        align-items: center;
        transition: left 320ms var(--ease-out),
                    right 320ms var(--ease-out),
                    transform 320ms var(--ease-out),
                    padding 320ms var(--ease-out),
                    gap 320ms var(--ease-out),
                    border-radius 320ms var(--ease-out),
                    box-shadow 320ms var(--ease-out);
    }

    /* Expanded drop-tile bar: activated while a card is being dragged.
       Each dot morphs into a full drop target with the column name visible,
       so the user can release the card on any column without first scrolling
       to reach it. */
    .board-dragging ~ .board-pagination,
    body:has(.board-dragging) .board-pagination {
        left: 0.6rem;
        right: 0.6rem;
        transform: none;
        gap: 0.5rem;
        padding: 0.6rem;
        border-radius: var(--radius);
        box-shadow: var(--shadow-lg);
    }

    .board-dragging ~ .board-pagination .board-dot,
    body:has(.board-dragging) .board-pagination .board-dot {
        flex: 1 1 0;
        width: auto;
        min-height: 64px;
        height: auto;
        border-radius: var(--radius-sm);
        background: var(--bg-2);
        border: 2px dashed var(--border-2);
        padding: 0.5rem 0.6rem;
    }

    .board-dragging ~ .board-pagination .board-dot-active,
    body:has(.board-dragging) .board-pagination .board-dot-active {
        background: var(--bg-2);
        border-color: var(--brand);
    }

    .board-dragging ~ .board-pagination .board-dot::before,
    body:has(.board-dragging) .board-pagination .board-dot::before {
        max-width: 100%;
        max-height: 2.4em;
        opacity: 1;
    }

    /* Highlight the tile the card is hovering over. SortableJS inserts a
       `.card-ghost`-classed clone into whichever drop target the user is
       currently dragging over. */
    .board-pagination .board-dot:has(.card-ghost) {
        background: var(--brand-glow) !important;
        border-color: var(--brand) !important;
        transform: translateY(-2px);
    }

    /* board-pagination.js.onAdd moves the dropped card back to its
       source column before Blazor re-renders, so the .card normally
       never sits inside a .board-dot. This rule is a belt-and-braces
       hide in case any code path leaves one stranded there. */
    .board-pagination .board-dot .card {
        display: none;
    }

    /* ── Modal: full screen slide-up ── */

    .modal-backdrop {
        padding: 0;
        align-items: flex-end;
    }

    .modal-backdrop::before {
        /* No blur on a phone. `backdrop-filter` re-filters the whole viewport —
           at a phone's pixel ratio that is millions of pixels — and it has to do
           it while the sheet below slides up over it, so the entrance dropped
           frames on exactly the devices with the least GPU to spare. A sheet
           that covers most of the screen leaves almost nothing to blur anyway,
           so the scrim just darkens a little further to keep the separation. */
        backdrop-filter: none;
        -webkit-backdrop-filter: none;
        background: rgba(25, 21, 19, 0.58);
    }

    .modal {
        width: 100%;
        /* The backdrop's height, not 100vh: a fixed box is as tall as what is
           on screen, while 100vh is the screen with the browser's toolbar
           folded away — so with the toolbar showing, a tall sheet was taller
           than the window and its header, the close button included, sat
           above the top edge. */
        max-height: 100%;
        border-radius: var(--radius) var(--radius) 0 0;
        animation: slideUpMobile 300ms var(--ease-out);
        /* A full-height sheet is a big layer to rasterize; asking for it before
           the first frame keeps the slide on the compositor from the start. */
        will-change: transform;
    }

    /* The card dialog is the whole screen from its first frame, rather than a
       sheet as tall as what has arrived so far. Its thread and its link
       previews load a moment after it opens — during the slide — and a sheet
       sized to its content grew under them, which moved the distance
       `slideUpMobile`'s 100% is measured in while it was being travelled. A
       card is rarely short enough on a phone for the difference to show at
       rest; the feedback and release-notes dialogs are, and stay sheets. */
    .card-modal {
        height: 100%;
    }

    .modal-body {
        padding: 1.15rem;
    }

    .modal-header {
        padding: 1rem 1.15rem;
    }

    .composer {
        padding: 0.9rem;
        gap: 0.85rem;
    }

    .composer-title {
        font-size: 1.2rem;
    }

    /* 16px keeps iOS from zooming the viewport on focus; the panel shrinks
       with the field so the preview keeps standing on the same footprint. */
    .composer-body {
        font-size: 16px;
    }

    .composer-panel,
    .composer-body,
    .composer-preview,
    .composer-empty {
        min-height: 9.5rem;
    }

    .segmented-tab {
        min-height: 36px;
        padding: 0.3rem 0.8rem;
    }

    .detail-panel {
        padding: 0.85rem 0.9rem 0.9rem;
    }

    /* Both thumb-sized, and the button takes the row under the menu rather
       than a sliver beside it. */
    .move-select {
        flex: 1 1 100%;
        min-height: 44px;
    }

    .move-go {
        flex: 1 1 100%;
        min-height: 44px;
    }

    .modal-footer {
        padding: 0.9rem 1.15rem;
        padding-bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px));
        flex-wrap: wrap;
        gap: 0.5rem;
    }

    .modal-footer button,
    .modal-footer .btn-primary,
    .modal-footer .btn-danger {
        min-height: 48px;
        padding: 0.65rem 1.15rem;
    }

    .modal-close {
        width: 44px;
        height: 44px;
    }

    .field input,
    .field textarea,
    .field select {
        font-size: 16px;  /* prevents iOS zoom on focus */
        min-height: 48px;
        padding: 0.7rem 0.9rem;
    }

    /* ── Admin pages ── */

    .admin-page {
        padding: 1rem;
    }

    .admin-page-title {
        padding: 0.85rem 1rem 0;
    }

    .admin-section {
        padding: 1.25rem;
    }

    .admin-form {
        flex-direction: column;
        align-items: stretch;
    }

    .admin-form input[type="text"],
    .admin-form input[type="color"],
    .admin-form select,
    .admin-form button {
        min-height: 48px;
        font-size: 16px;
    }

    /* The form is a stacked column here, so the swatch takes the width like
       everything else in it: at its desktop 3rem it is a small square adrift
       beside full-width rows, and full width it simply reads as the colour. */
    .admin-form input[type="color"] { width: 100%; }

    /* Tables collapse to stacked cards on phone. Each row becomes a bordered
       block with `data-label` captions rendered as ::before so the column
       meaning is preserved without the header row. */
    .admin-table,
    .admin-table thead,
    .admin-table tbody,
    .admin-table tr,
    .admin-table td {
        display: block;
        width: 100%;
        min-width: 0;
    }

    .admin-table thead {
        display: none;
    }

    .admin-table .admin-cell-fit { width: 100%; }

    .admin-table tbody tr {
        border: 1px solid var(--border-1);
        border-radius: var(--radius);
        padding: 0.75rem 0.9rem;
        margin-bottom: 0.75rem;
        background: var(--bg-1);
    }

    .admin-table tbody tr:hover {
        background: var(--bg-1);
    }

    .admin-table tbody tr:last-child {
        margin-bottom: 0;
    }

    .admin-table tbody td {
        border: none;
        padding: 0.35rem 0;
        display: flex;
        flex-direction: column;
        gap: 0.25rem;
        word-break: normal;
        overflow-wrap: anywhere;
    }

    .admin-table tbody td::before {
        content: attr(data-label);
        font-size: 0.7rem;
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: var(--fg-3);
    }

    .admin-table tbody td:empty,
    .admin-table tbody td[data-label=""]:not(.admin-row-actions) {
        display: none;
    }

    .admin-table .admin-did code {
        font-size: 0.75rem;
        word-break: break-all;
        white-space: normal;
    }

    .admin-table .admin-row-actions {
        flex-direction: row;
        flex-wrap: wrap;
        gap: 0.5rem;
        padding-top: 0.6rem;
        margin-top: 0.35rem;
        border-top: 1px solid var(--border-1);
    }

    .admin-table .admin-row-actions::before {
        display: none;
    }

    .admin-table .admin-row-actions button {
        flex: 1 1 auto;
        min-height: 44px;
    }

    .admin-table td input[type="text"] {
        width: 100%;
        min-height: 44px;
    }

    .admin-sortable-item {
        flex-wrap: wrap;
    }

    .admin-sortable-input {
        min-width: 0;
        flex: 1 1 100%;
        order: 1;
        margin-top: 0.35rem;
    }

    .admin-row-actions {
        order: 2;
        width: 100%;
        margin-top: 0.45rem;
    }

    .admin-row-actions button {
        min-height: 48px;
        flex: 1;
    }

    /* A board's row carries five controls, which is two more than fit on a
       phone — and `justify-content: flex-end` sends what does not fit off the
       *left* edge rather than wrapping it, so the two links into the board
       were sliced by the side of the screen. Wrapped and started from the
       left, the links are one line and Save/Delete the next: the basis is what
       forces the break, since a button that can take 45% of the row cannot sit
       beside the links and still have room for its pair. */
    .admin-sortable-item .admin-row-actions {
        flex-wrap: wrap;
        justify-content: flex-start;
    }

    .admin-sortable-item .admin-row-actions button {
        flex: 1 1 45%;
    }

    /* The request card is the same shape on every width — only the footer
       splits, because the phone rule above gives the buttons the full row. */
    .request-card {
        padding: 0.9rem 1rem;
    }

    .request-foot {
        flex-wrap: wrap;
    }

    .request-message {
        max-width: none;
    }

    .member-preview-card {
        flex-direction: column;
        align-items: center;
        text-align: center;
    }

    .member-preview-actions {
        flex-direction: column;
        width: 100%;
    }

    .member-preview-actions select,
    .member-preview-actions button {
        width: 100%;
        min-height: 48px;
    }

    /* ── Touch targets ── */

    .column-add {
        min-height: 48px;
        padding: 0.8rem;
    }

    /* `.card` used to be padded here too, at 1.1rem — *more* than the desktop
       card, on the device with the least room for it. A card is not a touch
       target that needs growing: the whole tile is the target and it is over a
       hundred pixels tall. Its padding is part of the board's spacing story
       and lives with the rest of it, up in the board block above. */

    /* Only the pickable chips in the card modal are touch targets. The static
       chips on a card are labels, not controls — sizing them to 34px made a
       one-word tag the loudest thing on the card. */
    .tag-chip:not(.tag-chip-static) {
        padding: 0.3rem 0.7rem;
        min-height: 34px;
        font-size: 0.8rem;
    }

    .assignee-chip {
        min-height: 44px;
        padding: 0.35rem 0.7rem 0.35rem 0.35rem;
    }

    .assignee-add,
    .assignee-option {
        min-height: 44px;
    }

    /* 16px, for the composer's reason: anything smaller and iOS zooms the
       page in on focus. */
    .assignee-search {
        min-height: 44px;
        font-size: 16px;
    }

    .comment-form textarea {
        font-size: 16px;
        min-height: 48px;
    }

    .comment-form .btn-primary {
        min-height: 48px;
    }

    /* ── Toast ── */

    .toast-container {
        bottom: calc(1rem + var(--phone-nav-h));
        left: 1rem;
        right: 1rem;
        transform: none;
    }

    .toast {
        width: 100%;
        text-align: center;
    }

    /* ── Login page ── */

    .login-page {
        padding: 1rem;
    }
}

@keyframes slideUpMobile {
    from { transform: translateY(100%); }
    to   { transform: translateY(0); }
}

/* The two nav sheets rest on the bottom bar rather than sliding out from under
   it, so their entrance is a short rise and not a full-height slide — see
   `.nav-menu` in the phone block above for why they cannot start below it. The
   card dialog keeps `slideUpMobile`: it covers the bar and everything else. */
@keyframes sheetRise {
    from { opacity: 0; transform: translateY(10px); }
    to   { opacity: 1; transform: none; }
}

/* ══════════════════════════════════════════════════════════
   RESPONSIVE — Tablet (600px – 959px)
   ══════════════════════════════════════════════════════════ */

@media (min-width: 600px) and (max-width: 959px) {

    .board {
        padding: 1.15rem;
        gap: 1rem;
    }

    .column {
        width: calc(50vw - 1.75rem);
        min-width: calc(50vw - 1.75rem);
    }
}

/* ══════════════════════════════════════════════════════════
   RESPONSIVE — Desktop (960px and up)
   ══════════════════════════════════════════════════════════ */

@media (min-width: 960px) {

    /* Share the spare width out instead of leaving it beside the board — at
       320px fixed, three columns on a 1080p screen left more empty page than
       board. The floor keeps a crowded board scrolling horizontally rather
       than squeezing its columns; the ceiling stops at roughly a 70-character
       description line, past which the text stops being comfortable to read. */
    .column {
        flex: 1 1 320px;
        width: auto;
        min-width: 320px;
        max-width: 520px;
    }
}

/* ── Signup / local login / invites ───────────────────── */

.signup-handle-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.signup-handle-row input {
    flex: 1;
    min-width: 0;
}

.signup-handle-domain {
    color: var(--fg-2);
    font-size: 0.88rem;
    font-weight: 600;
    white-space: nowrap;
}

.login-links {
    margin-top: 1.2rem;
    padding-top: 1.05rem;
    border-top: 1px solid var(--border-1);
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.login-links .login-alt {
    margin: 0;
}

.login-alt {
    margin: 1.5rem 0 0;
    font-size: 0.88rem;
    color: var(--fg-2);
}

.login-alt-link {
    color: var(--brand-bright);
    font-weight: 600;
    text-decoration: none;
}

.login-alt-link:hover {
    text-decoration: underline;
}

.invite-created {
    margin-top: 1rem;
    padding: 1rem 1.25rem;
    border: 1px solid var(--border-1);
    border-radius: var(--radius-sm);
    background: var(--bg-2);
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.invite-created p {
    margin: 0;
    font-size: 0.88rem;
    color: var(--fg-2);
}

.invite-code {
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: 0.02em;
}

.invite-link {
    width: 100%;
    font-size: 0.82rem;
    color: var(--fg-2);
}

.invite-status {
    font-size: 0.8rem;
    font-weight: 600;
    padding: 0.15rem 0.6rem;
    border-radius: 999px;
}

.invite-status-active {
    background: color-mix(in srgb, var(--success) 18%, transparent);
    color: var(--success);
}

.invite-status-used {
    background: color-mix(in srgb, var(--fg-2) 14%, transparent);
    color: var(--fg-2);
}

.invite-status-disabled {
    background: color-mix(in srgb, var(--danger) 16%, transparent);
    color: var(--danger);
}

/* ── Access requests ──────────────────────────────────── */

.hp-field {
    position: absolute !important;
    left: -9999px !important;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.request-keep-link {
    font-size: 0.85rem;
    color: var(--fg-2);
}

/* The access-denied card. Its paragraphs sit closer together than an auth
   card's single intro line does — there are three of them above the form
   (what happened, who you are, what to do), and .login-card > p's 1.5rem
   between each pushed the first field below the fold on a phone. */
.login-card > .access-identity,
.login-card > .access-ask,
.login-card > .access-pending {
    margin-bottom: 0.75rem;
}

.login-card > .access-ask {
    margin-bottom: 1.25rem;
}

/* The handle is the one thing on this page nobody typed, so it is set as code
   like it is in the admin queue rather than as prose. */
.access-identity {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: center;
    gap: 0.4rem;
    font-size: 0.88rem;
}

.access-identity code {
    font-family: var(--font-mono);
    font-size: 0.88rem;
    color: var(--fg);
    background: var(--bg-2);
    border-radius: var(--radius-xs);
    padding: 0.1rem 0.45rem;
}

/* Waiting is not an error and not a success — it is the state the request is
   in, so it reads as the brand note the rest of the auth pages use. */
.access-pending {
    background: var(--brand-glow);
    color: var(--fg);
    border-radius: var(--radius-xs);
    padding: 0.75rem 1rem;
    font-size: 0.9rem;
    line-height: 1.5;
}

/* A request is a decision unit, not a row: five short fields and a message of
   up to 2000 characters. As a table column that message lost every time —
   `overflow-wrap: anywhere` puts its min-content width at one character, so
   the auto layout starved it to a ribbon while the fields either side took
   what they wanted. As a card it gets the width, and the phone layout stops
   being a separate stacked-table rendering of the same thing. */
.request-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

.request-card {
    background: var(--bg-2);
    border: 1px solid var(--border-1);
    border-radius: var(--radius-sm);
    padding: 1rem 1.15rem;
    display: flex;
    flex-direction: column;
    gap: 0.8rem;
}

.request-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.5rem 0.9rem;
}

.request-who {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 0.15rem 0.6rem;
    min-width: 0;
}

.request-handle {
    font-size: 0.85rem;
    overflow-wrap: anywhere;
}

.request-name {
    font-weight: 600;
    font-size: 0.95rem;
}

/* Sits directly under the handle it vouches for, because that is what it is
   about: this username came out of a sign-in, not out of a text field. Quiet
   green — it is a fact about the request, not an action on it. */
.request-verified {
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    padding: 0.1rem 0.5rem;
    border-radius: 999px;
    background: color-mix(in srgb, var(--success) 16%, transparent);
    color: var(--success);
    white-space: nowrap;
}

/* Sits with the handle rather than with the name: it is an identifier, and it
   answers the same question — where this person can be reached. */
.request-email {
    font-size: 0.85rem;
    color: var(--fg-3);
    overflow-wrap: anywhere;
}

/* Not a warning. It only says the code will need carrying by hand. */
.request-email-none {
    font-style: italic;
}

.request-badges {
    display: flex;
    align-items: center;
    gap: 0.65rem;
}

/* Which kind it is decides what approving *does* (allowlist vs. mint an
   invite), so it stays legible — but the pill next to it is the state, and
   two pills would fight. */
.request-kind {
    font-size: 0.74rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--fg-3);
    white-space: nowrap;
}

/* Someone else's words, so they read as quoted — and capped at a line length
   that can be read, rather than run to the full width of the panel. */
.request-message {
    margin: 0;
    padding-left: 0.9rem;
    border-left: 2px solid var(--border-2);
    max-width: 62ch;
    font-size: 0.93rem;
    line-height: 1.6;
    color: var(--fg-2);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.request-foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.9rem;
}

.request-date {
    font-size: 0.82rem;
    color: var(--fg-3);
}

/* ── Auth tabs (sign in / create account switcher) ───── */

.auth-tabs {
    display: flex;
    background: var(--bg-2);
    border: 1px solid var(--border-1);
    border-radius: var(--radius-pill);
    padding: 4px;
    margin: 0 0 1.15rem;
    gap: 4px;
}

.auth-tab {
    flex: 1;
    text-align: center;
    padding: 0.55rem 0.75rem;
    border-radius: var(--radius-pill);
    font-size: 0.92rem;
    font-weight: 600;
    color: var(--fg-2);
    text-decoration: none;
    transition: all var(--duration) var(--ease);
}

.auth-tab:hover {
    color: var(--fg);
}

.auth-tab-active {
    background: var(--brand);
    color: var(--brand-contrast);
    box-shadow: var(--shadow-sm);
}

.auth-tab-active:hover {
    color: var(--brand-contrast);
}

/* ── Auth page polish ────────────────────────────────── */

/* The OAuth login form is a centered composition — its hint text was the
   only left-aligned element on the card. */
.atproto-login-form .atproto-login-hint {
    display: block;
    text-align: center;
    margin-top: 0.6rem;
    line-height: 1.55;
}

/* The "you probably already have an account" note, above the form on the two
   pages that hand out a new one. Brand-tinted rather than red: it offers a
   better path, it doesn't report a mistake. Left-aligned inside a card that
   centres everything else, because it is a sentence to read rather than a
   heading to glance at — and that misalignment is also what stops it reading
   as more intro copy. */
.auth-note {
    display: flex;
    gap: 0.7rem;
    align-items: flex-start;
    text-align: left;
    margin: 0 0 1.35rem;
    padding: 0.85rem 0.95rem;
    border: 1px solid var(--border-2);
    border-radius: var(--radius-sm);
    background: var(--brand-glow);
}

.auth-note-icon {
    flex-shrink: 0;
    margin-top: 0.1rem;
    color: var(--brand-bright);
}

.auth-note-body {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    min-width: 0;
}

.auth-note-title {
    margin: 0;
    font-size: 0.9rem;
    font-weight: 700;
    color: var(--fg);
}

.auth-note-text {
    margin: 0;
    font-size: 0.86rem;
    line-height: 1.5;
    color: var(--fg-2);
}

.auth-note .login-alt-link {
    margin-top: 0.15rem;
    font-size: 0.86rem;
}

/* Signup form: numbered step groups so the field list has visual guidance. */
.form-step {
    display: flex;
    flex-direction: column;
    gap: 0.85rem;
    border: none;
    margin: 0;
    padding: 0.95rem 0 0;
    border-top: 1px solid var(--border-1);
}

.form-step:first-of-type {
    border-top: none;
    padding-top: 0;
}

.form-step-title {
    display: flex;
    align-items: center;
    gap: 0.55rem;
    margin: 0;
    font-size: 0.95rem;
    font-weight: 700;
    color: var(--fg);
    font-family: var(--font-body);
}

.form-step-num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    flex-shrink: 0;
    border-radius: 50%;
    background: var(--brand-glow);
    color: var(--brand-bright);
    font-size: 0.85rem;
    font-weight: 700;
}

/* Auth page switch animation. Plays on first load AND on every
   Sign in ↔ Create account ↔ request switch, but exactly once each time —
   js/auth-enter.js drives the data-auth state machine that gates it, because
   the card's nodes get created more than once per load and a plain
   `.login-card { animation }` replayed on every one of them. See that file. */
@keyframes auth-card-in {
    from { opacity: 0; transform: translateY(12px); }
    to   { opacity: 1; transform: none; }
}

/* Before the entrance: hidden, so the prerendered card Blazor is about to throw
   away is never seen animating. Conditional on `js` so that without JavaScript
   there is no state machine and no hidden card — just a visible, unanimated one.
   data-auth="done" matches nothing here, which is what leaves the card visible
   with no animation for any later node churn to restart. */
html.js[data-auth="pending"] .login-card,
html.js[data-auth="pending"] .login-card > * {
    opacity: 0;
}

html[data-auth="playing"] .login-card {
    animation: auth-card-in 0.28s var(--ease) both;
}

@media (prefers-reduced-motion: reduce) {
    html[data-auth="playing"] .login-card {
        animation: none;
    }
}

/* Section heading with an inline action (e.g. Invites + Create button). */
.admin-section-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
}

.admin-section-header h2 {
    margin: 0;
}

/* Link-style toggle revealing resolved requests / spent invites. */
.admin-history-toggle {
    align-self: flex-start;
    margin-top: 0.75rem;
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
    font: inherit;
    font-size: 0.88rem;
    font-weight: 600;
    color: var(--brand-bright);
}

.admin-history-toggle:hover {
    text-decoration: underline;
}

/* ── Brand mark (Ponente west-wind squircle) ─────────── */

.brand-mark {
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    display: block;
}

.board-title-link {
    display: inline-flex;
    align-items: center;
    gap: 0.6rem;
}

.login-card .brand-mark-lg {
    width: 48px;
    height: 48px;
    margin: 0 auto 0.7rem;
}

/* ── Ponente polish pass: atmosphere, wind details, focus ─ */

/* Empty columns get a little wind doodle, idly drifting. */
.column-empty::before {
    content: "";
    display: block;
    width: 44px;
    height: 24px;
    margin: 0 auto 0.8rem;
    background: currentColor;
    opacity: 0.55;
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 24'%3E%3Cg fill='none' stroke='black' stroke-width='3' stroke-linecap='round'%3E%3Cpath d='M40 7 H12 a3.5 3.5 0 1 0 3.5 -6'/%3E%3Cpath d='M34 16 H6'/%3E%3Cpath d='M28 22 H14'/%3E%3C/g%3E%3C/svg%3E") no-repeat center / contain;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 24'%3E%3Cg fill='none' stroke='black' stroke-width='3' stroke-linecap='round'%3E%3Cpath d='M40 7 H12 a3.5 3.5 0 1 0 3.5 -6'/%3E%3Cpath d='M34 16 H6'/%3E%3Cpath d='M28 22 H14'/%3E%3C/g%3E%3C/svg%3E") no-repeat center / contain;
    animation: idle-drift 4s ease-in-out infinite alternate;
}

@keyframes idle-drift {
    from { transform: translateX(4px); }
    to   { transform: translateX(-4px); }
}

/* The big auth-card mark breathes: streamlines sway in the wind. */
.brand-mark .brand-wind path {
    animation: wind-sway 3.4s ease-in-out infinite alternate;
    transform-box: fill-box;
}

.brand-mark .brand-wind path:nth-child(2) { animation-delay: 0.5s; }
.brand-mark .brand-wind path:nth-child(3) { animation-delay: 1s; }

@keyframes wind-sway {
    from { transform: translateX(1.6px); }
    to   { transform: translateX(-1.6px); }
}

/* Keyboard focus: one consistent brand ring for interactive elements.
   No border-radius here — it would reshape pill and circle controls. */
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
    .column-empty::before,
    .brand-mark .brand-wind path,
    .spinner::before,
    .spinner::after {
        animation: none;
    }
}

/* ── Second design pass: texture, entrances, wind language ─ */

/* Fine grain over everything — kills the flat-gradient feel. Fixed and
   non-interactive; barely-there in light, a touch more present in dark. */
body::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 9000;
    pointer-events: none;
    opacity: 0.022;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}

[data-theme="dark"] body::before {
    opacity: 0.04;
}

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) body::before {
        opacity: 0.04;
    }
}

/* Column headers carry the wind: a hairline trailing off westward,
   waking up on hover. */
.column-header {
    position: relative;
}

.column-header::after {
    content: "";
    position: absolute;
    left: var(--column-rail);
    right: var(--column-rail);
    bottom: 0;
    height: 2px;
    border-radius: 999px;
    background: linear-gradient(270deg, var(--brand) 0%, transparent 90%);
    opacity: 0.35;
    transition: opacity var(--duration) var(--ease);
}

/* Hover-only for `.card:hover`'s reason: the tap that opens a card also hovers
   its column, and the hairline's fade then repainted the board under the card
   dialog's entrance. */
@media (hover: hover) {
    .column:hover .column-header::after {
        opacity: 0.8;
    }
}

/* Orchestrated entrances: columns rise in sequence when the board mounts;
   the auth card's contents follow its entrance in a quick cascade. */
@keyframes rise-in {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: none; }
}

/* The columns are animated from JS, not by a rule keyed to element creation.
   That keying is what made this undeployable: the board is built twice on every
   load — the prerender paints columns, the circuit's first render replaces them
   with Board.razor's spinner, and the data builds them again — so a CSS
   animation fires on whichever sets happen to exist, and every attempt to gate
   it was a timer racing that churn. Board.razor calls the entrance itself, from
   the render where its data has actually arrived, and js/auth-enter.js animates
   exactly those elements once. Nothing created afterwards can animate, because
   there is no rule left to fire.

   All this does is keep the columns from being seen before that call. Scoped to
   `js` so a visitor without JavaScript gets a plain visible board, and dropped
   the moment the entrance runs. The timings live here so CSS stays the source of
   truth; the JS reads them back off :root. */
:root {
    --entrance-duration: 450ms;
    --entrance-lead: 30ms;
    --entrance-stagger: 60ms;
    --entrance-rise: 14px;
    /* The home page's entrance, which is allowed a spring the board is not:
       a small overshoot that settles, on the page a visit starts from. */
    --home-stagger: 35ms;
    --home-pop-duration: 420ms;
    --ease-spring: cubic-bezier(0.34, 1.45, 0.64, 1);
}

html.js:not([data-board-entered]) .board > .column {
    opacity: 0;
}

/* The calendar's rows, hidden until auth-enter.js has them to animate — the same
   bargain the columns above make, and conditional on `js` for the same reason:
   without it a visitor with no JavaScript would get a month that never appears.
   There is deliberately **no** entrance rule for these elements anywhere in this
   file. A CSS animation keyed to element creation fires on whichever of the
   prerendered, spinner and rebuilt sets happens to exist, which is the trap the
   board's entrance is written around; these are animated explicitly from JS. */
/* The home page's headings, tiles, strip and empty state, on the same bargain — hidden
   until auth-enter.js plays them, never animated by a rule in this file. */
html.js:not([data-home-entered]) .home-title,
html.js:not([data-home-entered]) .home-deleted-title,
html.js:not([data-home-entered]) .foryou-card,
html.js:not([data-home-entered]) .home-tiles > .board-tile,
html.js:not([data-home-entered]) .home-empty,
html.js:not([data-home-entered]) .home-deleted-row {
    opacity: 0;
}

html.js:not([data-calendar-entered]) .calendar-week,
html.js:not([data-calendar-entered]) .timeline-row,
html.js:not([data-calendar-entered]) .calendar-agenda-day {
    opacity: 0;
}


/* The card's contents cascade in behind it — gated on the same state as the
   card itself, so the two halves of the entrance can never get out of step. */
html[data-auth="playing"] .login-card > * {
    animation: rise-in 0.4s var(--ease-out) both;
}

html[data-auth="playing"] .login-card > :nth-child(1) { animation-delay: 0.05s; }
html[data-auth="playing"] .login-card > :nth-child(2) { animation-delay: 0.1s; }
html[data-auth="playing"] .login-card > :nth-child(3) { animation-delay: 0.15s; }
html[data-auth="playing"] .login-card > :nth-child(4) { animation-delay: 0.2s; }
html[data-auth="playing"] .login-card > :nth-child(n+5) { animation-delay: 0.25s; }

@media (prefers-reduced-motion: reduce) {
    html[data-auth="playing"] .login-card > * {
        animation: none;
    }
}
