/* ============================================================
   Fonts — Geist Sans (body) + Geist Mono (brand)
   ============================================================
   Self-hosted Vercel Geist (SIL Open Font License). The font-display: swap
   directive means the browser shows the system fallback immediately while
   Geist downloads in the background — no FOIT (flash of invisible text).
   ============================================================ */

@font-face {
    font-family: 'Geist';
    font-style: normal;
    font-weight: 300;
    font-display: swap;
    src: url('/static/fonts/geist/Geist-Light.woff2') format('woff2');
}

@font-face {
    font-family: 'Geist';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url('/static/fonts/geist/Geist-Regular.woff2') format('woff2');
}

@font-face {
    font-family: 'Geist';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url('/static/fonts/geist/Geist-Medium.woff2') format('woff2');
}

@font-face {
    font-family: 'Geist';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url('/static/fonts/geist/Geist-SemiBold.woff2') format('woff2');
}

@font-face {
    font-family: 'Geist';
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url('/static/fonts/geist/Geist-Bold.woff2') format('woff2');
}

@font-face {
    font-family: 'Geist Mono';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url('/static/fonts/geist-mono/GeistMono-Regular.woff2') format('woff2');
}

/* ============================================================
   Design tokens — CSS custom properties (variables)
   ============================================================
   The single source of truth for colors, type, spacing, radii,
   and shadows. Any rule below should reference these via
   var(--token-name) rather than hard-coding hex values.

   Currently mirrors the values that were hard-coded throughout
   the stylesheet (and via Bootstrap defaults) before pr6 — so
   defining these is purely structural. pr6b will start swapping
   hard-coded values to var() references and refining the palette.
   pr6c adds [data-theme="dark"] overrides.

   Naming: --color-* for color, --space-* for spacing, --radius-*
   for border radii, --font-* for typography, --shadow-* for shadows.
   ============================================================ */

:root {
    /* ---- Typography -------------------------------------- */

    /* Font families — Geist Sans for body, Geist Mono for the brand
       wordmark. The fallback chain is the same system stack that
       Bootstrap defaults to, so the page is fully readable before
       Geist finishes downloading. */
    --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI',
        Roboto, 'Helvetica Neue', Arial, sans-serif;
    --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo,
        Consolas, monospace;

    /* Type scale — matches what's currently rendering. We only
       have a handful of sizes in use right now (10/11/12/13/14
       and Bootstrap's 1rem default for body). */
    --font-size-xs: 10px;
    /* tiny meta text — flash close button */
    --font-size-sm: 11px;
    /* avatar-sm initial, smallest meta */
    --font-size-md: 12px;
    /* avatar-md, small meta, post flash */
    --font-size-base: 13px;
    /* avatar-lg, comment author, dropdown */
    --font-size-body: 14px;
    /* avatar-xl, post author, post body */
    --font-size-lg: 1rem;
    /* default body */
    --font-size-brand: 1.1rem;
    /* navbar-brand */

    /* Font weights — matches what we use today */
    --font-weight-light: 300;
    --font-weight-regular: 400;
    --font-weight-medium: 500;
    --font-weight-semibold: 600;
    --font-weight-bold: 700;

    /* ---- Colors — neutrals ------------------------------- */

    /* Backgrounds & surfaces. Cool off-white page, white card surface,
       subtly cool tint for avatar/empty backgrounds. Inspired by
       Linear's light variant. */
    --color-bg-page: #fafafa;
    --color-bg-surface: #ffffff;
    --color-bg-subtle: #f4f4f5;

    /* Text colors. Near-black with cool cast (vs Bootstrap's warm dark
       gray); zinc-tinted muted gray for timestamps and meta text. */
    --color-text: #0a0a0a;
    --color-text-muted: #71717a;
    --color-text-on-accent: #ffffff;

    /* Borders. Subtle cool neutrals; the Linear-style understated
       dividers. */
    --color-border: #e4e4e7;
    --color-border-subtle: rgba(0, 0, 0, 0.06);

    /* ---- Colors — accent + status ------------------------ */

    /* Accent: Violet-500 (Tailwind's violet scale). Lighter, softer
       violet that's clearly distinct from Bootstrap blue without
       going neon. Used for primary buttons, .mention links,
       dropdown-item :active, focus rings. */
    --color-accent: #8b5cf6;
    --color-accent-hover: #7c3aed;

    /* Status colors — refined for the moody-cool palette.
       Slightly desaturated and chosen to read clearly on the
       cool off-white page background. */
    --color-success: #16a34a;
    /* follow / like / vibe — positive */
    --color-warning: #eab308;
    /* "are you sure" prompts */
    --color-danger: #dc2626;
    /* delete / yeet / block */
    --color-info: #0ea5e9;
    /* informational alerts */

    /* ---- Radii ------------------------------------------- */

    /* Softer/larger radii for the Linear-leaning aesthetic. */
    --radius-sm: 8px;
    /* small interactive elements (dropdown items) */
    --radius-md: 12px;
    /* link previews, small cards */
    --radius-lg: 14px;
    /* main cards, post-menu */
    --radius-pill: 999px;
    /* fully rounded — for badges, avatar */

    /* ---- Shadows ----------------------------------------- */

    /* Today: only one shadow (link-preview hover). Naming for
       three levels gives us room to differentiate components later. */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
    --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.10);
    --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);

    /* ---- Spacing ----------------------------------------- */

    /* Spacing scale based on Bootstrap's 0.25rem base unit so
       Bootstrap utility classes (mb-2, mt-3 etc) line up. */
    --space-1: 0.25rem;
    /* 4px */
    --space-2: 0.5rem;
    /* 8px */
    --space-3: 0.75rem;
    /* 12px */
    --space-4: 1rem;
    /* 16px */
    --space-5: 1.5rem;
    /* 24px */
    --space-6: 2rem;
    /* 32px */

    /* ---- Transitions ------------------------------------- */

    /* Snappy for hover/focus, slower for state changes (flash fade). */
    --transition-fast: 100ms ease;
    --transition-medium: 150ms ease;
    --transition-slow: 350ms ease;
}

/* ============================================================
   Bootstrap variable overrides
   ============================================================
   Bootstrap 5 exposes its theme as CSS custom properties (--bs-*).
   Overriding them here propagates the new palette to every Bootstrap
   component (buttons, alerts, dropdowns, modals, form inputs, navbar)
   without touching their selectors directly.

   Each override maps a Bootstrap variable to one of our design tokens,
   keeping our :root tokens as the single source of truth.

   Order of precedence: Bootstrap's CDN CSS loads first (sets defaults),
   our style.css loads second, this :root block runs last and wins.
   ============================================================ */

:root {
    /* Page background — Bootstrap's body uses --bs-body-bg */
    --bs-body-bg: var(--color-bg-page);

    /* Body text color */
    --bs-body-color: var(--color-text);

    /* Muted text (text-muted utility, .small text, secondary content) */
    --bs-secondary-color: var(--color-text-muted);

    /* Borders — used by .card, form inputs, list-group, etc. */
    --bs-border-color: var(--color-border);

    /* Primary brand color — flows into .btn-primary, .text-primary,
       .bg-primary, link colors, focus rings, dropdown-item :active. */
    --bs-primary: var(--color-accent);
    --bs-primary-rgb: 139, 92, 246;
    /* RGB form needed for rgba() Bootstrap helpers like btn focus shadow */

    /* Status colors */
    --bs-success: var(--color-success);
    --bs-success-rgb: 22, 163, 74;
    --bs-warning: var(--color-warning);
    --bs-warning-rgb: 234, 179, 8;
    --bs-danger: var(--color-danger);
    --bs-danger-rgb: 220, 38, 38;
    --bs-info: var(--color-info);
    --bs-info-rgb: 14, 165, 233;

    /* Link color follows accent */
    --bs-link-color: var(--color-accent);
    --bs-link-color-rgb: 139, 92, 246;
    --bs-link-hover-color: var(--color-accent-hover);

    /* Component radii — Bootstrap exposes these as variables, so cards,
       buttons, inputs, etc. all inherit our softer aesthetic. */
    --bs-border-radius: var(--radius-md);
    --bs-border-radius-sm: var(--radius-sm);
    --bs-border-radius-lg: var(--radius-lg);

    /* Typography — Bootstrap defaults to its own font stack;
       we override to Geist. */
    --bs-body-font-family: var(--font-sans);
}

.btn-primary {
    --bs-btn-bg: var(--color-accent);
    --bs-btn-border-color: var(--color-accent);
    --bs-btn-hover-bg: var(--color-accent-hover);
    --bs-btn-hover-border-color: var(--color-accent-hover);
    --bs-btn-active-bg: var(--color-accent-hover);
    --bs-btn-active-border-color: var(--color-accent-hover);
    --bs-btn-disabled-bg: var(--color-accent);
    --bs-btn-disabled-border-color: var(--color-accent);
    --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}

.btn-outline-primary {
    --bs-btn-color: var(--color-accent);
    --bs-btn-border-color: var(--color-accent);
    --bs-btn-hover-bg: var(--color-accent);
    --bs-btn-hover-border-color: var(--color-accent);
    --bs-btn-active-bg: var(--color-accent);
    --bs-btn-active-border-color: var(--color-accent);
    --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}

/* ============================================================
   Bootstrap component overrides — pr6c spillover
   ============================================================
   Bootstrap 5 hardcodes per-component variables inside each
   component's own selector with literal values, so :root-level
   overrides don't reach them. We override the components we
   actually use here. The full sweep is pr6d's job; these are
   only the ones that broke visibly in dark mode.
   ============================================================ */

/* Navbar — token-driven instead of Bootstrap's bg-white +
   navbar-light combo, so it follows light/dark theme. */
.navbar {
    background-color: var(--color-bg-surface);
    color: var(--color-text);
}

.navbar .nav-link {
    color: var(--color-text-muted);
}

.navbar .nav-link:hover,
.navbar .nav-link:focus {
    color: var(--color-text);
}

.navbar .navbar-brand {
    color: var(--color-text);
}

.navbar a.text-secondary {
    color: var(--color-text-muted) !important;
}

.navbar a.text-secondary:hover {
    color: var(--color-text) !important;
}

/* Popover — header background is hardcoded in Bootstrap, so we
   re-declare popover variables on the .popover selector. */
.popover {
    --bs-popover-bg: var(--color-bg-surface);
    --bs-popover-header-bg: var(--color-bg-subtle);
    --bs-popover-header-color: var(--color-text);
    --bs-popover-body-color: var(--color-text);
    --bs-popover-border-color: var(--color-border);
}

/* Card — Bootstrap's default sets --bs-card-bg to --bs-body-bg, which
   in our setup is the page background. We want cards to be raised
   surfaces, just like dashboard columns. */
.card {
    --bs-card-bg: var(--color-bg-surface);
    --bs-card-border-color: var(--color-border);
    --bs-card-color: var(--color-text);
}

/* Compose textarea — inherits .form-control then customizes.
   Background matches the card surface so it reads as a continuation
   of the card, but with a visible border to mark the typing zone.
   Replaces the inline style="resize: none; box-shadow: none;" that
   was previously passed to WTForms (TODOs.md item now resolved). */
.compose-textarea {
    background-color: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    color: var(--color-text);
    resize: none;
    box-shadow: none;
}

.compose-textarea:focus {
    background-color: var(--color-bg-surface);
    color: var(--color-text);
    border-color: var(--color-accent);
    box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
}

/* Modal — same pattern as .card. Bootstrap declares modal variables
   on the .modal-content selector itself, hardcoded, so :root tokens
   don't reach them. Re-map to our surface tokens. */
.modal-content {
    --bs-modal-bg: var(--color-bg-surface);
    --bs-modal-border-color: transparent;
    --bs-modal-color: var(--color-text);
    /* Border becomes the glow instead. Setting the var to transparent
       and letting box-shadow do the work, rather than fighting Bootstrap's
       border declaration. */
}

/* ============================================================
   Halo treatment — reusable accent glow (pr16b wave 2)
   ============================================================
   Apply .has-halo to any element to give it a soft three-layer
   accent glow: a tight inner ring at the border, a soft mid
   glow, and a far diffuse halo. The glow fades to nothing
   within ~60px.

   Color via the --halo-color CSS variable. Defaults to the
   active accent (--bs-primary-rgb), which theme-switches with
   light/dark mode automatically.

   To use a different color, set --halo-color inline or via a
   modifier class. The variable holds an "R, G, B" triplet (no
   parentheses, no "rgb(...)") because rgba() in the box-shadow
   composes it with alpha. Example:
       <div class="has-halo" style="--halo-color: 220, 38, 38;">
   for a red halo. Easier as a class:
       .has-halo-danger { --halo-color: var(--bs-danger-rgb); }

   Currently applied to: #composeModal, #freeSpaceModal.

   Note for modals specifically: Bootstrap's .modal-content is the
   element that paints the modal surface, so the halo selector
   reaches into .modal-content. For other element types this
   inner selector won't apply — the rule below adds a top-level
   shadow declaration that lives on .has-halo itself, so .has-halo
   on (say) a button or card just works.
   ============================================================ */

.has-halo {
    /* The variable doubles as the on-class default. Per-instance
       overrides cascade through normally. */
    --halo-color: var(--bs-primary-rgb);

    /* Top-level halo — applies when .has-halo is on a regular
       element (button, card, etc.). For modals, the inner
       .modal-content rule below paints the halo instead because
       Bootstrap renders modal chrome on .modal-content, not on
       the .modal wrapper. */
    box-shadow:
        0 0 0 1px rgba(var(--halo-color), 0.5),
        0 0 24px 4px rgba(var(--halo-color), 0.25),
        0 0 60px 8px rgba(var(--halo-color), 0.12);
}

/* Modal-specific descendant: when .has-halo is on a .modal element,
   paint the halo on the inner .modal-content instead so the glow
   wraps the visible modal surface, not the invisible wrapper. */
.modal.has-halo .modal-content {
    box-shadow:
        0 0 0 1px rgba(var(--halo-color), 0.5),
        0 0 24px 4px rgba(var(--halo-color), 0.25),
        0 0 60px 8px rgba(var(--halo-color), 0.12);
}

/* If .has-halo is on a .modal element, suppress the top-level
   box-shadow — the .modal wrapper is a transparent positioning
   shell, so painting a shadow on it would look strange. The
   descendant rule above handles that case. */
.modal.has-halo {
    box-shadow: none;
}

/* ============================================================
   Dark theme — pr6c
   ============================================================
   The dark palette overrides the color tokens declared in :root.
   Every rule below this block already references colors via
   var(--color-*) or var(--bs-*), so this is the only place we
   need to redeclare values.

   Three trigger conditions, in increasing specificity:
     1. @media (prefers-color-scheme: dark) on :root with no
        data-theme attribute — the User.theme = 'auto' case
        when the OS is in dark mode.
     2. [data-theme="dark"] — explicit User.theme = 'dark', wins
        over the media query.

   The 'light' case needs no override block — :root already
   defines light. [data-theme="light"] explicitly suppresses the
   media query when the user has chosen light despite OS-dark.
   That's handled by selector specificity alone (the :root rules
   declared first match [data-theme="light"]'s html element, and
   the media query's :root:not([data-theme]) doesn't match because
   data-theme IS set).

   The two trigger blocks below share identical declarations.
   They're written separately for clarity rather than DRY because
   merging them with a comma between selectors that include a
   media query is awkward and slightly less readable.
   ============================================================ */

[data-theme="dark"],
:root[data-theme="dark"] {
    /* ---- Backgrounds (inverted depth pattern) ----
       Light mode: page is off-white, cards are white-on-page.
       Dark mode: cards are LIGHTER than the page (raised surfaces).
       This is the standard Material/Linear dark-mode convention —
       surfaces feel "lifted" rather than inset. */
    --color-bg-page: #0a0a0b;
    /* near-black with a slight cool cast */
    --color-bg-surface: #18181b;
    /* zinc-900 — cards, modals, panel */
    --color-bg-subtle: #27272a;
    /* zinc-800 — avatar bg, hover states */

    /* ---- Text ----
       Slightly off-white (zinc-50, not pure white) — pure white
       on near-black is harsh. Muted gray bumped brighter than
       light mode's so it stays legible on dark surfaces. */
    --color-text: #fafafa;
    --color-text-muted: #a1a1aa;
    /* --color-text-on-accent stays #ffffff — the accent is the
       same violet in both themes, so its on-color is unchanged */

    /* ---- Borders ----
       Subtle on dark needs more luminance contrast than subtle
       on light. ~10% white reads as a dividing line. */
    --color-border: #3f3f46;
    /* zinc-700 */
    --color-border-subtle: rgba(255, 255, 255, 0.06);

    /* ---- Accent ----
       Violet-400 (lighter, slightly less saturated than violet-500)
       reads better on dark. Hover step goes LIGHTER in dark mode
       (instead of darker in light mode) since the surface is dark. */
    --color-accent: #a78bfa;
    /* violet-400 */
    --color-accent-hover: #c4b5fd;
    /* violet-300 */

    /* ---- Status colors ----
       Each goes one shade brighter than light mode. Light-mode
       desaturated versions disappear into dark backgrounds. */
    --color-success: #22c55e;
    /* green-500 */
    --color-warning: #fbbf24;
    /* amber-400 */
    --color-danger: #f87171;
    /* red-400 */
    --color-info: #38bdf8;
    /* sky-400 */

    /* ---- Shadows ----
       Shadows on dark backgrounds barely show with light-mode
       alpha values. We bump alpha to ~5x so they actually register. */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
    --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.5);
    --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);

    /* ---- Bootstrap variable bridge ----
       Same pattern as :root — Bootstrap component variables need
       to be re-mapped to the dark tokens. This catches Bootstrap-
       rendered components (alerts, badges, modals, dropdowns) at
       the :root level. Per-component overrides remain pr6d's job. */
    --bs-body-bg: var(--color-bg-page);
    --bs-body-color: var(--color-text);
    --bs-secondary-color: var(--color-text-muted);
    --bs-border-color: var(--color-border);
    --bs-primary: var(--color-accent);
    --bs-primary-rgb: 167, 139, 250;
    --bs-link-color: var(--color-accent);
    --bs-link-color-rgb: 167, 139, 250;
    --bs-link-hover-color: var(--color-accent-hover);

    /* Bootstrap's --bs-tertiary-bg and --bs-secondary-bg are used
       in the column header and post-detail-panel close hover.
       Defaults are light grays — override to elevated zincs so
       hover states show on dark. */
    --bs-tertiary-bg: #27272a;
    --bs-secondary-bg: #27272a;

    /* Status RGB triplets (Bootstrap helpers like .alert use them
       in rgba() expressions). */
    --bs-success: var(--color-success);
    --bs-success-rgb: 34, 197, 94;
    --bs-warning: var(--color-warning);
    --bs-warning-rgb: 251, 191, 36;
    --bs-danger: var(--color-danger);
    --bs-danger-rgb: 248, 113, 113;
    --bs-info: var(--color-info);
    --bs-info-rgb: 56, 189, 248;
}

/* OS-level prefers-color-scheme: dark, but only when User.theme
   is 'auto' (no data-theme attribute on <html>). The selector
   :root:not([data-theme]) ensures explicit user choice wins. */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme]) {
        --color-bg-page: #000000;
        --color-bg-surface: #27272a;
        --color-bg-subtle: #3f3f46;

        --color-text: #fafafa;
        --color-text-muted: #a1a1aa;

        --color-border: #3f3f46;
        --color-border-subtle: rgba(255, 255, 255, 0.06);

        --color-accent: #a78bfa;
        --color-accent-hover: #c4b5fd;

        --color-success: #22c55e;
        --color-warning: #fbbf24;
        --color-danger: #f87171;
        --color-info: #38bdf8;

        --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
        --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.5);
        --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);

        --bs-body-bg: var(--color-bg-page);
        --bs-body-color: var(--color-text);
        --bs-secondary-color: var(--color-text-muted);
        --bs-border-color: var(--color-border);
        --bs-primary: var(--color-accent);
        --bs-primary-rgb: 167, 139, 250;
        --bs-link-color: var(--color-accent);
        --bs-link-color-rgb: 167, 139, 250;
        --bs-link-hover-color: var(--color-accent-hover);

        --bs-tertiary-bg: #27272a;
        --bs-secondary-bg: #27272a;

        --bs-success: var(--color-success);
        --bs-success-rgb: 34, 197, 94;
        --bs-warning: var(--color-warning);
        --bs-warning-rgb: 251, 191, 36;
        --bs-danger: var(--color-danger);
        --bs-danger-rgb: 248, 113, 113;
        --bs-info: var(--color-info);
        --bs-info-rgb: 56, 189, 248;
    }
}

/* ============================================================
   Base — page background, card defaults
   ============================================================ */

body {
    background-color: var(--color-bg-page);
    font-family: var(--font-sans);
}

.card {
    border-radius: var(--radius-lg);
}

/* ============================================================
   Brand — navbar logo / wordmark
   ============================================================ */

.navbar-brand {
    font-family: var(--font-mono);
    font-size: var(--font-size-brand);
}

/* ============================================================
   Mentions — @username links inside post and comment text
   ============================================================
   The mention class is applied by render_mentions() in feed/utils.py.
   Style change here flows everywhere @mentions render.
   ============================================================ */

.mention {
    color: var(--color-accent);
    font-weight: var(--font-weight-semibold);
}

/* ============================================================
   Link previews — Open Graph card under posts with URLs
   ============================================================ */

.link-preview {
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
    transition: box-shadow var(--transition-medium);
}

.link-preview:hover {
    box-shadow: var(--shadow-md);
}

.link-preview-img {
    max-height: 200px;
    object-fit: cover;
}

.link-preview-title {
    font-size: var(--font-size-body);
    color: var(--color-text);
}

.link-preview-desc {
    font-size: var(--font-size-base);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* ============================================================
   Shared post — embedded card for quote/repost/orphaned share
   ============================================================
   Used as the inset card inside a parent post-card when the post
   has a share_type (quote, repost, or orphaned). Renders as a
   subtle inset surface that's visually distinct from the outer
   card without going full bg-light (which hardcoded #f8f9fa and
   broke in dark mode).
   ============================================================ */

.shared-post-card {
    background-color: var(--color-bg-subtle);
    border-color: var(--color-border-subtle);
}

/* ============================================================
   Unread badge — small dot on the avatar / drafts icon
   ============================================================
   Used in base.html for the avatar pending-follows indicator
   and in the nav for unread messages.
   ============================================================ */

.unread-badge {
    position: absolute;
    top: -2px;
    right: -2px;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background-color: var(--color-text-muted);
    border: 2px solid var(--color-text);
}

/* ============================================================
   Post action menu — three-dot dropdown
   ============================================================
   Two trigger conditions for the menu:
     - Bootstrap toggles .show on .dropdown-menu when opened
     - aria-expanded on the trigger button reflects open state
   Animation uses opacity + transform for the smoothest feel
   without fighting Bootstrap's display:none/block toggle.
   ============================================================ */

/* Wrapper positioning — extracted from inline style. */
.post-menu {
    top: var(--space-2);
    right: var(--space-2);
    z-index: 2;
}

/* The trigger button — three vertical dots. Faded by default,
   full opacity on hover/open. */
.post-menu .btn {
    opacity: 0.5;
    border-radius: var(--radius-sm);
    transition: opacity var(--transition-medium),
        background-color var(--transition-fast);
}

.post-menu .btn:hover,
.post-menu .btn[aria-expanded="true"] {
    opacity: 1;
    background-color: var(--color-bg-subtle);
}

/* Focus ring for keyboard users — only when keyboard-focused. */
.post-menu .btn:focus-visible {
    opacity: 1;
    outline: none;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 40%, transparent);
}

/* The menu panel itself — frosted glass, animated reveal.
   We force display:block always so transitions work, then use
   visibility + opacity + pointer-events to hide/show. */
.post-menu-list {
    display: block;
    visibility: hidden;
    opacity: 0;
    pointer-events: none;

    /* Visual treatment — frosted glass. */
    font-size: var(--font-size-base);
    border-radius: var(--radius-lg);
    border: 1px solid var(--color-border-subtle);
    padding: 6px;
    min-width: 200px;
    background-color: color-mix(in srgb, var(--color-bg-surface) 80%, transparent);
    backdrop-filter: blur(12px) saturate(1.5);
    -webkit-backdrop-filter: blur(12px) saturate(1.5);
    box-shadow: var(--shadow-lg);

    /* Animation — slight downward translate + scale-from-top-right.
       The transform-origin matters: dropdown-menu-end means the
       menu's top-right corner aligns with the trigger, so scaling
       from top-right makes it grow visually FROM the trigger. */
    transform: translateY(-4px) scale(0.96);
    transform-origin: top right;
    transition: opacity 180ms cubic-bezier(0.32, 0.72, 0, 1),
        transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
        visibility 0s linear 180ms;
}

/* Open state — Bootstrap adds .show when the menu is open. */
.post-menu-list.show {
    visibility: visible;
    opacity: 1;
    pointer-events: auto;
    transform: translateY(0) scale(1);
    /* On open, visibility flips immediately (no delay) so the
       opacity/transform transitions are visible. */
    transition: opacity 180ms cubic-bezier(0.32, 0.72, 0, 1),
        transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
        visibility 0s;
}

/* Dropdown items — refined hover, focus ring, smooth transitions. */
.post-menu-list .dropdown-item {
    border-radius: var(--radius-sm);
    padding: 6px 10px;
    transition: background-color var(--transition-fast),
        color var(--transition-fast);
}

.post-menu-list .dropdown-item:hover,
.post-menu-list .dropdown-item:focus {
    background-color: var(--color-bg-subtle);
    color: var(--color-text);
}

.post-menu-list .dropdown-item:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 40%, transparent);
}

.post-menu-list .dropdown-item:active {
    background-color: var(--color-accent);
    color: var(--color-text-on-accent);
}

/* Section label inside the dropdown (e.g. "visibility"). */
.post-menu-section-label {
    font-size: var(--font-size-sm);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: var(--color-text-muted);
    padding: 4px 10px 2px;
}

/* Check icon next to active visibility option — fades in/out. */
.post-menu-list .check-icon {
    opacity: 0;
    transition: opacity var(--transition-fast);
}

.post-menu-list .check-icon.is-active {
    opacity: 1;
}

/* Reduced-motion respect — disable animations for users who've
   asked the OS to minimize motion. */
@media (prefers-reduced-motion: reduce) {

    .post-menu .btn,
    .post-menu-list,
    .post-menu-list .dropdown-item,
    .post-menu-list .check-icon {
        transition: none;
    }

    .post-menu-list,
    .post-menu-list.show {
        transform: none;
    }
}

/* ============================================================
   Per-post flash — auto-fade after a few seconds
   ============================================================
   Animation defined in @keyframes post-flash-fade below.
   The .card .alert selector is broad — auto-fades any alert
   inside any card. If we add a non-flash alert in a card later,
   scope this to .post-flash class. (Logged in TODOs.md.)
   ============================================================ */

.card .alert {
    animation: post-flash-fade 3.5s ease forwards;
}

@keyframes post-flash-fade {
    0% {
        opacity: 0;
        transform: translateY(-4px);
    }

    10% {
        opacity: 1;
        transform: translateY(0);
    }

    85% {
        opacity: 1;
    }

    100% {
        opacity: 0;
        transform: translateY(-4px);
    }
}

/* ============================================================
   Layout helpers — utility classes for inline-style replacements
   ============================================================ */

.nav-icon {
    vertical-align: -3px;
}

.inline-icon {
    vertical-align: -2px;
}

/* ============================================================
   Avatars — reusable circle-with-initial component
   ============================================================
   The .avatar class is the wrapper; size modifiers set the
   pixel dimensions and matching font size for the initial.

   When avatar_path is set on a User, the macro emits an <img>
   with the .avatar class instead of a div with the initial.
   The img rule below makes the image fill the circle and crop
   on aspect mismatch (object-fit: cover).

   Sizes:
     xs    16px  tiny attribution chips (links-column tile)
     sm    24px  comments, message senders, nested-in-message preview
     md    28px  shared/quoted post nested header
     lg    32px  top-level post cards, nav avatar, share/send previews
     xl    36px  compose, inbox rows, followers/following lists
     2xl   48px  dashboard profile-column header
     hero  96px  profile page header, edit-profile preview

   Use via the _avatar.html macro rather than hand-rolling
   markup at call sites — the macro picks <img> vs initial-circle
   based on whether the user has uploaded an avatar.
   ============================================================ */

.avatar {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    border-radius: 50%;
    background-color: var(--color-bg-subtle);
    font-weight: var(--font-weight-medium);
    color: var(--color-text);
    /* Avatar text shouldn't be selectable — initial-circle case
       is decoration plus an aria-label, not body content. */
    user-select: none;
}

/* When .avatar is an <img>, fill the circle with the image and
   crop on aspect mismatch. border-radius is inherited from the
   shared .avatar declaration, so this works for any size. */
img.avatar {
    object-fit: cover;
    /* Same dimensions as the wrapper sizes below — width/height
       come from .avatar-<size>. No need to set them here. */
}

.avatar-xs {
    width: 16px;
    height: 16px;
    font-size: var(--font-size-xs);
}

.avatar-sm {
    width: 24px;
    height: 24px;
    font-size: var(--font-size-sm);
}

.avatar-md {
    width: 28px;
    height: 28px;
    font-size: var(--font-size-md);
    background-color: var(--color-bg-surface);
    /* shared-post nested avatar sits on a bg-subtle parent — needs surface white */
}

.avatar-lg {
    width: 32px;
    height: 32px;
    font-size: var(--font-size-base);
}

.avatar-xl {
    width: 36px;
    height: 36px;
    font-size: var(--font-size-body);
}

.avatar-2xl {
    width: 48px;
    height: 48px;
    font-size: 18px;
    font-weight: var(--font-weight-semibold);
}

.avatar-hero {
    width: 96px;
    height: 96px;
    font-size: 36px;
    font-weight: var(--font-weight-semibold);
}

/* ============================================================
   Post body & comment body — text wrapping + sizing
   ============================================================ */

.post-body {
    white-space: pre-wrap;
}

.post-author-name {
    font-size: var(--font-size-body);
}

.shared-author-name {
    font-size: var(--font-size-base);
}

.comment-body {
    font-size: var(--font-size-body);
    white-space: pre-wrap;
}

.comment-author-name {
    font-size: var(--font-size-base);
}

.post-image {
    max-width: 100%;
    height: auto;
}

/* ============================================================
   Compose form — visibility dropdown + textarea
   ============================================================ */

.compose-visibility-wrap {
    position: absolute;
    top: 0;
    right: 0;
    z-index: 2;
}

.compose-visibility-menu {
    min-width: 8rem;
    font-size: var(--font-size-base);
}

.compose-visibility-menu .dropdown-item {
    cursor: pointer;
}

/* ============================================================
   Edit-post media thumbs — existing media + remove button
   ============================================================ */

.media-thumb-edit {
    position: relative;
    width: 80px;
    height: 80px;
}

.media-thumb-edit img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.remove-media-btn {
    position: absolute;
    top: -6px;
    right: -6px;
    width: 20px;
    height: 20px;
    background: var(--color-text);
    color: var(--color-text-on-accent);
    border: 2px solid var(--color-bg-surface);
    border-radius: 50%;
    padding: 0;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--font-size-md);
    line-height: 1;
    transition: background var(--transition-fast), transform var(--transition-fast);
}

.remove-media-btn:hover {
    background: var(--color-danger);
    transform: scale(1.1);
}

/* === === ===
   Edit-media button — small pencil/crop icon on each preview thumb.
   Sibling of .remove-media-btn (positioned in the opposite corner).
   Only visible when window.openMediaEditor exists at render time.
   === === === */
.edit-media-btn {
    position: absolute;
    top: -6px;
    /* match .remove-media-btn pattern */
    left: -6px;
    /* opposite corner from remove */
    width: 22px;
    height: 22px;
    padding: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;

    /* Match .remove-media-btn's high-contrast treatment so it reads
       as a discrete interactive control on any background. Same
       border-ring pattern that lifts it off the underlying image. */
    background: var(--color-text);
    color: var(--color-text-on-accent);
    border: 2px solid var(--color-bg-surface);
    border-radius: 50%;

    cursor: pointer;
    transition: background var(--transition-fast),
        transform var(--transition-fast);
    z-index: 1;
}

.edit-media-btn:hover,
.edit-media-btn:focus-visible {
    background: var(--color-accent);
    transform: scale(1.1);
}

/* ============================================================
   Media editor (me1)
   Cropper.js modal — crop, rotate, aspect ratio chips.
   ============================================================ */

/* Modal-on-modal: when the editor opens over the compose modal,
   Bootstrap's default z-index puts both at 1055. Bump the editor
   so it sits cleanly above the compose modal and its backdrop. */
#mediaEditorModal {
    z-index: 1075;
}

#mediaEditorModal+.modal-backdrop,
.modal-backdrop.show~#mediaEditorModal+.modal-backdrop {
    z-index: 1070;
}

.media-editor-dialog {
    max-width: min(720px, 92vw);
}

.media-editor-canvas {
    /* Constrain the cropper stage to a comfortable viewing size.
       Cropper.js fills the container; the container drives the size. */
    max-height: 60vh;
    min-height: 320px;
    width: 100%;
    overflow: hidden;
    background-color: var(--color-bg-subtle);
    border-radius: var(--radius-md, 0.5rem);
}

.media-editor-canvas img {
    /* Cropper.js sizes its UI to match the source image's rendered
       dimensions at init time. Force the image to fill the canvas
       container *before* Cropper mounts so it builds a properly-sized
       UI. object-fit: contain preserves aspect ratio — a portrait
       photo won't stretch into the landscape container shape. */
    display: block;
    width: 100%;
    height: 100%;
    max-width: 100%;
    object-fit: contain;
}

/* ============================================================
   Media editor — mobile polish (me5)
   ============================================================
   modal-dialog-scrollable on the dialog gives us pinned header
   and footer with a scrolling body — load-bearing on small phones
   where the canvas + filter strip + controls + footer otherwise
   overflow the viewport.

   The filter strip already scrolls horizontally; the fade gradient
   below signals there's more to scroll to. Without it, on narrow
   screens the rightmost visible thumb just looks like the last
   one.
   ============================================================ */

/* Right-edge fade on the filter strip — only when the content
   actually overflows. background-image is layered on top of the
   strip's existing background-color, anchored to the right
   container edge so it stays visible regardless of scroll
   position. Pointer-events none so it doesn't intercept clicks
   on the underlying thumbs. */
.media-editor-filter-strip {
    position: relative;
    /* Mask the right edge with a subtle gradient so users see
       there's more to scroll to. CSS mask-image is wider-supported
       in 2026 than the old vendor-prefixed version was; this is
       still gracefully degraded — older browsers just don't see
       the fade. */
    -webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
    mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}

/* Lasagna hint — small disclaimer below the filter strip. */
.media-editor-filter-hint {
    color: var(--color-text-muted);
    font-size: var(--font-size-sm);
    font-style: italic;
}

/* The canvas needs a sensible min-height even on very short
   viewports. 60vh on a 568px-tall phone is 340px which is fine,
   but rotating to landscape (where vh becomes ~375px) gives only
   225px, which feels cramped. Floor it at 240px and let scroll
   handle the rest. */
@media (max-height: 600px) {
    .media-editor-canvas {
        min-height: 240px;
        max-height: 50vh;
    }
}

/* On narrow screens the aspect-ratio chip row + rotate buttons
   already stack via the existing @media(max-width: 540px) rule.
   Make sure the chips don't grow weird when stacked. */
@media (max-width: 540px) {
    .media-editor-aspect-group {
        width: 100%;
        justify-content: center;
    }

    .media-editor-rotate-group {
        width: 100%;
        justify-content: center;
    }
}

/* ============================================================
   Media editor — filter strip (me2)
   Horizontal scrollable row of preview thumbs, each showing the
   source image with a different CSS filter preset baked into a
   small offscreen canvas. Active state mirrors the aspect-ratio
   chip active state — violet ring + label tint.
   ============================================================ */

.media-editor-filter-strip {
    display: flex;
    gap: var(--space-2);
    overflow-x: auto;
    padding-bottom: var(--space-1);
    /* breathing room for scrollbar */

    /* Momentum scrolling on iOS — important since the modal is the
       primary editing surface on phones. */
    -webkit-overflow-scrolling: touch;

    /* Hide the horizontal scrollbar on browsers that respect this.
       Strip is meant to feel like a chip row, not a scroll region. */
    scrollbar-width: thin;
}

.media-filter-thumb {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    padding: 0;
    background: none;
    border: 2px solid transparent;
    border-radius: var(--radius-md, 0.5rem);
    cursor: pointer;
    transition: border-color var(--transition-fast),
        transform var(--transition-fast);
}

.media-filter-thumb canvas {
    display: block;
    width: 64px;
    height: 64px;
    object-fit: cover;
    border-radius: calc(var(--radius-md, 0.5rem) - 2px);
    background: var(--color-bg-subtle);
}

.media-filter-thumb-label {
    font-size: 11px;
    color: var(--color-text-muted);
    text-transform: lowercase;
    white-space: nowrap;
}

.media-filter-thumb:hover,
.media-filter-thumb:focus-visible {
    border-color: color-mix(in srgb, var(--color-accent) 50%, transparent);
}

.media-filter-thumb:hover .media-filter-thumb-label,
.media-filter-thumb:focus-visible .media-filter-thumb-label {
    color: var(--color-text);
}

.media-filter-thumb.is-active {
    border-color: var(--color-accent);
}

.media-filter-thumb.is-active .media-filter-thumb-label {
    color: var(--color-accent);
    font-weight: var(--font-weight-semibold);
}

.media-editor-controls {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    flex-wrap: wrap;
}

.media-editor-aspect-group .btn.active {
    /* Cropper aspect chip — active state matches the project's
       accent treatment. */
    background-color: var(--color-accent);
    color: white;
    border-color: var(--color-accent);
}

/* On narrow viewports the controls row stacks vertically. */
@media (max-width: 540px) {
    .media-editor-controls {
        flex-direction: column;
        align-items: stretch;
    }

    .media-editor-rotate-group {
        margin-left: 0 !important;
        justify-content: center;
    }
}

/* ============================================================
   Post-card flash banner — small in-card notification
   ============================================================ */

.post-flash {
    font-size: var(--font-size-md);
}

.post-flash .btn-close {
    font-size: var(--font-size-xs);
    padding: 6px;
}

/* === Dashboard (Phase 11b db1, db4) ============================== */

/* Outer grid container. Always a flex row holding (in order):
     [optional phantom spacer]
     [.dashboard-grid-wrapper holding columns]
     [optional + button]
     [pagination dots, hidden in default mode]
   In default mode, the wrapper just passes columns through with its
   own flex layout. In swipe mode, JS adds .swiper / .swiper-wrapper /
   .swiper-slide classes and SwiperJS owns the wrapper. */
.dashboard-grid {
    display: flex;
    flex-direction: row;
    gap: 1rem;
    align-items: flex-start;
    justify-content: center;
}

/* Inner wrapper. In default mode it's a flex row distributing columns
   side by side. The flex: 1 1 auto + min-width: 0 lets it grow to fill
   available space between the phantom and the + button. */
.dashboard-grid-wrapper {
    display: flex;
    flex-direction: row;
    gap: 1rem;
    align-items: flex-start;
    flex: 1 1 auto;
    min-width: 0;
    /* Bound the wrapper so 1-column users don't get a comically wide
       single column — caps wrapper width to (3 columns + 2 gaps). */
    max-width: calc(540px * 3 + 1rem * 2);
    /* Center the columns inside the wrapper. With 2 or 3 columns,
       the columns naturally fill the wrapper's width via flex-grow,
       so this is a no-op. With 1 column, the column is capped at
       540px and the remaining wrapper width is split evenly on
       either side, centering it. */
    justify-content: center;
}

/* In swipe mode, JS adds .swiper-wrapper to this element. SwiperJS's
   own CSS sets width/transform on .swiper-wrapper. Our flex rules need
   to get out of the way so SwiperJS can measure correctly. */
.dashboard-grid.is-swipe .dashboard-grid-wrapper {
    /* Don't set `display` here — SwiperJS's own CSS sets
       .swiper-wrapper to display: flex, which is what we want for the
       horizontal slide layout. Overriding it breaks the carousel.
       
       Don't set `width` either — SwiperJS computes wrapper width
       based on slide count × slide width × etc, and sets it inline.
       
       We just neutralize our default-mode flex rules so they don't
       fight SwiperJS's layout. */
    flex: initial;
    gap: 0;
    max-width: none;
    min-width: 0;
    width: max-content;
}

/* In swipe mode, the outer grid becomes block so SwiperJS's transform
   on .swiper-wrapper has full container width to translate against. */
.dashboard-grid.is-swipe {
    display: block;
}

/* A single column. Default mode: flex item in .dashboard-grid-wrapper. */
.dashboard-column {
    flex: 1 1 0;
    min-width: 0;
    max-width: 540px;
    background: var(--color-bg-surface);
    border: 1px solid var(--bs-border-color);
    border-radius: var(--bs-border-radius);
    overflow: hidden;
}

/* In swipe mode, kill flex behavior. SwiperJS sets explicit inline widths
   on each .swiper-slide that we mustn't fight. */
.dashboard-grid.is-swipe .dashboard-column {
    flex: none;
    max-width: none;
    min-height: 70vh;
}

/* Phantom spacer and + button hide in swipe mode. */
.dashboard-grid.is-swipe .add-column-phantom,
.dashboard-grid.is-swipe .add-column-btn {
    display: none;
}

/* === Pagination dots (db4) ======================================= */

/* Hidden by default. JS adds .swiper-pagination class in swipe mode. */
.dashboard-swipe-pagination {
    display: none;
}

.dashboard-swipe-pagination.swiper-pagination-bullets {
    display: block;
    position: fixed;
    bottom: calc(1rem + env(safe-area-inset-bottom, 0));
    left: 0;
    right: 0;
    text-align: center;
    z-index: 10;
}

.dashboard-swipe-pagination .swiper-pagination-bullet {
    background: var(--color-text-muted);
    opacity: 0.4;
    width: 8px;
    height: 8px;
    margin: 0 4px;
    transition: opacity var(--transition-medium), background var(--transition-medium);
}

.dashboard-swipe-pagination .swiper-pagination-bullet-active {
    background: var(--color-accent);
    opacity: 1;
}

/* === CSS fallback for narrow viewports (db4) ===================== */

/* Pre-JS / no-JS safety net: at very narrow widths, force columns
   to stack vertically full-width. JS will replace this with proper
   swipe behavior on hydration. */
@media (max-width: 600px) {
    .dashboard-grid:not(.is-swipe) {
        flex-direction: column;
    }

    .dashboard-grid:not(.is-swipe) .dashboard-grid-wrapper {
        flex-direction: column;
        max-width: none;
    }

    .dashboard-grid:not(.is-swipe) .dashboard-column {
        max-width: none;
        width: 100%;
    }

    .dashboard-grid:not(.is-swipe) .add-column-phantom {
        display: none;
    }
}

.dashboard-column-header {
    padding: 0.75rem 1rem;
    border-bottom: 1px solid var(--bs-border-color);
    background: var(--bs-tertiary-bg);
    display: flex;
    align-items: center;
    justify-content: space-between;
}

/* Drag affordance — cursor turns into the grab hand on hover, the
   closed grab hand while dragging. SortableJS doesn't manage these
   directly; the host CSS owns them. */
.dashboard-column-header {
    cursor: grab;
}

.dashboard-column-header:active,
.dashboard-column.dashboard-column-chosen .dashboard-column-header {
    cursor: grabbing;
}

.column-menu-btn {
    background: transparent;
    border: 0;
    color: var(--bs-secondary-color);
    opacity: 0.5;
    padding: 4px;
    border-radius: 4px;
    cursor: pointer;
    transition: opacity 0.15s ease, background 0.15s ease;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    text-decoration: none;
}

.column-menu-btn:hover {
    opacity: 1;
    background: var(--bs-secondary-bg);
}

.dashboard-column-title {
    font-weight: 600;
    text-transform: capitalize;
    /* "feed" → "Feed" without changing data */
    font-size: 0.95rem;
}

.dashboard-column-body {
    padding: 1rem;
}

/* The "+" button at the right end of the row when fewer than 3
   columns exist. */
.add-column-btn {
    flex: 0 0 auto;
    width: 60px;
    height: 60px;
    align-self: flex-start;
    position: sticky;
    top: 80px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: transparent;
    border: 2px dashed var(--bs-border-color);
    border-radius: var(--bs-border-radius);
    color: var(--bs-secondary-color);
    text-decoration: none;
    font-size: 1.5rem;
    font-weight: 300;
    cursor: pointer;
    transition: all 0.15s ease;
}

.add-column-btn:hover {
    border-color: var(--bs-primary);
    color: var(--bs-primary);
    background: var(--bs-primary-bg-subtle);
}

/* Invisible spacer on the left of the column row, the same width
   as the "+" button on the right. Centers the columns visually. */
.add-column-phantom {
    flex: 0 0 auto;
    width: 60px;
    visibility: hidden;
    pointer-events: none;
}

/* ============================================================
   Profile column header (pr24)
   Larger profile-identity block at the top of the Profile
   column body. Distinct from .dashboard-column-header (the
   sticky type-label row) — this is the identity content per
   DESIGN_DECISIONS_VISUAL.md §3.3.
   ============================================================ */

.profile-column-header {
    padding: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    border-bottom: 1px solid var(--bs-border-color-translucent);
}

.profile-column-identity {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-width: 0;
}

.profile-column-identity-text {
    flex: 1 1 auto;
    min-width: 0;
}

.profile-column-name-row {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
}

.profile-column-name {
    font-size: var(--font-size-body);
    min-width: 0;
}

.profile-column-handle {
    /* Inherits text-muted + small from utility classes;
       this rule just guarantees truncation room. */
    min-width: 0;
}

.profile-column-bio {
    font-size: var(--font-size-base);
    line-height: 1.5;
}

.profile-column-meta {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-2);
    flex-wrap: wrap;
}

.profile-column-meta-stats {
    /* Group the post-count and join-date so they wrap together
       on narrow columns, leaving "view full profile →" on the
       right. */
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-1);
}

.profile-column-meta-sep {
    /* Subtle separator dot — slightly more muted than the
       surrounding text so it reads as punctuation, not content. */
    opacity: 0.6;
}

/* Pencil button — same visual language as .freespace-edit-btn
   but slimmer because it sits inline with the display name
   rather than absolute-positioned over a cell. */
.profile-column-edit-btn {
    flex-shrink: 0;
    width: 24px;
    height: 24px;
    display: inline-flex;
    align-items: center;
    justify-content: center;

    background: transparent;
    border: none;
    border-radius: var(--radius-pill);
    color: var(--color-text-muted);

    cursor: pointer;
    transition: background-color var(--transition-fast),
        color var(--transition-fast);
}

.profile-column-edit-btn:hover,
.profile-column-edit-btn:focus-visible {
    background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
    color: var(--color-accent);
    text-decoration: none;
}

/* === Reading-width content wrapper =============================== */

/* Default wrapper class for non-dashboard pages (timeline, drafts,
   new post, post detail, settings, etc). Capped at a comfortable
   reading width so post cards don't sprawl edge-to-edge on wide
   monitors. The dashboard overrides {% block content_wrapper_class %}
   to be empty so it can use the full container width. */
.content-narrow {
    max-width: 600px;
    margin: 0 auto;
}

/* === Media column (Phase 11b db3) ================================ */

/* Square-thumbnail grid. Always 3 tiles per row, regardless of the
   Media column's rendered width — `aspect-ratio: 1 / 1` + `object-fit: cover`
   on the tiles means images scale beautifully from narrow (~340px) to
   wide (~1080px) without needing different counts.
   
   Spec amendment vs. DESIGN_DECISIONS_VISUAL.md §3.4: the original 5/3/3
   responsive count was over-engineered. Three tiles at any width works.
   Logged for the next docs PR. */
.media-column-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 4px;
}

/* A single tile. Square aspect ratio enforced by CSS, so the grid
   lays out cleanly regardless of source image dimensions. The image
   inside is object-fit: cover, so it center-crops without distortion. */
.media-column-tile {
    position: relative;
    display: block;
    aspect-ratio: 1 / 1;
    overflow: hidden;
    border-radius: var(--radius-sm);
    background: var(--color-bg-subtle);
    text-decoration: none;
    color: inherit;
}

.media-column-tile-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* Stacked-cards indicator — top-right badge showing image count
   when a post has more than one image. */
.media-column-stack-indicator {
    position: absolute;
    top: 6px;
    right: 6px;
    background: rgba(0, 0, 0, 0.6);
    color: var(--color-text-on-accent);
    font-size: var(--font-size-xs);
    font-weight: var(--font-weight-semibold);
    padding: 2px 6px;
    border-radius: var(--radius-pill);
    line-height: 1.2;
}

/* Meta overlay (avatar + author name). Hidden by default;
   the desktop hover-reveal and mobile persistent-avatar treatments
   land in a later polish PR. For now: hidden, so tiles read as a
   clean photo grid. */
.media-column-tile-meta {
    display: none;
}

/* === Links column (Phase 11b db3) ================================ */

/* Masonry grid. Always 2 internal columns, regardless of width — same
   reasoning as the Media column (3 fixed): the 3/2/1 responsive count
   in the spec was over-engineered; a fixed 2-up renders cleanly across
   the full width range a Links column actually occupies in v1.
   
   Spec amendment vs. DESIGN_DECISIONS_VISUAL.md §3.5: dropped responsive
   3/2/1 count in favor of fixed 2 columns. Logged for the same docs PR
   that captures the Media count amendment. */
.links-column-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    grid-auto-rows: 8px;
    gap: 12px;
}

/* A single link tile. Image at top, title + domain + attribution below. */
.links-column-tile {
    display: flex;
    flex-direction: column;
    width: 100%;
    min-width: 0;
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    transition: box-shadow var(--transition-medium);
}

.links-column-tile:hover {
    box-shadow: var(--shadow-md);
}

/* Image wrapper — sized to the natural aspect ratio of the OG image
   so masonry's row-span is correct before the image loads. */
.links-column-tile-img-wrap {
    width: 100%;
    background: var(--color-bg-subtle);
}

.links-column-tile-img {
    width: 100%;
    height: auto;
    display: block;
}

/* Body section below the image: title, domain, attribution. */
.links-column-tile-body {
    padding: 8px 10px 10px;
}

.links-column-tile-title {
    font-size: var(--font-size-base);
    font-weight: var(--font-weight-semibold);
    color: var(--color-text);
    line-height: 1.3;
    margin-bottom: 2px;
    /* Truncate to two lines */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.links-column-tile-domain {
    font-size: var(--font-size-xs);
}

.links-column-tile-attribution {
    font-size: var(--font-size-xs);
}

/* === FAB — floating action button (Phase 11b db5) ================ */

/* Base FAB: circular, fixed-position, elevated. The .fab-compose
   modifier picks the actual bottom/right offsets and accent color.
   
   Sized at 56px (Material Design's standard FAB size — the universal
   "pleasant tap target on mobile, visible-but-not-shouty on desktop"
   default).
   
   z-index: 1040 sits above SwiperJS's pagination dots (z-index: 10)
   but below Bootstrap's modal backdrop (1050) and modal (1055), so
   when the modal opens the FAB is correctly hidden behind the
   backdrop. */
.fab {
    position: fixed;
    width: 56px;
    height: 56px;
    border-radius: var(--radius-pill);
    border: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: var(--shadow-lg);
    z-index: 1040;
    transition: transform var(--transition-fast),
        box-shadow var(--transition-medium),
        background var(--transition-fast);
}

.fab:hover {
    transform: translateY(-2px);
    box-shadow: 0 12px 28px rgba(0, 0, 0, 0.16);
}

.fab:active {
    transform: translateY(0);
    box-shadow: var(--shadow-md);
}

.fab:focus-visible {
    outline: 3px solid var(--color-accent);
    outline-offset: 3px;
}

/* The compose-specific FAB. Bottom-right positioning, accent color.
   env(safe-area-inset-*) lets us sit clear of iOS's home indicator
   and notch on phones; falls back to 0 on desktop. */
.fab-compose {
    bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
    right: calc(1.5rem + env(safe-area-inset-right, 0px));
    background: var(--color-accent);
    color: var(--color-text-on-accent);
}

.fab-compose:hover {
    background: var(--color-accent-hover);
}

/* === Compose modal (Phase 11b db5) =============================== */

/* Mobile keyboard handling. Bootstrap's default modal uses 100vh,
   which on iOS doesn't shrink when the keyboard appears — the textarea
   ends up hidden behind it. 100dvh (dynamic viewport height) is the
   visible area minus on-screen UI like the keyboard, so the modal
   fits what the user can actually see.
   
   Only applies inside .modal-dialog-scrollable; non-scrollable modals
   keep Bootstrap's default sizing. */
#composeModal .modal-dialog-scrollable {
    max-height: calc(100dvh - 1rem);
}

/* Tighter padding on mobile so the form has room to breathe inside
   the smaller modal. */
@media (max-width: 600px) {
    #composeModal .modal-dialog {
        margin: 0.5rem;
    }

    #composeModal .modal-body {
        padding: 0.75rem;
    }
}

/* === Post detail panel (Phase 11b db6) ===========================
   
   Panel that slides in over the dashboard when a user clicks a post.
   Singleton — there's exactly one panel element in the DOM on the
   dashboard, hidden by default, populated and shown by
   dashboard_panel.js (Phase D) or by server-side rendering when the
   dashboard is hit with ?post=<id> (Phase B).
   
   Behavior split:
     - Desktop (>= 769px): slides in from the right edge, takes about
       half the viewport width but caps at 540px. Dashboard remains
       partially visible underneath.
     - Mobile (<= 768px): full-viewport takeover, slides up from the
       bottom. Dashboard hidden under the backdrop.
   
   The .is-open class is the on/off switch — JS toggles it; server
   renders it when ?post=<id> hits the dashboard route.
   
   Z-index choices:
     - .post-detail-panel-backdrop: 1040 (just below Bootstrap modals
       at 1050, just above standard dropdowns at 1000)
     - .post-detail-panel: 1041 (above its own backdrop)
   ============================================================ */

.post-detail-panel {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    max-width: 540px;
    background-color: var(--bs-body-bg);
    border-left: 1px solid var(--bs-border-color);
    box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
    z-index: 1041;
    display: flex;
    flex-direction: column;

    /* Hidden by default: pushed off the right edge. Transition makes
       the slide-in/out animate when .is-open is toggled. */
    transform: translateX(100%);
    transition: transform 280ms ease-out;

    /* When hidden, also remove from accessibility tree so screen
       readers don't see the empty panel. visibility (not display)
       so the transition can play out before fully hiding. */
    visibility: hidden;
}

.post-detail-panel.is-open {
    transform: translateX(0);
    visibility: visible;
}

.post-detail-panel-header {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    padding: 12px 16px;
    border-bottom: 1px solid var(--bs-border-color);
    /* Sticky in case the body scrolls — header stays put while
       comments scroll under it. */
    position: sticky;
    top: 0;
    background-color: var(--bs-body-bg);
    z-index: 1;
    flex-shrink: 0;
}

.post-detail-panel-close {
    background: transparent;
    border: 0;
    font-size: 20px;
    line-height: 1;
    color: var(--bs-secondary-color);
    cursor: pointer;
    padding: 4px 8px;
    border-radius: 4px;
    transition: background-color 120ms ease;
}

.post-detail-panel-close:hover,
.post-detail-panel-close:focus-visible {
    background-color: var(--bs-tertiary-bg);
    color: var(--bs-body-color);
    outline: none;
}

.post-detail-panel-body {
    flex: 1;
    overflow-y: auto;
    padding: 16px;
    /* Body scroll lock for the panel — prevents the dashboard
       from scrolling underneath when the user reaches the panel's
       scroll boundary. (overscroll-behavior is well-supported by
       2026; no fallback needed.) */
    overscroll-behavior: contain;
}

/* Constrain images inside the panel — without this, large images
   (per the Phase A test screenshot) blow out the panel width. The
   global .post-image rule already does this, but scoping a panel-
   specific rule with higher specificity is forgiveness for any
   inline <img> that doesn't pick up .post-image. */
.post-detail-panel-body img {
    max-width: 100%;
    height: auto;
}

/* === Backdrop ============================================
   Dims the rest of the viewport when the panel is open.

   Desktop: NOT shown. The dashboard remains visible to the
   left of the panel and clicks on it should reach the
   dashboard (so replace-on-second-click works). A backdrop
   covering the dashboard would intercept those clicks.

   Mobile: shown. The panel is full-screen so the backdrop
   only flashes briefly during the slide-in transition, but
   it provides a clean fade behind the panel.
   ======================================================== */

.post-detail-panel-backdrop {
    position: fixed;
    inset: 0;
    background-color: rgba(0, 0, 0, 0.4);
    z-index: 1040;
    opacity: 0;
    visibility: hidden;
    transition: opacity 280ms ease-out, visibility 280ms ease-out;
    /* Hidden on desktop — only the mobile media query reveals it. */
    display: none;
}

@media (max-width: 768px) {
    .post-detail-panel-backdrop {
        display: block;
    }

    .post-detail-panel-backdrop.is-open {
        opacity: 1;
        visibility: visible;
    }
}

/* === Mobile takeover ====================================
   Below the standard mobile breakpoint, the panel becomes a
   full-viewport takeover that slides up from the bottom
   instead of in from the right. Per DESIGN_DECISIONS.md §5.
   ======================================================== */

@media (max-width: 768px) {
    .post-detail-panel {
        max-width: none;
        width: 100%;
        height: 100dvh;
        border-left: 0;
        border-top: 1px solid var(--bs-border-color);

        /* Slide UP from the bottom on mobile, not in from the right.
           Override the desktop transform. */
        transform: translateY(100%);
    }

    .post-detail-panel.is-open {
        transform: translateY(0);
    }

    .post-detail-panel-header {
        /* Mobile: account for iOS safe-area inset at the top so the
           close button isn't tucked under the notch / status bar. */
        padding-top: max(12px, env(safe-area-inset-top));
    }

    .post-detail-panel-body {
        /* Mobile: account for iOS home indicator at the bottom. */
        padding-bottom: max(16px, env(safe-area-inset-bottom));
    }
}

/* === Body scroll lock when panel is open ================
   When the panel is open, prevent the underlying document
   from scrolling. The panel body has its own scroll. JS in
   Phase D toggles this class on <body>.
   ======================================================== */

body.panel-open {
    overflow: hidden;
}

/* Cursor pointer on dashboard post cards — hint that they're clickable
   to open the panel. Doesn't apply elsewhere (e.g. on the standalone
   post detail page) because .panel-clickable is only on cards
   rendered inside the dashboard's columns. */
.dashboard-grid .panel-clickable {
    cursor: pointer;
}

/* Subtle hover lift on dashboard post cards. Optional polish. */
.dashboard-grid .panel-clickable:hover {
    background-color: var(--bs-tertiary-bg);
}

/* ============================================================
   Navbar — expandable icon row
   ============================================================
   The avatar button toggles a row of icons (users, message,
   drafts, new-post) that slides out to its right. Two trigger
   conditions:
     1. Hover anywhere on .nav-center (mouse users get instant
        reveal without clicking).
     2. .is-expanded class on .nav-center (set by JS on avatar
        click — needed for touch devices and keyboard users).
   The collapsed state uses max-width + opacity rather than
   display:none so the transition has something to animate.
   ============================================================ */

/* Full-width navbar container — anchored to the page edges. */
.nav-container {
    display: flex;
    align-items: center;
    width: 100%;
    padding: 0 var(--space-4);
    gap: var(--space-4);
    /* No justify-content: the wordmark + .nav-center hug the
       left, .nav-end is pushed to the right with margin-left:auto. */
}

/* Wordmark stays anchored at the very left. */
.nav-container .navbar-brand {
    flex-shrink: 0;
}

/* Center cluster — left-anchored, hugs the wordmark. */
.nav-center {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    /* No flex-grow — this cluster is exactly as wide as its
       contents, no more. The expandable row growing inside it
       doesn't push anything else around because .nav-end uses
       margin-left:auto to claim its right-edge spot independently. */
}

/* Right-anchored zone (gear). */
.nav-end {
    margin-left: auto;
    flex-shrink: 0;
}

/* Nav avatar button — wraps the .avatar element from the macro
   and adds button-specific hover/focus behavior. The .avatar
   inside provides sizing. */
.nav-avatar-btn {
    padding: 0;
    border: 0;
    background: transparent;
    flex-shrink: 0;
    border-radius: 50%;
    /* Subtle scale on hover for tactile feedback. */
    transition: transform var(--transition-fast),
        box-shadow var(--transition-fast);
}

.nav-avatar-btn:hover {
    transform: scale(1.05);
}

.nav-avatar-btn:focus-visible {
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 40%, transparent);
    outline: none;
}

/* The expandable row — collapsed by default. */
.nav-expandable {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    /* max-width animates from 0 to a generous upper bound.
       opacity fades the icons during the slide. */
    max-width: 0;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
    /* iOS-feeling cubic-bezier — gentler deceleration than
       Material's standard. Duration tuned down from the slow
       (350ms) token to medium (150ms) for a snappier feel. */
    transition: max-width 280ms cubic-bezier(0.32, 0.72, 0, 1),
        opacity 200ms ease,
        margin 280ms cubic-bezier(0.32, 0.72, 0, 1);
}

/* Reveal: hover on the center cluster (mouse) OR explicit
   .is-expanded class (set by JS for click/touch/keyboard). */
.nav-center:hover .nav-expandable,
.nav-center.is-expanded .nav-expandable {
    max-width: 220px;
    opacity: 1;
    pointer-events: auto;
    overflow: visible;
}

/* Per-icon stagger — much shorter delays, much shorter slide
   distance. The whole reveal completes in ~330ms instead of
   ~550ms. */
.nav-expandable>* {
    transform: translateX(-4px);
    transition: transform 180ms cubic-bezier(0.32, 0.72, 0, 1);
}

.nav-center:hover .nav-expandable>*,
.nav-center.is-expanded .nav-expandable>* {
    transform: translateX(0);
}

.nav-expandable>*:nth-child(1) {
    transition-delay: 30ms;
}

.nav-expandable>*:nth-child(2) {
    transition-delay: 60ms;
}

.nav-expandable>*:nth-child(3) {
    transition-delay: 90ms;
}

.nav-expandable>*:nth-child(4) {
    transition-delay: 120ms;
}

/* On collapse, kill the stagger so they retract together. */
.nav-expandable:not(.is-expanded):not(:hover)>* {
    transition-delay: 0ms;
}

/* Subtle hover/focus state on every nav icon button. */
.nav-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-muted);
    border-radius: var(--radius-sm);
    padding: var(--space-1);
    transition: color var(--transition-fast),
        background-color var(--transition-fast),
        transform var(--transition-fast);
}

.nav-icon-btn:hover {
    color: var(--color-text);
    background-color: var(--color-bg-subtle);
}

/* Focus ring for keyboard users — visible only on keyboard
   focus, not mouse click. :focus-visible is the modern selector
   that respects this distinction. */
.nav-icon-btn:focus-visible {
    color: var(--color-text);
    outline: none;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 40%, transparent);
}

/* Reduced-motion respect. */
@media (prefers-reduced-motion: reduce) {

    .nav-expandable,
    .nav-expandable>*,
    .nav-avatar-btn,
    .nav-icon-btn {
        transition: none;
    }

    .nav-expandable>* {
        transform: none;
    }
}

/* ============================================================
   Post media carousel — multi-image posts (pr11)
   ============================================================
   SwiperJS-driven horizontal carousel for posts with 2+ images.
   Single-image posts skip this entirely — they render as a plain
   <img class="post-image"> outside any carousel container.

   Markup contract (set in _post_card.html / post_detail.html):
     .media-carousel.swiper
       └── .swiper-wrapper
             └── .swiper-slide × N (each containing an <img>)
       └── .media-carousel-pagination.swiper-pagination
       └── .media-carousel-prev (button)
       └── .media-carousel-next (button)
   ============================================================ */

/* Container — establishes the box the slides live inside.
   Aspect ratio caps the height so very tall portrait images
   don't blow out the card. 4:3 felt like a good middle ground
   between landscape-friendly (16:9 too letterboxy for portrait)
   and portrait-friendly (1:1 wastes space for landscape). */
.media-carousel {
    position: relative;
    width: 100%;
    aspect-ratio: 4 / 3;
    max-height: 600px;
    overflow: hidden;
    border-radius: var(--radius-md);
    background-color: var(--color-bg-subtle);
}

/* Each slide fills the carousel; the image inside is constrained
   by the slide. object-fit: contain keeps the whole image
   visible (no cropping), with letterboxing if needed against the
   container's background-color. */
.media-carousel .swiper-slide {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
}

.media-carousel .swiper-slide img.post-image {
    width: 100%;
    height: 100%;
    object-fit: contain;
    /* Override the global .post-image max-width:100% rule's
       implicit height:auto so the image actually fills the slide. */
}

/* === Pagination (dot indicator) ============================
   SwiperJS renders bullets as <span> inside this container.
   We override its defaults to use our accent token. */

.media-carousel .media-carousel-pagination.swiper-pagination {
    position: absolute;
    bottom: var(--space-2);
    left: 0;
    right: 0;
    text-align: center;
    z-index: 2;
    /* Don't intercept clicks on the slide itself. */
    pointer-events: none;
}

.media-carousel .swiper-pagination-bullet {
    display: inline-block;
    width: 7px;
    height: 7px;
    margin: 0 3px;
    border-radius: 50%;
    background-color: var(--color-bg-surface);
    opacity: 0.7;
    box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
    transition: background-color var(--transition-fast),
        opacity var(--transition-fast),
        transform var(--transition-fast);
    /* Re-enable clicks on bullets specifically — the parent
       has pointer-events: none so the carousel image stays
       clickable everywhere except the dots themselves. */
    pointer-events: auto;
    cursor: pointer;
    border: 0;
}

.media-carousel .swiper-pagination-bullet-active {
    background-color: var(--color-accent);
    opacity: 1;
    transform: scale(1.15);
}

/* === Arrow buttons =========================================
   Hidden by default; appear on hover (desktop) or focus
   (keyboard). Hidden entirely on touch devices — swiping is
   the primary interaction. */

.media-carousel-prev,
.media-carousel-next {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background-color: color-mix(in srgb, var(--color-bg-surface) 80%, transparent);
    color: var(--color-text);
    border: 0;
    font-size: 22px;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    z-index: 3;
    opacity: 0;
    transition: opacity var(--transition-fast),
        background-color var(--transition-fast);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
}

.media-carousel-prev {
    left: var(--space-2);
}

.media-carousel-next {
    right: var(--space-2);
}

/* Reveal on hover (mouse) or focus-within (keyboard). */
.media-carousel:hover .media-carousel-prev,
.media-carousel:hover .media-carousel-next,
.media-carousel:focus-within .media-carousel-prev,
.media-carousel:focus-within .media-carousel-next {
    opacity: 1;
}

.media-carousel-prev:hover,
.media-carousel-next:hover {
    background-color: var(--color-bg-surface);
}

/* Focus ring — matches the rest of the app. */
.media-carousel-prev:focus-visible,
.media-carousel-next:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 40%, transparent);
}

/* SwiperJS adds .swiper-button-disabled when at first/last slide
   (since loop: false). Dim those instead of hiding — the user
   still sees the affordance but knows it won't do anything. */
.media-carousel-prev.swiper-button-disabled,
.media-carousel-next.swiper-button-disabled {
    opacity: 0.3 !important;
    cursor: default;
}

/* On touch devices, hide arrows entirely — swipe is primary.
   hover: none catches touch devices reliably. */
@media (hover: none) {

    .media-carousel-prev,
    .media-carousel-next {
        display: none;
    }
}

/* Reduced motion — disable all transitions. SwiperJS's own
   speed: 0 (set in JS) handles slide transitions; this catches
   our hover/dot animations. */
@media (prefers-reduced-motion: reduce) {

    .media-carousel-prev,
    .media-carousel-next,
    .media-carousel .swiper-pagination-bullet {
        transition: none;
    }
}

/* ============================================================
   Edit carousel — remove-active-slide button
   ============================================================
   Pinned top-right of the carousel container, applies to whichever
   slide is currently active. Distinct from .remove-media-btn (which
   was per-slide); this button is per-carousel.
   ============================================================ */
.remove-active-media-btn {
    position: absolute;
    top: var(--space-2);
    right: var(--space-2);
    width: 32px;
    height: 32px;
    border-radius: 50%;
    background-color: color-mix(in srgb, var(--color-bg-surface) 85%, transparent);
    color: var(--color-text);
    border: 0;
    font-size: 18px;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    z-index: 4;
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    transition: background-color var(--transition-fast),
        color var(--transition-fast),
        transform var(--transition-fast);
}

.remove-active-media-btn:hover {
    background-color: var(--color-danger, #dc3545);
    color: white;
    transform: scale(1.05);
}

.remove-active-media-btn:focus-visible {
    outline: none;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 40%, transparent);
}

@media (prefers-reduced-motion: reduce) {
    .remove-active-media-btn {
        transition: none;
    }
}

/* ============================================================
   Media thumb polish — enter/leave animations + zoom modal (pr12)
   ============================================================
   Builds on the existing .media-thumb-edit / .remove-media-btn rules.
   Adds: responsive sizing on small screens, fade-in on add, fade-out
   on remove, larger touch hit-target for the remove button, and the
   chrome-less zoom modal that opens on thumb click.
   ============================================================ */

/* === Responsive sizing ====================================
   Override the fixed 80×80 in the existing .media-thumb-edit rule
   on narrow viewports. clamp() gives us a smooth shrink from
   96px on tablets to 64px on small phones. */
.media-thumb-edit {
    width: clamp(64px, 18vw, 96px);
    height: clamp(64px, 18vw, 96px);
    /* Both the enter and leave transitions ride on opacity + transform.
       Defined here so the base state has a transition to animate against. */
    transition: opacity 240ms ease-out, transform 240ms ease-out;
    opacity: 1;
    transform: scale(1);
}

/* === Enter animation =====================================
   .media-thumb-enter is added on creation, then removed on the
   next animation frame by JS. CSS transitions back to the base
   state, which is opacity:1 + scale(1). */
.media-thumb-edit.media-thumb-enter {
    opacity: 0;
    transform: scale(0.85);
}

/* === Leave animation =====================================
   .media-thumb-leaving is added by fadeOutAndRemove() before
   the DOM element gets removed. */
.media-thumb-edit.media-thumb-leaving {
    opacity: 0;
    transform: scale(0.85);
    pointer-events: none;
}

/* === Touch hit-target ====================================
   On touch devices, the 20×20 remove button is genuinely hard to
   tap accurately. Bump to 28×28 — Apple's HIG recommends 44pt
   minimum, but 28px is the largest we can go without the button
   overlapping the thumb on small viewports. The pseudo-element
   technique would let us extend the hit area beyond the visual
   size, but for now this is enough.

   Desktop keeps the original compact 20×20 (defined in the existing
   .remove-media-btn rule). */
@media (hover: none) {
    .remove-media-btn {
        width: 28px;
        height: 28px;
        font-size: var(--font-size-lg);
        top: -8px;
        right: -8px;
    }
}

/* === Zoomable image affordance ===========================
   Visual hint that the image is clickable. Subtle scale-up on
   hover (mouse) and focus (keyboard), pointer cursor everywhere. */
.media-thumb-edit img[data-zoomable="true"] {
    cursor: zoom-in;
    transition: transform var(--transition-fast),
        box-shadow var(--transition-fast);
}

.media-thumb-edit img[data-zoomable="true"]:hover {
    transform: scale(1.05);
}

/* Keyboard focus ring — same pattern as nav/carousel. */
.media-thumb-edit img[data-zoomable="true"]:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 40%, transparent);
    transform: scale(1.05);
}

/* === Zoom modal ===========================================
   Override Bootstrap's default modal chrome. We want a dim
   background + the image floating in the center, no card body,
   no header, no footer. */

.media-zoom-modal .modal-dialog {
    /* modal-lg is 800px max-width; this lets us go wider on big
       screens without overflowing. */
    max-width: min(90vw, 1200px);
    margin: var(--space-4) auto;
}

.media-zoom-modal .modal-content.media-zoom-content {
    background-color: transparent;
    border: 0;
    box-shadow: none;
    align-items: center;
    /* The image inside is centered; the modal-content collapses
       to fit the image's intrinsic size. */
}

/* When THIS modal is open, darken the backdrop. The `body.media-zoom-open`
   class is added/removed by our JS so the rule only applies during
   actual zoom-modal display, not during dismissal animation. This
   avoids fighting Bootstrap's cleanup logic. */
body.media-zoom-open .modal-backdrop {
    opacity: 0.85;
}

/* The close button — sits absolute top-right of the image,
   white so it's visible against any image. Overrides Bootstrap's
   default .btn-close (which is a dark X intended for white-card
   modals). */
.media-zoom-close {
    position: absolute;
    top: var(--space-2);
    right: var(--space-2);
    z-index: 2;
    /* The default btn-close is a small SVG-background X; we want
       it light-on-dark for visibility against arbitrary images. */
    filter: invert(1) brightness(1.2);
    /* Subtle dark backing for contrast against light images. */
    background-color: rgba(0, 0, 0, 0.4);
    border-radius: 50%;
    padding: var(--space-2);
    opacity: 0.9;
    transition: opacity var(--transition-fast), transform var(--transition-fast);
}

.media-zoom-close:hover,
.media-zoom-close:focus-visible {
    opacity: 1;
    transform: scale(1.1);
    outline: none;
}

/* === The zoomed image ====================================
   Constrain to the viewport so massive full-res phone photos
   don't overflow. object-fit keeps aspect ratio intact. */
.media-zoom-image {
    max-width: 100%;
    max-height: 85vh;
    width: auto;
    height: auto;
    object-fit: contain;
    border-radius: var(--radius-md);
    /* Subtle shadow lifts the image off the dark backdrop. */
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}

/* === Reduced motion ======================================
   Disable all the polish transitions for users with the OS
   preference set. The DOM removal still happens (via the JS
   setTimeout fallback), the fade just becomes instant. */
@media (prefers-reduced-motion: reduce) {

    .media-thumb-edit,
    .media-thumb-edit img[data-zoomable="true"],
    .media-zoom-close {
        transition: none;
    }

    .media-thumb-edit.media-thumb-enter,
    .media-thumb-edit.media-thumb-leaving {
        /* Skip the scale, keep the opacity change so JS still
           sees the element transition through opacity:0. */
        transform: none;
    }
}

/* ============================================================
   Avatar hover-preview popover (pr22)
   ============================================================ */

.avatar-popover {
    max-width: 280px;
}

.avatar-card {
    /* Bootstrap's .popover-body already provides padding. */
}

.avatar-card-bio {
    line-height: 1.4;
    color: var(--bs-body-color);
    word-break: break-word;
}

.avatar-card-stats {
    display: flex;
    align-items: baseline;
}

/* Avatar links — let the underlying avatar styling carry through;
   we just need to remove the default <a> underline / color tint and
   make the link inline with the avatar's wrapper sizing. */

.avatar-link {
    display: inline-block;
    text-decoration: none;
    color: inherit;
    line-height: 0;
    /* Prevents extra vertical space from inline-block + img baseline */
}

.avatar-link:hover,
.avatar-link:focus {
    text-decoration: none;
}

.avatar-card-loading {
    text-align: center;
    padding: var(--space-2);
}

/* ============================================================
   Pinned post (pr16a — wave 1)
   ============================================================ */

.pinned-post-wrapper {
    /* Subtle visual lift so the pinned post reads as elevated. The
       label above already telegraphs "this is pinned"; we just want
       a quiet styling cue, not a big highlight. */
}

.pinned-post-label {
    /* The "📌 pinned to profile" row above the post. Lives outside
       the .card so it doesn't fight post-card styling. */
    padding-left: var(--space-2);
}

/* ============================================================
   Profile free-space (pr16b wave 2)
   ============================================================
   The cell that sits next to the avatar block on a profile page.
   Visible to the profile owner always (with the empty-state CTA);
   visible to visitors only when a mode is configured.

   Visual treatment: light violet border with a subtle inner gradient
   fading the border into the surface near the corners. Same vibe as
   the compose modal — reads as "interactive container," not just
   a random rectangle.
   ============================================================ */

.profile-header-identity {
    /* Lets the column shrink as the row resizes. */
    min-width: 0;
}

.profile-header-freespace {
    /* Same — the cell content shouldn't force horizontal overflow. */
    min-width: 0;
}

.freespace-cell {
    /* Border-as-gradient pattern: a translucent violet that's
       strongest in the middle of each edge and fades into the
       surface near the corners. We achieve this by layering a
       background-image (the gradient) on a transparent border —
       but a simpler-and-still-good approach is a soft solid
       border at the accent color with reduced opacity. We can
       upgrade to the gradient treatment in a polish pass. */
    border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
    border-radius: var(--radius-md);
    padding: var(--space-4);
    background-color: color-mix(in srgb, var(--color-accent) 3%, var(--color-bg-surface));
    min-height: 100%;

    /* The cell stretches to match the left column's height when the
       left is taller. When the cell is taller, the card grows. */
    display: flex;
    flex-direction: column;
    justify-content: center;
}

.freespace-cell--unframed {
    border-color: transparent;
    background-color: transparent;
    padding: 0;
}

/* ============================================================
   Free-space cell — owner hover scale (pr16b wave 4 polish)
   ============================================================ */
.freespace-cell--unframed.freespace-cell--owner:hover,
.freespace-cell--unframed.freespace-cell--owner:focus-within {
    border-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
    background-color: color-mix(in srgb, var(--color-accent) 2%, var(--color-bg-surface));
    padding: var(--space-2);
    transition: border-color var(--transition-medium),
        background-color var(--transition-medium),
        padding var(--transition-medium);
}

.freespace-cell--unframed {
    transition: border-color var(--transition-medium),
        background-color var(--transition-medium),
        padding var(--transition-medium);
}

.freespace-cell--owner {
    transform: scale(1);
    transform-origin: center;
    transition: transform var(--transition-medium),
        border-color var(--transition-medium),
        background-color var(--transition-medium),
        padding var(--transition-medium);
}

.freespace-cell--owner:hover,
.freespace-cell--owner:focus-within {
    transform: scale(1.02);
}

/* override the media image hover scale */
.freespace-cell--owner .freespace-media-link:hover .freespace-media-image,
.freespace-cell--owner .freespace-media-link:focus-visible .freespace-media-image {
    transform: none;
}

@media (prefers-reduced-motion: reduce) {

    .freespace-cell--owner,
    .freespace-cell--owner:hover,
    .freespace-cell--owner:focus-within {
        transform: none;
        transition: border-color var(--transition-medium),
            background-color var(--transition-medium),
            padding var(--transition-medium);
    }
}

/* Empty-state CTA (owner only) */
.freespace-empty-cta {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    width: 100%;
    min-height: 80px;
    color: var(--color-text-muted);
    text-decoration: none;
    font-size: var(--font-size-body);
    border-radius: var(--radius-sm);
    transition: color var(--transition-fast),
        background-color var(--transition-fast);
}

.freespace-empty-cta:hover,
.freespace-empty-cta:focus-visible {
    color: var(--color-accent);
    background-color: color-mix(in srgb, var(--color-accent) 6%, transparent);
}

.freespace-empty-plus {
    font-size: 1.5rem;
    font-weight: var(--font-weight-light);
    line-height: 1;
}

/* Mobile: the row already stacks via Bootstrap's col-md-* classes
   (cells become full width below 768px). Just clean up spacing. */
@media (max-width: 767.98px) {
    .freespace-cell {
        margin-top: var(--space-3);
    }
}

/* ---- Edit pencil button (owner only) -------------------- */

.freespace-cell {
    /* Re-declared here as a positioning context for the edit btn. */
    position: relative;
}

.freespace-edit-btn {
    position: absolute;
    top: var(--space-2);
    right: var(--space-2);
    width: 28px;
    height: 28px;
    padding: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;

    background: transparent;
    border: none;
    border-radius: var(--radius-pill);
    color: var(--color-text-muted);

    cursor: pointer;
    transition: background-color var(--transition-fast),
        color var(--transition-fast);

    /* Above the empty-state CTA so it's clickable through it. */
    z-index: 2;
}

.freespace-edit-btn:hover,
.freespace-edit-btn:focus-visible {
    background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
    color: var(--color-accent);
}

/* ---- Modal mode picker ---------------------------------- */

.freespace-mode-picker {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.freespace-mode-option {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-sm);
    cursor: pointer;
    transition: background-color var(--transition-fast);
}

.freespace-mode-option:hover {
    background-color: color-mix(in srgb, var(--color-accent) 6%, transparent);
}

.freespace-mode-option.is-disabled {
    cursor: not-allowed;
    opacity: 0.5;
}

.freespace-mode-option.is-disabled:hover {
    background-color: transparent;
}

.freespace-mode-label {
    font-size: var(--font-size-body);
    color: var(--color-text);
}

.freespace-mode-option.is-disabled .freespace-mode-label {
    color: var(--color-text-muted);
}

/* The empty-state CTA was an <a> in step 3 but is now a <button>.
   Reset Bootstrap's button defaults so it visually matches what we
   had before. */
button.freespace-empty-cta {
    background: transparent;
    border: none;
    text-align: center;
}

/* ---- Mode A (text) rendering ---------------------------- */

.freespace-content-text {
    word-break: break-word;
    font-size: var(--font-size-body);
    color: var(--color-text);
    line-height: 1.5;
}

.freespace-content-text p {
    white-space: pre-wrap;
    margin-bottom: var(--space-2);
}

/* ---- Style presets for mode A (text) -------------------- */

/* Each preset overrides one or more aspects of the base
   .freespace-content-text styling. NULL preset uses base styling.

   Design intent: each preset is a coherent "voice" for the cell.
   Not a la carte. Users pick a preset, not individual controls. */

.freespace-preset-default {
    /* Identical to base styling. Declared explicitly so the picker
       UI has a "default" option that the user can select to revert
       from another preset without it being a no-op or confusing. */
    font-family: var(--font-sans);
    font-size: var(--font-size-body);
    font-weight: var(--font-weight-regular);
    line-height: 1.5;
    text-align: left;
}

.freespace-preset-emphasis {
    /* Pull-quote feel — large, light, centered, generous line-height.
       Geist Light (300) was self-hosted in pr6a so this works without
       a font-fetching delay. */
    font-family: var(--font-sans);
    font-size: 1.5rem;
    font-weight: var(--font-weight-light);
    line-height: 1.4;
    text-align: center;
    /* Slight letter-spacing tightening, which large light text usually
       benefits from. */
    letter-spacing: -0.01em;
}

.freespace-preset-tight {
    /* Info-density treatment. Smaller text, tight line-height.
       Useful for "fast facts" or bullet-list bios. */
    font-family: var(--font-sans);
    font-size: var(--font-size-base);
    font-weight: var(--font-weight-regular);
    line-height: 1.3;
    text-align: left;
}

.freespace-preset-mono {
    /* Computer voice. Geist Mono (self-hosted, pr6a). Body-size for
       readability — mono fonts are larger-feeling per character so
       keeping at body-size avoids visual heaviness. */
    font-family: var(--font-mono);
    font-size: var(--font-size-base);
    font-weight: var(--font-weight-regular);
    line-height: 1.5;
    text-align: left;
}

/* Markdown output styling — make sure rendered <strong>, <em>, etc.
   look right inside the cell regardless of preset. The base rules
   here are inherited by all presets unless overridden. */

.freespace-content-text strong {
    /* Use semibold rather than bold; reads cleaner alongside Geist's
       weight range. */
    font-weight: var(--font-weight-bold);
}

.freespace-content-text em {
    font-style: italic;
}

.freespace-content-text s {
    /* Strikethrough is fine at default; just make sure the line is
       visible against muted text. */
    text-decoration-color: currentColor;
}

.freespace-content-text code {
    font-family: var(--font-mono);
    font-size: 0.9em;
    background-color: var(--color-bg-subtle);
    padding: 1px 4px;
    border-radius: var(--radius-sm);
}

.freespace-content-text a {
    color: var(--color-accent);
    text-decoration: underline;
    text-underline-offset: 2px;
}

.freespace-content-text a:hover {
    color: var(--color-accent-hover);
}

/* ---- Markdown list spacing (consolidated) -------------- 
   Markdown lists get fully-controlled spacing here to avoid the
   layered-margin-compounding problem (li margin + p margin inside
   li + ul vertical rhythm all stacking). Override everything and
   own the values explicitly. */

.freespace-content-text ul,
.freespace-content-text ol {
    padding-left: var(--space-5);
    margin: var(--space-2) 0 0 0;
}

.freespace-content-text li {
    margin: 0;
    line-height: 1.5;
}

.freespace-content-text li+li {
    margin-top: var(--space-1);
}

.freespace-content-text li p {
    margin: 0;
}

.freespace-content-text p:last-child {
    margin-bottom: 0;
}

/* When the free-space cell is rendering, the left column shrinks to
   col-md-4 — a narrow rail. Center its contents both horizontally
   AND vertically inside the rail so the identity block sits like a
   well-balanced "card" against the cell on its right. Forms inside
   the column (follow / unfollow) are block elements so text-align
   alone doesn't center them; flex centering picks them up. */
.profile-header-identity>.text-center {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: 100%;
}

/* The follow/unfollow forms are block-level so they'd default to
   full column width. Constrain them to their natural button width
   so the centering above does its job. */
.profile-header-identity>.text-center>form {
    margin: 0;
}

/* ---- Toolbar above the textarea (pr16b wave 2) ---------- */

.freespace-toolbar {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    padding: var(--space-1) var(--space-2);
    background-color: var(--color-bg-subtle);
    border: 1px solid var(--color-border);
    border-bottom: none;
    border-top-left-radius: var(--radius-sm);
    border-top-right-radius: var(--radius-sm);
}

.freespace-toolbar-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 28px;
    height: 28px;
    padding: 0 var(--space-2);

    background: transparent;
    border: none;
    border-radius: var(--radius-sm);
    color: var(--color-text-muted);
    font-size: var(--font-size-base);
    line-height: 1;
    cursor: pointer;

    transition: background-color var(--transition-fast),
        color var(--transition-fast);
}

.freespace-toolbar-btn:hover,
.freespace-toolbar-btn:focus-visible {
    background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
    color: var(--color-accent);
    outline: none;
}

/* The icon-based button (list) needs to vertically center the SVG;
   the text-based buttons (B/I/S) don't. Both end up looking right
   because the flex centering on the button itself handles either. */
.freespace-toolbar-btn svg {
    display: block;
}

/* The inline-code button shows "<>" which needs a slightly tighter
   font-size to match the visual weight of B/I/S. */
.freespace-toolbar-btn code {
    background: transparent;
    padding: 0;
    font-size: var(--font-size-sm);
    color: inherit;
}

/* Live character counter — sits in the form-text row beside the
   syntax hint. Subtle until you approach the limit, then warning,
   then danger. */
.freespace-char-count {
    flex-shrink: 0;
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
    /* tabular-nums keeps digit widths fixed so the counter doesn't
       jitter as numbers change. Cheap polish. */
}

.freespace-char-count.is-warning {
    color: var(--color-warning);
}

.freespace-char-count.is-danger {
    color: var(--color-danger);
    font-weight: var(--font-weight-semibold);
}

/* Make the textarea sit flush below the toolbar — no top border,
   no top radii — so the toolbar and textarea read as one integrated
   input rather than two stacked controls. */
.freespace-textarea {
    border-top-left-radius: 0 !important;
    border-top-right-radius: 0 !important;
    /* The toolbar's bottom edge already provides the visual divider. */
    border-top: 1px solid var(--color-border);
    resize: vertical;
}

.freespace-textarea:focus {
    /* When the textarea is focused, ring the entire toolbar+textarea
       combo. The toolbar gets a sibling rule below for the top half. */
    border-color: var(--color-accent);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}

/* Wrap the toolbar's border color to match when the textarea is
   focused — keeps the visual integrity of "one input." */
.freespace-textarea:focus~* .freespace-toolbar,
.freespace-subform:focus-within .freespace-toolbar {
    border-color: var(--color-accent);
}

/* ============================================================
   Profile free-space — wave 3 (modes B and C)
   ============================================================ */

/* ---- Mode B (media) rendering --------------------------- */

.freespace-content-media {
    /* The cell takes the image's natural aspect ratio — no
       letterboxing or cropping. The browser computes the
       intrinsic ratio from the <img>'s width/height attributes,
       so cell height = (cell width × image-height / image-width),
       capped at max-height below. */
    width: 100%;
    border-radius: var(--radius-sm);
    overflow: hidden;
}

.freespace-media-link {
    display: block;
    /* Width fills the cell; height collapses to the image's
       natural height (bounded by max-height on the img). */
    width: 100%;
    cursor: pointer;
    /* Anchor inherits the wrapper's overflow:hidden so the
       hover-zoom transform stays clipped to the cell. */
    overflow: hidden;
    border-radius: var(--radius-sm);
}

.freespace-media-image {
    /* Fill the width of the cell. Height auto-scales from the image's intrinsic ratio (via the width/height HTML
       attributes the template renders). max-height caps very tall portraits so they don't dominate the header on
       desktop — adjust to taste. */
    width: 100%;
    height: auto;
    max-height: 460px;
    /* When max-height clamps a tall image, object-fit:contain keeps the whole image visible inside the clamped box 
    rather than cropping. For images that aren't clamped, object-fit has no effect (the box matches the image). */
    object-fit: contain;
    display: block;
    transition: transform var(--transition-base);
}

/* ---- Mode B (video) rendering — pr25b ----
   Sized to match the image-mode container. Native controls bar 
   provided by the browser; we just constrain dimensions and match the rounded-corner styling. */
.freespace-media-video {
    display: block;
    width: 100%;
    height: auto;
    max-height: 460px;
    border-radius: var(--radius-sm);
    background-color: #000;
}

.freespace-media-link:hover .freespace-media-image,
.freespace-media-link:focus-visible .freespace-media-image {
    /* Subtle zoom-on-hover affordance — signals interactivity without being noisy. Matches the visual language used on
       post-card media hover. */
    transform: scale(1.02);
}

.freespace-media-link:focus-visible {
    /* Keyboard focus needs an explicit ring since the <img> itself isn't the focus target — the <a> is. */
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}

/* ---- Mode C (single link) rendering --------------------- */

.freespace-content-link {
    /* Caption (if any) renders below the card. The wrapper just provides a vertical stack context. */
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.freespace-link-card {
    /* The whole card is one clickable anchor. Flex layout: image on the left (when present), body on the right.
       On narrow viewports the card stacks vertically — image on top, body below — see the mobile media query at the bottom of this block. */
    display: flex;
    align-items: stretch;
    gap: 0;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    background-color: var(--color-bg-surface);
    transition: border-color var(--transition-fast),
        background-color var(--transition-fast);
}

.freespace-link-card:hover,
.freespace-link-card:focus-visible {
    border-color: var(--color-accent);
    background-color: color-mix(in srgb, var(--color-accent) 4%, var(--color-bg-surface));
    outline: none;
}

.freespace-link-card-image {
    /* Fixed-width image rail on the left of the card. Image fills its container with object-fit: cover so it never distorts. */
    flex: 0 0 120px;
    background-color: var(--color-bg-subtle);
    overflow: hidden;
}

.freespace-link-card-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.freespace-link-card-body {
    flex: 1 1 auto;
    padding: var(--space-3);
    /* Vertical centering when the body is shorter than the image, which is the common case for OG cards with just title + URL. */
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: var(--space-1);
    min-width: 0;
    /* min-width: 0 fixes flex-item-overflow-with-ellipsis — without it, long titles can push the card wider than its container. */
}

.freespace-link-card-title {
    font-weight: var(--font-weight-semibold);
    color: var(--color-text);
    line-height: 1.3;
    /* Clamp the title to two lines max — long page titles otherwise balloon the card height. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.freespace-link-card-description {
    /* One-line clamp on description; full text is on the linked page itself, so truncating in the card is fine. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.freespace-link-card-url {
    /* URL is the tertiary visual element — small, single-line, truncated with ellipsis. Tells the user where the link goes without dominating the card. */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Stack the link card vertically on narrow viewports — the horizontal layout starts looking cramped below ~520px. 
The cell itself is full-width below the md breakpoint (768px), so the card has room until the viewport itself gets narrow. */
@media (max-width: 519.98px) {
    .freespace-link-card {
        flex-direction: column;
    }

    .freespace-link-card-image {
        flex: 0 0 auto;
        height: 140px;
        width: 100%;
    }
}

/* ---- Caption (shared by modes B and C) ----------------- */

.freespace-caption {
    /* Same visual treatment as the bio — small, muted, with mentions/links inheriting their accent color from the global anchor rules. */
    line-height: 1.4;
}

.freespace-caption a {
    color: var(--color-accent);
    text-decoration: underline;
    text-underline-offset: 2px;
}

.freespace-caption a:hover {
    color: var(--color-accent-hover);
}

/* ============================================================
   Free-space Mode E — post reference (pr16b wave 4)
   ============================================================ */

/* ----- Picker UI (inside modal) ----- */

.freespace-post-picker[data-state="searching"] .freespace-post-picker-selected {
    display: none;
}

.freespace-post-picker[data-state="selected"] .freespace-post-picker-search {
    display: none;
}

.freespace-post-results {
    max-height: 320px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    /* No outer border — each row is its own card. */
    background: transparent;
    padding: 0.25rem;
}

.freespace-post-result-row {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    width: 100%;
    padding: 0.75rem;
    text-align: left;
    color: var(--bs-body-color);
    background: var(--bs-tertiary-bg);
    border: 1px solid var(--bs-border-color);
    border-radius: var(--bs-border-radius);
    cursor: pointer;
    transition: background-color 120ms ease, border-color 120ms ease;
}

/* Make sure text descendants honor the row's body color rather than
   inheriting the modal's darker palette. */
.freespace-post-result-row .freespace-post-result-display-name,
.freespace-post-result-row .freespace-post-result-preview {
    color: var(--bs-body-color);
}

.freespace-post-result-row .freespace-post-result-handle {
    /* Keep the handle muted relative to the display name. */
    color: var(--bs-secondary-color);
}

.freespace-post-result-row:hover,
.freespace-post-result-row:focus-visible {
    background: var(--bs-secondary-bg);
    border-color: var(--bs-emphasis-color);
    outline: none;
}

.freespace-post-result-row.is-selected-preview {
    cursor: default;
}

.freespace-post-result-row.is-selected-preview:hover {
    background: var(--bs-tertiary-bg);
    border-color: var(--bs-border-color);
}

.freespace-post-result-row.is-selected-preview {
    cursor: default;
}

.freespace-post-result-row.is-selected-preview:hover {
    background: transparent;
}

.freespace-post-result-author {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.35rem;
}

.freespace-post-result-avatar {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    object-fit: cover;
}

.freespace-post-result-author-text {
    display: flex;
    align-items: baseline;
    gap: 0.35rem;
    min-width: 0;
}

.freespace-post-result-display-name {
    font-weight: 500;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.freespace-post-result-body {
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
    min-width: 0;
}

.freespace-post-result-preview {
    flex: 1;
    min-width: 0;
    font-size: 0.9rem;
    line-height: 1.35;
    overflow-wrap: anywhere;
}

.freespace-post-result-thumbnail {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
    border-radius: var(--bs-border-radius-sm);
    object-fit: cover;
}

/* ----- Rendered post on the profile page ----- */

.freespace-content-post {
    display: flex;
    flex-direction: column;
}

.freespace-post-caption {
    /* spec §3 Mode E: caption above the post, like Twitter quote-tweet
       commentary. Same tone as the bio — small, muted. */
    line-height: 1.4;
}

.freespace-post-attribution {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--bs-border-color-translucent);
    font-size: 0.9rem;
}

.freespace-post-attribution .avatar-link {
    /* Prevent the avatar from being stretched by the flex layout. */
    flex-shrink: 0;
    line-height: 0;
}

.freespace-post-attribution-text {
    display: flex;
    flex-direction: column;
    line-height: 1.2;
    min-width: 0;
}

.freespace-post-attribution-name {
    font-weight: 500;
    color: var(--bs-body-color);
    text-decoration: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.freespace-post-attribution-name:hover {
    text-decoration: underline;
}

.freespace-post-attribution-handle {
    text-decoration: none;
}

.freespace-post-body {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.freespace-post-text {
    font-size: 0.95rem;
    line-height: 1.4;
    overflow-wrap: anywhere;
}

.freespace-post-media {
    text-align: center;
    /* center images */
}

.freespace-post-media img {
    display: block;
    max-width: 100%;
    max-height: 240px;
    width: auto;
    height: auto;
    margin: 0 auto;
    border-radius: var(--bs-border-radius-sm);
    object-fit: contain;
}

.freespace-post-view-link {
    color: var(--bs-link-color);
    text-decoration: none;
    font-weight: 500;
}

.freespace-post-view-link:hover {
    text-decoration: underline;
}

.freespace-content-post-empty {
    /* Owner-only state when the featured post is no longer visible. */
    display: flex;
    flex-direction: column;
    align-items: flex-start;
}

/* ---- Modal additions for wave 3 ------------------------- */

/* Image input preview inside the modal: a thumbnail strip below
   the file input that shows either the existing stored image or
   the newly-selected file (JS swaps the src). */

.freespace-media-preview-wrap {
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    padding: var(--space-2);
    background-color: var(--color-bg-subtle);
}

.freespace-media-preview {
    display: block;
    max-width: 100%;
    max-height: 200px;
    margin: 0 auto;
    border-radius: var(--radius-sm);
    object-fit: contain;
    /* In the preview we DO want contain rather than cover —
       the user is reviewing the actual image, not the cropped
       version that'll render on their profile. */
}

.freespace-media-preview-label {
    text-align: center;
    color: var(--color-text-muted);
}

.freespace-media-edit-btn {
    position: absolute;
    top: var(--space-2);
    left: var(--space-2);
    z-index: 1;
}

/* Link fetch-preview button + error banner inside the modal. */

.freespace-link-fetch-btn {
    /* Matches the .btn-outline-secondary look but lives inside an
       input-group, so it shares borders with the URL input. No
       extra rules needed — Bootstrap input-group handles geometry. */
}

.freespace-link-fetch-error {
    color: var(--color-danger);
}

/* Hide the carried-through hidden fields (preview_img,
   og_image_width, og_image_height) from view. HiddenField renders
   <input type="hidden"> which is already invisible — but we apply
   classes for JS targeting. No visual rules needed. */

/* ============================================================
   Free-space Mode D — linktree (pr16b wave 4)
   ============================================================ */

/* ---- Editor (inside modal) ---- */

.freespace-linktree-row {
    /* hidden attribute on the row handles visibility; no rule needed */
}

.freespace-linktree-row .freespace-linktree-label {
    flex: 0 0 35%;
    min-width: 100px;
}

.freespace-linktree-row .freespace-linktree-url {
    flex: 1 1 auto;
    min-width: 0;
}

.freespace-linktree-row .freespace-linktree-remove-btn {
    flex: 0 0 auto;
    padding: 0.25rem 0.5rem;
}

.freespace-linktree-add-btn:disabled {
    cursor: not-allowed;
    opacity: 0.5;
}

/* ---- Rendered on profile ---- */

.freespace-content-linktree {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.freespace-linktree-link {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    padding: var(--space-3);
    background-color: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    text-decoration: none;
    color: var(--color-text);
    font-weight: var(--font-weight-medium);
    transition: border-color var(--transition-fast),
        background-color var(--transition-fast),
        transform var(--transition-fast);
}

.freespace-linktree-link:hover,
.freespace-linktree-link:focus-visible {
    border-color: var(--color-accent);
    background-color: color-mix(in srgb, var(--color-accent) 4%, var(--color-bg-surface));
    color: var(--color-text);
    transform: translateY(-1px);
    outline: none;
}

.freespace-linktree-link-label {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.freespace-linktree-link-icon {
    flex: 0 0 auto;
    color: var(--color-text-muted);
    transition: color var(--transition-fast), transform var(--transition-fast);
}

.freespace-linktree-link:hover .freespace-linktree-link-icon,
.freespace-linktree-link:focus-visible .freespace-linktree-link-icon {
    color: var(--color-accent);
    transform: translateX(2px);
}

/* ============================================================
   Free-space media placeholders (pr25b)
   ============================================================
   Rendered in place of <video> when transcode_status is "pending"
   or "failed". Same outer dimensions as .freespace-media-video so
   the cell doesn't reflow when JS swaps the placeholder for a
   working <video> after transcode completion (SSE-driven).

   Structure:
     .freespace-media-placeholder            outer container, has poster
                                             as background-image
       .freespace-media-placeholder-overlay  dark layer + centered content
         .freespace-media-placeholder-spinner  (pending only)
         .freespace-media-placeholder-text     copy
         .freespace-media-placeholder-remove-btn (failed only)
   ============================================================ */

.freespace-media-placeholder {
    /* Match the working video's geometry so swapping in a real <video> doesn't shift the layout. */
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
    max-height: 460px;
    border-radius: var(--radius-sm);

    /* Poster image background. Set inline via style="background-image: url(...)" in the template, so this rule just sets layout. Fallback color
       for when no poster exists. */
    background-color: #000;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;

    /* Position context for the overlay. */
    position: relative;
    overflow: hidden;
}

.freespace-media-placeholder-overlay {
    /* Cover the entire placeholder with a dark layer so the copy is readable against any poster image. Stronger
       than .post-detail-panel-backdrop's 0.4 — the poster image's brightness varies a lot and we need readable text on the
       worst case. */
    position: absolute;
    inset: 0;
    background-color: rgba(0, 0, 0, 0.6);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    padding: var(--space-4);
    text-align: center;
}

.freespace-media-placeholder-text {
    /* Light text against dark overlay. Same size as body copy so it reads as a clear status message rather than fine print. */
    color: var(--color-text-on-accent);
    font-size: var(--font-size-body);
    font-weight: var(--font-weight-medium);
    margin: 0;
    /* Soft text shadow improves legibility on edge cases (e.g. light poster image bleeding through low-opacity overlay corners). */
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}

/* ---- Spinner (pending state) ----
   Pure CSS spinner — no SVG dependency, no JS, no font-awesome. A border-trick circle that rotates indefinitely. */

.freespace-media-placeholder-spinner {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    border: 3px solid rgba(255, 255, 255, 0.25);
    border-top-color: rgba(255, 255, 255, 0.9);
    animation: freespace-spinner-rotate 800ms linear infinite;
}

@keyframes freespace-spinner-rotate {
    to { transform: rotate(360deg); }
}

/* Reduced-motion respect — kill the spin animation. The spinner still renders as a static circle (which is fine — the
   text below still communicates "in progress"). */
@media (prefers-reduced-motion: reduce) {
    .freespace-media-placeholder-spinner {
        animation: none;
    }
}

/* ---- Remove button (failed state) ----
   .btn-outline-light is Bootstrap's light-on-dark outline button. Sized small via .btn-sm in the template. We just need
   light spacing here. */

.freespace-media-placeholder-remove-btn {
    margin-top: var(--space-1);
}