/* ============================================================================
   Lab — Studio interface
   Dark-fantasy atelier. Atmosphere comes from a WebGL ambient canvas, not from
   stacked CSS gradients. Surfaces are flat. Shadows are restrained.
   ============================================================================ */


/* ------------------------------------------------------------------ tokens */

:root {
  color-scheme: dark;

  /* Wood (the desk) */
  --wood-deep:    oklch(15% 0.018 45);
  --wood-dark:    oklch(20% 0.022 45);
  --wood-mid:     oklch(25% 0.025 45);
  --wood-edge:    oklch(32% 0.028 50);

  /* Parchment (the documents) */
  --parch:        oklch(85% 0.040 80);
  --parch-light:  oklch(89% 0.038 78);
  --parch-warm:   oklch(81% 0.048 75);
  --parch-edge:   oklch(70% 0.050 70);
  --parch-shade:  oklch(60% 0.050 65);

  --ink:          oklch(22% 0.045 45);
  --ink-soft:     oklch(36% 0.045 50);
  --ink-faint:    oklch(50% 0.045 55);
  --ink-trace:    oklch(62% 0.040 60);

  /* Cream — printed on dark surfaces. Pulled toward steel/silver so it
     reads cooler against the warm ambient mist instead of fighting it. */
  --cream:        oklch(86% 0.018 95);
  --cream-soft:   oklch(70% 0.015 100);
  --cream-faint:  oklch(54% 0.012 110);
  --cream-trace:  oklch(42% 0.010 120);

  /* Moonstone — luminous cool accent for the brand seal */
  --moon:         oklch(92% 0.015 220);
  --moon-deep:    oklch(50% 0.020 230);

  /* Accents */
  --wine:         oklch(38% 0.180 22);
  --wine-deep:    oklch(28% 0.150 20);
  --wine-bright:  oklch(55% 0.220 25);
  --wine-glow:    oklch(68% 0.220 30);

  --wax:          oklch(48% 0.18 28);
  --wax-deep:     oklch(38% 0.17 25);
  --wax-bright:   oklch(58% 0.20 32);

  --ember:        oklch(64% 0.18 45);
  --ember-deep:   oklch(50% 0.19 38);
  --ember-glow:   oklch(70% 0.16 50);

  --amber:        oklch(74% 0.15 80);
  --amber-deep:   oklch(58% 0.16 70);

  --mist:         oklch(64% 0.080 195);
  --mist-pale:    oklch(54% 0.08 200);

  --brass:        oklch(60% 0.09 85);

  /* Semantic */
  --bg:           var(--wood-deep);
  --surface:      var(--parch);
  --rule-on-wood: oklch(100% 0 0 / 0.08);
  --rule:         color-mix(in oklch, var(--parch-edge) 60%, transparent);
  --text:         var(--cream);
  --text-soft:    var(--cream-soft);
  --text-faint:   var(--cream-faint);
  --on-parch:     var(--ink);
  --on-parch-soft:var(--ink-soft);
  --on-parch-faint:var(--ink-faint);

  /* Type */
  --font-serif:   "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, Georgia, serif;
  --font-sans:    -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
  --font-mono:    "JetBrains Mono", ui-monospace, "SF Mono", "Menlo", monospace;

  --fs-xs:   0.75rem;
  --fs-sm:   0.8125rem;
  --fs-base: 0.9375rem;
  --fs-md:   1.0625rem;
  --fs-lg:   1.25rem;
  --fs-xl:   1.625rem;
  --fs-2xl:  2.25rem;

  --lh-tight: 1.2;
  --lh-base:  1.55;

  /* Spacing */
  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.25rem;
  --s-6: 1.5rem;
  --s-8: 2rem;
  --s-10: 2.5rem;

  /* Radius */
  --r-xs: 2px;
  --r-sm: 3px;
  --r-md: 5px;
  --r-lg: 8px;

  /* Shadows — single-layer, restrained */
  --shadow-rest:  0 1px 2px oklch(0% 0 0 / 0.35);
  --shadow-sheet: 0 8px 24px oklch(0% 0 0 / 0.45);
  --shadow-lift:  0 14px 36px oklch(0% 0 0 / 0.55);

  /* Motion */
  --ease: cubic-bezier(.4, 0, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  --dur-fast: 120ms;
  --dur-base: 200ms;
  --dur-slow: 320ms;

  /* Layout */
  --rail-width: 64px;
  --rail-head: 52px;
  --content-max: 1280px;
}


/* ------------------------------------------------------------------- reset */

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

html, body { height: 100%; }

body {
  font-family: var(--font-sans);
  font-size: var(--fs-base);
  line-height: var(--lh-base);
  color: var(--text);
  background-color: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img, svg, video { max-width: 100%; display: block; }

/* Turbo's stream-source carrier is an invisible custom element — it only
   holds attributes for the ActionCable subscription. Default `display:
   inline` makes it a layout participant: dropped inside a flex container
   with a gap, it claims a full gap slot and pushes the next sibling
   down. Strip it from layout. */
turbo-cable-stream-source { display: none; }

button, input, select, textarea {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
}

a { color: inherit; text-decoration: none; }

ul, ol { list-style: none; }

h1, h2, h3, h4 {
  font-family: var(--font-serif);
  font-weight: 500;
  line-height: var(--lh-tight);
  letter-spacing: -0.005em;
  color: var(--text);
  text-wrap: balance;
}

h1 { font-size: var(--fs-2xl); font-weight: 400; }
h2 { font-size: var(--fs-xl); }
h3 { font-size: var(--fs-lg); }
h4 { font-size: var(--fs-md); }

p, li { text-wrap: pretty; }

code, kbd, samp {
  font-family: var(--font-mono);
  font-size: 0.875em;
  background: oklch(100% 0 0 / 0.05);
  padding: 0.1em 0.4em;
  border-radius: var(--r-xs);
  color: var(--cream);
}

::selection {
  background: color-mix(in oklch, var(--ember) 50%, transparent);
  color: var(--cream);
}

:focus-visible {
  outline: 2px solid var(--ember);
  outline-offset: 2px;
  border-radius: var(--r-xs);
}


/* ----------------------------------------------------------- ambient canvas */
/* Lives behind everything. Drawn by ambient_controller.js (WebGL2). */

#ambient {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 0;
}


/* ------------------------------------------------------------------ shell */

.studio {
  min-height: 100dvh;
  display: grid;
  grid-template-columns: var(--rail-width) 1fr;
  position: relative;
  z-index: 1;
}

.studio.unauthenticated {
  grid-template-columns: 1fr;

  & > .main {
    padding: 0;
    max-width: none;
    display: block;
  }
}


/* ------------------------------------------------------------- side rail */

/* The rail starts opaque on the left and fades to transparent toward its
   right edge, so it dissolves into the ambient mist rather than sitting
   on it as a hard-edged panel — no border, no backdrop blur. */
.rail {
  position: sticky;
  top: 0;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-3) 0 var(--s-4);
  background: linear-gradient(
    to right,
    var(--wood-dark) 0%,
    var(--wood-dark) 52%,
    transparent 100%);
  z-index: 10;
}

.rail .brand {
  position: relative;
  width: 100%;
  height: var(--rail-head);
  display: grid;
  place-items: center;
  filter:
    drop-shadow(0 0 6px oklch(62% 0.14 55 / 0.18))
    drop-shadow(0 1px 2px oklch(0% 0 0 / 0.45));
  view-transition-name: brand-mark;

  /* When the user is home, the brand carries the same ember marker as
     active nav items — flush to the rail's left edge, vertically aligned
     to the mark. Mirrors `.rail nav a.active::before` so the rail reads
     as one consistent vocabulary. */
  &.active::before {
    content: "";
    position: absolute;
    left: 0;
    top: 50%;
    width: 3px;
    height: 26px;
    background: var(--ember);
    border-radius: 0 2px 2px 0;
    transform: translateY(-50%);
    box-shadow: 0 0 10px var(--ember-glow);
  }
}

.brand-mark { width: 40px; height: 40px; }

.rail nav {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  flex: 1;
}

.rail .rail-foot {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  align-items: center;
  margin-top: var(--s-3);
}

.rail nav a,
.rail .rail-foot > a {
  --c: var(--cream-soft);
  position: relative;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border-radius: var(--r-md);
  color: var(--c);
  transition:
    color var(--dur-fast) var(--ease),
    background var(--dur-fast) var(--ease);

  & svg { width: 22px; height: 22px; }

  &:hover {
    --c: var(--cream);
    background: oklch(100% 0 0 / 0.05);
  }

  &.active {
    --c: var(--ember-glow);
    background: oklch(0% 0 0 / 0.3);

    &::before {
      content: "";
      position: absolute;
      left: -10px;
      top: 50%;
      width: 3px;
      height: 22px;
      background: var(--ember);
      border-radius: 0 2px 2px 0;
      transform: translateY(-50%);
      box-shadow: 0 0 10px var(--ember-glow);
    }
  }

  /* tooltip — minimal nameplate */
  &[data-label]::after {
    content: attr(data-label);
    position: absolute;
    left: calc(100% + 12px);
    top: 50%;
    transform: translateY(-50%) translateX(-4px);
    padding: var(--s-1) var(--s-3);
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--cream);
    background: oklch(15% 0.02 45 / 0.92);
    border: 1px solid var(--rule-on-wood);
    border-radius: var(--r-xs);
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transition: opacity var(--dur-fast) var(--ease),
                transform var(--dur-fast) var(--ease);
    z-index: 100;
    backdrop-filter: blur(8px);
  }

  &:hover[data-label]::after {
    opacity: 1;
    transform: translateY(-50%) translateX(0);
  }
}


/* ------------------------------------------------------------------ main */

.main {
  padding: var(--s-8);
  max-width: var(--content-max);
  width: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
}


/* ----------------------------------------------------------------- header */

.page-header {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: var(--s-6);
}

.page-header h1 {
  margin: 0;
  color: var(--cream);
  font-style: italic;
}

.page-header .actions {
  display: flex;
  gap: var(--s-2);
}


/* --------------------------------------------------------------- sheet */
/* Parchment laid on the desk. The base color is flat; the parchment grain
   is a single procedural SVG turbulence data URI shared by all sheets,
   blended over the base in multiply mode at low opacity. No raster textures. */

:root {
  --parchment-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch' seed='3'/><feColorMatrix values='0 0 0 0 0.20  0 0 0 0 0.13  0 0 0 0 0.06  0 0 0 0.10 0'/></filter><rect width='100%25' height='100%25' filter='url(%23g)'/></svg>");
  --parchment-foxing: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='420' height='420'><filter id='f'><feTurbulence type='fractalNoise' baseFrequency='0.012' numOctaves='2' stitchTiles='stitch' seed='7'/><feColorMatrix values='0 0 0 0 0.30  0 0 0 0 0.16  0 0 0 0 0.05  0 0 0 0.07 0'/></filter><rect width='100%25' height='100%25' filter='url(%23f)'/></svg>");
}

/* Full parchment recipe — grain + foxing, multiplied over the base surface.
   Shared by every "document on the desk" surface so the texture stays
   identical and a token change ripples through everything at once. */
.sheet,
.editions,
.envelope {
  background:
    var(--parchment-grain),
    var(--parchment-foxing),
    var(--surface);
  background-size: 220px 220px, 420px 420px, auto;
  background-blend-mode: multiply, multiply, normal;
  color: var(--on-parch);
}

/* Lighter parchment recipe — grain only, used on smaller / shorter-lived
   surfaces where the foxing freckles would feel busy. */
.autocomplete-dropdown,
.modal-body,
.flash {
  background:
    var(--parchment-grain),
    var(--surface);
  background-size: 220px 220px, auto;
  background-blend-mode: multiply, normal;
}

.sheet {
  border-radius: var(--r-md);
  box-shadow: var(--shadow-sheet);
  position: relative;
  transition: box-shadow var(--dur-base) var(--ease);
}

.sheet.padded { padding: var(--s-6); }

.sheet > header {
  padding: var(--s-4) var(--s-5);
  border-bottom: 1px solid var(--rule);
  display: flex;
  align-items: center;
  gap: var(--s-3);

  & h2, & h3 {
    font-family: var(--font-serif);
    color: var(--ink);
    font-size: var(--fs-md);
    font-weight: 500;
    margin: 0;
    font-style: italic;
  }

  & .spacer { flex: 1; }
}

.sheet > .body { padding: var(--s-5); color: var(--on-parch); }

.sheet.sheet-narrow > .body { max-width: 560px; }

.sheet > .foot {
  padding: 0 var(--s-5) var(--s-5);
  display: flex;
  gap: var(--s-2);
}

a.sheet, button.sheet, .sheet.is-link {
  display: block;
  text-align: left;
  cursor: pointer;
  color: var(--on-parch);

  &:hover {
    box-shadow:
      var(--shadow-lift),
      0 0 20px color-mix(in oklch, var(--wine-glow) 22%, transparent);
  }
}


/* ----------------------------------------------------------- dashboard */
/* User home — greeting (optional) + the work bands. Reuses .quest-list /
   _quest.html.erb verbatim, so this layer just owns the vertical rhythm
   and the greeting card. */
.dashboard {
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
  max-width: 760px;
  width: 100%;
  margin: 0 auto;
}

/* The greeting is a small parchment note pinned to the wall. The .sheet
   recipe handles the texture; we only own the head row's "From" label
   and the dismiss affordance. */
.greeting {
  & > header {
    & .greeting-from {
      display: flex;
      flex-direction: column;
      line-height: 1.2;
    }

    & .from-label {
      font-family: var(--font-sans);
      font-size: var(--fs-xs);
      text-transform: uppercase;
      letter-spacing: 0.14em;
      color: var(--ink-faint);
    }

    & .from-name {
      font-family: var(--font-serif);
      font-style: italic;
      color: var(--ink);
    }

    & .from-anonymous {
      color: var(--ink-soft);
    }
  }
}

/* Discrete close button — square pad, no fill, only the ember tint on
   hover so it doesn't shout from the corner of the note. */
.greeting-dismiss {
  appearance: none;
  background: none;
  border: 0;
  padding: var(--s-1);
  display: grid;
  place-items: center;
  color: var(--ink-faint);
  cursor: pointer;
  border-radius: var(--r-sm);
  transition: color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);

  & svg { width: 16px; height: 16px; }

  &:hover {
    color: var(--wine);
    background: color-mix(in oklch, var(--ink) 5%, transparent);
  }
}

.dashboard-quiet {
  font-family: var(--font-serif);
  font-style: italic;
  color: var(--cream-trace);
  text-align: center;
  padding: var(--s-8) var(--s-4);
}


/* ----------------------------------------------------------- standards */
/* Project card — a hanging standard with the project's crest dominating
   the panel. Two actions live at the foot: Run (open the live build) and
   Explore (drop into the project's workshop sheet). The card itself is
   not a link — the buttons own the actions. */

.standards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--s-6) var(--s-5);
  padding-top: var(--s-2);
}

.standard {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-5) var(--s-3);
  color: var(--cream);
}

/* The crest is the project's primary affordance — clicking it opens the
   dossier (same target as the Explore button). The lamp-warmth glow
   triggers only on the link itself so the buttons below don't double-fire
   the crest's hover state. */
.standard-crest-link {
  display: block;
  border-radius: var(--r-md);
  outline: none;

  &:hover .standard-crest,
  &:focus-visible .standard-crest {
    filter:
      drop-shadow(0 4px 26px color-mix(in oklch, var(--ember-glow) 55%, transparent))
      brightness(1.06);
  }

  &:focus-visible {
    box-shadow: 0 0 0 2px color-mix(in oklch, var(--ember) 60%, transparent);
  }
}

/* The frame is the positioning context for the painted-text overlay
   used on projects without a crest — keeps the label anchored to the
   blank shield's scroll regardless of the card's flex flow. */
.standard-crest-frame {
  position: relative;
  width: 180px;
  height: 180px;
  max-width: 100%;
}

.standard-crest {
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 4px 14px color-mix(in oklch, var(--wine-glow) 22%, transparent));
  transition: filter var(--dur-base) var(--ease);
}

/* Painted name on the blank shield's scroll — sits where the parchment
   banner would carry hand-lettered text on the real crests. Not for
   crested projects (their name is already in the artwork). */
.standard-crest-label {
  position: absolute;
  top: 68%;
  left: 50%;
  width: 70%;
  transform: translateX(-50%);
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-base);
  color: oklch(32% 0.04 60);
  text-align: center;
  text-wrap: balance;
  pointer-events: none;
}

/* The h2 is kept for the document outline / screen readers but never
   shown — the crest carries the name visually (either painted into the
   artwork, or overlaid on the blank scroll above). Same job as the
   .visually-hidden utility, kept as a dedicated alias so the markup
   reads its intent. */
.standard-name,
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.standard-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s-2);
  width: 100%;
  margin-top: auto;
}


/* ----------------------------------------------------------- edition row */

.editions {
  border-radius: var(--r-md);
  box-shadow: var(--shadow-sheet);
  overflow: hidden;
}

.edition {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  transition: background var(--dur-fast) var(--ease);

  &:hover { background: oklch(0% 0 0 / 0.04); }

  & .number {
    display: inline-grid;
    place-items: center;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    border: 1.5px solid var(--ink);
    color: var(--ink);
    font-family: var(--font-serif);
    font-style: italic;
    font-size: var(--fs-sm);
    line-height: 1;
    flex: 0 0 auto;
  }

  & .label {
    display: flex;
    flex-direction: column;
    gap: 2px;
    flex: 1 1 0;
    min-width: 0;

    & .version {
      font-family: var(--font-serif);
      font-size: var(--fs-md);
      color: var(--ink);
      font-style: italic;
    }

    & .when {
      font-size: var(--fs-xs);
      color: var(--ink-faint);
    }
  }

  & .ribbon-current {
    display: inline-flex;
    align-items: center;
    padding: var(--s-1) var(--s-3);
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    line-height: 1;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--parch-light);
    background: var(--wax);
    border-radius: var(--r-xs);
    flex: 0 0 auto;
  }

  & .open {
    color: var(--wax);
    font-family: var(--font-serif);
    font-style: italic;
    font-size: var(--fs-sm);
    display: inline-flex;
    align-items: center;
    gap: var(--s-1);
    flex: 0 0 auto;

    &:hover { color: var(--wax-bright); }
  }

  /* Action cluster on the right side of a row (download / fork / delete).
     Ghost buttons keep the row visually quiet — the row itself isn't the
     primary action surface. */
  & .actions {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    flex: 0 0 auto;
    margin-left: auto;

    & form { margin: 0; }
  }
}

/* Asset version rows are denser than build editions: the number drops its
   medallion for a quiet monospace `#N`, the "current" tag loses its wax pill
   for a small wax-tinted label, and the actions shrink to borderless icon
   buttons — the row is a log entry, not a call to action. */
.versions {
  & .number {
    width: auto;
    height: auto;
    border: 0;
    border-radius: 0;
    font-family: var(--font-mono);
    font-style: normal;
    font-size: var(--fs-xs);
    color: var(--ink-trace);
  }

  & .ribbon-current {
    padding: 0;
    background: transparent;
    color: var(--wax);
    font-size: 0.6875rem;
  }
}

/* Compact icon button for a version row — borderless until hovered, so a
   row of three reads as quiet glyphs rather than a button bar. */
.version-action {
  display: inline-grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: var(--r-sm);
  border: 1px solid transparent;
  background: transparent;
  color: var(--ink-faint);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);

  & svg { width: 15px; height: 15px; }

  &:hover, &:focus-visible {
    color: var(--ink);
    background: oklch(0% 0 0 / 0.05);
    outline: 0;
  }

  &.danger:hover, &.danger:focus-visible {
    color: var(--wine-bright);
    background: color-mix(in oklch, var(--wine) 12%, transparent);
  }
}


/* ----------------------------------------------------------------- cards */

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: var(--s-5) var(--s-4);
}

/* Cards use the stretched-link pattern: a <article> wraps an inner <a> that
   carries the primary action, plus sibling content (tags) and any secondary
   controls (the eye button, the dialog). The link's ::before extends its
   hitbox to the whole card; siblings are pointer-events:none so clicks on
   empty space still resolve to the link. The eye button opts back in to
   pointer events to remain interactive.

   The card reads as a pinned plate: a square preview tile sits flush at the
   top, the name + category beneath on a dark translucent panel that lets the
   ember mist read through, matching the quest log. */
.card {
  color: var(--cream);
  background: oklch(100% 0 0 / 0.02);
  border: 1px solid oklch(100% 0 0 / 0.06);
  border-radius: var(--r-sm);
  display: flex;
  flex-direction: column;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  transition: background var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              box-shadow var(--dur-base) var(--ease);

  &:hover {
    background: oklch(0% 0 0 / 0.22);
    border-color: oklch(100% 0 0 / 0.14);
    box-shadow: 0 0 18px color-mix(in oklch, var(--wine-glow) 18%, transparent);
  }

  & .card-link {
    display: flex;
    flex-direction: column;
    color: var(--cream);
    text-decoration: none;

    &::before {
      content: "";
      position: absolute;
      inset: 0;
      z-index: 1;
    }
  }

  /* Square preview tile — the piece itself, shown flush and at full
     visibility on a dark mat. `cover` keeps the grid uniform; the full,
     uncropped asset is one click away in the lightbox. Assets with no image
     preview fall back to a centred glyph on the same mat. */
  & .card-thumb {
    aspect-ratio: 1;
    display: grid;
    place-items: center;
    overflow: hidden;
    background: oklch(0% 0 0 / 0.28);
    border-bottom: 1px solid oklch(100% 0 0 / 0.06);

    & img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
      transition: transform var(--dur-base) var(--ease);
    }

    & svg {
      width: 34%;
      height: 34%;
      color: var(--cream-trace);
    }
  }

  &:hover .card-thumb img { transform: scale(1.04); }

  & .card-meta {
    display: flex;
    flex-direction: column;
    gap: var(--s-1);
    padding: var(--s-3) var(--s-4) var(--s-4);
  }

  & .name {
    font-family: var(--font-serif);
    font-size: var(--fs-sm);
    font-weight: 500;
    word-break: break-word;
  }

  & .category {
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    color: var(--cream-faint);
    text-transform: uppercase;
    letter-spacing: 0.12em;
  }

  & .tags {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    padding: 0 var(--s-4) var(--s-4);
    pointer-events: none;

    /* Tags inside a card can be either spans (index) or links (show);
       restore pointer events only on real interactive chips. */
    & a.tag { pointer-events: auto; }
  }
}

/* Eye button — a small dark medallion pinned to the corner of a card
   with a preview. Dimmed at rest so it reads as an affordance rather than
   shouting; lifts to wine-glow on hover. Sits above the stretched link so
   it captures its own clicks, and the Stimulus action stops propagation so
   the underlying card link is never followed. */
.card-eye {
  position: absolute;
  top: var(--s-3);
  right: var(--s-3);
  width: 28px;
  height: 28px;
  border-radius: 999px;
  border: 1px solid oklch(100% 0 0 / 0.15);
  background: oklch(0% 0 0 / 0.55);
  color: var(--cream-soft);
  display: grid;
  place-items: center;
  cursor: pointer;
  z-index: 3;
  opacity: 0.7;
  transform: translateY(0) scale(1);
  transition: opacity var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease),
              transform 140ms cubic-bezier(0.34, 1.6, 0.6, 1);

  & svg {
    width: 16px;
    height: 16px;
    transition: transform 200ms cubic-bezier(0.34, 1.6, 0.6, 1);
  }

  &:hover, &:focus-visible {
    opacity: 1;
    color: var(--wine-bright);
    background: oklch(0% 0 0 / 0.72);
    border-color: color-mix(in oklch, var(--wine-bright) 50%, transparent);
    box-shadow: 0 0 12px color-mix(in oklch, var(--wine-glow) 30%, transparent);
    outline: 0;
    transform: translateY(-1px) scale(1.08);

    & svg { transform: scale(1.1); }
  }

  &:active {
    transform: scale(0.9);
    transition-duration: 60ms;
  }
}


/* -------------------------------------------------------------- tag chip */

.tag {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  line-height: 1;
  color: oklch(82% 0.14 28);
  background:
    radial-gradient(ellipse at 50% 50%,
      color-mix(in oklch, var(--wine-deep) 70%, transparent) 0%,
      color-mix(in oklch, var(--wine-deep) 90%, transparent) 100%);
  padding: 3px 10px;
  border-radius: 999px;
  border: 1px solid color-mix(in oklch, var(--wine) 60%, transparent);
  box-shadow:
    0 0 10px color-mix(in oklch, var(--wine-glow) 25%, transparent),
    0 0 0 1px color-mix(in oklch, var(--wine-bright) 20%, transparent) inset;

  & svg { width: 12px; height: 12px; }
}

a.tag {
  transition: box-shadow var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease);

  &:hover {
    color: oklch(90% 0.16 30);
    box-shadow:
      0 0 18px color-mix(in oklch, var(--wine-glow) 50%, transparent),
      0 0 0 1px var(--wine-bright) inset;
  }
}

/* Plain tags are supporting metadata, not actions — they recede behind the
   project pills. No border: a soft parchment fill alone carries the chip shape,
   so the row reads as "projects (lit) + quiet keywords" instead of a wall of
   outlined pills. Border kept transparent (not removed) so muted and project
   pills stay the same height on a shared flex row. */
.tag.muted {
  color: var(--ink-soft);
  background: oklch(0% 0 0 / 0.05);
  border-color: transparent;
  box-shadow: none;
  font-style: italic;
}

/* On the dark index cards, the plain-tag fill flips to light-on-dark so
   it still reads as a quiet chip rather than vanishing into the panel. */
.card .tag.muted,
.standard .tag.muted {
  color: var(--cream-faint);
  background: oklch(100% 0 0 / 0.05);
}


/* ----------------------------------------------------------------- buttons */

.btn {
  --bg: oklch(100% 0 0 / 0.04);
  --bd: oklch(100% 0 0 / 0.12);
  --fg: var(--cream);
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-4);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: 1;
  letter-spacing: 0.04em;
  background: var(--bg);
  color: var(--fg);
  border: 1px solid var(--bd);
  border-radius: var(--r-sm);
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  transition:
    background var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);

  & svg { width: 14px; height: 14px; }

  &:hover {
    --bg: oklch(100% 0 0 / 0.08);
    --bd: oklch(100% 0 0 / 0.18);
  }

  &:active { transform: translateY(1px); }
}

/* Primary action — an ember coal, not a SaaS CTA. The fill leans toward
   wax-deep so it reads as warm coal rather than a flat orange block, and
   it carries a resting glow halo so it looks lit by the ambient mist.
   Mirrors .btn.danger's "lit from within" treatment, in ember not wine. */
.btn.primary {
  --bg: color-mix(in oklch, var(--ember) 86%, var(--wax-deep));
  --bd: var(--ember-deep);
  --fg: oklch(20% 0.05 40);
  box-shadow:
    0 0 18px color-mix(in oklch, var(--ember-glow) 30%, transparent),
    0 0 0 1px color-mix(in oklch, var(--ember-glow) 28%, transparent) inset;

  &:hover {
    --bg: var(--ember-glow);
    --bd: var(--ember);
    box-shadow:
      0 0 26px color-mix(in oklch, var(--ember-glow) 55%, transparent),
      0 0 0 1px color-mix(in oklch, var(--ember-glow) 50%, transparent) inset;
  }
}

.btn.ghost {
  --bg: transparent;
  --bd: transparent;
  --fg: var(--cream-soft);

  &:hover {
    --bg: oklch(100% 0 0 / 0.05);
    --fg: var(--cream);
  }
}

.btn.danger {
  --bg: color-mix(in oklch, var(--wine-deep) 30%, transparent);
  --bd: var(--wine);
  --fg: oklch(80% 0.18 28);
  box-shadow:
    0 0 14px color-mix(in oklch, var(--wine-glow) 30%, transparent),
    0 0 0 1px color-mix(in oklch, var(--wine) 50%, transparent) inset;

  &:hover {
    --bg: var(--wine);
    --bd: var(--wine-bright);
    --fg: var(--parch-light);
    box-shadow:
      0 0 22px color-mix(in oklch, var(--wine-glow) 60%, transparent),
      0 0 0 1px var(--wine-bright) inset;
  }
}

/* Default .btn on dark gets a subtle wine kiss on hover so action buttons
   feel "lit from within" against the atmospheric mist. */
.btn:not(.primary):not(.danger):not(.ghost):hover {
  box-shadow: 0 0 14px color-mix(in oklch, var(--wine-glow) 25%, transparent);
}

.btn.sm    { padding: var(--s-1) var(--s-3); font-size: var(--fs-xs); }
.btn.block { display: flex; width: 100%; justify-content: center; }

/* Form action row inside .sheet */
.form-actions {
  display: flex;
  gap: var(--s-2);
  margin-top: var(--s-4);
}

/* Inline text variants on parchment surfaces */
.text-faint { color: var(--ink-faint); }
.text-soft  { color: var(--ink-soft); }

/* Buttons inside parchment .sheet shift to ink-on-parchment */
.sheet .btn:not(.primary):not(.danger) {
  --bg: oklch(0% 0 0 / 0.04);
  --bd: oklch(0% 0 0 / 0.15);
  --fg: var(--ink);

  &:hover {
    --bg: oklch(0% 0 0 / 0.08);
    --bd: oklch(0% 0 0 / 0.22);
  }
}

.sheet .btn.ghost {
  --bg: transparent;
  --bd: transparent;
  --fg: var(--ink-soft);

  &:hover {
    --bg: oklch(0% 0 0 / 0.05);
    --fg: var(--ink);
  }
}


/* ----------------------------------------------------------------- menu
   Reusable overflow menu — a kebab (⋮) trigger opening a small parchment slip
   of actions. Built for secondary + destructive actions that shouldn't sit in
   the open: one deliberate click reveals them, which calms the surface and
   makes a mis-click on a destructive row impossible. Driven by
   menu_controller.js. Shared by sheet-tools and the chat message actions.
   Markup in shared/_menu.html.erb. */
.menu {
  position: relative;
  display: inline-flex;
}

.menu-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-2);
  background: transparent;
  border: 0;
  border-radius: var(--r-sm);
  color: color-mix(in oklch, var(--ink) 35%, transparent);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);

  & svg { width: 16px; height: 16px; }
}

.menu-trigger:hover,
.menu-trigger:focus-visible,
.menu-trigger[aria-expanded="true"] {
  color: var(--ink);
  background: color-mix(in oklch, var(--ink) 8%, transparent);
  outline: none;
}

/* The panel is a small parchment slip lifted off the sheet — single soft
   depth shadow, never a glow (it's a surface, not an interactive element). */
.menu-panel {
  position: absolute;
  top: calc(100% + var(--s-1));
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: 1px;
  /* Explicit width (not auto) so the `right: 0` anchor wins: an absolutely
     positioned panel with only `right` set is otherwise over-constrained by
     the narrow trigger it hangs off, which squeezes the rows and clips the
     longest label. `max-content` sizes the slip to its widest row. */
  width: max-content;
  max-width: min(20rem, 80vw);
  min-width: 11rem;
  padding: var(--s-1);
  background: var(--parch-light);
  border: 1px solid color-mix(in oklch, var(--parch-edge) 70%, transparent);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-sheet);
}

.menu-panel-end { right: 0; }
.menu-panel-start { left: 0; }

/* Flipped open upward (set by menu_controller.js when a downward panel would
   spill past the viewport — e.g. the latest chat messages low in the column). */
.menu-panel-up {
  top: auto;
  bottom: calc(100% + var(--s-1));
}

.menu-panel[hidden] { display: none; }

.menu-panel form { margin: 0; }

.menu-item {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  padding: var(--s-2) var(--s-3);
  background: transparent;
  border: 0;
  border-radius: var(--r-sm);
  color: var(--ink);
  font: inherit;
  font-size: var(--fs-sm);
  text-align: left;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);

  & svg {
    flex: none;
    width: 16px;
    height: 16px;
    color: color-mix(in oklch, var(--ink) 50%, transparent);
    transition: color var(--dur-fast) var(--ease),
                filter var(--dur-fast) var(--ease);
  }
}

/* Constructive rows warm to ember on hover; the destructive row heats to
   wine — the same semantics the old icon buttons carried, now spelled out
   with a label so the action is named, not guessed. */
.menu-item:hover,
.menu-item:focus-visible {
  color: var(--ember);
  background: color-mix(in oklch, var(--ember-deep) 10%, transparent);
  outline: none;

  & svg { color: currentColor; }
}

.menu-item.danger:hover,
.menu-item.danger:focus-visible {
  color: var(--wine-bright);
  background: color-mix(in oklch, var(--wine-deep) 12%, transparent);
}

/* Copy rows confirm in place (clipboard_controller swaps the label and adds
   `is-copied`): the row holds an ember-lit state for a beat so the copy reads
   as acknowledged without a toast. */
.menu-item.is-copied,
.menu-item.is-copied:hover {
  color: var(--ember);
  background: color-mix(in oklch, var(--ember-deep) 8%, transparent);

  & svg { color: currentColor; }
}

/* Hairline between the reference/bookkeeping rows and the mutating ones
   (edit / destroy) — a beat of separation so the destructive row isn't a
   reflex click away from a copy. */
.menu-sep {
  height: 1px;
  margin: var(--s-1) var(--s-2);
  border: 0;
  background: color-mix(in oklch, var(--parch-edge) 55%, transparent);
}

/* The pin row carries its state into the menu: planted = ember icon with a
   glow halo and a slight tilt, so a pinned sheet reads "stuck in" at a glance
   even with the menu closed-then-opened. */
.menu-item.sheet-pin.pinned svg {
  color: var(--ember);
  fill: currentColor;
  stroke: var(--ember-deep);
  filter: drop-shadow(0 0 6px color-mix(in oklch, var(--ember-glow) 60%, transparent));
  transform: rotate(-8deg);
}

@media (prefers-reduced-motion: no-preference) {
  .menu-panel:not([hidden]) {
    animation: menu-in var(--dur-fast) var(--ease-out);
    transform-origin: top var(--menu-origin-x, right);
  }

  .menu-panel-start { --menu-origin-x: left; }

  .menu-panel-up:not([hidden]) {
    animation: menu-in-up var(--dur-fast) var(--ease-out);
    transform-origin: bottom var(--menu-origin-x, right);
  }
}

@keyframes menu-in {
  from { opacity: 0; transform: translateY(-4px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes menu-in-up {
  from { opacity: 0; transform: translateY(4px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}


/* ----------------------------------------------------------- form fields */

.field {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  margin-bottom: var(--s-4);

  & > label {
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--ink-faint);
    font-weight: 500;
  }

  & > small {
    font-size: var(--fs-xs);
    color: var(--ink-faint);
    font-style: italic;
  }
}

.input,
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="number"],
input[type="url"],
textarea,
select {
  width: 100%;
  padding: var(--s-2) var(--s-3);
  font: inherit;
  font-family: var(--font-serif);
  font-size: var(--fs-base);
  color: var(--ink);
  background: oklch(100% 0 0 / 0.5);
  border: 1px solid color-mix(in oklch, var(--ink) 25%, transparent);
  border-radius: var(--r-sm);
  transition: border-color var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease);
  field-sizing: content;

  &::placeholder {
    color: var(--ink-trace);
    font-style: italic;
  }

  &:hover { border-color: color-mix(in oklch, var(--ink) 40%, transparent); }

  &:focus {
    outline: none;
    border-color: var(--ember);
    box-shadow: 0 0 0 3px color-mix(in oklch, var(--ember) 25%, transparent);
  }
}

textarea { min-height: 4lh; resize: vertical; }

/* Chrome/Safari paint a hard-coded autofilled state (yellowed bg, system
   text color) that ignores our parchment palette. The standard override:
   a giant inset box-shadow replaces the bg, and `-webkit-text-fill-color`
   replaces the text — both with our own tokens so autofilled fields are
   visually indistinguishable from manually-typed ones. The long
   transition delay defers any restoration of the native style until the
   user effectively never sees it. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill {
  -webkit-box-shadow: 0 0 0 1000px oklch(100% 0 0 / 0.5) inset;
  -webkit-text-fill-color: var(--ink);
  caret-color: var(--ink);
  transition: background-color 9999s ease-in-out 0s;
}

input[type="file"] {
  font-size: var(--fs-sm);
  color: var(--ink-soft);
  background: transparent;
  border: 0;
  padding: 0;

  &::file-selector-button {
    margin-right: var(--s-3);
    padding: var(--s-1) var(--s-3);
    font: inherit;
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--ink);
    background: oklch(0% 0 0 / 0.06);
    border: 1px solid oklch(0% 0 0 / 0.15);
    border-radius: var(--r-sm);
    cursor: pointer;
    transition: background var(--dur-fast) var(--ease);
  }

  &::file-selector-button:hover {
    background: oklch(0% 0 0 / 0.10);
  }
}

input[type="checkbox"] {
  width: 14px;
  height: 14px;
  accent-color: var(--ember-deep);
}

fieldset {
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  padding: var(--s-3) var(--s-4) var(--s-2);
  margin-bottom: var(--s-4);

  & > legend {
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--ink-faint);
    padding: 0 var(--s-2);
  }

  & label {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    margin: var(--s-1) var(--s-3) var(--s-1) 0;
    font-family: var(--font-serif);
    font-size: var(--fs-sm);
    color: var(--ink);
    cursor: pointer;
  }
}


/* ----------------------------------------------------------- autocomplete */
/* Shared parchment dropdown for both the multi-value tag input and the
   single-value category select. Both controllers anchor their dropdown to
   a wrapper that's `position: relative`. */

.autocomplete-dropdown {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  z-index: 20;
  max-height: 240px;
  overflow-y: auto;
  padding: 4px;
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  box-shadow: var(--shadow-lift);

  &[hidden] { display: none; }
}

.autocomplete-option {
  padding: 4px 10px;
  font-family: var(--font-serif);
  font-size: var(--fs-sm);
  font-style: italic;
  color: var(--ink);
  border-radius: var(--r-xs);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease);

  &:hover,
  &.active {
    background: color-mix(in oklch, var(--ember) 18%, transparent);
  }
}

/* In-place editors render their values lowercase and upright (tag chips,
   category field) — so their dropdown options drop the strip's inherited
   uppercase and the parchment italic to match what gets committed. */
.editing .autocomplete-option {
  font-style: normal;
  text-transform: none;
}

.autocomplete-empty {
  padding: 4px 10px;
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-xs);
  color: var(--ink-faint);
}


/* -------------------------------------------------------------- tag input */
/* Select2-style chip field: chips + a typing input live inside one bordered
   surface. The hidden <input type="hidden"> behind the scenes carries the
   canonical comma-separated value to Rails. */

.tag-input {
  position: relative;
  display: block;
}

.tag-input-field {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  padding: 6px 8px;
  background: oklch(100% 0 0 / 0.5);
  border: 1px solid color-mix(in oklch, var(--ink) 25%, transparent);
  border-radius: var(--r-sm);
  min-height: 2.2rem;
  cursor: text;
  transition: border-color var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease);

  &:hover { border-color: color-mix(in oklch, var(--ink) 40%, transparent); }

  &:focus-within {
    border-color: var(--ember);
    box-shadow: 0 0 0 3px color-mix(in oklch, var(--ember) 25%, transparent);
  }
}

.tag-input-typed {
  flex: 1 1 8ch;
  min-width: 8ch;
  width: auto;
  padding: 2px 0;
  background: transparent;
  border: 0;
  font-family: var(--font-serif);
  font-size: var(--fs-base);
  color: var(--ink);

  &:focus {
    outline: none;
    border: 0;
    box-shadow: none;
  }

  &::placeholder {
    color: var(--ink-trace);
    font-style: italic;
  }
}

.tag-input-chip {
  cursor: default;
  padding-right: 4px;
  user-select: none;
}

.tag-input-remove {
  width: 16px;
  height: 16px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: transparent;
  color: inherit;
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  opacity: 0.7;
  transition: background var(--dur-fast) var(--ease),
              opacity var(--dur-fast) var(--ease);

  &:hover {
    opacity: 1;
    background: oklch(0% 0 0 / 0.25);
  }

  &:focus-visible {
    outline: 2px solid var(--ember);
    outline-offset: 1px;
  }
}


/* ----------------------------------------------------------- select input */
/* Single-value autocomplete. The native input keeps its styling; the wrapper
   provides positioning for the dropdown. Free-form text is preserved — the
   input's value IS what gets posted, dropdown is purely assistive. */

.select-input {
  position: relative;
}

.select-input::after {
  content: "";
  position: absolute;
  right: 12px;
  top: calc(50% + 0.45em);
  width: 8px;
  height: 8px;
  border-right: 1.5px solid var(--ink-faint);
  border-bottom: 1.5px solid var(--ink-faint);
  transform: translateY(-50%) rotate(45deg);
  pointer-events: none;
  transition: border-color var(--dur-fast) var(--ease);
}

.select-input:focus-within::after {
  border-color: var(--ember);
}

.select-input input {
  padding-right: var(--s-8);
}


/* ------------------------------------------------------------- error list */

.errors {
  background: color-mix(in oklch, var(--wax) 10%, var(--parch-light));
  border-left: 3px solid var(--wax);
  border-radius: var(--r-sm);
  padding: var(--s-3) var(--s-4);
  margin-bottom: var(--s-4);
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-sm);
  color: var(--wax-deep);

  & li { margin-left: var(--s-4); list-style: disc; }
}


/* ----------------------------------------------------------------- flash

   "Proclamation" banner — a grain-parchment card that drops in from the
   top edge, lit from within like a guild achievement, with a wax-seal glyph
   stamped beside the message. Ember accent for a notice, wine for an alert.
   Auto-dismissed by the `flash` Stimulus controller, which relies on the
   dismiss transition firing `transitionend` — so that transition stays
   unconditional even under reduced motion. Parchment background comes from
   the shared grain recipe above. */

.flash-stack {
  position: fixed;
  top: var(--s-5);
  left: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-3);
  z-index: 50;
  pointer-events: none;
}

.flash {
  --flash-accent: var(--ember);
  --flash-frame: var(--ember-deep);
  --flash-glow: var(--ember-glow);
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  max-width: min(460px, 92vw);
  padding: var(--s-3) var(--s-5);
  border-radius: var(--r-md);
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-md);
  color: var(--on-parch);
  border: 1px solid color-mix(in oklch, var(--flash-frame) 60%, transparent);
  box-shadow:
    var(--shadow-lift),
    0 0 30px color-mix(in oklch, var(--flash-glow) 36%, transparent);
  transition: opacity var(--dur-base) var(--ease),
              transform var(--dur-base) var(--ease);
}

.flash[data-flash="alert"],
.flash[data-flash="error"] {
  --flash-accent: var(--wine-bright);
  --flash-frame: var(--wine-deep);
  --flash-glow: var(--wine-glow);
}

/* Wax-seal glyph holder — a flat lit disc (no bombé), single border plus a
   glow halo. Carries the check / exclaim, tinted cream against the accent. */
.flash-seal {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  color: var(--cream);
  background: var(--flash-accent);
  border: 1px solid var(--flash-frame);
  box-shadow: 0 0 14px color-mix(in oklch, var(--flash-glow) 55%, transparent);
}

.flash-seal .flash-glyph {
  width: 19px;
  height: 19px;
}

.flash-text {
  text-wrap: balance;
}

.flash.dismissing {
  opacity: 0;
  transform: translateY(-12px) scale(0.97);
}

/* Entrance: the banner drops in from the top edge with an overshoot while
   its glow blooms, then the seal stamps in a beat later. Gated for reduced
   motion; the dismiss transition above is not, so cleanup still fires. */
@media (prefers-reduced-motion: no-preference) {
  .flash {
    animation: flash-in 520ms cubic-bezier(0.34, 1.55, 0.55, 1) backwards;
  }
  .flash-seal {
    animation: flash-seal-in 380ms 200ms cubic-bezier(0.34, 1.7, 0.55, 1) backwards;
  }
}

@keyframes flash-in {
  from {
    opacity: 0;
    transform: translateY(-26px) scale(0.93);
    box-shadow: var(--shadow-lift), 0 0 0 transparent;
  }
  55% {
    box-shadow: var(--shadow-lift),
                0 0 52px color-mix(in oklch, var(--flash-glow) 72%, transparent);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
    box-shadow: var(--shadow-lift),
                0 0 30px color-mix(in oklch, var(--flash-glow) 36%, transparent);
  }
}

@keyframes flash-seal-in {
  from { opacity: 0; transform: scale(0.2); }
  to   { opacity: 1; transform: scale(1); }
}


/* --------------------------------------------------------- turbo progress */
/* Thin ember strip at the top of the viewport, drawn by Turbo while a
   navigation is in flight. The delay before it shows is configured in
   application.js — fast loads stay silent. */

.turbo-progress-bar {
  height: 2px;
  background: var(--ember-glow);
  box-shadow:
    0 0 8px  color-mix(in oklch, var(--ember-glow) 70%, transparent),
    0 0 16px color-mix(in oklch, var(--wine-glow)  40%, transparent);
}


/* ----------------------------------------------------------------- modal

   Native <dialog> wrapped by the `modal` Stimulus controller. The browser
   gives us focus trap, escape-to-close, the ::backdrop pseudo-element, and
   top-layer rendering for free — we only style.

   The dialog itself stays as a transparent positioning frame; the parchment
   surface lives in `.modal-body` so backdrop clicks (which fire on the
   dialog element) don't get swallowed by inner padding. Variants like
   `.modal-image` opt the body into an image viewer instead of the default
   text panel.
*/

.modal {
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--on-parch);
  max-width: min(92vw, 1200px);
  max-height: 92vh;
  margin: auto;
  overflow: visible;

  &::backdrop {
    background: oklch(8% 0.015 45 / 0.72);
    backdrop-filter: blur(6px);
  }
}

.modal-body {
  position: relative;
  border-radius: var(--r-md);
  padding: var(--s-5);
  box-shadow:
    0 30px 80px oklch(0% 0 0 / 0.55),
    0 0 0 1px color-mix(in oklch, var(--parch-edge) 40%, transparent);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  max-height: 92vh;
  max-width: min(92vw, 1200px);
}

.modal-body.modal-image {
  padding: var(--s-2);

  & img {
    display: block;
    max-width: 100%;
    max-height: calc(92vh - var(--s-4));
    width: auto;
    height: auto;
    object-fit: contain;
    border-radius: var(--r-sm);
  }
}

.modal-close {
  position: absolute;
  top: calc(var(--s-2) * -1);
  right: calc(var(--s-2) * -1);
  width: 32px;
  height: 32px;
  border-radius: 999px;
  border: 1px solid color-mix(in oklch, var(--parch-edge) 60%, transparent);
  background: var(--parch-light);
  color: var(--ink-soft);
  display: grid;
  place-items: center;
  cursor: pointer;
  z-index: 1;
  transition: color var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease);

  & svg {
    width: 16px;
    height: 16px;
  }

  &:hover, &:focus-visible {
    color: var(--wine-bright);
    box-shadow: 0 0 14px color-mix(in oklch, var(--wine-glow) 35%, transparent);
    outline: 0;
  }
}

/* Gallery viewer — the image modal grown a pair of side arrows and a quiet
   #N badge so a version history reads as a flip-through. The arrows float
   over the image edges; the badge sits in the bottom corner, discreet. */
.gallery-body {
  align-items: center;
}

.gallery-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  border-radius: 999px;
  border: 1px solid color-mix(in oklch, var(--parch-edge) 50%, transparent);
  background: color-mix(in oklch, var(--parch-light) 88%, transparent);
  color: var(--ink-soft);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease),
              opacity var(--dur-fast) var(--ease);

  & svg { width: 20px; height: 20px; }

  /* JS hides the arrows for a single-image gallery via `hidden`; our
     `display: grid` would otherwise beat the UA `[hidden]` rule. */
  &[hidden] { display: none; }

  &.prev { left: var(--s-3); }
  &.next { right: var(--s-3); }

  &:hover, &:focus-visible {
    color: var(--wine-bright);
    background: var(--parch-light);
    box-shadow: 0 0 14px color-mix(in oklch, var(--wine-glow) 30%, transparent);
    outline: 0;
  }

  &:disabled {
    opacity: 0.3;
    cursor: default;
    box-shadow: none;
  }
}

.gallery-label {
  position: absolute;
  bottom: var(--s-3);
  left: var(--s-3);
  z-index: 2;
  padding: 2px var(--s-2);
  border-radius: var(--r-xs);
  background: oklch(0% 0 0 / 0.5);
  color: var(--cream-soft);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  line-height: 1.4;
  pointer-events: none;
}

/* Open choreography. Four staggered tracks so the appearance has weight:
   the backdrop dims first (the room darkens), the parchment falls in with
   a small overshoot (paper landing on the desk), the close medallion is
   pressed into place a beat later, and the image blooms from a hair
   oversize so it feels seated in its frame rather than pasted on. */
@media (prefers-reduced-motion: no-preference) {
  .modal[open]::backdrop {
    animation: modal-backdrop-in 220ms var(--ease-out) both;
  }
  .modal[open] {
    animation: modal-in 420ms cubic-bezier(0.34, 1.55, 0.5, 1) both;
  }
  .modal[open] .modal-close {
    animation: modal-seal-in 340ms 160ms cubic-bezier(0.34, 1.7, 0.55, 1) both;
    transform-origin: center;
  }
  .modal[open] .modal-image img {
    animation: modal-bloom-in 460ms 60ms var(--ease-out) both;
  }
}

@keyframes modal-backdrop-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes modal-in {
  from {
    transform: translateY(18px) scale(0.94);
    opacity: 0;
  }
  to {
    transform: translateY(0) scale(1);
    opacity: 1;
  }
}

@keyframes modal-seal-in {
  from {
    transform: scale(0.3) rotate(-60deg);
    opacity: 0;
  }
  to {
    transform: scale(1) rotate(0);
    opacity: 1;
  }
}

@keyframes modal-bloom-in {
  from {
    transform: scale(1.04);
    opacity: 0;
  }
  to {
    transform: scale(1);
    opacity: 1;
  }
}


/* ----------------------------------------------------------------- empty */

.empty {
  padding: var(--s-10) var(--s-6);
  text-align: center;
  color: var(--cream-trace);
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-md);
  border: 1px dashed var(--rule-on-wood);
  border-radius: var(--r-md);
}

.sheet .empty {
  color: var(--ink-faint);
  border-color: var(--rule);
}


/* ------------------------------------------------------------ wax seal */

.user-stamp {
  flex: 0 0 auto;
  width: 36px;
  height: 36px;
  filter:
    drop-shadow(0 0 10px color-mix(in oklch, var(--wine-glow) 35%, transparent))
    drop-shadow(0 1px 2px oklch(0% 0 0 / 0.5));
}

.user-stamp.sm { width: 26px; height: 26px; }
.user-stamp.lg { width: 56px; height: 56px; }

/* Portrait variant: replaces the seal disc with an uploaded image. The
   brass-framed avatars we seed are already circular with alpha around the
   rim, but we clip defensively so user uploads stay disc-shaped. */
.user-stamp.portrait {
  object-fit: cover;
  border-radius: 50%;
}

.rail .rail-foot .user-stamp {
  filter:
    drop-shadow(0 0 14px color-mix(in oklch, var(--wine-glow) 50%, transparent))
    drop-shadow(0 1px 2px oklch(0% 0 0 / 0.5));
}

/* On parchment, the seal *sits* on the page — no wine glow, just a
   proper cast shadow so it reads as pressed wax, not as a lit button. */
.sheet .user-stamp {
  filter:
    drop-shadow(0 1px 1px oklch(0% 0 0 / 0.4))
    drop-shadow(0 4px 7px oklch(0% 0 0 / 0.22));
}

.user-stamp-letters {
  font-family: var(--font-serif);
  font-style: italic;
  font-weight: 500;
  font-size: 20px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  fill: oklch(18% 0.07 25);
}

.user-stamp-letters.highlight {
  fill: oklch(92% 0.08 60);
  opacity: 0.32;
}

/* Echo seal — the barred circle stamped in the wax instead of initials. */
.user-stamp-echo-mark {
  fill: none;
  stroke: oklch(18% 0.07 25);
  stroke-width: 2.4;
  stroke-linecap: round;
}


/* -------------------------------------------------------------- screens */

.entry {
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: var(--s-6);
  position: relative;
}

.envelope {
  width: min(440px, 100%);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-lift);
  padding: var(--s-10) var(--s-8) var(--s-8);
  position: relative;

  & header {
    text-align: center;
    margin-bottom: var(--s-6);

    & h1 {
      font-family: var(--font-serif);
      font-size: var(--fs-2xl);
      font-weight: 400;
      font-style: italic;
      letter-spacing: 0.04em;
      color: var(--ink);
    }

    & .subtitle {
      margin-top: var(--s-2);
      font-family: var(--font-serif);
      font-size: var(--fs-sm);
      color: var(--ink-faint);
      font-style: italic;
    }
  }

  & form {
    display: flex;
    flex-direction: column;
    gap: var(--s-4);
  }

  & footer {
    margin-top: var(--s-6);
    padding-top: var(--s-4);
    border-top: 1px dashed var(--rule);
    text-align: center;
    font-family: var(--font-serif);
    font-style: italic;
    font-size: var(--fs-xs);
    color: var(--ink-faint);
  }
}


/* === Account ====================================================== */

.account-grid {
  display: grid;
  /* `min(100%, 380px)` is the responsive-grid idiom: each column wants
     to be at least 380px wide, but when the viewport itself is narrower
     than that, the minimum collapses to 100% so columns don't overflow
     the container — single column on phone, two columns on wide. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
  gap: var(--s-6);
}

.avatar-field-row {
  display: flex;
  align-items: center;
  gap: var(--s-4);
}

.avatar-field-controls {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  flex: 1 1 auto;
  min-width: 0;
}

.avatar-field-controls input[type="file"] {
  font-family: var(--font-sans);
  font-size: 0.875rem;
  color: var(--ink-soft);
}

.avatar-remove-form {
  margin-top: var(--s-3);
}



/* === Asset detail ================================================= */

/* === Asset & Project show ========================================== */
/* Two-column show page mirroring the quest log: the leaf on the left,
   the chat docked right and stuck to the top on wide viewports. The
   asset and project pages share this layout — only their modules
   differ — so the rules are grouped rather than duplicated. */
.asset-show,
.project-show,
.note-show {
  display: grid;
  gap: var(--s-6);
  align-items: start;
  justify-content: center;
  grid-template-columns: minmax(0, 760px);
}

.asset-show .chat,
.project-show .chat,
.note-show .chat { margin-top: 0; }

/* In edit / new mode the article is wrapped in a <form>; `display: contents`
   dissolves the wrapper so the article stays the grid item. */
:is(.asset-show, .project-show, .note-show) > form { display: contents; }

/* In show mode the main sheet stacks with its scoped bands (quests,
   assets, notes…) — they share the left column under the article. The
   stack is the single grid item, so the chat column doesn't slot
   between siblings. Shared by project show and quest show. */
.show-stack {
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
  min-width: 0;
}

/* A band groups its heading with its list/grid as a single visual unit.
   The internal gap is smaller than the parent's so the eyebrow sits
   closer to its own content than to the previous block. */
.band {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  min-width: 0;
}

/* Band heading — small caps eyebrow on the left, see-all link on the
   right. Mirrors the .module-heading register from inside the sheet, so
   the project page reads as one composition. */
.band-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);

  & .module-heading {
    margin: 0;
  }
}

.band-see-all {
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease);

  &:hover { color: var(--wine); }

  &::after {
    content: " →";
    transition: transform var(--dur-fast) var(--ease);
    display: inline-block;
  }

  &:hover::after { transform: translateX(2px); }
}

@media (min-width: 1024px) {
  .asset-show,
  .project-show,
  .note-show {
    grid-template-columns: minmax(0, 720px) minmax(320px, 400px);
  }

  /* The new page has no chat yet — drop the second column so the leaf
     stays centred instead of hugging the left track. */
  :is(.asset-show, .project-show, .note-show):not(:has(.chat)) {
    grid-template-columns: minmax(0, 760px);
  }

  .asset-show .chat,
  .project-show .chat,
  .note-show .chat {
    position: sticky;
    top: var(--s-6);
  }
}

.asset-page,
.project-page {
  padding: var(--s-6);
}

/* Masthead — the title lives inside the leaf, centred above the
   inscription, the same composition as the quest page. */
.asset-head,
.project-head {
  text-align: center;
}

/* The project masthead's first row is a full-width title input — without
   the asset page's centred <h1>, it would slide under the corner
   sheet-tools. Drop it clear of them while editing. */
.editing.project-page .project-head {
  margin-top: var(--s-6);
}

.asset-title,
.project-title {
  margin: 0;
  font-family: var(--font-serif);
  font-style: italic;
  font-size: clamp(1.4rem, 3.2vw, 2rem);
  line-height: 1.15;
  color: var(--ink);
  text-wrap: balance;
  word-break: break-word;
}

/* A new piece has no file, so no title yet — the placeholder reads as
   faint until the first version names it. */
.asset-title-blank {
  color: var(--ink-faint);
}

/* The name field wears the title's serif/italic so editing feels like
   renaming the piece in place, not filling a form box. It keeps the input
   chrome (border, focus ring) so it still reads as editable. Centred to sit
   under the corner sheet-tools like the project title input. */
.asset-title-input {
  display: block;
  width: auto;
  max-width: 100%;
  margin-inline: auto;
  text-align: center;
  font-size: clamp(1.2rem, 2.6vw, 1.6rem);
}

.asset-head .inscription,
.project-head .inscription { justify-content: center; }

/* Project crest above the title on the show sheet — the masthead's
   only visible identifier. Falls back to the blank shield with the
   project's name painted across the scroll. */
.project-head .project-crest-frame {
  position: relative;
  width: 220px;
  height: 220px;
  margin: 0 auto var(--s-3);
}

.project-head .project-crest {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 4px 14px color-mix(in oklch, var(--wine-glow) 22%, transparent));
}

.project-head .project-crest-label {
  position: absolute;
  top: 68%;
  left: 50%;
  width: 70%;
  transform: translateX(-50%);
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-lg);
  color: oklch(32% 0.04 60);
  text-align: center;
  text-wrap: balance;
  pointer-events: none;
}

/* Edit mode wants the form fields in view, not a 220px crest pushing
   them down — keep the crest as a small visual cue and let the inputs
   own the masthead. */
.editing.project-page .project-head .project-crest-frame {
  width: 96px;
  height: 96px;
}

/* Crest edit field — preview sits inline above the file input, so the
   author sees what they're about to replace. */
.project-crest-edit {
  & .project-crest-preview {
    display: block;
    margin: var(--s-2) 0;
    width: 64px;
    height: 64px;
    object-fit: contain;
  }
}

/* A module is a small-caps heading + its content, generous air between —
   same rhythm as the quest page's modules. */
.asset-module,
/* Editions live in a closed-by-default fold under the journal — the build
   ledger is bookkeeping, not a primary affordance. The fold styling is the
   journal's; the list inside just needs a touch of breathing room under
   the summary, matching .journal-list. */
.editions-fold {
  & > .editions,
  & > .empty {
    margin-top: var(--s-3);
  }
}

.asset-state {
  margin-top: var(--s-5);
}

.asset-state-meta {
  margin-top: var(--s-2);
  text-align: right;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--ink-faint);
}

/* The build / version list sits inside the page leaf, so it drops the
   standalone sheet's cast shadow — it reads as a module, not a panel. */
.asset-page .editions,
.project-page .editions {
  box-shadow: none;
}

/* Launch — the project's hero action (open its URL). Centred under the
   masthead; the ember-lit button itself carries the emphasis. */
.project-launch {
  margin-top: var(--s-5);
  text-align: center;
}

/* Upload zone — dashed frame is a deliberate affordance ("drop here"),
   not decorative noise. The file input lies invisible over the whole
   zone so a native click opens the dialog and a native drop fills it. */
.dropzone {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  padding: var(--s-5);
  border: 1.5px dashed color-mix(in oklch, var(--ink) 25%, transparent);
  border-radius: var(--r-sm);
  background: oklch(0% 0 0 / 0.02);
  color: var(--ink-faint);
  text-align: center;
  cursor: pointer;
  transition:
    border-color var(--dur-fast) var(--ease),
    background var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);

  &:hover {
    border-color: color-mix(in oklch, var(--ink) 40%, transparent);
    color: var(--ink-soft);
  }

  &.is-dragover {
    border-color: var(--ember);
    background: color-mix(in oklch, var(--ember) 8%, transparent);
    color: var(--ink);
  }

  /* The file input is invisible, so route its keyboard focus ring to
     the zone — a Tab user still sees where they are. */
  &:has(.dropzone-input:focus-visible) {
    border-color: var(--ember);
    box-shadow: 0 0 0 3px color-mix(in oklch, var(--ember) 25%, transparent);
  }
}

.dropzone-input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
}

.dropzone-glyph { width: 16px; height: 16px; }

.dropzone-label {
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-sm);
}

/* Draft preview — the image picked but not yet saved sits flat on the
   parchment, depth shadow only, no heavy frame. The show page's `.preview`
   is the enshrined treatment; this is the in-flow one. Explicit [hidden]
   override because `.preview-draft` would otherwise override the UA
   `[hidden] { display: none }` via `display: grid`. */
.preview-draft {
  display: grid;
  place-items: center;
  padding: var(--s-2);
}

.preview-draft[hidden] { display: none; }

.preview-draft img {
  display: block;
  max-width: 100%;
  max-height: 200px;
  border-radius: var(--r-xs);
  border: 1px solid var(--ink);
  box-shadow: var(--shadow-sheet);
}

.preview-draft:not([hidden]) + .dropzone { margin-top: var(--s-4); }


/* The piece sits on the parchment sheet as a single framed plate: a
   hairline edge so light-on-light drawings keep a clean boundary, plus
   one soft depth shadow. No ornate dark frame — the sheet is already the
   frame, and stacking a second one read as a double border. The wrapper
   hugs the image (fit-content) so the corner toolbar pins to the plate. */
.preview {
  position: relative;
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;

  & img {
    display: block;
    max-width: 100%;
    max-height: 60vh;
    border-radius: var(--r-xs);
    box-shadow:
      0 0 0 1px oklch(0% 0 0 / 0.18),
      var(--shadow-sheet);
  }
}

/* Corner toolbar — fullscreen + download medallions pinned to the plate's
   top-right. Dark discs read cleanly over both the parchment and the
   light drawing; they lift to a wine glow on hover. */
.preview-actions {
  position: absolute;
  top: var(--s-3);
  right: var(--s-3);
  z-index: 1;
  display: flex;
  gap: var(--s-2);
}

.preview-action {
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  border-radius: 999px;
  border: 1px solid oklch(100% 0 0 / 0.15);
  background: oklch(0% 0 0 / 0.55);
  color: var(--cream-soft);
  cursor: pointer;
  transition:
    background var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease),
    box-shadow var(--dur-fast) var(--ease);

  & svg { width: 16px; height: 16px; }

  &:hover, &:focus-visible {
    background: oklch(0% 0 0 / 0.72);
    color: var(--cream);
    box-shadow: 0 0 12px color-mix(in oklch, var(--wine-glow) 30%, transparent);
    outline: 0;
  }
}


/* ------------------------------------------------------------------ bridge */
/* Workshop register — members of the order, ranked by hand on parchment.
   Each row is a .sheet so it inherits parchment grain + depth shadow. */

.bridge-roster {
  list-style: none;
  display: grid;
  gap: var(--s-3);
  margin-top: var(--s-5);
}

/* Each member sits as a framed portrait on the workshop wall — dark
   translucent surface, lit from behind by a soft ember halo so wax seals
   and brass-framed portraits both read as luminous regardless of their
   internal value. */
.bridge-entry {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--s-5);
  padding: var(--s-4) var(--s-5);
  background: oklch(100% 0 0 / 0.025);
  border-radius: var(--r-md);
  color: var(--cream);
}

/* The portrait niche — a tight ember halo that hugs the stamp's edge so
   the disc reads as the focal element of the row without bleeding into
   the rest of the card. */
.bridge-entry .portrait-niche {
  display: grid;
  place-items: center;
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background: radial-gradient(circle at center,
    transparent 38%,
    color-mix(in oklch, var(--ember) 14%, transparent) 48%,
    transparent 58%);
}

.bridge-entry .name {
  font-family: var(--font-serif);
  font-size: var(--fs-lg);
  color: var(--cream);
  letter-spacing: 0.01em;
}

/* Role plaque — a small inked stamp on the parchment beside each name.
   Per-role tints lean on existing accent tokens so the palette stays whole. */
.role-plaque {
  display: inline-block;
  padding: var(--s-1) var(--s-3);
  border-radius: var(--r-xs);
  font-family: var(--font-serif);
  font-size: var(--fs-xs);
  font-variant: small-caps;
  letter-spacing: 0.08em;
  color: var(--on-parch);
  background: color-mix(in oklch, var(--parch-shade) 30%, var(--surface));
  border: 1px solid color-mix(in oklch, var(--ink) 12%, transparent);
}

.role-plaque.role-keeper {
  background: color-mix(in oklch, var(--ember) 24%, var(--surface));
  border-color: color-mix(in oklch, var(--ember-deep) 32%, transparent);
}

.role-plaque.role-artist {
  background: color-mix(in oklch, var(--wax) 16%, var(--surface));
  border-color: color-mix(in oklch, var(--wax-deep) 22%, transparent);
}

.role-plaque.role-patron {
  background: color-mix(in oklch, var(--amber) 22%, var(--surface));
  border-color: color-mix(in oklch, var(--amber-deep) 22%, transparent);
}

.role-plaque.role-wanderer {
  background: color-mix(in oklch, var(--mist-pale) 18%, var(--surface));
  border-color: color-mix(in oklch, var(--ink-faint) 18%, transparent);
}

/* Persona plaque — dim brass, the dial of an automaton. Distinct from the
   warm amber of patron and the saturated wax of artist so the AI-driven
   members read as machined rather than living. */
.role-plaque.role-persona {
  background: color-mix(in oklch, var(--brass) 28%, var(--surface));
  border-color: color-mix(in oklch, var(--brass) 40%, transparent);
}

/* Echo plaque — cold mist, drained of warmth. It is the only tint that
   pulls away from the ember palette, so the presence reads as belonging
   to somewhere else. */
.role-plaque.role-echo {
  background: color-mix(in oklch, var(--mist-pale) 12%, var(--surface));
  border-color: color-mix(in oklch, var(--mist-pale) 24%, transparent);
  color: color-mix(in oklch, var(--mist-pale) 70%, var(--on-parch));
}

/* An echo keeping watch — not a member, a presence that flickers in and
   out with the bridge. The whole row vacillates on an irregular signal,
   the niche halo turns cold, and the eroded-glyph name sits mist-pale.
   Deliberately restrained: an unstable signal, not a zalgo gimmick. */
.echo-entry {
  animation: echo-flicker 6.5s steps(1, end) infinite;
}

.echo-entry .portrait-niche {
  background: radial-gradient(circle at center,
    transparent 38%,
    color-mix(in oklch, var(--mist-pale) 16%, transparent) 48%,
    transparent 60%);
}

.echo-entry .name {
  color: color-mix(in oklch, var(--mist-pale) 55%, var(--cream));
  letter-spacing: 0.14em;
}

@keyframes echo-flicker {
  0%, 100% { opacity: 0.6; }
  8%       { opacity: 0.3; }
  10%      { opacity: 0.56; }
  39%      { opacity: 0.52; }
  41%      { opacity: 0.24; }
  44%      { opacity: 0.5; }
  68%      { opacity: 0.58; }
  70%      { opacity: 0.32; }
  73%      { opacity: 0.5; }
}

/* Respect reduced-motion: hold a steady, dimmed presence instead of the
   flicker for viewers who opt out of animation. */
@media (prefers-reduced-motion: reduce) {
  .echo-entry {
    animation: none;
    opacity: 0.5;
  }
}

/* Two-panel register: chat ledger on the left, roster docked right.
   Stacks below 1024px — roster first, chat under it — since the page
   reads as the order's roll first and the discussion second. */
.bridge-show {
  display: grid;
  gap: var(--s-6);
  align-items: start;
}

.bridge-show .bridge-roster {
  margin-top: 0;
  order: 1;
}

.bridge-show .chat {
  margin-top: 0;
  order: 2;
}

@media (min-width: 1024px) {
  .bridge-show {
    grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
  }

  .bridge-show .chat {
    order: 0;
  }

  .bridge-show .bridge-roster {
    order: 0;
    position: sticky;
    top: var(--s-6);
  }
}


/* === Notes ===================================================== */
/* Each note is a leaf of parchment — a thought pinned to a project or
   quest. The list is a 2-column grid so two leaves sit side-by-side on
   a desktop project show; under ~640px container width the auto-fit
   drops it to one column. Material (parchment grain, foxing, soft drop,
   hover lift) all comes from `.sheet.is-link` so nothing is redeclared
   here. The body uses `.brief` for markdown styling (shared with quest
   and project briefs); the leaf adds only the fade-out mask so a 2-line
   jot reads in full while a long doc tails off cleanly. */

.note-leaves {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
  gap: var(--s-5) var(--s-4);
  /* Grid items stretch by default; the `<li>` track-height matches the
     row's tallest card. Pin a baseline so a row of one-liners doesn't
     collapse to a strip. */
  grid-auto-rows: minmax(11rem, auto);
}

/* Notes whose only context is a sealed quest read as historical — kept
   in the list so they stay reachable, but visually de-emphasised so the
   active work catches the eye first. Hover lifts them back to full
   opacity so the writer can read without strain when they actually
   reach for them. */
.note-leaf-faded { opacity: 0.55; }
.note-leaf-faded:hover { opacity: 1; }

/* While the band is `.collapsed` (Stimulus adds the class on connect),
   only the first 6 leaves render — the rest sit behind the "+ N more"
   button below. Kept in sync with `VISIBLE_NOTE_COUNT` in
   ProjectsController; `:nth-child()` doesn't accept `var()` so the
   number lives in two places by necessity. A viewer with JS off never
   gets the `.collapsed` class, so they see every leaf — graceful
   degradation, not silent loss. */
.notes-band.collapsed .note-leaves > .note-leaf:nth-child(n + 7) { display: none; }

.note-leaves-more {
  display: block;
  margin: var(--s-3) auto 0;
  padding: var(--s-2) var(--s-4);
  background: none;
  border: 0;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease);
}
.note-leaves-more:hover { color: var(--wine); }

.note-leaves-empty {
  margin: var(--s-2) 0 0;
  font-style: italic;
  color: var(--text-faint);
}

/* The card itself is `.sheet.is-link` — that supplies parchment material
   and hover lift. We only own the inside layout. The `height: 100%`
   makes the `<a>` fill its grid track so a short note next to a long
   one in the same row keeps the row's height, not its content's. */
.note-leaf {
  display: flex; /* so the inner .sheet.is-link can `height: 100%` */
}
.note-leaf-link {
  flex: 1;
  padding: var(--s-5);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* Echoes the show page's `.note-title` voice (italic serif) at a smaller
   scale so the leaf reads as a small portrait of the page it links to.
   Without the italic + size jump the title is the same family/size as
   the body below and the two blend. */
.note-leaf-title {
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-lg);
  font-weight: 500;
  line-height: 1.2;
  margin: 0;
  color: var(--ink);
  text-wrap: balance;
}

/* Fade-out tail so a long doc reads as "more below" without forcing a
   scroll inside a card. `.brief` keeps the markdown typography honest. */
.note-leaf-body {
  --leaf-max: 9rem;
  max-height: var(--leaf-max);
  overflow: hidden;
  mask-image: linear-gradient(to bottom, black calc(var(--leaf-max) - 1.6rem), transparent);
}

/* === Note detail ============================================== */
/* The full leaf. Reuses .sheet (the article material) + .brief (the
   body markdown) + the shared two-column show grid above. The page
   only owns the masthead and the body-as-input editing state. */

.note-page { padding: var(--s-6); }

/* Masthead — title above a small-caps inscription, both centred, the
   same composition as the asset and project mastheads. The element is
   a <div> on purpose so `.sheet > header` (the codebase's flex
   one-liner rule) doesn't shove the title and chips onto one row. */
.note-head {
  text-align: center;
  margin-bottom: var(--s-6);
}
.note-head .inscription { justify-content: center; }

/* Tag picker on its own row in editing mode. Lives outside the
   inscription so the full-width `.tag-input-field` box doesn't slam
   against the anchor chip above. Capped width so it reads as a focused
   editor, not a full-bleed band. */
.note-head-tags {
  margin-top: var(--s-3);
  display: flex;
  justify-content: center;
}
.note-head-tags .tag-picker { width: min(100%, 480px); }

/* Footer action row — Cancel + Save laid out at the bottom of a sheet
   in editing mode. Used by notes and the new/edit asset form, both of
   which are full-fledged forms where explicit buttons read more clearly
   than the corner ✗/✓ glyphs. */
.sheet-actions {
  margin-top: var(--s-6);
  padding-top: var(--s-4);
  border-top: 1px solid var(--rule);
  display: flex;
  gap: var(--s-3);
  justify-content: flex-end;
}

.note-title {
  font-family: var(--font-serif);
  font-style: italic;
  font-size: clamp(1.4rem, 3.2vw, 2rem);
  line-height: 1.15;
  font-weight: 500;
  margin: 0;
  color: var(--ink);
  text-wrap: balance;
  word-break: break-word;
}

.note-title-derived {
  color: var(--ink-faint);
  font-style: italic;
}



/* === Chat ===================================================== */
/* Discussion thread anchored to a Quest / Asset / Bridge. Rendered as a
   parchment sheet of messages; each row shows the author's wax seal,
   a timestamp, and the body. Sits below the host's main content.
   Section element carries both `.chat` and `.sheet` for the grain. */

.chat {
  margin-top: var(--s-6);
  padding: 0;
  container-type: inline-size;
}

.chat-head {
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  padding: var(--s-4) var(--s-5);
  border-bottom: 1px solid var(--rule);
}

.chat-heading {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-serif);
  font-size: var(--fs-md);
  font-style: italic;
  font-weight: 500;
  color: var(--ink);
  margin: 0;

  & .chat-icon {
    width: 18px;
    height: 18px;
    color: var(--wax-deep);
  }
}

/* Mobile-only affordance — hidden on desktop where the chat already has
   its own scroll context. The mobile rule below flips it on. Single flat
   glyph; ember tint on hover/focus, matching the rest of the icon-buttons
   on the page. */
.chat-fullscreen-toggle {
  display: none;
  margin-left: auto;
  /* Heading uses `align-items: baseline` for the text — an SVG-only button
     has no usable baseline and would sink. Pin it to centre instead. */
  align-self: center;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: var(--r-sm);
  color: var(--ink-faint);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease);

  & .chat-fullscreen-icon { width: 18px; height: 18px; }
  & .chat-fullscreen-collapse-icon { display: none; }

  &:hover, &:focus-visible {
    color: var(--ember);
    background: color-mix(in oklch, var(--ink) 6%, transparent);
    outline: none;
  }
}

.chat.is-fullscreen .chat-fullscreen-toggle .chat-fullscreen-expand-icon {
  display: none;
}
.chat.is-fullscreen .chat-fullscreen-toggle .chat-fullscreen-collapse-icon {
  display: block;
}

/* Scrollable viewport for the message history. Caps at ~480px so the
   chat doesn't dominate the page. The Stimulus controller is the source
   of truth for scroll position (auto-scroll on append, preserve on
   prepend) and toggles `.is-scrollable` whenever the content actually
   overflows — only then do we trap wheel scrolling inside the panel,
   so a viewer with only a few messages can still wheel through the
   page naturally. */
.chat-scroll {
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 200px;
  max-height: 480px;
  overflow-y: auto;
}

.chat-scroll.is-scrollable {
  overscroll-behavior: contain;
}

.chat-messages {
  list-style: none;
  padding: 0;
  margin: 0;
}

.chat-loader {
  padding: var(--s-3) var(--s-5);
  text-align: center;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.10em;
  color: var(--ink-faint);
  font-style: italic;
}

/* "Jump to recent" pill — Discord pattern. Sits sticky to the bottom of
   the scroll viewport at the right edge; surfaces only when the section
   carries `.is-scrolled-up` (the Stimulus controller toggles that class
   from its scroll listener). Flat fill, glow halo on hover — no bombé. */
.chat-jump {
  display: none;
  position: sticky;
  bottom: var(--s-3);
  align-self: flex-end;
  margin-right: var(--s-4);
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-3);
  background: var(--ink);
  color: var(--cream);
  border: 0;
  border-radius: 999px;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.10em;
  cursor: pointer;
  z-index: 1;
  transition: filter var(--dur-fast) var(--ease);

  & svg { width: 14px; height: 14px; }

  &:hover, &:focus-visible {
    filter: drop-shadow(0 0 8px color-mix(in oklch, var(--ember-glow) 60%, transparent));
    outline: none;
  }
}

.chat.is-scrolled-up .chat-jump {
  display: inline-flex;
}

.chat-message {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-5);
  transition: background-color var(--dur-fast) var(--ease);

  & .user-stamp {
    width: 36px;
    height: 36px;
    flex: 0 0 auto;
  }

  /* Faint ink wash on hover so the row under the cursor is unambiguous —
     useful especially for grouped (tight) rows where the delete affordance
     would otherwise be hard to associate with a specific line. */
  &:hover {
    background: color-mix(in oklch, var(--ink) 6%, transparent);
  }
}

.chat-message-body {
  min-width: 0;
}

.chat-message-meta {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  margin-bottom: var(--s-1);
}

.chat-message-author {
  font-family: var(--font-serif);
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--ink);
}

.chat-message-when {
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  color: var(--ink-faint);
}

.chat-message-text {
  font-family: var(--font-serif);
  font-size: var(--fs-base);
  line-height: var(--lh-base);
  color: var(--on-parch);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  text-wrap: pretty;
}

/* Inline ref links (#project-slug, #Q123, #A123, #N123). Tinted with
   the wine palette so they read as live affordances against the
   parchment body, but with no underline by default to keep the body
   text calm — underline surfaces on hover/focus. */
.chat-ref {
  color: var(--wine);
  text-decoration: none;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;

  &:hover,
  &:focus-visible {
    color: var(--wine-bright);
    text-decoration: underline;
    text-underline-offset: 2px;
  }
}

/* Kind badge (Q/A/N) — a faint stamp before the title, set off by a middot
   so "Q · Bestiaire" reads as "the quest Bestiaire", not two words. */
.chat-ref-kind {
  font-weight: 600;
  opacity: 0.6;
}

.chat-ref-kind::after {
  content: "·";
  margin-inline: 0.3em;
  opacity: 0.7;
}

/* Per-row action cluster (edit + delete). Hidden by default for every
   message. The chat Stimulus controller tags rows authored by the
   current viewer with `[data-mine]`, and tags persona-authored rows
   with `[data-can-moderate]` when the viewer is a keeper. CSS then
   exposes the cluster accordingly. Broadcasts ship identical HTML to
   every subscriber, so per-viewer visibility has to live client-side.
   Server still enforces the matching rules on edit/destroy. */
.chat-message-actions {
  align-self: start;
  display: none;
  align-items: center;
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}

.chat-message[data-mine] .chat-message-actions,
.chat-message[data-can-moderate] .chat-message-actions {
  display: inline-flex;
}

/* Moderators only get the trash — editing a persona's words is not
   theirs to do, only pruning. */
.chat-message[data-can-moderate]:not([data-mine]) .chat-message-edit-toggle {
  display: none;
}

.chat-message[data-mine]:hover .chat-message-actions,
.chat-message[data-mine]:focus-within .chat-message-actions,
.chat-message[data-can-moderate]:hover .chat-message-actions,
.chat-message[data-can-moderate]:focus-within .chat-message-actions {
  opacity: 1;
}

/* Narrow chat columns (e.g. the side panel on quest/project/note shows)
   give the body a few more characters per line by shrinking the wax seal
   and tightening the row's horizontal padding/gap. The seal stays present
   so the chat still reads as "voices stamped on the page" — just at a
   register more appropriate to a narrow column. */
@container (max-width: 420px) {
  .chat-message {
    padding-inline: var(--s-4);
    gap: var(--s-2);
  }

  .chat-message .user-stamp {
    width: 28px;
    height: 28px;
  }
}

/* "(edited)" indicator — sits inline at the tail of the message body so
   it follows the last word's wrap, the way Discord does it. Quiet
   secondary type so it doesn't compete with the body. */
.chat-message-edited {
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  color: var(--ink-faint);
  white-space: nowrap;
}

/* Inline edit form. Rendered inside every message but only revealed
   when the parent `.chat-message` carries `.is-editing` (toggled by the
   `message-edit` Stimulus controller when the author clicks the pencil).
   Hidden by default — same HTML ships to every viewer, but only the
   author's `[data-mine]` row exposes the pencil to trigger it. */
.chat-message-edit {
  display: none;
  flex-direction: column;
  gap: var(--s-1);
  margin-top: var(--s-1);
}

.chat-message.is-editing .chat-message-edit {
  display: flex;
}

.chat-message.is-editing .chat-message-text {
  display: none;
}

.chat-message-edit-input {
  width: 100%;
  min-height: 0;
  max-height: calc(7lh + var(--s-2) * 2);
  field-sizing: fixed;
  resize: none;
  overflow-y: auto;
  font-family: var(--font-serif);
  font-size: var(--fs-base);
  line-height: var(--lh-base);
  color: var(--on-parch);

  /* Same diegetic treatment as the chat composer: strip the boxed
     widget, leave the text floating on parchment with a single ink-line
     guide at the baseline. */
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--rule);
  border-radius: 0;
  padding: var(--s-1) var(--s-3);

  &:hover {
    border-bottom-color: color-mix(in oklch, var(--ink) 30%, transparent);
    box-shadow: none;
  }

  &:focus {
    outline: none;
    border-bottom-color: var(--ember);
    box-shadow: 0 4px 8px -4px color-mix(in oklch, var(--ember-glow) 40%, transparent);
  }
}

.chat-message-edit-hint {
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  color: var(--ink-faint);

  & kbd {
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    padding: 0 4px;
    border: 1px solid var(--rule);
    border-radius: var(--r-sm);
    background: color-mix(in oklch, var(--parch-warm) 60%, transparent);
    color: var(--ink-soft);
  }
}

.chat-message-edit-sep {
  margin: 0 var(--s-1);
  color: var(--ink-trace);
}

/* Consecutive messages from the same author within 5 minutes merge into
   a single visual block: the stamp and the author/time header hide, and
   the row shrinks vertically. The stamp keeps its grid column (its
   explicit 36px width drives the auto column) but collapses to zero
   height, so the body text in grouped rows aligns with the parent
   message above. Toggled by chat_controller.js#syncGrouping on every
   list mutation; the server-rendered markup is untouched. */
.chat-message.is-grouped {
  padding-top: 2px;
  padding-bottom: 2px;

  & .user-stamp {
    visibility: hidden;
    height: 0;
    min-height: 0;
  }

  & .chat-message-meta {
    display: none;
  }
}

/* When a message has a grouped follower, its own bottom padding shrinks
   too — otherwise the first line of a group sits in a taller box than
   the rest, breaking the "single visual block" illusion. */
.chat-message:has(+ .chat-message.is-grouped) {
  padding-bottom: 2px;
}

/* System events live in the same <ol> as messages but read as quiet
   scribed notes: no avatar, no delete, centered italic line flanked by
   hairlines so they don't compete with the voiced messages. */
.chat-event {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-5);
  list-style: none;
}

.chat-event-rule {
  height: 1px;
  background: color-mix(in oklch, var(--parch-edge) 45%, transparent);
}

.chat-event-body {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s-2);
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-xs);
  color: var(--ink-faint);
  text-wrap: balance;
}

.chat-event-text {
  color: var(--ink-soft);
}

.chat-event-dot {
  color: var(--ink-faint);
  opacity: 0.6;
}

.chat-event-when {
  font-family: var(--font-sans);
  font-style: normal;
  font-size: var(--fs-xs);
  color: var(--ink-faint);
}

.chat-empty {
  display: none;
  margin: auto;
  padding: var(--s-5);
  text-align: center;
  font-style: italic;
  color: var(--ink-faint);
}

/* Surface the empty state only when the list has no items at all (neither
   messages nor system events). `:has` keeps this reactive to broadcasts
   (first arrival hides it, last removal restores it) without JS bookkeeping. */
.chat-scroll:not(:has(.chat-message)):not(:has(.chat-event)) .chat-empty {
  display: block;
}

.chat-form {
  display: flex;
  align-items: flex-end;
  gap: var(--s-3);
  padding: var(--s-4) var(--s-5);
  border-top: 1px solid var(--rule);
  background: color-mix(in oklch, var(--parch-warm) 60%, transparent);
  border-radius: 0 0 var(--r-md) var(--r-md);
}

.chat-input {
  flex: 1;
  min-height: 0;
  /* 7 lines of content + own vertical padding (var(--s-2) on each side).
     Past that, the textarea scrolls instead of pushing the page. The JS
     controller resizes the field to scrollHeight on every input, so we
     opt out of the base textarea's `field-sizing: content` to avoid the
     two layout systems fighting. */
  max-height: calc(7lh + var(--s-2) * 2);
  field-sizing: fixed;
  resize: none;
  overflow-y: auto;

  /* Override the base form-widget look (box border, white fill, halo
     focus ring). The parchment under us is the writing surface; the
     textarea just provides a faint ink-line guide that deepens and
     catches an ember glow when focused, like the lamp's warm light
     warming the inked rule. */
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--rule);
  border-radius: 0;
  padding: var(--s-2) var(--s-3);

  &:hover {
    border-bottom-color: color-mix(in oklch, var(--ink) 30%, transparent);
    box-shadow: none;
  }

  &:focus {
    outline: none;
    border-bottom-color: var(--ember);
    box-shadow: 0 4px 8px -4px color-mix(in oklch, var(--ember-glow) 40%, transparent);
  }
}

/* Icon-only send affordance. Three states:
   - empty field   → greyed (pointer-events disabled)
   - has content   → plain ink
   - hover / focus → wax color + soft glow halo
   The height matches a single-line textarea so the icon sits centered
   on the last line at any size (align-items: flex-end on the form). */
.chat-submit {
  background: transparent;
  border: 0;
  padding: 0;
  height: calc(1lh + var(--s-2) * 2);
  aspect-ratio: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--ink-soft);
  cursor: pointer;
  border-radius: var(--r-sm);
  transition: color var(--dur-fast) var(--ease),
              filter var(--dur-fast) var(--ease);

  & svg { width: 20px; height: 20px; }

  &:hover, &:focus-visible {
    color: var(--wax-deep);
    filter: drop-shadow(0 0 6px color-mix(in oklch, var(--wax-bright) 55%, transparent));
    outline: none;
  }
}

.chat-input:placeholder-shown ~ .chat-submit {
  color: var(--ink-trace);
  pointer-events: none;
  filter: none;
}

@media (max-width: 560px) {
  .chat-message {
    grid-template-columns: auto 1fr;
  }

  .chat-message-actions {
    grid-column: 2;
    justify-self: end;
    opacity: 1;
  }

  /* Reveal the fullscreen affordance on phone-format screens — it lives
     in the chat's top-right corner and replaces the trapped-scroll model
     with an opt-in immersive surface. */
  .chat-fullscreen-toggle {
    display: inline-flex;
  }

  /* Default mobile: drop the scroll context entirely so a vertical swipe
     has no chat to scroll — the touch bubbles to the page instead. The
     panel keeps its bounded look (max-height carried over from the base
     rule); messages overflow off the *top* edge clipped by `overflow:
     hidden`, with `justify-content: flex-end` pinning the latest message
     to the bottom so the visible exchange is always the most recent.
     Older history is one tap away via the fullscreen toggle.
     (`touch-action: pan-x` was tried first — Safari iOS doesn't honour
     it on `overflow-y: auto` containers, so we kill the overflow itself.) */
  .chat:not(.is-fullscreen) .chat-scroll {
    overflow: hidden;
    justify-content: flex-end;
  }

  /* The "Jump to recent" pill and the older-history loader are tied to
     the panel's own scroll context. With overflow gone they're both
     unreachable in this mode and would only confuse the surface. */
  .chat:not(.is-fullscreen) .chat-jump,
  .chat:not(.is-fullscreen) .chat-loader {
    display: none;
  }

  /* Fullscreen mode — lifts the chat into a fixed full-viewport panel
     anchored below the sticky rail (`top: 64px` ≈ rail-head 48 + 2×s-2).
     The chat-scroll keeps its overflow so the viewer can swipe through
     history; the page underneath is locked via `.has-fullscreen-chat`
     on <body>. z-index 9 sits the panel just below the rail (z-index 10)
     so the rail stays the topmost UI. */
  .chat.is-fullscreen {
    position: fixed;
    top: 64px;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 9;
    margin: 0;
    display: flex;
    flex-direction: column;
    border-radius: 0;
  }

  .chat.is-fullscreen .chat-scroll {
    flex: 1 1 auto;
    min-height: 0;
    max-height: none;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .chat.is-fullscreen .chat-form {
    flex: 0 0 auto;
  }

  body.has-fullscreen-chat {
    overflow: hidden;
  }
}


/* ----------------------------------------------------------- responsive */

@media (max-width: 720px) {
  :root {
    --rail-width: 56px;
    --rail-head: 48px;
  }

  .main { padding: var(--s-5) var(--s-4); }

  .rail nav a::after,
  .rail .rail-foot > a::after { display: none; }
}

/* Phone-format: the rail flips from sticky-left vertical column to a
   sticky-top horizontal strip. Every flex property used by the desktop
   layout is re-asserted here so nothing leaks from the column form —
   the rail's height especially must be content-driven, never inherited
   from the desktop `height: 100dvh`. The wood-dark gradient is re-aimed
   top-to-bottom so the bar still dissolves into the ambient mist below
   it. Tooltips were already hidden at the 720px breakpoint above. */
@media (max-width: 560px) {
  .studio {
    grid-template-columns: 1fr;
    /* `min-height: 100dvh` on the studio plus implicit auto-rows means
       short pages stretch the rows to fill the viewport — and the rail
       (row 1) ate a slice of that surplus, ballooning to ~150px on
       short pages while compact on tall ones. Pin row 1 to `auto`
       (content size) and let the main row swallow the leftover 1fr. */
    grid-template-rows: auto 1fr;
  }

  .rail {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: flex-start;
    gap: var(--s-2);
    height: auto;
    min-height: 0;
    width: 100%;
    padding: var(--s-2) var(--s-3);
    background: linear-gradient(
      to bottom,
      var(--wood-dark) 0%,
      var(--wood-dark) 52%,
      transparent 100%);
  }

  .rail .brand {
    width: auto;
    height: var(--rail-head);
    flex: 0 0 auto;
  }

  .rail nav {
    display: flex;
    flex-direction: row;
    flex: 1 1 auto;
    align-items: center;
    justify-content: center;
    gap: var(--s-2);
    height: auto;
    min-height: 0;
  }

  .rail .rail-foot {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: var(--s-2);
    margin-top: 0;
    height: auto;
    min-height: 0;
  }

  /* The vertical accent strip to the left of the active icon doesn't
     translate cleanly to a row layout — drop it and let the icon's own
     darkened pill + ember tint do the work. */
  .rail nav a.active::before,
  .rail .rail-foot > a.active::before,
  .rail .brand.active::before { display: none; }
}


/* ----------------------------------------------------------- mimic status */
/* Internal connectivity dashboard — shows both directions of the Lab ⇄
   Mimic bridge on a single page. Two stacked cards (inbound / outbound)
   plus a config sheet for the URLs in play. Not user-facing — admin
   surface — so the visual register stays utilitarian.  */
.mimic-status {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  max-width: 760px;
  width: 100%;
  margin: 0 auto;
}

.mimic-status-card {
  & h2 {
    font-family: var(--font-serif);
    font-style: italic;
    font-size: var(--fs-lg);
    margin: 0 0 var(--s-3);
    color: var(--ink);
  }

  &.ok { border-left: 3px solid var(--ember); }
  &.bad, &.unreachable { border-left: 3px solid var(--wine); }
  &.pending { border-left: 3px solid color-mix(in oklch, var(--ink) 25%, transparent); }
}

.config-list,
.status-fields {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: var(--s-2) var(--s-4);
  margin: 0;

  & dt {
    font-family: var(--font-sans);
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--ink-faint);
    align-self: center;
  }

  & dd {
    margin: 0;
    color: var(--ink);
    word-break: break-word;
  }

  & code {
    font-family: var(--font-mono);
    font-size: var(--fs-sm);
    color: var(--ink);
    background: color-mix(in oklch, var(--ink) 6%, transparent);
    padding: 0.1em 0.4em;
    border-radius: var(--r-sm);
  }

  & code + code { margin-left: var(--s-1); }

  & pre {
    grid-column: 1 / -1;
    padding: var(--s-3);
    background: color-mix(in oklch, var(--ink) 7%, transparent);
    border-radius: var(--r-sm);
    overflow-x: auto;
    font-size: var(--fs-xs);
  }
}

/* Inline hint that sits at the top of a "bad" inbound card — wine-tinted
   so it draws the eye, but readable on parchment. */
.signature-mismatch-hint {
  margin: 0 0 var(--s-3);
  padding: var(--s-3);
  background: color-mix(in oklch, var(--wine) 8%, transparent);
  border-left: 3px solid var(--wine);
  border-radius: var(--r-sm);
  color: var(--ink);
  font-size: var(--fs-sm);
}

/* Same shape for the Mimic→Lab error paragraph — keeps the visual
   language consistent across both directions. */
.mimic-outbound p.bad,
.mimic-outbound .bad {
  margin: 0;
  padding: var(--s-3);
  background: color-mix(in oklch, var(--wine) 8%, transparent);
  border-left: 3px solid var(--wine);
  border-radius: var(--r-sm);
  color: var(--ink);
}

.ok-pill,
.bad-pill {
  display: inline-block;
  padding: 0.1em 0.6em;
  font-family: var(--font-sans);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  border-radius: 999px;
}
.ok-pill  { color: var(--ember); background: color-mix(in oklch, var(--ember) 12%, transparent); }
.bad-pill { color: var(--wine);  background: color-mix(in oklch, var(--wine)  12%, transparent); }


/* ----------------------------------------------------------- view transitions */
/* Page-to-page crossfade, driven by Turbo + the View Transitions API.
   Short and ease-out so navigation still feels instant; the brand mark
   is named so it stays still while the rest of the page swaps. */

@view-transition { navigation: auto; }

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 150ms;
  animation-timing-function: var(--ease-out);
}


/* ----------------------------------------------------------- a11y */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }

  @view-transition { navigation: none; }

  #ambient { display: none; }
}
