/* ═══════════════════════════════════════════════════════════
   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));

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

    /* 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;
}

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;
}

.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;
}

/* Default styles for *text* buttons inside .board-user. The icon-only
   round buttons (.nav-hamburger) own their own
   padding/border-radius and are excluded so the text-button defaults
   don't override their fixed-aspect 40×40 sizing. */
.board-user button:not(.nav-hamburger) {
    border: 1px solid var(--border-2);
    background: var(--bg-2);
    color: var(--fg-2);
    border-radius: var(--radius-pill);
    padding: 0.4rem 1rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.85rem;
    font-weight: 500;
    transition: all var(--duration) var(--ease);
}

.board-user button:not(.nav-hamburger):hover {
    background: var(--bg-3);
    border-color: var(--border-3);
    color: var(--fg);
}

.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; }

.admin-page-title {
    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 {
    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 1.15rem;
    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. */
.column-filter-panel {
    position: absolute;
    top: calc(100% + 0.55rem);
    right: 1.15rem;
    z-index: 200;
    width: 15rem;
    max-width: calc(100% - 2.3rem);
    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: it puts the card edges on the same
   1.15rem line as the header label and the hairline under it (which cards at
   0.65rem missed by 8px), and leaves the hover shadow room before the clip. */
.column-cards {
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    padding: 0.5rem 1.15rem 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 1.15rem;
    cursor: pointer;
    transition: all var(--duration) var(--ease);
    position: relative;
    box-shadow: var(--shadow-card);
}

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

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

.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;
}

/* 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 {
    /* Bottom inset matches the header's 1rem top inset. */
    margin: 0.35rem 1.15rem 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 ─────────────────────────────────────────────── */

.modal-backdrop {
    position: fixed;
    inset: 0;
    background: rgba(25, 21, 19, 0.45);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    padding: 1rem;
    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;
}

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

/* — 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);
}

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

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

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

/* 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. */
.card-dragging {
    box-shadow: var(--shadow-brand), var(--shadow-lg);
    z-index: 10;
    rotate: 2.5deg;
    transition: none;
}

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

.card-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin-bottom: 0.5rem;
}

.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. */
.tag-picker .tag-chip-selected::before,
.column-filter-tags .tag-chip-selected::before,
.calendar-legend .tag-chip-selected::before,
.assignee-picker .assignee-chip-selected::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-picker .assignee-chip-selected::after {
    background: var(--brand);
    width: 0.95rem;
    height: 0.95rem;
    margin-left: 0.05rem;
}

.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;
}

.card-assignees {
    display: flex;
    flex-wrap: wrap;
    gap: 0.2rem;
}

/* 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;
}

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

.assignee-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    padding: 0.3rem 0.7rem 0.3rem 0.3rem;
    border-radius: var(--radius-pill);
    border: 1px solid var(--border-2);
    background: var(--bg-0);
    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);
}

.assignee-chip-selected {
    background: var(--brand-surface);
    border-color: var(--brand);
}

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

/* ── 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);
}

/* ── 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);
}

.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);
}

/* — 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;
        /* The phone header is 56px, not 64 — same reckoning as .board. */
        height: calc(100vh - 56px);
        height: calc(100dvh - 56px);
        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;
}

/* 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"] {
    accent-color: var(--brand);
    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);
}

/* ── 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);
}

.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;
}

.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);
}

.admin-row-actions {
    display: flex;
    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;
}

.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;
}

.admin-did code {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--fg-3);
    word-break: break-all;
}

/* ── 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;
}

.admin-sortable-input {
    flex: 1;
    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 ───────────────────────────────────── */

#components-reconnect-modal {
    border: none;
    border-radius: var(--radius);
    padding: 0;
    background: var(--bg-1);
    box-shadow: var(--shadow-xl);
}

#components-reconnect-modal::backdrop {
    background: rgba(25, 21, 19, 0.5);
    backdrop-filter: blur(10px);
}

.components-reconnect-container {
    padding: 2.25rem;
    text-align: center;
    color: var(--fg);
    font-family: var(--font-body);
}

.components-reconnect-container p {
    margin: 0.5rem 0;
    color: var(--fg-2);
    font-size: 0.93rem;
}

.components-reconnect-container button {
    margin-top: 0.85rem;
    background: var(--brand);
    border: none;
    color: var(--brand-contrast);
    border-radius: var(--radius-pill);
    padding: 0.6rem 1.5rem;
    cursor: pointer;
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.93rem;
    font-weight: 600;
    transition: all var(--duration) var(--ease);
}

.components-reconnect-container button:hover {
    background: var(--brand-bright);
}

/* ── Nav admin links wrapper ──────────────────────────── */

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

/* ── Nav account (avatar + handle) ────────────────────── */

.nav-account {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    padding: 0.25rem 0.85rem 0.25rem 0.3rem;
    background: var(--bg-2);
    border: 1px solid var(--border-1);
    border-radius: var(--radius-pill);
    transition: all var(--duration) var(--ease);
}

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

.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);
}

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

/* ── Sign-out icon button ─────────────────────────────── */

/* ── Hamburger menu (prefs popover) ───────────────────── */

.nav-hamburger {
    display: inline-flex;
    position: relative;
    background: var(--bg-2);
    border: 1px solid var(--border-1);
    color: var(--fg-2);
    cursor: pointer;
    padding: 0;
    width: 40px;
    height: 40px;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: all var(--duration) var(--ease);
    flex-shrink: 0;
}

.nav-hamburger svg {
    width: 22px;
    height: 22px;
    flex-shrink: 0;
}

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

/* Nav menu popover (all viewports) */
.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.65rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    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: 260px;
}

/* 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;
}

/* On desktop+tablet, hide the duplicated mobile admin links inside the menu */
.nav-menu-mobile-links { display: none; }

/* ── Prefs (theme + lang) inside menu ─────────────────── */

.nav-menu-prefs {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 0.5rem 0.5rem 0.65rem;
}

.nav-pref-row {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.nav-pref-label {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--fg-3);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

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

/* Specificity is bumped with `.board-user` so these pill styles win over
   the generic `.board-user button:not(...)` defaults (which would otherwise
   paint a surface background on top of the active pill's brand fill). */
.board-user .nav-pref-options button {
    flex: 1;
    border: none;
    background: transparent;
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.82rem;
    font-weight: 500;
    padding: 0.45rem 0.75rem;
    border-radius: var(--radius-pill);
    cursor: pointer;
    transition: all var(--duration) var(--ease);
    white-space: nowrap;
}

.board-user .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. */
.board-user .nav-pref-options button.active,
:root:not([data-theme]) .board-user .nav-pref-options button[data-theme-set="auto"],
:root[data-theme="light"] .board-user .nav-pref-options button[data-theme-set="light"],
:root[data-theme="dark"] .board-user .nav-pref-options button[data-theme-set="dark"] {
    background: var(--brand);
    color: var(--brand-contrast);
    box-shadow: var(--shadow-xs);
}

/* Sign-out inside menu */
.nav-menu-signout {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    width: 100%;
    border: none;
    background: none;
    color: var(--fg-2);
    font: inherit;
    font-family: var(--font-body);
    font-size: 0.92rem;
    font-weight: 500;
    padding: 0.7rem 1rem;
    cursor: pointer;
    border-top: 1px solid var(--border-1);
    margin-top: 0.25rem;
    border-radius: var(--radius-xs);
    transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

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

/* ── Install as an app ────────────────────────────────── */

/* The one hint outside the menu that installing is possible: a dot on the
   hamburger, shown until the menu has been opened once (js/pwa-install.js
   remembers), and never on a browser that cannot install. */
.nav-hamburger-dot {
    position: absolute;
    top: 6px;
    right: 6px;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--brand);
    /* Punches a ring out of the button so the dot reads at any header color. */
    box-shadow: 0 0 0 2px var(--bg-2);
}

/* Specificity bumped the same way the pref pills are, so the generic
   `.board-user button:not(...)` pill defaults don't paint over this. */
.board-user button.nav-menu-install {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    width: 100%;
    text-align: left;
    border: none;
    background: var(--brand-surface);
    color: var(--brand);
    font: inherit;
    font-family: var(--font-body);
    padding: 0.6rem 0.75rem;
    cursor: pointer;
    border-radius: var(--radius-xs);
    transition: background var(--duration) var(--ease);
}

.board-user button.nav-menu-install:hover {
    background: var(--brand-glow);
    color: var(--brand);
}

.nav-menu-install svg {
    flex-shrink: 0;
}

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

.nav-menu-install-label {
    font-size: 0.92rem;
    font-weight: 600;
}

.nav-menu-install-sub {
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--fg-2);
}

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

/* ── 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;
}

.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);
    pointer-events: auto;
    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) {

    .board-header { padding: 0 0.75rem; height: 56px; }

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

    .nav-admin-links { display: none; }

    .nav-account { padding: 0.2rem; gap: 0; }

        .nav-menu {
        position: fixed;
        top: calc(56px + 0.5rem);
        right: 0.75rem;
        left: 0.75rem;
        min-width: 0;
        max-height: calc(100dvh - 56px - 1rem);
        overflow-y: auto;
    }

    .nav-menu-mobile-links {
        display: flex;
        flex-direction: column;
        gap: 0.15rem;
        padding-bottom: 0.25rem;
        border-bottom: 1px solid var(--border-1);
        margin-bottom: 0.25rem;
    }

    .nav-menu-mobile-links .board-nav-link {
        color: var(--fg);
        font-size: 0.95rem;
        font-weight: 500;
        min-height: 44px;
        display: flex;
        align-items: center;
        padding: 0.5rem 1rem;
        width: 100%;
        text-align: left;
        box-sizing: border-box;
        border-radius: var(--radius-xs);
    }

    .nav-menu-mobile-links .board-nav-link:hover {
        background: var(--brand-glow);
        color: var(--brand);
    }

    /* ── Board: single column with scroll-snap ── */

    .board {
        padding: 0.75rem;
        gap: 0.75rem;
        scroll-snap-type: x mandatory;
        scroll-padding-inline: 0.75rem;
        -webkit-overflow-scrolling: touch;
        /* Phone header is 56px, not the desktop 64px. */
        height: calc(100vh - 56px);
        height: calc(100dvh - 56px);
    }

    .column {
        width: calc(100vw - 1.5rem);
        min-width: calc(100vw - 1.5rem);
        flex: 0 0 auto;
        scroll-snap-align: start;
        max-height: calc(100dvh - 56px - 3rem);
    }

    /* 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;
        bottom: calc(0.6rem + env(safe-area-inset-bottom, 0px));
        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;
        /* 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%;
        max-height: 100vh;
        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;
    }

    .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;
    }

    .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;
    }

    /* 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 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;
    }

    /* 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 {
        padding: 1.1rem 1.15rem;
    }

    /* 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;
    }

    /* Touch-target sizing for text buttons inside .board-user — the
       icon-only round buttons (.nav-hamburger) are
       excluded so the 44px min-height doesn't stretch them into ovals. */
    .board-user button:not(.nav-hamburger) {
        min-height: 44px;
        padding: 0.55rem 1rem;
    }

    .nav-hamburger {
        width: 44px;
        height: 44px;
        padding: 0;
    }

    .comment-form textarea {
        font-size: 16px;
        min-height: 48px;
    }

    .comment-form .btn-primary {
        min-height: 48px;
    }

    /* ── Toast ── */

    .toast-container {
        bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
        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); }
}

/* ══════════════════════════════════════════════════════════
   RESPONSIVE — Tablet (600px – 959px)
   ══════════════════════════════════════════════════════════ */

@media (min-width: 600px) and (max-width: 959px) {

    .nav-menu-mobile-links { display: none; }

    .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);
}

/* 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 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: 1.15rem;
    right: 1.15rem;
    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);
}

.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;
}

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. */
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;
    }
}
