/*
 * Forovanta Community Software
 * Copyright (c) 2026 Zovanta Software Co. <sales@zovanta.dev> - https://zovanta.dev
 * Licensed under the Forovanta Free License Agreement. See LICENSE for details.
 *
 * The self-hosted board's stylesheet.
 *
 * The palette is the React build's palette: the same oklch values from
 * src/index.css (dark and light), the same 0.75rem radius, the same
 * transparent-white borders in dark mode and the same soft card shadow. Keep
 * the two in step — a change to one is a change to both.
 *
 * **One deliberate exception.** `beta.51` moved this board's `--accent` from the React
 * build's violet to an azure at the same lightness and chroma, because the board's owner
 * asked for the purple gone from a dark board. That is the whole of the divergence: every
 * other token, here and in the light block below, is still the React build's value, and a
 * change to any of *those* is still a change to both. The comment on the token itself says
 * the same thing where somebody editing it will read it.
 *
 * Everything below lives in the `legacy` cascade layer. That layer is declared
 * first by the compiled Tailwind sheet the *port* ships, which is what lets a
 * template mix an old class like `.card` with a React utility like `rounded-xl`
 * and have the utility win. The starter does not load that sheet: its tokens are
 * unprefixed, and `--muted` and `--accent` mean different things here than they do
 * in Tailwind's theme, so the two cannot share a page until this file's tokens are
 * name-spaced. Until then, values in this file are matched to `src/index.css` by
 * hand — a change to one is a change to both.
 */
@layer legacy {

/* ---------- design tokens ---------- */
:root {
    --bg: oklch(0.165 0.012 265);
    --surface: oklch(0.205 0.014 265);
    --surface-2: oklch(0.26 0.014 265);
    --border: oklch(1 0 0 / 9%);
    --input: oklch(1 0 0 / 14%);
    --text: oklch(0.955 0.004 260);
    --muted: oklch(0.68 0.014 262);
    /*
     * Azure, not violet.
     *
     * Dark mode used to wear the React build's own accent (`oklch(0.68 0.16 270)`, a
     * violet) and the board's owner asked for it gone: every primary button, pill and
     * focus ring on a dark board was reading as purple. This is the same lightness and
     * chroma at hue 245, so every contrast pair above and below it — `--accent-fg`,
     * `--accent-soft`, the 25% focus wash — still meets the ratio it was chosen for, and
     * nothing else in the palette moves. It is a deliberate divergence from
     * `src/index.css`; the React build keeps its violet, and the two are no longer the
     * same accent.
     */
    --accent: oklch(0.68 0.15 245);
    --accent-hover: oklch(0.74 0.14 245);
    --accent-fg: oklch(0.16 0.02 265);
    --accent-soft: oklch(0.3 0.05 245);
    --accent-soft-fg: oklch(0.88 0.07 245);
    --success: oklch(0.696 0.17 162.48);
    --danger: oklch(0.704 0.191 22.216);
    --radius: 0.75rem;
    /*
     * The gap between cards, everywhere they are stacked or gridded.
     *
     * One token rather than a margin here and a gap there: separate cards were touching
     * edge-to-edge on several pages because each container had grown its own spacing, and a
     * container that already used `gap` gained nothing from a per-page margin. 0.9rem is the
     * middle of the compact 12–16px band, and it is the same in light and dark because it is a
     * distance, not a colour. A container that is a **joined row on purpose** (a table body, a
     * list where the divider is the spacing) keeps `gap: 0` — it opts out deliberately rather
     * than by accident.
     */
    --card-gap: 0.9rem;
    --shadow: 0 1px 2px oklch(0 0 0 / 0.3), 0 4px 16px oklch(0 0 0 / 0.25);
    --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    /*
     * The browser draws its own scrollbars, its own default form controls and its own
     * selection colour from this, and its default is the light set. A dark board that
     * leaves it alone wears white scrollbars down the side of every panel — which is
     * what the chat's viewport was doing.
     */
    color-scheme: dark;
}

:root[data-mode="light"] {
    --bg: oklch(0.988 0.002 260);
    --surface: oklch(1 0 0);
    --surface-2: oklch(0.965 0.004 260);
    --border: oklch(0.92 0.005 260);
    --input: oklch(0.92 0.005 260);
    --text: oklch(0.21 0.02 265);
    --muted: oklch(0.52 0.016 262);
    /* The same azure as dark mode, darkened for white text on a pale page. */
    --accent: oklch(0.52 0.18 250);
    --accent-hover: oklch(0.46 0.17 250);
    --accent-fg: oklch(0.985 0 0);
    --accent-soft: oklch(0.95 0.02 250);
    --accent-soft-fg: oklch(0.38 0.12 250);
    --success: oklch(0.6 0.118 184.704);
    --danger: oklch(0.577 0.245 27.325);
    --shadow: 0 1px 2px oklch(0.21 0.02 265 / 0.04), 0 4px 16px oklch(0.21 0.02 265 / 0.05);
    color-scheme: light;
}

/*
 * ---------- the visitor's layout choices ----------
 *
 * The three switches behind **Customize layout** in the footer, and the same three the
 * React build keeps in `src/lib/layout-prefs.ts`. They are the *reader's* preference,
 * not the board's: stored in `localStorage` under `forovanta.layout` and applied as
 * classes on `<html>` (which is why every rule here is written from `html.layout-…`,
 * two classes or an element plus a class, so none of them needs `!important` to beat
 * the base rule it is overriding).
 *
 * The classes are put on before the first paint by the small script in the header, so a
 * reader who chose fluid width never sees the fixed-width page flash past first.
 */
html.layout-fluid .app-shell { max-width: none; }
html.layout-fluid .site-footer-inner { max-width: none; }
/*
 * Fluid width gives the spare room to the two columns that frame the page, exactly as
 * the React build does: its nav rail is `w-56` fixed and `w-64` fluid, and its widget
 * rail is `w-60` fixed and `w-72` fluid. The fixed pair is in the shell's own rules
 * further down; these are only the fluid overrides, written here so every fluid rule is
 * in one place — and two classes beat the one-class rules they override whatever the
 * order, so they do not need to come after them.
 */
html.layout-fluid .app-shell { grid-template-columns: 16rem minmax(0, 1fr); }
html.layout-fluid .with-rail { grid-template-columns: minmax(0, 1fr) 18rem; }
html.layout-grid .category-grid { grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); }
/* The rail is sticky in the shell's own rule and has been since the board had one, so
   that is the default a reader gets: **Sticky sidebar** written down as *off* is what
   turns it into a column that scrolls away with the top of the page. */
html.layout-static .rail { position: static; }
/* The footer's control says so when any of the three is on, the way the React build's
   switch in the same place shows a live state — and unlike it, this one is a hint about
   the panel rather than a control that changes anything by itself. */
.site-footer-customize.is-on { color: var(--accent); }
.layout-option .switch { margin-top: 0.1rem; }

/*
 * The Customize layout panel: a title, three switch rows, and a Save.
 *
 * `Saved on this device only` is stated on the panel rather than left to the reader to
 * find out, because this is one of the few controls on the board that does not change
 * anything for anybody else.
 */
.layout-option {
    display: flex; align-items: flex-start; gap: 0.75rem;
    padding: 0.85rem 0; border-bottom: 1px solid var(--border);
    margin: 0; font-weight: 400;
}
.layout-option:last-of-type { border-bottom: none; }
.layout-option-text { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.layout-option-title { font-size: 0.875rem; font-weight: 600; }
.layout-option-note { font-size: 0.78rem; color: var(--muted); line-height: 1.5; }
.layout-actions {
    display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
    flex-wrap: wrap; padding-top: 0.9rem;
}
.layout-actions .hint { margin: 0; }

/*
 * List / Grid / Feed — the segmented control.
 *
 * The React build's `ViewModeToggle`: a rounded bordered pill holding three buttons, the
 * active one washed in the accent with a hairline ring and the rest muted until hovered.
 * It is drawn twice — inside the Customize layout panel and again above the forums — from
 * the one helper (`layout_view_toggle()`), so the two copies cannot drift.
 */
.view-toggle {
    display: inline-flex; align-items: center; gap: 0.25rem; flex-shrink: 0;
    padding: 0.2rem; border: 1px solid var(--border); border-radius: 999px;
    background: var(--surface-2);
}
.view-toggle-btn {
    display: inline-flex; align-items: center; gap: 0.35rem;
    padding: 0.25rem 0.6rem; border: 0; border-radius: 999px;
    background: none; color: var(--muted);
    font: inherit; font-size: 0.75rem; font-weight: 600; line-height: 1.5; cursor: pointer;
    transition: background 0.15s ease, color 0.15s ease;
}
.view-toggle-btn:hover { background: var(--surface); color: var(--text); }
/* The active button: `bg-primary/15 text-primary` in the reference, drawn here from the
   palette's own soft-accent pair so it reads the same in both colour modes. */
.view-toggle-btn.is-on { background: var(--accent-soft); color: var(--accent-soft-fg); box-shadow: inset 0 0 0 1px var(--accent); }
.view-toggle-icon { font-size: 0.8rem; }
/* The panel's Default view row: the copy on the left, the segmented control on the right. */
.layout-option--view { justify-content: space-between; align-items: center; }

/* The card head's right-hand side: the view toggle, and a section's own fold control. */
.card-head-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }

/*
 * How the topics are drawn.
 *
 * `list` is the shipped layout and changes nothing at all — a reader who never opens the
 * panel sees the board exactly as it has always been. `grid` turns the feed into a grid of
 * bordered cards, and `feed` sets the same rows out to be **read**: the author's line above
 * the title, the opening post's own two lines under it, the counts on a line of their own
 * and no last-poster column. The classes are put on `<html>` before the first paint, so the
 * right layout is there on the first frame.
 *
 * **Feed is a reordering, not a second list.** Every one of the three draws the same rows —
 * `views/partials/topic-row.php`, the board's only topic row — and the feed is the only mode
 * that reveals the excerpt that partial already carries and moves the meta line above the
 * title. It shipped for one version as a pair of rules that changed a row's padding and
 * nothing else, which made **Feed** and **List** the same screen with 4px between them: a
 * control the reader has to hunt for a difference in is not offering them a choice.
 */
html.layout-grid .topic-feed { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); gap: 0.85rem; }
/*
 * The card lifts under the pointer.
 *
 * This is the reference's own `hover:border-primary/40` — the hairline takes the accent —
 * with the movement the app does in Framer Motion and a stylesheet has to do in `transform`,
 * `box-shadow` and `transition`. `transform` and `box-shadow` are the two properties a browser
 * can animate on the compositor without laying the card out again, which is what keeps a
 * twelve-card grid smooth on a phone.
 */
html.layout-grid .topic-feed .topic-feed-row {
    flex-wrap: wrap; padding: 0.85rem; border: 1px solid var(--border);
    border-radius: 0.85rem; background: var(--surface-2);
    transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
html.layout-grid .topic-feed .topic-feed-row:hover {
    transform: translateY(-3px);
    border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
    box-shadow: var(--shadow);
}
html.layout-grid .topic-feed .topic-feed-tail { width: auto; }
/*
 * Feed.
 *
 * The React build's `FeedEntry`, drawn from the same row: `align-items: flex-start` and
 * `flex-wrap: wrap` so the row can put the counts under the writing instead of beside it.
 */
html.layout-feed .topic-feed .topic-feed-row {
    position: relative;
    align-items: flex-start; flex-wrap: wrap;
    padding: 1rem 0.6rem; border-radius: calc(var(--radius) - 2px);
    transition: background 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
}
/*
 * The accent rail, which grows out of the entry's own middle.
 *
 * An entry is one thread's worth of a page: under the pointer it lifts, its background warms,
 * a shadow settles under it and this rail unrolls down its leading edge, so the reader can see
 * which of a dozen entries the pointer is on. It is drawn on the row's own box rather than on
 * its first child so the rail cannot move when a long title wraps to a second line.
 *
 * The rail is the only part of the motion expressed as a size rather than as a movement, which
 * is why the reduced-motion block below drops it rather than flattening it to a nudge.
 */
html.layout-feed .topic-feed .topic-feed-row::before {
    content: ""; position: absolute; left: 0; top: 50%;
    width: 2px; height: 0; border-radius: 999px; background: var(--accent);
    transform: translateY(-50%); transition: height 0.18s ease;
}
html.layout-feed .topic-feed .topic-feed-row:hover::before { height: 62%; }
html.layout-feed .topic-feed .topic-feed-row:hover {
    background: color-mix(in srgb, var(--surface-2) 55%, transparent);
    transform: translateY(-2px); box-shadow: var(--shadow);
}
/* The author's circle steps forward with the entry it opens. */
html.layout-feed .topic-feed .topic-feed-avatar { transition: transform 0.18s ease; }
html.layout-feed .topic-feed .topic-feed-row:hover .topic-feed-avatar { transform: scale(1.06); }
/* The author's line above the title. `order: -1` on a two-class selector beats the base
   rule's own `margin-top` without needing `!important`, and the column direction is what
   lets the two stack in the first place. */
html.layout-feed .topic-feed .topic-feed-main { display: flex; flex-direction: column; }
html.layout-feed .topic-feed .topic-meta { order: -1; margin: 0 0 0.35rem; }
/* The title line wraps in the feed — the reference's `FeedEntry` title row is `flex-wrap` — so
   a long title can take the second line it is given and its marks can slide down with it. */
html.layout-feed .topic-feed .topic-title { font-size: 1rem; flex-wrap: wrap; }
/* The title wraps in the feed: the reference's `FeedEntry` title is a plain link with no
   truncation, and a reading feed is the one place a long title may take two lines. */
html.layout-feed .topic-feed .topic-title > a { white-space: normal; }
/*
 * The opening post, two lines of it.
 *
 * `-webkit-box` is the line-clamp idiom — with `-webkit-line-clamp` and the vertical
 * orientation it is the one combination every current engine honours, and the reference
 * reaches for the same thing with Tailwind's `line-clamp-2`. The base rule hides this
 * element; only this one reveals it.
 */
html.layout-feed .topic-feed .topic-feed-excerpt {
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
    margin: 0.45rem 0 0; font-size: 0.875rem; line-height: 1.55; color: var(--muted);
}
/* No face stack and no last-poster column: the reference's feed carries neither. */
html.layout-feed .topic-feed .topic-faces,
html.layout-feed .topic-feed .topic-feed-tail { display: none; }
/* The counts on a line of their own, aligned under the writing rather than under the
   author's circle — the circle is 40px and the row's own gap is 0.75rem. */
html.layout-feed .topic-feed .topic-feed-stats {
    order: 5; flex-basis: 100%;
    margin-top: 0.25rem; padding-left: 3.25rem;
}
/*
 * Motion is the first thing to go when a reader has asked for less of it.
 *
 * The wash, the accent hairline and the shadow all stay — none of them moves anything — while
 * every `transform` and the rail's growth are dropped. A reader who asked the system for no
 * animation is not asking for a still board: they are asking not to be moved around by one.
 */
@media (prefers-reduced-motion: reduce) {
    html.layout-grid .topic-feed .topic-feed-row:hover,
    html.layout-feed .topic-feed .topic-feed-row:hover,
    html.layout-feed .topic-feed .topic-feed-row:hover .topic-feed-avatar { transform: none; }
    html.layout-feed .topic-feed .topic-feed-row::before { display: none; }
}

/*
 * ---------- the author card on a post ----------
 *
 * The React build's `PostAuthorPanel`, drawn down the left of every reply: the face, the
 * handle and its role mark, the group ribbons, the five facts and the badge shelf — a
 * centred column, `items-center text-center` in the reference, so the face, the handle and
 * the badge shelf line up down the middle of the column rather than against its left edge.
 * Everything here is written from a two-class selector (`.post-author-facts
 * .post-author-fact`) so it wins against the plainer rules further down this sheet without
 * needing `!important`.
 *
 * The thread's owner used to be marked here, by a crown tab hanging off this column. It is
 * marked on the post instead now — `.post-owner`, under the post's own rules — because the
 * thing that carries the thread is the post, not the byline.
 */
.post-author { position: relative; }
/*
 * The marks a handle wears, wherever a name is drawn: a gold crown for an administrator, a
 * yellow shield for a moderator, and an electric blue seal for a verified member.
 *
 * Each is one inline box in the name's own line rather than a block beside it — a mark that
 * is its own flex item in a column can be pushed onto a line of its own, which is how the
 * crown ended up printed *under* a handle on a post's author card.
 *
 * Each is sized by **font-size** rather than by a fixed width, and that is the whole of why
 * the crown came out as large as the name beside it: a glyph in an icon font is drawn at the
 * type size the element is given, so a mark whose box alone was shrunk still drew its crown
 * at the handle's own size. A fraction of the handle's font size is the size that can never
 * come out larger than the name. The verified mark is two glyphs in one box — Font Awesome's
 * free set has a jagged seal and a check and no single glyph that is both, so the check is
 * set inside the seal, in white — which is why the box around them is positioned.
 */
.role-mark, .verified-mark {
    display: inline-flex; align-items: center; margin-left: 0.3rem;
    vertical-align: middle; flex-shrink: 0;
}
.role-mark-icon { font-size: 0.75em; width: 1em; height: 1em; line-height: 1; }
.role-mark--admin { color: #f3b61f; }
.role-mark--mod { color: #facc15; }
.verified-mark { position: relative; justify-content: center; }
/* Two rows already space their own children apart, so the mark's own margin is doubled there. */
.profile-name .role-mark, .profile-name .verified-mark,
.member-card-name .role-mark, .member-card-name .verified-mark { margin-left: 0; }
.verified-mark-seal { font-size: 0.95em; width: 1em; height: 1em; line-height: 1; color: #23a6ff; }
/*
 * The check, centred on the seal rather than laid out after it: both are glyphs of the same
 * font, so a box of the seal's own middle is a box the check's ink sits in the middle of.
 */
.verified-mark-check {
    position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
    font-size: 0.48em; width: 1em; height: 1em; line-height: 1; color: #fff;
}
.post-author-banners { width: 100%; justify-content: center; }
.post-author-ribbon {
    display: flex; align-items: center; gap: 0.3rem; width: 100%;
    padding: 0.15rem 0.35rem;
    border: 1px solid color-mix(in srgb, #fff 20%, transparent); border-radius: 0.25rem;
    background: linear-gradient(180deg, var(--banner-color), color-mix(in srgb, var(--banner-color) 80%, transparent));
    color: #fff; font-size: 0.5625rem; font-weight: 900; letter-spacing: 0.025em;
    text-transform: uppercase; text-align: center; box-shadow: var(--shadow);
}
/*
 * The label is one line and does not break. The reference sets it in one — its own label is
 * a `flex-1` span with no wrapping rule at all — while `overflow-wrap: anywhere` in a column
 * this narrow split the longest one mid-word, so `ADMINISTRATIVE` was printed as
 * "ADMINISTRATI/VE" and the ribbon read as a defect rather than as a badge of office. A
 * label longer than the ribbon is cut with an ellipsis instead, so nothing spills out of the
 * panel either.
 */
.post-author-ribbon > span { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.post-author-ribbon-icon { width: 0.7rem; height: 0.7rem; flex-shrink: 0; }
.post-author-facts .post-author-fact {
    padding: 0.15rem 0.4rem; border: 1px solid var(--border); border-radius: calc(var(--radius) - 6px);
}
.post-author-badges {
    display: flex; flex-wrap: wrap; justify-content: center; gap: 0.3rem; width: 100%;
    margin-top: 0.4rem; padding-top: 0.4rem; border-top: 1px solid var(--border);
}
.post-author-badge {
    display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 6px);
    background: color-mix(in srgb, var(--surface-2) 50%, transparent); color: var(--badge-color);
}
.post-author-badge-icon { width: 0.85rem; height: 0.85rem; }
/* The share marks under a post: the rail's row, a step quieter and on the left. */
.post-share { margin-top: 0.5rem; gap: 0.3rem; }
.post-share .rail-share-link { width: 1.15rem; height: 1.15rem; }
.post-share .rail-share-icon { width: 0.6rem; height: 0.6rem; }

/*
 * ---------- the blog index ----------
 *
 * Two cards to a row from `md` up, which is the reference's `grid gap-4 md:grid-cols-2`: one
 * column on a phone where a cover would leave no room for the words, two everywhere else.
 */
.blog-grid { display: grid; gap: var(--card-gap); grid-template-columns: minmax(0, 1fr); }
@media (min-width: 768px) { .blog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.blog-card {
    display: flex; flex-direction: column;
    overflow: hidden;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow);
    transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.blog-card:hover {
    transform: translateY(-2px);
    border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.blog-card-cover { display: block; }
.blog-card-cover img { display: block; width: 100%; height: 10rem; object-fit: cover; }
/* No cover: the reference's accent wash, `h-24 bg-gradient-to-br from-primary/25 via-primary/10`. */
.blog-card-fallback {
    display: block; height: 6rem;
    background: linear-gradient(135deg,
        color-mix(in srgb, var(--accent) 25%, transparent),
        color-mix(in srgb, var(--accent) 10%, transparent) 55%,
        transparent);
}
.blog-card-body { display: flex; flex-direction: column; gap: 0.25rem; padding: 1rem; }
.blog-card-title { margin: 0; font-size: 1.125rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; }
.blog-card-title a { color: var(--text); }
.blog-card-title a:hover { color: var(--accent); text-decoration: none; }
/* Three lines of excerpt, then it stops — the reference's `line-clamp-3`. */
.blog-card-excerpt {
    display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
    overflow: hidden; margin: 0.25rem 0 0;
    font-size: 0.875rem; color: var(--muted);
}
.blog-card-foot {
    display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
    margin-top: 0.75rem; font-size: 0.75rem;
}
.blog-card-author { font-size: 0.75rem; font-weight: 500; color: var(--text); }
/* Two classes, because `.tag`'s own font size is one class and would otherwise win. */
.blog-card .blog-card-tag { font-size: 0.625rem; }

/*
 * ---------- one blog post ----------
 *
 * The article's own head (eyebrow, category, title, byline and its `…` menu), the row of
 * things a reader can do to it, and the discussion under it: the reference build's
 * `BlogPost` page and `CommentsSection`, in the board's own tokens.
 */
.blog-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
.blog-eyebrow {
    font-size: 0.72rem; font-weight: 700; letter-spacing: 0.18em;
    text-transform: uppercase; color: var(--accent);
}
.blog-title { margin: 0.6rem 0 0.35rem; font-size: clamp(1.6rem, 3.4vw, 2.1rem); line-height: 1.15; }
.blog-byline { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
/* The menu rides at the end of the byline, where the reference keeps it. */
.blog-menu-tail { margin-left: auto; display: inline-flex; align-items: center; }
.blog-actions {
    display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
    margin-top: 0.9rem; padding-top: 0.75rem; border-top: 1px solid var(--border);
}
.blog-share { display: flex; align-items: center; gap: 0.45rem; }
.blog-share > .muted { display: inline-flex; align-items: center; gap: 0.3rem; }
.blog-report > summary,
.comment-report > summary {
    list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 0.35rem;
}
.blog-report > summary::-webkit-details-marker,
.comment-report > summary::-webkit-details-marker { display: none; }
.comment-report > summary {
    width: 1.9rem; height: 1.9rem; justify-content: center; border-radius: 999px;
    border: 1px solid var(--border); color: var(--muted); background: var(--surface-2);
}
.comment-report > summary:hover { color: var(--text); }
.comment-report[open] > summary { color: var(--accent); border-color: var(--accent); }
/* The report form opens under the control that opened it, not over the page. */
.blog-report > .report-form, .comment-report > .report-form { margin-top: 0.6rem; width: min(22rem, 80vw); }
.comment-report { position: relative; }
.comment-report > .report-form {
    position: absolute; right: 0; z-index: 5; padding: 0.75rem;
    border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
    box-shadow: var(--shadow);
}

/* ---------- the discussion under a post ---------- */
.comments { display: flex; flex-direction: column; gap: 0.85rem; min-width: 0; }
.comments-head h2 { margin: 0.35rem 0 0.4rem; font-size: clamp(1.4rem, 2.6vw, 1.85rem); }
.comment-card { padding: 1.1rem; }
.react-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
.react-title { margin: 0; font-weight: 600; }
.reaction-pills { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; }
/* A pill is a button on a form, so it carries no browser chrome of its own. */
.reaction-pill {
    display: inline-flex; align-items: center; gap: 0.35rem;
    padding: 0.45rem 0.8rem; border: 1px solid var(--border); border-radius: 999px;
    background: transparent; color: var(--text); font: inherit; font-size: 0.78rem;
    font-weight: 500; cursor: pointer;
}
.reaction-pill:hover { background: var(--surface-2); }
.reaction-pill.is-on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-soft-fg); }
.reaction-pill.is-static { cursor: default; color: var(--muted); }
.reaction-pill.is-static:hover { background: transparent; }
.comment-compose-foot {
    display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
    flex-wrap: wrap; margin-top: 0.7rem;
}
.comment { padding: 1.05rem; }
.comment-head { display: flex; align-items: flex-start; gap: 0.7rem; }
.comment-who { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.comment-name { display: inline-flex; align-items: center; gap: 0.45rem; font-weight: 600; flex-wrap: wrap; }
.comment-head-tail { margin-left: auto; display: inline-flex; align-items: center; gap: 0.35rem; }
.comment-icon-btn {
    display: inline-flex; align-items: center; justify-content: center;
    width: 1.9rem; height: 1.9rem; border-radius: 999px; cursor: pointer;
    border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
}
.comment-icon-btn:hover { color: var(--danger); border-color: var(--danger); }
.comment-body { margin: 0.7rem 0 0; }
.comment-bar {
    display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap;
    margin-top: 0.8rem; padding-top: 0.75rem; border-top: 1px solid var(--border);
}
.comment-vote {
    display: inline-flex; align-items: center; justify-content: center;
    width: 1.9rem; height: 1.9rem; border: 0; border-radius: 999px;
    background: transparent; color: var(--muted); cursor: pointer;
}
.comment-vote:hover { background: var(--surface-2); }
.comment-vote--up:hover, .comment-vote--up.is-on { color: var(--success); }
.comment-vote--down:hover, .comment-vote--down.is-on { color: var(--danger); }
.comment-vote--up.is-on { background: color-mix(in srgb, var(--success) 12%, transparent); }
.comment-vote--down.is-on { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.comment-vote.is-static { cursor: default; opacity: 0.7; }
.comment-vote.is-static:hover { background: transparent; }
.comment-vote-icon { font-size: 0.9rem; }
.comment-score {
    min-width: 2.4rem; text-align: center; font-weight: 700;
    font-variant-numeric: tabular-nums; color: var(--muted);
}
.comment-score.is-up { color: var(--success); }
.comment-score.is-down { color: var(--danger); }
.comment-bar-sep { width: 1px; height: 1.1rem; background: var(--border); margin: 0 0.2rem; }
.comment-reaction {
    display: inline-flex; align-items: center; gap: 0.3rem;
    padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 999px;
    background: transparent; color: var(--text); font: inherit; font-size: 0.75rem; cursor: pointer;
}
.comment-reaction:hover { background: var(--surface-2); }
.comment-reaction.is-on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-soft-fg); }
.comment-reaction.is-static { cursor: default; color: var(--muted); }
.comment-reaction.is-static:hover { background: transparent; }

/*
 * ---------- the bell, and its panel ----------
 *
 * The dropdown the React build's `ForumHeader` opens: the newest notifications, then
 * "Mark all read" and "View all". It hangs off the header, so it stays put while the list
 * inside it scrolls, and it is a `<details>` — the summary is the trigger, which is why it
 * carries the icon-button class the header's other controls use.
 */
.bell-menu { position: relative; }
.bell-menu > summary { list-style: none; cursor: pointer; }
.bell-menu > summary::-webkit-details-marker { display: none; }
.bell-menu[open] > summary { background: var(--surface-2); color: var(--text); }
.bell-panel {
    position: absolute; right: 0; top: calc(100% + 0.5rem); z-index: 60;
    width: min(20rem, calc(100vw - 2rem)); overflow: hidden;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface); box-shadow: var(--shadow);
}
.bell-panel-head {
    display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
    padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border);
}
.bell-panel-title { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.02em; }
.bell-list { max-height: 20rem; overflow-y: auto; }
.bell-empty { padding: 1.6rem 0.75rem; text-align: center; font-size: 0.85rem; color: var(--muted); margin: 0; }
.bell-item {
    display: flex; align-items: flex-start; gap: 0.55rem;
    padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text);
}
.bell-item:last-child { border-bottom: none; }
.bell-item:hover { background: var(--surface-2); text-decoration: none; }
.bell-item.unread { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.bell-item-mark {
    display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
    width: 1.6rem; height: 1.6rem; border-radius: 999px;
    background: var(--surface-2); color: var(--accent);
}
.bell-item-icon { font-size: 0.8rem; }
.bell-item-main { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
.bell-item-line { font-size: 0.82rem; line-height: 1.3; }
.bell-item-topic { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bell-item-when { font-size: 0.7rem; color: var(--muted); }
.bell-panel-foot {
    display: flex; align-items: center; gap: 0.35rem;
    padding: 0.4rem; border-top: 1px solid var(--border);
}
.bell-panel-foot form { flex: 1; margin: 0; }
.bell-foot-link {
    display: block; width: 100%; padding: 0.4rem 0.5rem;
    border: 0; border-radius: calc(var(--radius) - 6px); background: none;
    color: var(--text); font: inherit; font-size: 0.78rem; text-align: center; cursor: pointer;
}
.bell-panel-foot > .bell-foot-link { flex: 1; }
.bell-foot-link:hover { background: var(--surface-2); text-decoration: none; }
.bell-foot-link--accent { color: var(--accent); }

/*
 * ---------- Admin → Addons ----------
 *
 * One card per package. The React build's `AddonsAdmin` draws the same thing: the name, its
 * version, how many settings it brings and who wrote it on one line, the two state pills
 * beside them, the description in full, and the two things an administrator does with a
 * package — download it, or install it — under that.
 */
/*
 * A heading between the breadcrumb and the cards.
 *
 * The reference's `AddonsAdmin` leads with one, and the admin shell has nothing between its
 * breadcrumb and the first card: without it the screen opens on a form with no idea what the
 * screen is. `--tight` is the same block used again mid-page, above a second list.
 */
.admin-screen-head { margin: 0 0 1rem; }
.admin-screen-head--tight { margin-top: 1.6rem; }
.admin-screen-head h2 { margin: 0; font-size: 1.15rem; }
.admin-screen-head p { margin: 0.2rem 0 0; font-size: 0.86rem; color: var(--muted); line-height: 1.55; }
.admin-screen-head code {
    padding: 0.05rem 0.3rem; border-radius: 0.3rem;
    background: var(--surface-2); font-family: var(--mono); font-size: 0.82em;
}

/*
 * The installer: a drop zone, then the button that reads the package.
 *
 * The zone is a `<label>` around the file input, so it opens the picker without a line of script —
 * `assets/addons.js` only adds dragging onto it and the name of the file that has been chosen.
 */
.addon-install { margin: 0; }
.addon-dropzone {
    display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
    padding: 1.8rem 1.2rem; border: 2px dashed var(--border); border-radius: var(--radius);
    background: color-mix(in srgb, var(--surface-2) 55%, transparent);
    text-align: center; cursor: pointer; transition: border-color 0.15s ease, background 0.15s ease;
}
.addon-dropzone:hover, .addon-dropzone.is-dragging {
    border-color: var(--accent);
    background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.addon-dropzone-icon { font-size: 1.5rem; color: var(--muted); }
.addon-dropzone.is-dragging .addon-dropzone-icon, .addon-dropzone:hover .addon-dropzone-icon { color: var(--accent); }
.addon-dropzone-title { font-size: 0.92rem; font-weight: 600; }
.addon-dropzone-hint { font-size: 0.8rem; color: var(--muted); max-width: 34rem; line-height: 1.5; }
.addon-dropzone-hint code { padding: 0.05rem 0.3rem; border-radius: 0.3rem; background: var(--surface-2); font-family: var(--mono); }
.addon-dropzone-file { margin-top: 0.3rem; font-family: var(--mono); }
.addon-dropzone-button { margin-top: 0.5rem; pointer-events: none; }
.addon-install-actions { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; margin-top: 0.8rem; }

/*
 * The pre-install review.
 *
 * Everything the installer found, drawn before it writes: the manifest's own facts, what the
 * archive holds, what it declares, and the plan — a new install, an upgrade, a rollback, or a
 * refusal. The accent border is the same one the reference puts round it.
 */
.addon-review {
    display: flex; flex-direction: column; gap: 0.7rem;
    padding: 1rem 1.1rem; border: 1px solid var(--border); border-radius: var(--radius);
    background: color-mix(in srgb, var(--surface-2) 40%, transparent);
}
.addon-review--blocked { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.addon-review-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.8rem; flex-wrap: wrap; }
.addon-review-name { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; margin: 0; font-weight: 600; }
.addon-review-identity { margin: 0.25rem 0 0; }
.addon-review-identity code { font-family: var(--mono); }
.addon-review-verdict { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: var(--success); }
.addon-review-verdict--bad { color: var(--danger); }
.addon-review-plan { margin: 0; font-size: 0.82rem; color: var(--muted); line-height: 1.55; }
.addon-review-plan--bad { color: var(--danger); }
.addon-review-badges { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.addon-review-badges .pill { font-size: 0.74rem; }
.addon-review-settings {
    padding: 0.7rem 0.8rem; border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
    background: var(--surface-2);
}
.addon-review-settings-title {
    margin: 0; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em;
    text-transform: uppercase; color: var(--muted);
}
.addon-review-settings ul { margin: 0.45rem 0 0; padding: 0; list-style: none; display: grid; gap: 0.25rem; }
.addon-review-settings li { font-size: 0.78rem; color: var(--muted); }
.addon-review-settings code { font-family: var(--mono); color: var(--text); }
.addon-review-warnings { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.3rem; font-size: 0.8rem; color: var(--danger); }
.addon-review-warnings li { display: flex; align-items: flex-start; gap: 0.35rem; }
.addon-review-notes { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.3rem; font-size: 0.78rem; color: var(--muted); line-height: 1.5; }
.addon-review-actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.addon-review-actions form { margin: 0; }
.addon-review-actions .button { display: inline-flex; align-items: center; gap: 0.35rem; }

/*
 * The packages this board runs, as **rows**.
 *
 * This was a grid of tiles — two to a row, a description and a switch on each — and the geometry
 * was its own bug report twice over (a `19rem` floor drew three across where the reference drew
 * two, and the body had no `flex` of its own so it took every pixel of shrinkage and wrapped one
 * word to a line). It is a table now, because the installed list is a **management list**: ten
 * packages to a page, one line each, ordered so the columns can be compared down the page — the
 * version, whether it is running, whether it is licensed — and the actions in a column of their
 * own. A tile cannot be compared with the tile beside it; a row can.
 *
 * The table scrolls sideways inside `.table-scroll` rather than squeezing: the actions column holds
 * stacked forms, and a 360px phone is served better by a horizontal scroll than by four-pixel-wide
 * buttons. `min-width: 0` on the name cell is what stops a long package name from setting the
 * table's width for it. That wrapper is the starter's own (see `.table-scroll` below and
 * `admin/members.php`); the view first shipped with a `table-wrap` class that no rule defined, so
 * at 390px the installed table set the document's width instead of scrolling inside it.
 */
.addon-views { margin-top: 0; }
.addon-view-count {
    margin-left: 0.35rem; padding: 0 0.35rem; border-radius: 999px; font-size: 0.68rem;
    background: color-mix(in srgb, var(--muted) 18%, transparent);
}
.addon-search { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; margin: 0 0 0.9rem; }
.addon-search input[type="search"] { flex: 1 1 14rem; min-width: 0; }
.addon-search-count { margin-left: auto; }
.addon-rows { font-size: 0.82rem; }
.addon-rows td { vertical-align: top; }
.addon-rows-actions-head { width: 1%; white-space: nowrap; }
.addon-rows-actions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; min-width: 12rem; }
.addon-rows-actions form { margin: 0; }
.addon-rows-actions .button { display: inline-flex; align-items: center; gap: 0.3rem; }
.addon-row-name { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0; font-weight: 600; }
.addon-row-name a { color: var(--text); }
.addon-row-icon { color: color-mix(in srgb, var(--muted) 55%, transparent); }
.addon-row-icon.is-on { color: var(--accent); }
.addon-row-meta { margin: 0.2rem 0 0; min-width: 0; overflow-wrap: anywhere; }
.addon-row-meta code { font-family: var(--mono); }
.addon-row-db, .addon-row-licence { display: inline-block; margin-top: 0.2rem; }
.addon-settings-table { font-size: 0.8rem; }
.addon-settings-table td { vertical-align: top; }
.addon-settings-group td { background: var(--surface-2); font-size: 0.78rem; }
.addon-settings-value { font-family: var(--mono); overflow-wrap: anywhere; }
.addon-quicklinks .addon-settings-links { margin-top: 0; }
.addon-settings-links { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }
.addon-doc { padding: 0.7rem 0; border-top: 1px solid var(--border); }
.addon-doc:first-of-type { border-top: none; padding-top: 0; }
.addon-doc > summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; }
.addon-doc > summary code { font-family: var(--mono); font-weight: 500; }
.addon-doc-body { padding-top: 0.7rem; }
.addon-doc-body .addon-code { margin-top: 0; }

/*
 * The licences.
 *
 * A licence is a form and a state, and three of them standing between the packages and the webhooks
 * made the screen read as a licensing page with some packages on it. They were moved into a fold,
 * and are their **own view** on the Add-ons screen now — the same forms in the same place, one click
 * from the list instead of in the middle of it.
 */
.addon-licences-body { display: flex; flex-direction: column; gap: 1.1rem; }
.addon-licence-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0 0 0.25rem; font-size: 0.95rem; }
.addon-licence-row { padding-top: 0.9rem; border-top: 1px solid var(--border); }
.addon-licence-row:first-child { padding-top: 0; border-top: none; }
.addon-licence-master p { margin: 0 0 0.5rem; line-height: 1.55; }
.addon-licence-buy { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 0.6rem 0 0; }

/*
 * The events the board can announce, and the addresses it announces them to.
 */
.addon-events { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); }
.addon-event { padding: 0.6rem 0.7rem; border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); }
.addon-event code { font-size: 0.78rem; font-weight: 600; font-family: var(--mono); }
.addon-event p { margin: 0.15rem 0 0; line-height: 1.45; }
.addon-deliveries { margin: 0.9rem 0 1.1rem; }
.addon-webhooks { margin: 0; }
/*
 * The webhook "events to send" checklist.
 *
 * The board's global `input { width: 100%; height: 2.25rem }` is meant for text boxes; applied
 * to a checkbox it stretched every tick into a full-width row and pushed the event's own wording
 * onto the line below, so the list read as a column of empty boxes beside a column of labels —
 * the tick drawn in the middle of its stretched box, far from what it names. Scoping the ticks
 * back to a real checkbox size and laying each label out as a row fixes it, the same correction
 * `.api-scope` makes on the API keys screen.
 */
.webhook-events { border: 1px solid var(--border); border-radius: 0.6rem; padding: 0.55rem 0.75rem; margin: 1rem 0 0; }
.webhook-events legend { padding: 0 0.375rem; }
.webhook-event { display: flex; align-items: flex-start; gap: 0.6rem; margin: 0.4rem 0 0; font-weight: 400; }
.webhook-event input[type="checkbox"] { width: 1rem; height: 1rem; margin: 0.15rem 0 0; flex-shrink: 0; accent-color: var(--accent); }
.webhook-event > span { min-width: 0; }
.webhook-event code { font-family: var(--mono); font-size: 0.78rem; }

/*
 * The Package & manifest reference.
 *
 * The one place on the board where the installer's own rules are written down, so it is a card of
 * copy rather than a control: the ZIP's shape, a manifest to copy from, and the four promises the
 * installer keeps.
 */
.addon-code {
    margin: 0 0 0.9rem; padding: 0.85rem 1rem; overflow-x: auto;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px);
    background: var(--surface-2); color: var(--text);
    font-family: var(--mono); font-size: 0.76rem; line-height: 1.6;
}
.addon-code-title { margin: 0 0 0.4rem; font-weight: 600; font-size: 0.86rem; }
.addon-rules { display: grid; gap: 0.6rem; margin: 0 0 0.9rem; }
.addon-rules p { display: flex; align-items: flex-start; gap: 0.45rem; margin: 0; font-size: 0.8rem; color: var(--muted); line-height: 1.55; }
.addon-rules p svg, .addon-rules p i { flex: none; margin-top: 0.15rem; }
.addon-rules b { color: var(--text); }
.addon-list { display: flex; flex-direction: column; }
.addon-item { padding: 1.05rem 0; border-top: 1px solid var(--border); }
.addon-item:first-child { border-top: none; padding-top: 0.4rem; }
.addon-item-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.addon-item-name { margin: 0; font-size: 1rem; }
.addon-item-description { margin: 0.5rem 0 0; font-size: 0.86rem; color: var(--text); line-height: 1.55; }
/*
 * The licence line, on a commercial package only.
 *
 * Full width and quiet rather than a footnote, because it decides whether the package may
 * be run at all: an administrator should not have to open the licence to find out that one
 * live board per licence is the rule.
 */
.addon-item-licence {
    margin: 0.55rem 0 0; padding: 0.55rem 0.7rem;
    border-left: 3px solid var(--warning, var(--accent)); border-radius: 0 0.35rem 0.35rem 0;
    background: color-mix(in srgb, var(--accent) 7%, transparent);
    font-size: 0.8rem; color: var(--muted); line-height: 1.5;
}
.addon-item-actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.8rem; }
.addon-item-actions form { margin: 0; }
.addon-item-actions .button { display: inline-flex; align-items: center; gap: 0.35rem; }
.addon-item-listing { display: flex; align-items: center; gap: 0.35rem; margin: 0.6rem 0 0; flex-wrap: wrap; }
/* The licence pill: the one badge on this screen that is not a state. */
.pill--licence {
    background: color-mix(in srgb, #d97706 16%, transparent);
    color: #b45309; border-color: transparent;
}
html[data-mode="dark"] .pill--licence { color: #fbbf24; }
/*
 * Where a commercial package's licence actually stands.
 *
 * A line of its own under the licence text, because the two answer different questions: the
 * notice above says what the agreement is, this says whether this board is inside it.
 */
.addon-item-licence-state {
    display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin: 0.5rem 0 0;
}
.addon-item-licence-state svg { flex: none; }
/*
 * A licence form: the label, the key, the button that stores it, and the pill.
 *
 * A licence is up to about 150 characters, so the field takes the room rather than the row
 * sitting in a column a third of the width of the card with the key cut off in it.
 */
/*
 * The two vendor buttons on the Addons screen: Browse and Buy point at the same address, so
 * they sit together as one row that wraps on a narrow screen.
 */
.addon-store-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 0.8rem 0 0; }
.addon-store-actions .button { display: inline-flex; align-items: center; gap: 0.35rem; }
.addon-licence { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0.7rem 0 0; }
.addon-licence label { flex-basis: 100%; margin: 0; }
.addon-licence input[type="text"] {
    flex: 1 1 24rem; min-width: 0; padding: 0.5rem 0.6rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface-2); color: var(--text);
    font: inherit; font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis;
}
.addon-licence .pill { margin-left: auto; }
/*
 * A stored licence key, read back on demand.
 *
 * The box above stores a key and never draws it, so what is left to style is the way an
 * administrator gets it out again: **Show key** and **Copy key** as one small row, with the value
 * on its own line under them once it is drawn — a key is up to about 150 characters of one long
 * word, so it wraps anywhere rather than stretching the panel, and it is set in the monospace the
 * Themes screen's own key field uses so it can be read character by character.
 *
 * The value and the two buttons are drawn in the page only after the button is pressed; the
 * script writes the value in as text. The rules live here rather than in a screen's own
 * stylesheet because the Add-ons and Themes screens both draw this control.
 */
.licence-key { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0 0; }
.licence-key .button { display: inline-flex; align-items: center; gap: 0.3rem; }
.licence-key-value {
    flex-basis: 100%; min-width: 0; padding: 0.5rem 0.6rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface-2); color: var(--text);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.75rem; line-height: 1.6; word-break: break-all;
}
.licence-key-value[hidden] { display: none; }
.licence-key-note { flex-basis: 100%; margin: 0; }
.licence-key-note.is-error { color: var(--danger); }
.licence-key.is-busy .button { opacity: 0.65; }
/*
 * The vendor master licence.
 *
 * The authors' own licence, so it sits above the packages and is marked as the odd one out:
 * a card that looked like a fifth package would read as one more thing to buy.
 */
.addon-master-card {
    border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
    background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 70%);
}
html[data-mode="dark"] .addon-master-card {
    background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 10%, transparent), transparent 70%);
}

/* ---------- base ---------- */
* { box-sizing: border-box; }
/*
 * The scrollbar's gutter, reserved on every page.
 *
 * A page that needs a scrollbar gets one; a page that does not, does not — and the
 * viewport it is drawn in is 15px narrower when it does. On the board that only made
 * one column a hair narrower than its neighbours. In the moderation panel it was a
 * visible defect: the overview is short enough to fit and the queues are not, so the
 * whole panel — heading, nav, cards — stepped sideways every time a moderator moved
 * between sections. Reserving the gutter whether or not a scrollbar is drawn holds
 * the layout still, and the pages that never scroll simply keep the space empty.
 *
 * `scrollbar-gutter` is a browser feature, so it is written as a declaration the
 * browsers that support it act on and the ones that do not ignore.
 */
html { scrollbar-gutter: stable; }
body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font-family: var(--font);
    font-size: 1rem;
    line-height: 1.55;
    font-feature-settings: "cv02", "cv03", "cv04", "cv11";
    -webkit-font-smoothing: antialiased;
}
h1, h2, h3 { margin: 0 0 0.5rem; line-height: 1.2; letter-spacing: -0.01em; }
h1 { font-size: 1.5rem; font-weight: 700; }
h2 { font-size: 1.125rem; font-weight: 600; }
h3 { font-size: 1rem; font-weight: 600; }
/*
 * No underlines on links, at rest or under the pointer.
 *
 * The board shows an affordance the way the React build does instead: a link takes
 * the brighter accent and, where it sits in a block of its own, a background. An
 * underline under every hovered link — including nav items, buttons drawn as anchors,
 * chips, avatars and card titles — is the one piece of 1999 that was left.
 */
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
hr { border: none; border-top: 1px solid var(--border); }

/* One consistent keyboard focus ring, matching the React build. */
:where(a, button, [role="button"], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.checkbox-row {
    display: flex; gap: 0.5rem; align-items: center;
    margin: 0.5rem 0; font-weight: 400;
}
.checkbox-row input[type="checkbox"] { width: auto; }

/*
 * The form layer.
 *
 * These four classes are the PHP twin of the React form primitives, and every
 * new screen should be built from them rather than from inline styles: a
 * `.field` is one label + one control stacked with the same 0.5rem gap the
 * React `grid gap-2` form rows use, `.form-row` lays several fields out
 * side by side and wraps, `.switch-row` is a labelled toggle pinned to the far
 * right of its row, and `.check` is a standalone tick box that does not stretch
 * across the card (the board's global `input { width: 100% }` would do that).
 */
.field { display: flex; flex-direction: column; gap: 0.5rem; margin: 0 0 1rem; min-width: 0; }
.field > label { margin: 0; font-size: 0.875rem; font-weight: 500; line-height: 1; }
.field > .hint, .hint { font-size: 0.8rem; color: var(--muted); line-height: 1.5; margin: 0; }
.field > .hint { font-size: 0.75rem; }
.field:last-child { margin-bottom: 0; }
.form-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
.form-row > .field { flex: 1 1 11rem; margin-bottom: 0; }
.form-row > .field.wide { flex: 2 1 16rem; }
.form-row > .field.narrow { flex: 0 1 8rem; }
.form-row > .form-action { padding-bottom: 0; }

.switch-list { display: flex; flex-direction: column; margin: 0.25rem 0 1rem; }
.switch-row {
    display: flex; align-items: center; justify-content: space-between; gap: 1rem;
    padding: 0.65rem 0; border-bottom: 1px solid var(--border);
    font-size: 0.875rem; font-weight: 400;
}
.switch-row:last-child { border-bottom: none; }
.switch-row > span { min-width: 0; }
.switch-row input[type="checkbox"] {
    width: 1.05rem; height: 1.05rem; flex-shrink: 0; margin: 0;
    accent-color: var(--accent);
}
.check {
    display: inline-flex; align-items: center; gap: 0.5rem;
    margin: 0; font-size: 0.875rem; font-weight: 400;
}
.check input[type="checkbox"] { width: 1rem; height: 1rem; margin: 0; accent-color: var(--accent); }
/*
 * The tick box and the hidden `0` beside it, as one control at the far right of a `.switch-row`.
 *
 * `.switch-row` lays its two children out with `justify-content: space-between`, and a provider
 * card's switch posts a hidden `0` before the box so that an unchecked box is a stated "off"
 * rather than a missing field (`admin/payments.php`, `payment_provider_save`). The pair travels as
 * one flex child — the hidden input draws nothing — so the box stays exactly where it was.
 */
.switch-row__control { display: inline-flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }

/*
 * A masked gateway credential: the password box, then Show / Copy, then the note.
 *
 * `input { width: 100% }` is the board's global rule, so the box takes its own row and the
 * controls sit under it rather than squeezing it — the same arrangement every other field on the
 * screen has. The note is the line the reveal script writes into; empty until it has something to
 * say, so a secret stays `textContent`-only and never reaches the markup.
 */
.secret-field { display: flex; flex-direction: column; gap: 0.5rem; }
.secret-field__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.secret-field__note { margin: 0; }
.secret-field__note.is-error { color: var(--danger, #dc2626); }
.secret-field.is-busy .secret-field__actions { opacity: 0.65; }

/* A titled block inside a card: heading, one-line description, then controls. */
.block { border-top: 1px solid var(--border); margin-top: 1.15rem; padding-top: 1.15rem; }
.block:first-child { border-top: none; margin-top: 0; padding-top: 0; }
.block > h3 { margin: 0 0 0.2rem; }
.block > .hint { font-size: 0.8rem; color: var(--muted); margin: 0 0 0.85rem; }

/* ---------- app shell (sidebar + content), mirroring the React layout ---------- */
/*
 * Geometry taken straight from the React `ForumLayout`: a 14rem sidebar, 2rem
 * between the columns and 1rem inside them, `gap-4` between nav groups, a
 * 10px/0.16em muted caption above each group, and 0.875rem rows with a 0.5rem
 * radius that gain a small shadow when active.
 */
.app-shell {
    display: grid;
    grid-template-columns: 14rem minmax(0, 1fr);
    gap: 2rem;
    max-width: 1400px;
    margin: 0 auto;
    padding: 1.5rem 1rem 4rem;
    align-items: start;
}
/*
 * One width, 1400px, everywhere.
 *
 * The staff screens used to raise this container to 1600px on the argument that the
 * React build does the same for its admin area. It does — but a board that asks three
 * columns of tables for 1600px is a board laid out for a monitor the reader may not
 * have, and a page that silently overflows or crowds on a 1280 screen is worse than one
 * that wraps a card. The ceiling is the same on every screen now.
 *
 * The number itself is the React build's own `max-w-[1400px]` on `ForumLayout` — the
 * column that holds the sidebar, the page and the widget rail. It was 1280 for as long
 * as the board had one width at all, and the two front ends were not the same product at
 * 1280: with the rail taking 20rem of the column, a 1280 shell leaves the page itself
 * about 650px, which is where "everything on the front end is scrunched together" came
 * from. The staff screens are not widened by this — they are the same column as every
 * other page, which is what the one-width rule was for.
 */
.app-sidebar { position: sticky; top: 4rem; display: flex; flex-direction: column; gap: 1rem; }
/*
 * Sidebar hidden: the grid loses its first column, so the content takes the width
 * the navigation was using. This is the React layout's `sidebarHidden` state, and
 * the header's hamburger is what reaches it.
 */
.app-shell.is-collapsed { grid-template-columns: minmax(0, 1fr); }
.app-shell.is-collapsed .app-sidebar { display: none; }
/*
 * The same state, read back before the first paint.
 *
 * Hiding the column is remembered across pages (see the scripts in
 * `views/layout/header.php` and `views/layout/footer.php`). The head marks `<html>`
 * before the body renders, which is what stops a hidden column from flashing back for
 * a frame; the footer hands the marker over to the shell. Both rules live inside the
 * same 901px query the column itself does — below that width the sidebar is a drawer
 * that is opened rather than hidden, and it has no remembered state at all.
 */
@media (min-width: 901px) {
    html.sidebar-collapsed .app-shell { grid-template-columns: minmax(0, 1fr); }
    html.sidebar-collapsed .app-sidebar { display: none; }
}
.nav-group { display: flex; flex-direction: column; gap: 0.125rem; }
.nav-group .button { justify-content: center; margin-top: 0.4rem; border-radius: 999px; }
.nav-group-title {
    font-size: 0.625rem; letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--muted); opacity: 0.7; font-weight: 600;
    padding: 0 0.75rem; margin: 0 0 0.25rem;
}
.nav-link {
    display: flex; align-items: center; gap: 0.625rem;
    padding: 0.5rem 0.75rem; border-radius: 0.5rem;
    color: var(--muted); font-size: 0.875rem; font-weight: 500;
}
.nav-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.nav-link.active {
    background: var(--accent-soft); color: var(--accent-soft-fg);
    box-shadow: 0 1px 2px oklch(0 0 0 / 0.08);
}
/* Unread count on a sidebar entry (e.g. Following). Pushed to the end of the
   row by margin-left:auto so it does not depend on the label's length. */
.nav-badge {
    margin-left: auto; min-width: 20px; height: 19px; padding: 0 6px;
    display: grid; place-items: center; border-radius: 99px;
    background: var(--accent); color: var(--accent-fg);
    font-size: 0.68rem; font-weight: 700; line-height: 1;
}
/*
 * Icons are Font Awesome 6 glyphs, not inline SVG.
 *
 * `board_icon()` draws `<i class="fa-solid fa-bell …">` (see `app/icons.php`), so a
 * class that was written as the box an `<svg>` needed — `width` and `height`, which
 * an inline-block `<i>` also honours — now sizes the glyph with it. Font Awesome
 * already makes these elements `inline-block` and gives them `line-height: 1`, so the
 * glyph sits centred in exactly the box it used to fill, and no row reflows.
 *
 * The sizes below are the measurements the SVG classes had, one for one: a class
 * whose box was 0.95rem draws a 0.95rem glyph. `font-style` is reset because the
 * element is an `<i>`, whose default is italic. Brand marks are included, so the
 * share row's service glyphs measure like the rest.
 */
i.fa-solid, i.fa-regular, i.fa-brands {
  font-style: normal;
  text-align: center;
}
.widget-toggle-icon, .rail-stat-icon, .site-footer-icon { font-size: 0.95rem; }
.rail-button-icon, .admin-menu-caret { font-size: 0.9rem; }
.search-palette-icon { font-size: 1.25rem; flex-shrink: 0; color: var(--muted); }
.nav-toggle-icon { font-size: 1.15rem; }
/*
 * The terms page is a document, so it is set as one: a comfortable measure for
 * reading, and space between paragraphs rather than a wall of text.
 */
.terms-body { max-width: 46rem; }
.terms-body p { margin: 0 0 0.9rem; line-height: 1.65; }
.terms-body p:last-child { margin-bottom: 0; }

/*
 * Two small pieces the plan screens draw. `.field-label` is the label above an
 * input inside a wrapper label (the admin's plan forms); `.plan-swatch` is the dot
 * beside a plan's name in the admin's list, showing its accent colour.
 */
.field-label { display: block; margin-bottom: 0.35rem; }
.plan-swatch { display: inline-block; width: 0.7rem; height: 0.7rem; margin-right: 0.4rem; border-radius: 50%; vertical-align: middle; }

/*
 * Membership plans — the Upgrade page.
 *
 * The React build's `/subscribe` screen: a centred heading, the tab row, the plan
 * cards with the second highlighted as the popular choice, and the comparison table
 * underneath. The geometry follows the React page's own classes — the accent strip
 * along the top of a card, the 5rem plan image, the pills for tabs and payment
 * methods — so the two editions read as one product.
 */
.plan-head { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; text-align: center; }
.plan-head h1 { margin: 0; font-size: clamp(1.6rem, 1.2rem + 1.6vw, 2.25rem); letter-spacing: -0.01em; }
.plan-head p { max-width: 42rem; margin: 0; font-size: 0.9rem; }
.plan-badge {
    display: inline-flex; align-items: center; gap: 0.4rem;
    padding: 0.25rem 0.7rem; border: 1px solid var(--border); border-radius: 999px;
    font-size: 0.78rem; color: var(--muted);
}
.plan-badge-icon { font-size: 0.8rem; color: var(--accent); }
.plan-current {
    align-self: center; margin: 0; padding: 0.5rem 1rem;
    border: 1px solid var(--border); border-radius: 999px; background: var(--surface-2); font-size: 0.85rem;
}
.plan-current--pending { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.12); }
.plan-tabs { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
.plan-tab {
    padding: 0.45rem 1rem; border: 1px solid var(--border); border-radius: 999px;
    color: var(--text); font-size: 0.85rem; text-decoration: none;
}
.plan-tab:hover { background: var(--surface-2); }
.plan-tab.is-active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.plan-methods { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.5rem; }
.plan-methods-label { font-size: 0.85rem; font-weight: 600; }
.plan-method {
    display: inline-flex; align-items: center; gap: 0.35rem;
    padding: 0.3rem 0.7rem; border: 1px solid var(--border); border-radius: 999px; font-size: 0.75rem;
}
.plan-method-icon { font-size: 0.75rem; }
.plan-provider-label { display: block; margin: 0.85rem 0 0.3rem; font-size: 0.8rem; font-weight: 600; }
.plan-provider-select {
    display: block; width: 100%; padding: 0.5rem 0.6rem; margin-bottom: 0.35rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface); color: var(--text); font-size: 0.85rem;
}
.plan-notice {
    margin: 0; padding: 0.75rem 1rem; border: 1px solid rgba(245, 158, 11, 0.4);
    border-radius: var(--radius); background: rgba(245, 158, 11, 0.12);
    text-align: center; font-size: 0.85rem;
}
.plan-grid { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); align-items: stretch; }
.plan-grid--compact { grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); }
.plan-card {
    position: relative; display: flex; flex-direction: column; overflow: hidden;
    border: 1px solid var(--border); border-radius: calc(var(--radius) + 0.15rem); background: var(--surface);
}
.plan-card--popular { border-color: var(--accent); box-shadow: var(--shadow); }
.plan-accent { display: block; height: 0.375rem; }
.plan-icon-wrap { display: flex; justify-content: center; padding: 1.25rem 1.25rem 0; }
.plan-icon { width: 5rem; height: 5rem; border: 1px solid var(--border); border-radius: 0.9rem; object-fit: cover; }
.plan-popular {
    position: absolute; top: 1rem; right: 1rem; padding: 0.15rem 0.6rem;
    border-radius: 999px; background: var(--accent); color: var(--accent-fg);
    font-size: 0.72rem; font-weight: 600;
}
.plan-card-body { display: flex; flex: 1; flex-direction: column; gap: 0.4rem; padding: 1.25rem; }
.plan-name { margin: 0; font-size: 1.05rem; }
.plan-description { margin: 0; font-size: 0.85rem; }
.plan-price { margin: 0.35rem 0 0; font-size: 1.8rem; font-weight: 700; letter-spacing: -0.02em; }
.plan-price-note { font-size: 0.85rem; font-weight: 400; color: var(--muted); }
.plan-features { display: flex; flex-direction: column; gap: 0.6rem; margin: 0.75rem 0 0; padding: 0; list-style: none; font-size: 0.85rem; }
.plan-features li { display: flex; align-items: flex-start; gap: 0.5rem; }
.plan-feature-icon { margin-top: 0.15rem; color: var(--success); font-size: 0.85rem; }
.plan-form { display: flex; margin-top: auto; padding-top: 1.25rem; }
.plan-cta { width: 100%; justify-content: center; border-radius: 999px; }
.plan-compare { padding: 0; overflow: hidden; }
.plan-compare h2 { margin: 0; padding: 1.25rem 1.25rem 0.75rem; font-size: 1rem; }
.plan-compare-scroll { overflow-x: auto; }
.plan-compare-table { width: 100%; min-width: 40rem; border-collapse: collapse; font-size: 0.85rem; }
.plan-compare-table th, .plan-compare-table td { padding: 0.75rem; border-top: 1px solid var(--border); text-align: left; }
.plan-compare-table thead th { text-align: center; font-weight: 600; }
.plan-compare-table thead th:first-child { text-align: left; }
.plan-compare-band th { background: var(--surface-2); font-weight: 600; }
.plan-compare-cell { text-align: center; }
.plan-compare-yes { color: var(--success); font-size: 0.85rem; }
.plan-compare-no { color: var(--muted); font-size: 0.85rem; }
.plan-foot { margin: 0; text-align: center; font-size: 0.78rem; }
.search-palette-item-icon, .badge-card-help-icon, .member-card-banned-icon, .user-menu-icon { font-size: 0.875rem; }
.search-palette-item-arrow-icon, .chatbox-icon-glyph, .chatbox-send-icon, .admin-head-icon { font-size: 1rem; }
.chatbox-state-mark { font-size: 2rem; }
.chatbox-tool-icon, .chatbox-remove-icon, .chatbox-tab-icon { font-size: 0.75rem; }

.nav-icon { font-size: 1.05rem; width: 1.05rem; height: 1.05rem; flex-shrink: 0; }
.nav-toggle-icon { width: 1.15rem; height: 1.15rem; }

.app-content { min-width: 0; display: flex; flex-direction: column; gap: 1.5rem; }
.stack { display: flex; flex-direction: column; gap: var(--card-gap); min-width: 0; }
/*
 * The page and the widget rail beside it.
 *
 * Both numbers are the React build's: the rail column is its `w-60` on the widget
 * `<div>` in `ForumLayout`, and the gap is the `gap-8` on the row that holds the nav
 * rail, the page and this column. They were 20rem and 1.5rem, which is what made the
 * widget column read as a wide slab beside a squeezed page — a 20rem rail plus a
 * 14rem nav rail ate 44% of a 1280 shell before the page got any of it.
 */
.with-rail { display: grid; grid-template-columns: minmax(0, 1fr) 15rem; gap: 2rem; align-items: start; }
.rail { display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 4.5rem; }

/*
 * The hamburger, immediately left of the logo — the React header's sidebar
 * control in the same corner, at every width. Above 900px it hides the sidebar
 * column; below that the sidebar is a drawer and the same button opens it. The
 * button itself is the row's first child, and the 0.75rem the React row keeps
 * between it and the logo (`gap-3`) is the row's own gap, so no negative margin is
 * needed to trim one.
 *
 * It is a ghost control, like the React header's: no border, no fill, and a
 * background only under the pointer.
 */
.nav-toggle {
    display: inline-flex; align-items: center; justify-content: center;
    width: 2.25rem; height: 2.25rem; flex-shrink: 0;
    border: 1px solid transparent; border-radius: calc(var(--radius) - 4px);
    background: transparent; color: var(--text); cursor: pointer;
}
.nav-toggle:hover { background: var(--surface-2); }
/*
 * The section links, centred in the bar rather than pushed there by whatever
 * space is left over beside them: the React header pins its menu to the middle of
 * the row with an absolutely positioned wrapper, so the links do not drift when the
 * wordmark or the right-hand cluster changes width.
 *
 * They are plain text until you point at one — no pill, no resting background — and
 * they are drawn only at xl and up, which is where the React header stops hiding
 * them behind the sidebar.
 */
/*
 * A link never breaks in two, but the row may wrap.
 *
 * A multi-word label ("Creator studio") used to break in two inside its own box, so the
 * header drew a ragged two-line menu. A link never wraps now — but the **row** does: when the
 * labels no longer fit between the brand and the utilities, the menu folds onto a second line
 * and the bar grows taller to hold it, rather than overflowing its neighbours. Below the width
 * where even that is too tight, the sidebar carries the same links and the header menu is
 * hidden, which is the reference's own behaviour.
 */
.header-nav {
    /*
     * A flow item between the brand and the utilities, not an absolutely-centred strip.
     *
     * It used to be positioned at `left: 50%` with `max-width: calc(100% - 20rem)` and
     * `flex-wrap: nowrap`: once the board had enough header links to need more than that cap, the
     * centred strip grew **symmetrically past its own box** and ran under the logo on one side and
     * the utilities on the other — which is the overlapping "Categories" mark in the report.
     * Laid out in flow it can never do that: it takes the space between its neighbours, tightens
     * its own gaps, and **wraps** when the labels still will not fit. The header grows a little
     * taller to hold the second line, which is the point — it grows instead of overlapping.
     */
    display: flex; align-items: center; justify-content: center;
    flex: 1 1 auto; min-width: 0; flex-wrap: wrap;
    gap: 0.125rem; margin: 0 0.5rem; padding: 0.15rem 0;
}
@media (max-width: 1279px) { .header-nav { display: none; } }
@media (min-width: 1280px) and (max-width: 1439px) {
    .header-nav { gap: 0; margin: 0 0.25rem; }
    .header-nav a { padding: 0.3rem 0.28rem; font-size: 0.72rem; }
    .header-nav-button { padding: 0.3rem 0.28rem; font-size: 0.72rem; }
    /* The narrow desktop is where the menu is longest and the room shortest, so the
       logo gives some of its width back rather than pushing the links onto a second
       row sooner than they need to be. */
    .brand-image { max-width: 8.5rem; }
}
.header-nav a {
    padding: 0.35rem 0.5rem; border-radius: calc(var(--radius) - 6px);
    color: var(--muted); font-size: 0.8125rem; font-weight: 500; line-height: 1.4;
    white-space: nowrap;
}
.header-nav a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
/*
 * The current section keeps the foreground colour and nothing else, so the bar still
 * says where you are without carrying a second filled pill.
 */
.header-nav a.active { color: var(--text); }
/*
 * A section's heading is a control, not a link.
 *
 * A **section** is a heading with the links under it, so its cell has no address to go to and the
 * trigger is a `button` — drawn to the same measure as the links beside it so the bar still reads
 * as one row. It stays keyboard-reachable, and the cell's `:focus-within` rule opens the dropdown
 * on tab exactly as hover opens it on a pointer.
 */
.header-nav-button {
    display: inline-flex; align-items: center;
    padding: 0.35rem 0.5rem; border: 0; border-radius: calc(var(--radius) - 6px);
    background: transparent; color: var(--muted); font: inherit;
    font-size: 0.8125rem; font-weight: 500; line-height: 1.4; white-space: nowrap; cursor: pointer;
}
.header-nav-button:hover, .header-nav-button:focus-visible { color: var(--text); background: var(--surface-2); }
/*
 * A header link with children is a submenu.
 *
 * Each link is wrapped in a cell so the dropdown can be positioned against it. The submenu is
 * drawn on hover and on keyboard focus (`:focus-within`), never on click alone, so it works with
 * scripting off and is reachable by tab. It is laid out below its cell and above the page, and a
 * child of a child is indented a little further rather than becoming a second fly-out.
 */
.header-nav-cell { position: relative; display: flex; align-items: center; flex: 0 0 auto; }
.header-nav-caret { margin-left: 0.3rem; font-size: 0.6rem; opacity: 0.65; }
.header-submenu {
    position: absolute; top: 100%; left: 0; min-width: 12rem; padding: 0.35rem;
    display: none; flex-direction: column; gap: 0.05rem;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px); box-shadow: var(--shadow); z-index: 60;
}
.header-nav-cell.has-submenu:hover > .header-submenu,
.header-nav-cell.has-submenu:focus-within > .header-submenu { display: flex; }
.header-submenu a { display: block; padding: 0.4rem 0.55rem; border-radius: calc(var(--radius) - 8px); color: var(--muted); white-space: nowrap; }
.header-submenu a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
.header-submenu a.header-submenu-sub { padding-left: 1.35rem; opacity: 0.85; }

.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.page-head h1 { margin: 0; }
.page-head p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.9rem; }
.card-head {
    display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
    margin: -0.25rem 0 0.5rem;
}
.card-head h2 { margin: 0; }
/*
 * The badge in a card head.
 *
 * `card-head` is a flex row that does not wrap, so its last child is squeezed into whatever
 * the heading leaves it — and a badge that was allowed to shrink took three lines inside a
 * 99px radius and drew as a tall blob beside the title (the master licence card's state, at
 * the time of writing). A badge is one line of text: it does not shrink and it does not wrap.
 */
.card-head > .pill { flex: none; white-space: nowrap; }

/* ---------- widgets (the right-hand rail) ---------- */
.widget {
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
}
.widget-head { padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); }
.widget-title { margin: 0; font-size: 0.9rem; font-weight: 600; }
.widget-body { padding: 1rem 1.1rem; }
.widget-list { display: flex; flex-direction: column; gap: 0.65rem; }
.widget-row { display: flex; align-items: center; gap: 0.6rem; font-size: 0.875rem; min-width: 0; }
.stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--card-gap); }
.stat-box {
    display: flex; align-items: center; gap: 0.55rem;
    padding: 0.65rem 0.7rem; border-radius: 0.6rem;
    background: color-mix(in srgb, var(--surface-2) 55%, transparent);
}
.stat-box .stat-num { font-size: 1rem; line-height: 1.2; }
.tag-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }

/*
 * The rail is drawn from `xl` up in the React build (`hidden xl:flex`) and the page
 * takes the whole column below it. 1279px is Tailwind's `xl` minus one, so the two
 * builds drop the rail at the same window width instead of 130px apart.
 */
@media (max-width: 1279px) {
    .with-rail { grid-template-columns: minmax(0, 1fr); }
    .rail { position: static; }
}
@media (max-width: 900px) {
    .app-shell { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
    .app-sidebar { display: none; position: static; }
    .app-sidebar.open { display: flex; }
    .header-nav { display: none; }
}

/* ---------- plain shell (admin, upgrade) ---------- */
/*
 * `width: 100%` is load-bearing, not decoration.
 *
 * `.shell` sits inside `.app-content`, which is a column flex container, and it carries
 * `margin: 0 auto` so a centred column does not need a wrapper. Those two together are a
 * trap: a flex item with **automatic cross-axis margins** never gets `align-self: stretch`,
 * so instead of filling its column it is sized to its own content and then centred in the
 * leftover space. Every page whose content happened to be narrow came out narrow — which
 * is exactly why the moderation panel changed width as a moderator walked its six links,
 * and why a settings screen of short fields sat in the middle of a wide column. Asking for
 * the full width first then capping it gives the same layout at every size of content.
 */
.shell { width: 100%; max-width: 1240px; margin: 0 auto; padding: 1.75rem 1.25rem 4rem; }
.shell-wide { max-width: 1240px; }
.grid-2 { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1.5rem; align-items: start; }
.grid { display: grid; gap: var(--card-gap); }
.grid.two { grid-template-columns: 1fr 1fr; }
@media (max-width: 900px) {
    .grid-2 { grid-template-columns: 1fr; }
    .grid.two { grid-template-columns: 1fr; }
}

/* ---------- header ---------- */
.site-header {
    /*
     * A full-bleed strip holding a centred row, as the React header is: the strip
     * carries the border and the blur across the whole window, and
     * `.site-header-inner` is the `max-w-6xl` row of controls inside it.
     */
    /*
     * The fill, from a style's **Header background** property when one sets it, and the built-in
     * translucent wash of the page background otherwise. The custom property is only ever emitted
     * by `theme_style_html()` for a style that actually chose a header colour, so an imported
     * style's own header background, image and CSS are untouched unless it asked for this.
     */
    background: var(--fv-header-bg, color-mix(in srgb, var(--bg) 80%, transparent));
    border-bottom: 1px solid var(--border);
    position: sticky; top: 0; z-index: 40;
    backdrop-filter: blur(12px);
}
.site-header-inner {
    position: relative;
    display: flex; align-items: center; gap: 0.75rem;
    /* `min-height`, not `height`: the bar is allowed to grow when the menu wraps, which is
       what keeps a long header menu from colliding with the logo and the utilities. */
    min-height: 3.5rem; width: 100%; max-width: 72rem; margin: 0 auto; padding: 0.4rem 1rem;
}
.brand { display: flex; align-items: center; gap: 0.5rem; color: var(--text); font-weight: 600; flex-shrink: 0; }
.brand:hover { text-decoration: none; }
/* The built-in mark, for a board with no logo of its own: a rounded square in the
   accent with a sparkle in it. Its size is written inline from `logo_size()`. */
.brand-mark {
    border-radius: 0.5rem; background: var(--accent);
    display: grid; place-items: center; color: var(--accent-fg); flex-shrink: 0;
}
.brand-mark-icon { font-size: 1em; }
/* A configured logo is drawn `logo_size()` pixels tall; the title beside it is part
   of the image when the admin chose "logo only". */
.brand-image { width: auto; max-width: 12rem; object-fit: contain; object-position: left center; }
/*
 * The light/dark logo **pair**.
 *
 * `brand_logo()` draws both marks when a board has given two, and which one is seen is decided
 * here, from `html[data-mode]` — the attribute the header's switch flips. That is the whole point:
 * the switch repaints **in place**, without a reload, so a single `<img>` whose `src` the server
 * chose was only ever right for the mode the page was loaded in. The logo changed on the next page
 * load and not on the click, which is what "the logos do not truly change" was.
 *
 * A board with one logo gets one image and neither of these classes, so nothing here applies to
 * it. A board with only a light-mode mark draws the same file twice with the two classes, so both
 * of these rules point at the same picture and either one is correct.
 */
/* The three rules that show one mark of the pair sit at the **end** of this file and
   **outside** the `legacy` layer, and they have to be there — the note above them says why. */
/*
 * The site name's four steps, the ones `logoTitleClass()` uses in the React build: the
 * name grows with the mark so a 64px logo is not captioned in 12px type. No uppercase
 * and no letter-spacing — the configured name is drawn as it was typed.
 */
.brand-title { font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; }
.brand-title--sm { font-size: 0.875rem; }
.brand-title--md { font-size: 1.0625rem; }
.brand-title--lg { font-size: 1.25rem; }
.brand-title--xl { font-size: 1.5rem; }
/*
 * The search is a magnifier that opens the palette (see the search-palette block
 * below), not a permanently open input, which is what keeps the bar's proportions
 * the same as the React header's. It is an <a> rather than a <button> so that with
 * scripting off it is still a link to the search page.
 */
/*
 * The bar reads left to right as the React header does: the drawn mark, the word
 * mark, the section links in the middle, then the utilities on the right.
 */
/*
 * The right-hand cluster: the magnifier, the light/dark switch, the bell and the
 * account menu, held against the far edge of the row. It is pushed there by
 * `margin-left: auto` rather than by the menu in the middle, which is absolutely
 * positioned and takes no room in the flow.
 */
.header-actions { margin-left: auto; display: flex; align-items: center; gap: 0.375rem; flex-shrink: 0; }
/* The signed-out action, a pill as it is in the React header. */
.header-signin { border-radius: 999px; }

/* ---------- buttons ---------- */
.button {
    display: inline-flex; align-items: center; gap: 0.45rem;
    height: 2.25rem; padding: 0 0.95rem;
    border-radius: calc(var(--radius) - 4px);
    background: var(--accent); color: var(--accent-fg);
    border: 1px solid transparent;
    font-weight: 500; font-size: 0.875rem; font-family: inherit;
    cursor: pointer; white-space: nowrap;
    transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
}
/*
 * A button keeps its own label colour under the pointer.
 *
 * `a:hover { color: var(--accent-hover) }` further down is *more specific* than `.button`
 * on its own — a pseudo-class counts as a class — so a button written as a link (which
 * almost every button on the board is, since most of them navigate) had its label repainted
 * in the very colour its fill moved to: `color` and `background` became the same value and
 * the text vanished under the pointer. A button written as a `<button>` was untouched,
 * which is why it read as "some buttons" rather than all of them, and why the secondary
 * buttons looked fine — `.button.secondary` has two classes and beat the link rule on its
 * own. The colour is declared where the fill moves, so the pair can never collapse.
 */
.button:hover, .button:focus-visible { background: var(--accent-hover); color: var(--accent-fg); text-decoration: none; }
.button.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border); }
.button.secondary:hover, .button.secondary:focus-visible { background: color-mix(in srgb, var(--surface-2) 70%, var(--accent-soft)); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); color: var(--text); }
.button.danger { background: var(--danger); color: #fff; }
/*
 * A danger button darkens under the pointer.
 *
 * It used to take the rule above with it — `.button:hover` paints `--accent-hover`, whatever
 * the button's own fill is — so a Delete button turned blue on hover, and on a board whose
 * accent is a light blue it turned white-on-light-blue at the same time. The text never
 * moves; only the fill it sits on does.
 */
.button.danger:hover, .button.danger:focus-visible { background: color-mix(in srgb, var(--danger) 88%, black); color: #fff; }
.button.small { height: 2rem; padding: 0 0.75rem; font-size: 0.8rem; gap: 0.35rem; }

/* ---------- forms ---------- */
label { display: block; margin: 0.9rem 0 0.35rem; font-weight: 500; font-size: 0.875rem; }
/*
 * The board's text inputs.
 *
 * A checkbox and a radio are **not** text fields, and the width/height below is what they must
 * not take: a tick box stretched to `width: 100%` and `height: 2.25rem` draws its square in the
 * middle of a full-width box with its label pushed onto the next line. That is exactly the
 * webhook checklist report, and it applied everywhere one of these controls was not already
 * inside a scoped rule (`.check`, `.switch-row`, `.api-scope`, …). The exclusion is here, on the
 * base rule, rather than patched screen by screen, so a tick box added later is normal-sized by
 * default. Their sizing is set once, just below.
 */
input:not([type="checkbox"]):not([type="radio"]), textarea, select {
    width: 100%; padding: 0.5rem 0.75rem;
    border: 1px solid var(--input); border-radius: calc(var(--radius) - 4px);
    background: var(--surface-2); color: var(--text); font: inherit;
}
input:not([type="checkbox"]):not([type="radio"]), select { height: 2.25rem; }
textarea { min-height: 140px; resize: vertical; }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); }
input::placeholder, textarea::placeholder { color: var(--muted); }
input[type="color"] { padding: 0.2rem; }
/* A tick box and a radio keep their native size and sit against the first line of their label. */
input[type="checkbox"], input[type="radio"] {
    width: 1rem; height: 1rem; min-width: 1rem; min-height: 0;
    padding: 0; margin: 0.15rem 0 0; flex-shrink: 0; accent-color: var(--accent);
}

/* ---------- breadcrumbs ---------- */
/*
 * The trail under a page's title bar: an ordered list of real links, with the chevron drawn by
 * the stylesheet so it is decoration rather than a word a screen reader announces between every
 * step. The last crumb is the page you are on, so it is text rather than a link — clicking it
 * would only reload what is already open.
 */
.crumbs { margin: 0 0 0.85rem; }
.crumbs ol {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem;
    margin: 0; padding: 0; list-style: none; font-size: 0.8rem;
}
.crumbs li { display: inline-flex; align-items: center; gap: 0.3rem; min-width: 0; color: var(--muted); }
.crumbs li + li::before { content: "\203A"; color: var(--muted); opacity: 0.55; }
.crumbs a { color: var(--muted); }
.crumbs a:hover { color: var(--text); text-decoration: none; }
.crumbs li[aria-current="page"] {
    color: var(--text); font-weight: 500;
    max-width: 24rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ---------- pager ---------- */
/* Real server-side pages: Previous / Page n of m / Next, centred under a list. */
.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.6rem; margin: 1rem 0 0; }
.pager-status { font-size: 0.8rem; color: var(--muted); }
.button.is-disabled, .button[aria-disabled="true"] { opacity: 0.5; pointer-events: none; }

/* ---------- flash ---------- */
.flash {
    padding: 0.7rem 0.95rem; border-radius: calc(var(--radius) - 2px);
    margin-bottom: 1rem; font-size: 0.875rem;
    border: 1px solid var(--border);
}
.flash.success { background: color-mix(in srgb, var(--success) 14%, var(--surface)); color: var(--success); border-color: color-mix(in srgb, var(--success) 35%, transparent); }
.flash.error { background: color-mix(in srgb, var(--danger) 14%, var(--surface)); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); }

/* ---------- cards ---------- */
/* `rounded-xl` in the React build, i.e. the base radius plus 4px. */
.card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: calc(var(--radius) + 4px);
    padding: 1.5rem;
    box-shadow: var(--shadow);
}
.card > h2:first-child, .card > h3:first-child { margin-top: 0; }
.card > .hint:first-child { margin-top: 0; }

/* ---------- hero ---------- */
.hero {
    position: relative;
    padding: 2.25rem 1.75rem;
    border-radius: var(--radius);
    border: 1px solid var(--border);
    background:
        radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent) 22%, transparent) 0%, transparent 55%),
        var(--surface);
    box-shadow: var(--shadow);
    overflow: hidden;
}
.hero h1 { font-size: clamp(1.6rem, 3.2vw, 2.25rem); margin-bottom: 0.4rem; }
.hero p { margin: 0; max-width: 60ch; }

.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.center { text-align: center; }

/* ---------- topic lists ---------- */
.topic-row {
    display: flex; gap: 0.9rem; align-items: flex-start;
    padding: 0.9rem 0; border-bottom: 1px solid var(--border);
}
.topic-row:last-child { border-bottom: none; }
.topic-title { font-weight: 600; font-size: 1rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.topic-title a { color: var(--text); }
.topic-title a:hover { color: var(--accent); text-decoration: none; }
.topic-meta { font-size: 0.82rem; color: var(--muted); margin-top: 0.2rem; }
.topic-meta a { color: var(--muted); }
.topic-meta a:hover { color: var(--accent); }

/*
 * Circular, as every avatar in the React build is; `avatar_html()` writes the size
 * and the tone. The gradient is only the fallback for a circle drawn without a tone
 * class, so a template that predates the tones cannot come out colourless.
 */
.avatar {
    width: 40px; height: 40px; border-radius: 999px;
    background: linear-gradient(135deg, var(--accent), var(--accent-soft));
    color: var(--accent-fg); display: grid; place-items: center;
    font-weight: 700; font-size: 0.85rem; flex-shrink: 0; overflow: hidden;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
/*
 * A circle with a picture in it is the picture's colour, not the tone's.
 *
 * The eight tones exist so an avatar drawn from initials is a colour rather than a grey
 * box; `avatar_html()` puts one on every circle. Behind a *picture* they are a coloured
 * ring — and behind a logo or any image with a transparent corner they show through as
 * coloured wedges around it, which is what the member's own logo on the default cover art
 * looked like: a red disc with a mark on it. With a picture the circle falls back to the
 * page background instead, which is what the React build's `bg-muted` fallback does.
 */
.avatar--image { background: var(--bg); }
.avatar.small { width: 28px; height: 28px; font-size: 0.7rem; }

/*
 * The tag box: type, pick, Enter.
 *
 * The React build's tag control is a chip list with a type-ahead under it; the board
 * posted a comma-separated text box. This is the surface — the chips, the running input
 * and the suggestion panel — and `assets/tags.js` is the behaviour behind it.
 */
.tag-input { position: relative; }
.tag-input-chips {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem;
    padding: 0.4rem 0.5rem; min-height: 2.6rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
    background: var(--surface-2); cursor: text;
}
.tag-input-chips:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
.tag-chip {
    display: inline-flex; align-items: center; gap: 0.3rem;
    padding: 0.1rem 0.45rem; border-radius: 999px;
    border: 1px solid color-mix(in srgb, var(--chip-color, var(--accent)) 45%, transparent);
    background: color-mix(in srgb, var(--chip-color, var(--accent)) 16%, transparent);
    color: var(--chip-color, var(--accent));
    font-size: 0.75rem; font-weight: 500;
}
.tag-chip-remove {
    display: grid; place-items: center; width: 1rem; height: 1rem;
    border: 0; background: none; color: inherit; cursor: pointer; opacity: 0.75;
    font-size: 0.7rem; line-height: 1; padding: 0;
}
.tag-chip-remove:hover { opacity: 1; }
.tag-input-field {
    flex: 1 1 8rem; min-width: 6rem; border: 0; background: none; outline: none;
    padding: 0.2rem 0.15rem; color: var(--text); font: inherit; font-size: 0.85rem;
}
.tag-input-field:focus { outline: none; }
.tag-input-list {
    position: absolute; left: 0; right: 0; top: calc(100% + 0.3rem); z-index: 40;
    display: flex; flex-direction: column; gap: 0.1rem; padding: 0.3rem;
    max-height: 14rem; overflow-y: auto;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: 0 18px 40px oklch(0 0 0 / 0.35);
}
.tag-input-list[hidden] { display: none; }
.tag-input-option {
    display: flex; align-items: center; gap: 0.5rem; width: 100%;
    padding: 0.35rem 0.5rem; border: 0; border-radius: calc(var(--radius) - 8px);
    background: none; color: var(--text); font: inherit; font-size: 0.82rem;
    text-align: left; cursor: pointer;
}
.tag-input-option:hover, .tag-input-option.is-current { background: var(--surface-2); }
.tag-input-option-dot { width: 0.6rem; height: 0.6rem; border-radius: 999px; flex-shrink: 0; }
.tag-input-option-uses { margin-left: auto; font-size: 0.7rem; color: var(--muted); }
.tag-input-new { color: var(--accent); font-weight: 600; }
.tag-input-hint { margin: 0.35rem 0 0; font-size: 0.75rem; color: var(--muted); }

/* ---------- chips ---------- */
.pill {
    display: inline-block; padding: 0.1rem 0.55rem; border-radius: 99px;
    font-size: 0.72rem; font-weight: 600; letter-spacing: 0.02em;
    background: var(--surface-2); color: var(--muted);
}
/*
 * A glyph drawn *inside* a chip or a bare text button — a pinned topic's tack, a
 * post's bookmark. Neither lays its children out as a row the way `.button` does, so
 * the icon needs its own space rather than a `gap`, and `board_icon()` draws it as an
 * `<i>` with no margin of its own.
 */
.pill > i, .like-btn > i { margin-right: 0.35rem; }
/* A glyph sitting inside a run of text rather than in a control of its own — the
   padlock ahead of "This topic is locked." */
.inline-icon { margin-right: 0.35rem; }
.tag {
    display: inline-block; padding: 0.1rem 0.6rem; border-radius: 99px;
    background: var(--accent-soft); color: var(--accent-soft-fg);
    font-size: 0.75rem; font-weight: 500;
}
.tag:hover { text-decoration: none; color: var(--accent-soft-fg); }
/*
 * A tinted pill keeps its own colour under the pointer.
 *
 * The rule above is for a plain tag link. It is a pseudo-class selector at the same
 * weight as `.tag-tinted`, and it sits before it in the sheet — but a hover rule that is
 * *later* than it wins, and the tint itself sets `color: var(--tag-color)`. Without this
 * one, every pill in the rail's cloud turned the accent's near-white the moment a reader
 * pointed at it: the tag's colour disappeared exactly when they were trying to read it.
 * Same weight, later in the sheet, so the tint survives and the pill only lifts.
 */
.tag-tinted:hover { color: var(--tag-color); text-decoration: none; }
.tag + .tag { margin-left: 0.25rem; }

/* The release chip, following the same flat chip convention as .pill. */
.version-pill {
    display: inline-block; padding: 0.1rem 0.55rem; border-radius: 99px;
    background: var(--surface-2); color: var(--muted);
    font-size: 0.72rem; letter-spacing: 0.02em; white-space: nowrap;
}
.version-pill b { color: var(--text); font-weight: 700; }
@media (max-width: 640px) { .version-pill { display: none; } }

/*
 * One of the header's round controls. Transparent at rest — the React header's ghost
 * buttons carry no border and no fill — and filled only under the pointer.
 */
.icon-btn {
    position: relative; display: inline-grid; place-items: center;
    width: 2.25rem; height: 2.25rem; border-radius: 999px;
    background: transparent; border: 1px solid transparent;
    text-decoration: none; font-size: 0.95rem; color: var(--text);
}
.icon-btn:hover { background: var(--surface-2); text-decoration: none; }
.badge-dot {
    position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px;
    padding: 0 4px; border-radius: 99px; background: var(--danger); color: #fff;
    font-size: 0.62rem; font-weight: 700; display: grid; place-items: center;
}
.count-pill {
    display: inline-block; margin-left: 0.35rem; padding: 0 0.4rem;
    border-radius: 99px; background: var(--danger); color: #fff;
    font-size: 0.68rem; font-weight: 700;
}

/* ---------- account menu ---------- */
/*
 * The avatar is a <details>/<summary> pair, so the menu opens on click and is
 * reachable by keyboard without any JavaScript; the footer script only adds
 * closing on an outside click and on Escape.
 */
.user-menu { position: relative; }
.user-menu > summary,
.user-menu-trigger {
    list-style: none; cursor: pointer; padding: 0; border: 0;
    outline-offset: 3px; display: inline-flex; border-radius: 999px;
}
.user-menu > summary::-webkit-details-marker { display: none; }
.user-menu[open] > summary { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent); }
.user-menu-panel {
    position: absolute; right: 0; top: calc(100% + 0.5rem); z-index: 60;
    width: min(17rem, calc(100vw - 2rem));
    padding: 0.45rem;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 2px);
    box-shadow: 0 18px 40px -20px rgba(0, 0, 0, 0.55);
}
.user-menu-head {
    display: flex; align-items: center; gap: 0.6rem;
    padding: 0.5rem 0.55rem 0.65rem; margin-bottom: 0.35rem;
    border-bottom: 1px solid var(--border);
}
.user-menu-ident { display: flex; flex-direction: column; min-width: 0; }
.user-menu-name { font-weight: 700; font-size: 0.88rem; } 
.user-menu-email { font-size: 0.74rem; color: var(--muted); overflow-wrap: anywhere; }
.user-menu-list { display: flex; flex-direction: column; gap: 0.1rem; }
.user-menu-item {
    display: flex; align-items: center; gap: 0.6rem;
    padding: 0.5rem 0.55rem; border-radius: calc(var(--radius) - 6px);
    color: var(--text); font-size: 0.87rem; text-decoration: none;
    background: none; border: 0; width: 100%; text-align: left; cursor: pointer; font: inherit;
}
.user-menu-item:hover, .user-menu-item:focus-visible {
    background: var(--surface-2); color: var(--text); text-decoration: none;
}
.user-menu-item.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 600; }
.user-menu-item--danger { color: var(--danger); }
.user-menu-icon { width: 17px; height: 17px; flex-shrink: 0; }
.user-menu-badge {
    margin-left: auto; min-width: 1.35rem; padding: 0 0.35rem;
    border-radius: 99px; background: var(--accent); color: var(--accent-fg);
    font-size: 0.68rem; font-weight: 700; text-align: center;
}
.user-menu-sep { display: block; height: 1px; margin: 0.35rem 0.2rem; background: var(--border); }
.user-menu-form { margin: 0; }

/* ---------- categories ---------- */
.category-grid { display: grid; gap: var(--card-gap); }
/* A top-level forum and the sub-forums filed under it, so the grid cell carries both. */
.category-node { display: flex; flex-direction: column; }
.category-card {
    display: flex; gap: 1rem; align-items: center;
    padding: 0.9rem; border-radius: calc(var(--radius) - 2px);
    border: 1px solid var(--border); background: color-mix(in srgb, var(--surface-2) 45%, transparent);
}
.category-icon {
    width: 44px; height: 44px; border-radius: 0.65rem; flex-shrink: 0;
    display: grid; place-items: center; font-size: 1.05rem; font-weight: 700;
    background: var(--accent-soft); color: var(--accent-soft-fg);
    overflow: hidden;
}
/* A category icon that is a picture fills its tile rather than sitting in it. */
.category-icon img { width: 100%; height: 100%; object-fit: cover; }
.stat-num { font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }

/* ---------- posts ---------- */
/*
 * A post, in the React build's shape.
 *
 * The author sits in a column of their own down the left — face, handle, role mark, the
 * two facts about them — and the writing is a column beside it, opened by the post's
 * number and the time it was written and closed by its reactions. What this used to be
 * was one flex row of face-then-text, which is a comment box rather than a forum post:
 * there was nowhere for the author's own details to live, no number to quote a post by
 * and no menu holding the actions, so four buttons sat in the open under every reply.
 */
/*
 * The byline's column is the reference's own width — `w-36`, nine rem — rather than the
 * eight it was drawn at. The panel below adds a hairline and a padding the reference's
 * bare `border-r` column does not carry, and eight rem minus both is the width at which
 * the longest group ribbon and the widest fact row stop fitting.
 */
.post { display: grid; grid-template-columns: 9rem minmax(0, 1fr); gap: 1.1rem; padding: 1.1rem 0; border-bottom: 1px solid var(--border); }
@media (max-width: 768px) { .post { grid-template-columns: minmax(0, 1fr); gap: 0.75rem; } }
.post:last-child { border-bottom: none; }
.post-body { min-width: 0; }
/*
 * The thread's opening post, outlined in electric blue.
 *
 * The owner of a thread is worth finding while scrolling forty replies, and the tab that
 * used to say so hung off the author column, outside the post it was talking about. The
 * outline says it about the post itself: the whole area is what carries the thread, so the
 * whole area is what is marked. The glow is what makes it read as a current rather than as
 * a selected row — two soft rings, held low enough that the text on top of them stays plain.
 */
.post-owner {
    border-color: #23a6ff;
    box-shadow:
        0 0 0 1px color-mix(in srgb, #23a6ff 70%, transparent),
        0 0 12px color-mix(in srgb, #23a6ff 48%, transparent),
        0 0 34px color-mix(in srgb, #23a6ff 22%, transparent);
}
/*
 * The byline column is drawn as its own panel — a step off the post's surface, a hairline
 * around it and the card radius on its corners — so the person reads as a column beside the
 * writing rather than as the first few lines of it. The reference separates the two with a
 * right rule alone; the panel is the same idea given enough weight to find while scrolling.
 * The tint comes from `--surface-2`, which is a shade lighter than the card in the dark
 * theme and a shade darker in the light one, so a single value stands out in both.
 */
.post-author {
    display: flex; flex-direction: column; align-items: center; text-align: center;
    gap: 0.35rem; min-width: 0;
    padding: 0.6rem 0.45rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: color-mix(in srgb, var(--surface-2) 60%, transparent);
}
.post-author-face { display: inline-flex; flex-shrink: 0; }
.post-author-face:hover { text-decoration: none; }
/*
 * The handle, with its marks in the same line box: an inline flex row, so the crown and the
 * seal sit beside the name and cannot be pushed under it by the column they live in.
 */
.post-author-name { display: inline-flex; align-items: center; font-size: 0.9rem; font-weight: 600; color: var(--text); overflow-wrap: anywhere; }
.post-author-name:hover { color: var(--accent); }
.post-author-title { font-size: 0.75rem; color: var(--muted); }
.post-author-banners { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.15rem; }
.post-author-facts { display: flex; flex-direction: column; gap: 0.25rem; width: 100%; margin-top: 0.4rem; }
.post-author-fact { display: flex; align-items: baseline; justify-content: space-between; gap: 0.4rem; font-size: 0.75rem; color: var(--muted); }
.post-author-fact b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.post-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
/* What this reply is answering, quoted above it rather than inside it. */
.post-quote {
    margin: 0.6rem 0; padding: 0.4rem 0.8rem;
    border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0;
    background: var(--surface-2);
}
.post-head-when { font-size: 0.78rem; color: var(--muted); }
.post-head-tail { margin-left: auto; display: inline-flex; align-items: center; gap: 0.4rem; }
/*
 * The post's own menu: a `<details>` at the end of its header, so a reply carries one
 * quiet control instead of four buttons in the open, and it works with scripting off.
 */
.post-menu { position: relative; }
.post-menu > summary {
    display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
    border: 1px solid transparent; border-radius: 999px; cursor: pointer;
    color: var(--muted); list-style: none;
}
.post-menu > summary::-webkit-details-marker { display: none; }
.post-menu > summary:hover, .post-menu[open] > summary { background: var(--surface-2); color: var(--text); border-color: var(--border); }
.post-menu-icon { width: 0.95rem; height: 0.95rem; }
.post-menu-panel {
    position: absolute; right: 0; top: calc(100% + 0.35rem); z-index: 20; min-width: 11rem;
    display: flex; flex-direction: column; gap: 0.15rem; padding: 0.35rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 3px);
    background: var(--surface); box-shadow: var(--shadow);
}
.post-menu-panel form { display: block; margin: 0; }
.post-menu-item {
    display: flex; align-items: center; gap: 0.45rem; width: 100%;
    padding: 0.4rem 0.5rem; border: 0; border-radius: calc(var(--radius) - 8px);
    background: none; color: var(--text); font: inherit; font-size: 0.82rem;
    text-align: left; cursor: pointer;
}
.post-menu-item:hover { background: var(--surface-2); }
.post-menu-item--danger { color: var(--danger); }
.post-menu-icon-sm { width: 0.85rem; height: 0.85rem; }
.post-body p { margin: 0.4rem 0; }
.post-body img { max-width: 100%; height: auto; border-radius: 0.5rem; }
/*
 * A post body is sanitized HTML, so the elements the editor can produce need the same
 * spacing the React build gives them; without these a blockquote or a list runs into
 * the paragraph above it and a code block reads as prose.
 */
.post-body > :first-child { margin-top: 0; }
.post-body > :last-child { margin-bottom: 0; }
.post-body h2, .post-body h3, .post-body h4 { margin: 0.9rem 0 0.4rem; line-height: 1.25; }
.post-body ul, .post-body ol { margin: 0.5rem 0; padding-left: 1.4rem; }
.post-body li { margin: 0.15rem 0; }
.post-body blockquote {
    margin: 0.6rem 0; padding: 0.4rem 0.9rem;
    border-left: 3px solid var(--accent); background: var(--surface-2);
    border-radius: 0 0.4rem 0.4rem 0; color: var(--muted);
}
.post-body pre {
    margin: 0.6rem 0; padding: 0.7rem 0.9rem; overflow-x: auto;
    background: var(--surface-2); border: 1px solid var(--border);
    border-radius: 0.45rem; font-family: var(--mono); font-size: 0.85rem;
}
.post-body code {
    padding: 0.05rem 0.3rem; border-radius: 0.25rem;
    background: var(--surface-2); font-family: var(--mono); font-size: 0.88em;
}
.post-body hr { margin: 0.9rem 0; border: none; border-top: 1px solid var(--border); }
.post-body a { text-decoration: none; }
.post-body table { border-collapse: collapse; margin: 0.6rem 0; }
.post-body th, .post-body td { border: 1px solid var(--border); padding: 0.35rem 0.6rem; }

/* ---------- panels opened over the page ---------- */
/*
 * The chat settings & moderation dialog (`views/partials/chat-settings.php`), shown by
 * the footer's modal controller. The backdrop covers the page and the panel scrolls
 * inside the viewport, so a long moderation panel is usable on a laptop screen without
 * the page behind it moving.
 */
.modal { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; }
.modal[hidden] { display: none; }
.modal-backdrop { position: absolute; inset: 0; background: oklch(0.16 0.02 265 / 62%); backdrop-filter: blur(2px); }
.modal-panel {
    position: relative; display: flex; flex-direction: column;
    width: min(46rem, 100%); max-height: min(88vh, 54rem); overflow: hidden;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: var(--shadow);
}
.modal-panel--wide { width: min(56rem, 100%); }
.modal-head {
    display: flex; align-items: center; justify-content: space-between; gap: 1rem;
    padding: 0.85rem 1rem; border-bottom: 1px solid var(--border);
}
.modal-head h2 { margin: 0; font-size: 1.05rem; }
.modal-body { padding: 1rem; overflow-y: auto; }
.modal-body .card { padding: 1rem; }
.modal-body h3 { margin-top: 0; }
.modal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.modal-list li {
    display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
    padding: 0.45rem 0; border-bottom: 1px solid var(--border); font-size: 0.87rem;
}
.modal-list li:last-child { border-bottom: none; }
/*
 * The chat rules panel (`views/partials/chat-rules.php`).
 *
 * The React build's `ChatRulesDialog`: a primary band across the top carrying the
 * book, the title and the one-line note, and the rules below it on the muted
 * surface. It is narrower than the moderation panel and has no separate header row
 * — the band *is* the header, so the close control sits inside it.
 */
.modal-panel--rules { width: min(36rem, 100%); }
.modal-rules-head {
    position: relative; padding: 1.15rem 3.25rem 1.25rem 1.5rem;
    background: var(--accent); color: var(--accent-fg);
}
.modal-rules-title {
    display: flex; align-items: center; gap: 0.5rem;
    margin: 0; font-size: 1.05rem; font-weight: 600; color: var(--accent-fg);
}
.modal-rules-icon { width: 1.15rem; height: 1.15rem; }
.modal-rules-note { margin: 0.3rem 0 0; font-size: 0.85rem; opacity: 0.85; }
.modal-rules-close { position: absolute; top: 0.6rem; right: 0.6rem; color: var(--accent-fg); }
.modal-rules-close:hover { background: color-mix(in srgb, var(--accent-fg) 14%, transparent); }
.modal-rules-body {
    padding: 1.25rem 1.5rem; overflow-y: auto;
    background: color-mix(in srgb, var(--surface-2) 60%, transparent);
    font-size: 0.9rem; line-height: 1.65;
}
.modal-rules-body > :first-child { margin-top: 0; }
.modal-rules-body > :last-child { margin-bottom: 0; }

/* The page behind an open panel does not scroll, so the panel's own scroll is the only
   one moving. The class is put on the root by the footer's controller. */
html.has-modal, html.has-modal body { overflow: hidden; }

/* ---------- the rich text editor ---------- */
/*
 * One editor for every composer (`views/partials/editor.php`, drawn by `render_editor()`
 * and driven by `assets/editor.js`). The `<textarea>` is what the form always had; the
 * script hides it and puts the formatting surface in its place, so `.is-ready` marks the
 * upgraded state and everything below it is only there once that has happened.
 */
.editor { display: flex; flex-direction: column; gap: 0.4rem; }
.editor-input { margin: 0; }
.editor.is-ready .editor-input { display: none; }
/*
 * The two bars, pinned under the site header.
 *
 * `.editor` is a column and the surface grows with whatever is written into it, so a long post
 * carried the toolbar off the top of the window — Indent, the link button and the HTML toggle
 * could only be reached by scrolling back up to the top of the box the member was already
 * writing in. The two rows are wrapped in one sticky box by `assets/editor.js` and travel
 * together for as long as any part of the editor is on screen, which is what `.rail` and
 * `.app-sidebar` already do for the columns beside the page.
 *
 * `3.5rem` is `.site-header-inner`'s own `min-height`, so the bar parks under the header rather
 * than under the window's top edge, where the header would sit on top of it; `z-index: 5` keeps
 * it below the header's `40` and above the surface it scrolls over.
 *
 * An ancestor that clips — `overflow: hidden` — becomes the sticky box's own scroll container,
 * and a sticky box inside a container that never scrolls never sticks. `.dln-card` is the one
 * such ancestor an editor sits in, and it clips with `clip` for exactly this reason.
 */
.editor-bars {
    display: flex; flex-direction: column;
    /* The same gap `.editor` puts between its own rows, inherited rather than copied so the two
       cannot drift apart: the bar's rows are spaced exactly as they were before they were
       wrapped, and only the wrapper is pinned. */
    gap: inherit;
    position: sticky; top: 3.5rem; z-index: 5;
}
.editor-toolbar {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.15rem;
    padding: 0.3rem 0.35rem; border: 1px solid var(--border); border-bottom: none;
    border-radius: calc(var(--radius) - 4px) calc(var(--radius) - 4px) 0 0;
    background: var(--surface-2);
}
/*
 * The HTML toggle's own row, under the formatting toolbar.
 *
 * It is not a formatting control: it swaps the rendering for the `<textarea>` the form posts,
 * so it sits apart from the buttons that act on a selection — the same second row the board's
 * editors have always shown. `assets/editor.js` draws it rather than the partial, because the
 * toolbar is described in one place.
 */
.editor-sourcebar {
    display: flex; align-items: center; gap: 0.15rem;
    padding: 0.2rem 0.35rem; border: 1px solid var(--border); border-top: none;
    border-radius: 0;
    background: var(--surface-2);
}
.editor-group { display: flex; align-items: center; gap: 0.1rem; padding-right: 0.35rem; margin-right: 0.35rem; border-right: 1px solid var(--border); }
.editor-group:last-child { border-right: none; padding-right: 0; margin-right: 0; }
.editor-btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem;
    width: 1.75rem; height: 1.75rem; padding: 0; border: 0; border-radius: 0.3rem;
    background: transparent; color: var(--muted); cursor: pointer; font-size: 0.8rem;
}
.editor-btn:hover { background: var(--surface); color: var(--text); }
.editor-btn--text { width: auto; padding: 0 0.45rem; font-size: 0.75rem; font-weight: 600; }
.editor-btn--swatch { position: relative; }
.editor-select {
    height: 1.75rem; width: auto; min-width: 0; padding: 0 0.3rem;
    border: 1px solid var(--border); border-radius: 0.3rem;
    background: var(--surface); color: var(--text); font-size: 0.75rem;
}
/*
 * The editor's popovers — the emoji grid, the text colour and the highlighter.
 *
 * `[hidden]` is written out rather than left to the browser. The attribute is only a
 * user-agent rule (`display: none`), and an author declaration of `display` on the same
 * element beats it whatever the specificity — so `pop.hidden = true` from
 * `assets/editor.js` was setting a property the stylesheet was overriding, and **every**
 * popover was painted at once, stacked over the toolbar and the message box that the
 * member was trying to write in. `.modal[hidden]` and `.tag-input-list[hidden]` below
 * already carry the same guard; this one was missing it.
 */
.editor-popover {
    position: absolute; z-index: 20; display: flex; flex-wrap: wrap; gap: 0.25rem;
    max-width: 13rem; padding: 0.35rem; margin-top: 0.15rem;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px); box-shadow: var(--shadow);
}
.editor-popover[hidden] { display: none; }
.editor-swatch { width: 1.15rem; height: 1.15rem; border: 1px solid var(--border); border-radius: 0.25rem; cursor: pointer; padding: 0; }
.editor-popover--emoji { max-width: 15rem; }
.editor-emoji { background: none; border: 0; cursor: pointer; font-size: 1.05rem; line-height: 1; padding: 0.15rem; border-radius: 0.25rem; }
.editor-emoji:hover { background: var(--surface-2); }
.editor-surface {
    min-height: 8rem; padding: 0.65rem 0.75rem; overflow-y: auto;
    border: 1px solid var(--border); border-radius: 0 calc(var(--radius) - 4px) calc(var(--radius) - 4px) 0;
    background: var(--surface); color: var(--text); font-size: 0.95rem; line-height: 1.6;
    outline: none; overflow-wrap: anywhere;
}
.editor-surface:focus { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
/* A contenteditable holds no placeholder of its own: the hint is drawn behind it. */
.editor-surface:empty::before { content: attr(data-placeholder); color: var(--muted); }
.editor-surface img { max-width: 100%; height: auto; border-radius: 0.5rem; }
.editor-foot { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
.editor-words, .editor-hint { font-size: 0.75rem; color: var(--muted); }
.editor-error { margin: 0 auto 0 0; color: var(--danger); font-size: 0.78rem; font-weight: 600; }
.editor-hint { text-align: right; }

/* ---------- your settings: the two pictures ---------- */
/*
 * The cover frame is the profile's own strip (`.profile-cover` in the member view),
 * so a member sets the position on the thing they will see it in and not on a shape
 * that has to be imagined. Dragging moves the picture; the position is written to a
 * hidden field as the CSS pair the server stores.
 */
.cover-editor-frame {
    position: relative; display: grid; place-items: center;
    height: 11rem; border-radius: var(--radius); overflow: hidden;
    border: 1px solid var(--border); background-color: var(--surface-2);
    background-size: cover; background-repeat: no-repeat; background-position: 50% 50%;
}
.cover-editor-frame.is-draggable { cursor: grab; }
.cover-editor-frame.is-dragging { cursor: grabbing; }
.cover-editor-empty { color: var(--muted); font-size: 0.85rem; }

.avatar-editor { display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap; }
.avatar-editor-frame {
    display: grid; place-items: center; flex-shrink: 0;
    width: 7rem; height: 7rem; border-radius: 50%; overflow: hidden;
    border: 1px solid var(--border); background: var(--surface-2);
}
.avatar-editor-frame img, .avatar-editor-canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-editor-canvas { cursor: grab; touch-action: none; }
.avatar-editor-canvas.is-dragging { cursor: grabbing; }
.avatar-editor-controls { flex: 1 1 18rem; min-width: 0; }
.avatar-editor-controls input[type="range"] { width: 100%; }

/* The four shortcuts out of the settings screen, drawn as tiles rather than text. */
.settings-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.6rem; margin-top: 1.25rem; }
.settings-link {
    display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
    background: var(--surface-2); color: var(--text); font-size: 0.85rem; font-weight: 500;
}
.settings-link:hover { background: var(--surface); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }

/* The account's own facts: a label and a value per row, quiet labels. */
.settings-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.9rem 1.5rem; margin: 0; }
.settings-facts > div { min-width: 0; }
.settings-facts dt { font-size: 0.7rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
.settings-facts dd { margin: 0.15rem 0 0; font-size: 0.9rem; overflow-wrap: anywhere; }

/* The ring around a profile's avatar: the helper draws the circle, this draws the
   border the profile card has always had around it. */
/*
 * The ring around the avatar, and the offset that lifts it over the cover.
 *
 * `border-box` rather than `content-box`: the border is part of the circle the reader
 * sees, so the ring's outer edge is what the row measures. With `content-box` the ring
 * grew by 8px past the size every other avatar on the board is drawn at, which is what
 * pushed the picture in from the card's edge on the left and left the identity block
 * looking as if it had been nudged sideways.
 */
.profile-avatar-ring {
    position: relative;
    display: inline-flex; border-radius: 999px; border: 4px solid var(--surface);
    background: var(--bg); flex-shrink: 0; margin-top: -2rem;
}
/*
 * The camera on your own face, in the corner of the circle.
 *
 * The React build's `AvatarUploadButton` at `-bottom-1 -right-1 size-7`: a circle the
 * size of a fingernail, sitting over the picture's corner, in the card's own colour so it
 * reads as part of the avatar rather than a badge stuck to it. Its form is pinned to the
 * corner of the ring, so the button does not take a share of the row the avatar sits in.
 */
.profile-avatar-camera { position: absolute; right: -0.25rem; bottom: -0.25rem; display: inline-flex; }
.profile-avatar-camera-btn {
    display: grid; place-items: center; width: 1.75rem; height: 1.75rem;
    border: 1px solid var(--border); border-radius: 999px;
    background: var(--surface); color: var(--text); cursor: pointer;
    box-shadow: var(--shadow);
}
.profile-avatar-camera-btn:hover { background: var(--surface-2); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.profile-avatar-camera-btn.is-busy { opacity: 0.6; cursor: progress; }
.profile-avatar-camera-icon { width: 0.8rem; height: 0.8rem; }

/* ---------- the member profile ---------- */
/*
 * A profile, in the React build's shape: a cover with its two pills, the avatar standing
 * over it beside the name, three counters, a badge shelf, the facts, and the two lists.
 *
 * The numbers across the top are three cards rather than two figures hung off the header,
 * which is what the React build's `CountStat` grid draws, and the two lists are two cards
 * because a topic somebody started and a reply they wrote are two different things.
 *
 * The chips derive their wash from one value — `--badge-color`, set inline from the badge's
 * own colour — the same way a tag pill derives its own, so a badge on this page is the
 * colour the catalogue shows it in.
 */
.profile-card { padding: 0; overflow: hidden; }
.profile-cover {
    position: relative;
    height: 11rem;
    background: linear-gradient(135deg, var(--accent-soft), color-mix(in srgb, var(--accent) 20%, transparent));
    background-size: cover;
    background-repeat: no-repeat;
    /* Dragging a picture is not a scroll gesture, and the pointer is captured anyway. */
    touch-action: none;
}
.profile-cover.is-draggable { cursor: default; }
.profile-cover.is-repositioning { cursor: move; }
.profile-cover-actions {
    position: absolute; right: 0.75rem; bottom: 0.75rem;
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
    justify-content: flex-end;
}
.profile-cover-pill {
    display: inline-flex; align-items: center; gap: 0.35rem;
    padding: 0.4rem 0.75rem; border-radius: 999px;
    border: 1px solid var(--border);
    background: color-mix(in srgb, var(--surface) 88%, transparent);
    color: var(--text); font-size: 0.75rem; font-weight: 500;
    cursor: pointer; backdrop-filter: blur(6px);
}
.profile-cover-pill:hover { background: var(--surface); color: var(--text); text-decoration: none; }
.profile-cover-pill.is-active { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.profile-cover-icon { font-size: 0.85rem; }

/*
 * The header row under the cover.
 *
 * The React build lifts **the avatar** over the cover and nothing else: the picture
 * carries `-mt-8`, the row is a plain flex row aligned to its own bottom edge, and the
 * name and the buttons therefore sit *beneath* the cover rather than on top of it. This
 * carried the negative margin on the whole row instead — hero numbers pulled up over the
 * cover, the name printed across the picture, and the **Edit profile** button hanging at
 * whatever height the avatar happened to set. Moving the offset onto the avatar alone is
 * the whole fix: the row keeps its natural height, the avatar stands over the strip, and
 * the identity block and its button line up under it.
 *
 * Below 641px the row stacks, as the React build's `flex-col sm:flex-row` does, so a
 * narrow window gets the avatar over the cover with the name and the button beneath it
 * rather than three columns fighting for the same line.
 */
.profile-head {
    display: flex; flex-direction: column; gap: 1rem;
    padding: 0 1.25rem 1.5rem;
}
@media (min-width: 641px) {
    /* The row clears the strip the avatar hangs over, so the name is never printed across
       the cover and the buttons never crowd the picture: the avatar's own `-2rem` is the
       only thing that overlaps, and it is what carries it onto the cover. */
    .profile-head { flex-direction: row; align-items: flex-end; gap: 1.25rem; padding-top: 0.35rem; }
    .profile-ident { padding-bottom: 0.15rem; }
}
.profile-ident { flex: 1; min-width: 0; }
.profile-name { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0; font-size: 1.4rem; }
.profile-handle { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.875rem; }
.profile-role {
    font-size: 0.7rem; font-weight: 600;
    padding: 0.15rem 0.5rem; border-radius: 999px;
    background: var(--surface-2); color: var(--muted); border: 1px solid var(--border);
}
.profile-role--admin { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); border-color: transparent; }
.profile-role--mod { background: color-mix(in srgb, var(--success) 14%, transparent); color: var(--success); border-color: transparent; }
.profile-online { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.75rem; font-weight: 500; color: var(--success); }
.profile-online-dot { width: 0.4rem; height: 0.4rem; border-radius: 999px; background: var(--success); }
.profile-banners { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.45rem; }
/* The row's own buttons are top-aligned, which is the React build's `self-start`. */
.profile-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.4rem; align-self: flex-start; }

.profile-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--card-gap); }
.profile-stat { display: flex; flex-direction: column; gap: 0.1rem; padding: 1rem; }
.profile-stat-num { font-size: 1.35rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
.profile-stat-label { font-size: 0.75rem; color: var(--muted); }

.profile-card-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 1rem; }
.profile-card-icon { font-size: 1rem; color: var(--muted); }
.profile-card-link { font-size: 0.813rem; color: var(--accent); }

.profile-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.6rem; }
.profile-badge {
    display: inline-flex; align-items: center; gap: 0.4rem;
    padding: 0.3rem 0.7rem; border-radius: 999px;
    font-size: 0.813rem; font-weight: 500;
    color: var(--badge-color, var(--accent));
    background: color-mix(in srgb, var(--badge-color, var(--accent)) 12%, transparent);
    border: 1px solid color-mix(in srgb, var(--badge-color, var(--accent)) 30%, transparent);
}
.profile-badge-icon { font-size: 0.9rem; }
.profile-badge--more { color: var(--muted); background: var(--surface-2); border-color: var(--border); }

.profile-bio { margin: 0.6rem 0 0; }
.profile-facts { display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem; margin-top: 0.6rem; color: var(--muted); font-size: 0.813rem; }
.profile-facts > span { display: inline-flex; align-items: center; gap: 0.4rem; min-width: 0; }
.profile-fact-icon { font-size: 0.85rem; }
.profile-facts a { color: var(--accent); }

.profile-list { display: flex; flex-direction: column; margin-top: 0.35rem; }
.profile-list-row {
    display: flex; align-items: center; justify-content: space-between; gap: 1rem;
    padding: 0.55rem 0.5rem; border-radius: calc(var(--radius) - 4px);
    color: var(--text); font-size: 0.9rem;
}
.profile-list-row:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.profile-list-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.profile-list-when { flex-shrink: 0; font-size: 0.75rem; color: var(--muted); }

.profile-posts { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.6rem; }
.profile-post { border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); padding: 0.65rem 0.75rem; }
.profile-post-topic { font-weight: 500; font-size: 0.9rem; color: var(--text); }
.profile-post-topic:hover { color: var(--accent); }
.profile-post-body { margin: 0.3rem 0 0; color: var(--muted); font-size: 0.875rem; line-height: 1.55; }
.profile-post-when { margin: 0.3rem 0 0; font-size: 0.75rem; color: var(--muted); opacity: 0.75; }

@media (max-width: 640px) {
    .profile-stats { grid-template-columns: minmax(0, 1fr); }
}
.post-footer { display: flex; gap: 0.9rem; align-items: center; margin-top: 0.7rem; font-size: 0.82rem; color: var(--muted); flex-wrap: wrap; }
/* The post's closing row: what readers made of it, then the one quiet way to report it. */
.post-reactions {
    display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
    margin-top: 0.85rem; padding-top: 0.7rem; border-top: 1px dashed var(--border);
}
.post-reactions-form { display: inline-flex; margin: 0; }
.post-reaction {
    display: inline-flex; align-items: center; gap: 0.35rem;
    padding: 0.2rem 0.6rem; border: 1px solid var(--border); border-radius: 999px;
    background: var(--surface-2); color: var(--muted);
    font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer;
}
.post-reaction:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); color: var(--text); }
.post-reaction.liked { border-color: transparent; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
.post-reaction-icon { width: 0.8rem; height: 0.8rem; }
.like-btn { background: none; border: none; color: var(--muted); cursor: pointer; font: inherit; padding: 0; }
.like-btn:hover, .like-btn.liked { color: var(--accent); }
.post-hidden { opacity: 0.6; border-color: var(--danger) !important; }

/* ---------- downloads ---------- */
.resource-row { display: flex; align-items: center; gap: 1rem; padding: 0.9rem 0; border-bottom: 1px solid var(--border); }
.resource-row:last-child { border-bottom: none; }
.price-tag { font-weight: 700; color: var(--success); font-variant-numeric: tabular-nums; }
.price-tag.free { color: var(--muted); }

/* ---------- auth ---------- */
.auth-card { max-width: 420px; margin: 3rem auto; }
.auth-card .button { width: 100%; justify-content: center; margin-top: 1rem; margin-bottom: 0.25rem; }

/* ---------- community & moderation ---------- */
.staff-tools {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
    margin-top: 1.25rem; padding: 0.75rem;
    border: 1px dashed var(--border); border-radius: calc(var(--radius) - 2px);
    background: color-mix(in srgb, var(--surface-2) 55%, transparent);
}
.staff-tools form { display: inline; }
.staff-tools-label {
    font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--muted); font-weight: 700; margin-right: 0.25rem;
}
.report-form {
    margin-top: 0.6rem; padding: 0.75rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px);
    background: color-mix(in srgb, var(--surface-2) 55%, transparent);
}
.report-form textarea { min-height: 60px; }
.notification-row {
    display: flex; gap: 0.8rem; align-items: flex-start;
    padding: 0.8rem 0.25rem; border-bottom: 1px solid var(--border);
}
.notification-row:last-child { border-bottom: none; }
.notification-row.unread {
    background: color-mix(in srgb, var(--accent) 10%, transparent);
    border-radius: calc(var(--radius) - 4px);
    padding-left: 0.7rem; padding-right: 0.7rem;
}
.notif-type { font-size: 1.1rem; }

/* ---------- admin ---------- */
/*
 * The admin content column.
 *
 * `width: 100%` is load-bearing, and it is why the admin sidebar was stranded at the far
 * left of a wide window. `.app-content` is a flex column, and an item with **auto** cross
 * margins is not stretched — it is sized to its own content. So this block shrank to the
 * width of its widest child (the fourteen-bar trend chart, about 940px) and centred itself
 * inside a 1312px column, which left a 200px hole between the navigation and the page.
 * Stretching it fills the column it was given.
 */
.admin-grid { width: 100%; max-width: 100%; margin: 0; }

/*
 * The gap between the blocks of an admin screen.
 *
 * Every admin screen is a stack — the heading card the shared renderer draws, then whatever
 * the screen itself puts under it — and a stack with no gap between its cards reads as one
 * torn panel rather than as separate things. Until now the only spacing in the admin area
 * was whatever each screen happened to put on its own element, so two cards that followed
 * each other touched: the report was stacked cards with no air between them.
 *
 * The rule is **adjacency**, deliberately, not a margin on `.card` itself. A margin on the
 * card would open a hole under every card inside every `.grid-2`, every table cell, every
 * dropdown panel and every dialog — the layouts that are fine because their own container
 * already gaps them. Touching sibling blocks of the page column is the only case that was
 * broken, so it is the only case this changes, and it inherits margin collapsing: a screen
 * whose first element already carries a bottom margin keeps that margin rather than doubling
 * it. A screen that draws its own internal `.grid-2` is untouched, because that grid is the
 * single child here and its own `gap` lays out everything inside it.
 */
.admin-grid > * + * { margin-top: 1.25rem; }

/*
 * A card inside a card — the Feed importer's three panels are the shape this is for — keeps
 * the same air as two cards that sit side by side in the column. It is scoped to a card that
 * is itself a direct child of the admin column, so it cannot reach a card nested inside a
 * `.grid-2`, inside a table or inside a dialog.
 */
.admin-grid > .card > .card + .card,
.admin-grid > .card > section.card + section.card,
.admin-grid > .card > form.card + form.card { margin-top: 1rem; }

/*
 * The status strip a screen may draw under its heading card.
 *
 * It is the pills and nothing else — the Package Manager's "update in progress" and "storage
 * not writable" badges — so it is a row of badges with no surface of its own, sitting in the
 * column's normal 1.25rem rhythm rather than adding a card that would repeat the heading.
 */
.admin-status-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.admin-status-strip .pill { flex: none; white-space: nowrap; }

/*
 * The internal padding of a nested panel.
 *
 * A `.menu-edit-block` carries no surface of its own — it is a plain grouping inside the card
 * that holds several of them — and its own fields are `.card` rows, so without this the rows
 * of one panel crowd the next panel's heading. This is the same 1rem the board's other nested
 * panels use, kept in one place so every screen that nests one gets it.
 */
.admin-grid .menu-edit-block { display: block; }
.admin-grid .menu-edit-block + .menu-edit-block { margin-top: 1.25rem; }
.admin-grid .menu-edit-block > .card-head:first-child { margin-top: 0; }

/*
 * The Edit / Remove pair at the end of a row.
 *
 * Every list screen in the admin — the four name lists, the custom pages, the marketplace,
 * the review queues — ends its rows with a link and a small form, and a form is a block by
 * default: without this the buttons would stack into a column twice the height of the row
 * they belong to. Right-aligned and wrapped so a narrow window folds them rather than
 * overflowing the table.
 */
.row-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; justify-content: flex-end; align-items: center; }
.row-actions form { margin: 0; }
/*
 * The overview's figures, laid out as the React build lays them out: the four counters in
 * one row of four, then the three fourteen-day trend cards in one row of three, and the
 * migrations list under them at full width. `.admin-stats` and `.admin-trends` are grids
 * with a fixed column count rather than `auto-fit`, because the React build's own classes
 * are `md:grid-cols-4` and `lg:grid-cols-3` — a count, not a "however many fit".
 */
.admin-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
@media (min-width: 768px) {
    .admin-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.admin-stat { display: flex; flex-direction: column; gap: 0.1rem; padding: 1rem; }
.admin-stat-num { font-size: 1.5rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
.admin-stat-label { font-size: 0.75rem; color: var(--muted); }

.admin-trends { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--card-gap); margin-bottom: 1rem; }
@media (min-width: 1024px) {
    .admin-trends { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.admin-trend { display: flex; flex-direction: column; }
/* The chart well: bars grow from the floor, and every day keeps its own column whether
   or not anything happened in it. */
.admin-trend-bars { display: flex; align-items: flex-end; gap: 0.25rem; height: 9rem; margin-top: 0.5rem; }
.admin-trend-day { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; flex: 1 1 0; min-width: 0; height: 100%; justify-content: flex-end; }
.admin-trend-bar { display: block; width: 100%; min-height: 2px; border-radius: 3px 3px 0 0; transition: opacity 0.15s ease; }
.admin-trend-day:hover .admin-trend-bar { opacity: 0.8; }
.admin-trend-label { font-size: 0.6rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.admin-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 1.25rem 0; }
.admin-tabs a {
    display: inline-flex; align-items: center; height: 2rem; padding: 0 0.8rem;
    border-radius: calc(var(--radius) - 4px); font-size: 0.813rem; font-weight: 500;
    color: var(--muted); border: 1px solid var(--border); background: transparent;
}
.admin-tabs a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
.admin-tabs a.active { color: var(--accent-soft-fg); background: var(--accent-soft); border-color: transparent; }

/* ---------- the admin bar ---------- */
/*
 * The React admin's grouped menu: one dropdown per group, a caption inside it,
 * and the screen you are on named in a breadcrumb underneath. It replaces the old
 * flat strip of tabs, which no longer resembled the React build once the board
 * grew past a handful of screens.
 */
.admin-head {
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 1rem; flex-wrap: wrap; margin: 1.25rem 0 1rem;
}
.admin-head h1 { display: flex; align-items: center; gap: 0.6rem; margin: 0; font-size: 1.6rem; }
.admin-head p { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.9rem; }
.admin-head-icon { width: 1rem; height: 1rem; }

.admin-bar { position: relative; }
/*
 * The bar is one line, never two — and never clipped.
 *
 * There is deliberately **no** `overflow` here. beta.35 gave the bar `overflow-x: auto` so
 * that nine groups would scroll rather than wrap, and that one declaration removed every
 * dropdown in the admin area: `overflow` makes the bar a scroll container, a scroll
 * container clips everything positioned inside it, and each group's menu is
 * `position: absolute`. All nine panels were drawn and then cut away, and the ninth group
 * (`System`) sat past the right edge behind a hidden scrollbar, which put the whole System
 * section out of reach.
 *
 * One line without clipping is a sizing problem, not a scrolling one: the type and the
 * padding below are `min()`s that shrink with the window, so nine labelled groups fit a
 * 1280px screen and a 1000px one, and everything on the bar stays clickable at both.
 */
.admin-menu {
    display: flex; flex-wrap: nowrap; gap: min(0.15rem, 0.3vw);
    padding: 0.3rem; border: 1px solid var(--border);
    border-radius: var(--radius); background: var(--surface);
}
.admin-menu-group { position: relative; }
.admin-menu-group { flex: 0 0 auto; }
.admin-menu-group > summary {
    list-style: none; cursor: pointer; white-space: nowrap;
    display: inline-flex; align-items: center; gap: min(0.4rem, 0.5vw);
    padding: 0.4rem min(0.55rem, 0.62vw); border-radius: calc(var(--radius) - 4px);
    color: var(--muted); font-size: min(0.82rem, 0.95vw); font-weight: 500;
}
.admin-menu-group > summary::-webkit-details-marker { display: none; }
.admin-menu-group > summary:hover { background: var(--surface-2); color: var(--text); }
.admin-menu-group[open] > summary { background: var(--surface-2); color: var(--text); }
.admin-menu-group[open] > summary .admin-menu-caret { transform: rotate(180deg); }
/*
 * The group the current screen lives in is marked, not opened. An open panel is a
 * panel hanging over the page, and the one moment it is least wanted is the moment
 * a tab has just been chosen — so the marker carries the "you are here" and the
 * breadcrumb underneath names the screen itself.
 */
.admin-menu-group.is-active > summary { color: var(--text); }
.admin-menu-group.is-active > summary .admin-menu-icon { color: var(--accent); }
.admin-menu-icon { width: 0.95rem; height: 0.95rem; }
.admin-menu-caret { width: 0.75rem; height: 0.75rem; opacity: 0.7; transition: transform 0.15s ease; }
/*
 * The right-most group's panel opens leftwards.
 *
 * `System` is last, and its panel is wider than its own summary, so a left-aligned one
 * would hang off the end of the bar. Anchoring the last one to the right keeps every panel
 * on screen without measuring anything.
 */
.admin-menu-group:last-child .admin-menu-panel { left: auto; right: 0; }
.admin-menu-panel {
    position: absolute; left: 0; top: calc(100% + 0.35rem); z-index: 50;
    min-width: 15rem; padding: 0.45rem;
    display: flex; flex-direction: column; gap: 0.1rem;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: 0 18px 40px oklch(0 0 0 / 0.35);
}
.admin-menu-caption {
    font-size: 0.66rem; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--muted); opacity: 0.7; font-weight: 600;
    padding: 0.35rem 0.6rem 0.25rem;
}
.admin-menu-item {
    display: flex; align-items: center; gap: 0.5rem;
    padding: 0.45rem 0.6rem; border-radius: calc(var(--radius) - 6px);
    color: var(--muted); font-size: 0.875rem;
}
.admin-menu-item:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.admin-menu-item.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
/*
 * A dropdown's sub-groups.
 *
 * The bar is two levels deep. A group's panel lists its screens; where a title has more
 * than a handful — System's appearance, its communications, its reports — it lists them
 * under a folding heading instead of all together, so the panel opens at a readable
 * length and the section you are in is the one already unfolded. The heading is a
 * `<details>` like the group above it: it opens without scripting, it is reachable by
 * keyboard, and it carries a caret so it reads as something that opens.
 */
.admin-menu-sub { display: block; margin-top: 0.15rem; }
.admin-menu-sub > summary {
    list-style: none; cursor: pointer;
    display: flex; align-items: center; justify-content: space-between; gap: 0.6rem;
    padding: 0.35rem 0.6rem 0.25rem; border-radius: calc(var(--radius) - 6px);
    font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase;
    color: var(--muted); opacity: 0.75; font-weight: 600;
}
.admin-menu-sub > summary::-webkit-details-marker { display: none; }
.admin-menu-sub > summary:hover { background: var(--surface-2); color: var(--text); opacity: 1; }
.admin-menu-sub[open] > summary { color: var(--text); opacity: 1; }
.admin-menu-subcaret { width: 0.6rem; height: 0.6rem; opacity: 0.6; transition: transform 0.15s ease; }
.admin-menu-sub[open] > summary .admin-menu-subcaret { transform: rotate(180deg); }
/* Inset, so a screen reads as belonging to the heading it sits under. */
.admin-menu-sub .admin-menu-item { padding-left: 1.1rem; }
.admin-breadcrumb {
    display: flex; align-items: center; gap: 0.45rem;
    margin: 0.75rem 0 1.25rem; font-size: 0.78rem; color: var(--muted);
}
.admin-breadcrumb a { color: var(--muted); }
.admin-breadcrumb a:hover { color: var(--text); }
.admin-breadcrumb b { color: var(--text); font-weight: 600; }
.admin-breadcrumb-sep { opacity: 0.6; }
@media (max-width: 760px) {
    /* A narrow screen stacks the panels under the bar instead of over it. */
    .admin-menu-panel { position: static; box-shadow: none; margin-top: 0.35rem; }
}

/*
 * The admin dialogs' two-column field grid.
 *
 * The React build's category dialog is Colour beside Icon, and the prefix row is six
 * controls across; a single column of full-width inputs is what makes a form of four
 * fields look like a form of twelve.
 */
/*
 * The four label screens' own furniture: the inline add row, the per-row controls, and the
 * group chips.
 *
 * The add row is `.form-row` with its fields sized to their contents rather than stretched
 * across the card — the React build's own row is six controls in a line, and a row of
 * full-width inputs would be six lines.
 */
.tax-add { align-items: flex-end; }
.tax-add > .field { flex: 0 1 auto; }
.tax-add > .field > input, .tax-add > .field > select { min-width: 9rem; }
/* A cell that is a one-field form: the control, and nothing else. */
.tax-cell { display: inline-flex; margin: 0; }
.tax-cell > select { width: auto; min-width: 9rem; padding: 0.3rem 0.5rem; font-size: 0.82rem; }
.tax-cell > input[type="color"] {
    width: 3.5rem; height: 1.7rem; padding: 0.1rem; cursor: pointer;
    border: 1px solid var(--border); border-radius: 999px; background: var(--surface-2);
}
.tax-group-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.25rem; }
.tax-group-chip {
    display: inline-flex; align-items: center; gap: 0.45rem;
    padding: 0.3rem 0.4rem 0.3rem 0.6rem;
    border: 1px solid color-mix(in srgb, var(--group-color) 40%, var(--border));
    border-radius: 999px; background: color-mix(in srgb, var(--group-color) 12%, transparent);
    font-size: 0.82rem;
}
.tax-group-chip form { display: inline-flex; margin: 0; }
.tax-group-dot { width: 0.6rem; height: 0.6rem; border-radius: 999px; background: var(--group-color); flex-shrink: 0; }
/* The tag table's use count: a number, against the right edge, with digits that line up. */
.tax-uses { text-align: right; font-variant-numeric: tabular-nums; }
/*
 * "make global" — the way back from a label narrowed to one category.
 *
 * A button, because it writes; dressed as a link, because the row it sits in is prose. It is
 * its own form so that clearing the scope is one field posted on its own.
 */
.tax-global { display: inline-flex; margin: 0 0 0 0.45rem; }
.tax-global button {
    background: none; border: 0; padding: 0; cursor: pointer;
    color: var(--accent); font: inherit; font-size: 0.75rem;
}
.tax-global button:hover { text-decoration: underline; }
/* The chip being typed in the tags add row, drawn at the size a row's chip is. */
.tax-preview { align-self: flex-start; }
/* A row's leading mark: the category's colour as a dot beside its name. */
.category-name { display: inline-flex; align-items: center; gap: 0.5rem; }
.category-dot { width: 0.7rem; height: 0.7rem; border-radius: 999px; flex-shrink: 0; }
/* The screen's own head: what this is, and the button that adds the first one. */
.admin-screen-head {
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;
}
.admin-screen-head h2 { margin: 0; }
.admin-screen-head p { margin: 0.2rem 0 0; }

/* The bin inside a chip or a row: an icon button that carries no background of its own. */
.icon-btn {
    display: grid; place-items: center; width: 1.6rem; height: 1.6rem;
    border: 1px solid transparent; border-radius: 999px;
    background: none; color: var(--muted); cursor: pointer; padding: 0;
}
.icon-btn:hover { background: var(--surface-2); color: var(--danger); border-color: var(--border); }
.icon-btn .admin-menu-icon { width: 0.85rem; height: 0.85rem; }

.form-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1rem; }
.form-trio { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 1rem; }
@media (max-width: 700px) { .form-pair, .form-trio { grid-template-columns: minmax(0, 1fr); } }
/* The colour swatch beside its label, as the React colour picker draws it. */
.colour-field { display: flex; align-items: center; gap: 0.6rem; }
.colour-field input[type="color"] {
    width: 100%; height: 2.4rem; padding: 0.15rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
    background: var(--surface-2); cursor: pointer;
}
/* A screenshot of the thing you are making: the field with its button beside it. */
.field-with-button { display: flex; align-items: stretch; gap: 0.4rem; }
.field-with-button > input { flex: 1 1 auto; min-width: 0; }

table.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
table.list th {
    text-align: left; padding: 0.6rem 0.7rem; color: var(--muted);
    font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600;
    border-bottom: 1px solid var(--border); white-space: nowrap;
}
table.list td { padding: 0.7rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
table.list tr:last-child td { border-bottom: none; }
table.list tbody tr:hover td { background: color-mix(in srgb, var(--surface-2) 45%, transparent); }

/* ---------- changelog ---------- */
/*
 * The admin changelog reads CHANGELOG.md in this upload, so the styling stays
 * deliberately plain: a release is a collapsible block, a section is a small
 * caption, and an entry is a bullet. Nothing here needs to be edited to add a
 * release — only the markdown file changes.
 */
.changelog-release { padding: 1rem 1.1rem; }
.changelog-release > summary {
    cursor: pointer; font-size: 1rem; letter-spacing: -0.01em;
    display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
}
.changelog-release > summary::-webkit-details-marker { color: var(--muted); }
.changelog-release[open] > summary { margin-bottom: 0.6rem; }
/* The release's own opening line: the prose between its heading and its first
   `###`, which used to be skipped and left a release with nothing under it. */
.changelog-lede { margin: 0 0 0.7rem; font-size: 0.9rem; line-height: 1.55; color: var(--muted); }
.changelog-lede strong { color: var(--text); }
.changelog-section {
    margin: 1rem 0 0.35rem; font-size: 0.72rem; text-transform: uppercase;
    letter-spacing: 0.06em; color: var(--muted); font-weight: 600;
}
.changelog-list { margin: 0; padding-left: 1.15rem; font-size: 0.9rem; line-height: 1.55; }
.changelog-list li + li { margin-top: 0.4rem; }
.changelog-list code { font-size: 0.85em; }

/* ---------- footer ---------- */
/*
 * The React build's footer: the brand and its strapline, then the link columns
 * beside it, and a bottom bar carrying the layout control, the copyright and the
 * credit. The hairline above it is the theme's own border token.
 */
.site-footer {
    border-top: 1px solid var(--border); margin-top: 1rem;
    color: var(--muted); font-size: 0.8rem;
}
.site-footer-inner { max-width: 1400px; margin: 0 auto; padding: 2.5rem 1rem 1.25rem; }
.site-footer-grid {
    display: grid;
    grid-template-columns: minmax(12rem, 1.5fr) repeat(4, minmax(0, 1fr));
    gap: 2rem;
}
.site-footer-brand .brand { font-size: 1rem; }
.site-footer-note { margin: 0.6rem 0 0; }
/*
 * The footer's logo — its own (`footer_logo()` draws `.site-footer-logo`) or, when the board
 * has set none, the header's mark drawn as `.brand-image`.
 *
 * Either way it is bounded by its column here. `.brand-image`'s global `max-width: 12rem` is
 * wider than a footer cell once `.site-footer-grid` becomes two columns below 900px, which is
 * where a wide logo overflowed and read as "full size" on a phone; this caps the width to the
 * column and the height to a footer-sized mark, and the media query below tightens the height
 * again for the narrow layout.
 */
.site-footer-logo,
.site-footer-brand .brand-image {
    width: auto; max-width: 100%; max-height: 3rem;
    object-fit: contain; object-position: left center; vertical-align: middle;
}
/* The About line under the logo: the sanitized rich-text body an administrator writes. */
.site-footer-about { margin: 0.6rem 0 0; max-width: 26rem; line-height: 1.6; }
.site-footer-about > :first-child { margin-top: 0; }
.site-footer-about > :last-child { margin-bottom: 0; }
.site-footer-about a { color: var(--accent); }
.site-footer-about a:hover { color: var(--text); }
.site-footer-col { display: flex; flex-direction: column; gap: 0.55rem; }
.site-footer-col h4 { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--text); }
.site-footer-col a { color: var(--muted); }
.site-footer-col a:hover { color: var(--text); }
/* A footer link that owns a submenu: its children are indented under it, one step each. */
.site-footer-col a.site-footer-sub { padding-left: 0.9rem; font-size: 0.82rem; opacity: 0.92; }
.site-footer-col a.site-footer-sub--deep { padding-left: 1.7rem; opacity: 0.82; }
/*
 * The bar is three zones in one row: the layout control at the start, the copyright in the
 * middle of it, and the feed marks with the credit at the end.
 *
 * The middle zone is what keeps it three rather than two. An auto margin on each side of the
 * copyright is the one thing that centres it in a flex row whatever the two outer zones
 * happen to measure — a `justify-content` cannot, because the control and the marks are not
 * the same width — and it is why the copyright is wrapped in an element of its own rather
 * than left as a bare text node in the row, which no margin can reach.
 */
.site-footer-bar {
    display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
    margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--border);
}
.site-footer-copy { margin-inline: auto; text-align: center; }
.site-footer-customize,
.site-footer-consent-button {
    display: inline-flex; align-items: center; gap: 0.45rem;
    background: none; border: 0; padding: 0; color: var(--accent);
    font: inherit; cursor: pointer;
}
.site-footer-customize:hover,
.site-footer-consent-button:hover { color: var(--text); }
/* Both footer controls are forms or buttons; the wrapper is a form, so the bar's row display has
   to be put back on it. */
.site-footer-consent { display: inline-flex; align-items: center; margin: 0; }
.site-footer-icon { width: 0.95rem; height: 0.95rem; }

/* ---------- the cookie notice ---------- */
/*
 * The notice, drawn at the foot of every page until the visitor answers it
 * (`views/partials/cookie-consent.php`, printed by the footer).
 *
 * **Sticky at the bottom, not fixed.** The React build pins its bar to the window and measures its
 * own height into a CSS variable (`COOKIE_OFFSET_VAR`) so other bottom-anchored surfaces can sit
 * above it; a `position: sticky` bar that is last in the document needs none of that. It rides the
 * bottom of the window for as long as the page scrolls, settles into its own space at the end of
 * the document, and — because it is laid out in the flow — it can never bury content the way a
 * fixed bar can: the footer's last row is pushed up by the notice rather than covered by it.
 *
 * `z-index: 55` puts the notice above the sticky header (`40`) and below the modal layer (`70`),
 * which is the order that matters: a dialog is a deliberate act and the notice must never cover
 * one. The notification and account panels (`60`+) therefore draw over the notice, which is the
 * right way round — the panel a visitor has just opened wins.
 */
.cookie-consent {
    position: sticky; bottom: 0; z-index: 55;
    border-top: 1px solid var(--border);
    background: color-mix(in srgb, var(--surface) 96%, transparent);
    backdrop-filter: blur(10px);
    box-shadow: 0 -8px 30px color-mix(in srgb, var(--text) 12%, transparent);
}
.cookie-consent-inner {
    max-width: 72rem; margin: 0 auto;
    display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
    padding: 0.75rem 1rem;
}
.cookie-consent-mark {
    display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
    width: 2.25rem; height: 2.25rem; border-radius: 999px;
    background: color-mix(in srgb, var(--accent) 12%, transparent);
    color: var(--accent);
}
.cookie-consent-mark-icon { width: 1rem; height: 1rem; }
.cookie-consent-copy { min-width: 0; flex: 1 1 22rem; }
.cookie-consent-title { margin: 0; font-size: 0.875rem; font-weight: 600; }
.cookie-consent-body {
    margin: 0.25rem 0 0; max-width: 46rem;
    font-size: 0.75rem; line-height: 1.6; color: var(--muted);
}
.cookie-consent-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-left: auto; }
/* The reference draws its three answers as pills; the board's own button keeps the shape. */
.cookie-consent-actions .button { border-radius: 999px; }
.site-footer-meta { display: inline-flex; align-items: center; gap: 0.6rem; }
/*
 * The visitor's style chooser, sitting in the footer bar beside the layout control.
 *
 * A plain GET form: label, select and Apply button, wrapping on a narrow screen so the bar
 * keeps its one-row shape on a desktop and stacks cleanly on a phone.
 */
.site-footer-style { display: inline-flex; align-items: center; gap: 0.5rem; }
.site-footer-style-label { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--muted); font-size: 0.85rem; }
.site-footer-style-form { display: inline-flex; align-items: center; gap: 0.4rem; }
.site-footer-style-select {
    font: inherit; font-size: 0.85rem; color: var(--text);
    background: var(--surface); border: 1px solid var(--border); border-radius: 0.4rem;
    padding: 0.3rem 0.5rem;
}
@media (max-width: 900px) {
    .site-footer-bar { flex-direction: column; align-items: flex-start; }
    .site-footer-copy { margin-inline: 0; text-align: left; }
}
.site-footer-meta a { color: var(--accent); }
.site-footer-meta a:hover { color: var(--text); }
/* The credit is words, not a mark: it keeps the bar's own colour while the icons carry the accent. */
.site-footer-meta .site-footer-credit { color: var(--muted); }
.site-footer-meta .site-footer-credit:hover { color: var(--text); }
@media (max-width: 900px) {
    .site-footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    /* The footer's two-column mobile layout: a shorter mark, never the header's size. */
    .site-footer-logo,
    .site-footer-brand .brand-image { max-height: 2.25rem; }
}

/* ---------- the widget rail ---------- */
/*
 * The column every forum page carries. It follows the React rail: a card per
 * widget, a heading with a fold control, and the numbers inside.
 */
.rail-stats { display: flex; flex-direction: column; gap: 0.55rem; font-size: 0.875rem; }
.rail-stat { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.rail-stat-icon { width: 0.95rem; height: 0.95rem; color: var(--muted); flex-shrink: 0; }
.rail-stat-icon--online { color: var(--accent); }
.rail-stat b { font-weight: 600; min-width: 1.4rem; font-variant-numeric: tabular-nums; }
.rail-stat span { color: var(--muted); }
.rail-divide { height: 1px; background: var(--border); margin: 0.85rem 0; }
.rail-newest { display: flex; align-items: baseline; gap: 0.45rem; font-size: 0.813rem; min-width: 0; }
.rail-newest-name { color: var(--text); font-weight: 500; }
.rail-caption {
    font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.12em;
    color: var(--muted); opacity: 0.75; font-weight: 600;
}
.rail-empty { margin: 0; color: var(--muted); font-size: 0.813rem; line-height: 1.55; }
.rail-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.813rem; }
.rail-list-row { display: flex; gap: 0.5rem; align-items: baseline; min-width: 0; }
.rail-list-date { color: var(--muted); font-variant-numeric: tabular-nums; }
.rail-facepile { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.75rem; }
.rail-wide { width: 100%; justify-content: center; margin-top: 0.85rem; }
.rail-button-icon { width: 0.9rem; height: 0.9rem; }
.rail-share { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.85rem; }
/*
 * A share chip: the service's own mark on the service's own colour.
 *
 * A white glyph on a coloured round button is a mark at a glance; the same glyph in the
 * widget's own text colour on the widget's own surface was a row of empty circles. The
 * colour arrives as `--share-color` from the template, one value per service, so the row
 * is one rule here rather than five.
 */
.rail-share-link {
    display: grid; place-items: center; width: 1.25rem; height: 1.25rem;
    /* A `<button>`, because the marks must not be anchors: a content blocker's "social"
       filter list hides the four share endpoints by their `href` and has no rule for
       Instagram, so an anchor row reached a reader as one lonely Instagram mark. The glyph
       inside is an inline `<svg>` for the same family of reason — `fa-facebook-f` and
       `fa-x-twitter` name their service in the class, which a broad cosmetic rule reads. */
    border: 0; padding: 0; font: inherit; cursor: pointer; appearance: none;
    border-radius: 999px; color: #fff;
    background: var(--share-color, var(--surface-2));
    box-shadow: 0 1px 2px oklch(0 0 0 / 0.28);
    transition: transform 0.15s ease, filter 0.15s ease;
}
.rail-share-link:hover { text-decoration: none; filter: brightness(1.12); transform: translateY(-1px); }
.rail-share-icon { font-size: 0.75rem; width: 0.75rem; height: 0.75rem; }
/*
 * **Our Socials** — the board's own accounts, one chip each.
 *
 * The same chip the share row above draws, for the same reason: a white brand glyph on the
 * service's own colour reads as a mark at a glance. The difference is what the mark *is* — these
 * are anchors a reader follows, not share buttons, so each opens in a new tab. The colour arrives
 * as `--social-color`, one value per network from `social_networks()`, so this is one rule rather
 * than thirty; a board's own uploaded icon draws its picture instead and takes the plain surface.
 */
.rail-socials { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.85rem; }
.rail-social-link {
    display: grid; place-items: center; width: 1.6rem; height: 1.6rem;
    border-radius: 999px; color: #fff; overflow: hidden;
    background: var(--social-color, var(--surface-2));
    box-shadow: 0 1px 2px oklch(0 0 0 / 0.28);
    transition: transform 0.15s ease, filter 0.15s ease;
}
.rail-social-link:hover { text-decoration: none; filter: brightness(1.12); transform: translateY(-1px); }
.rail-social-icon { font-size: 0.8rem; width: 0.8rem; height: 0.8rem; }
.rail-social-image { width: 100%; height: 100%; object-fit: cover; display: block; }
.rail-sections { display: flex; align-items: center; gap: 0.85rem; font-size: 0.75rem; padding: 0 0.25rem; }
.rail-sections-link {
    background: none; border: 0; padding: 0;
    color: var(--accent); font: inherit; cursor: pointer;
}
.rail-sections-link:hover { color: var(--text); }

/* The heading of a rail widget, and the control that folds it away. */
.widget-head {
    display: flex; align-items: center; justify-content: space-between;
    gap: 0.5rem;
}
.widget-head-actions { display: inline-flex; align-items: center; gap: 0.35rem; }
.widget-toggle {
    display: grid; place-items: center; width: 1.5rem; height: 1.5rem;
    border: 0; border-radius: 0.4rem; background: none;
    color: var(--muted); cursor: pointer;
}
.widget-toggle:hover { background: var(--surface-2); color: var(--text); }
.widget-toggle-icon { width: 0.95rem; height: 0.95rem; }
.widget.collapsed .widget-body { display: none; }
.pill-mini {
    font-size: 0.7rem; font-weight: 600; padding: 0.1rem 0.55rem;
    border-radius: 999px; background: var(--accent); color: var(--accent-fg);
}
/* The rail's "Open" pill fills with the accent too, so it keeps its label for the same reason. */
.pill-mini:hover, .pill-mini:focus-visible { text-decoration: none; color: var(--accent-fg); }

/* ---------- the tag index ---------- */
/*
 * The Tags screen: a filter in the page head, then a band per tag group and a
 * chip per tag that fills in proportion to that tag's share of the board.
 */
.filter-field { position: relative; display: inline-flex; align-items: center; }
.filter-field-icon {
    position: absolute; left: 0.7rem; width: 0.95rem; height: 0.95rem;
    color: var(--muted); pointer-events: none;
}
.filter-field input { padding-left: 2.1rem; min-width: 18rem; }
.tag-band { margin-bottom: 1.5rem; }
/*
 * The band head is the reference's `flex items-center gap-2` over a `text-sm` name and a
 * `text-xs` count: 0.875rem at `tracking-wide` (0.025em), not the 0.72rem at 0.12em that
 * was here, which drew every band name smaller and far more tracked than the build it
 * copies, and a count at 0.875rem/600 rather than the reference's 0.75rem/400.
 */
.tag-band-head {
    display: flex; align-items: center; gap: 0.5rem;
    font-size: 0.875rem; letter-spacing: 0.025em; text-transform: uppercase;
    color: var(--muted); font-weight: 600; margin-bottom: 0.75rem;
}
.tag-band-dot { width: 0.625rem; height: 0.625rem; border-radius: 999px; flex-shrink: 0; }
/* The count sits beside the band's name, not at the far edge of the column: the reference's
   band head is a dot, an uppercase name and the number, read as one line. */
.tag-band-count { font-weight: 400; font-size: 0.75rem; font-variant-numeric: tabular-nums; }
/*
 * The band's grid — the reference's `grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`.
 *
 * It used to be `repeat(auto-fill, minmax(15rem, 1fr))`, which asks the *column* how many
 * chips fit rather than asking the viewport, and the two do not agree: on a 1512px screen the
 * main column is about 830px, so `auto-fill` laid three chips across where the reference lays
 * four three breakpoints earlier. These are the same four steps the reference uses, at the
 * same widths, so a band of tags is drawn the same size in both front ends.
 */
.tag-band-grid { display: grid; gap: 0.75rem; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 640px) { .tag-band-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1024px) { .tag-band-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 1280px) { .tag-band-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
/*
 * A chip is the reference's tag card: a plain bordered card with the coloured tag pill on the
 * left and the share on the right, not a card washed in the tag's own colour.
 *
 * The wash was the whole card — border, fill and *name* — which made a band of tags read as a
 * wall of colour rather than as the labelled list the reference draws. `--tag-color` still
 * drives it; it now only reaches the pill and is mixed at the reference's 10% for the fill,
 * which is what `TagPill` does with `hex + "1a"`.
 */
.tag-chip {
    display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
    min-width: 0; padding: 0.75rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    font-size: 0.875rem;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}
.tag-chip:hover {
    text-decoration: none;
    background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
.tag-chip-pill {
    display: inline-flex; align-items: center; min-width: 0;
    padding: 0.125rem 0.625rem; border-radius: 999px;
    background: color-mix(in srgb, var(--tag-color) 10%, transparent);
    color: var(--tag-color); font-weight: 500; font-size: 0.875rem;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tag-chip-percent {
    flex-shrink: 0; color: var(--muted);
    font-size: 0.75rem; font-variant-numeric: tabular-nums;
}
/*
 * A tag pill in the rail's cloud, and on a topic row, wears its tag's colour: the name
 * in the colour itself and the pill's fill a wash of the same value, which is the React
 * build's `TagPill` (`backgroundColor: hex + "1a"`, `color: hex`). `--tag-color` is set
 * per pill by the template; the `--accent` default it used to fall back to is why the
 * whole cloud came out one colour, and why the tag chips on `/tags` came out unstyled.
 */
.tag-tinted {
    border-color: color-mix(in srgb, var(--tag-color) 40%, transparent);
    background: color-mix(in srgb, var(--tag-color) 14%, transparent);
    color: var(--tag-color);
}
.tag-filter-empty { margin-top: 1rem; }

/* ---------- the chatbox ---------- */
/*
 * The React build's chatbox: a card the room's own colour tints, a header of room
 * tabs and four controls, a gradient viewport of bubbles, and a composer with a
 * formatting row, an emoji picker and a circular send button.
 *
 * Two values come from the room rather than the theme — `--chat-primary` and
 * `--chat-accent`, set on the section by `views/chatbox.php` — and everything
 * tinted is mixed from them at the same strengths the React build's inline styles
 * use: `55` for the border, `0d` and `0b` for the two gradient legs, `18` for the
 * welcome tile. A room with no colour configured falls back to the theme, which is
 * what the defaults in `app/chat.php` already are.
 *
 * The geometry is the React widget's: a 240px-ceiling viewport that takes a fifth
 * of a short window, 28px round avatars, bubbles capped at three quarters of the
 * width and squared off on the side they point to, and a 2rem circular send button.
 */
.chatbox {
    --chat-primary: var(--accent);
    --chat-accent: var(--success);
    background: var(--surface);
    border: 1px solid color-mix(in srgb, var(--chat-primary) 33%, transparent);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    overflow: hidden;
}

/* The header: the title, the room tabs, then mentions, rules, settings and fold. */
.chatbox-head {
    display: flex; align-items: center; gap: 0.25rem;
    padding: 0.5rem;
    border-bottom: 1px solid var(--border);
    background: color-mix(in srgb, var(--surface-2) 30%, transparent);
}
.chatbox-title { flex-shrink: 0; padding: 0 0.5rem; font-size: 0.875rem; font-weight: 600; }
.chatbox-tabs { display: flex; flex: 1; min-width: 0; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
.chatbox-tabs::-webkit-scrollbar { display: none; }
.chatbox-tab {
    flex-shrink: 0; padding: 0.375rem 0.75rem;
    border: 0; border-radius: calc(var(--radius) - 4px);
    background: none; color: var(--muted);
    font-family: inherit; font-size: 0.75rem; font-weight: 500;
    white-space: nowrap;
}
.chatbox-tab:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.chatbox-tab.is-active { background: var(--surface); color: var(--text); box-shadow: 0 1px 2px oklch(0 0 0 / 0.2); }
.chatbox-tab-icon { width: 0.75rem; height: 0.75rem; vertical-align: -0.1em; }
.chatbox-icon {
    position: relative; display: grid; place-items: center; flex-shrink: 0;
    width: 2rem; height: 2rem;
    border: 0; border-radius: calc(var(--radius) - 4px);
    background: none; color: var(--muted); cursor: pointer;
}
.chatbox-icon:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.chatbox-icon-glyph { width: 1rem; height: 1rem; }
.chatbox-badge {
    position: absolute; top: -0.125rem; right: -0.125rem;
    display: grid; place-items: center; min-width: 1rem; height: 1rem; padding: 0 0.2rem;
    border-radius: 999px; background: #ef4444; color: #fff;
    font-size: 0.5625rem; font-weight: 700; line-height: 1;
}
.chatbox.collapsed .chatbox-body { display: none; }

/*
 * The viewport: the two gradient legs, then the rows.
 *
 * The base under the gradients is the page's own dark, not the surface the card sits on:
 * a chat panel is a well, and a well that is lighter than the page around it reads as
 * somebody else's window rather than as part of the board. The two legs are laid over
 * that.
 *
 * Its scrollbar is drawn here rather than left to the browser. The track is the well
 * itself — nothing at all, so no grey band appears beside the messages — and the thumb
 * is only a step lighter than the well, which is enough to find it and not enough to
 * pull the eye off the conversation. `color-scheme: dark` on the root covers the
 * browsers that ignore these rules and would otherwise draw the light scrollbar.
 */
.chatbox-viewport {
    height: min(28vh, 240px);
    overflow-y: auto;
    padding: 0.75rem 1rem;
    background:
        linear-gradient(135deg, color-mix(in srgb, var(--chat-primary) 5%, transparent), transparent 46%),
        linear-gradient(315deg, color-mix(in srgb, var(--chat-accent) 4%, transparent), transparent 55%),
        var(--bg);
    scrollbar-width: thin;
    scrollbar-color: color-mix(in srgb, var(--text) 16%, transparent) transparent;
}
.chatbox-viewport::-webkit-scrollbar { width: 0.6rem; height: 0.6rem; }
.chatbox-viewport::-webkit-scrollbar-track { background: transparent; }
.chatbox-viewport::-webkit-scrollbar-thumb {
    border-radius: 999px; border: 0.18rem solid transparent; background-clip: padding-box;
    background-color: color-mix(in srgb, var(--text) 17%, transparent);
}
.chatbox-viewport::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--text) 30%, transparent); }
.chatbox-list { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; }
.chatbox-welcome {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    min-height: 16rem; text-align: center;
}
.chatbox-welcome-mark {
    display: grid; place-items: center; width: 3.5rem; height: 3.5rem;
    border-radius: 1rem; font-size: 1.5rem;
    background: color-mix(in srgb, var(--chat-primary) 9%, transparent);
}
.chatbox-welcome-title { margin: 0.75rem 0 0; font-size: 1rem; font-weight: 600; }
.chatbox-welcome-text { margin: 0.25rem 0 0; max-width: 24rem; font-size: 0.875rem; color: var(--muted); line-height: 1.55; }

/* One shout: the avatar, then the byline and the bubble. Your own turns around. */
.chatbox-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.chatbox-row.is-own { flex-direction: row-reverse; }
.chatbox-col { max-width: 75%; min-width: 0; }
.chatbox-col.is-own { text-align: right; }
.chatbox-meta {
    display: flex; align-items: center; gap: 0.375rem; margin-bottom: 0.125rem;
    font-size: 0.625rem; color: var(--muted);
}
.chatbox-col.is-own .chatbox-meta { justify-content: flex-end; }
.chatbox-name { font-weight: 600; color: var(--text); }
.chatbox-time { font-variant-numeric: tabular-nums; }
.chatbox-remove {
    padding: 0.125rem; border: 0; border-radius: 0.25rem;
    background: none; color: var(--muted); cursor: pointer; line-height: 0;
}
.chatbox-remove:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
.chatbox-remove-icon { width: 0.75rem; height: 0.75rem; }
.chatbox-bubble {
    padding: 0.375rem 0.5rem;
    border: 1px solid var(--border); border-radius: 1rem; border-top-left-radius: 0.25rem;
    background: var(--bg);
    font-size: 0.75rem; line-height: 1.375; text-align: left;
    white-space: pre-wrap; overflow-wrap: anywhere;
    box-shadow: 0 1px 2px oklch(0 0 0 / 0.15);
}
.chatbox-col.is-own .chatbox-bubble {
    border-radius: 1rem; border-top-right-radius: 0.25rem;
    background: var(--chat-primary); border-color: var(--chat-primary); color: #fff;
}
.chatbox-mention { font-weight: 600; }
.chatbox-attachment {
    display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;
    padding: 0.375rem 0.5rem;
    border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
    border-radius: 0.5rem; background: oklch(0 0 0 / 5%);
    font-size: 0.75rem; font-weight: 500;
}
.chatbox-attachment-icon { width: 0.875rem; height: 0.875rem; }

/* The composer: the formatting row above, the field and the round send below. */
.chatbox-composer {
    padding: 0.5rem 0.75rem;
    border-top: 1px solid var(--border);
    background: color-mix(in srgb, var(--surface-2) 20%, transparent);
}
.chatbox-composer-inner {
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface); box-shadow: var(--shadow);
}
.chatbox-composer-inner:focus-within { border-color: color-mix(in srgb, var(--chat-primary) 55%, var(--border)); }
.chatbox-toolbar {
    display: flex; align-items: center; gap: 0.125rem;
    padding: 0.25rem 0.5rem;
    border-bottom: 1px solid var(--border);
}
.chatbox-tool {
    display: inline-flex; align-items: center; justify-content: center;
    width: 1.5rem; height: 1.5rem;
    border: 0; border-radius: 0.25rem;
    background: none; color: var(--muted); cursor: pointer;
}
.chatbox-tool:hover:not([disabled]) { background: var(--surface-2); color: var(--text); }
.chatbox-tool[disabled] { opacity: 0.45; cursor: not-allowed; }
.chatbox-tool-icon { width: 0.75rem; height: 0.75rem; }
.chatbox-count { margin-left: auto; font-size: 0.625rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.chatbox-emoji-wrap { position: relative; }
.chatbox-emoji {
    position: absolute; bottom: 2.25rem; left: 0; z-index: 20;
    display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.25rem;
    width: 11rem; padding: 0.5rem;
    border: 1px solid var(--border); border-radius: 0.5rem;
    background: var(--surface); box-shadow: var(--shadow);
}
.chatbox-emoji[hidden] { display: none; }
.chatbox-emoji-item {
    padding: 0.25rem; border: 0; border-radius: 0.25rem;
    background: none; cursor: pointer; font-size: 0.875rem; line-height: 1;
}
.chatbox-emoji-item:hover { background: var(--surface-2); }
.chatbox-compose-row { display: flex; align-items: flex-end; gap: 0.5rem; padding: 0.375rem; }
.chatbox-input {
    flex: 1; width: auto; min-width: 0;
    min-height: 2rem; max-height: 6rem; resize: none;
    margin: 0; padding: 0.375rem 0.5rem;
    border: 0; border-radius: 0;
    background: transparent; color: var(--text);
    font-family: inherit; font-size: 0.875rem; line-height: 1.4;
}
.chatbox-input:focus { border: 0; outline: none; box-shadow: none; }
.chatbox-send {
    display: grid; place-items: center; flex-shrink: 0;
    width: 2rem; height: 2rem;
    border: 0; border-radius: 999px;
    background: var(--chat-primary); color: #fff; cursor: pointer;
}
.chatbox-send:hover { filter: brightness(1.08); }
.chatbox-send-icon { width: 1rem; height: 1rem; }

/*
 * The rules used to be an accordion here, under the composer. They are the panel the
 * book control opens now (`views/partials/chat-rules.php`, styled with `.modal-rules-*`
 * above), which is where the React build keeps them — so the widget has no rule block
 * of its own left to style.
 */

/* Switched off, empty of rooms, or waiting for a sign-in. */
.chatbox-state { padding: 1.5rem 1rem; text-align: center; }
.chatbox-state-mark { display: block; width: 2rem; height: 2rem; margin: 0 auto; color: var(--accent); }
.chatbox-state h2 { margin: 0.75rem 0 0; }
.chatbox-state p { margin: 0.35rem auto 0; max-width: 30rem; font-size: 0.875rem; color: var(--muted); line-height: 1.55; }
.chatbox-state .button { margin-top: 1rem; border-radius: 999px; }

/* The `/chat` page's own heading, and the round controls beside it. */
.chat-page-head h1 { margin: 0.25rem 0 0; }
.chat-page-head > div > p:last-child { margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--muted); }
/* The small capitalised line above a page's `h1`. `.chat-eyebrow` was the first
   screen to need one; `.page-eyebrow` is the same idea for the rest of the board,
   so the two share a rule rather than drifting apart. */
.chat-eyebrow, .page-eyebrow {
    margin: 0; font-size: 0.75rem; font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.18em; color: var(--accent);
}
.chat-head-actions { display: flex; align-items: center; gap: 0.5rem; }
.chat-head-actions .chatbox-icon {
    width: 2.25rem; height: 2.25rem;
    border: 1px solid var(--border); border-radius: 999px;
}

/*
 * The avatar's eight pastels, and the round shape they are drawn in.
 *
 * The values are the React build's own Tailwind classes read out of the compiled
 * sheet — `indigo-100`/`indigo-700` in light, `indigo-950`/`indigo-300` in dark —
 * and `avatar_tone()` in `app/helpers.php` picks between them with the same hash
 * the React build uses, so a member's circle is the same colour on both builds.
 */
:root {
    --tone-0-bg: oklch(25.7% 0.09 281.288); --tone-0-fg: oklch(78.5% 0.115 274.713);
    --tone-1-bg: oklch(26.2% 0.051 172.552); --tone-1-fg: oklch(84.5% 0.143 164.978);
    --tone-2-bg: oklch(27.9% 0.077 45.635); --tone-2-fg: oklch(87.9% 0.169 91.605);
    --tone-3-bg: oklch(27.1% 0.105 12.094); --tone-3-fg: oklch(81% 0.117 11.638);
    --tone-4-bg: oklch(29.3% 0.066 243.157); --tone-4-fg: oklch(82.8% 0.111 230.318);
    --tone-5-bg: oklch(28.3% 0.141 291.089); --tone-5-fg: oklch(81.1% 0.111 293.571);
    --tone-6-bg: oklch(27.7% 0.046 192.524); --tone-6-fg: oklch(85.5% 0.138 181.071);
    --tone-7-bg: oklch(26.6% 0.079 36.259); --tone-7-fg: oklch(83.7% 0.128 66.29);
}
:root[data-mode="light"] {
    --tone-0-bg: oklch(93% 0.034 272.788); --tone-0-fg: oklch(45.7% 0.24 277.023);
    --tone-1-bg: oklch(95% 0.052 163.051); --tone-1-fg: oklch(50.8% 0.118 165.612);
    --tone-2-bg: oklch(96.2% 0.059 95.617); --tone-2-fg: oklch(55.5% 0.163 48.998);
    --tone-3-bg: oklch(94.1% 0.03 12.58); --tone-3-fg: oklch(51.4% 0.222 16.935);
    --tone-4-bg: oklch(95.1% 0.026 236.824); --tone-4-fg: oklch(50% 0.134 242.749);
    --tone-5-bg: oklch(94.3% 0.029 294.588); --tone-5-fg: oklch(49.1% 0.27 292.581);
    --tone-6-bg: oklch(95.3% 0.051 180.801); --tone-6-fg: oklch(51.1% 0.096 186.391);
    --tone-7-bg: oklch(95.4% 0.038 75.164); --tone-7-fg: oklch(55.3% 0.195 38.402);
}
.chatbox-avatar {
    display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
    width: 1.75rem; height: 1.75rem; border-radius: 999px; overflow: hidden;
    font-size: 0.625rem; font-weight: 600; line-height: 1;
}
/* A shout's circle with the member's own picture in it. */
.chatbox-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* Behind a picture the circle is the page colour, not a tone: a logo or an image with a
   transparent corner would otherwise show the tone through as coloured wedges. */
.chatbox-avatar--image { background: var(--bg); }
/* The tone is a class of its own, not a chatbox one: the member directory draws the
   same eight circles at a different size. */
.avatar-tone--0 { background: var(--tone-0-bg); color: var(--tone-0-fg); }
.avatar-tone--1 { background: var(--tone-1-bg); color: var(--tone-1-fg); }
.avatar-tone--2 { background: var(--tone-2-bg); color: var(--tone-2-fg); }
.avatar-tone--3 { background: var(--tone-3-bg); color: var(--tone-3-fg); }
.avatar-tone--4 { background: var(--tone-4-bg); color: var(--tone-4-fg); }
.avatar-tone--5 { background: var(--tone-5-bg); color: var(--tone-5-fg); }
.avatar-tone--6 { background: var(--tone-6-bg); color: var(--tone-6-fg); }
.avatar-tone--7 { background: var(--tone-7-bg); color: var(--tone-7-fg); }

/* ---------- the moderation panel ---------- */
/*
 * The React build's moderator panel: six sections down the left, the chosen queue
 * on the right, and the same markup drawing both the standalone page and the admin
 * area's Moderation tab. The geometry is the React build's `lg:grid-cols-[15rem_minmax(0,1fr)]`
 * with `gap-5`, which collapses to one column on a narrow screen — the nav becomes
 * a row of chips there rather than a column nobody can reach.
 */
.modpanel { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: 1.25rem; align-items: start; }
.modpanel-nav { display: flex; flex-direction: column; gap: 0.25rem; }
.modpanel-link {
    display: flex; align-items: center; gap: 0.6rem;
    padding: 0.5rem 0.75rem; border-radius: calc(var(--radius) - 4px);
    color: var(--muted); font-size: 0.875rem; font-weight: 500;
}
.modpanel-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.modpanel-link.is-active { background: var(--accent-soft); color: var(--accent-soft-fg); box-shadow: var(--shadow); }
.modpanel-link-icon { font-size: 0.875rem; width: 0.875rem; }
.modpanel-body { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
.modpanel-hint { margin: 0 0 0.25rem; }

.mod-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0.75rem; }
.mod-card {
    display: flex; flex-direction: column; gap: 0.3rem;
    padding: 1rem 1.15rem; background: var(--surface);
    border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow);
    color: var(--text);
}
.mod-card:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); text-decoration: none; }
.mod-card-label { display: flex; align-items: center; gap: 0.4rem; color: var(--muted); font-size: 0.8rem; }
.mod-card-icon { font-size: 0.8rem; }
.mod-card-value { font-size: 1.8rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
.mod-card-note { color: var(--muted); font-size: 0.75rem; }
.mod-card-open { margin-top: 0.35rem; color: var(--accent); font-size: 0.8rem; font-weight: 600; }
.mod-card-open-icon { font-size: 0.7rem; }
.mod-card-title { display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; margin: 0 0 0.5rem; }

/* The report queue: the filter row, then one card per report. */
.mod-filters { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.mod-filter {
    padding: 0.3rem 0.75rem; border: 1px solid var(--border); border-radius: 999px;
    color: var(--muted); font-size: 0.8rem; font-weight: 500;
}
.mod-filter:hover { color: var(--text); text-decoration: none; }
.mod-filter.is-active { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.mod-empty {
    padding: 1.5rem; margin: 0; border: 1px dashed var(--border); border-radius: var(--radius);
    text-align: center; color: var(--muted); font-size: 0.875rem;
}
.mod-report { display: flex; flex-direction: column; gap: 0.6rem; }
.mod-report-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
.mod-target {
    padding: 0.7rem 0.85rem; border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px);
    background: color-mix(in srgb, var(--surface-2) 60%, transparent);
}
.mod-target-title { margin: 0 0 0.2rem; font-weight: 600; font-size: 0.9rem; }
.mod-quote {
    margin: 0; padding: 0.6rem 0.8rem; border: 1px dashed var(--border);
    border-radius: calc(var(--radius) - 4px); color: var(--muted); font-size: 0.8rem;
}
.mod-actions { display: flex; flex-direction: column; gap: 0.5rem; margin: 0; }
.mod-actions textarea { width: 100%; min-height: 3.2rem; }
.mod-action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.mod-suspend { display: inline-flex; align-items: center; gap: 0.4rem; }
.mod-strong { color: var(--text); font-weight: 600; }

/* A suspended member, and the member-notes header, are the same row of avatar,
   name and one control. */
.mod-member { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
.mod-member-main { flex: 1; min-width: 0; }
.mod-avatar {
    display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
    width: 2.1rem; height: 2.1rem; border-radius: 999px;
    font-size: 0.72rem; font-weight: 600; line-height: 1; overflow: hidden;
}
.mod-avatar--lg { width: 2.4rem; height: 2.4rem; }
.mod-avatar img { width: 100%; height: 100%; object-fit: cover; }

.mod-log-row {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
    padding: 0.5rem 0.75rem; border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px); background: var(--surface); font-size: 0.8rem;
}
.mod-log-action {
    padding: 0.1rem 0.4rem; border: 1px solid var(--border); border-radius: 0.35rem;
    font-family: var(--mono); font-size: 0.7rem; color: var(--muted);
}
.mod-log-when { margin-left: auto; }

.mod-member-face { display: inline-flex; flex-shrink: 0; }
.mod-member-face:hover { text-decoration: none; }
/* The save row under the notes box: the button sits at the far right of its card, which
   is the React build's `justify-end` row. */
.mod-note-action { display: flex; justify-content: flex-end; }

.mod-subhead {
    margin: 1rem 0 0.4rem; font-size: 0.75rem; font-weight: 600;
    text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
}
/* The notes picker: one search field, then the member chips two to a line — the
   React build's `sm:grid-cols-2` grid, not a column count that follows the window. */
.mod-note-search { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; }
.mod-note-members { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.5rem; margin-top: 0.75rem; }
@media (min-width: 641px) {
    .mod-note-members { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.mod-note-member {
    display: flex; align-items: center; gap: 0.5rem;
    padding: 0.5rem 0.7rem; border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px); color: var(--text); font-size: 0.85rem;
}
.mod-note-member:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); text-decoration: none; }
/* `display: flex` above would otherwise beat the plain `[hidden]` rule, and the
   picker's filter hides chips by the attribute. */
.mod-note-members [hidden] { display: none; }
.mod-note-member-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mod-note-list { display: flex; flex-direction: column; gap: 0.4rem; }
.mod-note-preview {
    display: flex; flex-direction: column; gap: 0.1rem;
    padding: 0.5rem 0.7rem; border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px); color: var(--text); font-size: 0.8rem;
}
.mod-note-preview:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); text-decoration: none; }
.mod-note {
    display: flex; align-items: flex-start; gap: 0.75rem;
    padding: 0.6rem 0.8rem; border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px); background: var(--surface);
}
.mod-note-main { flex: 1; min-width: 0; }
.mod-note-body { margin: 0 0 0.2rem; font-size: 0.875rem; white-space: pre-wrap; overflow-wrap: anywhere; }

/* The panel's own page heading, and an icon inside a button. */
.mod-page-head { margin-bottom: 1.25rem; }
.button-icon { font-size: 0.9rem; }

@media (max-width: 900px) {
    .modpanel { grid-template-columns: minmax(0, 1fr); }
    .modpanel-nav { flex-direction: row; overflow-x: auto; gap: 0.35rem; padding-bottom: 0.25rem; }
    .modpanel-link { white-space: nowrap; }
}

/* A label kept in the accessibility tree but out of the layout. */
.sr-only {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* ---------- the badge catalogue ---------- */
/*
 * The React build's badges screen: a strip of recent awards, then the catalogue
 * banded by tier, each band a row of cards whose round mark carries the badge's own
 * colour. The chip on a band is the React build's own badge for it, down to the
 * three colourways — amber-800/amber-400 for bronze, slate for silver, amber for
 * gold — which is why they are named rather than derived from the theme.
 */
.card-title-sm { margin: 0 0 0.5rem; font-size: 0.875rem; font-weight: 600; }
.badge-recent { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; font-size: 0.875rem; }
.badge-recent-item { display: inline-flex; align-items: center; gap: 0.375rem; min-width: 0; }
.badge-recent-icon { width: 1rem; height: 1rem; }
.badge-recent-user { font-weight: 500; color: var(--text); }
.badge-recent-verb { color: var(--muted); }
.badge-recent-name { font-weight: 600; }
.badge-tier-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.badge-tier {
    display: inline-block; padding: 0.1rem 0.6rem;
    border: 1px solid transparent; border-radius: 99px;
    font-size: 0.72rem; font-weight: 600; text-transform: capitalize;
}
.badge-tier--bronze { background: color-mix(in srgb, #92400e 15%, transparent); color: #fbbf24; border-color: color-mix(in srgb, #92400e 25%, transparent); }
.badge-tier--silver { background: color-mix(in srgb, #94a3b8 15%, transparent); color: #cbd5e1; border-color: color-mix(in srgb, #94a3b8 30%, transparent); }
.badge-tier--gold { background: color-mix(in srgb, #fbbf24 15%, transparent); color: #fbbf24; border-color: color-mix(in srgb, #fbbf24 30%, transparent); }
:root[data-mode="light"] .badge-tier--bronze { color: #b45309; }
:root[data-mode="light"] .badge-tier--silver { color: #475569; }
:root[data-mode="light"] .badge-tier--gold { color: #d97706; }
.badge-grid { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); }
.badge-card { display: flex; align-items: flex-start; gap: 0.75rem; padding: 1rem; }
.badge-mark { display: grid; place-items: center; flex-shrink: 0; width: 2.5rem; height: 2.5rem; border-radius: 999px; }
.badge-mark-icon { width: 1.25rem; height: 1.25rem; }
.badge-card-main { min-width: 0; }
.badge-card-head { display: flex; align-items: center; gap: 0.5rem; }
.badge-card-name { font-weight: 600; }
.badge-card-count { font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.badge-card-help { display: inline-flex; color: var(--muted); }
.badge-card-help-icon { width: 0.875rem; height: 0.875rem; }
.badge-card-desc {
    margin: 0.125rem 0 0; font-size: 0.75rem; line-height: 1.5; color: var(--muted);
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}

/* ---------- the member directory ---------- */
/*
 * The React build draws a directory of people, not a spreadsheet: a grid of cards,
 * the most active first, each one a 44px round avatar in that member's own tone,
 * their handle and what they have written, and their role when they hold one.
 */
.member-grid { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); }
.member-card {
    display: flex; align-items: center; gap: 0.75rem;
    padding: 1rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface); box-shadow: var(--shadow); color: inherit;
    transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.member-card:hover {
    transform: translateY(-2px); text-decoration: none;
    box-shadow: 0 6px 20px oklch(0 0 0 / 0.28);
}
.member-avatar {
    display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
    width: 2.75rem; height: 2.75rem; border-radius: 999px;
    font-size: 0.9rem; font-weight: 600;
}
.member-card-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.member-card-name { display: flex; align-items: center; gap: 0.375rem; font-weight: 500; color: var(--text); }
.member-card-banned { display: inline-flex; color: var(--danger); }
.member-card-banned-icon { width: 0.875rem; height: 0.875rem; }
.member-card-meta { font-size: 0.75rem; color: var(--muted); }
.role-badge {
    flex-shrink: 0; padding: 0.1rem 0.55rem;
    border: 1px solid transparent; border-radius: 99px;
    font-size: 0.72rem; font-weight: 600;
}
.role-badge--admin { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.role-badge--moderator { background: color-mix(in srgb, #10b981 12%, transparent); color: #34d399; border-color: color-mix(in srgb, #10b981 25%, transparent); }
:root[data-mode="light"] .role-badge--moderator { color: #047857; }

/* ---------- the search palette ---------- */
/*
 * The React build's command palette: a panel a tenth of the way down the window,
 * the field along the top with the Esc key beside it, the hits under that, and a
 * footer strip carrying the keyboard hints.
 *
 * The panel is 42rem wide at most and its hit list stops at 22rem, which is the
 * React build's `sm:max-w-2xl` and `max-h-[22rem]` — tall enough for a handful of
 * results, short enough that the field never leaves the screen.
 */
.search-palette {
    position: fixed; inset: 0; z-index: 100;
    display: grid; justify-items: center; align-items: start;
    padding: 10vh 1rem 2rem;
}
.search-palette[hidden] { display: none; }
.search-palette-backdrop {
    position: absolute; inset: 0;
    background: oklch(0 0 0 / 0.55);
    backdrop-filter: blur(2px);
}
.search-palette-panel {
    position: relative;
    width: 100%; max-width: 42rem;
    overflow: hidden;
    border: 1px solid var(--border);
    border-radius: calc(var(--radius) + 4px);
    background: var(--surface);
    box-shadow: 0 24px 60px oklch(0 0 0 / 0.45);
}
.search-palette-field {
    display: flex; align-items: center; gap: 0.75rem;
    padding: 0.75rem 1rem;
    border-bottom: 1px solid var(--border);
}
.search-palette-icon { flex-shrink: 0; width: 1.25rem; height: 1.25rem; color: var(--muted); }
.search-palette-input {
    flex: 1; width: auto; min-width: 0;
    margin: 0; padding: 0;
    border: 0; border-radius: 0; background: transparent;
    color: var(--text); font-family: inherit; font-size: 1rem;
}
.search-palette-input:focus { border: 0; outline: none; box-shadow: none; }
.search-palette-body { max-height: 22rem; overflow-y: auto; }
.search-palette-empty {
    margin: 0; padding: 2rem 1rem;
    text-align: center; font-size: 0.875rem; color: var(--muted);
}
.search-palette-group {
    margin: 0; padding: 0.75rem 1rem 0.25rem;
    font-size: 0.625rem; font-weight: 600; letter-spacing: 0.16em;
    text-transform: uppercase; color: var(--muted); opacity: 0.7;
}
.search-palette-item {
    display: flex; align-items: flex-start; gap: 0.75rem;
    padding: 0.625rem 0.75rem; color: inherit;
}
.search-palette-item:hover { text-decoration: none; background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
.search-palette-item.is-active { background: var(--surface-2); }
.search-palette-item-mark {
    display: grid; place-items: center; flex-shrink: 0;
    width: 1.75rem; height: 1.75rem; margin-top: 0.125rem;
    border-radius: 0.4rem; background: var(--surface-2); color: var(--muted);
}
.search-palette-item-mark--avatar { border-radius: 999px; font-size: 0.625rem; font-weight: 600; }
.search-palette-item-icon { width: 0.875rem; height: 0.875rem; }
.search-palette-item-main { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.search-palette-item-title {
    font-size: 0.875rem; font-weight: 500; color: var(--text);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-palette-item-excerpt,
.search-palette-item-meta {
    font-size: 0.75rem; color: var(--muted);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-palette-item-meta { font-size: 0.6875rem; }
.search-palette-item-arrow { flex-shrink: 0; margin-top: 0.25rem; color: var(--muted); }
.search-palette-item-arrow-icon { width: 1rem; height: 1rem; }
.search-palette-all {
    display: block; padding: 0.625rem 1rem;
    border-top: 1px solid var(--border);
    font-size: 0.75rem; font-weight: 500; color: var(--accent);
}
.search-palette-foot {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.25rem;
    padding: 0.625rem 1rem;
    border-top: 1px solid var(--border);
    background: color-mix(in srgb, var(--surface-2) 30%, transparent);
}
.search-palette-hint {
    display: inline-flex; align-items: center; gap: 0.375rem;
    font-size: 0.6875rem; color: var(--muted);
}

/* A key cap: the Esc beside the field and the three hints along the bottom. */
.kbd {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 1.5rem; padding: 0.1rem 0.35rem;
    border: 1px solid var(--border); border-radius: 0.35rem;
    background: var(--surface-2); color: var(--muted);
    font-family: var(--mono); font-size: 0.7rem; font-weight: 600; line-height: 1.4;
}
.search-palette-hint .kbd { min-width: 1.25rem; padding: 0.05rem 0.3rem; font-size: 0.65rem; }

/*
 * Printing a page should not print the furniture: the rail, the footer's link
 * columns and the header's controls are all noise on paper.
 */
@media print {
    .rail, .site-header, .site-footer-grid, .site-footer-bar { display: none; }
    .with-rail { display: block; }
}

/* ---------- the forum dashboard ---------- */
/*
 * The home page's dashboard: the tabbed card and the newest-members panel beside it.
 *
 * The two are one section in the React build (`CollapsibleSection id="home.dashboard"`,
 * used twice) and one section here — both carry `data-collapse-group="home.dashboard"`,
 * so the fold in either header collapses both cards at once, which is what the panel's
 * own caption promises. Only the bodies roll up: the tab strip and the panel's heading
 * stay where they are.
 */
/*
 * `stretch`, not `start`: the tabbed card and the newest-members panel are two halves of
 * one section, so they are the same height — the shorter one grows to meet the taller.
 */
.dashboard { display: grid; gap: 1rem; grid-template-columns: minmax(0, 1fr); align-items: stretch; }
/* The second column only when the newest-members panel is actually drawn, so a board
   with it switched off does not leave an empty 17rem strip beside the tabs. */
.dashboard--split { grid-template-columns: minmax(0, 1fr) 17rem; }
@media (max-width: 1024px) { .dashboard--split { grid-template-columns: minmax(0, 1fr); } }
.dashboard-main { padding: 0; overflow: hidden; }
/*
 * The strip is one line, and the arrows are how it moves.
 *
 * With five tabs switched on it used to wrap onto a second row, which pushed the panel
 * down and made the card look broken on a narrow window. It scrolls instead — the same
 * gesture the React build's strip makes — and the two arrows in the settings button's
 * corner scroll it a tab at a time for anyone who would rather click than drag. Both are
 * enhancements: with scripting off the strip is still scrollable by hand.
 */
/*
 * The head of the dashboard card: the strip, with an arrow at each end of it.
 *
 * The border and the wash moved up to this wrapper, because the arrows are outside the
 * scroller — a control inside it would scroll away from the end it belongs to.
 */
.dashboard-tabs-wrap {
    display: flex; align-items: stretch;
    border-bottom: 1px solid var(--border);
    background: color-mix(in srgb, var(--surface-2) 45%, transparent);
}
.dashboard-tabs {
    display: flex; align-items: stretch; flex-wrap: nowrap; flex: 1 1 auto; min-width: 0;
    overflow-x: auto; scroll-behavior: smooth; scrollbar-width: none;
}
.dashboard-tabs::-webkit-scrollbar { display: none; }
.dashboard-tab {
    flex: 0 0 auto; min-width: 0; padding: 0.7rem 0.7rem; white-space: nowrap;
    border: 0; border-right: 1px solid var(--border); background: none;
    color: var(--muted); cursor: pointer;
    font-family: inherit; font-size: 0.82rem; font-weight: 500; line-height: 1.3;
}
.dashboard-tab:hover { background: var(--surface-2); color: var(--text); }
.dashboard-tab.is-active {
    background: var(--surface); color: var(--accent);
    box-shadow: inset 0 -2px 0 var(--accent);
}
/*
 * The settings control is chrome, so it takes no share of the strip.
 *
 * The section's fold is the one in the newest-members header and only that one: with a
 * control at each end of the strip a reader counted two folds for one section, and the
 * shared rule that used to clear a tab button's background and border never named the
 * strip's copy — so the browser drew that one as a default button, a pale box with a
 * dark minus in it. The strip's copy is gone and its rules went with it.
 */
.dashboard-tab--icon {
    flex: 0 0 auto; display: grid; place-items: center; padding: 0 0.9rem;
    border-right: 0; border-left: 1px solid var(--border);
}
.dashboard-tab-icon { width: 0.95rem; height: 0.95rem; }
/* The strip's arrows: chrome, so they take no share of the strip and never wrap. */
.dashboard-tab-arrow {
    flex: 0 0 auto; display: grid; place-items: center; padding: 0 0.55rem;
    border: 0; background: none; color: var(--muted); cursor: pointer;
}
.dashboard-tab-arrow--start { border-right: 1px solid var(--border); }
.dashboard-tab-arrow--end { border-left: 1px solid var(--border); }
.dashboard-tab-arrow[hidden] { display: none; }
.dashboard-tab-arrow:hover { background: var(--surface-2); color: var(--text); }
.dashboard-tab-arrow[disabled] { opacity: 0.35; cursor: default; }
.dashboard-tab-arrow-icon { width: 0.85rem; height: 0.85rem; }
/* The tabbed card's floor. It is the height the newest-members panel keeps up with, so
   it is deliberately modest: a taller floor is empty space the panel has to match. */
.dashboard-body { padding: 0.4rem; min-height: 14rem; }
/* One panel is in the document at all times: with scripting off the first tab shows. */
.dashboard-panel { display: none; }
.dashboard-panel.is-active { display: block; }
.dashboard-empty { margin: 0; padding: 1rem 0.6rem; }
[data-collapse-group].is-collapsed [data-collapse-body] { display: none; }

/* A row: the mark, the title and its meta, and the tail. */
.dashboard-row {
    display: flex; align-items: center; gap: 0.75rem;
    padding: 0.7rem 0.6rem; border-radius: calc(var(--radius) - 4px);
}
.dashboard-row:hover { background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
.dashboard-row-mark {
    display: grid; place-items: center; flex-shrink: 0;
    width: 2rem; height: 2rem; border-radius: 999px;
    background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent);
}
.dashboard-row-icon { width: 0.95rem; height: 0.95rem; }
.dashboard-row-main { display: flex; flex-direction: column; gap: 0.1rem; flex: 1; min-width: 0; }
.dashboard-row-title { font-size: 0.9rem; font-weight: 500; color: var(--text); }
.dashboard-row-meta { font-size: 0.78rem; color: var(--muted); }
.dashboard-row-tail { display: inline-flex; align-items: center; gap: 0.25rem; flex-shrink: 0; font-size: 0.75rem; color: var(--muted); }
.dashboard-row-tail-icon { width: 0.85rem; height: 0.85rem; }

/* Two tiles to a row, as the React build lays the products tab out. */
.dashboard-grid { display: grid; gap: var(--card-gap); grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); padding: 0.6rem; }
.dashboard-tile {
    display: flex; flex-direction: column; gap: 0.25rem;
    padding: 0.85rem; border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
}
.dashboard-tile:hover { border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.dashboard-tile-title { font-size: 0.9rem; font-weight: 600; color: var(--text); }
.dashboard-tile-meta { font-size: 0.78rem; color: var(--muted); }

/* The "last visited threads" tab when there is nothing to show yet. */
.dashboard-note {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 0.6rem; min-height: 16rem; text-align: center; padding: 1.5rem;
}
.dashboard-note p { margin: 0; font-size: 0.875rem; color: var(--muted); }
.dashboard-note-icon { width: 1.75rem; height: 1.75rem; color: var(--muted); opacity: 0.5; }

/*
 * The newest-members panel: a face, a handle and how long they have been here.
 *
 * It is half of one section with the tabbed card beside it, so it is stretched to that
 * card's height — and its rows then share out the space between them, which is what stops
 * a short register from leaving a band of nothing under the last name. `flex: 1 1 auto`
 * with a 3rem floor: a row never collapses into a line, and a panel with two names in it
 * draws two rows half the panel tall rather than two rows and a hole. That is what the
 * panel's own caption means by "one fold collapses this card and the tabs beside it":
 * one section, drawn twice.
 */
.dashboard-side { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.dashboard-side .card-head { padding: 0.9rem 1rem; border-bottom: 1px solid var(--border); align-items: flex-start; }
.dashboard-side-body { display: flex; flex-direction: column; flex: 1; }
.dashboard-member {
    display: flex; align-items: center; gap: 0.6rem;
    flex: 0 0 auto; min-height: 3rem;
    padding: 0.65rem 1rem; border-bottom: 1px solid var(--border);
}
.dashboard-member:last-child { border-bottom: none; }
.dashboard-member:hover { background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
.dashboard-member-name { flex: 1; min-width: 0; font-size: 0.875rem; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-member-when { font-size: 0.7rem; color: var(--muted); flex-shrink: 0; }

/* ---------- the topic's own head ---------- */
/*
 * A topic, above its posts: the breadcrumb line, the title, one row of actions.
 *
 * The React build draws the marks and the counters *above* the title in a single muted
 * line and keeps the actions under it, which is what makes the title the loudest thing on
 * the page. The board had the title first with its meta underneath and the actions hung
 * out to the right, which read as a card of six unrelated rows.
 */
.topic-head-card { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1.25rem; }
.topic-crumbs {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
    font-size: 0.75rem; color: var(--muted);
}
.topic-head-title {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
    margin: 0; font-size: 1.4rem; line-height: 1.25;
}
.topic-actions {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
    margin-top: 0.35rem;
}
.topic-action-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; }
.topic-report { margin: 0.35rem 0 0; }
/*
 * The two thread menus' triggers — the moderator's and the thread owner's — are labelled
 * buttons, not the bare `…` a post carries, because they sit in a row of buttons.
 */
.post-menu.topic-mod-menu > summary,
.post-menu.topic-owner-menu > summary {
    display: inline-flex; align-items: center; gap: 0.4rem;
    width: auto; height: auto; padding: 0.35rem 0.6rem;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
    background: var(--surface-2); color: var(--text);
    font-size: 0.8rem; font-weight: 500;
}
.post-menu.topic-mod-menu > summary:hover,
.post-menu.topic-owner-menu > summary:hover { background: var(--surface); }
.topic-mod-menu .post-menu-panel, .topic-owner-menu .post-menu-panel { min-width: 13rem; }
/*
 * An entry that opens a box is a link, not a write: **Edit topic** and **Edit this post** go to
 * `?edit=…`, and they wear the same item the forms above and below them do.
 */
.post-menu-panel a.post-menu-item { color: var(--text); text-decoration: none; }
/*
 * The rename box, under the title it renames.
 */
.topic-rename {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.6rem;
}
.topic-rename input { flex: 1 1 18rem; min-width: 0; }
/*
 * The post editor, in the post's own place — the React build's `editing` state, which swaps
 * the writing for the editor and its Save/Cancel row.
 */
.post-edit-form { margin-top: 0.5rem; }
.post-edit-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
.post-edit-row input { flex: 1 1 16rem; min-width: 0; }

/* ---------- the topic feed ---------- */
/*
 * The home page's topic list.
 *
 * One dense row per thread — the React build's `TopicRow`, and the reason this board is
 * a Discourse rather than a XenForo: a category is a chip on a row, not a card of its
 * own. The row carries the author's face, the title with its marks and labels, the
 * category, the author and when the thread last moved, its tags, the stack of faces of
 * the people who posted in it with the reply and view counts, and the last poster with
 * the time of that post.
 *
 * The counters and the tail drop away on a narrow screen, which is the same trade the
 * React build makes with `hidden sm:flex` and `hidden md:flex`: the title and the person
 * who wrote it are what a phone has room for.
 */
.topic-feed { display: flex; flex-direction: column; }
.topic-feed-row { gap: 0.75rem; align-items: center; padding: 0.7rem 0; }
/*
 * Two lines per row, and only two.
 *
 * The React build's `TopicRow` is a dense, scannable list: the title with its marks on
 * the first line and the category, the author, the age and the tags on the second. The
 * board's own title style is a wrapping row with a 1rem face — right for a topic's own
 * page, crowded twelve times down a feed — so inside the feed the title is one clipped
 * line and the meta beneath it is a step smaller.
 */
.topic-feed .topic-title { font-size: 0.95rem; flex-wrap: nowrap; }
.topic-feed .topic-title > a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topic-feed .topic-meta { margin-top: 0.2rem; font-size: 0.75rem; gap: 0.35rem; }
.topic-feed .topic-cat { font-size: 0.72rem; }
.topic-feed-avatar { flex-shrink: 0; }
/*
 * The content mark that opens a topic row (REQ #9): an uploaded icon or a default glyph drawn in
 * the same fixed square, so a picture and a glyph line up and every row keeps one thumbnail size.
 * `object-fit: cover` keeps an uploaded image's proportions rather than stretching it.
 */
.topic-feed-icon {
    display: inline-flex; align-items: center; justify-content: center;
    width: 2.5rem; height: 2.5rem; overflow: hidden;
    border-radius: calc(var(--radius) - 2px);
    background: var(--surface-2); color: var(--muted);
}
.topic-feed-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }
.topic-feed-icon-glyph { font-size: 1rem; }
/* The circle a topic row or a post opens with: the `avatar_html()` circle inside the
   link that takes a reader to the member's profile. The link does not shrink, and the
   circle inside it does not either, so a long title cannot squeeze the face. */
.post-avatar { display: inline-flex; flex-shrink: 0; }
.post-avatar:hover { text-decoration: none; }
.rail-face { display: inline-flex; }
.rail-face:hover { text-decoration: none; }
.search-palette-item-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: 999px; }
.topic-feed-main { flex: 1; min-width: 0; }
/*
 * The opening post's own two lines, which the reading feed draws under a title.
 *
 * Hidden here and revealed by `html.layout-feed` alone: the element ships in every row so
 * the feed has something to show without a second copy of the listing, and the list and the
 * grid are what they would have been without it. See `views/partials/topic-row.php`.
 */
.topic-feed-excerpt { display: none; }
.topic-mark { font-size: 0.8rem; flex-shrink: 0; }
.topic-mark--pinned { color: var(--accent); }
/* The React build's `text-amber-500` for a featured thread. */
.topic-mark--featured { color: #f59e0b; }
/* A topic's prefix or suffix label, tinted by the label's own colour. */
.topic-label {
    display: inline-flex; align-items: center; border-radius: 999px;
    padding: 0.1rem 0.5rem; font-size: 0.7rem; font-weight: 600; line-height: 1.5;
    color: var(--label-color); background: color-mix(in srgb, var(--label-color) 15%, transparent);
}
/* The category chip: the React build's coloured pill, from the category's own colour. */
.topic-cat {
    display: inline-flex; align-items: center; border-radius: 999px;
    padding: 0.05rem 0.5rem; font-size: 0.78rem; font-weight: 500;
    color: var(--cat-color); background: color-mix(in srgb, var(--cat-color) 13%, transparent);
}
.topic-cat:hover { text-decoration: none; filter: brightness(1.15); }
.topic-meta {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
    margin-top: 0.35rem; font-size: 0.8rem; color: var(--muted);
}
.topic-meta-dot { opacity: 0.55; }
.topic-feed-stats {
    display: flex; align-items: center; gap: 0.9rem; flex-shrink: 0;
    font-size: 0.78rem; color: var(--muted);
}
/* A Discourse-style stack: the faces of the people who posted in the thread. */
.topic-faces { display: inline-flex; }
.topic-face { display: inline-flex; border-radius: 999px; box-shadow: 0 0 0 2px var(--surface); }
.topic-face + .topic-face { margin-left: -0.4rem; }
.topic-count { display: inline-flex; align-items: center; gap: 0.25rem; font-variant-numeric: tabular-nums; }
/* Ten replies or more is the thread worth opening, as it is in the React build. */
.topic-count--hot { color: var(--accent); font-weight: 600; }
.topic-count-icon { font-size: 0.85rem; }
.topic-feed-tail {
    display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;
    width: 7rem; justify-content: flex-end; font-size: 0.78rem; color: var(--muted);
}
.topic-when { font-variant-numeric: tabular-nums; }
@media (max-width: 860px) { .topic-feed-stats { display: none; } }
@media (max-width: 620px) { .topic-feed-tail { display: none; } }

/* ---------- who is reading this thread ---------- */
/*
 * The React build's **"N here"** pill, above the posts: a bordered chip holding a pulsing
 * green dot, the readers' faces, the count, and a muted line saying what the count is of.
 * It is server-rendered with the first count and kept current by the heartbeat beside it.
 */
.presence-pill { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.85rem; }
.presence-chip {
    display: inline-flex; align-items: center; gap: 0.5rem;
    padding: 0.25rem 0.65rem; border: 1px solid var(--border); border-radius: 999px;
    background: var(--surface-2);
}
.presence-dot { position: relative; width: 0.5rem; height: 0.5rem; border-radius: 999px; background: #10b981; }
/* The halo the React build draws with `animate-ping`: a second dot that scales out and fades. */
.presence-dot::after {
    content: ""; position: absolute; inset: 0; border-radius: 999px; background: #10b981;
    animation: presence-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes presence-ping { 0% { transform: scale(1); opacity: 0.6; } 70%, 100% { transform: scale(2.2); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .presence-dot::after { animation: none; } }
.presence-faces { display: inline-flex; }
.presence-faces .avatar { box-shadow: 0 0 0 2px var(--surface-2); }
.presence-faces .avatar + .avatar { margin-left: -0.4rem; }
.presence-count { font-size: 0.75rem; font-weight: 600; }
.presence-note { font-size: 0.75rem; color: var(--muted); }

/* ---------- the board's numbers ---------- */
/*
 * The **Community statistics** card below the topic list: four counters across one row,
 * divided by hairlines, which is the React build's `grid divide-y sm:grid-cols-4
 * sm:divide-x`. "Online now" carries the faces of who is there rather than a fourth
 * glyph, so the number has somebody behind it.
 */
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.stats-cell { display: flex; align-items: center; gap: 0.7rem; padding: 0.95rem 1rem; border-right: 1px solid var(--border); }
.stats-cell:last-child { border-right: 0; }
.stats-icon { font-size: 1.05rem; color: var(--accent); flex-shrink: 0; }
.stats-icon--online { color: var(--success); }
.stats-num { margin: 0; font-size: 1.05rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
.stats-label { margin: 0; font-size: 0.72rem; color: var(--muted); }
@media (max-width: 860px) {
    .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .stats-cell { border-bottom: 1px solid var(--border); }
    .stats-cell:nth-child(2n) { border-right: 0; }
    .stats-cell:nth-child(n + 3) { border-bottom: 0; }
}

/* ---------- the page's short notes ---------- */
/*
 * What a post made *over* the page reports back with.
 *
 * The page has not moved, so a note at the top of the document would be a note the reader
 * never saw: these rise from the foot of the screen, say one sentence, and go. The host is
 * created by the script in `views/layout/footer.php` the first time there is something to
 * say, so a board with scripting off never draws an empty one.
 */
/*
 * While the resource gallery's lightbox is open, anything an administrator injected through
 * Custom code is put behind it: a floating support or chat widget usually carries a very high
 * z-index and would otherwise sit above the dark backdrop. The gallery script adds
 * `.fv-lightbox-open` to the body while the dialog is up; the injected container is hidden here
 * and restored the moment the dialog closes.
 */
body.fv-lightbox-open .custom-body-code { visibility: hidden !important; }

.page-toast-host {
    position: fixed; z-index: 120; left: 50%; bottom: 1.25rem;
    transform: translateX(-50%); display: flex; flex-direction: column;
    align-items: center; gap: 0.5rem; pointer-events: none;
}
.page-toast {
    max-width: min(30rem, calc(100vw - 2rem));
    padding: 0.55rem 0.95rem; border-radius: 999px;
    background: var(--surface-2); color: var(--text);
    border: 1px solid var(--border); box-shadow: var(--shadow);
    font-size: 0.85rem; font-weight: 500;
    transition: opacity 0.35s ease, transform 0.35s ease;
}
.page-toast--success { border-color: color-mix(in srgb, var(--success) 45%, var(--border)); }
.page-toast--error { border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); }
.page-toast.is-going { opacity: 0; transform: translateY(0.5rem); }

} /* @layer legacy */
/*
 * The Members half of the admin area.
 *
 * Users, Groups & permissions, Group banners, Submission permissions and Support tickets
 * are one subject seen five ways, so they share one vocabulary here: a screen heading with
 * its own search or its own button, a tab strip, a banner chip, a rule row and a ticket
 * queue. Writing them once is what keeps the five screens from drifting into five slightly
 * different versions of the same table.
 */

/* A screen's own heading, with its button or its search box beside it. */
.admin-screen-head {
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;
}
.admin-screen-head h2 { margin: 0; }
.admin-screen-head p { max-width: 52rem; }

/*
 * A heading that follows a card keeps the air above it.
 *
 * The block above used to reset the heading's margins with the `margin: 0 0 1rem` shorthand.
 * Because it is written **later** in this sheet than both `.admin-screen-head--tight` (beside the
 * rule that declares it) and the column's own `.admin-grid > * + *` rhythm, it silently won the
 * top margin for every heading — and a mid-screen heading therefore sat flush against the card
 * above it. The Add-ons screen's "Add-on settings" was the report: the heading and the card it
 * introduces read as one torn panel.
 *
 * The fix is in two parts. The reset above now only sets what it means to set, so it cannot
 * cancel a top margin it was never about; and the two rules below name the step a `--tight`
 * heading and a card-to-heading pair actually want, at a specificity nothing later in the sheet
 * overrides. Nothing moves inside a `.grid-2`, a table or a dialog: this is adjacency in the
 * admin column, the same rule the rest of the spacing here follows.
 */
.admin-screen-head--tight { margin-top: 1.6rem; }
.admin-grid > .card + .admin-screen-head,
.admin-grid > .card + .admin-screen-head--tight { margin-top: 1.5rem; }
.admin-grid > .admin-screen-head + .card { margin-top: 1.5rem; }
.admin-screen-search { display: flex; align-items: center; gap: 0.4rem; margin: 0; flex-wrap: wrap; }
.admin-screen-search input[type="search"] { width: 15rem; }
.admin-screen-search .row-action-icon { width: 0.85rem; height: 0.85rem; }

/* A table wide enough to need its own scroll on a narrow window. */
.table-scroll { overflow-x: auto; }

/*
 * A banner: a group's coloured strip with its wording on it, or the picture an
 * administrator uploaded instead. The colour arrives as a custom property so the chip is
 * tinted from one place, the same trick the topic labels use.
 */
.member-banner {
    display: inline-flex; align-items: center; gap: 0.3rem;
    padding: 0.12rem 0.5rem; border-radius: 999px;
    background: color-mix(in srgb, var(--banner-color, #4f5ee8) 22%, transparent);
    border: 1px solid color-mix(in srgb, var(--banner-color, #4f5ee8) 55%, transparent);
    color: var(--banner-color, #4f5ee8);
    font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
    white-space: nowrap; line-height: 1.5;
}
.member-banner--large { font-size: 0.8125rem; padding: 0.25rem 0.7rem; }
.member-banner-icon { width: 0.7rem; height: 0.7rem; }
.member-banner--image { padding: 0; border: 0; background: none; }
.member-banner--image img { display: block; height: 1.5rem; width: auto; max-width: 12rem; object-fit: contain; border-radius: 999px; }
.member-banner--large.member-banner--image img { height: 2rem; }
.member-banner-preview {
    display: flex; align-items: center; min-height: 2.4rem;
    padding: 0.35rem 0.6rem; border: 1px dashed var(--border); border-radius: var(--radius);
}
.member-banner-table td { vertical-align: middle; }

/* A quiet chip for "built-in", "staff powers", "you". */
.member-badge {
    display: inline-flex; align-items: center; gap: 0.25rem;
    padding: 0.05rem 0.45rem; border-radius: 999px;
    border: 1px solid var(--border); background: var(--surface-2);
    color: var(--muted); font-size: 0.6875rem; font-weight: 600; line-height: 1.6;
    white-space: nowrap;
}
.member-badge-icon { width: 0.65rem; height: 0.65rem; }

/* A member's face, handle and a small note, in one cell. */
.member-cell { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.member-cell a { font-weight: 600; }
.member-table td { vertical-align: middle; }
.member-table .row-actions { justify-content: flex-end; }
.row-action-icon { width: 0.95rem; height: 0.95rem; }

/* The suspension switch. A button, so it works with scripting off. */
.member-status { display: flex; align-items: center; gap: 0.5rem; margin: 0; }
.switch {
    position: relative; display: inline-block; flex-shrink: 0;
    width: 2.25rem; height: 1.25rem; padding: 0;
    border: 1px solid var(--border); border-radius: 999px;
    background: var(--surface-2); cursor: pointer;
}
.switch.is-on { background: var(--accent); border-color: var(--accent); }
.switch-knob {
    position: absolute; top: 0.11rem; left: 0.11rem;
    width: 0.9rem; height: 0.9rem; border-radius: 999px;
    background: #fff; transition: transform 0.15s ease;
}
.switch.is-on .switch-knob { transform: translateX(1rem); }

/* The ban-by-email / ban-by-IP toolbar over the member table. */
.member-toolbar { display: flex; flex-wrap: wrap; gap: 1.25rem; }
.member-toolbar-field { display: flex; align-items: center; gap: 0.4rem; margin: 0; flex-wrap: wrap; }
.member-toolbar-field input { width: 15rem; }

/* The Groups screen's two tabs, in the React build's shape. */
.member-tabs { display: flex; gap: 0.4rem; margin: 0 0 1rem; flex-wrap: wrap; }
.member-tab {
    display: inline-flex; align-items: center; gap: 0.4rem;
    padding: 0.35rem 0.75rem; border-radius: 999px;
    border: 1px solid var(--border); background: var(--surface);
    color: var(--muted); font-size: 0.8125rem; font-weight: 600; text-decoration: none;
}
.member-tab:hover { color: var(--text); border-color: var(--accent); text-decoration: none; }
.member-tab.is-active { background: var(--surface-2); border-color: var(--accent); color: var(--text); }
.member-tab-icon { width: 0.85rem; height: 0.85rem; }

/* One group, in the Groups tab. */
.member-group-card { padding: 1rem 1.1rem; }
.member-group-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.member-group-ident { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.member-group-name { font-weight: 600; font-size: 1rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.member-group-actions { display: flex; align-items: center; gap: 0.4rem; }

/* Members of a group, inside its dialog. */
.member-add-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0 0 0.75rem; }
.member-pill-list { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.member-pill {
    display: inline-flex; align-items: center; gap: 0.35rem;
    padding: 0.15rem 0.55rem; border: 1px solid var(--border); border-radius: 999px;
    background: var(--surface-2); font-size: 0.8125rem;
}
.member-pill form { margin: 0; }
.member-pill-icon { width: 0.75rem; height: 0.75rem; }

/* Category access: a banner, a name, a level. */
.member-access-list { display: flex; flex-direction: column; gap: 0.5rem; margin: 0.9rem 0; }
.member-access-row {
    display: flex; align-items: center; gap: 0.75rem;
    padding: 0.5rem 0.65rem; border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface-2); flex-wrap: wrap;
}
.member-access-name { flex: 1; min-width: 8rem; font-weight: 600; }
.member-access-form { display: flex; align-items: center; gap: 0.4rem; margin: 0; }
.member-access-form select { width: auto; }
.member-access-foot {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
    margin: 0.75rem 0 0; padding-top: 0.75rem; border-top: 1px solid var(--border);
}
.member-access-default {
    padding: 0.15rem 0.55rem; border-radius: 999px;
    background: var(--surface-2); border: 1px solid var(--border);
    font-size: 0.8125rem; font-weight: 600;
}
.member-access-foot select { width: auto; }

/* Submission permissions: the three board defaults, side by side. */
.area-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; }
.area-tile { padding: 0.85rem 0.9rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }

/* One group's rules, and one member's, in the same shape. */
.group-rule-block { padding: 0.9rem 0; border-bottom: 1px solid var(--border); }
.group-rule-block:last-of-type { border-bottom: 0; }
.group-rule-head { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.6rem; font-weight: 600; flex-wrap: wrap; }
.rule-row {
    display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
    padding: 0.4rem 0;
}
.rule-label { min-width: 10rem; font-weight: 600; font-size: 0.875rem; }
.rule-row select { width: auto; min-width: 13rem; }
.rule-approval { display: flex; align-items: center; gap: 0.35rem; font-size: 0.8125rem; color: var(--muted); }
.rule-approval input { width: auto; }
.rule-chip {
    display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
    background: var(--surface-2); border: 1px solid var(--border);
    font-size: 0.6875rem; color: var(--muted); white-space: nowrap;
}
.rule-chip.is-own { border-color: var(--accent); color: var(--accent); }

/* Support tickets: the queue beside the conversation. */
.ticket-layout { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 1rem; align-items: start; }
.ticket-list { display: flex; flex-direction: column; gap: 0.4rem; }
.ticket-item {
    display: block; padding: 0.6rem 0.7rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface); color: var(--text); text-decoration: none;
}
.ticket-item:hover { border-color: var(--accent); text-decoration: none; }
.ticket-item.is-active { border-color: var(--accent); background: var(--surface-2); }
.ticket-item-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.ticket-item-subject { font-weight: 600; font-size: 0.9rem; }
.ticket-item-meta { margin-top: 0.15rem; font-size: 0.75rem; color: var(--muted); }
.ticket-state {
    flex-shrink: 0; padding: 0.05rem 0.45rem; border-radius: 999px;
    font-size: 0.6875rem; font-weight: 600;
    border: 1px solid var(--border); color: var(--muted); background: var(--surface-2);
}
.ticket-state--open { border-color: var(--accent); color: var(--accent); }
.ticket-state--closed { opacity: 0.75; }
.ticket-detail-head {
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 1rem; flex-wrap: wrap; margin: 0 0 0.9rem;
}
.ticket-detail-head h2 { margin: 0; }
.ticket-detail-head form { margin: 0; }
.ticket-detail-head select { width: auto; }
.ticket-thread { display: flex; flex-direction: column; gap: 0.75rem; margin: 0 0 1.1rem; }
.ticket-message { padding: 0.75rem 0.85rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
.ticket-message header { display: flex; align-items: center; gap: 0.45rem; margin: 0 0 0.45rem; }
.ticket-reply { margin: 0; }
.ticket-reply .button { margin-top: 0.5rem; }

/* The editor's own action row, in the banner dialog. */
.form-actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
.form-actions .row-action-icon { width: 0.85rem; height: 0.85rem; }

.field-inline { display: flex; flex-direction: column; gap: 0.25rem; }
.field-inline select { width: auto; }

@media (max-width: 900px) {
    .ticket-layout { grid-template-columns: minmax(0, 1fr); }
    .rule-label { min-width: 0; }
}

/*
 * ---------- two-factor authentication ----------
 *
 * The Settings card. Three states share one layout: the forms sit side by side on a wide
 * screen, the setup key is set apart so it can be read off the screen and typed, and the
 * recovery codes are laid out in a grid — they are copied out once, and a monospaced list
 * is what makes that easy.
 */
.two-factor-actions, .two-factor-setup {
    display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
    align-items: end; margin-top: 1rem;
}
.two-factor-actions > form, .two-factor-key { min-width: 0; }
.two-factor-actions > form { display: grid; gap: 0.5rem; justify-items: start; }
.two-factor-actions > form label { margin: 0; }
.two-factor-actions > form input { width: 100%; }
.two-factor-key { display: grid; gap: 0.5rem; justify-items: start; }
.two-factor-key code {
    /* Wide tracking, because this is copied by eye rather than with a clipboard. */
    font-size: 1rem; letter-spacing: 0.08em; padding: 0.6rem 0.85rem;
    background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius);
    overflow-wrap: anywhere;
}
/*
 * The setup code itself, drawn by `assets/qr.js` into the holder beside the setup key.
 * The holder collapses while it is empty — either because the script has not run yet or
 * because the page is being read without scripting — so the key and the link keep the room
 * they had before there was a picture.
 */
.two-factor-qr { display: grid; gap: 0.5rem; justify-items: start; }
.two-factor-qr:empty { display: none; }
.two-factor-qr svg {
    display: block; width: 100%; max-width: 13rem; height: auto;
    background: #ffffff; padding: 0.5rem; border: 1px solid var(--border);
    border-radius: var(--radius);
}
.recovery-codes {
    display: grid; gap: 0.4rem 1rem; margin: 1rem 0 0; padding: 0;
    grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); list-style: none;
}
.recovery-codes code {
    display: block; padding: 0.4rem 0.6rem; text-align: center; letter-spacing: 0.06em;
    background: var(--surface-2); border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
}
/* "On" beside the card's heading — the accent, the same as every other live state. */
.pill--on { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }

@media (max-width: 900px) {
    .two-factor-actions, .two-factor-setup { grid-template-columns: minmax(0, 1fr); }
}

/*
 * ---------- admin screen chrome ----------
 *
 * The pieces the React build's admin screens all share. They live together because they
 * are one layout language: a heading line, an action row, a tab strip, a table mark and
 * a filter bar that any screen in the bar can use.
 */

/* Which add-on owns the options below — name, version, state, and a way into Addons. */
.admin-addon-line {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
    margin: 0.9rem 0 0; font-size: 0.8rem;
}
.admin-addon-line code { font-size: 0.78rem; }
.admin-addon-line a { margin-left: auto; font-size: 0.8rem; }

/* Save, Reset defaults, and the sentence that says where the values are read. */
.admin-save-row {
    display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
    margin-top: 1.25rem;
}
.admin-save-row .muted { margin-left: auto; max-width: 34rem; }

/*
 * The Menus editors.
 *
 * A menu is rows, not a card of fields, so each row is a grid: the label, the URL, who may see
 * it, the two flags and the move controls. The grid collapses to one column on a narrow screen so
 * a row stays readable rather than squeezing the URL against the controls. The generated add-on
 * rows carry `--generated` and are drawn muted, because they are shown, not edited.
 */
.menus-editor .menu-edit-block { margin-top: 1.5rem; }
.menu-edit-rows { display: flex; flex-direction: column; gap: 0.4rem; }
.menu-edit-row {
    display: grid; gap: 0.4rem; align-items: center; padding: 0.45rem 0.55rem;
    border: 1px solid var(--border); border-radius: 0.6rem; background: var(--surface-2, var(--card));
    grid-template-columns: 1.4rem minmax(6rem, 12rem) minmax(8rem, 1fr) minmax(7rem, 9rem) auto auto auto;
}
.menu-edit-row--generated { opacity: 0.75; background: transparent; border-style: dashed; }
.menu-edit-handle { color: var(--muted); text-align: center; cursor: grab; font-size: 0.8rem; }
.menu-edit-input { width: 100%; min-width: 0; padding: 0.35rem 0.5rem; border: 1px solid var(--border); border-radius: 0.4rem; background: var(--field, transparent); color: inherit; font: inherit; font-size: 0.82rem; }
.menu-edit-static { display: inline-block; font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menu-edit-flag { display: flex; align-items: center; gap: 0.3rem; font-size: 0.74rem; color: var(--muted); white-space: nowrap; }
.menu-edit-controls { display: flex; gap: 0.15rem; justify-content: flex-end; }
.menu-edit-badge { font-size: 0.68rem; color: var(--muted); font-family: var(--mono); white-space: nowrap; }
.menu-edit-sections { display: flex; flex-direction: column; gap: 0.9rem; }
/* The header's list mixes plain rows with section cards, so it is spaced a little tighter than
   the footer's, which is all cards. */
.menu-edit-sections[data-menu-group="header"] { gap: 0.5rem; }
.menu-edit-section { padding: 0.7rem; }
.menu-edit-section-head { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
.menu-edit-heading { flex: 1 1 auto; min-width: 0; padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 0.4rem; background: var(--field, transparent); color: inherit; font: inherit; font-weight: 600; }
/*
 * A node is a row plus the children dropped under it. The child list is indented under a rule so
 * a submenu reads as one, and an empty list is not drawn at all — every node has a drop target,
 * but only a node that owns children shows one. The drag states mark where a drop will land:
 * `drop-child` nests, `drop-before`/`drop-after` reorder at that edge.
 */
.menu-edit-node { display: flex; flex-direction: column; }
.menu-edit-children {
    display: flex; flex-direction: column; gap: 0.4rem;
    margin: 0.4rem 0 0.15rem 1.6rem; padding-left: 0.85rem;
    border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
}
.menu-edit-children:empty { display: none; }
.menu-edit-row.is-dragging { opacity: 0.45; }
.menu-edit-row.is-dragging .menu-edit-handle { cursor: grabbing; }
.menu-edit-node.drop-child > .menu-edit-row { outline: 2px dashed var(--accent); outline-offset: 2px; }
.menu-edit-node.drop-before > .menu-edit-row { box-shadow: 0 -2px 0 0 var(--accent); }
.menu-edit-node.drop-after > .menu-edit-row { box-shadow: 0 2px 0 0 var(--accent); }
@media (max-width: 820px) {
    .menu-edit-row { grid-template-columns: 1.2rem minmax(0, 1fr); }
    .menu-edit-row > .menu-edit-input,
    .menu-edit-row > .menu-edit-flag,
    .menu-edit-row > .menu-edit-controls,
    .menu-edit-row > .menu-edit-static { grid-column: 2; }
}

/*
 * The **Feed importer**'s rows.
 *
 * A feed is a card whose head answers the questions an owner actually asks of it — its name, its
 * address, whether it is switched on, when it last ran and what happened — with **Run now** and
 * **Remove** on the right, above the fields that configure it. The fields are a two-column grid
 * that folds to one on a narrow screen, and the cron lines are printed in a `<pre>` so a command
 * copied out of the screen keeps its shape instead of being reflowed into prose.
 */
.rss-feed-card { margin-top: 0.75rem; }
.rss-feed-head { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-start; justify-content: space-between; }
.rss-feed-head > div { min-width: 0; }
.rss-feed-actions { display: flex; gap: 0.4rem; flex: 0 0 auto; }
.rss-feed-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 0.35rem 1rem; margin: 0.75rem 0 0; }
.rss-feed-fields .field { margin: 0; }
.rss-cron { margin: 0.15rem 0 0.6rem; padding: 0.55rem 0.7rem; overflow-x: auto; background: var(--surface-2); border: 1px solid var(--border); border-radius: 0.5rem; font-size: 0.78rem; }
.rss-cron code { display: block; white-space: pre; }

/*
 * The **Package Manager** (Admin → System).
 *
 * A release is a card that answers, in order, what an administrator asks of it: the type and
 * version at the top, the facts of the review (what the archive changes, what its checksums
 * proved), the blockers that stop it, and the Apply / Remove controls on the right. The
 * overview pairs the two versions side by side so "what am I running" and "what is available"
 * read as one comparison, and the review facts are a definition list so a long file count wraps
 * without pushing the card wider than its column.
 */
.pm-overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; margin-top: 0.75rem; }
.pm-version { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.85rem 1rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); }
.pm-version-label { font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.pm-version-value { font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.pm-version-value--new { color: var(--accent); }
.pm-available { margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid var(--border); }
.pm-notes { margin: 0.5rem 0; padding: 0.7rem 0.9rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 0.5rem; font-size: 0.85rem; line-height: 1.6; }
.pm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: 0.35rem 1rem; margin: 0.6rem 0 0; }
.pm-facts > div { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
.pm-facts dt { font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.pm-facts dd { margin: 0; font-size: 0.85rem; overflow-wrap: anywhere; }
.pm-blocker { color: var(--danger); font-size: 0.82rem; }
.pm-recovery { color: var(--accent); font-size: 0.78rem; }
.pm-package { margin-top: 0.9rem; }
.pm-package-head { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-start; justify-content: space-between; }
.pm-package-head > div { min-width: 0; }
.pm-package-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; flex: 0 0 auto; }
.pm-package-actions form { margin: 0; }
.pm-sha { font-size: 0.78rem; }
.pm-edit { margin-top: 0.75rem; }
.pm-edit > summary { cursor: pointer; font-weight: 600; font-size: 0.85rem; }
.pm-edit-form { margin-top: 0.75rem; }
.pm-upload-form .form-row { margin-bottom: 0.75rem; }
.pm-backup-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; justify-content: space-between; padding: 0.6rem 0; border-top: 1px solid var(--border); }
.pm-backup-row > div { min-width: 0; }
.pm-history td { vertical-align: top; }
.pm-pager { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.9rem; }
/* The in-page jump row, so the four places an administrator needs are one click apart. */
.pm-jump { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; margin: 0.9rem 0 0; padding-top: 0.75rem; border-top: 1px solid var(--border); }
/* The first pending update sits directly under the card's own heading, so it does not need the
   separator line that divides it from the update above it. */
.pm-pending .pm-available:first-of-type { margin-top: 0.35rem; padding-top: 0; border-top: 0; }
.pm-review { margin: 0; }
.pm-review > summary { cursor: pointer; }
.pm-review[open] > summary { margin-bottom: 0.5rem; }
.pm-blocked { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.75rem; }
.pm-blocked > summary { cursor: pointer; font-weight: 600; font-size: 0.85rem; }
.pm-blocked-row { margin-top: 0.75rem; padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
.pm-publish { margin-top: 1rem; }
.pm-publish > summary { cursor: pointer; font-weight: 600; font-size: 0.85rem; }
.pm-publish[open] > summary { margin-bottom: 0.75rem; }
/* The install card is the screen's first action, so its own padding is the padding its fields
   and its button need — no extra wrapper, no field that crowds the button. */
.pm-install .pm-upload-form { margin-top: 0.5rem; }
.pm-install .pm-upload-form .form-row:last-of-type { margin-bottom: 1rem; }

/* The two books, one at a time — Orders, refunds & sales and Balances & payouts. */
.admin-area-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.chip-select {
    padding: 0.35rem 0.8rem; border-radius: 99px; font-size: 0.78rem; font-weight: 600;
    background: var(--surface-2); color: var(--muted); border: 1px solid var(--border);
}
.chip-select:hover { text-decoration: none; color: var(--text); }
.chip-select.is-active { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }

/*
 * A row's own mark, ahead of its name in a table.
 *
 * The dot and the glyph sit in a fixed-width box so every name in the column starts at the
 * same place whether or not the row above it had a picture.
 */
.admin-row-mark { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; margin-right: 0.4rem; vertical-align: -0.15rem; }
.admin-row-mark img { border-radius: 3px; object-fit: cover; }
/*
 * A post's cover picture beside its title, in the admin's list rows.
 *
 * The React row is an `img` at `size-10` — 2.5rem, rounded — beside a two-line block of the
 * title over the category, and the pair has to be able to shrink: a long title in a narrow
 * column otherwise pushes the table wider than its card.
 */
.admin-row-post { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.admin-row-post img { width: 2.5rem; height: 2.5rem; border-radius: calc(var(--radius) - 6px); object-fit: cover; flex-shrink: 0; }
.admin-row-post-text { display: flex; flex-direction: column; min-width: 0; }
.admin-row-post-text a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-dot { width: 0.6rem; height: 0.6rem; border-radius: 99px; display: inline-block; }

/* Resource management: the filter bar. */
.admin-filters { margin-bottom: 1rem; }
.admin-filter-grid { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; align-items: flex-end; }
.admin-filter-grid .field { flex: 1 1 12rem; min-width: 9rem; margin: 0; }
.admin-filter-grid .field label { font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.admin-filter-check { margin-bottom: 0.35rem; }
.admin-filter-actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.empty-note {
    padding: 1.5rem 1rem; text-align: center;
    border: 1px dashed var(--border); border-radius: var(--radius);
}

/* One listing on that screen, with its controls underneath. */
.resource-row {
    padding: 1rem 0; border-top: 1px solid var(--border);
}
.resource-row:first-of-type { border-top: 0; }
.resource-row-head { display: flex; align-items: center; gap: 0.75rem; }
.resource-row-head h3 { margin: 0; font-size: 1rem; min-width: 0; flex: 1; }
.resource-row-head h3 a { color: var(--text); }
.resource-row-meta { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0 0.35rem; }
.resource-row-by { margin: 0 0 0.75rem; }
.resource-row-form { display: grid; gap: 0.6rem; }
.resource-row-form .field { margin: 0; }
.resource-row-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.resource-row-areas { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; }
.resource-row-areas .check { margin: 0; }
/* Permanent deletion: its own form, under the reversible controls, set apart because the one
   thing it must never look like is another button in the row above it. */
.resource-purge {
    display: flex; align-items: center; justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
    margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed var(--border);
}
.resource-purge .check { margin: 0; color: var(--muted); font-size: 0.813rem; }
.resource-purge-hidden, .resource-purge-blocked { margin: 0.75rem 0 0; text-align: right; }
/* A listing that is not publicly visible, in the list's own colours. */
.pill--warn { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }

@media (max-width: 700px) {
    .admin-save-row .muted { margin-left: 0; }
    .admin-filter-actions { margin-left: 0; }
}

/*
 * ---------- the page builder ----------
 *
 * Two columns: a palette that stays put while the canvas scrolls, and the canvas itself. The
 * palette is a fixed 296px because every widget card in it is the same shape — a glyph, a name,
 * a sentence — and a column that changes width as you scroll is a column that keeps re-wrapping
 * the sentences under your eyes.
 */
.builder {
    display: grid; gap: 1rem; align-items: start;
    grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 1000px) {
    .builder { grid-template-columns: 296px minmax(0, 1fr); }
    .builder-panel { position: sticky; top: 5rem; }
}
.builder-panel { min-width: 0; }
.builder-palette {
    display: flex; flex-direction: column; gap: 0.6rem;
    /* The palette is the one place on the board where a smaller type size is right: it is a
       list of twenty things you scan, not read. */
    font-size: 0.82rem;
}
.builder-palette .card-head { margin-bottom: 0; }
.builder-palette .card-head h2 { font-size: 0.95rem; }

.builder-search { position: relative; }
.builder-search input { width: 100%; padding-left: 2rem; }
.builder-search::before {
    content: "\f002"; /* Font Awesome magnifier — the same set every other glyph comes from. */
    font-family: "Font Awesome 6 Free", "Font Awesome 6 Pro", sans-serif; font-weight: 900;
    position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%);
    color: var(--muted); font-size: 0.75rem; pointer-events: none;
}

/* One folding band of the palette: a chevron, a label, then its body. */
.builder-fold.is-closed .builder-fold-body { display: none; }
.builder-fold.is-closed .builder-fold-head > i { transform: rotate(-90deg); }
.builder-fold-head {
    display: flex; align-items: center; gap: 0.4rem; width: 100%;
    background: none; border: 0; padding: 0.35rem 0.15rem; cursor: pointer;
    color: var(--muted); font: inherit; font-weight: 600; text-align: left;
}
.builder-fold-head:hover { color: var(--text); }
.builder-fold-head > i { transition: transform 0.15s ease; }
/* Pages and the three palette bands are headings; "Page settings" is a control, so it is not
   shouted in capitals. */
.builder-fold-head > span { letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.7rem; }
.builder-fold:not(.builder-group) .builder-fold-head[data-builder-fold-toggle] > span { text-transform: none; letter-spacing: 0; font-size: 0.82rem; }

.builder-fold-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.15rem 0; }
.builder-page-select { width: 100%; }
.builder-new-page { width: 100%; justify-content: center; }

/*
 * Every custom page, with the way into editing it on the frontend beside it.
 *
 * The picker above loads one page into the canvas; this is the list an owner reaches for when
 * they know which page they want to work on, so the "Edit on the page" link here opens the
 * frontend editor for **that** row rather than for whichever page happens to be loaded.
 */
.builder-page-list { list-style: none; margin: 0.2rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
.builder-page-list li {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
    padding: 0.35rem 0.45rem; border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px);
}
.builder-page-list li.is-active { border-color: var(--accent); background: var(--accent-soft); }
.builder-page-list .builder-page-open {
    flex: 1; min-width: 6rem; font-size: 0.82rem; font-weight: 600;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.builder-page-list .button { flex: none; }

/* Where a widget goes when it leaves the page. */
.builder-trash {
    display: flex; flex-direction: column; align-items: center; gap: 0.15rem;
    padding: 0.85rem 0.6rem; margin: 0.15rem 0; text-align: center;
    border: 1px dashed var(--border); border-radius: var(--radius);
}
.builder-trash.is-over { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); }
.builder-trash-title { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.builder-trash-hint { font-size: 0.72rem; color: var(--muted); }

/* The widget cards: glyph, name, one sentence, star. */
.builder-widget-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
.builder-widget-card {
    display: flex; align-items: flex-start; gap: 0.5rem;
    padding: 0.5rem 0.55rem; cursor: grab;
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); background: var(--surface);
}
.builder-widget-card:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
.builder-widget-card.is-dragging { opacity: 0.45; }
.builder-widget-glyph { display: inline-flex; align-items: center; color: var(--accent); margin-top: 0.1rem; }
.builder-widget-text { min-width: 0; flex: 1; display: grid; gap: 0.1rem; }
.builder-widget-text b { font-size: 0.82rem; font-weight: 600; }
.builder-widget-text small { color: var(--muted); font-size: 0.72rem; line-height: 1.35; }
.builder-star, .builder-widget-card .builder-star {
    background: none; border: 0; padding: 0.1rem; cursor: pointer; color: var(--muted); flex-shrink: 0;
}
.builder-star:hover { color: var(--accent); }
.builder-star.is-on { color: var(--accent); }
.builder-star.is-on > i { font-weight: 900; }
.builder-nomatch { margin: 0.4rem 0; text-align: center; }

.builder-finish {
    width: 100%; justify-content: center; margin-top: 0.5rem;
    background: var(--success); border-color: var(--success); color: #04231a;
}
.builder-finish:hover { background: color-mix(in srgb, var(--success) 85%, #000); }

/* The canvas. */
.builder-canvas { min-width: 0; display: grid; gap: 0.75rem; }
.builder-canvas-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.builder-canvas-head h2 { margin: 0; font-size: 1.35rem; }
.builder-canvas-actions { display: flex; align-items: center; gap: 0.4rem; margin-left: auto; flex-wrap: wrap; }
.builder-canvas-note { margin: 0; }
.builder-legacy-note {
    margin: 0; padding: 0.5rem 0.7rem;
    border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: calc(var(--radius) - 2px);
}
.builder-zones { display: grid; gap: 1rem; grid-template-columns: minmax(0, 1fr); align-items: start; }
@media (min-width: 1200px) { .builder-zones { grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); } }
.builder-zone {
    border: 1px solid var(--border); border-radius: var(--radius);
    padding: 0.5rem; background: color-mix(in srgb, var(--surface-2) 30%, transparent);
}
.builder-zone-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 0.25rem 0.5rem; }
.builder-zone-label { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.builder-zone-hint { font-size: 0.72rem; color: var(--muted); }
.builder-drop {
    display: grid; gap: 0.5rem; min-height: 12rem; align-content: start;
    padding: 0.6rem; border: 1px dashed var(--border); border-radius: calc(var(--radius) - 2px);
}
.builder-drop.is-over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.builder-drop-empty {
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.6rem;
    min-height: 10rem; color: var(--muted); font-size: 0.82rem; text-align: center;
}
.builder-drop-circle {
    display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: 99px;
    border: 1px solid var(--border); color: var(--muted);
}
/* A widget that is on the page. Drawn as the card it will become, not as a row in a list. */
.builder-block {
    border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); background: var(--surface);
}
.builder-block[open] { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
.builder-block.is-dragging { opacity: 0.45; }
.builder-block > summary {
    display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.6rem;
    cursor: pointer; list-style: none;
}
.builder-block > summary::-webkit-details-marker { display: none; }
/*
 * The **only** drag handle on a widget.
 *
 * The card used to be `draggable` as a whole, which put a draggable ancestor around the rich
 * editor inside it — and a draggable element swallows the caret and the Enter key in a
 * `contenteditable` descendant, so text could not be selected and every line ran together.
 * The handle says "grab" and nothing else in the card does.
 */
.builder-block-grip { color: var(--muted); cursor: grab; padding: 0 0.1rem; border-radius: 0.25rem; }
.builder-block-grip:hover { color: var(--text); background: var(--surface-2); }
.builder-block-grip:active { cursor: grabbing; }
.builder-block-glyph { display: inline-flex; align-items: center; color: var(--accent); }
.builder-block-text { min-width: 0; flex: 1; display: grid; gap: 0.05rem; }
.builder-block-text b { font-size: 0.82rem; }
.builder-block-text small { color: var(--muted); font-size: 0.72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.builder-block-form {
    display: grid; gap: 0.6rem; padding: 0.6rem; border-top: 1px solid var(--border);
}
.builder-block-form .field { margin: 0; }
.builder-block-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }

/*
 * ---------- the frontend page editor ----------
 *
 * The same widget machinery as the admin builder, moved onto the page itself: a panel that
 * slides in **from the left** holds the palette, and the page's own content becomes the canvas
 * while editing. With the editor closed the page is a page and the panel is off-screen; with it
 * open the canvas replaces the page and the palette sits at the left edge. `.fpe-view` is what a
 * reader normally sees and `.fpe-canvas` is the editable one — only one is ever visible.
 */
.fpe { position: relative; }
.fpe-view .built-page { margin: 0; }
.fpe-launch { margin-top: 0.75rem; }
/*
 * Why the editor is not available.
 *
 * Shown in place of the panel when Demo Mode is what is refusing the writes, so a read-only
 * demonstration explains itself instead of offering controls that silently do nothing.
 */
.fpe-disabled {
    margin-top: 1rem; padding: 0.9rem 1rem; border: 1px solid var(--border);
    border-left: 3px solid var(--accent); border-radius: var(--radius); background: var(--surface-2);
}
.fpe-disabled b { display: inline-flex; align-items: center; gap: 0.4rem; }
.fpe-disabled p { margin: 0.4rem 0 0; font-size: 0.88rem; line-height: 1.6; }
.fpe-disabled .row-action-icon { width: 1rem; height: 1rem; }

.fpe-scrim {
    position: fixed;
    inset: 0;
    z-index: 70;
    background: color-mix(in srgb, #000 48%, transparent);
}
.fpe-scrim[hidden] { display: none; }

.fpe-panel {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    z-index: 80;
    width: min(22rem, 92vw);
    display: flex;
    flex-direction: column;
    background: var(--surface);
    color: var(--text);
    border-right: 1px solid var(--border);
    box-shadow: var(--shadow);
    overflow-y: auto;
    animation: fpe-slide-in 0.18s ease-out;
}
/* `[hidden]` is written out: the element declares its own `display`, so the attribute alone
   would be out-ranked and the closed panel would paint over the page. */
.fpe-panel[hidden] { display: none; }
@keyframes fpe-slide-in {
    from { transform: translateX(-100%); }
    to { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) { .fpe-panel { animation: none; } }

.fpe-panel-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.75rem 1rem;
    border-bottom: 1px solid var(--border);
    position: sticky;
    top: 0;
    background: var(--surface);
    z-index: 1;
}
.fpe-panel-body { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.85rem 1rem 1.5rem; }

.fpe-canvas { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; }
.fpe-canvas[hidden] { display: none; }
.fpe.is-editing .fpe-view,
.fpe.is-editing .fpe-launch { display: none; }
.fpe-canvas-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.fpe-cover { margin-top: 0.5rem; }
.fpe-cover-preview {
    max-width: 100%;
    border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px);
}

/*
 * Preview of the unsaved draft.
 *
 * A fixed overlay over the page being edited, filled by script from the `page_preview` action's
 * answer. It replaces a link to the published page, which previewed the board's stored state
 * rather than the edit in front of the person making it. It lives in the page rather than in a
 * bare new tab so the board's own stylesheet already applies to what it draws — a preview in an
 * unstyled tab is a preview of a page nobody has.
 */
.fpe-preview {
    position: fixed;
    inset: 0;
    z-index: 90;
    display: flex;
    flex-direction: column;
    gap: 0;
    padding: 1rem;
    background: color-mix(in srgb, #000 56%, transparent);
}
.fpe-preview[hidden] { display: none; }
.fpe-preview-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    padding: 0.6rem 0.9rem;
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    border-bottom: 0;
    border-radius: var(--radius) var(--radius) 0 0;
}
.fpe-preview-head b { display: inline-flex; align-items: center; gap: 0.4rem; }
.fpe-preview-body {
    flex: 1;
    overflow: auto;
    padding: 1rem;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 0 0 var(--radius) var(--radius);
}
.fpe-preview-doc {
    max-width: 62rem;
    margin: 0 auto;
    padding: 1.25rem;
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: var(--radius);
}
.fpe-preview-flag {
    display: flex;
    align-items: center;
    gap: 0.45rem;
    margin: 0 0 1rem;
    padding: 0.5rem 0.75rem;
    font-size: 0.85rem;
    background: var(--accent-soft);
    color: var(--accent-soft-fg);
    border-radius: calc(var(--radius) - 4px);
}
.fpe-preview-flag .row-action-icon { width: 1rem; height: 1rem; flex: none; }
.builder-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }

/*
 * The banner a custom page may carry above its widgets: a cover, a kicker and a description,
 * each its own column so the editor edits them apart.
 */
.page-banner { display: flex; flex-direction: column; gap: 0.35rem; margin: 0 0 1rem; }
.page-banner-cover {
    display: block;
    width: 100%;
    max-height: 22rem;
    object-fit: cover;
    border: 1px solid var(--border);
    border-radius: calc(var(--radius) - 4px);
}
.page-banner-kicker {
    margin: 0;
    font-size: 0.688rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--accent);
}
.page-banner-text { margin: 0; font-size: 1rem; color: var(--muted); }

/*
 * ---------- knowledge base ----------
 *
 * Two cards: categories, then articles. The category form is a 2x2 grid of the four fields the
 * React screen has, and the article editor is a view of its own, so nothing here is drawn on
 * top of the lists.
 */
.kb-cat-form { display: grid; gap: 0.75rem; margin-bottom: 1rem; }
.kb-cat-grid { display: grid; gap: 0.6rem; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 800px) { .kb-cat-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
.kb-cat-actions { display: flex; align-items: center; gap: 0.5rem; }
.kb-upload { cursor: pointer; }
.kb-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.4rem; }
.kb-list > li {
    display: flex; align-items: center; gap: 0.75rem;
    padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius);
}
.kb-mark {
    display: grid; place-items: center; flex-shrink: 0; width: 2rem; height: 2rem;
    border-radius: 8px; overflow: hidden;
    background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent);
}
.kb-mark img { width: 100%; height: 100%; object-fit: cover; }
.kb-main { min-width: 0; flex: 1; display: grid; gap: 0.1rem; }
.kb-name { font-weight: 600; overflow-wrap: anywhere; }
.kb-article-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
.kb-article-grid { display: grid; gap: 0.75rem; grid-template-columns: minmax(0, 1fr) minmax(0, 10rem); }
.icon-btn--danger { color: var(--danger); }
.icon-btn--danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); }
.icon-btn.is-on { color: var(--accent); }
.kb-heading-mark { display: inline-flex; align-items: center; margin-right: 0.4rem; vertical-align: -0.15rem; color: var(--accent); }
.kb-heading-mark img { border-radius: 4px; object-fit: cover; }
.kb-index { margin: 0; padding-left: 1.1rem; display: flex; flex-direction: column; gap: 0.6rem; }
.kb-index li.is-featured > a { color: var(--accent); }
.article-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }

/*
 * ---------- page widgets on the public side ----------
 *
 * A built page is a stack of blocks in the content column. Each widget carries its own
 * spacing so the gap between two widgets is the same as the gap between two cards.
 */
.built-page { display: grid; gap: 1rem; }
.block-heading h1 { margin: 0; }
.block-heading h2 { margin: 0; font-size: 1.25rem; }
.block-heading h3 { margin: 0; font-size: 1.05rem; }
.block-heading p { margin: 0.35rem 0 0; }
.block-text :first-child { margin-top: 0; }
.block-text :last-child { margin-bottom: 0; }
.block-button { display: flex; }
.block-button.is-center { justify-content: center; }
.block-divider { border: 0; border-top: 1px solid var(--border); margin: 0; }
.block-image { margin: 0; display: grid; gap: 0.5rem; }
.block-image img { width: 100%; height: auto; display: block; }
.block-image.is-rounded img { border-radius: var(--radius); }
.block-image figcaption { text-align: center; }
.block-carousel { display: flex; gap: 0.6rem; overflow-x: auto; padding-bottom: 0.35rem; }
.block-carousel img { height: 9rem; width: auto; border-radius: var(--radius); flex-shrink: 0; }
.block-embed { width: 100%; border-radius: var(--radius); background: #000; }
.block-embed-frame { position: relative; aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; }
.block-embed-frame iframe { position: absolute; inset: 0; width: 100%; height: 100%; }
.block-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.block-list a { font-weight: 600; }
.block-list--members .block-member { display: flex; align-items: center; gap: 0.5rem; }
.block-faces { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.block-face { display: inline-flex; }
.block-stats { display: grid; gap: 0.75rem; margin: 0; grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr)); }
.block-stats dt { font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.block-stats dd { margin: 0.15rem 0 0; font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.tag-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }


/*
 * ---------- trust levels ----------
 *
 * The ladder on Admin → Trust levels and the member's own panel on Settings. The
 * badge is a monospace label rather than a colour-coded chip: the rungs are ordinal,
 * so tinting them would say "TL3 is a different kind of thing" when it is only
 * further along the same one. The rung a member has cleared is the exception —
 * struck through and dimmed, because a met requirement is done with.
 *
 * Written outside the `legacy` layer like the sections above it, and reading the same
 * tokens, so a theme that redefines `--accent` paints these too.
 */
.trust-ladder { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); }
.trust-rung {
    display: flex; flex-direction: column;
    padding: 0.75rem; border: 1px solid var(--border); border-radius: var(--radius);
    background: color-mix(in srgb, var(--surface-2) 55%, transparent);
}
.trust-rung-head { display: flex; align-items: center; gap: 0.5rem; }
.trust-badge {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 2.5rem; padding: 0.1rem 0.4rem; border-radius: 999px;
    background: var(--accent-soft); color: var(--accent-soft-fg);
    font-family: var(--mono); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.02em;
}
.trust-reqs { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25rem; font-size: 0.78rem; color: var(--muted); }
.trust-reqs li { position: relative; padding-left: 0.85rem; }
.trust-reqs li::before { content: "·"; position: absolute; left: 0.2rem; }
.list th.num, .list td.num { text-align: right; font-variant-numeric: tabular-nums; }

.trust-panel { display: grid; gap: 0.75rem; }
.trust-panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.trust-panel-head .trust-next { margin-left: auto; color: var(--muted); font-size: 0.82rem; }
.trust-panel p { margin: 0; }
.trust-meter { height: 0.4rem; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.trust-meter > span { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
.trust-checklist {
    list-style: none; margin: 0; padding: 0; display: grid; gap: 0.3rem;
    font-size: 0.8rem; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
}
.trust-checklist li { display: flex; align-items: center; gap: 0.4rem; }
.trust-check {
    display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
    width: 1rem; height: 1rem; border-radius: 999px;
    background: var(--surface-2); color: var(--muted); font-size: 0.65rem;
}
.trust-checklist li.is-met { color: var(--muted); text-decoration: line-through; }
.trust-checklist li.is-met .trust-check {
    background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success);
}


/*
 * ---------- safety & spam ----------
 *
 * Two screens in one tab — the watched words and the spam layers — so they are
 * stacked with the same gap two cards have anywhere else. The address table's
 * pattern column is monospaced because an address is read character by character
 * when it is wrong, and the row actions are three small forms in a line.
 */
.safety-stack { display: grid; gap: 1rem; }
.spam-add {
    display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.6rem;
    margin-bottom: 0.5rem;
}
.spam-add .field { flex: 1 1 12rem; margin: 0; }
.spam-add .field.narrow { flex: 0 1 8rem; }
.spam-add .button { flex: 0 0 auto; }
.spam-pattern { font-family: var(--mono); font-size: 0.82rem; overflow-wrap: anywhere; }
.spam-counts { display: grid; gap: 0.6rem; margin: 0.5rem 0 1rem; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); }
.spam-count {
    display: grid; gap: 0.1rem; padding: 0.6rem 0.75rem;
    border: 1px solid var(--border); border-radius: var(--radius);
}
.spam-count-num { font-size: 1.15rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.spam-row-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.spam-row-actions form { margin: 0; }
.spam-heuristics { display: grid; gap: 1rem; }
.spam-toggle {
    display: flex; align-items: center; justify-content: space-between; gap: 1rem;
    padding: 0.75rem; border: 1px solid var(--border); border-radius: var(--radius);
    font-weight: 400;
}
.spam-toggle > span { display: grid; gap: 0.15rem; }
.spam-toggle-title { font-weight: 600; }
.spam-toggle input[type="checkbox"] { width: auto; flex: 0 0 auto; }
.spam-holds { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.spam-holds > li {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
    padding: 0.6rem 0.25rem; border-bottom: 1px solid var(--border);
}
.spam-holds > li:last-child { border-bottom: 0; }
.spam-hold-meta { margin-left: auto; }
.spam-hits { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.spam-hit {
    display: inline-flex; align-items: center; gap: 0.4rem;
    padding: 0.2rem 0.6rem; border: 1px solid var(--border); border-radius: 999px;
    font-size: 0.78rem;
}
.spam-hit code { font-family: var(--mono); }

/* ---------- security & 2fa ---------- */
/*
 * The Security screen's own pieces. The counters reuse `.admin-stat` (there is no second
 * counter style on this board), so all that is added here is the three-column variant, the
 * total after the slash, and the member search above the two-factor reset list.
 */
.admin-stats--three { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 768px) {
    .admin-stats--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* "7/12": the half after the slash is a total, so it reads at the size of a label. */
.admin-stat-of { font-size: 0.95rem; font-weight: 500; color: var(--muted); }
/* The member search above the reset list. One row, and it wraps on a phone. */
.two-factor-search { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 0 0 1rem; }
.two-factor-search input[type="search"] { flex: 1 1 14rem; min-width: 0; margin: 0; }
/*
 * A switch row whose label carries a sentence under it. The row is centred by default,
 * which is right for a one-line label and wrong for two: the tick would float against the
 * middle of the paragraph.
 */
.switch-row--tall { align-items: flex-start; }
.switch-row--tall > span > .muted { display: block; }

/* ---------- api keys ---------- */
/*
 * The endpoint list, the scope boxes and the shown-once key on Admin -> API keys. The
 * reference draws the endpoints in a muted box with the method coloured, the scopes as a
 * two-column grid of labelled checkboxes, and the fresh key in a bordered panel that says
 * it is the only time it will be seen.
 */
.api-endpoints { border: 1px solid var(--border); border-radius: 0.75rem; background: var(--surface-2); padding: 0.9rem 1rem; margin-bottom: 1.15rem; }
.api-endpoints-title { margin: 0 0 0.5rem; font-size: 0.8rem; font-weight: 600; }
.api-endpoints ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.api-endpoints li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.8rem; }
.api-endpoints code { font-family: var(--mono); font-size: 0.78rem; }
.api-endpoints > .muted { margin: 0.6rem 0 0; }
/* The method, in the one colour the reference gives it. */
.api-method { font-family: var(--mono); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; color: #059669; }
.api-scopes { display: grid; gap: 0.5rem; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 768px) {
    .api-scopes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.api-scope { display: flex; align-items: flex-start; gap: 0.6rem; border: 1px solid var(--border); border-radius: 0.6rem; padding: 0.6rem 0.75rem; font-size: 0.85rem; font-weight: 400; }
.api-scope input[type="checkbox"] { width: 1rem; height: 1rem; margin: 0.15rem 0 0; flex-shrink: 0; accent-color: var(--accent); }
.api-scope > span { min-width: 0; }
.api-scope-label { display: block; font-weight: 500; }
.api-scope-label code { font-family: var(--mono); font-size: 0.72rem; color: var(--muted); }
.api-key-fresh { border: 1px solid var(--accent); border-radius: 0.75rem; padding: 0.9rem 1rem; margin-top: 1.15rem; }
.api-key-fresh p { margin: 0 0 0.6rem; font-size: 0.8rem; color: var(--muted); }
.api-key-fresh input { font-family: var(--mono); font-size: 0.85rem; }

/*
 * ---------- passkeys ----------
 *
 * The member's own passkeys (Settings) and the sign-in button, which are the same few pieces:
 * a row per passkey, the form that renames it, the form that removes it, and the line a refusal
 * is written into. The rows are a grid rather than a table because each one holds two forms and
 * a table cell is not a place to put a form.
 */
.passkey-add {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: flex-end;
  padding: 0.9rem 1rem;
  border: 1px dashed color-mix(in srgb, var(--border) 80%, transparent);
  border-radius: 0.75rem;
}
.passkey-add .field { flex: 1 1 14rem; min-width: 0; margin: 0; }
.passkey-status { flex: 1 1 100%; margin: 0; min-height: 1.1rem; }
.passkey-status.ok { color: var(--accent); }
.passkey-status.warn { color: #d71920; }

.passkey-list { list-style: none; margin: 1.15rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
.passkey-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  gap: 0.5rem 1rem;
  align-items: center;
  padding: 0.75rem 0.9rem;
  border: 1px solid var(--border);
  border-radius: 0.75rem;
}
.passkey-facts { display: grid; gap: 0.25rem; min-width: 0; }
.passkey-rename { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.passkey-rename input { margin: 0; padding: 0.35rem 0.5rem; font-size: 0.85rem; width: 12rem; }
.passkey-when { text-align: right; white-space: nowrap; }
.passkey-remove { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0; }
.passkey-remove label { margin: 0; font-size: 0.75rem; }
.passkey-remove input { margin: 0; padding: 0.35rem 0.5rem; width: 9rem; }

.passkey-signin { display: grid; gap: 0.5rem; justify-items: stretch; }
.auth-divider {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 1rem 0 0.75rem;
  color: var(--muted);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.auth-divider::before, .auth-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

@media (max-width: 720px) {
  .passkey-row { grid-template-columns: minmax(0, 1fr); }
  .passkey-when { text-align: left; }
  .passkey-remove input { width: 100%; }
}

/*
 * ---------- the light/dark switch ----------
 *
 * Both glyphs are in the markup and the current mode decides which one shows, so the
 * in-page toggle only has to move `data-mode` on <html> — it never has to know an icon
 * name. A sun while the board is dark (click to go light) and a moon while it is light is
 * the React build's own pairing.
 */
.mode-toggle__icon { display: none; }
:root[data-mode="light"] .mode-toggle__icon--moon,
:root[data-mode="dark"] .mode-toggle__icon--sun { display: inline-block; }

/*
 * ---------- the scroll controls ----------
 *
 * The React build's `ScrollArrows`: pinned to the right edge of the window, beside the
 * scrollbar but not over it, and vertically centred. `size-7` (1.75rem) with a 0.875rem
 * glyph, mostly the surface colour so the page reads through, and a hairline border that
 * takes the accent under the pointer.
 *
 * A click scrolls half a viewport and a press-and-hold jumps to the end; that behaviour is
 * the footer's script, and it deliberately ignores hover so passing the cursor over the
 * arrows never moves the page.
 */
.scroll-arrows {
  position: fixed;
  right: 0.375rem;
  top: 50%;
  transform: translateY(-50%);
  z-index: 30;
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
}
.scroll-arrows__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: color-mix(in srgb, var(--surface) 90%, transparent);
  color: var(--muted);
  box-shadow: var(--shadow);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.scroll-arrows__btn:hover {
  border-color: color-mix(in srgb, var(--accent) 50%, transparent);
  color: var(--text);
}
.scroll-arrows__btn:active {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-fg);
}
.scroll-arrows__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.scroll-arrows__icon {
  width: 0.875rem;
  height: 0.875rem;
  font-size: 0.875rem;
  line-height: 1;
}

/* ---------- demo manager (private add-on) ---------- */
/*
 * The owner page groups its actions by what they do (registration, Demo Mode, baseline & reset,
 * schedule) instead of one long row of buttons, so each group reads on its own and the spacing
 * between groups stays even.
 */
.demo-manager-group { border-top: 1px solid var(--border); margin-top: 1rem; padding-top: 1rem; }
.demo-manager-group:first-of-type { border-top: none; margin-top: 0; padding-top: 0; }
.demo-manager-group > h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
.demo-manager-group > .muted { margin: 0.5rem 0 0; }
.demo-manager-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.demo-manager-actions form { margin: 0; }
.demo-manager-status { margin: 0.5rem 0 0; padding-left: 1.1rem; }
.demo-manager-status li { margin: 0.15rem 0; }
.demo-manager-cmd, .demo-manager-log {
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 0.5rem;
  padding: 0.75rem; overflow-x: auto; font-family: var(--mono); font-size: 0.8rem; margin: 0;
}

/*
 * Which mark of the light/dark pair is seen.
 *
 * These three rules are deliberately the **last** thing in this file and deliberately
 * **outside** `@layer legacy`, which is where the rest of the board's stylesheet lives.
 *
 * Layer membership is not a detail here. An **unlayered** rule out-ranks every layered one
 * whatever its specificity, and three unlayered stylesheets are printed after this file on
 * every page: the administrator's own **Custom CSS** (`Admin → Custom code`), the active
 * **style's** own stylesheet, and any switched-on **add-on's**. One broad rule in any of them
 * — `img { display: block }` is the usual one, and it is in most stylesheets copied from
 * anywhere — was therefore enough to out-rank `.brand-image--dark { display: none }` and draw
 * **both** marks at once in both modes. That is what "the light/dark logos do not truly
 * change" looks like from the outside: the board's own stylesheet was right, and something
 * printed after it won anyway.
 *
 * Unlayered and last, no ordinary rule can displace them, whatever a style brings with it.
 * The header's script sets the same two displays **inline** as it flips the mode (see
 * `views/layout/header.php`), which out-ranks every rule that does not carry `!important` —
 * these rules are what draws the right mark on the first paint, and what a board with
 * scripting off relies on.
 */
.brand-image--dark { display: none; }
html[data-mode="dark"] .brand-image--light { display: none; }
html[data-mode="dark"] .brand-image--dark { display: inline-block; }
