/* VibeOasis — charcoal & purple. The palette used to be a blue-violet
   "Miami sunrise": every neutral carried an indigo cast and the interactive
   colour was aqua, so the whole product read blue. It is now black and
   charcoal for every surface, with purple as the single accent family — the
   neutrals keep only a trace of purple (a few points of red and blue over
   grey) so they sit under the accent instead of fighting it.
   Every colour used anywhere in this file is one of these tokens (or
   color-mix() of one) — this block is the single place to change the
   palette. Dark is the default: this is a tool people look at late. */

/* ---- Typefaces -----------------------------------------------------------
   Self-hosted, because the server's CSP is `default-src 'self'` (server.js
   securityHeaders) — a Google Fonts <link> is blocked outright and would fail
   silently, exactly the way the inline theme script did before theme.js.

   Two families, two jobs, and no third one: Space Grotesk sets anything that
   is a *name* (the wordmark, headings, section titles) because its wide
   apertures and squared terminals give the product a face at a glance; Inter
   sets anything that is *read* (pitches, help copy, form labels) because it
   is built for exactly that at small sizes on screen. Code keeps the system
   monospace — a third downloaded family is not worth the bytes.

   Both are the fontsource variable cuts, re-subsetted (scripts in the
   redesign notes): the weight axis is clipped to the range this stylesheet
   actually uses, which is what takes Inter from 48KB to 31KB and Space
   Grotesk from 22KB to 20KB. Coverage matches the unicode-range below
   exactly, so anything outside Latin (a CJK project title, an emoji in a
   pitch) falls through to the system font rather than rendering as tofu.

   font-display: swap — text is readable in the fallback from first paint and
   never invisible; the two families' metrics are close enough to the system
   sans that the swap is not a jarring reflow. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 700;      /* must match the subset's clipped wght axis */
  font-display: swap;
  src: url("/fonts/inter-var.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 500 700;
  font-display: swap;
  src: url("/fonts/space-grotesk-var.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  --sky: #0A0A0C;          /* near-black, a hair warm of neutral */
  --surface: #131217;
  /* One step above --surface, for anything raised *on* a card (a hovered row,
     the tab that is currently on). Cards used to sit on the background with
     only a 1px line to separate them, which is what made every screen read as
     one flat sheet — three tones instead of two is most of the fix. */
  --surface-2: #1C1A22;
  --ink: #F3F0F8;
  --muted: #A29AB5;
  /* The accent family is one hue ramp, deep → vivid → pale, and the token
     names are kept from the old palette on purpose: they are used in ~150
     places and they long ago stopped meaning a colour and started meaning a
     ROLE. Read them as: --violet = the deep end (gradient only),
     --hot = a primary filled action, --coral = that action hovered,
     --aqua = anything interactive that is text (links, focus, active nav).
     Every one carries white or sits on near-black at 4.5:1 or better. */
  --violet: #5B21B6;       /* deep — the low end of the gradient */
  --hot: #7C3AED;          /* primary fill; white on it = 5.7:1 */
  --coral: #9333EA;        /* primary hovered; brighter, still 5.4:1 */
  --aqua: #C0A0FF;         /* interactive text; 9.1:1 on --sky */
  --gold: #E8A33D;         /* warning only — the ONE warm accent left, kept
                              because a caution that is also purple stops
                              reading as a caution */
  --sig: linear-gradient(100deg, var(--violet), var(--hot), var(--aqua));
  --line: #2A2733;
  --danger: #FF5B5B;
  --radius: 14px;
  --radius-sm: 10px;

  /* Elevation. Two shadows per level: a tight one that reads as a contact
     edge and a wide, heavily-offset one that reads as depth. --edge is the
     1px inner highlight along a card's top edge — the thing that actually
     makes a dark surface look lit rather than painted. */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.3), 0 14px 32px -18px rgba(0, 0, 0, 0.9);
  --edge: inset 0 1px 0 rgba(255, 255, 255, 0.055);

  /* Three very low-alpha purple radials, so the black is lit rather than
     flat — the only place any colour touches a surface. Kept at or
     under 12% alpha so it reads as atmosphere and never fights the content;
     painted on a fixed pseudo-element (see body::before) so scrolling never
     repaints it. */
  --glow:
    radial-gradient(1100px 700px at 12% -8%, color-mix(in srgb, var(--hot) 10%, transparent), transparent 70%),
    radial-gradient(900px 600px at 92% 4%, color-mix(in srgb, var(--aqua) 5%, transparent), transparent 70%),
    radial-gradient(1000px 800px at 60% 105%, color-mix(in srgb, var(--violet) 12%, transparent), transparent 70%);

  --font-display: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-body: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* ---- Type scale --------------------------------------------------------
     Seven steps, and nothing off-scale. This file previously carried 24
     distinct font sizes, 13 of them between 0.7rem and 0.95rem - steps of
     0.02rem that are invisible as hierarchy but very visible as
     inconsistency, and which rendered as fractional pixels (0.88rem =
     14.08px, 0.78rem = 12.48px) that nobody chose deliberately.

     The rule, borrowed from the densest feeds that get this right (Reddit's
     feed runs its whole page on 12/14/18px): below --text-lg, hierarchy is
     made with WEIGHT and COLOUR, never with size. If a label needs to
     recede it goes --muted, or drops to 400 - it does not go to 0.82rem.
     Two elements at one size with different weights read as deliberate;
     two elements 0.8px apart read as a mistake.

     Adding a step here should feel like a decision, not a convenience. */
  --text-xs: 0.75rem;      /* 12px - chips, meta, legends, overline labels */
  --text-sm: 0.875rem;     /* 14px - the default; most UI text lives here */
  --text-base: 1rem;       /* 16px - sustained reading: pitches, help copy */
  --text-lg: 1.125rem;     /* 18px - card titles, section headings */
  --text-xl: 1.375rem;     /* 22px - page headings, the sidebar wordmark */
  --text-2xl: clamp(1.9rem, 1.4rem + 1.6vw, 2.4rem);
  --text-display: clamp(2.1rem, 1.5rem + 2vw, 2.9rem);

  /* Height of the sticky search bar. On :root rather than on .topbar because
     .rail is a SIBLING of the bar, not a child — it offsets itself by this to
     stick to the bar's underside, and a custom property only inherits down. */
  --topbar-h: 4.25rem;
}
:root[data-theme="light"] {
  --sky: #F7F5FA;          /* the charcoal inverted, not a warm haze */
  --surface: #FFFFFF;
  --surface-2: #F2EFF7;
  --ink: #1C1726;
  --muted: #5F5771;
  --line: #E3DEEC;
  /* The accents need their own light values, not the dark ones dimmed: the
     dark palette's --aqua (#C0A0FF) is a pale link on near-black and lands
     at about 2:1 on white, which is unreadable. Same hue, taken down the
     ramp instead — and note the roles INVERT here: on black the interactive
     text is the palest purple, on white it is the deepest. */
  --violet: #4C1D95;
  --hot: #7C3AED;          /* primary fill; white on it = 5.7:1 */
  --coral: #5B21B6;        /* hovered — darker, since brighter would fade */
  --aqua: #6D28D9;         /* interactive text; 7.1:1 on white */
  --gold: #8A5A00;
  --danger: #C62828;

  /* Light needs its own elevation, not the dark values at lower alpha: on a
     white surface the inner highlight has nothing to do (there is no darkness
     to lift), so --edge becomes a hairline of pure white and the depth comes
     almost entirely from the wide shadow, tinted with the accent's own hue
     rather than neutral grey so the light theme is the same palette and not
     a second one. */
  --shadow-sm: 0 1px 2px rgba(40, 28, 60, 0.06);
  --shadow-md: 0 1px 2px rgba(40, 28, 60, 0.05), 0 12px 28px -16px rgba(40, 28, 60, 0.26);
  --edge: inset 0 1px 0 rgba(255, 255, 255, 0.9);
  --glow:
    radial-gradient(1100px 700px at 12% -8%, color-mix(in srgb, var(--hot) 5%, transparent), transparent 70%),
    radial-gradient(900px 600px at 92% 4%, color-mix(in srgb, var(--violet) 4%, transparent), transparent 70%),
    radial-gradient(1000px 800px at 60% 105%, color-mix(in srgb, var(--aqua) 4%, transparent), transparent 70%);
}

* { margin: 0; box-sizing: border-box; }

/* The base colour lives on <html>, not <body>, so body::before can paint the
   glow at z-index -1 and still be in front of it. */
html {
  background: var(--sky);
  scrollbar-color: var(--line) transparent;   /* themed scrollbars, no JS */
}
body {
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: 1.6;
  min-height: 100vh;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}
body::before {
  content: "";
  position: fixed; inset: 0; z-index: -1;
  background: var(--glow);
  pointer-events: none;
}
::selection { background: color-mix(in srgb, var(--hot) 35%, transparent); color: var(--ink); }

/* Prose links keep their underline — it is the accessible default and the
   pitches are real prose. Structural/UI links (nav, cards, chips, footer)
   opt out individually below; that split is the whole rule. */
a {
  color: var(--aqua);
  text-decoration-thickness: from-font;
  text-underline-offset: 0.16em;
}
a:hover { color: color-mix(in srgb, var(--aqua) 75%, var(--ink)); }

/* Headings are the display face, tracked in as they get bigger — Space
   Grotesk is drawn wide, and at 2rem+ the default spacing reads loose and
   unfinished. balance/pretty are progressive: they cost nothing where
   unsupported and stop the ragged single-word last line everywhere else. */
h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.015em;
  text-wrap: balance;
}
p { text-wrap: pretty; }

/* Keyboard focus, everywhere, one rule. Inputs additionally keep a :focus
   ring below (a click into a text field should show one; a click on a button
   should not). */
:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }

/* The gradient is used for exactly ONE thing per screen — the wordmark here.
   Spread across every accent it stops meaning anything. */
.wordmark {
  font-family: var(--font-display);
  font-weight: 700; font-size: var(--text-lg); letter-spacing: -0.03em;
  background: var(--sig); -webkit-background-clip: text; background-clip: text;
  color: transparent; text-decoration: none;
  width: fit-content;
}

header.bar {
  display: flex; align-items: center; gap: 1rem;
  /* the wordmark must not be squeezed by the search taking the slack */
  padding: 0.9rem 1.25rem; border-bottom: 1px solid var(--line);
  position: sticky; top: 0; background: var(--sky); z-index: 10;
}
header.bar > .wordmark { flex: 0 0 auto; }
/* The search caps at 420px, so `auto` here absorbs whatever is left and keeps
   the nav and the toggle on the right edge rather than trailing the search
   box in the middle of an otherwise empty bar. */
header.bar nav { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
/* The bar is wordmark → search → nav → theme. The search takes the slack so
   it is the biggest thing in the bar on a wide screen, and `margin-left: auto`
   moves from the nav (where it used to push the nav right) onto the search,
   which is now the element that has to sit off the wordmark. */
header.bar .search { flex: 1 1 auto; max-width: 420px; min-width: 0; margin-left: 0.75rem; }
header.bar .search input { height: 2.35rem; font-size: var(--text-sm); }
header.bar .bar-theme { margin: 0; flex: 0 0 auto; }
/* Structural, not prose: the top bar's links are navigation, so they take the
   same no-underline treatment as the sidebar's rather than arriving as
   default aqua underlined text in the middle of the chrome. */
header.bar nav a { color: var(--muted); text-decoration: none; font-weight: 600; font-size: var(--text-sm); }
header.bar nav a:hover { color: var(--ink); }

main { max-width: 860px; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
main > h1 {
  font-size: var(--text-2xl);
  letter-spacing: -0.03em;
  margin-bottom: 0.5rem;
}
.tagline {
  color: var(--muted); margin-bottom: 2rem;
  font-size: var(--text-base); line-height: 1.55; max-width: 56ch;
}

.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 1.25rem; margin-bottom: 1rem;
  box-shadow: var(--edge), var(--shadow-md);
}
.card h2 {
  font-size: var(--text-base); margin-bottom: 0.35rem; letter-spacing: -0.01em;
}
.card .hint { color: var(--muted); font-size: var(--text-sm); margin-bottom: 1rem; max-width: 62ch; }
/* Cards are as wide as their container, which on the help centre is the full
   860px main — long enough to push explanatory prose past 90 characters a
   line. Capping the measure is the whole readability fix; the card itself
   stays full width so the layout does not go ragged. */
.card > p, .card > ul { max-width: 68ch; }

/* Forms. The label is the field's name and should read like one — ink, not
   muted, at a size that is clearly a label and not body copy. Muted labels
   above muted placeholders inside muted borders was three greys doing the
   same job and no hierarchy at all. */
label {
  display: block; margin-bottom: 0.95rem;
  font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.01em;
  color: var(--ink);
}
input, textarea, select {
  display: block; width: 100%; margin-top: 0.4rem; padding: 0.65rem 0.75rem;
  background: var(--sky); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  font: inherit; font-size: var(--text-sm); font-weight: 400;
  transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 70%, transparent); }
input:hover, textarea:hover, select:hover { border-color: color-mix(in srgb, var(--muted) 45%, var(--line)); }
input:focus, textarea:focus, select:focus {
  outline: none;
  border-color: var(--aqua);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--aqua) 22%, transparent);
}
textarea { resize: vertical; min-height: 6rem; line-height: 1.6; }

button {
  font-family: var(--font-body); font-size: var(--text-sm);
  font-weight: 600; letter-spacing: 0.005em; cursor: pointer;
  padding: 0.55rem 1rem; border-radius: var(--radius-sm);
  border: 1px solid var(--line); background: var(--surface-2); color: var(--ink);
  box-shadow: var(--shadow-sm);
  transition: background 0.12s ease, border-color 0.12s ease, transform 0.06s ease;
}
button:hover { border-color: color-mix(in srgb, var(--aqua) 55%, var(--line)); background: var(--surface-2); }
/* A 1px press is the cheapest possible "that registered" — no timers, no JS,
   and it cannot desync from the actual click the way a class toggle can. */
button:active { transform: translateY(1px); }
button.primary {
  background: var(--hot); border-color: var(--hot); color: #fff;
  box-shadow: var(--shadow-sm), 0 6px 18px -8px color-mix(in srgb, var(--hot) 70%, transparent);
}
button.primary:hover { background: var(--coral); border-color: var(--coral); }
button.danger { border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); color: var(--danger); background: none; }
button.danger:hover { background: var(--danger); border-color: var(--danger); color: #fff; }
button.small { padding: 0.35rem 0.7rem; font-size: var(--text-sm); }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
button[disabled]:active { transform: none; }

.row { display: flex; align-items: center; gap: 0.75rem; }
.row + .row { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.row .grow { flex: 1; min-width: 0; }
/* Controls default to width:100%, which is right in a stacked form and wrong
   inside a .row: as a flex item the select claimed the full container width and
   squeezed the sibling .grow (the token-name input on the account page) down to
   nothing — the field was on the page but zero pixels wide. */
.row select { width: auto; flex: 0 0 auto; }
.row .meta { color: var(--muted); font-size: var(--text-sm); }

/* Bylines and secondary metadata. The @handle inside one is a link, but a
   full aqua underline there competes with the title directly above it — it
   gets the muted colour and earns the underline on hover. */
.meta { color: var(--muted); font-size: var(--text-sm); }
.meta a { color: var(--muted); text-decoration: none; font-weight: 500; }
.meta a:hover { color: var(--ink); text-decoration: underline; }

.avatar {
  width: 2rem; height: 2rem; border-radius: 50%;
  background: var(--line); object-fit: cover;
  border: 1px solid var(--line);
}

/* One chip system, one geometry. These used to be five different things on
   the same row of a feed card — an outlined pill, a bare count, an emoji, a
   plain link — at four different heights. Everything that is a chip now
   shares this box; only the colour changes. */
.pill {
  display: inline-flex; align-items: center; gap: 0.3rem;
  height: 1.5rem; padding: 0 0.6rem; border-radius: 999px;
  font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.01em;
  white-space: nowrap;
  border: 1px solid var(--line); color: var(--muted);
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  /* Counts here update in place (open units, hype, merged) and proportional
     digits make the row twitch as they change. Scoped to chips deliberately
     and NOT set on body: Inter's tnum also gives the hyphen a tabular advance
     width, which rendered handles and licences as "@test - seed" and
     "GPL - 2.0" everywhere it was inherited. */
  font-variant-numeric: tabular-nums;
}
.pill.on {
  color: var(--aqua);
  border-color: color-mix(in srgb, var(--aqua) 45%, transparent);
  background: color-mix(in srgb, var(--aqua) 12%, transparent);
}
.pill.current {
  color: var(--gold);
  border-color: color-mix(in srgb, var(--gold) 45%, transparent);
  background: color-mix(in srgb, var(--gold) 12%, transparent);
}

.note {
  padding: 0.75rem 0.95rem; border-radius: var(--radius-sm);
  font-size: var(--text-sm); margin-bottom: 1rem;
}
.note.err { background: color-mix(in srgb, var(--danger) 10%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 50%, transparent); color: var(--danger); }
.note.ok { background: color-mix(in srgb, var(--aqua) 10%, transparent); border: 1px solid color-mix(in srgb, var(--aqua) 50%, transparent); color: var(--aqua); }
.note.warn { background: color-mix(in srgb, var(--gold) 10%, transparent); border: 1px solid color-mix(in srgb, var(--gold) 50%, transparent); color: var(--gold); }

/* Bare <code> in prose. Both styled variants below are opt-in classes, so an
   unclassed <code> — which is what the help centre writes when it quotes a
   path pattern mid-sentence — was rendering as ordinary body text, making
   `src/parser/*.rs` indistinguishable from the words around it. */
code {
  padding: 0.08rem 0.35rem; border-radius: 5px;
  background: var(--sky); border: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 0.85em; word-break: break-word;
}

code.token {
  display: block; padding: 0.8rem 0.9rem; margin: 0.6rem 0;
  background: var(--sky);
  border: 1px solid color-mix(in srgb, var(--gold) 55%, var(--line));
  border-radius: var(--radius-sm);
  font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.5;
  word-break: break-all;
}

/* Declared paths. These are the promise a claim makes (SPEC §3.2), so they get
   to look like code rather than prose — a contributor should be able to read a
   claim's territory at a glance. */
.paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0; }
code.path {
  padding: 0.12rem 0.5rem; border-radius: 6px;
  background: color-mix(in srgb, var(--aqua) 9%, var(--sky));
  border: 1px solid color-mix(in srgb, var(--aqua) 28%, var(--line));
  color: var(--aqua);
  font-family: var(--font-mono); font-size: var(--text-xs); word-break: break-all;
}
.note.warn ul { margin: 0.5rem 0 0 1.1rem; }
.note.warn li { margin-bottom: 0.25rem; }

/* Feed sort tabs, as one segmented control rather than three loose outlines:
   the group gets the recessed track, and only the current tab is raised out
   of it. That reads as "one of these is on" at a glance, which three
   same-weight bordered pills never did. */
.tabs {
  display: inline-flex; gap: 0.25rem; flex-wrap: wrap; margin-bottom: 1.25rem;
  padding: 0.25rem; border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 5%, transparent);
  border: 1px solid var(--line);
}
.tab {
  padding: 0.35rem 0.85rem; border-radius: 999px;
  font-family: var(--font-body); font-size: var(--text-sm); font-weight: 600;
  border: 1px solid transparent; background: none; color: var(--muted);
  cursor: pointer; box-shadow: none;
  transition: color 0.12s ease, background 0.12s ease;
}
.tab:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 6%, transparent); border-color: transparent; }
.tab.current {
  color: var(--ink); background: var(--surface);
  border-color: var(--line); box-shadow: var(--shadow-sm);
}
.tab:active { transform: none; }

/* Category filters (migration 0007). `.row select` already gives selects
   width:auto/flex:0 0 auto instead of the block-level 100% every other
   select gets, which is what keeps three of them on one line instead of each
   claiming the full column width. */
.facet-filters { margin: 0 0 1.25rem; flex-wrap: wrap; }
.facet-filters select { margin-top: 0; min-width: 8.5rem; }
.facet-filters select[multiple] { min-height: unset; padding: 0.35rem; }
.facet-filters select:disabled { opacity: 0.5; cursor: not-allowed; }

/* Facet tags on a card — same chip as everything else in .hype-pills, just
   living above the pitch since these describe WHAT the project is rather
   than its current activity. */
.facet-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0 0; }

/* Agent mix bar (SPEC §5) — GitHub's language bar, for who did the work. */
.mix-bar {
  display: flex; height: 8px; border-radius: 999px; overflow: hidden;
  background: var(--line); margin: 0.75rem 0 0.5rem;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
}
.mix-seg { height: 100%; }
.mix-legend {
  display: flex; flex-wrap: wrap; gap: 0.35rem 0.8rem;
  font-size: var(--text-xs); color: var(--muted); line-height: 1.4;
}
.mix-key { display: inline-flex; align-items: center; gap: 0.35rem; }
.mix-key i { width: 0.5rem; height: 0.5rem; border-radius: 2px; display: inline-block; flex: 0 0 auto; }

/* Save button. It is a .pill so it sits on the same baseline as the counts
   beside it, but it is a real <button>, so the reset that pills never needed
   has to happen here. */
.save-btn {
  cursor: pointer; font-family: var(--font-body); font-size: var(--text-xs);
  box-shadow: none;
}
.save-btn:hover { color: var(--ink); border-color: color-mix(in srgb, var(--ink) 25%, var(--line)); }
.save-btn:disabled { opacity: 0.5; cursor: default; }
.save-btn.on:hover { color: var(--aqua); }

.saved-row { padding: 0.65rem 0; border-top: 1px solid var(--line); gap: 0.75rem; }
.saved-row:first-child { border-top: none; }

/* Help centre. The contents block is a plain link list; the deletion section
   is bordered in the danger colour so it reads as different in kind from the
   explanatory cards above it, without a second heading style. */
.toc { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; }
/* A contents list is navigation — eight underlined aqua links in a block read
   as a wall of rules rather than a set of destinations. */
.toc a { text-decoration: none; font-weight: 600; font-size: var(--text-sm); }
.toc a:hover { text-decoration: underline; }
.danger-card { border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); }
.danger-card ul, .card ul { margin: 0 0 1rem 1.15rem; }
.danger-card li, .card li { margin-bottom: 0.3rem; }
.card p + p, .card p + ul { margin-top: 0.6rem; }

.empty { color: var(--muted); font-style: italic; }

/* Site footer. Every page carries the same block, so it is also the one place
   that reliably answers "what is this site and where is everything" for
   somebody who landed deep — on a profile, or a legal page, with no nav.
   Columns collapse to a stack on narrow screens via auto-fit rather than a
   breakpoint, so it degrades at whatever width the content actually needs. */
.site-footer {
  border-top: 1px solid var(--line);
  padding: 3rem 1.75rem 2rem;
  color: var(--muted); font-size: var(--text-sm);
}
.foot-inner {
  max-width: 1100px; margin: 0 auto;
  display: grid; gap: 2rem;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.foot-brand p { margin-top: 0.6rem; max-width: 28ch; line-height: 1.55; }
.foot-col h3 {
  font-family: var(--font-body);
  font-size: var(--text-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--ink); margin-bottom: 0.7rem;
}
.foot-col a {
  display: block; padding: 0.22rem 0; color: var(--muted); text-decoration: none;
}
.foot-col a:hover { color: var(--aqua); }
.foot-base {
  max-width: 1100px; margin: 2rem auto 0; padding-top: 1.25rem;
  border-top: 1px solid var(--line); font-size: var(--text-xs);
}
/* Theme toggle. In the footer because it is on every page and because this is
   a set-once preference, not something to keep within thumb's reach. */
.theme-toggle { display: block; width: fit-content; margin: 1.5rem auto 0; }
.hidden { display: none !important; }

/* Suno-style app shell — a persistent left rail instead of a top bar, dark
   near-black chrome (var(--sky) already reads that way) with minimal
   decoration so the feed content carries the colour. Currently scoped to
   index.html only (dev_note.txt, 2026-08-11); header.bar above is untouched
   and still serves project/account/profile pages. */
/* Three columns, Reddit's shape: fixed left nav, fluid centre, fixed right
   rail. Grid rather than flex so the centre is the only column that flexes —
   `minmax(0, 1fr)` is what stops a long unbroken pitch from widening the
   track and pushing the rail off-screen. */
.app-shell {
  display: grid;
  grid-template-columns: 240px minmax(0, 1fr) 312px;
  /* Two rows now: the search bar, then everything else. The nav spans both so
     it stays a full-height column (Suno/Spotify), while the bar spans the
     centre and rail together (Reddit) — which is what makes the search read as
     the page's, not the feed column's. */
  grid-template-rows: auto minmax(0, 1fr);
  align-items: stretch; min-height: 100vh;
}
.sidebar { grid-column: 1; grid-row: 1 / -1; }
.topbar  { grid-column: 2 / -1; grid-row: 1; }
.app-main { grid-column: 2; grid-row: 2; }
.rail { grid-column: 3; grid-row: 2; }

/* The bar itself. Opaque, because unlike the desktop sidebar this one DOES
   overlay scrolling content — cards pass underneath it. --topbar-h is
   published as a variable because the rail below has to start beneath it and
   stick to its underside; a hard-coded second copy of this height would drift
   the moment the padding changed. */
.topbar {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; gap: 1rem;
  height: var(--topbar-h);
  padding: 0 1.5rem 0 1.75rem;
  background: var(--sky);
  border-bottom: 1px solid var(--line);
}
/* Search left, account zone hard right, empty space between them.
   TWO EARLIER ATTEMPTS WERE WRONG, both for the same reason — worth writing
   down so neither comes back:
     1. Centring the search with a three-column grid put the account button
        immediately to the right of the search field. At that distance
        proximity reads as relationship, and a filled magenta button sitting
        against a search input looks like the input's submit control, not the
        way into your account.
     2. That grid also gave the centre track a definite 560px max, so it was
        sized BEFORE the side tracks; below ~1200px there was nothing left for
        the account column and the button was laid out past the right edge of
        the viewport. A flex bar with the search capped and the account zone
        pinned by `margin-left: auto` cannot do that: the search is the item
        that shrinks.
   The empty middle is the separation, and it is deliberate. */
.topbar { display: flex; justify-content: initial; }
.topbar .search { flex: 1 1 auto; max-width: 560px; min-width: 0; }
.topbar-right {
  margin-left: auto; flex: 0 0 auto;
  display: flex; align-items: center; gap: 0.5rem;
}
/* Hairline between the page's controls and the account controls. Cheap, and it
   states the grouping outright instead of relying on the gap alone. */
.topbar-right::before {
  content: ""; flex: 0 0 auto;
  width: 1px; height: 1.5rem; margin-right: 0.35rem;
  background: var(--line);
}
.topbar-nav { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }

/* Quiet, so the account action is the only emphasised thing on the right.
   A bordered pill next to a filled pill made the bar look like it had two
   equal calls to action. */
.topbar-theme, .bar-theme {
  background: none; border-color: transparent; box-shadow: none; color: var(--muted);
  /* Kill the base .theme-toggle geometry. That rule (`display: block;
     width: fit-content; margin: 1.5rem auto 0`) is written for the FOOTER
     toggle, where the button is centred under the page with air above it.
     In a topbar it is wrong twice over: the 1.5rem top margin dropped the
     button 12px below the centreline of everything beside it, and the `auto`
     side margins fight the flex row's own gap.

     `header.bar .bar-theme` already reset this, but only for the .bar-theme
     variant, so the feed page's .topbar-theme kept inheriting it and sat
     visibly low next to Sign in. Reset it here, on the shared selector, so
     the two variants cannot drift apart again. */
  margin: 0; display: inline-flex; align-items: center; width: auto;
}
.topbar-theme:hover, .bar-theme:hover {
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  border-color: var(--line); color: var(--ink);
}

/* Loading skeletons ---------------------------------------------------------
   Shapes that match what replaces them. The point is not decoration: it is
   that the page's layout is already correct before the data lands, so nothing
   jumps when it does. Sizes here are deliberately tied to the same tokens the
   real elements use - a skeleton title that is not the height of a real title
   is a layout shift with extra steps.

   .sr-only carries the actual status for screen readers, because the boxes
   are aria-hidden and a silent "loading" is not a loading state for someone
   who cannot see the shimmer. */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

.skel-card {
  padding: 1.25rem 1.35rem;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--edge), var(--shadow-sm);
  margin-bottom: 1rem;
}

.skel {
  border-radius: 6px;
  /* Two layers: a flat base tint, and a highlight band swept across it. The
     base is what gives the block its shape at rest (and is all that shows
     under prefers-reduced-motion); the band is the only moving part. */
  background-color: color-mix(in srgb, var(--ink) 9%, transparent);
  background-image: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--ink) 7%, transparent),
    transparent
  );
  background-size: 200% 100%;
  background-repeat: no-repeat;
  animation: skel-sweep 1.4s ease-in-out infinite;
}

@keyframes skel-sweep {
  from { background-position: -120% 0; }
  to   { background-position: 220% 0; }
}

/* Heights mirror the real elements: --text-lg title, --text-sm meta/body. */
.skel-title { height: 1.35rem; width: 58%; margin-bottom: 0.6rem; }
.skel-meta  { height: 0.85rem; width: 32%; margin-bottom: 1.05rem; }
.skel-line  { height: 0.8rem;  width: 100%; margin-bottom: 0.5rem; }
.skel-line.short { width: 72%; }
.skel-pills { display: flex; gap: 0.45rem; margin-top: 1.05rem; }
.skel-pill  { height: 1.55rem; width: 5.2rem; border-radius: 999px; }

.skel-rail-item { padding: 0.65rem 0; border-bottom: 1px solid var(--line); }
.skel-rail-item:last-of-type { border-bottom: 0; }
.skel-rail-title { height: 0.95rem; width: 82%; margin-bottom: 0.45rem; }
.skel-rail-meta  { height: 0.75rem; width: 52%; }

/* A sweeping highlight is exactly the kind of repeating motion that triggers
   vestibular symptoms, and it runs until the network answers - potentially a
   long time on a bad connection. Drop to a static tint. */
@media (prefers-reduced-motion: reduce) {
  .skel { animation: none; background-image: none; }
}

/* Signed out, Publish is a signpost, not a call to action. It still goes to
   the publish page (which explains that GitHub is required) - it just stops
   claiming to be the main thing to do when the main thing to do is sign in. */
body.is-signed-out .side-link.create {
  background: none;
  color: var(--muted);
  box-shadow: none;
  border: 1px solid var(--line);
}
body.is-signed-out .side-link.create:hover {
  background: color-mix(in srgb, var(--ink) 6%, transparent);
  color: var(--ink);
}

/* Signed-out entry point ----------------------------------------------------
   When exactly one OAuth provider is configured, app.js renders a plain link
   here instead of a trigger + dropdown. It is styled as the emphasised action
   in the bar ON PURPOSE, reversing an earlier decision that made it quiet.

   The reasoning for "quiet" was that a bordered pill beside a filled pill
   looks like two competing calls to action. True in the abstract, wrong here:
   for a signed-out visitor this IS the only real action on the page. The
   Publish button in the sidebar looks like the primary action but leads
   straight to a "Sign in first" dead end, so demoting sign-in left the page
   with no correct next step emphasised at all. Emphasis follows what the user
   can actually do, not the colour count. */
.auth-direct {
  display: inline-flex; align-items: center;
  padding: 0.45rem 0.9rem;
  border-radius: var(--radius-sm);
  background: var(--hot); color: #fff;
  font-size: var(--text-sm); font-weight: 600;
  text-decoration: none; white-space: nowrap;
  box-shadow: var(--shadow-sm), 0 6px 18px -8px color-mix(in srgb, var(--hot) 70%, transparent);
  transition: background 0.12s ease;
}
.auth-direct:hover { background: var(--coral); color: #fff; }

/* First-run explainer -------------------------------------------------------
   Signed-out only; app.js empties it once you have an account. It sits between
   the tagline and the feed because that is where a first-time visitor's eye
   already is, rather than behind a "How it works" link nobody clicks. */
.intro {
  margin: 0 0 2.25rem;
  padding: 1.15rem 1.25rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--aqua) 5%, var(--surface));
  box-shadow: var(--edge);
}
.intro-steps { list-style: none; display: grid; gap: 0.55rem; margin: 0 0 1.05rem; padding: 0; }
.intro-steps li {
  display: grid; grid-template-columns: 1.4rem 1fr; align-items: baseline; gap: 0.6rem;
  font-size: var(--text-sm); color: var(--ink); line-height: 1.5;
}
/* Aqua, not --hot: these are numbered steps, not calls to action, and --hot is
   spoken for by the one button in this block. */
.intro-num {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.4rem; height: 1.4rem; border-radius: 50%;
  background: color-mix(in srgb, var(--aqua) 16%, transparent);
  color: var(--aqua); font-size: var(--text-xs); font-weight: 700;
}
.intro-cta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.9rem; }
.btn-cta {
  display: inline-flex; align-items: center;
  padding: 0.55rem 1.05rem; border-radius: var(--radius-sm);
  background: var(--hot); color: #fff;
  font-size: var(--text-sm); font-weight: 600; text-decoration: none;
  box-shadow: var(--shadow-sm), 0 6px 18px -8px color-mix(in srgb, var(--hot) 70%, transparent);
  transition: background 0.12s ease;
}
.btn-cta:hover { background: var(--coral); color: #fff; }
.intro-note { font-size: var(--text-xs); color: var(--muted); margin: 0; max-width: 46ch; }

/* Per-tab explanation. --text-xs and --muted so it reads as a caption on the
   tabs rather than as feed content competing with the first card. */
.tab-hint {
  font-size: var(--text-xs); color: var(--muted);
  margin: 0.6rem 0 1.1rem; min-height: 1.1em;
}

/* Account / sign-in menu ---------------------------------------------------
   One trigger, one panel. The panel is `position: absolute` off the trigger
   and the bar has z-index 20, so it lays over the feed rather than widening
   the bar. */
.auth-menu { position: relative; }
.auth-trigger {
  display: inline-flex; align-items: center; gap: 0.45rem;
  /* min-width: 0 is the load-bearing half: without it a flex item refuses to
     shrink below its content, so a long handle pushed the whole account zone
     off the right edge of the page (caught by the resize audit at 901px+). */
  max-width: 14rem; min-width: 0;
}
/* The signed-in trigger is a face, not a button-looking button — it carries an
   avatar and reads as an identity. */
.auth-trigger:has(.avatar) {
  padding: 0.25rem 0.55rem 0.25rem 0.3rem;
  background: none; border-color: transparent; box-shadow: none;
}
.auth-trigger:has(.avatar):hover { background: color-mix(in srgb, var(--ink) 8%, transparent); border-color: var(--line); }
.auth-trigger .avatar { width: 1.75rem; height: 1.75rem; flex: 0 0 auto; }
.auth-handle {
  font-size: var(--text-sm); font-weight: 600;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.auth-caret { font-size: var(--text-xs); color: var(--muted); flex: 0 0 auto; }
/* Stands in for a missing avatar. Same box as the <img>, so the trigger does
   not change width when one account has a picture and the next does not. */
.avatar-initial {
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--surface-2); color: var(--muted);
  font-size: var(--text-xs); font-weight: 700;
}

.auth-pop {
  position: absolute; top: calc(100% + 0.5rem); right: 0; z-index: 30;
  min-width: 13.5rem; padding: 0.4rem;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--edge), var(--shadow-md);
}
.auth-pop[hidden] { display: none; }
.auth-opt {
  display: block; width: 100%; text-align: left;
  padding: 0.5rem 0.6rem; border-radius: 8px;
  font-size: var(--text-sm); font-weight: 600;
  color: var(--ink); text-decoration: none;
  background: none; border: none; box-shadow: none; cursor: pointer;
}
.auth-opt:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); border-color: transparent; }
.auth-opt:active { transform: none; }
.auth-sep { height: 1px; margin: 0.35rem 0.3rem; background: var(--line); }
.auth-note {
  padding: 0.4rem 0.6rem 0.2rem;
  color: var(--muted); font-size: var(--text-xs); line-height: 1.45;
}

/* Search field. The whole thing is one rounded track with the icon inside it,
   rather than a label beside a box — at this size that reads as a search
   affordance without needing the word "search" anywhere. */
.search {
  position: relative; display: flex; align-items: center;
  min-width: 0;
}
.search input {
  width: 100%; margin: 0;
  height: 2.6rem; padding: 0 2.4rem 0 2.6rem;
  border-radius: 999px;
  background: var(--surface); border: 1px solid var(--line);
  font-size: var(--text-sm);
}
.search input:hover { background: var(--surface-2); }
.search input:focus {
  background: var(--surface);
  border-color: var(--aqua);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--aqua) 20%, transparent);
}
/* The native decorations fight the custom clear button and the rounded track:
   Chrome's × sits inside the padding and Safari's magnifier duplicates the
   icon below. */
.search input::-webkit-search-decoration,
.search input::-webkit-search-cancel-button,
.search input::-webkit-search-results-button { -webkit-appearance: none; appearance: none; }
.search-icon {
  position: absolute; left: 0.9rem; width: 1.05rem; height: 1.05rem;
  pointer-events: none;                       /* clicks fall through to the input */
  fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round;
  transition: stroke 0.12s ease;
}
.search:focus-within .search-icon { stroke: var(--aqua); }
.search-clear {
  position: absolute; right: 0.45rem;
  display: flex; align-items: center; justify-content: center;
  width: 1.6rem; height: 1.6rem; padding: 0;
  border: none; border-radius: 50%; background: none; box-shadow: none;
  color: var(--muted); font-size: var(--text-lg); line-height: 1;
}
.search-clear:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); color: var(--ink); }

/* Results header, in place of the sort tabs. */
.search-summary {
  display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
  margin-bottom: 1.25rem;
}
.search-count { color: var(--muted); font-size: var(--text-sm); }
.search-summary .pill { cursor: pointer; font-family: var(--font-body); }
.search-summary .pill:hover { color: var(--ink); border-color: color-mix(in srgb, var(--ink) 25%, var(--line)); }
/* Transparent on purpose: the glow on body::before runs the full width of the
   viewport, and an opaque sky-coloured column sitting on top of it would cut
   a hard vertical seam down the page. The nav is a grid column here, not an
   overlay, so nothing ever scrolls under it — it does not need to be opaque.
   The narrow layout is different and re-opaques it; see the 900px block. */
.sidebar {
  display: flex; flex-direction: column;
  padding: 1.5rem 1rem; border-right: 1px solid var(--line);
  position: sticky; top: 0; align-self: flex-start; height: 100vh; overflow-y: auto;
}
.sidebar .wordmark { font-size: var(--text-xl); padding: 0 0.55rem; margin-bottom: 2rem; display: block; }
.side-links { display: flex; flex-direction: column; gap: 0.15rem; }
.side-link {
  position: relative;
  display: flex; align-items: center; gap: 0.6rem;
  padding: 0.6rem 0.75rem; border-radius: var(--radius-sm);
  color: var(--muted); text-decoration: none;
  font-size: var(--text-sm); font-weight: 600; letter-spacing: 0.005em;
  transition: color 0.12s ease, background 0.12s ease;
}
.side-link:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 6%, transparent); }
.side-link.current { color: var(--ink); background: color-mix(in srgb, var(--aqua) 12%, transparent); }
/* The active marker is a bar on the leading edge rather than a stronger wash:
   a tinted background alone has to get loud enough to read as "selected",
   and at that point it competes with the Publish button right under it.

   The marker is AQUA, not --hot, for two reasons. First, hierarchy: --hot is
   spoken for by the primary action (Publish, immediately below this), and a
   selected-state marker in the same colour a foot above the main CTA makes
   the eye choose between them. "Where you are" and "what to do next" are
   different jobs and should not share a colour.

   Second, the palette only works if --aqua actually appears. Every other
   --aqua in this file is bound to :hover or :focus-visible, i.e. states that
   are invisible in a resting screenshot. This marker is the one that is on
   screen at rest, on every page — without it the pale end of the ramp never
   shows and the scheme is charcoal with a purple button. */
.side-link.current::before {
  content: ""; position: absolute; left: 0; top: 50%;
  width: 3px; height: 1.1rem; margin-top: -0.55rem;
  border-radius: 0 3px 3px 0; background: var(--aqua);
}
.side-link.create {
  margin-top: 0.6rem; color: #fff; background: var(--hot); justify-content: center;
  box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--hot) 75%, transparent);
}
.side-link.create:hover { background: var(--coral); color: #fff; }
.side-link.create::before { content: none; }

.app-main { min-width: 0; }
.app-main main { max-width: 720px; margin: 0 auto; padding: 2.5rem 1.75rem 5rem; }
.app-main h1 {
  font-size: var(--text-display);
  font-weight: 700; letter-spacing: -0.035em; line-height: 1.08;
  margin-bottom: 0.7rem;
}
.app-main .tagline { font-size: var(--text-base); margin-bottom: 2.5rem; }
/* "Projects" is a section label, not a second headline — it was set at the
   same weight and nearly the same size as the h1 two lines above it, so the
   page appeared to start twice. */
.app-main #feedHeading {
  font-family: var(--font-body);
  font-size: var(--text-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.12em;
  color: var(--muted);
  margin-bottom: 0.9rem;
}

/* Right rail. Sticky and independently scrollable, like the left one. It
   holds the standing "needs agents" shortlist plus the policy links, which is
   why the centre column's own footer is hidden while the rail is visible —
   two copies of Privacy/Terms on one screen. */
/* Sticks to the underside of the topbar, not the viewport top — at top: 0 it
   scrolled up behind the bar and its first card was clipped. Same subtraction
   on the height, or the column runs a bar's worth past the fold. */
.rail {
  padding: 1.75rem 1.5rem 2rem 0;
  position: sticky; top: var(--topbar-h, 4.25rem); align-self: flex-start;
  height: calc(100vh - var(--topbar-h, 4.25rem)); overflow-y: auto;
  display: flex; flex-direction: column; gap: 1rem;
}
.rail-card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 1.1rem 1.15rem;
  box-shadow: var(--edge), var(--shadow-md);
}
.rail-title {
  font-family: var(--font-display);
  font-size: var(--text-sm); font-weight: 700; letter-spacing: -0.01em;
}
.rail-hint { color: var(--muted); font-size: var(--text-xs); margin-bottom: 0.5rem; }
.rail-item {
  display: block; padding: 0.7rem 0; border-top: 1px solid var(--line);
  color: var(--ink); text-decoration: none;
}
.rail-item:first-child { border-top: none; }
.rail-item:hover .rail-item-title { color: var(--aqua); }
.rail-item-title {
  font-weight: 600; font-size: var(--text-sm); line-height: 1.35;
  letter-spacing: -0.005em;
  transition: color 0.12s ease;
}
.rail-item-meta { color: var(--muted); font-size: var(--text-xs); margin-top: 0.2rem; }
/* Sits under the shortlist and says where your OWN projects are — the rail is
   in the position a "your stuff" panel usually occupies, so it gets mistaken
   for one. Muted and separated, because it is a signpost off this list, not
   another entry in it. */
.rail-foot {
  display: block; margin-top: 0.75rem; padding-top: 0.7rem;
  border-top: 1px solid var(--line);
  color: var(--muted); font-size: var(--text-xs); font-weight: 700;
  text-decoration: none;
}
.rail-foot:hover { color: var(--aqua); }
/* The footer is a SIBLING of .app-shell, not a row inside it — see the note in
   index.html. Inside the grid, the sticky 100vh nav and rail overlapped it. */

/* Reddit drops the right rail before it touches the left nav — the rail is
   context, the nav is how you get anywhere. */
@media (max-width: 1200px) {
  .app-shell { grid-template-columns: 240px minmax(0, 1fr); }
  .rail { display: none; }
  /* `2 / -1` still resolves to "column two to the end" with the rail gone, so
     the bar keeps spanning everything right of the nav. */
}

@media (max-width: 900px) {
  /* One column, and the two bars stack: nav on top, search beneath it, both
     sticky. --navbar-h is an explicit height rather than `auto` precisely so
     the search bar has a number to offset itself by — two sticky siblings
     both at top: 0 would sit on top of each other. */
  .app-shell {
    --navbar-h: 3.5rem;
    grid-template-columns: 1fr;
    grid-template-rows: auto auto minmax(0, 1fr);
  }
  .sidebar { grid-column: 1; grid-row: 1; }
  .topbar { grid-column: 1; grid-row: 2; }
  .app-main { grid-column: 1; grid-row: 3; }
  .topbar {
    top: var(--navbar-h);
    height: auto; padding: 0.7rem 1rem;
  }
  /* The handle is the first thing to go — the avatar still identifies the
     account, and the menu behind it still names it. */
  .auth-handle, .auth-caret { display: none; }
  .topbar-right::before { display: none; }
  .sidebar {
    /* ⚠️ z-index HAS TO BEAT .topbar's 20, and not because these two bars
       overlap — they cannot: this one sticks at top 0 and the topbar sticks
       at top var(--navbar-h), directly beneath it.

       It is about the hamburger MENU. `z-index: 10` here makes the sidebar a
       stacking CONTEXT, which traps everything inside it: .menu-panel's
       `z-index: 60` then only competes with the sidebar's own children, never
       with the topbar. The topbar (20) beat the whole sidebar subtree (10), so
       the search box, theme toggle and sign-in button painted straight over
       the top ~107px of the open 302px menu — it read as the menu being cut in
       half. Raising the CONTEXT is the fix; raising .menu-panel's own z-index
       can never work, because the number is scoped to the wrong context.

       Desktop is unaffected and always was: the base .sidebar rule sets no
       z-index at all, so it forms no stacking context and the panel's 60
       applies against the page. */
    position: sticky; top: 0; z-index: 30; height: var(--navbar-h); width: auto;
    flex-direction: row; align-items: center; gap: 1rem;
    padding: 0.8rem 1rem; border-right: none; border-bottom: 1px solid var(--line);
    /* Opaque here, unlike the desktop column: in this layout the bar overlays
       the feed and content scrolls underneath it. */
    background: var(--sky); overflow: visible;
  }
  .sidebar .wordmark { margin-bottom: 0; }
  /* The desktop column stacks the brand above the nav, so .brand-row carries
     `margin-bottom: 2rem`. Here the sidebar is a fixed-height ROW and that
     margin is still in the box: 44px of brand + 32px of margin centred in a
     52px bar puts the row's top at -12px, slicing the wordmark and the menu
     button off against the top edge. Resetting `.sidebar .wordmark` above was
     not enough — the margin is on the row, not the wordmark inside it.
     Scoped to `.sidebar` for specificity, not tidiness: the base `.brand-row`
     rule is ~700 lines further down, so a bare `.brand-row` here ties on
     specificity and loses on order, inside a media query or not. */
  .sidebar .brand-row { margin-bottom: 0; }
  .side-links { flex-direction: row; margin-left: auto; gap: 0.4rem; }
  .side-link.create { margin-top: 0; }
  .side-link.current::before { content: none; }
  .app-main main { padding: 2.25rem 1.25rem 4rem; }
  .feed-card { padding: 1.25rem 1.15rem; }
}

/* Phone. The top bar carries the wordmark plus up to three controls, and at
   this width the desktop padding made "+ Publish" and "Sign in with GitHub"
   each wrap onto two lines, doubling the bar's height. Tightening the chrome
   is enough — nothing has to be hidden. */
/* Below this the bar's four items (wordmark, search, nav, toggle) cannot share
   one line without the toggle being pushed off the right edge — the resize
   audit caught it at 320px. The search takes its own row instead; nothing is
   hidden. */
@media (max-width: 700px) {
  header.bar { flex-wrap: wrap; gap: 0.6rem 0.75rem; padding: 0.75rem 1rem; }
  header.bar .search { order: 3; flex: 1 1 100%; max-width: none; margin-left: 0; }
  header.bar nav { margin-left: auto; }

  /* Same treatment for the app shell's .topbar, for the same reason. The
     account zone is `flex: 0 0 auto`, so the search was the only item on the
     row that could give — beside "Sign in with GitHub" it collapsed to 101px
     at 390px, narrow enough that the placeholder truncated to "Se". The
     resize audit never caught it because a 101px box overlaps nothing and
     overflows nothing; it was only ever asserting geometry, not usability.
     `height: auto` is required — the base rule pins .topbar to --topbar-h,
     which would clip the second line rather than grow. */
  .topbar { flex-wrap: wrap; height: auto; row-gap: 0.55rem; }
  .topbar .search { order: 3; flex: 1 1 100%; max-width: none; }
  /* With the search on its own row, `margin-left: auto` left ~200px of dead
     space to the left of the two controls. Spreading them fills the row and
     still states the grouping the desktop hairline states — the theme toggle
     is a page control, the account zone is not, and at this width
     .topbar-right::before is already hidden. */
  .topbar-right { margin-left: 0; width: 100%; justify-content: space-between; }
}

@media (max-width: 560px) {
  .app-shell { --navbar-h: 3.25rem; }
  .sidebar { gap: 0.6rem; padding: 0.7rem 0.85rem; }
  .topbar { padding: 0.6rem 0.85rem; }
  .search input { height: 2.4rem; }
  .sidebar .wordmark { font-size: var(--text-lg); padding: 0; }
  .side-links { gap: 0.3rem; }
  .side-link { padding: 0.45rem 0.6rem; font-size: var(--text-sm); white-space: nowrap; }
  .app-main h1 { letter-spacing: -0.025em; }
}

/* Instagram-style scroll feed (SPEC §4 Browse UX) — one project's full pitch
   per card, snap-scrolling to the next rather than a compact list. Wide
   rectangles, not near-square blocks: a modest min-height, footer pinned to
   the bottom via margin-top: auto so short pitches don't stretch the card.
   scroll-snap is on <html> (not a nested scroll box) so the whole page
   scrolls normally and just settles on card boundaries. */
html { scroll-snap-type: y proximity; }
/* The FIRST card must not be a snap point. With proximity snapping on <html>,
   the nearest snap point at scrollY 0 is card one at ~264px, so the browser
   drags the page down on load and refuses to return — window.scrollTo(0, 0)
   snaps straight back. That put the h1, the publish anchor and, worst, the
   sort tabs permanently out of reach. Every later card still snaps. */
.feed-card:first-child { scroll-snap-align: none; }
.feed-card {
  scroll-snap-align: start;
  scroll-margin-top: 1.5rem;
  min-height: 260px;
  display: flex; flex-direction: column;
  padding: 1.5rem 1.6rem;
  margin-bottom: 1.15rem;
  transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.feed-card:hover {
  border-color: color-mix(in srgb, var(--aqua) 32%, var(--line));
  transform: translateY(-2px);
  box-shadow: var(--edge), var(--shadow-md), 0 0 0 1px color-mix(in srgb, var(--aqua) 10%, transparent);
}
.feed-card h2 {
  font-size: var(--text-xl); margin-bottom: 0.3rem; letter-spacing: -0.025em; line-height: 1.2;
}
/* The card title was a default-styled <a>: aqua, underlined, at 1.4rem. Three
   of those stacked down the feed read as a directory listing, not as content.
   The title is the most important text on the card, so it takes the ink
   colour and the underline is dropped — the whole card is a hover target
   already, and the colour shift on hover is the affordance. */
.feed-card h2 a {
  color: var(--ink); text-decoration: none;
  transition: color 0.12s ease;
}
.feed-card:hover h2 a, .feed-card h2 a:hover { color: var(--aqua); }
.feed-card .meta { margin-top: 0.1rem; }
.feed-card .pitch {
  white-space: pre-wrap; line-height: 1.7; margin: 0.9rem 0 0;
  color: color-mix(in srgb, var(--ink) 88%, var(--muted));
  max-width: 68ch;
}
.feed-card .pitch.clamped {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6;
  overflow: hidden;
  /* Fade the final line instead of guillotining it mid-x-height. Makes the
     truncation read as deliberate, and as something continued — which is
     exactly what the Read more button below it offers. */
  -webkit-mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
  mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
}
/* The card is a flex column, so a bare <button> child stretched to the full
   width and centred its own label — which is why "Read more" was sitting in
   the middle of the card looking like a section divider. */
.feed-card .more-btn {
  align-self: flex-start;
  background: none; border: none; padding: 0; margin-top: 0.5rem;
  box-shadow: none; color: var(--aqua);
  font-weight: 600; cursor: pointer; font-size: var(--text-sm);
}
.feed-card .more-btn:hover { text-decoration: underline; background: none; }
.feed-card .more-btn:active { transform: none; }

/* Footer: hype signals bottom-left, mix bar bottom-right. margin-top: auto
   pins this to the card's bottom edge regardless of how tall the pitch
   above it is. */
.feed-card .card-footer {
  margin-top: auto; padding-top: 1.25rem;
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap;
}
.feed-card .hype-pills { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; }
/* Matched to .pill's box so the footer row sits on one baseline — this was a
   bare underlined link at body size among five chips. */
.feed-card .repo-link {
  display: inline-flex; align-items: center; gap: 0.25rem;
  height: 1.5rem; padding: 0 0.6rem; border-radius: 999px;
  font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.01em;
  border: 1px solid var(--line); color: var(--muted);
  text-decoration: none; white-space: nowrap;
}
.feed-card .repo-link::after { content: "↗"; font-size: 0.85em; opacity: 0.8; }
.feed-card .repo-link:hover { color: var(--aqua); border-color: color-mix(in srgb, var(--aqua) 45%, transparent); }

.feed-card .mix-mini { min-width: 160px; max-width: 260px; text-align: right; }
.feed-card .mix-mini .mix-bar { height: 6px; margin: 0 0 0.35rem; }
.feed-card .mix-mini .mix-legend { font-size: var(--text-xs); gap: 0.25rem 0.5rem; justify-content: flex-end; }
.feed-card .mix-mini .empty { font-size: var(--text-xs); }

/* Nothing above is load-bearing motion — every transition is a hover or press
   affordance — so honouring the preference costs no functionality. */
@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
  html { scroll-snap-type: none; }
  .feed-card:hover { transform: none; }
}

/* ---- Account / profile management ---------------------------------------
   A settings page, not a stack of cards. The sections were six identical
   blocks in one column: no way to see what the page held without scrolling
   it, and nothing distinguishing "change your handle" from "delete
   everything". This gives it an index and a hierarchy. */
.settings { max-width: 1040px; }
.settings-head { margin-bottom: 1.75rem; }
.settings-head .tagline { margin-bottom: 0; }

.settings-grid {
  display: grid;
  grid-template-columns: 190px minmax(0, 1fr);
  gap: 2.5rem;
  align-items: start;
}
/* Sticky, offset by the bar it would otherwise slide under. */
.settings-nav {
  position: sticky; top: calc(var(--topbar-h, 4.25rem) + 1.25rem);
  display: flex; flex-direction: column; gap: 0.1rem;
}
.settings-nav a {
  display: block; padding: 0.45rem 0.7rem; border-radius: 8px;
  color: var(--muted); text-decoration: none;
  font-size: var(--text-sm); font-weight: 600;
  transition: color 0.12s ease, background 0.12s ease;
}
.settings-nav a:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 7%, transparent); }
.settings-nav a.danger-link { color: color-mix(in srgb, var(--danger) 75%, var(--muted)); margin-top: 0.4rem; }
.settings-nav a.danger-link:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }

.settings-body { min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
.settings-body .card { margin-bottom: 0; scroll-margin-top: calc(var(--topbar-h, 4.25rem) + 1.5rem); }
/* :target — the section the URL currently points at gets a lit border, so
   clicking the index confirms which card you landed on. No scroll-spy JS. */
.settings-body .card:target { border-color: color-mix(in srgb, var(--aqua) 45%, var(--line)); }

/* Rows inside a settings card. The old .row put a 1px line above every row
   except the first, which read as a table with no header; this reads as a
   list of things you can act on. */
.row-list .row {
  padding: 0.75rem 0; gap: 0.75rem;
}
.row-list .row + .row { margin-top: 0; padding-top: 0.75rem; }
.row-list .row .grow { font-size: var(--text-sm); font-weight: 500; }
.row-list .row .meta { font-weight: 400; margin-top: 0.15rem; }
.row-list .empty { display: block; padding: 0.5rem 0; }

/* Two short, related fields side by side rather than stacked full width. */
.field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
/* The @ is part of the handle everywhere else on the site, so the field shows
   it instead of making the user remember whether to type it. It is decoration
   — the input's value stays exactly what the API expects. */
.field-prefixed { position: relative; display: block; }
.field-prefix {
  position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%);
  color: var(--muted); font-size: var(--text-sm); pointer-events: none;
}
.field-prefixed input { padding-left: 1.7rem; }
.form-actions { margin-top: 1.1rem; display: flex; gap: 0.6rem; flex-wrap: wrap; }
.token-form { margin-bottom: 0.5rem; flex-wrap: wrap; }

@media (max-width: 860px) {
  /* The index becomes a scrollable strip above the sections. Stacking it as a
     column here would put six links between the heading and the first thing
     you came to change. */
  .settings-grid { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
  .settings-nav {
    position: static;
    flex-direction: row; gap: 0.35rem;
    overflow-x: auto; padding-bottom: 0.35rem;
    border-bottom: 1px solid var(--line);
    scrollbar-width: none;
  }
  .settings-nav::-webkit-scrollbar { display: none; }
  .settings-nav a { white-space: nowrap; padding: 0.4rem 0.75rem; }
  .settings-nav a.danger-link { margin-top: 0; }
  .field-pair { grid-template-columns: minmax(0, 1fr); gap: 0; }
}

/* ---- Confirmation dialog -------------------------------------------------
   The panel for confirm.js. Everything here is a token, so the dialog is the
   same charcoal (or the same white) as the page underneath it without
   confirm.js knowing a theme exists — which is the whole reason this is a
   local component rather than a vendored library with a palette of its own.

   The one deliberate literal is the backdrop. It is drawn in the top layer,
   where custom properties inherited from :root are not reliably reachable, and
   an unresolved var() would leave the scrim fully transparent — the one
   failure that would make the dialog look broken. A dark scrim reads correctly
   over both themes (it is dimming the page, not matching it), so it is written
   out in full on purpose. Do not "fix" it to a token. */
.confirm {
  --confirm-motion: 180ms;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--ink);
  box-shadow: var(--shadow-md), var(--edge);
  padding: 0;
  width: min(26rem, calc(100vw - 2rem));
  /* Overrides the UA's margin:auto centring only in the block direction, so a
     tall panel on a short screen stays reachable rather than clipping. */
  margin: auto;
}
.confirm::backdrop {
  background: rgba(6, 5, 10, 0.62);
  opacity: 0;
  transition: opacity var(--confirm-motion) ease;
}
/* Selector, not a token: `::backdrop` cannot read custom properties from :root
   reliably, but it can still be *selected* by the theme attribute theme.js
   puts on <html>. The light scrim is lighter — 0.62 of near-black over a white
   page dims it to a muddy grey rather than reading as a dim. */
:root[data-theme="light"] .confirm::backdrop { background: rgba(28, 23, 38, 0.38); }
.confirm.is-open::backdrop { opacity: 1; }

.confirm-panel {
  padding: 1.6rem 1.5rem 1.3rem;
  text-align: center;                /* the SweetAlert shape: icon, question, consequence, buttons */
  opacity: 0;
  transform: translateY(6px) scale(0.96);
  transition: opacity var(--confirm-motion) ease,
              transform var(--confirm-motion) cubic-bezier(0.2, 0.8, 0.3, 1);
}
.confirm.is-open .confirm-panel { opacity: 1; transform: none; }

/* The in-place swap: confirming does not close this dialog and open another,
   it changes what this one says. The body cross-fades and the panel's height
   animates to the new content (confirm.js drives the height, which cannot be
   done in CSS from `auto`); the icon deliberately sits outside .confirm-body
   so it stays put and transitions its colour instead of fading — the ring
   reads as one object changing state, not two objects swapping. */
.confirm-body {
  transition: opacity calc(var(--confirm-motion) * 0.7) ease;
}
.confirm-body.is-swapping { opacity: 0; }
/* The glyph cross-fades even though the ring around it does not. Swapping "!"
   for the tick instantly, inside a ring that is smoothly transitioning its
   colour, was the most visible pop in the whole sequence. */
.confirm-glyph { display: inline-flex; transition: opacity calc(var(--confirm-motion) * 0.6) ease; }
.confirm-glyph.is-swapping { opacity: 0; }
/* Only while confirm.js is animating the height. The dialog's height is set on
   the <dialog>, but the padding and copy live in .confirm-panel — without this
   the panel's content spills through the rounded border and hangs outside the
   box for the length of the resize. Not applied at rest, because a permanent
   clip would cut the focus ring off the outermost button. */
.confirm.is-resizing { overflow: hidden; }

/* The icon reads the severity before the words do. A ring rather than a filled
   disc, so it carries colour without becoming the loudest thing on a dark
   surface — and the two states are the only colours it ever takes: --danger
   for anything destructive, --aqua for a question that merely needs an answer.
   --gold is reserved for warnings elsewhere in this file and stays that way. */
.confirm-icon {
  width: 3.5rem; height: 3.5rem;
  margin: 0 auto 0.9rem;
  border-radius: 50%;
  display: grid; place-items: center;
  font-family: var(--font-display);
  font-size: 1.75rem; font-weight: 700; line-height: 1;
  border: 2px solid currentColor;
  color: var(--aqua);
  background: color-mix(in srgb, currentColor 12%, transparent);
  /* Scales in a beat behind the panel — the bounce SweetAlert puts on its
     icon, minus the overshoot, which reads as noise on a confirmation people
     see several times a day. */
  transform: scale(0.7);
  opacity: 0;
  /* colour and background are in here so a question ring becoming a success
     ring is a shift in place rather than a repaint. They must stay part of
     this shorthand — a separate transition-property line above would be
     overwritten by it. */
  transition: transform var(--confirm-motion) cubic-bezier(0.2, 1.3, 0.4, 1) 60ms,
              opacity var(--confirm-motion) ease 60ms,
              color calc(var(--confirm-motion) * 0.8) ease,
              background calc(var(--confirm-motion) * 0.8) ease;
}
.confirm.is-open .confirm-icon { transform: none; opacity: 1; }
.confirm-icon.is-warning { color: var(--danger); }
/* Success is --aqua, not a new green. Every colour in this file is one of the
   tokens at the top by rule, and the palette has exactly one warm accent
   (--gold, warnings only) on purpose — a green would be a palette decision,
   not a component one. The tick shape is what says "done"; the ring only has
   to say "not an error". */
.confirm-icon.is-success { color: var(--aqua); }
.confirm-icon.is-success .confirm-glyph { transform: none; }   /* the SVG is already centred */
.confirm-glyph { transform: translateY(-1px); }   /* optical centring of ! and ? */
.confirm-glyph svg { display: block; }
.confirm-cancel[hidden] { display: none; }        /* an alert has nothing to decline */

.confirm-title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  margin: 0 0 0.45rem;
}
/* The question is the heading; this is the consequence, and it is the part
   people actually need, so it gets reading-sized type rather than meta. */
.confirm-text {
  margin: 0 auto; max-width: 30ch;
  color: var(--muted); font-size: var(--text-sm); line-height: 1.55;
}
.confirm-text[hidden] { display: none; }

.confirm-actions {
  display: flex; justify-content: center; gap: 0.6rem;
  margin-top: 1.4rem;
}
/* Bigger than a row button: these are the only two targets on screen, and the
   dialog has room the card rows do not. */
.confirm-actions button { padding: 0.5rem 1.1rem; min-width: 7rem; }
/* A destructive confirm is filled, not outlined. Everywhere else on the site
   button.danger is a quiet outline because it sits in a row of other controls;
   here it is the one action the dialog exists to offer, and an outline against
   a filled Cancel would put the visual weight on the wrong button. */
.confirm-ok.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.confirm-ok.danger:hover { background: color-mix(in srgb, var(--danger) 82%, #000); border-color: color-mix(in srgb, var(--danger) 82%, #000); }

@media (max-width: 480px) {
  /* Stacked, confirm on top — thumbs reach the bottom of the screen first, and
     that should be Cancel, not "Delete everything". Plain `column`, because
     source order is already confirm-then-cancel. */
  .confirm-actions { flex-direction: column; }
  .confirm-actions button { width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  /* Not `none` — confirm.js waits for transitionend with a timeout fallback,
     and a near-instant transition still fires it. */
  .confirm { --confirm-motion: 1ms; }
  .confirm-panel, .confirm-icon { transform: none; transition-delay: 0s; }
}

/* ---- Profile page --------------------------------------------------------
   A profile is one person's shelf: who they are, then everything they have
   published as the same cards the feed uses. The card styles come from
   .feed-card — only the page's own frame is defined here. */
.profile-head {
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--line);
  margin-bottom: 1.5rem;
}
.profile-head h1 { margin: 0 0 0.15rem; }
.profile-head .tagline { margin: 0.9rem 0 0; max-width: 60ch; }
/* Big enough to be a portrait rather than the 2rem row avatar, which is sized
   to sit inside a line of text. */
.avatar-lg { width: 4rem; height: 4rem; }
.profile-totals { margin-top: 1.1rem; }
.profile-projects h2 { margin: 0 0 0.9rem; }
/* The heading already says these are one person's projects, so the cards drop
   the "by @handle" line the feed needs — and with it the reason for the h2/h3
   size gap to be wide. */
.profile-projects .feed-card h3 { font-size: var(--text-lg); margin: 0; }
.profile-projects .feed-card + .feed-card { margin-top: 0.9rem; }

@media (max-width: 560px) {
  .avatar-lg { width: 3rem; height: 3rem; }
}

/* ===========================================================================
   Grid feed on a wide screen
   ===========================================================================
   The centre column was capped at 720px inside a track that can be 1200px
   wide, so on a large monitor the feed sat as a narrow ribbon with dead space
   either side of it. 720px is the right measure for ONE card — a pitch set
   much wider than ~68ch stops being readable — so the fix is more columns
   rather than a wider column: the cards tile, and the dead space becomes content.

   A CONTAINER query, not a media query, because the thing that decides whether
   two cards fit is the width of the centre track, and that is not a function
   of the viewport alone — the right rail drops out at 1200px, handing the
   centre ~300px in one step. A viewport breakpoint would have to encode that
   relationship twice and would be wrong in the band where the rail is gone. */
.app-main { container-type: inline-size; container-name: feedcol; }

/* 820px is measured, not chosen: it is the narrowest track in which two 340px
   cards and the gutter clear the column's own 1.75rem padding. Set at 900 it
   missed a 1440px laptop by twelve pixels — the exact screen the ribbon was
   most obvious on — because the right rail takes 312px of the viewport before
   the centre track gets any. */
@container feedcol (min-width: 820px) {
  /* Room for two columns plus the gutters, and no more: the cap still exists,
     it is just now a cap on the grid rather than on a single card. */
  .app-main main { max-width: 1180px; }

  #feed {
    display: grid;
    /* auto-fill with a floor, so the column count comes from the space that is
       actually there. 340px is the width below which the card footer (five
       chips and a mix bar) starts wrapping into three rows. */
    grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
    gap: 1.15rem;
  }

  /* Masonry packing, added by app.js once it has measured the cards.
     ---------------------------------------------------------------
     A plain grid puts every card in a row on the same row LINE, so the row is
     as tall as its tallest card and every shorter card in it trails dead space
     down to the next row — 15% of the feed's area at two columns and 25% at
     four, with single holes around 200px. The cards are wildly uneven by
     nature: the pitch is free text clamped at six lines, so a one-line pitch
     and a six-line one sit side by side constantly.

     The fix is the row-unit trick: make the implicit rows 1px, drop the row
     gap, and let each card span as many of those rows as it is tall (the gap
     is added into the span, so column-gap stays the single source of truth for
     the gutter). Auto-placement then drops each card into the first column
     with room, which is what masonry is.

     Native `grid-template-rows: masonry` would replace all of this, and should
     when it ships — it is still behind a flag in every browser.

     The class is the whole point of the two-step: without it this is an
     ordinary grid with ordinary gaps. If app.js never runs, the feed is merely
     gappy rather than a pile of 1px-tall overlapping cards. */
  #feed.is-masonry {
    grid-auto-rows: 1px;
    row-gap: 0;
  }
  /* Each card takes its own height rather than the tallest in its row.
     Stretching (the grid default) lines the bottoms up, but .card-footer is
     pinned to the bottom by margin-top: auto — so a short pitch beside a long
     one opened a 200px hole between the text and the chips, on the card with
     the LEAST to say. Ragged bottoms read as cards; a hole reads as a bug.
     The per-card bottom margin becomes the grid gap's job. */
  #feed { align-items: start; }
  /* min-height: 260px gives a full-width card a wide-rectangle shape. In a
     ~400px column it does the opposite: a two-line pitch is padded out with
     empty space above a footer that margin-top: auto has pushed to the floor.
     The column already provides the shape, so the card goes back to sizing
     itself. */
  #feed .feed-card { margin-bottom: 0; min-height: 0; }

  /* An empty state, an error note, or the "no results" line is a statement
     about the whole feed, not one tile in it. */
  #feed > .empty, #feed > .note { grid-column: 1 / -1; }

  /* Snap-scrolling is a one-card-at-a-time idea. With cards side by side there
     is no single next card to settle on, and the page fights the scroll. The
     snap type lives on <html> and cannot be reached from here, so the snap
     POINTS are removed instead — a proximity container with no snap areas
     scrolls normally. */
  #feed .feed-card { scroll-snap-align: none; }
}

@container feedcol (min-width: 1320px) {
  /* Three and then four across. Same floor, so this is really just permission
     to grow: auto-fill adds tracks once there is room for them.

     It stays capped, and the cap is a judgement rather than a measurement — a
     feed running the full width of a 2560px monitor is a wall, not a feed, and
     the cards would keep getting narrower rather than the reading getting
     better. 1720px is where four cards are still ~400px wide, which is about
     the width of the card on a tablet. */
  .app-main main { max-width: 1720px; }
}

/* ---------------------------------------------------------------------------
   Owner controls on a profile card (hide / delete)
   --------------------------------------------------------------------------- */
/* An unlisted project stays on its owner's own profile and nowhere else, so it
   needs to look different from the ones that are live — dimmed, with the state
   named rather than implied. Hover restores full opacity: it is dimmed to say
   "not published", not to say "unimportant". */
.feed-card.is-hidden-project { opacity: 0.62; border-style: dashed; }
.feed-card.is-hidden-project:hover { opacity: 1; }
.pill.hidden-pill {
  align-self: flex-start;
  color: var(--muted);
  border-style: dashed;
}
/* Both owner controls are chips, because they sit in the footer's chip row and
   anything else there would break the baseline. Delete carries the danger
   colour only on hover: at rest a red chip on every one of your own cards
   reads as an alarm about the project rather than as an available action. */
.pill.visibility-btn:hover { color: var(--aqua); border-color: color-mix(in srgb, var(--aqua) 45%, transparent); }
.pill.delete-btn { color: var(--muted); }
.pill.delete-btn:hover {
  background: var(--danger); border-color: var(--danger); color: #fff;
}

/* ===========================================================================
   Sidebar field guide (index.html, desktop only)
   ===========================================================================
   The left column was two links and 800px of nothing. It now holds the meaning
   of the words already on screen — the chips on every card, the names of the
   tabs — which is the one thing a permanent rail is good for: it is read WHILE
   looking at the feed, not instead of it.

   THE FIRST VERSION OF THIS WAS BAD and the reasons are worth keeping. It was
   bare 12px muted text laid straight onto the transparent column, 1600px of
   it, with three heading levels and two nested definition lists inside a 207px
   measure. Two separate mistakes: it was not an OBJECT (the right rail is made
   of cards, so unstyled text on the left read as documentation spilled into
   the nav), and there was far too much of it (a rail is a glance, not a page).

   So: the same card as .rail-card, and about a third of the words. Anything
   that needs a paragraph lives in /help.html, linked at the foot. */
.side-guide {
  margin-top: 1.75rem;
  display: flex; flex-direction: column; gap: 0.75rem;
  padding-bottom: 1.5rem;
}
/* Deliberately identical to .rail-card. The two rails are the same kind of
   thing — standing context either side of the feed — and should be built from
   the same object rather than two near-misses. */
.guide-card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 0.9rem 0.95rem;
  box-shadow: var(--edge), var(--shadow-sm);
}
/* Same treatment as .rail-title, so the left card and the right card have one
   voice. */
.guide-title {
  font-family: var(--font-display);
  font-size: var(--text-sm); font-weight: 700; letter-spacing: -0.01em;
  color: var(--ink);
  margin: 0 0 0.7rem;
}

/* The legend rows. The swatch is the REAL chip, not a description of it, so
   the definition never has to say which thing it is talking about — that is
   what keeps each entry to one short line in a 207px column. */
.guide-rows { margin: 0; font-size: var(--text-xs); line-height: 1.5; color: var(--muted); }
.guide-rows dt { margin-bottom: 0.3rem; }
.guide-rows dt + dd { margin: 0 0 0.75rem; }
.guide-rows dd:last-child { margin-bottom: 0; }
.guide-rows b { color: color-mix(in srgb, var(--ink) 85%, var(--muted)); font-weight: 700; }
/* The hype card has words where the first card has chips, so the term takes
   the chip's job: display face, ink colour, sitting above its own definition. */
.guide-rows.is-terms dt {
  font-family: var(--font-display);
  font-size: var(--text-xs); font-weight: 700; color: var(--ink);
  margin-bottom: 0.1rem;
}
.guide-rows.is-terms dt + dd { margin-bottom: 0.55rem; }

/* A caveat about the card, not about its last row — hence the rule above it. */
.guide-foot {
  margin: 0.75rem 0 0; padding-top: 0.7rem;
  border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
  font-size: var(--text-xs); line-height: 1.5; color: var(--muted);
}

/* Hanging marks rather than list bullets: a real marker at this size either
   clips against the card's padding or costs an indent the measure cannot
   spare. */
.guide-tips {
  list-style: none; margin: 0; padding: 0;
  font-size: var(--text-xs); line-height: 1.5; color: var(--muted);
}
.guide-tips li { position: relative; padding-left: 0.8rem; }
.guide-tips li + li { margin-top: 0.6rem; }
.guide-tips li::before {
  content: ""; position: absolute; left: 0; top: 0.55em;
  width: 4px; height: 1px;
  background: color-mix(in srgb, var(--aqua) 70%, transparent);
}
.guide-tips b { color: color-mix(in srgb, var(--ink) 85%, var(--muted)); font-weight: 700; }
.guide-tips em { color: var(--ink); font-style: italic; }

.guide-more {
  align-self: flex-start; padding: 0 0.15rem;
  font-size: var(--text-xs); font-weight: 700;
  color: var(--muted); text-decoration: none;
}
.guide-more:hover { color: var(--aqua); }

/* The chips in the legend are samples, not controls. Without this the pointer
   turns into a hand over them and they look clickable. */
.guide-card .pill { cursor: default; }

/* Desktop only, and this is the breakpoint that decides it: at 900px the
   sidebar stops being a column and becomes a horizontal strip a wordmark and
   two links tall. There is nothing to write in, and stacking the guide under
   the strip would push the search bar and the first card off the fold on a
   phone. Every word of it is in /help.html, linked from the footer at every
   width.

   This lives HERE, next to what it governs, rather than in the 900px block
   further up: `.side-guide` sets `display: flex`, and a same-specificity rule
   earlier in the file loses to it. Twice now this hide has silently stopped
   applying — once by being anchored into the 560px block by mistake, once by
   being outranked. Keep it last. */
@media (max-width: 900px) {
  .side-guide { display: none; }
}

/* ===========================================================================
   Phone ergonomics
   ===========================================================================
   Both blocks below come from a scripted audit (8 pages x 31 widths x
   signed-out/in), not from eyeballing — the failures were at 320px, which is
   the width nobody opens by hand. */

/* A .row is label-left, control-right. At 320px the control keeps its natural
   width and .grow (which has min-width: 0, so it is allowed to vanish) was
   squeezed to 28px — the account page's provider rows clipped their own
   labels to "Goo…" and "Git…". Wrapping is the honest fix: the label takes
   the full width and the control drops beneath it, which also gives the
   control room to be finger-sized. */
@media (max-width: 560px) {
  .row { flex-wrap: wrap; }
  .row .grow { flex: 1 1 100%; }
}

/* Tap targets. A 30px control is a coin toss with a thumb; 44px is the usual
   floor and what the real buttons get here. Chips are held to 34px instead —
   a row of five 44px pills stops reading as metadata and starts reading as a
   toolbar, and they are secondary actions on a card that is itself a link. */
@media (max-width: 600px) {
  button, .btn-cta, .auth-direct, .side-link, select {
    min-height: 44px;
  }
  button.small, .tab, .theme-toggle { min-height: 44px; }

  .pill, .repo-link, button.pill, .chip-select .chip { min-height: 34px; }
  /* The pill's height is fixed at 1.5rem, so min-height alone does nothing —
     it has to be released first. */
  .pill, .repo-link { height: auto; padding-top: 0.35rem; padding-bottom: 0.35rem; }

  /* "Read more" is a text button with no box: 17px tall, and the thing that
     reveals the rest of a pitch. Padding gives it a hit area without moving
     the text far from the paragraph it belongs to. */
  .feed-card .more-btn { min-height: 44px; padding: 0.5rem 0; }

  /* The help page's table of contents is a list of links, not prose — each
     one is a navigation target and needs the same floor. .toc is a wrapping
     flex row, so the links are flex items: `display: block` alone would not
     stack them, `flex: 1 1 100%` does. */
  .toc { gap: 0; }
  .toc a {
    flex: 1 1 100%;
    display: flex; align-items: center;
    min-height: 44px;
    border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
  }
  .toc a:last-child { border-bottom: 0; }

  /* The remaining offenders after the first pass, all of them navigation
     rendered as bare text: the wordmark (29px) and the top-bar links on the
     header.bar pages — Home, Feed, Your account, @handle (22px). They are the
     way off the page; they get the same floor as a button.

     The breadcrumb trail and the explore bands' "open in the feed" links are
     the same case, found by the audit once those pages existed. The crumbs get
     it on the <a> only: the current-page step is not a link and padding it
     would just make the trail tall for nothing. */
  .wordmark, header.bar nav a, .topbar-nav a, .crumbs a, .band-more {
    display: inline-flex; align-items: center;
    min-height: 44px;
  }
  /* The trail's own line-height has to keep up, or the 44px targets overlap
     each other when it wraps to two rows on a narrow phone. */
  .crumbs { gap: 0.1rem 0.4rem; }
  .band-head { align-items: center; }
}

/* ===========================================================================
   Hamburger menu
   ===========================================================================
   Left of the wordmark on every page — the one bit of chrome in the same place
   everywhere. It is not a second copy of the sidebar: the sidebar is Feed and
   Publish, this carries Explore, Discussions, your profile and help, and on a
   phone (where the sidebar collapses to a strip) it is the only route to any
   of them. */
.brand-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; }
/* `display` matters as much as the margin here. The phone tap-target block
   gives .wordmark `inline-flex; align-items: center; min-height: 44px`, but
   `.sidebar .wordmark` (line ~879) sets `display: block` at higher
   specificity, so the flex centring never applied: the anchor was the right
   44px box, and the GLYPHS sat at its top — 8.5px above the burger's centre
   beside it. Restated here, scoped to the row, which beats both. */
.brand-row .wordmark { margin-bottom: 0; display: inline-flex; align-items: center; }
#menuMount { position: relative; flex: 0 0 auto; }

.hamburger {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; padding: 0;
  background: none; border: 1px solid transparent; box-shadow: none;
  border-radius: var(--radius-sm);
}
.hamburger:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); border-color: var(--line); }
.hamburger-bars { display: block; width: 1.05rem; }
.hamburger-bars i {
  display: block; height: 2px; border-radius: 2px; background: var(--ink);
  transition: transform 0.16s ease, opacity 0.12s ease;
}
.hamburger-bars i + i { margin-top: 4px; }
/* Open state folds the bars into an ×. The middle bar fades rather than
   rotating with the others — three lines crossing reads as a mess at 17px. */
.hamburger.is-open .hamburger-bars i:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.hamburger.is-open .hamburger-bars i:nth-child(2) { opacity: 0; }
.hamburger.is-open .hamburger-bars i:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

.menu-panel {
  position: absolute; top: calc(100% + 0.5rem); left: 0; z-index: 60;
  min-width: 15rem; padding: 0.4rem;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--edge), var(--shadow-md);
}
.menu-panel[hidden] { display: none; }
.menu-item {
  display: block; padding: 0.5rem 0.6rem; border-radius: 8px;
  color: var(--ink); text-decoration: none;
}
.menu-item:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
.menu-item.current { background: color-mix(in srgb, var(--aqua) 12%, transparent); }
.menu-label { display: block; font-size: var(--text-sm); font-weight: 600; }
/* The hint is what makes this a menu rather than a list of nouns — "Explore"
   alone does not say how it differs from the feed. */
.menu-hint { display: block; font-size: var(--text-xs); color: var(--muted); margin-top: 0.1rem; }
.menu-sep { height: 1px; margin: 0.35rem 0.3rem; background: var(--line); }

/* ===========================================================================
   Breadcrumbs
   =========================================================================== */
.crumbs {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
  list-style: none; margin: 0 0 1.25rem; padding: 0;
  font-size: var(--text-xs); color: var(--muted);
}
.crumbs li { display: flex; align-items: center; gap: 0.4rem; }
.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--aqua); text-decoration: underline; }
/* The current page is not a link to itself. It takes the ink colour so the
   trail ends somewhere definite. */
.crumbs [aria-current="page"] { color: var(--ink); font-weight: 600; }
.crumb-sep { opacity: 0.5; }

/* ===========================================================================
   Project images
   ===========================================================================
   The feed is where people promote what they are building, so a picture is the
   post, not a thumbnail on a directory row. Full card width, leading.

   aspect-ratio, not a height: the box is reserved before the image arrives, so
   a slow load cannot reflow the card — which in a masonry column would re-pack
   everything below it. object-fit: cover means a portrait screenshot and a wide
   banner both fill the same box instead of one of them letterboxing. */
.feed-card.has-image { padding-top: 0; overflow: hidden; }
.card-img {
  display: block; margin: 0 -1.6rem 1.1rem;
  background: var(--surface-2);
}
.card-img img {
  display: block; width: 100%; aspect-ratio: 16 / 9;
  object-fit: cover;
  /* No border-radius: the card already clips with overflow: hidden, and a
     rounded image inside a rounded card double-rounds the corners. */
}
.project-img {
  display: block; width: 100%; max-height: 22rem; object-fit: cover;
  border-radius: var(--radius); border: 1px solid var(--line);
  margin-bottom: 1.25rem; background: var(--surface-2);
}

/* ===========================================================================
   Tombstones — a removed project
   ===========================================================================
   Deliberately blank-looking. A removed project has no pitch, no board and no
   repo link, and dressing the card up to look full would misrepresent what is
   left. The diagonal hatch is the "nothing here" mark; the one live link goes
   to the discussion, which is the reason the card exists at all. */
.feed-card.is-removed {
  border-style: dashed;
  background: color-mix(in srgb, var(--muted) 5%, var(--surface));
}
.feed-card.is-removed h3 a { color: var(--muted); }
.feed-card.is-removed h3 a:hover { color: var(--ink); }
.removed-mark {
  height: 3.5rem; border-radius: var(--radius-sm); margin-bottom: 0.9rem;
  background: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--muted) 12%, transparent) 0 6px,
    transparent 6px 12px);
}
.removed-note { color: var(--muted); font-size: var(--text-sm); margin: 0 0 0.5rem; }
.tombstone { margin-bottom: 1.5rem; }
.tombstone .removed-mark { height: 4.5rem; }

/* ===========================================================================
   Explore
   =========================================================================== */
.page-head { margin-bottom: 1.75rem; }
.explore-stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem; margin-bottom: 2.25rem;
}
.stat {
  padding: 0.9rem 1rem; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--edge);
}
.stat b {
  display: block; font-family: var(--font-display);
  font-size: var(--text-xl); letter-spacing: -0.02em; color: var(--ink);
}
.stat span { display: block; font-size: var(--text-xs); color: var(--muted); margin-top: 0.15rem; }

.explore-band { margin-bottom: 2.5rem; }
.band-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.band-head h2 { font-size: var(--text-lg); margin: 0; }
.band-more { font-size: var(--text-xs); font-weight: 700; color: var(--muted); text-decoration: none; white-space: nowrap; }
.band-more:hover { color: var(--aqua); }
.band-hint { color: var(--muted); font-size: var(--text-xs); margin: 0.25rem 0 0.9rem; }

/* Tiles wrap rather than scroll sideways. A horizontal scroller hides its own
   contents behind a gesture nobody performs on a desktop, and this page's whole
   job is showing range. */
.tile-row, .people-row {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 0.85rem;
}
.tile {
  display: flex; flex-direction: column;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--edge);
  overflow: hidden; text-decoration: none; color: var(--ink);
  transition: border-color 0.14s ease, transform 0.14s ease;
}
.tile:hover { border-color: color-mix(in srgb, var(--aqua) 40%, var(--line)); transform: translateY(-2px); }
.tile-img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: var(--surface-2); }
/* No picture is a state, not a gap: the same box, hatched, so a row of tiles
   keeps one rhythm whether or not people uploaded anything. */
.tile-img.is-blank {
  background: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--muted) 9%, transparent) 0 8px,
    transparent 8px 16px), var(--surface-2);
}
.tile-body { padding: 0.8rem 0.9rem; display: flex; flex-direction: column; gap: 0.15rem; }
.tile-body h3 { font-size: var(--text-sm); margin: 0; line-height: 1.3; }
.tile-pills { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.5rem; }
.tile-excerpt {
  font-size: var(--text-xs); color: var(--muted); margin: 0.4rem 0 0; line-height: 1.5;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
.tile.is-thread .tile-body { padding: 1rem; }

/* The card is no longer one big link: it holds a Follow button, and a button
   inside an anchor is invalid markup that navigates when you meant to follow. */
.person {
  display: flex; flex-direction: column; gap: 0.7rem;
  padding: 0.9rem; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--edge);
  color: var(--ink);
  transition: border-color 0.14s ease;
}
.person:hover { border-color: color-mix(in srgb, var(--aqua) 40%, var(--line)); }
.person-link {
  display: flex; gap: 0.75rem; align-items: center;
  text-decoration: none; color: inherit; min-width: 0;
}
.person-body { min-width: 0; display: block; }
.person-name { display: block; font-weight: 700; font-size: var(--text-sm); }
.person-body .meta { display: block; }
.person-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 0.5rem; flex-wrap: wrap;
}

/* Follow is an offer, so it is filled until you have taken it; afterwards it is
   a quiet state you can undo, not a call to action shouting at someone who has
   already said yes. */
.follow-btn { white-space: nowrap; background: var(--hot); border-color: var(--hot); color: #fff; }
.follow-btn:hover { background: var(--coral); border-color: var(--coral); color: #fff; }
.follow-btn.is-following { background: none; color: var(--muted); border-color: var(--line); }
.follow-btn.is-following:hover { color: var(--ink); border-color: var(--ink); background: none; }
.owner-follow { margin-top: 0.75rem; }

/* ===========================================================================
   Discussions
   =========================================================================== */
.thread {
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--edge);
  padding: 1.1rem 1.2rem; margin-bottom: 0.85rem;
}
.thread.is-open { border-color: color-mix(in srgb, var(--aqua) 35%, var(--line)); }
.thread-head { display: flex; gap: 0.75rem; align-items: flex-start; }
.thread-head h2 { font-size: var(--text-lg); margin: 0; line-height: 1.25; }
.thread-head h2 a { color: var(--ink); text-decoration: none; }
.thread-head h2 a:hover { color: var(--aqua); }
.thread-excerpt {
  color: color-mix(in srgb, var(--ink) 85%, var(--muted));
  margin: 0.7rem 0 0.8rem; line-height: 1.6;
}
.thread-body { margin-top: 1rem; }
.thread-prose { line-height: 1.7; }
.thread-prose p + p { margin-top: 0.8rem; }
.thread-owner { margin-top: 0.9rem; }
.thread-owner .delete-thread { color: var(--muted); }
.thread-owner .delete-thread:hover { background: var(--danger); border-color: var(--danger); color: #fff; }

.replies { margin-top: 1.25rem; border-top: 1px solid var(--line); padding-top: 1rem; }
.reply { display: flex; gap: 0.7rem; align-items: flex-start; }
.reply + .reply { margin-top: 0.9rem; padding-top: 0.9rem; border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent); }
.reply-body { line-height: 1.6; margin-top: 0.15rem; }
.reply-body p + p { margin-top: 0.5rem; }
.avatar-sm { width: 1.6rem; height: 1.6rem; }
.reply-form { margin-top: 1.1rem; }
.reply-form textarea { margin-bottom: 0.6rem; }

/* Relisting. "Put it back" is the one action an unlisted card exists to offer,
   so it is filled rather than being a sixth outline chip in the footer row —
   finding it used to mean reading every chip on a dimmed card. */
.pill.relist-btn {
  background: var(--hot); border-color: var(--hot); color: #fff;
}
.pill.relist-btn:hover { background: var(--coral); border-color: var(--coral); color: #fff; }

/* Grouped sections on your own profile: listed, then unlisted, then removed. */
.profile-group { margin-top: 2rem; }
.group-title {
  display: flex; align-items: center; gap: 0.5rem;
  font-family: var(--font-body);
  font-size: var(--text-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.12em;
  color: var(--muted);
  margin: 0 0 0.3rem;
}
.group-hint { color: var(--muted); font-size: var(--text-xs); margin: 0 0 0.9rem; max-width: 60ch; }

/* ===========================================================================
   Windowed feed
   ===========================================================================
   A shell is a card whose contents have been dropped because it is far from
   the viewport. It must occupy exactly the space the card did — its height is
   held by an inline min-height that app.js remembers — and must not LOOK like
   an empty card, or scrolling fast would flash a column of blank boxes. So it
   keeps its box and loses every visible surface. */
.feed-card.is-shell {
  background: none;
  border-color: transparent;
  box-shadow: none;
  padding: 0;
  min-height: 120px;          /* only used before a real height is known */
}
.feed-card.is-shell:hover { transform: none; border-color: transparent; box-shadow: none; }

/* The end marker doubles as the loading indicator: it is what the observer
   watches, and three quiet dots are enough to say "there is more". */
.feed-end {
  display: flex; justify-content: center; gap: 0.4rem;
  padding: 2rem 0 3rem;
}
.feed-end.hidden { display: none; }
.feed-end-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: color-mix(in srgb, var(--muted) 55%, transparent);
  animation: feed-end-pulse 1.1s ease-in-out infinite;
}
.feed-end-dot:nth-child(2) { animation-delay: 0.15s; }
.feed-end-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes feed-end-pulse {
  0%, 100% { opacity: 0.35; transform: translateY(0); }
  50%      { opacity: 1;    transform: translateY(-3px); }
}
@media (prefers-reduced-motion: reduce) {
  .feed-end-dot { animation: none; opacity: 0.5; }
}

/* ===========================================================================
   Brand lockup
   ===========================================================================
   The wordmark is the logo image rather than type. Applied as a background on
   the existing <a class="wordmark">VibeOasis</a> so no markup changed in the
   19 places it appears across 10 pages — and the text stays in the DOM as the
   accessible name, just pushed out of view.

   Both variants are cropped from public/img/VibeCodeLogo.png (which stacks
   them) to the SAME bounding box, so they are pixel-identical in size and the
   mark does not jump when the theme is toggled. Their flat backgrounds are
   keyed out to alpha; the source's dark half carries a vignette, so the key
   threshold there is measured from the background's own drift, not assumed.

   Dark is the file with no attribute because dark is this app's default and
   carries no attribute either (theme.js: a stored preference wins, no
   preference means dark, deliberately NOT the OS setting) — so there is no
   prefers-color-scheme arm here on purpose.

   Selector specificity is deliberate: `.sidebar .wordmark` (0,2,0) sets
   `display: block` ~900 lines up, and the phone tap-target block sets
   `display: inline-flex`. A bare `.wordmark` here would tie or lose against
   the first. Listing all three keeps this authoritative wherever it renders.
   `display` matters because the box is sized by aspect: block-level width
   resolves to `auto` = fill, which silently cancels aspect sizing. */
.wordmark, .sidebar .wordmark, .brand-row .wordmark {
  display: inline-block;
  /* Sized by WIDTH, not height. Both variants are exported to one artwork
     width and centred on an identical canvas, so width is the dimension that
     is guaranteed to match between them — the light lockup is genuinely
     taller (it carries a shadow the dark one does not). `contain` then fits
     the art inside the box and centres it, which keeps the two in register. */
  width: 9rem;                /* art renders 144x40 at the source's 480:133 */
  height: 44px;               /* the tap-target floor, kept */
  background: url('/img/logo-dark.png') left center / contain no-repeat;
  /* Keeps the accessible name and the link text, hides the glyphs. */
  text-indent: -200vw;
  overflow: hidden;
  white-space: nowrap;
}
:root[data-theme="light"] .wordmark,
:root[data-theme="light"] .sidebar .wordmark,
:root[data-theme="light"] .brand-row .wordmark {
  background-image: url('/img/logo-light.png');
}

/* The lockup is mark + two words, so it needs real width — more than the bar
   can spare on a narrow phone once the burger and the Feed/Publish links have
   taken theirs. Stepped down rather than hidden.

   Measured, because the overflow sweep does NOT catch this: the wordmark is
   the only shrinkable item in that flex row, so when the row goes over budget
   it silently absorbs the difference and the logo just renders smaller than
   the width here says. Nothing overlaps, nothing overflows, the audit stays
   green. The bottom step is sized so the row still fits at 320px rather than
   relying on that squeeze. */
@media (max-width: 560px) {
  .wordmark, .sidebar .wordmark, .brand-row .wordmark { width: 7.25rem; }
}
@media (max-width: 400px) {
  .wordmark, .sidebar .wordmark, .brand-row .wordmark { width: 5.125rem; }
}

/* ==========================================================================
   Touch input: never let focusing a field zoom the page (iOS Safari)
   ==========================================================================
   Mobile Safari auto-zooms whenever a focused form control computes to less
   than 16px, and EVERY control in this app was on --text-sm (14px) — the
   search box, the publish form, and worst of all the comment textarea, where
   the zoom lands you at ~1.14x with the box you are typing into pushed off
   the visible area. That is the bug Zane hit commenting on a project: the
   page zooms, and you can no longer see the screen or the comment box.

   The fix has to be the font-size, not the viewport tag — `maximum-scale=1` /
   `user-scalable=no` also stops the zoom but takes pinch-zoom away from
   everyone, which is an accessibility regression, so those stay out of the
   <meta> on all 10 pages.

   Two arms, because neither alone covers it. `pointer: coarse` is the honest
   test — the zoom is a property of the input mechanism, not the window, so a
   large tablet needs 16px no matter how wide it is. But an iPad with a
   trackpad or pencil attached reports `pointer: fine` while Safari still
   auto-zooms, so a width arm backs it up. 820px rather than the 700px used
   elsewhere in this file: this is about reaching every phone and portrait
   tablet, not about where the layout reflows.

   These sit at the END of the file and restate the selectors they override at
   equal-or-higher specificity ON PURPOSE — `header.bar .search input` (0,2,1)
   and `button.small` (0,1,1) both beat a bare `input`, so a lone
   `input, select, textarea` rule in here would silently lose to them. See the
   cascade note in NOTES.md: check the computed value, not the declaration. */
@media (pointer: coarse), (max-width: 820px) {
  input, textarea, select,
  header.bar .search input,
  .search input,
  .facet-filters select {
    font-size: 16px;
  }
  /* 16px on the control also means 16px on the options in an open picker. */
  select option { font-size: 16px; }
}

/* ==========================================================================
   Language filter / picker — toggle chips, not a <select multiple>
   ==========================================================================
   Was `<select multiple size="4">`. Three things were wrong with it:

   1. It rendered a partial row. size="4" plus the padding meant the scroll
      area was ~4.0 option-heights of CONTENT but the options scroll under the
      box's own padding, so a 5th language sat sliced in half against the
      bottom edge. That sliced row is what read as "broken".
   2. It was 85px tall next to two 42px dropdowns, and `align-items: center`
      centred it, so it protruded above AND below its siblings — the filter
      bar had no baseline.
   3. Multi-select needed ctrl/cmd+click, which is undiscoverable on a desktop
      and does not exist at all on a phone. Tapping a second language on a
      touch device silently REPLACED the first.

   Chips fix all three and are the vocabulary the app already speaks: this is
   the same pill as .facet-tags on a card and the hype pills under it, so the
   thing you press to filter by "Rust" looks like the "Rust" tag it matches.
   Real <button>s, so they are keyboard-reachable and announce pressed state
   via aria-pressed — which the listbox never did either. */
.chip-select { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.chip-select .chip {
  height: 1.75rem; padding: 0 0.7rem; border-radius: 999px;
  font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.01em;
  white-space: nowrap; cursor: pointer;
  border: 1px solid var(--line); color: var(--muted);
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  box-shadow: none;
  transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
}
.chip-select .chip:hover {
  border-color: color-mix(in srgb, var(--aqua) 55%, var(--line));
  color: var(--ink);
}
/* Selected state carries colour AND weight, not colour alone — the filter bar
   is read at a glance and a hue shift on a 12px chip is easy to miss. */
.chip-select .chip[aria-pressed="true"] {
  border-color: var(--aqua);
  background: color-mix(in srgb, var(--aqua) 16%, transparent);
  color: var(--ink);
}
.chip-select .chip:focus-visible {
  outline: none;
  border-color: var(--aqua);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--aqua) 22%, transparent);
}
/* The label that replaces the <select>'s aria-label, so the chip row still
   says what axis it is filtering on. */
.chip-select-label {
  font-size: var(--text-xs); font-weight: 600; color: var(--muted);
  letter-spacing: 0.01em; margin-right: 0.15rem;
}

/* The chip row always takes a line of its own, at every width. Sixteen
   languages wrap to three or four rows, and inline beside the dropdowns that
   left the two selects floating vertically centred against a tall block of
   chips with dead space beside them. Two honest rows instead: the two
   dropdowns (plus Clear) on the first, the language chips on the second. */
.facet-filters { flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem; }
.facet-filters .chip-select { flex: 1 1 100%; }

/* At phone width the two dropdowns split the line evenly rather than sizing to
   their content — "Any type" and "Any genre" side by side at their natural
   width leave a ragged gap, and neither is readable if a third control is
   squeezed onto the same line. */
@media (max-width: 700px) {
  .facet-filters select { flex: 1 1 0; min-width: 0; }
}

/* ==========================================================================
   Project action row on a phone — two buttons, not one slab
   ==========================================================================
   Follow and Boost are identical 44px blocks (the touch-target floor) in the
   same --surface-2, 12px apart, with the follower/boost count crammed on the
   same line. At 390px that reads as ONE segmented control rather than two
   separate actions — Zane's "the project buttons are one big button".

   Three changes, none of which touch the desktop layout:
   - the count drops to its own line, so the buttons are not competing with
     text for the row;
   - the buttons share the width evenly, so they are obviously a pair of peers
     rather than one block with a seam in it;
   - the gap doubles, which is what actually separates them visually.

   Also: the row had NO margin below it, so the "Work units" heading sat flush
   against the buttons (measured gap: 0px) and the actions looked like part of
   the next section. */
#signals .row { margin-bottom: 1.25rem; }
@media (max-width: 600px) {
  #signals .row { gap: 0.75rem; flex-wrap: wrap; }
  #signals .row button { flex: 1 1 0; min-width: 0; }
  #signals .row .meta { flex: 1 1 100%; order: 2; margin-top: 0.1rem; }
}

/* A form field whose control is not a single focusable element, so it cannot
   be a <label> — the chip group on the publish form. Matches `label`'s own
   spacing and type exactly so it sits in the stack as if it were one. */
.field { display: block; margin-bottom: 0.95rem; }
.field-label {
  display: block; margin-bottom: 0.4rem;
  font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.01em;
  color: var(--ink);
}

/* The signed-out stand-in for the comment box. Sits where the form would, so
   the thread does not simply stop with no explanation. */
.comment-signin { margin-top: 0.5rem; }
.comment-signin a { color: var(--aqua); font-weight: 600; }

/* ==========================================================================
   Sort tabs on a phone
   ==========================================================================
   The track is a 999px capsule with the current tab raised out of it, which
   is a one-row idea. Signed in there are FOUR tabs, they do not fit at 390px,
   and the capsule wrapped to two rows: "Needs agents / Swarming / Fresh" then
   "Following" stranded on a line of its own with the whole right half empty.
   A capsule that has wrapped is just a blob.

   Signed out there are only three tabs and they DO fit on one row, so the
   layout has to answer to which of the two states it is in rather than being
   fixed. `:has()` reads that off the DOM directly: app.js reveals the
   Following tab by removing `.hidden` from it, so the presence of a hidden
   tab IS the signed-out signal — no class on the container to keep in sync.

   Four tabs -> an even 2x2 grid: everything visible, no orphan, no dead
   space, and the tap targets get bigger rather than smaller.
   Three tabs -> stay one row, stretched to fill the width.
   If :has() is ever unavailable the flex rule stands on its own and the
   fourth tab wraps to a full-width row, which is still better than the blob. */
@media (max-width: 700px) {
  .tabs {
    display: flex; width: 100%; justify-content: stretch;
    /* Not a capsule any more: once this can occupy two rows a 999px radius
       bulges at the corners. The tabs inside keep their pill shape, so the
       raised "current" tab still reads as raised. */
    border-radius: 14px;
  }
  .tabs .tab { flex: 1 1 auto; }
  .tabs:not(:has(.tab.hidden)) { display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem; }
}
