/* Minimal dark-theme base. Full warm/playful design lands in the frontend
   milestone (M7), aligned to design/Workflow.dc.html. */

/* Self-hosted fonts (docs/DESIGN.md typography), vendored under static/fonts/ so we never
   hit an external CDN — CSP is font-src 'self' (no Google Fonts). Both are the Latin-subset
   VARIABLE files (one file covers the whole weight range). NOTE: Plus Jakarta Sans has NO
   Cyrillic glyphs, so Bulgarian (Cyrillic) UI text falls back to the system stack below —
   an intentional, documented gap (see NEXT_STEPS). JetBrains Mono does include Cyrillic. */
@font-face {
  font-family: "Plus Jakarta Sans";
  font-style: normal; font-weight: 400 800; font-display: swap;
  src: url("../fonts/plusjakarta.woff2") format("woff2");
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal; font-weight: 400 700; font-display: swap;
  src: url("../fonts/jetbrainsmono.woff2") format("woff2");
}

:root {
  /* Warm dark palette — docs/DESIGN.md tokens, adopted 2026-07-07 (was a cool
     placeholder ramp). The full token set is here for later views; the legacy aliases
     (--surface/--muted/--accent) are remapped onto the warm ramp so existing rules
     need no churn. */
  --bg:          #17130e; /* app background */
  --bg-sidebar:  #110e09; /* sidebar, compose bar */
  --bg-panel:    #1b1610; /* board columns, tables, cards */
  --bg-panel-2:  #1a160f; /* search box, inactive pills */
  --bg-detail:   #15110b; /* detail panel */
  --bg-card:     #221c14; /* raised card / hover row */
  --bg-code:     #0e0c08; /* <pre> code block */
  --border:      #221c14;
  --border-2:    #271f17;
  --border-3:    #322a1f;
  --border-4:    #2a241b; /* inputs */
  --text:        #f3ede2; /* primary text */
  --text-2:      #cabfae; /* subtitle / note body */
  --text-3:      #9d9384; /* secondary */
  --text-muted:  #7d7263; /* labels, captions */
  --brand:       #e8924a; /* primary action (orange) */
  --on-brand:    #2a1606; /* text on the orange */

  /* Line / overlay tokens. Tokenized (2026-07-07) so the light theme can flip them —
     as raw rgba(255,255,255,…) they'd vanish on a light background. Three border tiers
     + a hover fill + the code-pill fill. */
  --line:    rgba(255,255,255,.07); /* subtle dividers, card/section borders */
  --line-2:  rgba(255,255,255,.13); /* inputs, ghost buttons, badges */
  --line-3:  rgba(255,255,255,.28); /* strongest (hover border) */
  --hover:   rgba(255,255,255,.07); /* hover fill */
  --scroll-thumb:       rgba(255,255,255,.20); /* scrollbar thumb (Chromium only) */
  --scroll-thumb-hover: rgba(255,255,255,.34);
  --pill-bg: rgba(255,255,255,.05); /* code-pill background */

  /* The accent drives every primary action; a theme (see [data-accent=…]) overrides it.
     Default = brand orange. --on-accent is the readable text laid over the accent. */
  --accent:    var(--brand);
  --on-accent: var(--on-brand);

  /* Role treatment token (docs/DESIGN.md Dev blue / QA coral). Reserved for role-tinted
     surfaces per the mock (note left-borders, role chips in the thread). The nav avatar +
     role badge deliberately follow --accent so they track the user's chosen theme
     (user decision 2026-07-07). */
  --role-color: var(--accent);

  /* legacy aliases still referenced throughout the sheet */
  --surface:   var(--bg-card);
  --muted:     var(--text-3);
}

[data-role="programmer"] { --role-color: #6aa9f0; }
[data-role="qa"]         { --role-color: #ec7e63; }
[data-role="admin"]      { --role-color: #c77dff; }

/* ─── Accent themes (user-chosen via the nav palette menu; default orange) ────────────
   Every hue sits in the same lightness/saturation band as the brand orange so the set
   reads as one family on the warm dark canvas, and dark on-accent text stays legible.
   Colors are drawn from the palette already in the project (docs/DESIGN.md state/type
   hues) so a theme never feels foreign. */
[data-accent="orange"] { --accent: #e8924a; --on-accent: #2a1606; }
[data-accent="honey"]  { --accent: #d9a441; --on-accent: #2a1c06; } /* deep golden */
[data-accent="yellow"] { --accent: #e8c65c; --on-accent: #2a2306; }
[data-accent="red"]    { --accent: #e56a5a; --on-accent: #2c0d08; } /* warm coral-red */
[data-accent="green"]  { --accent: #5cc28d; --on-accent: #06231a; } /* resolved green */
[data-accent="blue"]   { --accent: #6aa9f0; --on-accent: #08182e; } /* Dev blue */
[data-accent="purple"] { --accent: #b98be0; --on-accent: #1e0e2e; } /* discuss violet */
[data-accent="pink"]   { --accent: #e88ab4; --on-accent: #2e0c1c; }
[data-accent="gray"]   { --accent: #b4a894; --on-accent: #241d14; } /* warm taupe */

/* ─── Light theme ────────────────────────────────────────────────────────────────────
   A whole warm-cream light mode: flip only the neutral ramp + line/overlay tokens; the
   chosen accent (above) rides on top unchanged, so any accent works in either mode. */
[data-theme="light"] {
  --bg:          #f4eee3;
  --bg-sidebar:  #ede4d5;
  --bg-panel:    #faf5ec;
  --bg-panel-2:  #f1eadd;
  --bg-detail:   #faf5ec;
  --bg-card:     #ffffff;
  --bg-code:     #efe7d8;
  --text:        #2a2117;
  --text-2:      #4a4032;
  --text-3:      #6f6557;
  --text-muted:  #8a8073;
  --line:    rgba(60,42,20,.12);
  --line-2:  rgba(60,42,20,.2);
  --line-3:  rgba(60,42,20,.34);
  --hover:   rgba(60,42,20,.05);
  --pill-bg: rgba(60,42,20,.06);
  --scroll-thumb:       rgba(60,42,20,.26);
  --scroll-thumb-hover: rgba(60,42,20,.42);
}

* { box-sizing: border-box; }

/* CHROMIUM SCROLLBARS, SHAPED LIKE FIREFOX'S (2026-08-13) — his ask: "I'll want to adapt it for
   all cromium browsers, and I like Mozillas as is, actually we can make cromium scrolls looks like
   mozillas", app-wide by his call.

   Firefox implements only the STANDARD `scrollbar-width`/`scrollbar-color` and ignores
   `::-webkit-scrollbar` entirely, so this block changes Chromium and leaves Firefox exactly as he
   likes it. No @supports gating, no feature detection — the split falls out of what each engine
   implements.

   🔴 **NEVER ADD `scrollbar-width` OR `scrollbar-color` HERE.** Measured in Chromium 149:
   declaring either on an element makes the browser DISCARD every ::-webkit-scrollbar rule for it
   and fall back to the plain standard bar. On one test element with deliberately disagreeing
   colours the webkit thumb painted **0** pixels and the standard one **126**. It looks like
   belt-and-braces and is actually a silent downgrade — `live_scrollbar_audit` check 3 ("the bar is
   thin") is what catches it.

   ⚠️ Verified with PIXELS in a HEADED browser, not computed styles: headless Chromium paints no
   scrollbars at all, and getComputedStyle would confirm the declaration parsed while telling you
   nothing about whether anything rendered. */
::-webkit-scrollbar { width: 10px; height: 10px; }
/* Chromium draws stepper arrows on some platforms; Firefox draws none. */
::-webkit-scrollbar-button { display: none; }
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
/* The transparent border + `background-clip: content-box` is what makes the thumb FLOAT inside the
   gutter the way Firefox's does, instead of filling it edge to edge. Both declarations are
   load-bearing: the border alone would just paint the thumb colour under it. */
::-webkit-scrollbar-thumb {
  background: var(--scroll-thumb);
  border: 2px solid transparent;
  background-clip: content-box;
  border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
  background: var(--scroll-thumb-hover);
  background-clip: content-box;
}
/* Held and dragged, the thumb takes the TEAM'S accent (2026-08-13, his ask: "when it's on focus
   dragged by the mouse to change color to the selected in the team color"). `:active` is the state
   Chromium sets while the thumb is held, which is what "dragged by the mouse" means here.

   ⚠️ MUST stay after `:hover`. A thumb being dragged is also hovered, and these two selectors have
   the same specificity — so source order alone decides the winner. Move this up and the accent
   silently never appears.
   ⚠️ `background-clip` is repeated because the `background` SHORTHAND resets it to `border-box`,
   which would fill the gutter edge to edge and lose the inset pill. Same reason it is repeated on
   :hover.
   ⚠️ `--accent`, never `--brand`: --brand is only the fixed orange --accent defaults to, and a
   rule keyed on it looks correct right up until someone moves the picker. */
::-webkit-scrollbar-thumb:active {
  background: var(--accent);
  background-clip: content-box;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  line-height: 1.5;
}

#app { max-width: 1100px; margin: 0 auto; padding: 2rem; }

a { color: var(--accent); }

/* ═══ Public landing (templates/home.html, anonymous) ═══════════════════════════════════
   Built on the app's own tokens + the real .badge-* chips, so the marketing page IS the
   product. Header/theme picker come from base.html's nav; no page-specific JS. */
.landing { display: flex; flex-direction: column; gap: 1rem; }

/* Hero */
.lp-hero {
  display: grid; grid-template-columns: 1.05fr 1fr; gap: 3rem; align-items: center;
  padding: 2.5rem 0 1.5rem;
}
.lp-eyebrow {
  text-transform: uppercase; letter-spacing: .16em; font-size: .72rem; font-weight: 700;
  color: var(--muted); margin: 0 0 1rem;
}
.lp-h1 { font-size: clamp(2.2rem, 5vw, 3.4rem); font-weight: 800; line-height: 1.08;
  letter-spacing: -.02em; margin: 0; text-wrap: balance; }
.lp-h1 em { font-style: normal; color: var(--accent); }
.lp-lead { color: var(--text-2); font-size: 1.1rem; margin: 1.1rem 0 1.6rem; max-width: 36ch; }
.lp-cta { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center; }
/* The hero pair is sized by `.btn-lg` (both CTAs carry it) — this only adds the hero's heavier
   weight, so the outlined «Log in» holds its own next to the filled primary. Size lives in one
   place on purpose: this rule used to set its own padding/font-size, which (at 0,2,0) out-specified
   `.btn-lg` and left the primary shorter than the secondary next to it. */
.lp-cta .btn-ghost { font-weight: 700; }
.lp-cta-note { color: var(--muted); font-size: .82rem; margin: 1.1rem 0 0; }

/* Hero app mock (dogfoods the real chips + avatars) — a miniature of the three-pane dashboard.
   Replaced the four-column Kanban board on 2026-08-10: that board pictured a screen with no
   route (M4 unbuilt), and columns are what every tracker has. The tree that updates itself is
   the thing worth showing. */
.lp-app {
  background: var(--bg-panel); border: 1px solid var(--line); border-radius: 16px;
  box-shadow: 0 24px 60px rgba(0,0,0,.4); padding: .9rem;
}
.lp-app-top { display: flex; align-items: center; gap: .4rem; padding: .2rem .4rem .8rem; }
.lp-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line-3); }
.lp-app-title { margin-left: .5rem; font-size: .74rem; color: var(--muted); }
/* Mirrors the real .dash-panes proportions (rail · note · chat) without its fixed px columns —
   fixed columns are exactly why the dashboard has a 730px floor and overflows below ~800px.
   The landing must not inherit that failure mode: these are fr units and `min-width: 0`, so the
   mock follows the viewport instead of holding the page open. */
.lp-panes { display: grid; grid-template-columns: 1fr 1.25fr 1fr; gap: .5rem; }
.lp-pane {
  background: var(--bg-card); border: 1px solid var(--line); border-radius: 10px;
  padding: .6rem .65rem; min-width: 0;
}
.lp-tree-row {
  font-size: .74rem; color: var(--text-2); line-height: 1.9;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lp-tree-kid { padding-left: .7rem; }
.lp-tree-note { color: var(--text-3); }
.lp-pane-title { font-size: .82rem; font-weight: 600; line-height: 1.3; margin-bottom: .5rem; }
.lp-note-chips { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .55rem; }
.lp-note-foot { display: flex; align-items: center; gap: .35rem; }
.lp-clip { font-size: .7rem; color: var(--muted); margin-right: auto; }
.lp-who { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 50%;
  font-size: .58rem; font-weight: 800; color: #fff; }
.lp-who.qa { background: #ec7e63; } .lp-who.dev { background: #6aa9f0; } .lp-who.pm { background: #c77dff; }
.lp-arrow { color: var(--muted); font-size: .7rem; }
.lp-msg { display: flex; gap: .4rem; align-items: flex-start; margin-bottom: .5rem; }
.lp-msg p { margin: 0; font-size: .74rem; color: var(--text-2); line-height: 1.35; }
.lp-compose {
  border: 1px solid var(--line); border-radius: 8px; padding: .35rem .5rem;
  font-size: .72rem; color: var(--muted);
}

/* Sections */
.lp-section { padding: 3rem 0; border-top: 1px solid var(--line); }
.lp-kicker { text-transform: uppercase; letter-spacing: .16em; font-size: .72rem; font-weight: 700;
  color: var(--accent); margin: 0 0 .8rem; }
.lp-h2 { font-size: clamp(1.5rem, 3.4vw, 2.1rem); font-weight: 800; letter-spacing: -.02em;
  margin: 0; text-wrap: balance; }
.lp-section-lead { color: var(--text-2); max-width: 52ch; margin: .8rem 0 0; }

/* Feature grid */
.lp-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1.8rem; }
.lp-feature { background: var(--bg-panel); border: 1px solid var(--line); border-radius: 14px; padding: 1.4rem; }
.lp-feature h3 { font-size: 1.02rem; font-weight: 700; margin: 0 0 .35rem; }
.lp-feature p { color: var(--text-3); font-size: .88rem; margin: 0; }

/* Final CTA */
.lp-final { text-align: center; }
.lp-final .lp-section-lead { margin-left: auto; margin-right: auto; margin-bottom: 1.6rem; }

/* Member home (authenticated) */
.member-home h1 { margin: 0 0 .3rem; }
.home-shortcuts { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-top: 1.5rem; }
.home-card { display: flex; flex-direction: column; gap: .3rem; text-decoration: none;
  background: var(--bg-panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.2rem;
  color: var(--text); transition: border-color .12s, background .12s; }
.home-card:hover { border-color: var(--line-3); background: var(--hover); }
.home-card-title { font-weight: 700; color: var(--accent); }

/* Landing responsive (its own breakpoints — the hero + grids reflow before the phone width) */
@media (max-width: 820px) {
  .lp-hero { grid-template-columns: 1fr; gap: 2rem; padding-top: 1.5rem; }
  /* A grid item's `min-width: auto` refuses to shrink below its content's min-content width.
     The retired board's was 616px (four `minmax(148px, 1fr)` columns), which held the whole PAGE
     open at 646.8px — 273px of horizontal scroll at a 390px viewport, against Rule 12. The mock
     that replaced it uses fr columns, but the same `min-width: 0` is still required: the STACKED
     hero puts copy and mock in one track, and three panes side by side still have a min-content
     floor of their own. */
  .lp-hero > * { min-width: 0; }
  /* Below the hero breakpoint the three panes stack rather than scroll sideways. The retired
     board scrolled inside `overflow-x: auto` because four columns cannot wrap; three panes can,
     so a phone gets the whole mock instead of two thirds of it behind a scrollbar. */
  .lp-panes { grid-template-columns: 1fr; }
  .lp-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 560px) {
  .lp-grid, .home-shortcuts { grid-template-columns: 1fr; }
}

/* --- top nav (role-aware; full design in M7) --- */
.topnav {
  display: flex; align-items: center; gap: 1rem;
  padding: .75rem 1.25rem; background: var(--surface);
  border-bottom: 1px solid var(--line);
}
.topnav .brand { font-weight: 800; letter-spacing: -.3px; color: var(--accent); text-decoration: none; }
.topnav-spacer { flex: 1; }
.navlinks { display: flex; gap: .9rem; }
.navlinks a, .navlink { color: var(--muted); text-decoration: none; font-weight: 600; font-size: .9rem; }
.navlinks a:hover, .navlink:hover { color: var(--text); }
.user-chip {
  display: inline-flex; align-items: center; gap: .5rem;
  text-decoration: none; color: var(--text); border-radius: 999px;
  padding: 2px 8px 2px 2px; transition: background .12s;
}
.user-chip:hover { background: var(--hover); }
.user-chip .uname { font-weight: 600; font-size: .9rem; color: var(--text); }
.logout-link { margin-left: .3rem; }
.avatar {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; border-radius: 50%;
  background: var(--accent); color: var(--on-accent); font-weight: 700; font-size: .8rem; flex: none;
}
.avatar-lg { width: 56px; height: 56px; font-size: 1.4rem; }
.role-badge {
  font-size: .7rem; font-weight: 700; letter-spacing: .4px; text-transform: uppercase;
  color: var(--accent); border: 1px solid var(--accent); border-radius: 6px; padding: 1px 7px;
}
/* Consolidated mobile account menu — desktop keeps the full nav, so this is hidden here and
   revealed in the @media (max-width: 640px) block below (user request 2026-07-10). */
.user-menu { display: none; }
/* --- projects index --- */
.project-list { list-style: none; margin: 0 0 1.5rem; padding: 0; }
.project-list li {
  display: flex; align-items: center; gap: .6rem;
  padding: .55rem .8rem; margin-bottom: .4rem;
  background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
}
.project-list .muted { color: var(--muted); background: none; border: none; }
/* per-row action buttons, pushed to the row's right edge */
.row-actions { margin-left: auto; display: inline-flex; align-items: center; gap: .45rem; }
/* Badge group inside a "Your projects" row. On desktop it's transparent (`contents`) so the
   badges are ordinary flex items of the row, exactly as before; a phone turns it into the row's
   second line (see #mine-list in the @media block). */
.row-badges { display: contents; }
/* The ⋯ kebab replaces the inline row actions on phones (always) and on desktop when the user
   opts into compact menus (body.kebab-actions — profile preference, 2026-07-11). Its appearance
   lives here in the base (not the media query) so the desktop opt-in can reveal it too; the
   visibility flip happens in the two rules just below + the @media block. */
.row-menu { display: none; position: relative; margin-left: auto; }
.btn-kebab {
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 30px; border-radius: 8px;
  border: 1px solid var(--line-2); color: var(--muted);
}
.btn-kebab::-webkit-details-marker { display: none; }
.btn-kebab:hover { color: var(--text); }
.row-menu[open] .btn-kebab { color: var(--accent); border-color: var(--accent); }
.btn-kebab .icon { width: 18px; height: 18px; }
.row-menu-panel {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
  min-width: 168px; padding: .4rem;
  display: flex; flex-direction: column;
  background: var(--surface); border: 1px solid var(--line-2); border-radius: 10px;
  box-shadow: 0 14px 36px rgba(0,0,0,.4);
}
/* The Make-default / Make-start items are <button type=submit> inside a <form class=inline>, not
   <a> links (2026-07-18 QA: they showed as a light-blue browser-default button with unreadable
   text). Let the wrapping form vanish so the button is the flex child, and reset the button chrome
   so it matches the <a> items exactly. */
.row-menu-panel form.inline { display: contents; }
.row-menu-item {
  display: flex; align-items: center; gap: .6rem; width: 100%;
  padding: .55rem; border: 0; border-radius: 7px;
  background: none; cursor: pointer; text-align: left;
  color: var(--text); text-decoration: none;
  font-family: inherit; font-weight: 600; font-size: .9rem;
}
.row-menu-item:hover { background: var(--hover); }
.row-menu-item .icon { flex: none; width: 16px; height: 16px; }
.row-menu-item.danger { color: #ff9a9a; }
.row-menu-item.danger:hover { background: rgba(255,122,122,.12); color: #ffbcbc; }
/* Desktop opt-in: compact ⋯ menus instead of inline buttons (phones force it via @media). */
body.kebab-actions .row-actions { display: none; }
body.kebab-actions .row-menu { display: block; }
.project-name { font-weight: 600; }
.code-pill {
  font-family: "JetBrains Mono", ui-monospace, monospace; font-size: .78rem;
  color: var(--muted); background: var(--pill-bg);
  border: 1px solid var(--line-2); border-radius: 6px; padding: 1px 6px;
}

.crumbs { margin-bottom: 1rem; font-size: .9rem; }
.crumbs a { color: var(--muted); text-decoration: none; font-weight: 600; }
.crumbs a:hover { color: var(--text); }
.crumb-sep { color: var(--muted); margin: 0 .35rem; }
.crumb-here { color: var(--text); font-weight: 700; }
/* The container header's title cluster: the project name + its lock state (user request
   2026-07-15). A phone flattens this with display:contents (see the @media block) so the lock
   keeps grouping with the ⋯ kebab at the right edge, exactly as before. */
.head-title { display: flex; align-items: center; gap: .6rem; min-width: 0; }
.head-actions { display: inline-flex; gap: .5rem; }
/* Container-header management actions (Lock/Members/Edit/Delete). On desktop the menu is
   transparent (display:contents) so they render inline as before and the ⋯ toggle is hidden;
   the @media block turns it into a dropdown on phones (user request 2026-07-10). */
.head-menu, .head-menu-panel { display: contents; }
.head-menu-cb, .head-menu > label.btn-kebab { display: none; }
/* Desktop: the New note/New section group is transparent (renders inline in the header) and the
   per-section-heading copies are hidden. A phone (see @media) flips both: hides `.head-add`, shows
   `.head-add-inline` next to each section heading (user request 2026-07-14). */
.head-add { display: contents; }
.section-head { display: contents; }
/* Two-class selector so it beats `.btn`/`.btn-ghost` (defined later) on desktop. */
.section-head .head-add-inline { display: none; }

/* --- DESKTOP-ONLY container-header refinements (user request 2026-07-15). The complement of the
   `max-width: 640px` phone block at the end of this file (640/641 — they can never both match), so
   the phone layout is untouched. Fixes a real misalignment: `#lock-control` and `#members-attn`
   exist as HTMX swap/OOB targets, but each was *also* the flex item of `.head-actions` — so the
   wrapper stretched while the control inside it did not. Members' <a> stayed `display:inline`
   (31.2px tall, 3px above its neighbours) and the lock's <button> sat at its natural 33.8px, while
   the unwrapped buttons stretched to 36.4px: three heights in one row. Handing the real controls to
   the flex line (display:contents on the wrappers — swap targets don't need boxes; `id` lookup and
   `form.htmx-request .spinner` are DOM-based and unaffected) + one explicit height fixes all of it. */
@media (min-width: 641px) {
  /* The lock's wrapper + no-JS form are plumbing, so the <button>/<span class=badge> itself is the
     flex item and centres against the title. An empty wrapper (non-manager, unlocked) then draws
     nothing at all, instead of a phantom item eating a .6rem gap. */
  .head-title #lock-control, .head-title #lock-control .inline { display: contents; }

  .container-page .head-actions { align-items: center; }
  .container-page .head-actions #members-attn { display: contents; }

  /* Collapse toggle sits at the FAR RIGHT of the header on desktop (user request 2026-07-18): the
     .list-title is a full-width flex row here (its .section-head parent is display:contents), so
     margin-left:auto pushes the button to the section's right edge, past the title + reorder ⇅. On a
     phone the rule is absent, so the button keeps its natural place right after the reorder button. */
  .container-page .btn-collapse { margin-left: auto; }
}
.muted-text { color: var(--muted); font-size: .85rem; }

.lang-switch { display: inline-flex; gap: 2px; }
.lang-switch button {
  border: 1px solid var(--line-2); background: none; color: var(--muted);
  font: inherit; font-weight: 700; font-size: .7rem; letter-spacing: .4px;
  padding: 2px 7px; border-radius: 6px; cursor: pointer;
}
.lang-switch button:hover { color: var(--text); }
.lang-switch button.active { color: var(--accent); border-color: var(--accent); cursor: default; }

/* --- profile --- */
.profile-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.profile-head h1 { margin: 0 0 .35rem; }
.profile-fields { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1.5rem; }
.profile-fields dt { color: var(--muted); font-weight: 600; }
.profile-fields dd { margin: 0; }
/* Row-actions preference: current value + the toggle button, side by side. */
.pref-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
/* Inline name editor on the profile page (mirrors the Members row editor). */
.name-field { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.name-value { min-height: 1.2em; }
/* A small square icon button for the pencil — sized like the row buttons, muted until hover. */
.btn-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--line);
  color: var(--muted); background: transparent; cursor: pointer; text-decoration: none;
}
.btn-icon:hover { background: var(--hover); color: var(--text); border-color: var(--line-2); }
.btn-icon .icon { display: block; }
.name-edit { display: inline-flex; align-items: center; gap: .5rem; margin: 0; flex-wrap: wrap; }
.name-edit input[type=text] {
  padding: .35rem .55rem; border: 1px solid var(--line-2); border-radius: 8px;
  background: var(--pill-bg); color: var(--text); font: inherit; min-width: 8rem;
}
.name-edit input[type=text]:focus { border-color: var(--accent); outline: none; }
.name-edit .btn-save {
  display: inline-flex; align-items: center; justify-content: center; min-width: 34px;
  padding: .38rem .5rem; border: none; border-radius: 8px; cursor: pointer;
  background: var(--accent); color: var(--on-accent);
}
.name-edit .btn-save:hover { filter: brightness(1.06); }

/* --- allauth pages (login / signup / verify / reset / MFA …) --- */
.auth-wrap { display: flex; justify-content: center; padding: 1rem 0 2.5rem; }
.auth-card {
  width: 100%; max-width: 420px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 16px; padding: 1.75rem 1.75rem 2rem;
}
.auth-card h1 { margin: 0 0 1.1rem; font-size: 1.5rem; letter-spacing: -.3px; }
.auth-card > p, .auth-card form > p:not(:has(label)) { color: var(--muted); }
.auth-card a { color: var(--accent); font-weight: 600; }

/* Django renders allauth fields via form.as_p → <p><label>…</label><input></p> */
.auth-card form p { margin: 0 0 1rem; display: flex; flex-direction: column; gap: .35rem; }
.auth-card label { color: var(--muted); font-weight: 600; font-size: .85rem; }
.auth-card input:not([type=checkbox]):not([type=radio]),
.auth-card select, .auth-card textarea {
  width: 100%; padding: .6rem .7rem;
  background: var(--bg); color: var(--text);
  border: 1px solid var(--line-2); border-radius: 9px;
  font: inherit; outline: none;
}
.auth-card input:focus, .auth-card select:focus, .auth-card textarea:focus {
  border-color: var(--accent);
}
.auth-card input[type=checkbox], .auth-card input[type=radio] { accent-color: var(--accent); }

/* allauth buttons render as bare <button> (submit) — make the primary action pop */
.auth-card button, .auth-card [type=submit] {
  width: 100%; margin-top: .35rem; padding: .65rem 1rem;
  background: var(--accent); color: var(--on-accent);
  border: none; border-radius: 10px;
  font: inherit; font-weight: 700; cursor: pointer;
}
.auth-card button:hover, .auth-card [type=submit]:hover { filter: brightness(1.06); }
.auth-card hr { border: none; border-top: 1px solid var(--line); margin: 1.25rem 0; }

/* errors + messages */
.auth-card .errorlist, .auth-card ul.errorlist {
  list-style: none; margin: 0 0 .5rem; padding: 0; color: #ff7a7a; font-size: .85rem;
}
.auth-card .helptext, .auth-card span { color: var(--muted); font-size: .8rem; }
.messages { list-style: none; margin: 0 0 1.1rem; padding: 0; display: flex; flex-direction: column; gap: .5rem; }
.messages .msg {
  padding: .6rem .75rem; border-radius: 9px; font-size: .9rem;
  background: var(--bg); border: 1px solid var(--line-2);
}
.messages .msg-error { border-color: #ff7a7a; color: #ff9a9a; }
.messages .msg-success { border-color: var(--accent); }

/* --- members page: company members, role editing, invitations (M2.8.4 / M2.8.5) --- */
.members h1 { margin: 0; font-size: 1.5rem; letter-spacing: -.3px; }
.members h2 {
  font-size: .75rem; text-transform: uppercase; letter-spacing: .5px;
  color: var(--muted); margin: 1.75rem 0 .65rem;
}
/* Members list heading + its Reorder ⇅ toggle (QA point 8, 2026-07-18): the h2's asymmetric
   margin box (1.75rem top / .65rem bottom) threw off the flex `align-items: center` — the button
   centred against the margin box, not the text, so it floated above the heading. Move the spacing
   onto the .list-title wrapper and zero the h2's margin so text + button centre together (same fix
   the settings page uses via .vocab-head). Scoped to #members-list so teams/settings are untouched. */
.members #members-list .list-title { margin: 1.75rem 0 .65rem; }
.members #members-list .list-title h2 { margin: 0; }
.members-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap; margin-bottom: .5rem;
}

/* ── Buttons ──────────────────────────────────────────────────────────────────────────────
   `.btn` (primary) and `.btn-ghost` (secondary) share ONE box — same type size, padding, border
   box and radius — so they line up wherever they sit side by side (user request 2026-07-15).
   They differ only in SKIN (fill + weight), which is the hierarchy signal.

   They used to drift: `.btn` was .5rem/.95rem, .85rem type, NO border; `.btn-ghost` .38rem/.85rem,
   .82rem type, a 1px border. That's ~2.6px apart on desktop — and, because the phone block below
   overrides only the vertical padding, ~1.3px apart the OTHER way (38 vs 39.3) on a phone.

   `inline-flex` is load-bearing: `.btn-ghost` set no display, so an `<a class="btn-ghost">` stayed
   inline, ignored its vertical padding and rendered ~5px short of a sibling `<button>`.

   Deliberately NOT set here:
   - `justify-content` — the phone kebab panel gives its items width:100% and left-aligns them.
   - `gap` — `.attention-dot` (the Members "!") carries its own margin-left.
   Height comes from type + padding, never a fixed `height`, so the phone block's bigger padding
   still buys Rule 12 tap targets. Size overrides must out-specify these (see `.lp-cta .btn-ghost`). */
.btn, .btn-ghost, label[for="id_files"] {
  display: inline-flex; align-items: center;
  box-sizing: border-box; padding: .45rem .9rem;
  border: 1px solid transparent; border-radius: 9px;
  font: inherit; font-size: .82rem;
  text-decoration: none; cursor: pointer; white-space: nowrap;
}
/* primary action button (also used as a link, e.g. "Invite a member") */
.btn { background: var(--accent); color: var(--on-accent); font-weight: 700; }
.btn:hover { filter: brightness(1.06); }

/* The marketing-CTA size (landing hero pair + the closing CTA), on either variant. It MUST stay
   after the shared `.btn, .btn-ghost` rule: it lived up in the landing block for months and, at
   equal specificity, simply lost to the `.btn` further down — so it did nothing at all and the
   hero's primary rendered smaller than the secondary beside it. The phone block's padding
   override still applies below (both CTAs shrink together, so they stay level). */
.btn-lg { padding: .7rem 1.4rem; border-radius: 11px; font-size: 1rem; }

/* row lists */
.member-list, .invite-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem; }
.member-list > li, .invite-list > li {
  display: flex; align-items: center; flex-wrap: wrap; gap: .55rem;
  padding: .65rem .9rem; background: var(--surface);
  border: 1px solid var(--line); border-radius: 12px;
}
.member-name,
.invite-list > li > span:first-child { font-weight: 600; margin-right: auto; }
.member-gone { color: var(--muted); font-style: italic; font-weight: 400; }
/* Groups inside a member row. Transparent (`contents`) on desktop so name/role/badges stay inline
   exactly as before; a phone turns them into the row's two lines (see #member-rows in @media). */
.member-main, .member-badges { display: contents; }
/* Edit + Remove cluster (QA point 14): inline on desktop, the phone grid's col-3 stack. */
.member-actions { display: contents; }

/* Managers-only roster subgroups: people who left / pending invites (2026-07-11). */
.member-subgroup { margin-top: 1rem; }
.member-subgroup-label {
  font-size: .7rem; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
  color: var(--muted); margin: 0 0 .45rem;
}
.member-list > li.needs-attention { border-color: #e6b15c; }
/* The red "!" alert on a project with departures to review. */
.attention-dot {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.15rem; height: 1.15rem; padding: 0 .3rem; margin-left: .1rem;
  font-size: .72rem; font-weight: 800; line-height: 1; color: var(--on-accent, #17130e);
  background: #ff7a7a; border-radius: 999px; text-decoration: none;
}
.attention-dot:hover { background: #ff9a9a; }

/* Rule 14: "Available to join" hides itself while its list has no rows (so live-adding the first
   joinable row reveals it and deleting the last hides it — no placeholder needed). Keyed on the
   presence of an <li> child (not :empty) so leftover whitespace text nodes don't keep it shown. */
#projects-joinable:not(:has(#join-list > li)) { display: none; }
#invites:not(:has(#invite-list > li)) { display: none; }
#roster-left-block:not(:has(#roster-left > li)) { display: none; }
#roster-pending-block:not(:has(#roster-pending > li)) { display: none; }

/* inline icons in buttons/badges (padlock etc.) */
.icon { vertical-align: -2px; }

/* red remove/delete button — kept red (user request 2026-07-07). Square + matches the
   other row buttons' height WITHOUT depending on the row resolving a height: equal padding
   around a square icon is always square, and a 19px icon + .38rem padding lands at the same
   ~33px height as .btn-ghost (font .82rem × 1.5 + .38rem padding). line-height:0 keeps the
   inline box from adding stray height. */
.btn-remove {
  display: inline-flex; align-items: center; justify-content: center;
  padding: .38rem; flex: 0 0 auto; line-height: 0;
  cursor: pointer; font: inherit; border-radius: 8px;
  color: #ff9a9a; background: transparent;
  border: 1px solid rgba(255,122,122,.4);
}
.btn-remove:hover { background: rgba(255,122,122,.12); border-color: #ff7a7a; color: #ffbcbc; }
.btn-remove .icon { display: block; width: 19px; height: 19px; }

/* add-member block under the project roster (M3.4c2; redesigned 2026-07-06 after user
   feedback — divider, uppercase mini-label, styled select that grows across the row) */
.member-add-block {
  margin-top: 1.1rem; padding-top: 1rem;
  border-top: 1px solid var(--line);
}
.member-add-label {
  display: block; margin-bottom: .55rem;
  font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
  color: var(--muted);
}
.member-add { display: flex; align-items: center; gap: .6rem; }
.member-add select {
  flex: 1; min-width: 0;
  appearance: none; -webkit-appearance: none;
  padding: .55rem 2.3rem .55rem .8rem;
  background-color: var(--bg);
  /* chevron as a data: URI — img-src already allows data: (favicon precedent) */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239d93ad' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right .75rem center; background-size: 14px;
  color: var(--text);
  border: 1px solid var(--line-2); border-radius: 10px;
  font: inherit; font-size: .9rem; cursor: pointer;
}
.member-add select:hover { border-color: var(--line-3); }
.member-add select:focus { border-color: var(--accent); outline: none; }

/* inline role / Manager edit form on a member row */
.member-edit { display: inline-flex; align-items: center; gap: .6rem; margin: 0; }
.member-edit select {
  padding: .35rem .55rem; background: var(--bg); color: var(--text);
  border: 1px solid var(--line-2); border-radius: 8px; font: inherit; font-size: .85rem;
}
.member-edit select:focus { border-color: var(--accent); outline: none; }
.member-edit label { display: inline-flex; align-items: center; gap: .35rem; color: var(--muted); font-size: .85rem; }
.member-edit input[type=checkbox] { accent-color: var(--accent); }
.member-edit button {
  padding: .38rem .9rem; border: none; border-radius: 8px;
  background: var(--accent); color: var(--on-accent); font: inherit; font-weight: 700; font-size: .82rem; cursor: pointer;
}
.member-edit button:hover { filter: brightness(1.06); }

/* async-button spinner: htmx puts .htmx-request on the in-flight form — swap the button label
   for the spinner. Works on any form carrying a .save-label/.spinner button pair
   (member editor, invite rows, inline invite form). currentColor follows the button's text. */
.spinner {
  display: none; width: 14px; height: 14px; vertical-align: -2px;
  border: 2px solid transparent; border-top-color: currentColor;
  border-right-color: currentColor; border-radius: 50%;
  animation: spin .6s linear infinite;
}
form.htmx-request .save-label { display: none; }
form.htmx-request .spinner { display: inline-block; }
form.htmx-request button[type=submit] { pointer-events: none; }
@keyframes spin { to { transform: rotate(360deg); } }

/* shared modal outlet — a native <dialog id="modal"> (base.html): the browser renders it in
   the top layer and paints ::backdrop, so it works even before this stylesheet refreshes */
dialog#modal, dialog#att-error, dialog#att-viewer, dialog#att-confirm {
  margin: auto; padding: 0; border: none; background: transparent; color: var(--text);
  width: min(480px, calc(100vw - 2rem)); max-height: calc(100vh - 2rem);
}
/* The viewer shows a PICTURE, not a form, so it takes far more room than the 480px the other
   two dialogs want (2026-08-02, QA point 11). */
dialog#att-viewer { width: min(1000px, calc(100vw - 3rem)); max-height: calc(100vh - 3rem); }
dialog#modal::backdrop, dialog#att-error::backdrop, dialog#att-viewer::backdrop,
dialog#att-confirm::backdrop {
  background: rgba(10, 8, 16, .6); backdrop-filter: blur(2px);
}
dialog#modal .modal-card, dialog#att-error .modal-card, dialog#att-viewer .modal-card,
dialog#att-confirm .modal-card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 16px; padding: 1.4rem 1.5rem 1.6rem;
  overflow-y: auto; box-shadow: 0 24px 64px rgba(0,0,0,.5);
}
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .9rem; }
.modal-head h2 { margin: 0; font-size: 1.1rem; letter-spacing: -.2px; }
.modal-close {
  background: none; border: none; color: var(--muted);
  font: inherit; font-size: 1.35rem; line-height: 1; cursor: pointer; padding: .1rem .35rem;
}
.modal-close:hover { color: var(--text); }
/* Footer row of the dialog: primary action left, Cancel pushed to the right edge. */
.modal-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 1.1rem; }

/* the invite form fragment (lives inside the modal; bare when rendered no-JS full-page) */
.invite-inline {
  margin: .9rem 0; padding: 1rem 1.1rem; max-width: 480px;
  background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
}
#modal .invite-inline { margin: 0; padding: 0; background: none; border: none; max-width: none; }
.invite-inline p { margin: 0 0 .75rem; }
.invite-inline label { display: block; margin-bottom: .25rem; color: var(--muted); font-size: .85rem; }
.invite-inline .helptext { display: block; color: var(--muted); font-size: .78rem; margin-top: .2rem; }
.invite-inline input:not([type=checkbox]):not([type=radio]),
.invite-inline select, .invite-inline textarea {
  width: 100%; padding: .6rem .7rem;
  background: var(--bg); color: var(--text);
  border: 1px solid var(--line-2); border-radius: 9px; font: inherit;
}
/* The description textarea matches the other fields' width; let it grow vertically only. */
.invite-inline textarea { resize: vertical; min-height: 4.5rem; }
.invite-inline input:focus, .invite-inline select:focus,
.invite-inline textarea:focus { border-color: var(--accent); outline: none; }
.invite-inline input[type=checkbox] { accent-color: var(--accent); }
.invite-inline .errorlist { list-style: none; margin: 0 0 .5rem; padding: 0; color: #ff8f7a; font-size: .85rem; }

/* Type-to-search combobox (2026-08-12) — app.js builds this around a real <select>, which stays
   in the form as the value carrier. Mobile-first: the phone layout is the base rule and desktop
   is the media query at the end (Rule 12).
   The input deliberately carries NO width/padding/border/background of its own — it is an <input>
   inside `.invite-inline`, so it already inherits the dialog's field skin from the rule above.
   Extending beats copying: a copy drifts the moment the dialog is restyled.
   Spec: docs/superpowers/specs/2026-08-12-section-combobox-design.md */
.combo { position: relative; }
/* display:none, NOT [disabled] — a hidden control still submits its value, a disabled one submits
   nothing at all. It also leaves the tab order and the a11y tree, so nothing is announced twice. */
.combo-native { display: none; }
.combo-input { min-height: 44px; }
/* IN FLOW, not absolute: `.modal-card` is `overflow-y:auto`, a scroll container, so an absolutely
   positioned list would be CLIPPED in both axes the moment it passed the card's padding box. This
   pushes the actions row down instead and the card scrolls. */
.combo-list {
  list-style: none; margin: .35rem 0 0; padding: .25rem;
  max-height: min(45vh, 288px); overflow-y: auto; overscroll-behavior: contain;
  background: var(--bg-panel); border: 1px solid var(--line-2); border-radius: 9px;
}
.combo-list li {
  display: flex; align-items: center; min-height: 44px;
  padding: .55rem .6rem; border-radius: 6px; cursor: pointer;
  /* WRAP long paths rather than truncate — an ellipsis hides which part of the path matched. */
  overflow-wrap: anywhere;
}
/* ⚠️ var(--accent), never var(--brand): --brand is the fixed orange --accent DEFAULTS to, so a
   --brand rule looks correct until the user moves the accent picker. */
.combo-list li[aria-selected="true"] { color: var(--accent); }
.combo-list li.is-active { background: var(--hover); }
.combo-empty { color: var(--muted); cursor: default; min-height: 0; }
@media (min-width: 641px) {
  .combo-input { min-height: 0; }
  .combo-list li { min-height: 0; padding: .4rem .55rem; }
}

/* invitation row actions */
.invite-actions { display: inline-flex; gap: .4rem; }
.inline { display: inline; margin: 0; }
/* Secondary button — the box comes from the shared `.btn, .btn-ghost` rule above; this is skin
   only. `border-color` (not the `border` shorthand) so it keeps the shared 1px width/style, and
   so `.btn-danger` below only has to recolour it. */
.btn-ghost, label[for="id_files"] {
  background: transparent; color: var(--text); border-color: var(--line-2); font-weight: 600;
}
.btn-ghost:hover, label[for="id_files"]:hover { background: var(--hover); }
.btn-danger { color: #ff9a9a; border-color: rgba(255,122,122,.4); }
.btn-danger:hover { background: rgba(255,122,122,.12); border-color: #ff7a7a; }

/* status pills */
.badge {
  font-size: .66rem; font-weight: 700; letter-spacing: .4px; text-transform: uppercase;
  color: var(--muted); border: 1px solid var(--line-2); border-radius: 6px; padding: 1px 7px;
}
.badge-warn { color: #e6b15c; border-color: #e6b15c; }
/* A pending project invite waiting for the member to accept (2026-07-11). */
.badge-invite { color: var(--accent); border-color: var(--accent); }
.you { color: var(--muted); font-size: .85rem; }

/* Semantic item chips — hues from docs/DESIGN.md. Class = badge-<facet>-<code>, where
   <code> is the raw TextChoices value (not the translated label), so colors survive
   i18n. Each carries its hue in the text + a translucent border; Critical also gets a
   faint fill so it reads as the alarm state. */
.badge-type-bug        { color: #ec7e63; border-color: rgba(236,126,99,.5); }
.badge-type-error      { color: #e8915c; border-color: rgba(232,145,92,.5); }
.badge-type-suggestion { color: #5cc2b4; border-color: rgba(92,194,180,.5); }

.badge-prio-low      { color: #a89d8b; border-color: rgba(168,157,139,.45); }
.badge-prio-medium   { color: #e6b15c; border-color: rgba(230,177,92,.5); }
.badge-prio-high     { color: #ec7e63; border-color: rgba(236,126,99,.5); }
.badge-prio-critical { color: #f08a80; border-color: rgba(224,87,74,.6); background: rgba(224,87,74,.12); }

.badge-state-discuss  { color: #b98be0; border-color: rgba(185,139,224,.5); }
.badge-state-pr       { color: #6aa9f0; border-color: rgba(106,169,240,.5); }
.badge-state-qa       { color: #e6b15c; border-color: rgba(230,177,92,.5); }
.badge-state-resolved { color: #5cc28d; border-color: rgba(92,194,140,.5); }

/* ─── The 25-colour badge palette (DESIGN.md open-Q #4) ────────────────────────────────
   Companies name their own roles / note types / states and pick a colour per term. The keys
   live in apps/common/palette.py and MUST match this list one-for-one (a browser test asserts
   they never drift). A picked colour can't be inlined as style="…" — our CSP is nonce-based
   with no unsafe-inline, and a nonce can't whitelist a style *attribute* — so each key is a
   class the stylesheet already knows. Same contract theme.py has with [data-accent="…"].

   Each key sets only a HUE + CHROMA; the LIGHTNESS comes from the theme, so all 25 stay legible
   on the dark panel AND the cream one from 25 lines instead of 50 hand-tuned hexes. (The older
   semantic chips above are fixed hexes with no light variant — this is strictly better, and they
   get migrated onto these keys as the vocabulary becomes data.)
   Hues 1-9 match the chips this app already ships, so seeded vocabulary keeps its colours.
   NB: never write a literal star-slash inside a CSS comment — it ends the comment early and the
   rest of the text is parsed as a selector, silently eating the rule that follows. */
:root {
  /* .74 is the measured mean lightness of the chips this app already ships (they fit oklch L
     .70-.79), so a palette chip sits beside a hard-coded priority chip without looking brighter. */
  --badge-l: .74;        /* text on the dark panels */
  --badge-bl: .58;       /* its border, dimmer so the chip reads as an outline */
}
[data-theme="light"] {
  --badge-l: .50;        /* text on the cream panels */
  --badge-bl: .72;
}
.badge[class*="badge-c-"] {
  color: oklch(var(--badge-l) var(--badge-ch) var(--badge-h));
  border-color: oklch(var(--badge-bl) var(--badge-ch) var(--badge-h) / .55);
}
/* The settings page's per-vocabulary heading row (h2 + its New button). `.members h2` is the
   uppercase mini-label, which reads right here too — it just needs the button beside it. */
.settings-page .vocab-head { margin-top: 1.5rem; }
.settings-page .vocab-head h2 { margin: 0; }
.settings-page #note-type-rows .row-main { display: flex; align-items: center; gap: .6rem; }
.settings-page #note-state-rows .row-main { display: flex; align-items: center; gap: .6rem; }

/* Drag-to-reorder (SortableJS POC, 2026-07-16 — workflow states). The handle is the only drag
   grip (Sortable `handle`); the ghost is the drop-placeholder, the chosen row is the one held. */
.drag-handle {
  display: none; align-items: center; color: var(--muted);
  cursor: grab; touch-action: none; /* let Sortable own the pointer on touch */
}
/* Reorder toggle (2026-07-18): handles are HIDDEN by default and revealed only while the list's
   scope is in reorder mode (app.js flips `.reordering` when the Reorder button next to the title is
   pressed). Hiding the handle also disables dragging until opt-in — you can't grip what isn't shown. */
[data-reorder-scope].reordering .drag-handle { display: inline-flex; }
/* Bigger, tighter grip (user request 2026-07-17): ~2× the old 14px, and the icon's viewBox is
   cropped to the dots (templates) so there's no empty margin inside the box. Same on desktop + phone
   — a drag target wants to be easy to hit with a finger. */
.drag-handle .icon { width: 18px; height: 28px; }
.drag-handle:active { cursor: grabbing; }
.sortable-ghost { opacity: .4; }
.sortable-chosen { background: var(--hover); border-radius: 8px; }

/* The Reorder toggle itself: a small icon button beside a list's title (personal-sort UX,
   2026-07-18). Muted until hovered/active; the pressed state (reorder mode on) tints it to the
   accent. `.list-title` groups the heading with the button so they cluster on the left even inside
   a space-between header. app.js hides the button (via [hidden]) when a list has <2 items. */
.list-title { display: flex; align-items: center; gap: .4rem; min-width: 0; }
.btn-reorder {
  display: inline-flex; align-items: center; justify-content: center;
  padding: .2rem; border: 1px solid transparent; border-radius: 7px;
  background: transparent; color: var(--muted); cursor: pointer; line-height: 0;
}
.btn-reorder[hidden] { display: none; }
.btn-reorder:hover { color: var(--text); background: var(--hover); }
.btn-reorder[aria-pressed="true"] {
  color: var(--accent); background: var(--hover); border-color: var(--line-2);
}
.btn-reorder .icon { width: 17px; height: 17px; }

/* Row enter/leave animations (QA point 9, 2026-07-19; user: "animate everything" — self-actions
   AND the 5s poller's background adds/removes share one look).
   LEAVE — a deleting row fades out AND shrinks to 20% (an 80% size reduction) before it's removed,
   so the removal reads clearly. Two triggers, one look:
   • DIRECT row swaps (e.g. invite Cancel) use htmx's own `.htmx-swapping` class, held for the
     `swap:Ns` window — keep N in sync with the transition duration below.
   • OOB / confirm-modal / poller deletes (`hx-swap-oob="delete"`, marked `.row-leaving` by
     oob_delete_row) can't get `.htmx-swapping`; static/js/app.js intercepts the OOB swap, keeps the
     existing row, adds `.row-leaving`, then removes the node after the transition (a scoped,
     CSP-safe alternative to the View Transitions API, whose per-row scoping needs an inline style
     attr the CSP forbids).
   ENTER — the mirror: a genuinely-new row fades in while growing from 20%. It's an ANIMATION (not a
   transition) so it plays on insertion with no reflow dance; app.js tags rows `.row-entering` only
   when they arrive inside a `rows-entering` OOB prepend wrapper, or when the middle list's live
   refresh places a note that started matching — so a full page load never animates.
   Reduced-motion users skip both (removal stays instant — app.js removes without the class). */
@media (prefers-reduced-motion: no-preference) {
  li.htmx-swapping,
  li.row-leaving,
  /* The dashboard's filtered-list row is a DIV, not an <li> (2026-08-13). It leaves when a note
     stops matching the Unread filter — his "remove the row on the spot". The app.js hook needed
     nothing: it keys on the class and uses evt.detail.target, so it never cared about the tag.
     Only this selector did, and without it the row vanished INSTANTLY while every other removal
     in the app fades and shrinks. */
  .dash-lrow.row-leaving {
    opacity: 0;
    transform: scale(.2);
    transform-origin: center;
    transition: opacity .6s ease, transform .6s ease;
  }
  li.row-entering,
  /* ...and the dashboard list row's twin (2026-09-17, his "Yes to the fade-in"): the middle list's
     live refresh tags a note that STARTS matching, the way the <li> poller tags a new row. */
  .dash-lrow.row-entering {
    animation: row-enter .6s ease;
    transform-origin: center;
  }
  @keyframes row-enter {
    from { opacity: 0; transform: scale(.2); }
  }
}

/* Collapse/expand (2026-07-18): the Alpine `collapsible` section folds its list. The animation is
   pure CSS via the grid-rows 0fr↔1fr trick (no fixed heights, no JS measuring) — Alpine only flips
   `.is-collapsed` on the section. The inner wrapper must clip (overflow:hidden, min-height:0) for the
   0fr row to actually hide it. */
.collapsible-body {
  display: grid; grid-template-rows: 1fr;
  transition: grid-template-rows .28s ease;
}
/* When EXPANDED the inner must NOT clip — otherwise a row's ⋯ kebab dropdown (position:absolute,
   opens below the last row) gets cut off (2026-07-18 QA). It only needs to clip while COLLAPSED, so
   the 0fr grid row actually hides the content. `transition-behavior: allow-discrete` + a delay keeps
   it hidden until the OPEN animation finishes, so nothing spills out mid-unfold. */
.collapsible-inner {
  overflow: visible; min-height: 0;
  transition: overflow 0s 0.28s allow-discrete;
}
.collapsible.is-collapsed .collapsible-body { grid-template-rows: 0fr; }
.collapsible.is-collapsed .collapsible-inner {
  overflow: hidden;
  transition: overflow 0s allow-discrete;  /* clip immediately when folding shut */
}

/* The collapse toggle: «−/+  N  ▴/▾» next to the list title. Sits at the FAR RIGHT on desktop
   (margin-left:auto below), and right after the reorder ⇅ on a phone (default flow). CSS picks the
   sign+chevron from `.is-collapsed`: expanded shows `.on-open` (−, ▴), collapsed shows `.on-closed`
   (+, ▾). */
.btn-collapse {
  display: inline-flex; align-items: center; gap: .18rem;
  padding: .12rem .35rem; border: 1px solid var(--line-2); border-radius: 7px;
  background: transparent; color: var(--muted); cursor: pointer;
  font-size: .82rem; line-height: 1;
}
.btn-collapse:hover { color: var(--text); background: var(--hover); }
.btn-collapse .icon { width: 14px; height: 14px; }
.btn-collapse .collapse-sign { font-weight: 700; }
.btn-collapse .collapse-count { font-variant-numeric: tabular-nums; }
.btn-collapse .on-closed { display: none; }
.collapsible.is-collapsed .btn-collapse .on-open { display: none; }
.collapsible.is-collapsed .btn-collapse .on-closed { display: inline-flex; }

/* The picker: 25 radios rendered as swatches (templates/tracker/_color_field.html). The input
   stays in the DOM and focusable — only visually replaced by its dot — so keyboard and screen
   readers get the real radiogroup for free. The dot is painted by the same badge-c-* key. */
.color-swatches { display: flex; flex-wrap: wrap; gap: .45rem; margin: .1rem 0 .2rem; }
/* NB `color-swatch`, not `swatch`: `.swatch` is already the THEME picker's accent button (below,
   with its own background + aria-pressed styling) and it is defined LATER, so it silently won.
   Two-class selectors throughout for the other half of the problem: these live inside
   `.invite-inline`, whose `label {display:block; color:var(--muted)}` (0,2,0) would otherwise
   flatten every swatch into a muted block row. Same trap as `.section-head .head-add-inline`. */
.color-swatches .color-swatch {
  display: inline-flex; cursor: pointer; border-radius: 50%; margin: 0; padding: 0;
}
.color-swatches .color-swatch input {
  position: absolute; width: 1px; height: 1px; opacity: 0;   /* focusable, not display:none */
  margin: 0; pointer-events: none;
}
.color-swatches .color-swatch-dot {
  display: block; width: 26px; height: 26px; border-radius: 50%;
  background: currentColor; color: oklch(var(--badge-l) var(--badge-ch) var(--badge-h));
  box-shadow: 0 0 0 2px var(--bg-panel);
  transition: box-shadow .12s, transform .12s;
}
.color-swatches .color-swatch:hover .color-swatch-dot { transform: scale(1.12); }
/* The label above the grid is a <span>, not a <label> (nothing to point at — the radios each
   carry their own aria-label), so it needs the field-label styling explicitly. */
.field-row .field-label {
  display: block; margin-bottom: .25rem; color: var(--muted); font-size: .85rem;
}
.field-row { margin: 0 0 .75rem; }
/* Checked + focus both read as a ring in the swatch's own hue, so the choice is obvious without
   relying on colour alone (the checked one also grows). */
.color-swatches .color-swatch input:checked + .color-swatch-dot {
  transform: scale(1.12);
  box-shadow: 0 0 0 2px var(--bg-panel), 0 0 0 4px currentColor;
}
.color-swatches .color-swatch input:focus-visible + .color-swatch-dot {
  box-shadow: 0 0 0 2px var(--bg-panel), 0 0 0 4px var(--text);
}

/* Hue wheel (the value is the stored key — never the label, so colour survives i18n). */
.badge-c-red     { --badge-h: 25;  --badge-ch: .15; }
.badge-c-coral   { --badge-h: 35;  --badge-ch: .13; }
.badge-c-orange  { --badge-h: 50;  --badge-ch: .13; }
.badge-c-amber   { --badge-h: 65;  --badge-ch: .12; }
.badge-c-honey   { --badge-h: 80;  --badge-ch: .12; }
.badge-c-yellow  { --badge-h: 100; --badge-ch: .13; }
.badge-c-lime    { --badge-h: 125; --badge-ch: .14; }
.badge-c-green   { --badge-h: 150; --badge-ch: .13; }
.badge-c-emerald { --badge-h: 165; --badge-ch: .12; }
.badge-c-teal    { --badge-h: 185; --badge-ch: .10; }
.badge-c-cyan    { --badge-h: 205; --badge-ch: .10; }
.badge-c-sky     { --badge-h: 230; --badge-ch: .11; }
.badge-c-blue    { --badge-h: 255; --badge-ch: .13; }
.badge-c-indigo  { --badge-h: 275; --badge-ch: .14; }
.badge-c-violet  { --badge-h: 295; --badge-ch: .14; }
.badge-c-purple  { --badge-h: 310; --badge-ch: .13; }
.badge-c-magenta { --badge-h: 330; --badge-ch: .14; }
.badge-c-pink    { --badge-h: 350; --badge-ch: .12; }
.badge-c-rose    { --badge-h: 10;  --badge-ch: .11; }
/* The muted family — same hues, low chroma, for terms that shouldn't shout. */
.badge-c-brown   { --badge-h: 45;  --badge-ch: .05; }
.badge-c-sand    { --badge-h: 75;  --badge-ch: .04; }
.badge-c-olive   { --badge-h: 115; --badge-ch: .04; }
.badge-c-slate   { --badge-h: 240; --badge-ch: .04; }
.badge-c-mauve   { --badge-h: 315; --badge-ch: .04; }
.badge-c-gray    { --badge-h: 70;  --badge-ch: .01; }

/* ─── Theme picker (nav) ───────────────────────────────────────────────────────────────
   A native <details> disclosure: <summary> is the trigger, the <form> is the dropdown.
   Swatches carry their own data-accent, so the [data-accent="…"] blocks above paint each
   one in its hue — the palette has a single source of truth. */
.theme-menu { position: relative; }
.theme-toggle {
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 26px; border-radius: 7px;
  border: 1px solid var(--line-2); color: var(--muted);
}
.theme-toggle::-webkit-details-marker { display: none; } /* Safari's default triangle */
.theme-toggle:hover { color: var(--text); }
.theme-menu[open] .theme-toggle { color: var(--accent); border-color: var(--accent); }

.theme-panel {
  position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
  width: 216px; padding: .9rem;
  background: var(--surface); border: 1px solid var(--line-2); border-radius: 12px;
  box-shadow: 0 18px 44px rgba(0,0,0,.45);
}
.theme-section-label {
  margin: 0 0 .55rem; font-size: .68rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
}
.swatches + .theme-section-label { margin-top: .95rem; }

.swatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: .5rem; }
.swatch {
  width: 100%; aspect-ratio: 1 / 1; padding: 0;
  border-radius: 50%; cursor: pointer;
  background: var(--accent); /* data-accent on the button scopes this to its own hue */
  border: 2px solid transparent; box-shadow: 0 0 0 1px var(--line-2);
  transition: transform .1s ease;
}
.swatch:hover { transform: scale(1.12); }
.swatch[aria-pressed="true"] {
  border-color: var(--surface); box-shadow: 0 0 0 2px var(--text);
}

.mode-toggle { display: flex; gap: .4rem; }
.mode-btn {
  flex: 1; padding: .42rem .5rem; border-radius: 8px; cursor: pointer;
  font: inherit; font-weight: 700; font-size: .8rem;
  background: var(--bg); color: var(--muted); border: 1px solid var(--line-2);
}
.mode-btn:hover:not([aria-pressed="true"]) { color: var(--text); }
.mode-btn[aria-pressed="true"] {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}

/* ─── Toggle switch (modern on/off; e.g. project "Locked") ──────────────────────────────
   A <label class="toggle"> wraps a visually-hidden checkbox + a track/thumb. The checkbox
   precedes the track, so :checked ~ .toggle-track slides + tints it. Fully keyboard- and
   no-JS-friendly (it's a real checkbox). */
/* label.toggle: the element+class specificity beats `.invite-inline label { display:block }`. */
label.toggle { display: flex; align-items: flex-start; gap: .7rem; cursor: pointer; margin: .2rem 0; }
.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle-track {
  position: relative; flex: 0 0 auto; margin-top: .1rem;
  width: 40px; height: 22px; border-radius: 999px;
  background: var(--bg); border: 1px solid var(--line-2); transition: background .15s, border-color .15s;
}
.toggle-thumb {
  position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
  background: var(--text-3); transition: transform .15s, background .15s;
}
.toggle input:checked ~ .toggle-track { background: var(--accent); border-color: var(--accent); }
.toggle input:checked ~ .toggle-track .toggle-thumb { transform: translateX(18px); background: var(--on-accent); }
.toggle input:focus-visible ~ .toggle-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle-text { display: flex; flex-direction: column; gap: .15rem; }
.toggle-label { font-weight: 600; color: var(--text); font-size: .9rem; }
.toggle-hint { color: var(--muted); font-size: .78rem; }

/* ─── Section item count (replaces the per-row code numbers — user request 2026-07-07) ─── */
.section-count {
  margin: .1rem 0 1.4rem; color: var(--muted);
  font-size: .78rem; letter-spacing: .02em;
}

/* ═══ Mobile (master.md Rule 12 — 100% usable on a phone) ════════════════════════════════
   Retrofit pass for everything built so far; see docs/MOBILE.md. Everything above is the
   desktop baseline — this block reflows it for small screens (no horizontal scroll, rows
   wrap, comfortable tap targets). New features should be built mobile-first from the start. */
@media (max-width: 640px) {
  #app { padding: 1.1rem 1rem 2.5rem; }

  /* --- Top nav on a phone (user request 2026-07-10): the header keeps the brand (= Home),
     the Projects + Members section links, and the name-badge. The account-level controls —
     Profile, language, theme, Log out — move into the dropdown the badge opens (.user-menu).
     Anonymous users (no .is-auth) keep the wrapping nav + switchers. --- */
  .topnav { flex-wrap: wrap; gap: .5rem .7rem; padding: .55rem .9rem; }
  .topnav-spacer { display: none; }               /* let items pack; the wrap does the spacing */

  .is-auth .topnav > a.user-chip,
  .is-auth .topnav > .theme-menu,
  .is-auth .topnav > .lang-switch,
  .is-auth .topnav > .logout-link { display: none; }

  /* Section links drop to their own full-width row below the brand + badge (order:5 sends
     them past the default-order items, width:100% forces the wrap — user request 2026-07-10). */
  .navlinks { order: 5; width: 100%; gap: 1.4rem; padding-top: .1rem; }
  .navlinks a { padding: .3rem 0; white-space: nowrap; }
  .lang-switch button { padding: .4rem .65rem; }
  .theme-toggle { width: 38px; height: 34px; }

  /* --- The name-badge dropdown --- */
  .user-menu { display: block; position: relative; margin-left: auto; }
  .user-menu > summary.user-chip { cursor: pointer; list-style: none; white-space: nowrap; }
  .user-menu > summary.user-chip::-webkit-details-marker { display: none; }
  .user-menu[open] > summary.user-chip { background: var(--hover); }
  .chip-caret { color: var(--muted); transition: transform .15s; }
  .user-menu[open] .chip-caret { transform: rotate(180deg); }
  /* The mobile name-badge shows avatar + name only — drop the role badge (QA/Programmer…) to
     keep the header uncluttered (user request 2026-07-14). Members/invite/profile badges are
     unaffected: they aren't inside .user-chip. */
  .user-menu > summary.user-chip .role-badge { display: none; }

  .menu-panel {
    position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
    width: min(264px, calc(100vw - 1.5rem)); padding: .5rem;
    display: flex; flex-direction: column;
    background: var(--surface); border: 1px solid var(--line-2); border-radius: 12px;
    box-shadow: 0 18px 44px rgba(0,0,0,.45);
  }
  .menu-item {
    display: flex; align-items: center; gap: .65rem;
    padding: .62rem .55rem; border-radius: 8px;
    color: var(--text); text-decoration: none; font-weight: 600; font-size: .95rem;
  }
  .menu-item:hover { background: var(--hover); }
  .menu-item.logout { color: var(--muted); }
  .menu-icon { flex: none; width: 18px; height: 18px; }
  .menu-div { border-top: 1px solid var(--line); margin: .45rem .25rem; }

  /* The shared language + theme forms render inline inside the panel (not as their desktop
     dropdowns). */
  .menu-panel .lang-switch { padding: .1rem .35rem .3rem; gap: .4rem; }
  .menu-panel .lang-switch button { padding: .45rem .85rem; font-size: .8rem; }
  .menu-panel .theme-panel {
    position: static; width: auto; padding: .35rem .35rem 0;
    background: none; border: none; box-shadow: none;
  }
  .menu-panel .theme-section-label { margin-bottom: .45rem; }
  .menu-panel .swatches + .theme-section-label { margin-top: .7rem; }

  /* --- Page header (title + action buttons) stacks; actions wrap. --- */
  .members-head { align-items: flex-start; }
  /* full width so the ⋯ can sit at the far right (margin-left:auto on .head-menu). */
  .head-actions { display: flex; width: 100%; flex-wrap: wrap; gap: .4rem; align-items: stretch; }

  /* --- In-project (container) header, phone (user request 2026-07-14). The lock badge + ⋯ kebab
     sit to the RIGHT of the project name and drop BELOW it (still right-aligned) when the name is
     long; New note / New section leave the header for the section headings. Scoped to
     .container-page so the projects / members / teams headers keep their full-width action row. --- */
  .container-page .head-add { display: none; }               /* moved to the section headings */
  /* column-gap is .4rem, not the .5rem it was: the lock and the ⋯ used to be siblings inside
     .head-actions (gap .4rem) and are now separated by THIS gap instead, so .4rem keeps the phone
     spacing byte-identical to before the lock moved. The name→lock gap is unaffected either way —
     #lock-control's margin-left:auto absorbs whatever's left over. */
  .container-page .members-head { align-items: center; gap: .3rem .4rem; }
  /* The title cluster is flattened here (desktop pairs the lock with the name inside it) so the
     name, the lock and the ⋯ are one flex line again — the lock keeps hugging the right next to
     the kebab, as before. NB the h1 is no longer a DOM child of .members-head, so this must not
     use a `>` combinator. */
  .container-page .head-title { display: contents; }
  .container-page #container-h1 { flex: 0 1 auto; min-width: 0; }

  /* The container page's headings are the browser defaults (2em / 1.5em) — far too heavy on a
     phone (user request 2026-07-15). The title drops to the app's page-title size (matching
     .members h1) and the section headings drop below it so the hierarchy still reads. Margins are
     em-based, so they scale down with the font. Desktop keeps the defaults. */
  .container-page #container-h1 { font-size: 1.5rem; letter-spacing: -.3px; }
  .container-page .section-head h2 { font-size: 1.15rem; }
  /* The lock now carries the right-push that .head-actions used to (it precedes the kebab), so
     [lock][⋯] still travel together to the right edge — including when the lock renders empty
     (non-manager on an unlocked project): a zero-width item with margin-left:auto still absorbs
     the free space, leaving the kebab hard right. */
  .container-page #lock-control { margin-left: auto; }
  .container-page .head-actions {                            /* just the [⋯], after the lock */
    width: auto; flex: 0 0 auto; flex-wrap: nowrap; margin-left: 0; align-items: center;
  }
  .container-page .head-menu { margin-left: 0; }             /* grouped with the lock badge, not far-right */
  /* Regular-size ⋯ (don't stretch to the lock button's height — the New note/New section buttons it
     used to match have moved to the section headings). User request 2026-07-14. */
  .container-page .head-menu > label.btn-kebab { align-self: center; height: 30px; }

  /* New note / New section move to the right of their section heading (the .head-add-inline copy). */
  .container-page .section-head {
    display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  }
  .container-page .section-head .head-add-inline { display: inline-block; }

  /* --- Comfortable tap targets on the main buttons. --- */
  .btn, .btn-ghost, .mode-btn, .member-edit button { padding-top: .55rem; padding-bottom: .55rem; }

  /* --- List rows wrap so nothing is pushed off-screen. The per-row actions collapse behind a
     ⋯ kebab menu at the row's end (user request 2026-07-10): the inline buttons are hidden and
     the .row-menu dropdown takes over. --- */
  /* Phones always use the ⋯ kebab (its appearance is defined in the base; here we only flip
     visibility). The desktop opt-in (body.kebab-actions) does the same flip at any width. */
  .project-list li { flex-wrap: wrap; }
  .row-actions { display: none; }
  .row-menu { display: block; }
  .member-edit { flex-wrap: wrap; }

  /* Two-column rows (user request 2026-07-14): column 1 stacks the name over its badges, column 2
     is the ⋯ kebab spanning both rows. Applies to the "Your projects" list (#mine-list: team /
     Locked / Owner / "!" badges) and the in-project Notes list (#notes: type / priority / state /
     assignee). Scoped by id so the teams / sections / joinable lists (also .project-list) keep
     their single-line wrap. */
  #mine-list > li, #notes > li {
    display: grid; grid-template-columns: 1fr auto; align-items: center;
    column-gap: .6rem; row-gap: .3rem;
  }
  #mine-list > li > .project-name, #notes > li > .project-name { grid-column: 1; grid-row: 1; min-width: 0; }
  #mine-list .row-badges, #notes .row-badges {
    grid-column: 1; grid-row: 2;
    display: flex; flex-wrap: wrap; align-items: center; gap: .35rem;
  }
  #mine-list > li > .row-menu, #notes > li > .row-menu { grid-column: 2; grid-row: 1 / span 2; align-self: stretch; margin-left: 0; }

  /* Personal-sort drag handle on phones (2026-07-17, user-approved): add a LEFT column for the
     handle, spanning both rows (mirrors the ⋯ kebab on the right), so the tuned name-over-badges
     stack is untouched. Drag starts immediately from the handle (app.js SortableJS forceFallback;
     no press-delay). Applies to the two-column lists #mine-list AND #notes. These override the
     shared placements above at equal specificity by source order. */
  #mine-list > li, #notes > li { grid-template-columns: auto 1fr auto; }
  #mine-list > li > .drag-handle, #notes > li > .drag-handle {
    grid-column: 1; grid-row: 1 / span 2; align-self: center;
  }
  #mine-list > li > .project-name, #notes > li > .project-name { grid-column: 2; }
  #mine-list .row-badges, #notes .row-badges { grid-column: 2; }
  #mine-list > li > .row-menu, #notes > li > .row-menu { grid-column: 3; }
  /* Make the ⋯ button fill the two-row height so it reads as one control spanning both rows, and
     scale up the dots to match that taller target (user request 2026-07-14). */
  #mine-list .row-menu .btn-kebab, #notes .row-menu .btn-kebab { height: 100%; }
  #mine-list .row-menu .btn-kebab .icon, #notes .row-menu .btn-kebab .icon { width: 26px; height: 26px; }

  /* Members list: the same two columns (user request 2026-07-14), but the role badge stays on the
     FIRST row with the name; row 2 holds authority / permission badges / "you". Column 2 is the
     Edit button, centered across both rows. Scoped to display rows (:has(.member-main)) so the
     inline edit form keeps its own flex flow. */
  #member-rows > li:has(.member-main) {
    display: grid; grid-template-columns: 1fr auto; align-items: center;
    column-gap: .6rem; row-gap: .3rem;
  }
  #member-rows .member-main {
    grid-column: 1; grid-row: 1;
    display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
  }
  #member-rows .member-name { margin-right: 0; }  /* drop the desktop right-push */
  #member-rows .member-badges {
    grid-column: 1; grid-row: 2;
    display: flex; flex-wrap: wrap; align-items: center; gap: .35rem;
  }
  #member-rows .member-badges:not(:has(*)) { display: none; }  /* no row-2 content → single line */
  #member-rows > li > .member-actions {
    grid-column: 2; grid-row: 1 / span 2; align-self: center;
    display: flex; flex-direction: column; gap: .35rem;
  }
  #member-rows .member-actions .btn-ghost { margin-left: 0; }

  /* Personal-sort handle on the members list (2026-07-17): a LEFT column spanning both rows (like
     #mine-list/#notes), so the name-over-badges stack is untouched and Edit stays on the right. Only
     display rows (:has(.member-main)) become the 3-column grid; the inline edit-form li is untouched. */
  #member-rows > li:has(.member-main) { grid-template-columns: auto 1fr auto; }
  #member-rows > li > .drag-handle { grid-column: 1; grid-row: 1 / span 2; align-self: center; }
  #member-rows .member-main { grid-column: 2; }
  #member-rows .member-badges { grid-column: 2; }
  #member-rows > li > .member-actions { grid-column: 3; }

  /* Container-detail header: New note + New section stay inline; Lock/Members/Edit/Delete fold
     into a ⋯ dropdown (user request 2026-07-10). Single instance, checkbox-toggled so it opens
     with no JS; app.js only adds outside-click / after-pick close. */
  /* inline-flex + margin-left:auto pushes the ⋯ to the row's right edge. 1px top/bottom padding
     on the wrapper (symmetric ⇒ stays centered) insets the stretched button so it's ~2px shorter
     than the buttons (user request). */
  .head-menu { display: inline-flex; position: relative; margin-left: auto; box-sizing: border-box; padding: 1px 0; }
  .head-menu-panel { display: none; }
  .head-menu > label.btn-kebab { display: inline-flex; height: auto; align-self: stretch; }
  .head-menu-cb:checked ~ .head-menu-panel {
    display: flex; flex-direction: column; gap: .35rem;
    position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
    min-width: 194px; padding: .45rem;
    background: var(--surface); border: 1px solid var(--line-2); border-radius: 10px;
    box-shadow: 0 14px 36px rgba(0,0,0,.4);
  }
  /* The management controls stack full-width as menu rows inside the dropdown. They are flex
     containers now (see the shared `.btn, .btn-ghost` rule), so their labels are held left by the
     default justify-content:flex-start — `text-align` alone no longer does it. */
  .head-menu-panel .btn-ghost { width: 100%; text-align: left; }

  /* --- Profile: label/value grid becomes a single column. --- */
  .profile-fields { grid-template-columns: 1fr; gap: .1rem .5rem; }
  .profile-fields dt { margin-top: .7rem; }
  .profile-fields dt:first-of-type { margin-top: 0; }

  /* --- Theme panel can't run off a narrow screen. --- */
  .theme-panel { width: min(216px, calc(100vw - 1.5rem)); }
}

/* ═══ Personal Dashboard (2026-07-20 shell slice; design/dashboard.mockup.html v3) ═══════════
   Desktop-only three-pane screen — full-bleed under the top-nav via #app:has(.dash-panes)
   escaping the shared #app max-width/padding. Reuses the app's own badge classes (the badge and
   badge-c-/badge-prio- palette chips) — only the pane/tree/pill layout below is new. */
/* Height (2026-07-25): the panes fill whatever the top-nav leaves, DERIVED — never a magic
   number. This was `calc(100vh - 47px)` while the real offset is 57px (22px nav content + 1.5rem
   padding + 1px border), so the grid overran the viewport by exactly 10px at every window size:
   that was both the stray page scroll AND the chat compose box sitting below the fold. Making the
   body a flex column means the nav measures itself, so a nav that grows (wrapping on a narrow
   window, a bigger font) can never push the panes off-screen again. */
body:has(.dash-panes) {
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: 100dvh;          /* dvh where supported: excludes mobile browser chrome */
  overflow: hidden;        /* the panes scroll internally; the PAGE never does */
}
body:has(.dash-panes) .topnav { flex: none; }
#app:has(.dash-panes) {
  max-width: none; padding: 0; margin: 0;
  flex: 1 1 auto;
  min-height: 0;           /* without this a flex child refuses to shrink below its content */
}

/* RESIZABLE since 2026-08-20 (his ask). The two outer panes carry a width; the middle is 1fr and
   absorbs whatever is left, so only two numbers exist to store. The member's stored values arrive
   as `--dash-rail-w` / `--dash-chat-w` in the server-rendered inline style; the defaults below are
   what the screen shipped with, so an untouched member sees exactly what they saw before.

   🔴 THE FITTING IS HERE, NOT ON THE SERVER, and that is not a shortcut: the server never learns
   the viewport width. `min()` caps each pane at a share of the space left over once the middle
   pane's 320px floor is reserved, and `max()` keeps it above its own minimum. Doing it in CSS also
   means the panes re-fit LIVE as the window is resized, which no server value could.

   The 45/55 split of that leftover matches the panes' own proportions closely enough that neither
   cap binds at any ordinary window size — at 1400px they are 486px and 594px, well clear of the
   330/400 defaults, so the caps only ever engage on a genuinely cramped window.

   ⚠️ `100%` here resolves against the grid's own inline size, deliberately — `100vw` would include
   the scrollbar and cap the panes a few px too small on every window.

   🔴 R2, 2026-08-20 — **the `max()` floors below are DELIBERATELY LOWER than what a member may
   drag to** (330 / 400 since his *"the provious widhts should be the minimal size"*; see
   DASH_PANE_LIMITS). Raising these to match would look like the tidy thing and would break the
   screen: 330 + 400 is exactly 730px, the dashboard's documented minimum window, so at the floor
   the middle pane would be handed 0px. These two numbers answer a different question — not "how
   narrow may he choose?" but "what happens on a window too narrow to give him what he chose?" —
   and a squeezed middle pane beats an unusable one. They only diverge below a ~1050px window. */
.dash-panes {
  display: grid;
  grid-template-columns:
    max(220px, min(var(--dash-rail-w, 330px), calc((100% - 320px) * .45)))
    0
    minmax(0, 1fr)
    0
    max(280px, min(var(--dash-chat-w, 400px), calc((100% - 320px) * .55)));
  height: 100%;
  min-height: 0;           /* no floor: the panes always fit, each scrolling on its own */
}
/* The handles occupy ZERO-width grid tracks and are painted with a negative margin, so adding
   them moved nothing: the rail's right edge and the chat's left edge are exactly where they were.
   The hit area is 7px, wider than the 1px border it sits on, because a 1px drag target is not a
   drag target. */
.dash-resizer {
  width: 7px; margin: 0 -3px; z-index: 3;
  cursor: col-resize; background: none; border: 0;
  align-self: stretch;
}
.dash-resizer:hover, .dash-resizer:focus-visible, .dash-resizer.is-dragging {
  background: var(--accent); opacity: .55; outline: none;
}
/* While dragging, the whole document takes the resize cursor and stops selecting text — without
   this the pointer flickers between cursors over every element it crosses, and a fast drag
   selects the tree. */
body.is-resizing-panes { cursor: col-resize; user-select: none; }
/* Grid/flex children default to min-*: auto, which blocks shrinking — so on a short window the
   panes would grow past the viewport instead of scrolling internally. */
.dash-rail, .dash-mid, .dash-chat { overflow-y: auto; min-height: 0; min-width: 0; }

.dash-rail {
  background: var(--bg-sidebar);
  border-right: 1px solid var(--border-2);
  padding: .8rem .6rem;
  display: flex;
  flex-direction: column;
  gap: .1rem;
}
.dash-rail-label {
  font-size: 10.5px; letter-spacing: .6px; color: var(--text-muted); font-weight: 700;
  padding: .2rem .5rem .45rem;
  /* The Move toggle sits on this row (2026-08-21), pushed to the far end. */
  display: flex; align-items: center; justify-content: space-between; gap: .4rem;
}
/* The tree's grip is the SHARED .drag-handle now (2026-08-24) — one control, one affordance.
   🔴 It TAKES THE EYE'S PLACE while sorting is on, and spans BOTH lines of the note (his call,
   2026-08-24 R2: "remove the eye and in his place to show the dots spaning on both rows").
   It has to be absolute for the same reason the eye does: the row is a COLUMN flex (title over
   chips), so an in-flow grip would sit on its own line above the title instead of beside it.
   `top/bottom: 0` is what makes the dots span the whole row rather than sit against the title.
   Reusing the eye's 14px slot means the row does not shift by a pixel when the mode flips —
   the same reason the eye reclaimed that slot from the old status dot. */
.dash-node-note > .drag-handle {
  position: absolute; left: .5rem; top: 0; bottom: 0;
  width: 14px; justify-content: center; align-items: center;
}
.dash-node-note > .drag-handle .icon { width: 14px; height: 100%; max-height: 30px; }
/* One slot, one occupant: while the mode is on the eye stands down. Marking a note read is still
   one click away — turn sorting off, which is also how you stop dragging by accident. */
[data-reorder-scope].reordering .dash-node-note > .dash-note-eye { display: none; }
/* While a drag is live, every container that can TAKE the note says so.
   ⚠️ --accent, never --brand: --brand is the fixed orange --accent defaults to, so a rule on it
   looks right until the user moves the colour picker. */
/* 🔴 `.is-drop-ok`, not every [data-drop-container] (2026-08-24, his report). Only the containers
   the dragged note may ACTUALLY reach are marked — app.js adds the class per drag, because which
   set is eligible depends on which note is moving and no static selector can say that. Marking a
   section from another project offered a move the server refuses with a 400. */
/* 🔴 `.dash-node-project` JOINED THIS LIST on 2026-08-31, with the root becoming a legal
   destination. It is the whole reason a CSS pass was needed at all: the markup and the server both
   accepted the drop the moment their gates came off, so every behavioural check passed — while the
   project row stayed the one eligible target that never drew an outline. An affordance the user
   cannot SEE is not an affordance, and no check that asserts "the dialog opened" can notice. */
body.dash-dragging .dash-node-project.is-drop-ok,
body.dash-dragging .dash-node-section.is-drop-ok,
body.dash-dragging li.is-drop-ok {
  outline: 1px dashed var(--accent); outline-offset: 2px; border-radius: 6px;
}
/* An OPEN section's notes block accepts the drop itself and SortableJS draws its own insertion
   feedback there — outlining it too would box the row and its notes separately. */
body.dash-dragging .dash-node-notes.is-drop-ok { min-height: 10px; }
.dash-node-project, .dash-node-section, .dash-node-note {
  display: flex; align-items: center; gap: .45rem; border-radius: 8px; cursor: pointer;
  color: var(--text-2);
}
.dash-node-project:hover, .dash-node-section:hover, .dash-node-note:hover { background: var(--hover); }
/* WHERE YOU ARE IN THE TREE (2026-08-13) — his ask: "when I click on project, or section the
   opened projects and sections to get lighter color to be easely distingtive where are you are in
   the tree."

   ⚠️ It shipped as TWO signals and he cut one the same day, having looked at it: *"remove the
   backround coloring of the project and sections."* So the open-node SURFACE is gone — open rows
   carry no background of their own — and the accent marker below is the whole feature. The map of
   what you expanded is already legible from the twisties and the indent; the surface added a
   second, louder answer to a question the tree was not being asked.

   📌 Deleted with it: a companion `:hover` rule that re-layered --hover over the card colour,
   which existed only because an opaque background outranked the translucent hover fill. Leaving
   it behind would have kept painting exactly the background he asked to remove, on hover — the
   removal is incomplete without it.

   The marker: `inset` box-shadow rather than a border-left, since a border would shift the row's
   content by its width every time the mark moved. --accent follows the picker; never --brand,
   which is only the fixed orange --accent happens to default to. */
.dash-node.is-current > .dash-node-project,
.dash-node.is-current > .dash-node-section { box-shadow: inset 2px 0 0 var(--accent); }
.dash-node.is-current > .dash-node-project .dash-node-name,
.dash-node.is-current > .dash-node-section .dash-node-name { color: var(--accent); }
/* Indentation (2026-07-25) is driven by NESTING, not per-row padding. It used to be a fixed
   padding-left per row type — 1.35rem for any section at any depth, 2.5rem for any note — so a
   nested section never stepped right, and a project's own notes (2.5rem) sat DEEPER than that
   project's sections (1.35rem), reading as if they belonged to a section. Now each
   .dash-node-kids adds one step, so depth compounds on its own however deep the tree goes. */
.dash-node-project { padding: .45rem .5rem; font-size: 13.5px; font-weight: 700; }
.dash-node-section { padding: .32rem .5rem; font-size: 12.5px; font-weight: 600; color: var(--text-3); }
/* A note row is TWO lines since 2026-07-25 (title, then its chips), so it stacks rather than
   sitting in a row. The dot is gone — the state badge carries state now — but its 14px slot has
   to be paid back as padding, or a note's text would start 14px+gap left of a SIBLING section's
   name and the shared level would stop lining up (the same alignment the dot was widened for). */
.dash-node-note {
  padding: .28rem .5rem .28rem calc(.5rem + 14px + .45rem);
  font-size: 12px; color: var(--text-3);
  flex-direction: column; align-items: stretch; gap: .18rem;
  position: relative;  /* anchors the unseen eye — see below */
}
/* The EYE (2026-07-28, the user's QA point 5) reclaims the 14px slot the old status dot left
   behind — the same slot the padding above pays back to keep note titles aligned with sibling
   section names. So it costs the row NO layout change and nothing shifts when the state flips.
   It has to be absolute: the row is a COLUMN flex (title over chips), so an in-flow eye would sit
   on its own line above the title rather than beside it.
   Accent when unread (matching .dash-new — in this rail, the accent hue means "unseen"), dim when
   read. It renders on EVERY row: it is a toggle, so it has to be there to be clicked.
   ⚠️ --accent, never --brand: --brand is the fixed orange that --accent merely DEFAULTS to, so a
   rule written against it looks identical until the user picks another accent and then stays
   orange (the user's report, 2026-08-07). */
.dash-note-eye {
  position: absolute; left: .5rem; top: .3rem;
  width: 14px; height: 14px; padding: 0; border: 0; background: none;
  color: var(--accent); cursor: pointer; line-height: 0;
}
.dash-note-eye svg { width: 14px; height: 14px; display: block; }
/* Read = present but receding. Kept faint enough not to compete with the unread ones down the
   rail, but it lifts on hover so it reads as clickable rather than as a dead marker. */
.dash-note-eye.is-read { color: var(--text-muted); opacity: .45; }
.dash-note-eye.is-read:hover, .dash-note-eye.is-read:focus-visible { opacity: 1; }
.dash-note-eye:hover { transform: scale(1.15); }
.dash-note-eye:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
@media (prefers-reduced-motion: reduce) { .dash-note-eye:hover { transform: none; } }
.dash-note-chips { display: flex; flex-wrap: wrap; gap: .25rem; }
/* Tree chips are a size down from the app's standard badge — two of them plus up to 65px of
   indent has to survive a 300px rail. */
.dash-note-chips .badge { font-size: .58rem; padding: 0 5px; letter-spacing: .3px; }
.dash-node-name, .dash-node-note-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* `hidden` must beat .dash-new's own display (2026-07-25 live-tree): the pill now renders ALWAYS
   so the poller has an OOB target, and carries its zero state as [hidden] rather than by being
   absent from the template. Without this the chip would show a bare "0". */
.dash-new[hidden] { display: none; }
/* The kids block splits into sections + notes (2026-07-25 live-tree) so reordering notes never
   touches nested sections. Both are plain blocks — the indent/guide line stays on .dash-node-kids,
   so nesting-based depth (commit 1cdeeba) is unaffected. */
.dash-node-kids { display: none; }
.dash-node.is-open > .dash-node-kids {
  display: block;
  /* One indent step is 13px and MUST stay 13px: every note row pays that slot back as padding so
     a note title lines up with a sibling section's name (see .dash-node-note). Here it is
     margin 7 + padding 6, with the guide line drawn by ::before rather than by a border. */
  margin-left: 7px;
  padding-left: 6px;
  position: relative;
}
/* The guide line (2026-08-07, the user: "the down arrow and the helper lines are not aligned").
   It used to be this block's `border-left`, which pinned it to wherever the indent arithmetic
   happened to put it — measured 7.5px LEFT of the twisty centre at every depth. (He estimated
   5px by eye; 5 would have left it 2.5px out.)
   ⚠️ It CANNOT be fixed by growing margin-left: margin + padding is the indent step, so moving
   the line that way indents the whole tree deeper — a different change from the one he asked for.
   Drawing it as a positioned pseudo-element decouples "where the line is" from "how far each
   level steps", so the two can never fight again.
   left: 7.5px is measured, not guessed: the containing block is this element's padding box, which
   starts one 7px margin in from the node, and the twisty's centre is 15px from that same node
   edge (.5rem row padding + half of the 14px twisty). */
.dash-node.is-open > .dash-node-kids::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: 7.5px;
  width: 1px; background: var(--line);
}
/* THE ARROW (2026-08-19, the user: "make the arrows as big as possible by reducing margin and
   padding so nothing moves because of the enlargment"). It is now the ONLY control that collapses
   a node, so it is sized for the eye AND for the pointer — and neither costs the row a pixel:

   • `font-size` is what paints the triangle. At the old 10px it painted 5.75px inside a 14px slot
     — 58% of the font-size, a ratio no DOM metric reports, which is why live_tree_twisty_audit.py
     PIXEL-SCANS it rather than inferring it.
   • `line-height: 0` keeps that growth out of the row's height entirely. The row is
     `align-items: center`, so its height is the tallest item's: at line-height 1 an 18px glyph
     still fits, but 20px pushes a SECTION row open (18.73px of content against a project row's
     20.23px). At 0 the arrow contributes NO height at any size and the name keeps deciding the
     row — so no padding had to be traded away after all.
   • 22px wide minus 4+4 of padding = a 14px CONTENT box in a 22px hit box, pulled back to a 14px
     FOOTPRINT by the negative margins. Vertically the same trick gives a 24px target out of a
     zero-height box. box-sizing is border-box app-wide, hence 22 rather than 14.

   🔴 NEVER enlarge this by widening the box. That 14px is load-bearing in four places: the note
   row pays it back as padding so a note title aligns with a sibling section's name, `.dash-dot`
   fills the same slot, the eye's box matches it, and the guide line's `left: 7.5px` is DERIVED
   from the arrow's centre sitting 15px from the row's edge (the alignment fixed on 2026-08-07).
   Grow the font-size instead; the ceiling is the 14px content box, i.e. ~24px of font.
   📌 Shipped at 24px, then taken down to 21px on 2026-08-19 — his "a touch smaller" after seeing
   it. Painted ink 13.5px → ~11.8px, still more than double the 5.75px it started at.
   ⚠️ The glyph is a text node the component rewrites (▸/▾), so it obeys font metrics, not a box:
   check the ink's vertical centre after any change, not just its size. */
.dash-twisty {
  /* ⚠️ The .093em split is a MEASURED correction, not decoration. `line-height: 0` hands the
     glyph's vertical placement to the font's baseline, and this triangle paints BELOW the centre
     of its own (perfectly centred) box — measured 3.25px low at 24px, while the BOX measured 0.00
     off. Only a pixel scan sees that difference. The two halves still sum to 24px, so the negative
     margins keep cancelling exactly.
     ⚠️ Written in em, but do NOT trust it to follow a resize on its own: the offset is not a clean
     fraction of the font-size (hinting rounds differently per size). Going 24px → 21px on
     2026-08-19 moved the ink from +0.25 to -0.88 and the constant had to be retuned .135 → .093.
     **Re-measure after any size change** — live_tree_twisty_audit.py fails at ±1.5px, which is
     what caught this. */
  width: 22px; padding: calc(12px - .093em) 4px calc(12px + .093em); margin: -12px -4px;
  color: var(--text-muted); font-size: 21px; line-height: 0; flex: none; text-align: center;
  user-select: none;  /* it is a control; a double-click should collapse, not select the glyph */
}
/* A note has a dot where a section has a twisty. Padding it out to the twisty's 14px keeps a
   note's title aligned with the name of a SIBLING section at the same depth — without this they
   sit 7px apart and the shared level stops being readable. */
.dash-dot {
  width: 7px; height: 7px; border-radius: 50%; flex: none;
  margin-inline: 3.5px;
}
.dash-dot-c-purple  { background: oklch(var(--badge-l) 0.13 310); }
.dash-dot-c-blue    { background: oklch(var(--badge-l) 0.13 250); }
.dash-dot-c-honey   { background: oklch(var(--badge-l) 0.13 80); }
.dash-dot-c-emerald { background: oklch(var(--badge-l) 0.13 160); }
.dash-dot-c-coral   { background: oklch(var(--badge-l) 0.13 35); }
.dash-dot-c-teal    { background: oklch(var(--badge-l) 0.13 190); }
.dash-dot-c-violet  { background: oklch(var(--badge-l) 0.13 290); }
/* Read-marker (2026-07-25, piece ③): the ACCENT pill is now the UNSEEN count and renders only
   when > 0; the total moved to the always-dim .dash-count beside it, so you keep both signals
   ("2 need my attention, 5 are mine") and the tree never goes blank once you're caught up.
   The node name flexes so the two pills sit right without competing `margin-left:auto`. */
.dash-node-name { flex: 1 1 auto; }
.dash-new, .dash-count {
  margin-left: 6px; font-size: 10.5px; font-weight: 800;
  border-radius: 999px; padding: 0 8px; line-height: 1.5; flex: none;
}
.dash-new { background: var(--accent); color: var(--on-accent); }
.dash-count { background: var(--bg-panel-2); color: var(--text-muted); border: 1px solid var(--border-2); }
.dash-node-note-title.is-new, .dash-lrow-title.is-new { font-weight: 800; color: var(--text); }

/* Hover tooltip for a tree note's description (2026-07-25). position:fixed rather than absolute
   inside the row: the rail is overflow-y:auto, so an in-flow popup would be clipped at the rail's
   edges. app.js sets left/top via CSSOM (which CSP's style-src does not govern — that covers
   parsed style= attributes and <style> blocks) and toggles .is-on after a 2s dwell. */
.dash-tip {
  position: fixed; z-index: 60; max-width: 280px;
  background: var(--bg-panel-2); color: var(--text-2);
  border: 1px solid var(--border-2); border-radius: 8px;
  padding: .45rem .6rem; font-size: 11.5px; line-height: 1.45;
  box-shadow: 0 6px 20px rgba(0,0,0,.28);
  opacity: 0; visibility: hidden; transition: opacity .12s ease;
  pointer-events: none;                 /* never eats the hover that summoned it */
}
.dash-tip.is-on { opacity: 1; visibility: visible; }
.dash-tip.is-muted { color: var(--text-muted); font-style: italic; }
/* Icon hints (2026-08-21, his ask) share this element with the row tooltip above; the accent edge
   is what tells the two apart at a glance — one is the note's own text, the other is the app
   explaining a control. Narrower, because a hint is one sentence and a long line is hard to catch
   in the three seconds it is on screen. */
.dash-tip.is-hint {
  max-width: 240px; border-left: 3px solid var(--accent); color: var(--text-2); font-style: normal;
}

.dash-filters {
  display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .8rem;
  border-top: 1px solid var(--line); padding-top: .7rem;
}
.dash-filters-label { flex-basis: 100%; padding: 0 .5rem .3rem; }
.dash-pill {
  background: var(--bg-panel-2); border: 1px solid var(--border-2); color: var(--text-3);
  border-radius: 999px; padding: .26rem .65rem; font-size: 11.5px; font-weight: 600; cursor: pointer;
}
.dash-pill.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }

/* Segmented picker (2026-08-21) — the hint delay, and anything else that is a short closed list.
   It borrows `.dash-pill`'s shape deliberately: a second visual language for "pick one of these"
   would be a new thing to learn for no gain. The segments touch, so the group reads as ONE
   control; only the outer corners are rounded. */
.seg { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid var(--border-2); }
.seg-btn {
  background: var(--bg-panel-2); border: 0; border-left: 1px solid var(--border-2);
  color: var(--text-3); padding: .26rem .7rem; font-size: 11.5px; font-weight: 600;
  cursor: pointer; line-height: 1.6;
}
.seg-btn:first-of-type { border-left: 0; }
.seg-btn:hover { color: var(--text-1); background: var(--hover); }
.seg-btn.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.seg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Named container (2026-08-20) so the note row's narrow-width steps can follow THIS pane rather
   than the viewport — see the @container rules further down. `inline-size` only: a size container
   on both axes would need a fixed height, which this pane deliberately does not have. */
.dash-mid {
  background: var(--bg); padding: 1.1rem 1.4rem;
  container: dash-mid / inline-size;
}
.dash-mid-empty { color: var(--text-muted); font-size: 13px; padding: 2rem 0; text-align: center; }
/* The path shares its line with the open note's own eye (2026-08-21, his ask). The margin moved
   from `.dash-crumb` to the ROW so the two stay on one baseline; the nav keeps `min-width: 0` so a
   long chain ellipsises instead of shoving the eye off the pane. */
.dash-crumb-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .5rem; }
.dash-crumb-row .dash-crumb { flex: 1 1 auto; min-width: 0; margin-bottom: 0; }
.dash-crumb { color: var(--text-muted); font-size: 12px; margin-bottom: .5rem; }
.dash-crumb a { color: var(--text-3); text-decoration: none; cursor: pointer; }
.dash-crumb a:hover { text-decoration: underline; }
.dash-crumb-sep { margin: 0 .3rem; }
.dash-back {
  display: inline-block; background: none; border: 1px solid var(--line-2); color: var(--text-3);
  border-radius: 7px; font-size: 12px; padding: .2rem .6rem; cursor: pointer; margin-bottom: .7rem;
}
/* Full-height flex column so an empty attachments block can grow to fill the middle pane. */
.dash-note { display: flex; flex-direction: column; min-height: 100%; }
.dash-note-title { font-size: 18px; font-weight: 700; margin: 0 0 .5rem; }
/* `align-items: center` matters since 2026-08-02: the people circles now share this row with
   the badges and are twice their height, so without it the badges hang off the top. */
.dash-note-chips { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-bottom: .4rem; }
/* Title left, badges right, on one row (2026-07-31, QA point 7 round 2, item 7). `flex-wrap` lets
   the chips drop under the title on a narrow pane rather than squeezing the title to nothing. */
.dash-note-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: .75rem; flex-wrap: wrap;
}
.dash-note-head .dash-note-title { margin: 0; flex: 1 1 12ch; min-width: 0; }
.dash-note-head .dash-note-chips { margin-bottom: 0; flex: 0 0 auto; }
/* A note badge that CHANGES the note (2026-07-31, QA point 7 round 2, item 4). <details> is the
   house dropdown pattern (.row-menu, .head-menu) — no JS, so the nonce-based CSP stays clean. */
.badge-menu { position: relative; display: inline-block; }
.badge-menu > summary { cursor: pointer; list-style: none; user-select: none; }
.badge-menu > summary::-webkit-details-marker { display: none; }
.badge-menu-caret { margin-left: .22rem; font-size: .85em; opacity: .8; }
/* ⚠️ `width: max-content` replaces a flat `min-width: 9rem` (2026-08-07, the user: "the width of
   the dropdown should be related to the longest text that it contains"). The floor padded every
   panel to 144px regardless of content — measured 144px for widest options of 80px, 71px and 58px,
   so the emptier the menu, the more dead space it carried.
   ⚠️ `max-width` is not optional. Type and state names are the COMPANY'S vocabulary (Rule 11), so
   an option can be arbitrarily long; without a ceiling one verbose state would push the panel past
   the note pane. Past the cap the label ellipsizes instead.
   Stays `right: 0` at the user's explicit call — the panel hangs left from its badge, which is
   what makes it read as belonging to that badge. */
.badge-menu-panel {
  position: absolute; z-index: 40; top: calc(100% + 4px); right: 0;
  width: max-content; max-width: 15rem;
  display: flex; flex-direction: column; gap: .15rem; padding: .25rem;
  background: var(--bg-panel-2); border: 1px solid var(--border-2); border-radius: 8px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .18);
}
.badge-menu-item {
  background: none; border: 0; padding: .2rem .3rem; text-align: left; cursor: pointer;
  border-radius: 5px;
  /* nowrap is what makes max-content mean "the longest LINE" rather than "the longest word";
     the ellipsis is the graceful end of the max-width cap above. */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge-menu-item:hover { background: var(--hover); }
/* Inside the badge row since 2026-08-02 (the user asked for the circles in front of the other
   badges), so it carries no margins of its own any more — the row's gap spaces it. */
.dash-note-people { display: inline-flex; align-items: center; gap: .35rem; margin: 0; }
.dash-note-people-arrow { color: var(--text-muted); font-size: .8rem; }
/* One member as a circle of initials (2026-07-31, QA point 7 round 2, item 5). Tinted from the
   member's ROLE via the shared badge palette, so it matches the role badges beside it.
   ⚠️ The fill MUST be composed with oklch(), fixed 2026-08-02 (QA round 3). `badge-c-<name>` sets
   `--badge-h`/`--badge-ch` to the hue and chroma NUMBERS (240, .04) — not colours. This rule used
   to say `background: var(--badge-h); color: var(--badge-ch)`, i.e. `background: 240`, which is
   invalid, so the browser dropped BOTH declarations: a correctly shaped circle with no fill and
   default-coloured initials, which reads as "the circles are missing". Recipe copied from
   .dash-msg-avatar, the app's other filled initials circle. */
.member-avatar {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.55rem; height: 1.55rem; border-radius: 50%; flex: 0 0 auto;
  font-size: .62rem; font-weight: 700; letter-spacing: .3px;
  background: oklch(var(--badge-l) var(--badge-ch) var(--badge-h));
  color: var(--on-brand);
}
/* Full pane width (2026-08-02, the user's QA round 3) — the 72ch prose measure it used to carry
   left the right of the pane empty, and the user asked for the width instead.
   `overflow-wrap: anywhere` is the other half of the same report: the box did not CONTAIN long
   text. A note body is user input, so it can be one unbroken 300-character token (a URL, a stack
   frame, a pasted path), and normal wrapping only breaks at spaces — without this such a line
   runs straight out of the rounded box. `anywhere` rather than `break-word` so the long token is
   also allowed to shrink the box's min-content width instead of forcing the pane wider. */
.dash-note-body {
  background: var(--bg-detail); border: 1px solid var(--border-2); border-radius: 10px;
  padding: .9rem 1.05rem; font-size: 13.5px; color: var(--text-2); margin-bottom: 1.1rem;
  overflow-wrap: anywhere; min-width: 0;
}
.dash-note-stamp {
  display: flex; align-items: center; gap: .4rem;
  font-size: 11px; color: var(--text-muted); margin-bottom: .35rem; letter-spacing: .5px;
}
/* Author-only inline edit of the note body (2026-07-31, QA point 7 round 2, item 6). */
.dash-note-desc-edit {
  background: none; border: 0; padding: 0 .1rem; cursor: pointer; line-height: 1;
  color: var(--text-muted); display: inline-flex; align-items: center;
}
.dash-note-desc-edit:hover { color: var(--text); }
.dash-note-desc-input {
  width: 100%; resize: vertical; font: inherit; color: var(--text-2);
  background: var(--bg-panel-2); border: 1px solid var(--border-2); border-radius: 7px;
  padding: .5rem .6rem;
}
.dash-note-desc-actions { display: flex; gap: .4rem; margin-top: .5rem; }

.dash-filterbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .9rem; }
.dash-filterbar select {
  background: var(--bg-panel-2); color: var(--text-2); border: 1px solid var(--border-2);
  border-radius: 8px; padding: .3rem .55rem; font: inherit; font-size: 12.5px; cursor: pointer;
}
/* Show buttons + the Unread toggle (2026-08-12). The three Show buttons reuse `.dash-pill`, the
   rail's own quick-filter skin — same control, same look, nothing new to learn. Mobile-first:
   `.dash-filterbar` is already flex-wrap above, so this only adds the tap target (Rule 12). */
/* ⚠️ No 44px tap-target floor here, deliberately. The dashboard is a DESKTOP-ONLY three-pane
   screen (see the .dash-panes comment above: a 730px floor, overflowing below ~800px, its nav
   link hidden under 640px — the user's decision, 2026-07-20). A 44px minimum would apply only at
   widths where this screen is already unusable, while making these controls taller than the
   selects beside them and than the rail's own .dash-pill. Matching the surrounding controls is
   the right call on a screen phones never reach. */
.dash-showgroup { display: flex; flex-wrap: wrap; gap: .35rem; }
.dash-unread-toggle {
  display: inline-flex; align-items: center; gap: .35rem;
  background: var(--bg-panel-2); border: 1px solid var(--border-2); color: var(--text-3);
  border-radius: 999px; padding: .26rem .65rem;
  font: inherit; font-size: 11.5px; font-weight: 600; cursor: pointer;
}
/* ⚠️ var(--accent), never var(--brand): --brand is the fixed orange --accent DEFAULTS to, so a
   --brand rule looks correct until the user moves the accent picker. */
.dash-unread-toggle.active {
  background: var(--accent); border-color: var(--accent); color: var(--on-accent);
}
.dash-unread-n { font-variant-numeric: tabular-nums; }
/* The attribute dropdowns sit right, away from the scope controls (his ask, 2026-08-12).
   ⚠️ `margin-left:auto` is safe HERE only because no sibling in this bar grows: an auto margin
   eats free space before a `flex-grow` sibling gets any, which is how the list eye ended up
   starved once before. If anything in this bar ever gains flex-grow, this becomes a bug. */
.dash-filterbar-right { display: flex; flex-wrap: wrap; gap: .5rem; margin-left: auto; }
/* Nothing unread: dim the control rather than hiding it, so the bar does not reflow as notes are
   read — a row of buttons that changes width under you is worse than a quiet zero. */
.dash-unread-toggle.is-empty { color: var(--text-muted); opacity: .65; }
.dash-unread-toggle.is-empty .dash-unread-eye { filter: grayscale(1); }
.dash-mid-h2 { font-size: 14.5px; margin: 0 0 .4rem; }
.dash-mid-empty-note { color: var(--text-muted); font-size: 12.5px; }
/* Full pane width (2026-08-02, QA point 7 round 3). The row used to carry `max-width: 84ch` from
   the 2026-07-20 dashboard shell — a prose readability cap, written before the row grew a path,
   badges and a description line. Those make it a DATA row, where the cap just left ~15% of the
   pane empty on the right.
   TWO lines, not three (2026-08-02, item 3.2): the path, then everything else on one line. The row
   is a COLUMN now — it used to be a wrapping row where `flex-basis: 100%` on the path and the
   description made line breaks emerge from the wrap, and a wrapping container cannot hold a line
   that must not wrap. */
.dash-lrow {
  display: flex; flex-direction: column; gap: .15rem;
  /* 25% off the original .55rem/.3rem, the user's call on 2026-08-02. Kept as exact quarters
     rather than rounded to .4/.22, so the ratio to every other row in the app stays legible. */
  border-top: 1px solid var(--line); padding: .4125rem .225rem; cursor: pointer; border-radius: 7px;
}
.dash-lrow:hover { background: var(--hover); }
/* Path left, the assignee's circle at the right end (2026-08-02, QA round 3). It carries no
   `margin-left: auto` — the path GROWS instead, for the same reason .dash-lrow-chips doesn't: an
   auto margin would eat the free space before flex-grow ran. Right-aligned here and on the line
   below, so the circle sits directly over the badges. */
.dash-lrow-pathrow { display: flex; align-items: center; gap: .55rem; min-width: 0; }
/* The meta cluster at the right end of the path line (2026-08-20): "Last edited <when> by (ST)
   · assigned to (PY)". It replaced the lone assignee circle that sat here from 2026-08-02.
   📌 HIS CALL: the spec's mock drew this on a third line and its prose said the path line — he
   chose the path line, so the row stays TWO lines. He had asked for two "to save space".
   `flex: 0 0 auto` + `nowrap` so the PATH absorbs every bit of pressure — the path already
   ellipsizes and this cluster must not, because half a timestamp is worse than no timestamp.
   Same 11px muted treatment as the path, so the line still reads as one quiet row of metadata. */
.dash-lrow-meta {
  display: flex; align-items: center; gap: .35rem; flex: 0 0 auto;
  color: var(--text-muted); font-size: 11px; white-space: nowrap;
}
.dash-lrow-edited,
.dash-lrow-assigned { display: flex; align-items: center; gap: .3rem; }
.dash-lrow-sep { opacity: .55; }
/* The editor's circle can carry a ✎ badge — see the narrow-width steps below. */
.dash-lrow-editor { position: relative; display: inline-flex; }
.dash-lrow-pencil { display: none; }

/* ── Narrow widths (2026-08-20, his call, after measuring) ─────────────────────────────────────
   The cluster needs ~295px and `.dash-lrow-path` cannot shrink past zero, so the row spilled from
   ~1100px down — 58px over at 1024 and 300px at the 730 floor, where the old row was clean until
   730. Two steps, and BOTH hide visually only.
   🔴 Never `display: none` here. That would drop the sentence out of the accessibility tree as
   well as off the screen; clipping keeps "Last edited <when> by <name>" readable to a screen
   reader at every width, which is the whole reason the words are marked up separately at all. */
/* 🔴 CONTAINER queries since 2026-08-20, not media queries. These used to watch the VIEWPORT,
   which was the same thing as the pane's width only while the panes were a fixed 330/1fr/400.
   Now that the member can drag them, a narrow middle pane on a wide monitor would spill the
   cluster without any media query ever firing.
   📌 The thresholds are MEASURED, not converted by arithmetic: at viewport 1100px the mid pane is
   370px, and at 950px it is 220px — so these reproduce the old behaviour exactly at the default
   widths, while now tracking the pane itself. */
@container dash-mid (max-width: 370px) {
  .dash-lrow-label {
    position: absolute; width: 1px; height: 1px; margin: -1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
}
/* Step 2 — the timestamp goes too, and the ✎ takes its place ON the editor's circle. His idea:
   "add a small pensil on the (ST) … this way we'll know at least who edited the note last."
   Without it the two circles are indistinguishable once the words are gone. */
@container dash-mid (max-width: 220px) {
  .dash-lrow-edited > time {
    position: absolute; width: 1px; height: 1px; margin: -1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
  .dash-lrow-sep { display: none; }
  .dash-lrow-pencil {
    display: block; position: absolute; right: -3px; bottom: -3px;
    font-size: 9px; line-height: 1; padding: 1px;
    border-radius: 50%; background: var(--bg); color: var(--text-muted);
  }
}
/* Smaller here than anywhere else (2026-08-02, QA round 3, the user's call). The pane-wide 1.55rem
   circle is taller than the 11px path text it shares a line with, so it cost the row 8px of the
   height 3.2 had just saved. Sized to the BADGES directly below it instead, so the two
   right-aligned columns read as one. */
.dash-lrow .member-avatar { width: 1.25rem; height: 1.25rem; font-size: .5rem; letter-spacing: 0; }
.dash-lrow-path {
  color: var(--text-muted); font-size: 11px;
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The single line. `nowrap` is the whole point; `min-width: 0` lets the children below actually
   shrink instead of forcing the row wider than the pane.
   `overflow: hidden` is the floor, not the mechanism: the description absorbs all the pressure
   down to a ~1200px viewport (measured — a 416px pane, description squeezed 384px → 84px), but
   below that it is already at zero and the badges cannot shrink, so the line would spill OVER the
   discussion pane. This clips it at the row's own edge instead. */
.dash-lrow-main {
  display: flex; align-items: center; gap: .55rem; flex-wrap: nowrap; min-width: 0;
  overflow: hidden;
}
/* Shrinks only AFTER the description has collapsed, so a long title can't push the badges out of
   view — but it still ellipsizes rather than overflowing. */
.dash-lrow-title {
  font-size: 13.5px; font-weight: 600; flex: 0 1 auto; min-width: 4ch;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Badges and the clip keep their intrinsic width at every pane size; the badges finish the line.
   ⚠️ NO `margin-left: auto` here to push them right — an auto margin swallows all positive free
   space BEFORE flex-grow is applied, which would starve `.dash-lrow-desc` (basis 0) to nothing and
   make every description invisible. The description's own `flex-grow` is what right-aligns these,
   which is why it renders even when the note has no text. */
.dash-lrow-chips { display: flex; gap: .35rem; flex: 0 0 auto; }
.dash-lrow .note-clip { flex: 0 0 auto; margin-left: 0; }
/* The note's own text on its own line (2026-07-31, QA point 7 round 2, item 10). `truncatechars`
   caps what SHIPS; `text-overflow` handles "not enough space" at whatever width the pane ends up,
   which server-side truncation alone can't know. */
/* The eye rides list rows too (2026-07-31, item 9); it keeps the tree row's COLOURS but not its
   positioning. ⚠️ `position: static` is load-bearing and was missing until 2026-08-02: the base
   `.dash-note-eye` is `position: absolute` (it is pinned into the tree row's indent), and an
   out-of-flow element ignores `flex`, so this override did nothing on its own. `.dash-lrow` is not
   a positioned ancestor either, so every list eye escaped its row and stacked in the corner of
   whatever ancestor was positioned. Measured, not read: a Playwright probe put all ten eyes at
   top=4.8px while their rows sat 146px+ down the page. */
.dash-lrow .dash-note-eye { position: static; flex: 0 0 auto; margin-right: .1rem; }
/* The open note's eye — HIS "lets make it bigger": 20px against the tree/list 14px slot.
   🔴 `position: static` is load-bearing here for exactly the reason recorded above the list-row
   rule: the base `.dash-note-eye` is `position: absolute`, so without this the eye leaves the flow
   and pins itself to the corner of whatever ancestor happens to be positioned — the bug that put
   ten list eyes at top=4.8px while their rows sat 146px down the page. The size lives on BOTH the
   button and its svg because the base rule sizes the svg explicitly. */
.dash-note-eye-lg { position: static; flex: 0 0 auto; width: 20px; height: 20px; }
.dash-note-eye-lg svg { width: 20px; height: 20px; }
/* `flex: 1 1 0` — takes the leftover space and nothing more, so it is the ONLY elastic part of the
   line and the first thing to give way as the pane narrows; everything else keeps its intrinsic
   width. Doubles as the spacer that pushes the badges to the right edge (see .dash-lrow-chips). */
.dash-lrow-desc {
  flex: 1 1 0; min-width: 0; font-size: 13px; color: var(--text-muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.dash-chat {
  background: var(--bg-detail); border-left: 1px solid var(--border-2);
  display: flex; flex-direction: column;
  position: relative;   /* the "New messages" jump anchors to the pane, not the thread */
}
/* Floats just above the composer, centred. Outside the thread on purpose: inside it, it would
   scroll away with the messages — which is exactly when you need it. */
.dash-jump {
  position: absolute; left: 50%; transform: translateX(-50%); z-index: 5;
  bottom: 4.2rem; cursor: pointer;
  display: inline-flex; align-items: center; gap: .3rem;
  padding: .28rem .7rem; border-radius: 999px; font-size: 11.5px; font-weight: 600;
  background: var(--accent); color: var(--on-accent); border: 0;
  box-shadow: 0 6px 18px rgba(0, 0, 0, .28);
}
.dash-jump[hidden] { display: none; }
.dash-jump:hover { filter: brightness(1.06); }
.dash-chat-head { padding: .8rem 1rem; border-bottom: 1px solid var(--line); flex: none; }
.dash-chat-label { font-size: 10.5px; letter-spacing: .6px; color: var(--text-muted); font-weight: 700; }
.dash-chat-empty { color: var(--text-muted); font-size: 12.5px; text-align: center; margin: auto; padding: 1rem; }

/* ═══ Dashboard discussion pane (2026-07-22, v1 backend piece ②) ══════════════════════════════
   Bubbles reuse the badge hue vars: `badge-c-<key>` on the <li> sets --badge-h/--badge-ch, which
   inherit to the bubble/avatar below — so all 25 palette colours work with no per-colour rule. */
.dash-chat-title { font-size: 12.5px; color: var(--text-3); margin-top: .2rem; }
/* min-height:0 lets the thread shrink so the compose box below it is never pushed out of the
   pane on a short window (flex children won't shrink past their content without it). */
.dash-thread {
  list-style: none; margin: 0; padding: .8rem 1rem;
  overflow-y: auto; flex: 1 1 auto; min-height: 0;
}
.dash-msg { display: flex; gap: .55rem; margin-bottom: .9rem; }
.dash-msg-avatar {
  width: 26px; height: 26px; border-radius: 50%; flex: none; display: grid; place-items: center;
  font-size: 11px; font-weight: 800; color: var(--on-brand);
  background: oklch(var(--badge-l) var(--badge-ch) var(--badge-h));
}
.dash-msg-main { min-width: 0; }
.dash-msg-meta {
  display: flex; align-items: center; gap: .4rem; flex-wrap: wrap;
  font-size: 11px; color: var(--text-muted); margin-bottom: .2rem;
}
.dash-msg-meta b { color: var(--text-2); font-size: 12px; }
.dash-msg-time { color: var(--text-muted); }
.dash-msg-edited { font-style: italic; }
.dash-msg-act { background: none; border: 0; color: var(--text-muted); cursor: pointer; font-size: 12px; padding: 0 .1rem; }
.dash-msg-act:hover { color: var(--text-2); }
.dash-msg-bubble {
  font-size: 13px; color: var(--text-2); line-height: 1.4;
  border-left: 3px solid oklch(var(--badge-l) var(--badge-ch) var(--badge-h));
  background: oklch(var(--badge-l) var(--badge-ch) var(--badge-h) / 0.09);
  border-radius: 0 8px 8px 0; padding: .4rem .6rem; word-wrap: break-word;
}
/* The quote inside a bubble (2026-08-20): a button that does not look like one. */
.dash-msg-quote {
  display: block; width: 100%; text-align: left;
  margin-bottom: .3rem; padding: .25rem .45rem;
  border: 0; border-left: 3px solid var(--accent);
  background: var(--hover); border-radius: 5px;
  font: inherit; font-size: 11.5px; color: var(--text-muted);
  cursor: pointer; overflow: hidden;
}
.dash-msg-quote:hover { color: var(--text-2); }
/* Two lines since 2026-08-20 (his call): the name on its own, the message under it. ⚠️ The
   ellipsis moved OFF the button and onto each line — a nowrap button with one overflow rule
   truncated the pair as a single run, which is what put them side by side in the first place. */
.dash-msg-quote b,
.dash-msg-quote > span {
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dash-msg-quote b { color: var(--text-2); line-height: 1.35; }
.dash-msg-quote > span { line-height: 1.35; }
/* The tombstone: a <span>, so it is not focusable and not clickable — and it must not LOOK it. */
.dash-msg-quote.is-gone {
  cursor: default; font-style: italic; border-left-color: var(--line);
}
/* The jump's flash (2026-08-20).
   🔴 It animates the ALPHA of the bubble's own oklch tint, never `opacity` or a flat colour. The
   bubble's background IS the author's role hue at 9% — fading the element would wash that hue
   out, and a flat colour would visibly snap back at the end because it would not land on the
   bubble's real resting value. This ends exactly on 0.09, which is that value. */
.dash-msg.is-flash .dash-msg-bubble { animation: msg-flash 1.2s ease-out; }
@keyframes msg-flash {
  0%   { background: oklch(var(--badge-l) var(--badge-ch) var(--badge-h) / 0.38); }
  100% { background: oklch(var(--badge-l) var(--badge-ch) var(--badge-h) / 0.09); }
}
.dash-msg-empty { list-style: none; color: var(--text-muted); font-size: 12.5px; padding: .5rem; }
/* The in-place editor (2026-08-07, the user's four-point ask). Scoped to .dash-msg-editing on
   purpose: `.dash-msg-main` is shared with every read-only bubble, and giving IT flex:1 would
   stretch every message in the thread to full width — a change to the whole chat that was never
   asked for. Only the row being edited grows. */
.dash-msg-editing .dash-msg-main { flex: 1; }
.dash-msg-editing .dash-compose-input { width: 100%; display: block; }
/* space-between puts Cancel on the textarea's left edge and Save on its right (the buttons are
   the only two children, so they land on the extremes without any margin trickery — an auto
   margin here would starve the sibling, the trap from the 2026-07 round-3 pass). */
.dash-msg-editacts {
  display: flex; gap: .4rem; margin-top: .35rem; justify-content: space-between;
}
.dash-compose {
  /* `wrap` since 2026-08-20: the reply strip is a full-width row ABOVE the field, and this is a
     flex ROW — without wrapping it would sit beside the textarea instead of over it. */
  display: flex; flex-wrap: wrap; gap: .5rem; align-items: flex-end; padding: .7rem 1rem;
  border-top: 1px solid var(--line); flex: none;
}
/* Reply mode's strip (2026-08-20). `flex: 0 0 100%` is what puts it on its own line above the
   field; the `hidden` attribute is what hides it, and the [hidden] rule below is REQUIRED because
   `display: flex` here would otherwise override the attribute's own display:none. */
.dash-compose-reply {
  flex: 0 0 100%;
  display: flex; align-items: center; gap: .5rem;
  padding: .3rem .5rem; margin-bottom: .1rem;
  border-left: 3px solid var(--accent);
  background: var(--hover); border-radius: 5px;
  font-size: 11.5px; color: var(--text-muted); min-width: 0;
}
.dash-compose-reply[hidden] { display: none; }
/* Two lines, matching the bubble's quote (2026-08-20, his call). ⚠️ The ellipsis lives on EACH
   line, not on the wrapper — a nowrap wrapper truncates the name and the message as one run,
   which is exactly what kept them side by side. */
.dash-compose-reply-text { flex: 1 1 auto; min-width: 0; overflow: hidden; }
.dash-compose-reply-author,
.dash-compose-reply-body {
  display: block; line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dash-compose-reply-author { color: var(--text-2); }
.dash-compose-reply-cancel {
  flex: 0 0 auto; background: none; border: 0; cursor: pointer;
  color: var(--text-muted); font-size: 12px; padding: 0 .15rem;
}
.dash-compose-reply-cancel:hover { color: var(--text-2); }
/* The field wraps the textarea so the emoji toggle can sit INSIDE it (2026-08-02, QA point 13,
   the user's placement), and takes over the textarea's flex role in the row. */
.dash-compose-field { flex: 1 1 auto; position: relative; display: flex; min-width: 0; }
.dash-compose-input {
  flex: 1 1 auto; resize: vertical; min-height: 2.1rem; max-height: 8rem;
  /* room for the toggle, so typed text never runs underneath it */
  padding-right: 2rem;
  background: var(--bg-panel-2); border: 1px solid var(--border-2); border-radius: 8px;
  color: var(--text-2); padding: .45rem .6rem; font: inherit; font-size: 13px;
}
.dash-compose-send { flex: none; }

/* ── Emoji picker (2026-08-02, QA point 13) ──────────────────────────────────────────────
   A <details>, the house dropdown pattern — it opens with no JS at all, so the nonce-based CSP
   stays clean; app.js only inserts the character and closes the menu. */
.emoji-menu { position: absolute; right: .3rem; bottom: .3rem; }
.emoji-toggle {
  list-style: none; cursor: pointer; user-select: none;
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.5rem; height: 1.5rem; border-radius: 6px;
  font-size: .95rem; line-height: 1; opacity: .55;
}
.emoji-toggle::-webkit-details-marker { display: none; }
.emoji-toggle:hover, .emoji-menu[open] .emoji-toggle { opacity: 1; background: var(--hover); }
/* Opens UPWARD: the composer sits at the bottom of the pane, so a panel below would fall off
   the screen. Right-aligned to its toggle for the same reason. */
/* ⚠️ Sized against the chat pane, which is a FIXED 400px grid column (.dash-panes), so these
   numbers are stable at every viewport width. Measured: anchored at `right: 0` the panel's right
   edge lands 307px into that pane, so 307px is the widest it can be before the LEFT edge escapes —
   and the pane is `overflow-y: auto`, which makes overflow-x compute to auto and CLIP it rather
   than let it spill. The negative `right` borrows the gap over the Send button (the panel floats
   above the compose row, so overhanging it is free), buying room for 10 columns plus a margin.

   ⚠️ 2026-08-07, the user's call, tuned by eye: +5px wide and 10px left, then 4px back right, then
   2px more. A further 1px was tried and **reverted at his call** — the net is **+5px wide, 4px
   left** of where it used to sit, and that last pixel is a settled question. He sees a
   little horizontal scroll in the panel on a second monitor; it does NOT reproduce here (measured
   with all 10 tabs and 888 emoji loaded, under both overlay and classic scrollbars: the grid needs
   270.3px of 289.2px available, the tab bar 259.7px of 289px, and nothing overflows), so these are
   HIS numbers, not derived ones — noted so nobody later "corrects" them back to a measurement that
   was taken on different hardware.
   ⚠️ The left edge is the tight one now that the panel is wider. The anchor at `-12px` puts the
   escape threshold at 319px against a 309px panel — ~10px of clearance, asserted by
   `live_emoji_panel_fit_audit.py`. Widening further without moving the anchor right WILL clip, and
   clipping does not look "too wide": the first emoji column simply vanishes. */
.emoji-panel {
  position: absolute; z-index: 60; bottom: calc(100% + 6px); right: calc(-1rem + 4px);
  padding: .4rem; border-radius: 10px; width: calc(19rem + 5px);
  background: var(--bg-panel-2, var(--surface)); border: 1px solid var(--border-2, var(--line));
  box-shadow: 0 10px 30px rgba(0, 0, 0, .28);
}
.emoji-loading { display: block; padding: 1rem; font-size: .78rem; color: var(--text-muted); }

/* ── Tabs, with NO JavaScript (2026-08-02) ───────────────────────────────────────────────────
   Radio inputs + sibling selectors. The radios are hidden but must stay in the DOM and BEFORE
   both the tabbar and the pages for `~` to reach them.
   ⚠️ Matched by POSITION (nth-of-type), never by key, so EMOJI_GROUPS can be reordered or renamed
   without touching this — but the number of groups must not exceed the rules below. */
.emoji-tab-radio { position: absolute; opacity: 0; pointer-events: none; }
.emoji-tabbar {
  display: flex; gap: .1rem; margin-bottom: .35rem; padding-bottom: .35rem;
  border-bottom: 1px solid var(--line); overflow-x: auto;
}
.emoji-tab {
  flex: 0 0 auto; cursor: pointer; border-radius: 6px; padding: .15rem .25rem;
  font-size: .9rem; line-height: 1.4; opacity: .5;
}
.emoji-tab:hover { opacity: .85; background: var(--hover); }
.emoji-page { display: none; grid-template-columns: repeat(10, 1.6rem); gap: .1rem; }
.emoji-pages { max-height: 12.5rem; overflow-y: auto; }
.emoji-tab-radio:nth-of-type(1):checked ~ .emoji-pages > .emoji-page:nth-child(1),
.emoji-tab-radio:nth-of-type(2):checked ~ .emoji-pages > .emoji-page:nth-child(2),
.emoji-tab-radio:nth-of-type(3):checked ~ .emoji-pages > .emoji-page:nth-child(3),
.emoji-tab-radio:nth-of-type(4):checked ~ .emoji-pages > .emoji-page:nth-child(4),
.emoji-tab-radio:nth-of-type(5):checked ~ .emoji-pages > .emoji-page:nth-child(5),
.emoji-tab-radio:nth-of-type(6):checked ~ .emoji-pages > .emoji-page:nth-child(6),
.emoji-tab-radio:nth-of-type(7):checked ~ .emoji-pages > .emoji-page:nth-child(7),
.emoji-tab-radio:nth-of-type(8):checked ~ .emoji-pages > .emoji-page:nth-child(8),
.emoji-tab-radio:nth-of-type(9):checked ~ .emoji-pages > .emoji-page:nth-child(9),
.emoji-tab-radio:nth-of-type(10):checked ~ .emoji-pages > .emoji-page:nth-child(10),
.emoji-tab-radio:nth-of-type(11):checked ~ .emoji-pages > .emoji-page:nth-child(11),
.emoji-tab-radio:nth-of-type(12):checked ~ .emoji-pages > .emoji-page:nth-child(12) {
  display: grid;
}
.emoji-tab-radio:nth-of-type(1):checked ~ .emoji-tabbar > .emoji-tab:nth-child(1),
.emoji-tab-radio:nth-of-type(2):checked ~ .emoji-tabbar > .emoji-tab:nth-child(2),
.emoji-tab-radio:nth-of-type(3):checked ~ .emoji-tabbar > .emoji-tab:nth-child(3),
.emoji-tab-radio:nth-of-type(4):checked ~ .emoji-tabbar > .emoji-tab:nth-child(4),
.emoji-tab-radio:nth-of-type(5):checked ~ .emoji-tabbar > .emoji-tab:nth-child(5),
.emoji-tab-radio:nth-of-type(6):checked ~ .emoji-tabbar > .emoji-tab:nth-child(6),
.emoji-tab-radio:nth-of-type(7):checked ~ .emoji-tabbar > .emoji-tab:nth-child(7),
.emoji-tab-radio:nth-of-type(8):checked ~ .emoji-tabbar > .emoji-tab:nth-child(8),
.emoji-tab-radio:nth-of-type(9):checked ~ .emoji-tabbar > .emoji-tab:nth-child(9),
.emoji-tab-radio:nth-of-type(10):checked ~ .emoji-tabbar > .emoji-tab:nth-child(10),
.emoji-tab-radio:nth-of-type(11):checked ~ .emoji-tabbar > .emoji-tab:nth-child(11),
.emoji-tab-radio:nth-of-type(12):checked ~ .emoji-tabbar > .emoji-tab:nth-child(12) {
  opacity: 1; background: var(--hover);
}
.emoji-item {
  background: none; border: 0; padding: 0; cursor: pointer; border-radius: 5px;
  width: 1.6rem; height: 1.6rem; font-size: .95rem; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
}
.emoji-item:hover { background: var(--hover); }

/* In-dashboard navigation links (2026-07-20): section names, note titles and breadcrumb nodes
   in the middle pane load fragments via hx-get (no href), so they need an explicit pointer. */
.crumb-link { cursor: pointer; }
.crumb-root { color: var(--text-muted); }

@media (max-width: 640px) {
  .dashboard-link { display: none; }  /* dashboard is desktop-only (user decision, mockup v3) */
}

/* ── Note attachments (dashboard middle pane + the note Edit modal) ─────────────────────── */
/* A flex COLUMN since 2026-08-02 (the user's point 11 round 2): the tile grid below grows to
   fill whatever height is left, so the whole block reads as the drop zone it always was. */
.attachments {
  margin-top: 1.25rem; border-radius: .6rem; outline-offset: 4px;
  display: flex; flex-direction: column; min-height: 0;
}
/* In the dashboard pane it takes the leftover height — but `1 1 auto` with `min-height: 0`
   means a LONG note body wins: the body is sized by its content, this yields. That is the
   'adjust itself if we have a longer note text' half of the ask. The Edit modal has no such
   column, so it simply falls back to content height there. */
.dash-note .attachments { flex: 1 1 auto; min-height: 7rem; }
/* Focusable for paste-scoping: a subtle ring when focused so it's clear where a paste lands. */
.attachments:focus-visible { outline: 2px solid var(--accent); }
.attachments:focus { outline: 2px solid var(--accent); }
/* Drag-over highlight (the only affordance at rest — no permanent drop box). */
.attachments.att-dragover {
  outline: 2px dashed var(--accent); outline-offset: 4px; background: var(--hover);
}
.attachments.att-dragover * { pointer-events: none; }  /* let the drop land on the section */
.att-head {
  font-size: .72rem; letter-spacing: .08em; color: var(--text-3, var(--muted));
  margin: 0 0 .5rem;
}

/* ── Upload progress (2026-08-31, his ask) ──────────────────────────────────────────────── */
/* TWO STATES, and the difference is the point. Determinate while bytes are in flight: the fill's
   width is a real percentage of a real total, written from JS via CSSOM (this app's CSP is
   `style-src 'self'` and refuses a style attribute — see app.js). Indeterminate once the body is
   in and the SERVER is working: `_attach_uploads` re-encodes each image and ClamAV-scans each
   office file sequentially inside the same request, and nothing in the browser can know how far
   along that is — so the bar stops claiming a number and plays its stripes instead.
   ⚠️ --accent, never --brand: --brand is the fixed orange --accent defaults to, so a rule on it
   looks right until the user moves the colour picker. */
.upload-progress { display: flex; flex-direction: column; gap: .3rem; margin: 0 0 .6rem; }
/* 🔴 WITHOUT THIS THE BAR NEVER HIDES. `hidden` is not magic — the UA stylesheet implements it as
   `[hidden] { display: none }`, which an author-level `display: flex` on the same element simply
   outranks. So the bar sat open on every note before any upload and stayed after every one, while
   `box.hidden` reported true the whole time: the JS was right and the CSS ignored it. Caught by
   the audit, never by a unit test — the attribute IS in the markup. */
.upload-progress[hidden] { display: none; }
.up-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
.up-label { font-size: .8rem; color: var(--text-2); }
/* Tabular figures so the number does not jitter the row as it climbs through the digits. */
.up-pct { font-size: .8rem; color: var(--text-2); font-variant-numeric: tabular-nums; }
.up-track {
  position: relative; height: 6px; border-radius: 999px;
  background: var(--hover); overflow: hidden;
}
.up-fill {
  display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent);
  /* Short and LINEAR: an ease would keep animating after the last event and drift ahead of the
     bytes, which is the one thing a progress bar must never do. */
  transition: width .15s linear;
}
.up-detail { margin: 0; font-size: .76rem; color: var(--text-2); }
/* The processing phase (2026-09-01, his call): the bar stays FULL — "like the full progress bar"
   — with diagonal bands running through it from LEFT TO RIGHT. `width` is removed by JS so this
   rule owns it, and only `background-position` animates, which touches no layout.
   🔴 R4 (same day): the bands alternate LIGHTER and DARKER over the accent, his pick. R3 drew a
   faint white-at-32% hatching that every style check saw and he did not: it measured a luminance
   contrast of 1.19–1.30 on the live box, and he reported "when hit 100% nothing happens" while
   the label HAD changed to Processing… — a texture that thin on a 6px bar does not register as
   motion. Light .5 / dark .28 gives ≈1.6–1.8 on every palette accent (yellow is the floor); the
   audit now measures it from a SCREENSHOT (≥ 1.5), which R3's stripe fails at 1.19. And it was a
   step DOWN from R1's accent-on-transparent barber pole, which had far more contrast — motion was
   bought with legibility, which is exactly the trade a style check cannot see.
   ⚠️ A SQUARE tile with hard stops at 25/50/75% is what makes the loop seamless: one cycle moves
   the pattern by exactly one tile. A `repeating-linear-gradient` period does not divide evenly
   into a horizontal shift, and the seam shows as a stutter once per cycle.
   ⚠️ rgba(), not color-mix(): that function appears nowhere else in this stylesheet, and an
   unsupported colour function drops the WHOLE declaration silently — leaving exactly the flat
   full bar he reported on 2026-08-31 ("stays there like an image"). */
.upload-progress.is-indeterminate .up-fill {
  width: 100%; transition: none;
  background-color: var(--accent);
  background-image: linear-gradient(
    -45deg,
    rgba(255, 255, 255, .5) 25%, rgba(0, 0, 0, .28) 25%, rgba(0, 0, 0, .28) 50%,
    rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, .5) 75%, rgba(0, 0, 0, .28) 75%, rgba(0, 0, 0, .28)
  );
  background-size: 16px 16px;
  /* One 16px tile per .4s ≈ 40px/s — R3's 23px/s was too slow to read as movement on a 6px bar. */
  animation: up-stripes .4s linear infinite;
}
/* Left to right: a POSITIVE x shift moves the pattern rightwards. One tile per cycle. */
@keyframes up-stripes {
  from { background-position: 0 0; }
  to   { background-position: 16px 0; }
}
/* 🔴 NO `prefers-reduced-motion` rule here, DELIBERATELY (2026-09-01, his call). His own machine
   asks for reduced motion. On 2026-08-31 this phase rendered there as a static full bar that he
   read as stuck, and the static stripe that replaced it is what he was looking at when he asked
   for the lines to MOVE ("play animation … diagonal lines going through it from left to right").
   A 6px bar's bands moving at ~40px/s is the essential kind of motion — it says "still
   working" — not the decorative kind the preference exists to remove. If that ever changes,
   `animation: none` under a `reduce` media query restores the static stripe; the pattern stays
   either way, so the phase is never a flat fill. */

.att-list {
  list-style: none; margin: 0;
  display: flex; flex-wrap: wrap; gap: .6rem;
  /* Grows into the space the section was given; `align-content: flex-start` keeps the tiles
     packed at the top instead of spreading down the empty area. */
  flex: 1 1 auto; align-content: flex-start; overflow-y: auto; min-height: 0;
  /* ⚠️ This padding is FUNCTIONAL, not decoration. The tiles' pencil and ✕ sit at top/right
     -.4rem — deliberately OUTSIDE the tile — and `overflow-y: auto` makes overflow-x compute to
     auto too, so this list is a CLIP BOX. With padding 0 the top row's buttons were cut off by a
     measured 6px. z-index cannot help: clipping is not a stacking question. The bar above gives
     back the .5rem in margin, so nothing moves. */
  padding: .5rem .5rem 0 0;
}
/* The 'Add files' row, now ABOVE the tiles (QA point 14, folded in here). */
.att-bar {
  display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
  /* .1rem + the list's .5rem top padding = the .6rem gap this used to have on its own. */
  margin: 0 0 .1rem; flex: none;
}
.att-bar .att-hint { margin: 0; }
.att-tile { position: relative; width: 7.5rem; }
/* A <button> since 2026-08-02 (it opens the viewer rather than navigating), so the browser's
   default button chrome has to be reset back to a plain tile. */
.att-tile-link {
  display: flex; flex-direction: column; gap: .3rem;
  text-decoration: none; color: inherit; cursor: pointer;
  background: none; border: 0; padding: 0; font: inherit; text-align: left; width: 100%;
}
.att-thumb {
  width: 7.5rem; height: 5.5rem; object-fit: cover;
  border-radius: .4rem; border: 1px solid var(--line);
}
.att-icon {
  width: 7.5rem; height: 5.5rem; display: grid; place-items: center;
  font-size: 2rem; border-radius: .4rem;
  background: var(--surface); border: 1px solid var(--line);
}
/* Per-type file icon (2026-08-06, the user's ruling R1: "appropriate icon for every type").
   ⚠️ The tint is set ONCE as `color` on the family class below; the outline, the band and the
   letters are all currentColor, so nothing here needs a second colour rule. The letters are filled
   with --surface — the box's own background — so they read in either theme for free. */
.att-icon-glyph { width: 2.9rem; height: auto; display: block; }
.att-icon-page, .att-icon-fold { opacity: .5; }
.att-icon-text {
  font-family: inherit; font-size: 12px; font-weight: 700; letter-spacing: .4px;
  fill: var(--surface);
}
/* Tuned for the DARK default: the real Office brand colours are too dark to read on --surface
   here, so these are the lifted versions and the deeper brand tones live in the light override. */
.att-icon--word       { color: #6ea8fe; }
.att-icon--excel      { color: #57c98a; }
.att-icon--powerpoint { color: #f0913f; }
.att-icon--pdf        { color: #f47171; }
.att-icon--image      { color: #a9b4c7; }
.att-icon--file       { color: var(--text-2, var(--text)); }
/* ⚠️ Deliberately independent of --accent: a file's type is a fact about the file, not a theme
   preference, so it must not shift when the user changes their accent colour. */
[data-theme="light"] .att-icon--word       { color: #2b579a; }
[data-theme="light"] .att-icon--excel      { color: #1d6f42; }
[data-theme="light"] .att-icon--powerpoint { color: #c04a1d; }
[data-theme="light"] .att-icon--pdf        { color: #b3261e; }
[data-theme="light"] .att-icon--image      { color: #5a6675; }
.att-name { font-size: .72rem; word-break: break-word; color: var(--text-2, var(--text)); }
/* The ✕ glyph is scaled up WITHOUT touching the 1.4rem button (2026-08-02, the user's ask): it
   was drawing 7.8px wide inside a 22.4px circle, which is a lot of empty button. `display: grid` +
   `place-items` keeps it optically centred as it grows — a larger glyph on the default inline
   layout drifts off the baseline instead. */
.att-del {
  position: absolute; top: -.4rem; right: -.4rem;
  width: 1.4rem; height: 1.4rem; border: 1px solid var(--line);
  border-radius: 50%; cursor: pointer; line-height: 1;
  display: grid; place-items: center; padding: 0;
  font-size: 1.15rem;
  background: var(--bg-card, var(--surface)); color: var(--text);
}
/* The pencil rides beside the ✕. It is UNGATED — any project member may rename (the user's
   QA point 11 ruling) — whereas .att-del only renders for those who may delete, so the two
   cannot be positioned as a fixed pair. */
.att-rename-open {
  position: absolute; top: -.4rem; right: 1.3rem;
  width: 1.4rem; height: 1.4rem; border: 1px solid var(--line);
  border-radius: 50%; cursor: pointer; line-height: 0;
  display: grid; place-items: center;
  background: var(--bg-card, var(--surface)); color: var(--text-muted);
}
.att-rename-open:hover { color: var(--text); }
/* Both tile actions appear ON HOVER only (2026-08-02, the user's point 11 round 2) — at rest a
   grid of files should read as files, not as a row of controls.
   ⚠️ `:focus-within` is not optional: these are the ONLY route to rename/delete, so hiding them
   from the keyboard would remove the feature for anyone tabbing. `visibility` rather than
   `display` keeps them focusable in the tab order at all times.
   ⚠️ And on a touch screen there is no hover at all, so they stay permanently visible there —
   the note Edit modal is reachable on a phone even though the dashboard is not. */
.att-tile .att-rename-open, .att-tile .att-del {
  opacity: 0; transition: opacity .12s ease;
}
.att-tile:hover .att-rename-open, .att-tile:hover .att-del,
.att-tile:focus-within .att-rename-open, .att-tile:focus-within .att-del {
  opacity: 1;
}
@media (hover: none) {
  .att-tile .att-rename-open, .att-tile .att-del { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .att-tile .att-rename-open, .att-tile .att-del { transition: none; }
}
/* The confirm dialog's body copy. */
.att-confirm-text { margin: 0; font-size: .9rem; color: var(--text-2, var(--text)); }
/* With no delete button there is no ✕ to sit left of, so the pencil takes its corner. */
.att-tile:not(:has(.att-del)) .att-rename-open { right: -.4rem; }
.att-rename { display: flex; flex-direction: column; gap: .35rem; }
.att-rename-input {
  width: 100%; font: inherit; font-size: .78rem; color: var(--text-2, var(--text));
  background: var(--bg-panel-2, var(--surface)); border: 1px solid var(--border-2, var(--line));
  border-radius: 6px; padding: .3rem .4rem;
}
.att-rename-actions { display: flex; gap: .3rem; }
.att-rename-actions .btn, .att-rename-actions .btn-ghost { font-size: .7rem; padding: .18rem .45rem; }

/* ── The viewer's contents ─────────────────────────────────────────────────────────────── */
.att-viewer-title {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
.att-viewer-body {
  display: flex; align-items: center; justify-content: center;
  min-height: min(60vh, 420px); max-height: calc(100vh - 16rem); overflow: auto;
  background: var(--bg-detail, var(--surface)); border: 1px solid var(--line); border-radius: 10px;
}
/* Fits the box without ever exceeding its own natural size, so a small image is not blown up. */
.att-viewer-img { max-width: 100%; max-height: calc(100vh - 16rem); object-fit: contain; }
.att-viewer-frame { width: 100%; height: calc(100vh - 16rem); border: 0; }
/* An office document's derived preview (2026-08-02): the embedded thumbnail beside a short
   text excerpt. Stacks on a narrow dialog. */
.att-viewer-doc {
  display: flex; gap: 1.1rem; align-items: flex-start; flex-wrap: wrap;
  padding: 1.1rem; width: 100%; max-height: calc(100vh - 16rem); overflow-y: auto;
}
.att-viewer-doc-thumb {
  max-width: 260px; max-height: 340px; flex: 0 0 auto;
  border-radius: 8px; border: 1px solid var(--line);
}
.att-viewer-excerpt { flex: 1 1 20ch; min-width: 0; }
.att-viewer-excerpt-head {
  margin: 0 0 .4rem; font-size: 10.5px; letter-spacing: .6px; font-weight: 700;
  color: var(--text-muted); text-transform: uppercase;
}
/* <pre> keeps the extracted line breaks, but the excerpt is prose from a document, so it has
   to wrap rather than scroll sideways — and a long unbroken token must not widen the dialog. */
.att-viewer-excerpt-body {
  margin: 0; font: inherit; font-size: 12.5px; color: var(--text-2, var(--text));
  white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5;
}
.att-viewer-none {
  display: flex; flex-direction: column; align-items: center; gap: .5rem;
  padding: 3rem 1rem; text-align: center; color: var(--text-3, var(--muted));
}
/* Held an emoji until 2026-08-06; now it wraps the shared per-type icon (R1). The old
   `opacity: .55` is gone deliberately — the glyph carries its own on the page outline, and dimming
   it again washed the tint out to something barely distinguishable, which is the whole complaint. */
.att-viewer-none-icon { display: grid; place-items: center; }
.att-viewer-none-icon .att-icon-glyph { width: 3.4rem; }
.att-viewer-none-title { font-size: .92rem; font-weight: 600; color: var(--text-2, var(--text)); }
.att-viewer-none-hint { font-size: .76rem; }
.att-viewer-meta { font-size: .78rem; color: var(--text-3, var(--muted)); }

.att-add { cursor: pointer; }
.att-hint { font-size: .72rem; color: var(--text-3, var(--muted)); margin: .4rem 0 0; }

/* Empty state: a big centered drop zone. In the dashboard pane the block grows to fill the
   remaining height (.dash-note is a full-height flex column); in the Edit modal it falls back to
   its min-height. */
.dash-note .attachments.attachments-empty { flex: 1 1 auto; min-height: 0; }
.attachments.attachments-empty { display: flex; flex-direction: column; }
.att-empty-zone {
  flex: 1 1 auto; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: .55rem;
  min-height: 200px; padding: 2rem 1rem; text-align: center; cursor: pointer;
  border: 1px dashed var(--line); border-radius: .6rem; color: var(--text-3, var(--muted));
}
.att-empty-zone:hover { background: var(--hover); }
.att-empty-icon { width: 52px; height: 52px; opacity: .5; }
.att-empty-title { font-size: .9rem; font-weight: 600; color: var(--text-2, var(--text)); }
.att-empty-hint { font-size: .74rem; color: var(--text-muted); max-width: 34ch; }
.note-clip { font-size: .78rem; color: var(--text-3, var(--muted)); margin-left: .4rem; }
/* Attachment-rejection dialog (#att-error) — reuses .modal-card; this is just its message list. */
#att-error .att-error-list {
  list-style: none; margin: 0 0 1rem; padding: 0;
  font-size: .88rem; color: var(--text-2, var(--text));
}
#att-error .att-error-list li {
  padding: .5rem .7rem; border-radius: .4rem;
  color: #ff9a9a; background: rgba(255,122,122,.12); border: 1px solid #ff7a7a;
}
#att-error .att-error-list li + li { margin-top: .4rem; }

/* Server-side notices in our own layout (2026-08-06) — see base.html. allauth styles its own. */
.messages { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-direction: column; gap: .4rem; }
.messages .msg {
  border: 1px solid var(--line-2); border-radius: 9px; padding: .5rem .8rem; font-size: .84rem;
}
.messages .msg-warning { border-color: rgba(255,122,122,.4); color: #ff9a9a; }

/* The create-note file area (2026-08-06). Built from what {{ form.as_p }} emits — a <label for>,
   the input, and the help text — because a custom widget template would never be FOUND: the form
   renderer's engine searches app templates/ dirs, and this project keeps everything in the
   top-level one. See the spec's §2.
   The input is hidden and the label opens the picker through the native for/id association, so
   the affordance works with NO JavaScript at all. */
p:has(> #id_files) {
  display: flex; align-items: center; flex-wrap: wrap; gap: .6rem; margin: .1rem 0 .9rem;
}
#id_files { display: none; }
#id_files_helptext { font-size: .72rem; color: var(--text-3, var(--muted)); margin: 0; }
/* "3 files selected" — written by app.js, empty until something is picked. */
.att-picked { font-size: .72rem; font-weight: 600; color: var(--text-2, var(--text)); }
