/* Veille — design system.
 *
 * One accent, hairline borders, generous whitespace, a tight type scale, and motion only where it
 * explains a state change. Everything is a custom property so the whole app can be re-themed from
 * this block, and so dark mode is a variable swap rather than a second stylesheet.
 *
 * The palette is the sibling email-editor's (`tmp/email-editor/editor.css`): Padam blue #166dfc on
 * a cool blue-grey ground, 12px radii, soft #f3f5f9 controls. It is a lighter, friendlier surface
 * than the neutral warm-grey it replaces — but the triage screen's hierarchy is unchanged, because
 * that is load-bearing: the article title is the loudest thing on a row and the score stays quiet.
 *
 * Rules that keep it calm:
 *   - one accent colour, used for exactly one thing per screen
 *   - borders are hairlines, never 2px; depth comes from shadow, not from outline weight
 *   - radius is --r everywhere except pills and the full-bleed shell
 *   - no gradient, no glow, no shadow on anything that is not floating above the page
 */

:root {
  color-scheme: light dark;

  /* Neutrals — a graphite ground, not a blue-grey one. The queue's provenance line is read sixty
   * times a morning, and --muted on the old tinted ground was 4.27:1: an AA failure on exactly that
   * line. A neutral ground one step darker buys the contrast back and stops a hue sitting behind
   * every glance. The reading column is the only lit surface on the screen. */
  --bg:        #f4f5f6;   /* queue ground + aside ground (recessed) */
  --surface:   #ffffff;   /* the reading column */
  --surface-2: #fafbfb;
  --sunken:    #eceef0;   /* control troughs, skeletons */

  /* Measured, not chosen. The pair after each is its ratio on --surface and on --bg. */
  --ink:       #14171a;   /* 17.99 / 16.48 */
  --ink-2:     #3f464c;   /*  9.58 /  8.78 — prose, why-line, aside rows */
  --muted:     #5c646b;   /*  6.02 /  5.51 — provenance, band labels, the hour */
  /* An ALIAS, deliberately equal to --muted: no text in this app sits below AA anywhere. Twenty
   * call sites still name --faint; rather than churn all of them, the token stops being a lighter
   * grey. Do not give it a value of its own again. */
  --faint:     #5c646b;

  --line:      #e3e6e8;
  --line-soft: #eceef0;
  /* The margin's spine, and nothing else. It is the only vertical rule in the application, so it is
   * the only hairline allowed to be firmer than a panel's. */
  --line-firm: #cfd4d8;

  /* Ink-weight neutral for anything that floats above the page: tooltips, the toast. De-blued with
   * the ground — a navy chip over a graphite app reads as two products. */
  --navy:      #1f2429;

  /* One accent. Padam blue, deepened until white on it clears AA — #166dfc gave 3.6:1, this gives
   * 5.90:1 — which matters now that the one filled accent surface in the app is the Retenir key. */
  --accent:      #0a5fd0;
  --accent-ink:  #ffffff;
  --accent-soft: #e8f0fd;
  --accent-line: #a8c6f4;

  /* Semantic — used sparingly, never as decoration. --reject is INK ONLY, never a fill: two filled
   * buttons side by side is a dialog, and a dialog asks a question rather than accepting sixty
   * answers a minute. */
  --keep:    #10683a;
  --keep-soft: #e4f3ea;
  --reject:  #c0392b;   /* 4.98:1 on --bg */
  --reject-soft: #fbe6e3;
  --warn:    #7a5600;   /* 6.09:1 — the regulation flag */
  --warn-soft: #fdf3d8;

  /* Type — the system stack, and only the system stack. "DM Sans" used to lead this list and Inter
   * sat inside it, which meant the app rendered in one face on a designer's Mac and another on every
   * machine that had installed neither: two products, one stylesheet, and no way to review either.
   * There is no `@font-face` and no font request anywhere in this app, so the honest default is the
   * face the OS already has — the personality comes from the scale, the tracking and the measure. */
  --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable Text",
          "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  --t-xs:   11px;
  /* Labels, never language: band heads, aside titles, `.card-meta`, `.prose-end`. Always uppercase
   * and tracked, which is what keeps a label from being mistaken for a row of text. */
  --t-caps: 11px;
  --t-sm:   12.5px;
  --t-base: 14px;
  --t-md:   15.5px;
  --t-lg:   19px;
  --t-xl:   25px;
  --t-2xl:  32px;
  /* The reading sizes — the only sizes in this app that exist for *prose* rather than for chrome.
   * 15.5px was the chrome's own size doing double duty; a triager spends minutes a day inside this
   * one block, and the step up is what makes a two-paragraph body read as a short article. */
  --t-read: 18px;
  --t-lede: 20.5px;
  /* The relevance number beside the headline. Big enough to read at a glance from the queue's own
   * column, small enough that it never competes with the headline. */
  --t-mark: 34px;

  --w-normal: 400;
  --w-medium: 510;
  --w-semi:   600;
  --w-bold:   680;   /* the article headline, and nothing else */

  /* Space — a 4px grid. Only these values appear in the app. */
  --s-1: 4px;  --s-2: 8px;  --s-3: 12px; --s-4: 16px;
  --s-5: 20px; --s-6: 24px; --s-8: 32px; --s-10: 40px; --s-16: 64px;

  --r-sm: 8px;
  --r:    12px;
  --r-lg: 16px;
  --r-pill: 999px;

  /* Navy-tinted, not black. On a blue-grey ground a neutral-black shadow reads as dirt. */
  --shadow-1: 0 1px 2px rgba(20, 20, 50, .05), 0 1px 1px rgba(20, 20, 50, .03);
  --shadow-2: 0 6px 18px rgba(20, 20, 50, .10), 0 1px 3px rgba(20, 20, 50, .06);
  --shadow-3: 0 12px 32px rgba(20, 20, 50, .16), 0 2px 8px rgba(20, 20, 50, .06);

  --ease: cubic-bezier(.32, .72, 0, 1);
  --fast: 130ms;
  --med:  200ms;

  --sidebar-w: 232px;
  --rail-w:    64px;
  /* One height for every control on the filter band — see `.filters` below. */
  --ctl-h:     32px;
  --detail-w: minmax(360px, 4fr);

  /* --- the margin ---------------------------------------------------------
   *
   * The one structural idea on the triage screen: a narrow column, left of everything, in which the
   * veille writes its own judgement and nothing else ever goes. Same measure in the queue and in the
   * article, so the two panes agree on where a text column starts — and the hairline at its right
   * edge is the only vertical rule in the app.
   *
   * Two widths because the two panes hold different objects in it: a 15.5px tabular number in the
   * queue, a 34px one plus the band, the themes and the flags in the article. */
  --mark-w:    56px;   /* the queue's margin (40px ≤720) */
  /* Measured, not chosen: the widest band label either catalogue produces is the English
   * "Strong match" at ~97px, and the column has to hold it on one line with the gutter it needs. */
  --mark-w-lg: 132px;  /* the article's margin */
  --mark-gap:  var(--s-4);

  /* The queue's own column and the third column. Both fixed rather than fractional: a queue row that
   * reflows as the window moves makes the whole list feel unstable, and the aside is a reference
   * column — it has one right width and no reason to have another. */
  --queue-w: 440px;
  --aside-w: 320px;

  /* The article's block — margin + gap + a 68ch measure + its two gutters. `.detail-body` and the
   * decision bar both take this width and centre inside the pane, which is what keeps Retenir under
   * the first letter of the headline at every window size, and what turns the slack at 1920 into a
   * page margin instead of a hole against the right edge. */
  --measure: 68ch;
  --read-w:  864px;

  /* Floating surfaces. One width for the facet/space popovers so a row of them lines up, and one
   * max-height so a 4-option facet and a 400-option facet are the same object. */
  --pop-w:      280px;
  --pop-w-sm:   260px;
  --pop-max-h:  340px;

  /* Tooltips are the one surface that inverts: dark chip on a light app. */
  --tip-bg:  var(--navy);
  --tip-ink: #ffffff;

  /* A framed article previewed "as mobile". 390px is the iPhone 14/15 logical width. */
  --phone-w: 390px;

  /* Sync progress. The fill's hue steps with the pipeline stage so a run that holds one stage for
   * minutes still visibly moves. Saturation and lightness are fixed, so only hue changes — that is
   * what keeps it reading as one bar progressing instead of a status light cycling through states.
   *
   * The arc is 32° wide — 204° at `start` to 236° at `done`, four degrees a stage, straddling the
   * accent's own 217°. It stops well short of 250°, which was the first thing tried: at this
   * saturation the last two stages came out violet, and a bar that ends a different colour from the
   * one it started is a status light, not a progress bar. Four degrees is below the step at which a
   * 4px band reads as a new colour rather than as the same one having moved, and the whole span
   * stays inside blue — sky at one end, the accent's own blue at the other. The transition is the
   * slowest in the app: the eye should notice it afterwards, not be pulled to it. */
  --sync-w:      96px;
  --sync-h0:     204;
  --sync-h-step: 4;
  --sync-s:      88%;
  --sync-l:      55%;
  --sync-hue-t:  1200ms;

  /* The schedule picker's label column. A token because the hints that sit *between* its rows have
   * to start at the same x as the controls, or the block reads as two forms interleaved. */
  --sched-label-w: 132px;

  --z-pop: 70;
  --z-tip: 80;
}

/* Derived from the light palette rather than kept from the old near-black theme: the light ground
 * is now blue-grey, so the dark one is navy-tinted too — a neutral #000 shell under a #166dfc
 * accent reads as two different products. Surfaces keep their light-mode *relationships*
 * (--surface-2 and --sunken still recede from --surface) so no rule needs a dark-mode variant. */
@media (prefers-color-scheme: dark) {
  :root {
    --bg:        #101214;
    --surface:   #191c1f;
    --surface-2: #15181a;
    --sunken:    #1f2326;

    /* The pair after each is its ratio on --surface. */
    --ink:       #eceef0;   /* 14.71 */
    --ink-2:     #c9ced2;   /* 10.79 */
    --muted:     #9aa2a8;   /*  6.61 */
    --faint:     #9aa2a8;   /* the same alias as in light mode */

    /* Hairlines have to work harder on a dark ground than on a near-white one: .07 white puts the
     * queue's row rules below the visible threshold and the list reads as one block. */
    --line:      rgba(255, 255, 255, .14);
    --line-soft: rgba(255, 255, 255, .07);
    --line-firm: rgba(255, 255, 255, .22);

    --navy:      #262b2f;

    /* The accent has to lighten to stay legible on a dark ground — and once it does, white on top of
     * it is only 3:1, so filled accent surfaces (Retenir, a pressed pill, a badge) take dark ink. */
    --accent:      #78a9ff;   /* 7.27:1 on --surface */
    --accent-ink:  #10131a;   /* 7.89:1 on the accent */
    --accent-soft: rgba(120, 169, 255, .16);
    --accent-line: rgba(120, 169, 255, .50);

    --keep:      #5fd39a;
    --keep-soft: rgba(95, 211, 154, .14);
    --reject:    #ff9c94;
    --reject-soft: rgba(255, 156, 148, .14);
    --warn:      #e0b055;
    --warn-soft: rgba(224, 176, 85, .14);

    /* A tooltip cannot be darker than the app it floats over. */
    --tip-bg:  #33393e;
    --tip-ink: #ffffff;

    --shadow-1: 0 1px 2px rgba(0, 0, 0, .5);
    --shadow-2: 0 6px 18px rgba(0, 0, 0, .55);
    --shadow-3: 0 12px 32px rgba(0, 0, 0, .7);

    /* Same hue arc, lifted to the ground the accent is lifted to: a 55% blue on #0e0e1f is a
     * bruise. The hue tokens are unchanged, so the progression is identical in both themes. */
    --sync-s: 96%;
    --sync-l: 71%;
  }
}

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

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

html, body {
  height: 100%;
  margin: 0;
}

body {
  font-family: var(--font);
  font-size: var(--t-base);
  line-height: 1.5;
  color: var(--ink);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  overflow: hidden; /* the shell scrolls its panes, never the page */
}

h1, h2, h3, h4, p, figure, ul, ol { margin: 0; }
ul, ol { padding: 0; list-style: none; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}
:focus:not(:focus-visible) { outline: none; }

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

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

.app {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  height: 100vh;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  padding: var(--s-4) var(--s-3);
  background: var(--surface-2);
  border-right: 1px solid var(--line);
  overflow-y: auto;
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-3) var(--s-5);
  font-size: var(--t-md);
  font-weight: var(--w-semi);
  letter-spacing: -0.01em;
}
.brand-mark {
  width: 22px; height: 22px;
  border-radius: var(--r-sm);
  background: var(--accent);
  display: grid; place-items: center;
  color: var(--accent-ink);
  font-size: var(--t-sm);
  font-weight: var(--w-semi);
  flex: none;
}

.nav-label {
  padding: var(--s-4) var(--s-3) var(--s-2);
  font-size: var(--t-xs);
  font-weight: var(--w-semi);
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--faint);
}

.nav-item {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-sm);
  color: var(--ink-2);
  font-size: var(--t-base);
  font-weight: var(--w-medium);
  text-decoration: none;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
  cursor: pointer;
  border: none;
  background: none;
  width: 100%;
  text-align: left;
}
.nav-item:hover { background: var(--line-soft); text-decoration: none; }
.nav-item[aria-current="page"], .nav-item.is-active {
  background: var(--accent-soft);
  color: var(--accent);
}
.nav-item .count {
  margin-left: auto;
  font-size: var(--t-sm);
  font-variant-numeric: tabular-nums;
  color: var(--faint);
}
.nav-item[aria-current="page"] .count { color: var(--accent); }

.sidebar-foot { margin-top: auto; padding-top: var(--s-4); }

.main {
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow: hidden;
}

/* The router's mount point is a column that must fill what the topbar leaves. Without this it
 * sizes to its content, and a short queue leaves the sticky action bar floating mid-screen. */
#view {
  flex: 1;
  min-height: 0;
}

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

/* The sidebar collapsed to a column of icons. It shares the sidebar's ground so the two are
 * interchangeable in the shell grid without anything else moving. */
.app:has(> .rail) { grid-template-columns: var(--rail-w) 1fr; }

.rail {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-1);
  width: var(--rail-w);
  padding: var(--s-3) 0;
  background: var(--surface-2);
  border-right: 1px solid var(--line);
  /* Deliberately not scrollable. `overflow:auto` here would establish a clipping box 64px wide and
   * every tooltip — which lives *outside* that box, to the right — would be cut off at the border.
   * A rail that needs to scroll has too many destinations; that is a content problem, not a CSS one. */
  overflow: visible;
}

/* The application names itself once, at the top of the rail. At 64px the word does not fit, but it
 * stays in the accessibility tree — the mark alone tells a screen reader nothing. */
.rail .brand { padding: var(--s-1) 0 var(--s-2); gap: 0; }
.rail .brand > span:not(.brand-mark) {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.rail-nav { display: flex; flex-direction: column; align-items: center; gap: 2px; }

.rail-item {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  flex: none;
  border: none;
  border-radius: var(--r-sm);
  background: none;
  color: var(--muted);
  cursor: pointer;
  text-decoration: none;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.rail-item:hover { background: var(--line-soft); color: var(--ink); text-decoration: none; }
.rail-item[aria-current="page"], .rail-item.is-active {
  background: var(--accent-soft);
  color: var(--accent);
}
.rail-item svg, .rail-item img { width: 20px; height: 20px; display: block; }

/* A count on a 44px square has nowhere to sit but the corner. Tabular, --faint, and it borrows the
 * rail's own ground as a knockout so it can overlap the glyph without a box of its own. */
.rail-item .count {
  position: absolute;
  top: 3px;
  right: 3px;
  min-width: 15px;
  padding: 0 3px;
  border-radius: var(--r-pill);
  background: var(--surface-2);
  color: var(--faint);
  font-size: 10px;
  font-weight: var(--w-semi);
  font-variant-numeric: tabular-nums;
  line-height: 15px;
  text-align: center;
}
.rail-item[aria-current="page"] .count, .rail-item.is-active .count {
  background: var(--accent);
  color: var(--accent-ink);
}

.rail-sep {
  width: 24px;
  height: 1px;
  flex: none;
  margin: var(--s-2) 0;
  background: var(--line);
  border: none;
}

.rail-foot { margin-top: auto; display: grid; justify-items: center; gap: var(--s-1); }

/* -------------------------------------------------------------- tooltip -- */

/* Pure CSS: the label is a child of the control it names, so it positions against that control and
 * nothing has to measure anything at runtime. Shown on hover *and* on keyboard focus — a rail with
 * no text labels is unusable to anyone tabbing through it if the tip is mouse-only. */
.tip {
  position: absolute;
  left: calc(100% + var(--s-2));
  top: 50%;
  z-index: var(--z-tip);
  padding: 4px 9px;
  border-radius: var(--r-sm);
  background: var(--tip-bg);
  color: var(--tip-ink);
  font-size: var(--t-xs);
  font-weight: var(--w-medium);
  line-height: 1.45;
  white-space: nowrap;
  box-shadow: var(--shadow-2);
  opacity: 0;
  pointer-events: none;
  transform: translate(-3px, -50%);
  transition: opacity var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.tip::before {
  content: "";
  position: absolute;
  right: 100%;
  top: 50%;
  margin-top: -4px;
  border: 4px solid transparent;
  border-right-color: var(--tip-bg);
}
/* `:focus-visible`, and `:has(:focus-visible)` for a trigger whose focusable part is a child — never
 * plain `:focus-within`. That is what this rule used to say, and it is why clicking a rail item left
 * its tooltip on screen until you clicked somewhere else: a mouse click on an `<a>` or a `<button>`
 * focuses it, `:focus-within` matches that focus, and the tip stayed lit with the pointer 500px away.
 * `:focus-visible` is the selector that means "focused *and* the user is navigating by keyboard", so
 * it draws the tip for the person who needs it and not for the person who just clicked. Measured on
 * `[data-testid="rail-triage"]` after a click with the pointer moved off: `:focus-within` true,
 * `:focus-visible` false. */
:hover > .tip,
:focus-visible > .tip,
:has(:focus-visible) > .tip { opacity: 1; transform: translate(0, -50%); }

/* In the rail the tooltip is not a label, it is a teaching surface: the word the icon replaced, in
 * bold, and one sentence saying what the screen is for. Two lines, so it has to wrap — which is the
 * only reason this override exists. */
.rail .tip {
  white-space: normal;
  overflow: visible;
  width: max-content;
  max-width: 240px;
  padding: var(--s-2) var(--s-3);
  text-align: left;
}
.rail .tip b {
  display: block;
  font-size: var(--t-sm);
  font-weight: var(--w-semi);
}
.rail .tip > span {
  display: block;
  margin-top: 1px;
  font-weight: var(--w-normal);
  opacity: .72;
}
/* An open popover replaces the tooltip: two floating layers over one trigger is noise. */
.rail-foot [aria-expanded="true"] > .tip { display: none; }

/* --------------------------------------------------------------- avatar -- */

/* Popovers are placed against their trigger's wrapper. `:has` means markup can use whatever
 * wrapper element it likes — the anchor is inferred from the popover itself. */
.rail-foot,
.pop-anchor,
:has(> .avatar-menu),
:has(> .space-menu) { position: relative; }

.avatar {
  display: grid;
  place-items: center;
  /* `.tip` is `position: absolute` and anchors to the nearest positioned ancestor. Without this it
     found `.rail-foot`, so the avatar's tooltip and the space switcher's were both drawn at the
     vertical centre of the *foot* — measured 28px above the avatar and 19px below the switcher, i.e.
     in the gap between the two controls, pointing at neither. */
  position: relative;
  width: 34px;
  height: 34px;
  flex: none;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  font-size: var(--t-sm);
  font-weight: var(--w-semi);
  text-transform: uppercase;
  cursor: pointer;
  /* The ring is the rail's own ground, so the bubble reads as lifted off it rather than outlined. */
  box-shadow: 0 0 0 3px var(--surface-2), var(--shadow-1);
  transition: box-shadow var(--fast) var(--ease);
}
.avatar:hover { box-shadow: 0 0 0 3px var(--surface-2), var(--shadow-2); }

/* Anchored above, because the avatar is at the foot of the rail. Popovers here are shown by
 * removing `hidden` — the same one-attribute pattern every existing view already uses — so no rule
 * below needs a state class. */
.avatar-menu, .space-menu, .ms-panel {
  position: absolute;
  z-index: var(--z-pop);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r);
  box-shadow: var(--shadow-3);
  animation: pop-in var(--fast) var(--ease);
}
@keyframes pop-in { from { opacity: 0; transform: translateY(4px); } }

.avatar-menu {
  bottom: calc(100% + var(--s-2));
  left: 0;
  min-width: 224px;
  padding: var(--s-1);
}

.menu-head { padding: var(--s-2) var(--s-3) var(--s-3); display: grid; gap: 1px; }
.menu-name  { font-size: var(--t-sm); font-weight: var(--w-semi); color: var(--ink); }
.menu-email { font-size: var(--t-xs); color: var(--muted); }
.menu-head .menu-name, .menu-head .menu-email {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.menu-sep { height: 1px; margin: var(--s-1) var(--s-1); background: var(--line); border: none; }
.menu-item {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  padding: 6px var(--s-3);
  border: none;
  border-radius: var(--r-sm);
  background: none;
  color: var(--ink);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}
.menu-item:hover { background: var(--accent-soft); color: var(--accent); text-decoration: none; }
.menu-item.is-danger { color: var(--reject); }
.menu-item.is-danger:hover { background: var(--reject-soft); color: var(--reject); }
.menu-credit {
  padding: var(--s-2) var(--s-3) var(--s-1);
  font-size: var(--t-xs);
  color: var(--muted);
  text-align: center;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The raw CI build identifier — same tokens as .menu-credit, one step more muted, so it never
   competes with it for attention. */
.menu-build {
  padding: 0 var(--s-3) var(--s-2);
  font-size: var(--t-xs);
  color: var(--muted);
  opacity: 0.7;
  text-align: center;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* --------------------------------------------------------------- spaces -- */

.space-switch {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  max-width: 220px;
  min-width: 0;
  padding: 5px var(--s-2) 5px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  color: var(--ink);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  cursor: pointer;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.space-switch:hover { background: var(--sunken); }
.space-switch > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.space-switch::after {
  content: "";
  width: 11px; height: 11px;
  flex: none;
  margin-left: auto;
  background: var(--faint);
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
  mask: var(--icon-chevron) center / contain no-repeat;
}
.space-switch[aria-expanded="true"] { border-color: var(--accent-line); background: var(--accent-soft); }

/* The switcher is a topbar-shaped pill by default — a bordered row with a chevron. In a 64px rail
 * it stands up instead: the space's initials over its name, small enough to read as a label. */
.rail-foot .space-switch {
  /* Same reason as `.avatar` above — its `.tip` was anchoring to `.rail-foot`. The popover panels are
     siblings of these controls rather than children, so positioning them changes nothing about how
     the menus are placed. */
  position: relative;
  flex-direction: column;
  gap: 2px;
  width: 48px;
  max-width: 48px;
  padding: 4px 2px;
  border-color: transparent;
  background: none;
}
.rail-foot .space-switch:hover { background: var(--line-soft); }
.rail-foot .space-switch::after { display: none; }   /* no room, and the tooltip already says it */

.rail-foot .space-mark {
  display: grid;
  place-items: center;
  width: 28px; height: 28px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--t-xs);
  font-weight: var(--w-semi);
}
.rail-foot .space-switch[aria-expanded="true"] .space-mark {
  border-color: var(--accent-line);
  color: var(--accent);
}
.rail-foot .space-name {
  max-width: 100%;
  font-size: 9.5px;
  line-height: 1.2;
  font-weight: var(--w-normal);
  color: var(--muted);
}

.space-menu {
  top: calc(100% + var(--s-2));
  left: 0;
  width: var(--pop-w);
  max-height: var(--pop-max-h);
  padding: 0;
  overflow: hidden;
}
/* `nav.js` renders the switcher in one place only — `.rail-foot` — so that is the one anchoring
 * this file describes. The downward default above is what a `position: absolute` panel does with
 * no `bottom`; from the foot of the rail it would open into the bottom edge of the window, so it
 * is turned over. The 280px panel juts out over the queue, which is the point: 64px of rail has no
 * room for a workspace name, and the panel is the only place one can be read. */
.rail-foot .space-menu {
  top: auto;
  bottom: calc(100% + var(--s-2));
  left: 0;
}
/* The search field is sticky rather than fixed above the list so a workspace list short enough not
 * to scroll does not pay for a divider it never needs. */
.space-menu .search {
  position: sticky;
  top: 0;
  z-index: 1;
  flex: none;
  width: auto;
  padding: var(--s-2);
  background: var(--surface);
  border-bottom: 1px solid var(--line-soft);
}
.space-menu .search .input { padding-left: 30px; }
.space-menu .search::before { left: 20px; }

.space-list { flex: 1; min-height: 0; overflow-y: auto; padding: var(--s-1); }
.space-opt {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  padding: 6px var(--s-2);
  border: none;
  border-radius: var(--r-sm);
  background: none;
  color: var(--ink);
  font-size: var(--t-sm);
  text-align: left;
  cursor: pointer;
}
.space-opt > span:first-of-type { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.space-opt:hover { background: var(--line-soft); }
/* `aria-current`, not `aria-selected`: the latter is only valid on a descendant of a listbox/tablist,
   and these are plain buttons in a `role="dialog"` panel — so a screen reader announced nothing and
   the current space was conveyed by this tint alone. `nav.js` marks it with `aria-current="true"` now,
   and the two must be renamed together or the tint silently disappears. */
.space-opt[aria-current="true"] {
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: var(--w-medium);
}

/* Roles are context, not status: a quiet grey chip, never one of the semantic tints, which are
 * reserved for keep / reject / regulation. */
.chip {
  display: inline-flex;
  align-items: center;
  flex: none;
  padding: 1px 7px;
  border-radius: var(--r-pill);
  background: var(--sunken);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--muted);
  font-size: var(--t-xs);
  font-weight: var(--w-medium);
  white-space: nowrap;
}
.space-opt[aria-current="true"] .chip { background: var(--surface); color: var(--accent); }

.space-foot {
  flex: none;
  padding: var(--s-1);
  border-top: 1px solid var(--line-soft);
  background: var(--surface);
}
.space-foot .menu-item { color: var(--accent); }

.space-menu .empty-line {
  padding: var(--s-4) var(--s-3);
  color: var(--muted);
  font-size: var(--t-sm);
  text-align: center;
}

/* A block inside a menu that is not a menu row — the veille `<select>`, the language segments. It
 * takes the menu's own gutter so its left edge agrees with the rows above and below it. */
.menu-pad { padding: var(--s-1) var(--s-2) var(--s-2); }
.menu-pad .segmented { width: 100%; }
.menu-pad .segmented button { flex: 1; }
.menu-pad .select { width: 100%; }

/* --------------------------------------------------------------- topbar -- */

/* `position`/`z-index` are here for one reason: `backdrop-filter` makes this bar a stacking context
 * whatever else it does, and a non-positioned stacking context paints with the in-flow blocks, in
 * tree order — so `#view`, which comes after it, drew over anything that escaped the bar downwards.
 * That is invisible until something does escape: the sync bar's tooltip came out with its numbers
 * behind the search field. `z-index: 1` puts the bar in the positioned layer, above the view and
 * still below the rail's own tooltips (80), the drawer scrim (49) and the toasts (60), all of which
 * sit in the root stacking context rather than in this one. */
.topbar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-5);
  border-bottom: 1px solid var(--line);
  background: color-mix(in srgb, var(--surface) 80%, transparent);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  min-height: 52px;
  flex: none;
}
/* The title and the subtitle are the only two things in this bar that give up width. A veille's
 * name and its description are both unbounded — the demo workspace's description is 96 characters
 * — and the bar does not scroll, so without `min-width: 0` a flex item with `nowrap` refuses to
 * shrink below its text and pushes the count and the actions off the right edge instead. A title
 * clipped to an ellipsis is still a title; a Sync button past the window edge is unreachable. */
/* The title never shrinks, for the same reason the actions do not: there is prose beside it that can
 * give the width up instead. Left on the default `flex: 0 1 auto` it shrank in proportion to its own
 * base size alongside the description, and at 1024 both lost — measured `Veille marketi…` in a 121px
 * box next to a 565px fragment of a 832px sentence. The name of the veille is what tells the reader
 * which queue they are triaging; the description is context they can read in the aside. So the
 * description is the one that truncates, and the ellipsis it already carries is the whole fix. */
.topbar h1 {
  flex: none;
  min-width: 0;
  font-size: var(--t-md);
  font-weight: var(--w-semi);
  letter-spacing: -0.01em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.topbar-sub {
  min-width: 0;
  font-size: var(--t-sm);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.topbar-spacer { flex: 1; }
/* The other half of that bargain: an action never shrinks, so "Synchroniser" never becomes
 * "Synchron…" while there is prose beside it that could have given the width up instead. */
.topbar > .btn { flex: none; }

/* "42 / 317", rendered into the topbar's action row. It is metadata that changes on every
 * keystroke, sitting among buttons that do not — so it is deliberately not button-shaped: no box,
 * no border, tabular so the digits do not jitter as the number narrows. */
.topbar-count {
  flex: none;
  padding-right: var(--s-1);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* -------------------------------------------------------------- controls - */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  padding: 6px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  color: var(--ink);
  font-size: var(--t-base);
  font-weight: var(--w-medium);
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease),
              transform var(--fast) var(--ease), opacity var(--fast) var(--ease);
}
.btn:hover  { background: var(--sunken); }
.btn:active { transform: scale(.97); }
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }

.btn-primary {
  background: var(--accent);
  border-color: transparent;
  color: var(--accent-ink);
}
.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, black); }

.btn-quiet { border-color: transparent; background: transparent; color: var(--ink-2); }
.btn-quiet:hover { background: var(--line-soft); }

.btn-keep   { color: var(--keep); }
.btn-keep:hover   { background: var(--keep-soft); border-color: var(--keep-soft); }
.btn-reject { color: var(--reject); }
.btn-reject:hover { background: var(--reject-soft); border-color: var(--reject-soft); }

.btn-sm { padding: 3px var(--s-2); font-size: var(--t-sm); }
.btn-icon { padding: 6px; width: 30px; }

.input, .select, .textarea {
  width: 100%;
  padding: 7px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  color: var(--ink);
  font-size: var(--t-base);
  line-height: 1.5;
  transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
/* Without `appearance: none` the OS paints the whole control: on a dark theme Chromium draws a
 * near-white field with black text, which is a light rectangle sitting on a black page. Painting
 * it ourselves is the only way the token swap reaches the one control that is not ours. */
.select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: var(--s-8);
  background-image: var(--icon-chevron);
  background-repeat: no-repeat;
  background-position: right var(--s-2) center;
  background-size: 14px;
  cursor: pointer;
}
.input::placeholder { color: var(--faint); }

/* A two-digit field that is 400px wide reads as a mistake. Cap it to its content. */
.input[type="number"] { max-width: 13ch; }

input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }

/* The browser's own disclosure triangle is the one glyph on the page nobody chose. */
summary { display: flex; align-items: center; gap: var(--s-2); cursor: pointer; list-style: none; }
summary::-webkit-details-marker { display: none; }
summary::before {
  content: "";
  width: 9px; height: 9px;
  flex: none;
  background: currentColor;
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
  mask: var(--icon-chevron) center / contain no-repeat;
  transform: rotate(-90deg);
  transition: transform var(--fast) var(--ease);
}
details[open] > summary::before { transform: none; }
.input:focus, .select:focus, .textarea:focus {
  outline: none;
  border-color: var(--accent-line);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.textarea { resize: vertical; min-height: 140px; font-family: var(--mono); font-size: var(--t-sm); line-height: 1.6; }

.search {
  position: relative;
  flex: none;
  width: 220px;
}
.search .input { padding-left: 30px; }
.search::before {
  content: "";
  position: absolute;
  left: 10px; top: 50%;
  width: 12px; height: 12px;
  margin-top: -6px;
  background: var(--faint);
  -webkit-mask: var(--icon-search) center / contain no-repeat;
  mask: var(--icon-search) center / contain no-repeat;
  pointer-events: none;
}

/* **One band, not four.**
 *
 * Status tabs, search, four facet dropdowns and a Période chip used to stack into four horizontal
 * strips before a single article appeared — 460px of an 844px phone spent on furniture the reader
 * touches once a week, and about 150px of a laptop.
 *
 * They are one 44px line now. The trick is `display: contents` on the two wrappers: their testids
 * are a published interface and the phone needs them back as real boxes, so they stay in the markup
 * and stop being boxes here. **Neither `.filters-main` nor `.filters-facets` has a box above 720px.**
 * Every control becomes a direct flex item of `.filters`, which means one gap governs the whole row
 * and one `flex-wrap` handles a window too narrow for it — the row grows a second line instead of
 * pushing the status tabs off the right edge.
 *
 * It must **wrap and stay visible**, never scroll: a facet panel opens downwards out of this row, and
 * any scroll container here (which `overflow-x` silently makes, by forcing `overflow-y: auto` too)
 * would slice that panel off at the bar's bottom edge. */
.filterband { flex: none; }
.filters {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-3);
  padding: var(--s-2) var(--s-5);
  border-bottom: 1px solid var(--line);
  min-height: 44px;
}
.filters-main, .filters-facets { display: contents; }
.filters .search { flex: 0 1 240px; width: auto; min-width: 140px; }
/* The segmented control, the dropdown triggers and the search field are built from four different
 * padding recipes and came out 31 / 27 / 24 / 37px tall — four misaligned boxes rather than one bar.
 * Each gives up its own vertical padding to a single row height. */
.filters .segmented,
.filters .space-switch,
.filters .btn,
.filters .ms-trigger,
.filters .period > summary,
.filters .select { min-height: var(--ctl-h); }
.filters .search .input,
.filters > .input { height: var(--ctl-h); padding-block: 0; }
/* The spacer only earns its keep on one line. Once the row wraps it would be a full-width blank
 * line pushing Clear onto a line of its own. */
.filters .topbar-spacer { flex: 1 1 var(--s-8); }
/* Above 720px the narrowings are always showing: the width is there, and a filter you cannot see is
 * a filter you forget is on. The disclosure button is the phone's affordance and nothing else's. */
.filters-toggle { display: none; }

/* The favori filter (TODO.md item Q). It sits beside the status tabs rather than behind `Filtres`
 * — it is a narrowing a reader reaches for as often as the search box beside it, so it is never
 * collapsed. `flex: 0 0 auto` for the same reason `.filters-toggle` has it: this is one of the
 * controls that must not be the thing giving way when the tab strip wants the room. */
.fav-filter { flex: 0 0 auto; gap: var(--s-1); }
.fav-filter[aria-pressed="true"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft); }

/* What the search just did, and which articles a date range is hiding. Two quiet notes and no
 * controls — Période moved onto the filter row — so the strip only exists when it has something to
 * say. `:has` rather than `:empty`, because the markup arrives with whitespace in it. */
.queue-head {
  display: flex;
  gap: var(--s-3);
  align-items: center;
  flex-wrap: wrap;
  padding: var(--s-2) var(--s-5);
  border-bottom: 1px solid var(--line-soft);
}
.queue-head:not(:has(> *)) { display: none; }

/* The pill row this replaced. Kept because `.facets` is still emitted elsewhere, and because the
 * `:has(.ms)` escape below is the safety net if a dropdown ever lands back inside it. */
.facets {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  overflow-x: auto;
  scrollbar-width: none;
  /* Fades the last 24px so a row that continues past the edge says so. Over content that stops
   * short the gradient falls on empty space and is invisible, so it costs nothing. */
  -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), transparent 100%);
  mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), transparent 100%);
}
.facets:empty { display: none; }
.facets::-webkit-scrollbar { display: none; }
/* `overflow-x: auto` forces `overflow-y: auto` too, which makes this row a clipping box — and a
 * facet dropdown opens *downwards, out of it*, so it would be sliced off at the filter bar's
 * bottom edge. Once the facets are `.ms` triggers the row no longer needs to scroll: that was a
 * fix for pill rows whose length grew with the dataset, and one trigger per facet does not. */
.facets:has(.ms) {
  overflow: visible;
  -webkit-mask-image: none;
  mask-image: none;
  flex-wrap: wrap;
  row-gap: var(--s-2);
}

.facet-group {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex: none;
}
.facet-group + .facet-group {
  margin-left: var(--s-1);
  padding-left: var(--s-3);
  border-left: 1px solid var(--line-soft);
}
/* Micro-caps, the same grammar as the sidebar's section labels — so it reads as a label rather
 * than as a pill someone disabled. */
.facet-label {
  font-size: var(--t-xs);
  font-weight: var(--w-semi);
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--faint);
  white-space: nowrap;
}

.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease),
              border-color var(--fast) var(--ease);
}
.pill:hover { background: var(--sunken); }
.pill[aria-pressed="true"] {
  background: var(--accent);
  border-color: transparent;
  color: var(--accent-ink);
}
.pill .count { font-variant-numeric: tabular-nums; opacity: .65; }

/* The email-editor's segmented control: a soft --sunken trough inside a hairline, with the pressed
 * segment lifted back out to --surface. The hairline is what stops it dissolving into the filter
 * bar now that the trough and the page ground are only a few points apart. */
.segmented {
  display: inline-flex;
  padding: 2px;
  gap: 2px;
  background: var(--sunken);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  flex: none;
}
.segmented button, .bodyswitch button {
  padding: 3px 12px;
  border: none;
  border-radius: calc(var(--r-sm) - 3px);
  background: transparent;
  color: var(--ink-2);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.segmented button[aria-pressed="true"] {
  background: var(--surface);
  color: var(--ink);
  box-shadow: var(--shadow-1);
}
.segmented button:hover:not([aria-pressed="true"]),
.bodyswitch button:hover:not([aria-pressed="true"]) { color: var(--ink); }

/* The account-menu language picker (TODO.md item T): four options with a flag each no longer fit
 * `.segmented`'s single row, so it is a 2x2 grid of its own pill buttons instead — same tokens,
 * same pressed/hover behaviour, just not constrained to one line. */
.lang-picker {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 4px;
}
.lang-opt {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.lang-opt span:first-child { font-size: var(--t-md); line-height: 1; }
.lang-opt[aria-pressed="true"] {
  background: var(--accent-soft);
  border-color: var(--accent-line);
  color: var(--accent-ink);
}
.lang-opt:hover:not([aria-pressed="true"]) { color: var(--ink); border-color: var(--line-soft); }

/* ---------------------------------------------------------- multiselect -- */

/* One dropdown per facet, replacing a row of pills that was linear in the size of the dataset. The
 * trigger is pill-shaped so the filter bar keeps one grammar; everything expensive — the options,
 * their counts, the search over them — moves into a panel that is a fixed size whether the facet
 * has four values or four hundred. */
.ms { position: relative; display: inline-flex; flex: none; }

.ms-trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 8px 3px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease),
              border-color var(--fast) var(--ease);
}
.ms-trigger::after {
  content: "";
  width: 11px; height: 11px;
  flex: none;
  background: currentColor;
  opacity: .55;
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
  mask: var(--icon-chevron) center / contain no-repeat;
}
.ms-trigger:hover { background: var(--sunken); color: var(--ink); }
.ms-trigger-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* Two different facts, two different states. Open = the panel is on screen. Active = this facet is
 * currently narrowing the queue, which stays true after the panel closes and is the one the eye
 * needs when scanning the bar for "why am I only seeing eleven articles".
 *
 * `:not([hidden])` is load-bearing: clearing the last value does not remove the badge, it hides it
 * — so a bare `:has(.ms-badge)` would leave the trigger accented over an empty facet forever. */
.ms-trigger[aria-expanded="true"] { border-color: var(--accent-line); color: var(--ink); }
.ms-trigger:has(.ms-badge:not([hidden])) {
  background: var(--accent-soft);
  border-color: var(--accent-line);
  color: var(--accent);
}

.ms-badge {
  display: inline-grid;
  place-items: center;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: var(--r-pill);
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 10px;
  font-weight: var(--w-semi);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* Left-anchored, always. `multiselect.js` measures nothing and sets no alignment attribute, so
 * there is no flip to describe here — the facet triggers are left-packed in a wrapping row and the
 * rightmost of four sits ~400px into a 1440px bar, which a 280px panel clears easily. The case
 * that does break is a 420px screen, where no anchor against a trigger fits; that is handled once,
 * in the narrow block at the bottom of this file, rather than guessed at here. */
.ms-panel {
  top: calc(100% + var(--s-2));
  left: 0;
  width: var(--pop-w);
  max-height: var(--pop-max-h);
  overflow: hidden;
}

/* The field is a sibling of the list, not a child of it, so it is a flex header rather than
 * `position: sticky` — it stays in reach at every scroll position because the list scrolls *under*
 * it, which is the same guarantee for none of the cost. `.input` gives it the field recipe; what is
 * overridden here is the box: inside a popover a second bordered rectangle 1px inside the panel's
 * own border is two frames around one control. */
.ms-panel > .ms-search {
  flex: none;
  width: 100%;
  padding: 7px var(--s-3);
  border: none;
  border-bottom: 1px solid var(--line-soft);
  border-radius: var(--r) var(--r) 0 0;
  font-size: var(--t-sm);
}
.ms-panel > .ms-search:focus {
  outline: none;
  border-bottom-color: var(--accent-line);
  box-shadow: none;
}
/* Chromium paints a grey ✕ inside `type="search"` that duplicates the Clear button in the foot. */
.ms-search::-webkit-search-cancel-button { display: none; }

.ms-list { flex: 1; min-height: 0; overflow-y: auto; padding: var(--s-1); overscroll-behavior: contain; }

.ms-opt {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  padding: 5px var(--s-2);
  border: none;
  border-radius: var(--r-sm);
  background: none;
  color: var(--ink);
  font-size: var(--t-sm);
  text-align: left;
  cursor: pointer;
}
/* The tick is a `<span>`, not a checkbox — the row is a `role="option"` and putting a real input
 * inside it would give the panel a second focusable thing per row for the keyboard to walk past.
 * So the box is drawn: a hairline square that fills with the accent when the option is selected. */
.ms-check {
  position: relative;
  flex: none;
  width: 14px; height: 14px;
  border-radius: 4px;
  box-shadow: inset 0 0 0 1px var(--line);
  background: var(--surface);
  transition: background var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
.ms-check::after {
  content: "";
  position: absolute;
  left: 4px; top: 1px;
  width: 4px; height: 8px;
  border: solid var(--accent-ink);
  border-width: 0 1.8px 1.8px 0;
  transform: rotate(42deg) scale(.4);
  opacity: 0;
  transition: opacity var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.ms-opt[aria-selected="true"] .ms-check {
  background: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}
.ms-opt[aria-selected="true"] .ms-check::after { opacity: 1; transform: rotate(42deg) scale(1); }

.ms-opt-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Right-aligned and tabular: down forty rows the counts form a column that can be compared, which
 * is the whole reason to show them. */
.ms-count {
  flex: none;
  margin-left: auto;
  padding-left: var(--s-2);
  color: var(--faint);
  font-size: var(--t-xs);
  font-variant-numeric: tabular-nums;
}
.ms-opt:hover { background: var(--line-soft); }
.ms-opt[aria-selected="true"] {
  color: var(--accent);
  font-weight: var(--w-medium);
}
.ms-opt[aria-selected="true"] .ms-count { color: inherit; opacity: .7; }

/* The keyboard cursor is real focus — ↑↓ call `.focus()` on a `tabindex="-1"` row — and it has to
 * be told apart from hover, because the two move independently: the pointer resting anywhere over
 * the panel would otherwise erase any sign of where Enter is about to land. Hover is a fill; the
 * cursor is a *ring*, which survives being drawn on top of a hovered or a selected row. The global
 * `:focus-visible` outline is suppressed here because it sits outside the row's box and clips
 * against the list's scroll edge; this ring is inset, so it does not. */
.ms-opt:focus {
  outline: none;
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.ms-opt:focus .ms-count { color: var(--accent); }

/* The country picker (`components/country-select.js`) borrows every class above so the two dropdowns
 * are the same object on screen. It needs exactly these two rules and no others.
 *
 * `.is-active` is the same ring as `:focus`, for a cursor that is not focus. A combobox keeps focus in
 * its text field and points at the current row with `aria-activedescendant`, so the row never matches
 * `:focus` and would otherwise have nothing to show — the arrow keys would move an invisible cursor. */
.ms-opt.is-active {
  outline: none;
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.ms-opt.is-active .ms-count { color: var(--accent); }

/* France and the UK are pinned above the alphabet. Without a line there, the first two rows look like
 * a sort that failed rather than a shortcut that was chosen. `role="presentation"` in the markup keeps
 * it out of the listbox's children, which may only be options. */
.cs-sep {
  height: 1px;
  margin: var(--s-1) 0;
  background: var(--line);
}

.ms-empty { padding: var(--s-5) var(--s-3); color: var(--muted); font-size: var(--t-sm); text-align: center; }

/* "showing 200 of 3 000" — an apology, not a status. It sits between the list and the foot, quiet
 * and centred, and it says the search field is the way past it. */
.ms-note {
  flex: none;
  padding: var(--s-2) var(--s-3);
  border-top: 1px solid var(--line-soft);
  color: var(--faint);
  font-size: var(--t-xs);
  line-height: 1.4;
  text-align: center;
}

.ms-foot {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex: none;
  padding: var(--s-2) var(--s-2) var(--s-2) var(--s-3);
  border-top: 1px solid var(--line-soft);
  background: var(--surface);
}
/* Clear is a link, not a button: it is destructive-ish, it is one word, and a bordered control in
 * the foot of a popover competes with the options above it for the same click. */
.ms-clear {
  flex: none;
  padding: 0;
  border: none;
  background: none;
  color: var(--accent);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  cursor: pointer;
}
.ms-clear:hover:not(:disabled) { text-decoration: underline; }
.ms-clear:disabled { color: var(--faint); cursor: default; text-decoration: none; }

/* The upper bound on what the current selection matches. Right-aligned against Clear so the two
 * ends of the foot are a verb and a number rather than two competing labels. */
.ms-matching {
  margin-left: auto;
  color: var(--muted);
  font-size: var(--t-xs);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* --------------------------------------------------------------- period -- */

/* Same popover language as `.ms`, but the shipped control is a `<details>` — the `<summary>` *is*
 * the trigger and the panel is shown by the element's own `open` state, so there is no
 * `[aria-expanded]` and no `.period-trigger` element to hang a box on. The box goes on `summary`.
 *
 * `triage.js` also carries `.period-menu`'s geometry as an inline `style` attribute, which beats
 * everything below. The full recipe is written out anyway, so that deleting those inline styles —
 * which that file's own comment asks the next person to do — is a no-op rather than a regression. */
.period { position: relative; flex: none; }

.period > summary {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  /* Matches `.segmented`'s box exactly: 3px + 1px border + 2px trough = the same 28px as a segment,
   * so the two controls share a baseline in a filter row instead of nearly sharing one. */
  padding: 3px var(--s-2) 3px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  white-space: nowrap;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease),
              border-color var(--fast) var(--ease);
}
/* The reset draws the disclosure marker as a leading `::before`. On a filter control the chevron
 * trails the value it opens, like every other trigger in the bar — so it is moved to the end
 * rather than redeclared as a second glyph. `.ms-trigger` brings its own, so the summary that wears
 * that class drops this one instead of showing two. */
.period > summary::before { order: 1; margin-left: var(--s-1); opacity: .55; }
.period > summary.ms-trigger::before { display: none; }
.period > summary:hover { background: var(--sunken); color: var(--ink); }
.period[open] > summary { border-color: var(--accent-line); color: var(--ink); }

/* Right-anchored. Période is the last control on the filter band, so a left-anchored panel opened
 * 93px past the right edge of a 1024px window — and `#view` is an `overflow: hidden` box, so it was
 * silently guillotined rather than scrolling. The facet dropdowns to its left keep opening leftwards;
 * each control opens inward from the edge it is nearest. */
.period-menu {
  position: absolute;
  z-index: var(--z-pop);
  top: 100%;
  left: auto;
  right: 0;
  display: grid;
  gap: var(--s-1);
  width: max-content;
  min-width: var(--pop-w-sm);
  max-width: calc(100vw - var(--s-8));
  margin-top: var(--s-2);
  padding: var(--s-1);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r);
  box-shadow: var(--shadow-3);
  animation: pop-in var(--fast) var(--ease);
}

/* Four preset buttons on one line, then a caption/field grid for the custom pair. Both are real
 * classes now: this geometry used to ride along as `style=""` attributes emitted from `triage.js`,
 * which is how the period popover ended up as the only control in the app with no rule in this file. */
.period-presets { display: flex; align-items: center; gap: var(--s-1); }
.period-dates {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: var(--s-1) var(--s-2);
  padding-top: var(--s-2);
  /* "Presets end here, a range begins." */
  border-top: 1px solid var(--line-soft);
}

/* The presets are `.btn.btn-quiet` in a row, with no trough behind them the way `.segmented` has
 * one — so the chosen period is marked by a fill. And by a tick as well: on a row where every
 * option is one word, colour alone is a weak signal. `.btn`'s own gap spaces it. */
.period-menu .btn { font-size: var(--t-sm); }
.period-menu .btn[aria-pressed="true"] {
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: var(--w-medium);
}
.period-menu .btn[aria-pressed="true"]::after { content: "✓"; }
/* Two date fields side by side in a 260px popover; without this they take the `.input` default of
 * `width: 100%` and the menu grows to whatever Chromium thinks a date field is worth. */
.period-menu .input { width: 100%; min-width: 0; }

/* ---------------------------------------------------------------- triage - */

/* THE MARGIN.
 *
 * The one structural idea on this screen. Every row of the queue, and the article beside it, is two
 * columns: a narrow margin in which the veille writes what *it* thinks, and then the piece. Nothing
 * else is ever allowed in the margin — not the date, not the source, not a chip — which is what makes
 * it readable as a column rather than as a first field. Sixty relevance numbers in one vertical line
 * can be compared at a glance; the same sixty scattered along sixty meta rows cannot be compared at
 * all, which is why the ranking that is the whole point of this app used to appear nowhere in the
 * list. The column is closed by `--line-firm`, the only vertical rule in the application.
 *
 * Two columns up to 1560px, three above it. The queue is a fixed width rather than a fraction: a row
 * whose title reflows every time the window moves makes the whole list feel unstable. What a bigger
 * screen buys is therefore not a wider queue and not a wider measure — a 120-character line is
 * *harder* to read — but a column the app had no room for: the veille's own reasoning. */
.triage {
  display: grid;
  grid-template-columns: var(--queue-w) minmax(0, 1fr);
  min-height: 0;
  flex: 1;
}
.aside-host { display: none; }

@media (min-width: 1560px) {
  .triage[data-aside="on"] {
    grid-template-columns: var(--queue-w) minmax(0, 1fr) var(--aside-w);
  }
  .triage[data-aside="on"] .aside-host {
    display: block;
    overflow-y: auto;
    border-left: 1px solid var(--line);
    background: var(--bg);
  }
}
/* At this width the veille's description has a column of its own to be read in (the aside's first
 * block), so it stops riding along in the topbar as a clipped single line beside the title. Said
 * once, not twice. The topbar is outside `#view`, so this cannot be a combinator on `.triage`; the
 * width is the same fact either way. */
@media (min-width: 1560px) { .topbar-sub { display: none; } }
@media (min-width: 1800px) { :root { --queue-w: 480px; --aside-w: 360px; --t-read: 19px; } }
/* Below this the queue gives width back rather than holding a measure the pane cannot afford. */
@media (max-width: 1280px) { :root { --queue-w: 340px; } }

.queue {
  overflow-y: auto;
  border-right: 1px solid var(--line);
  scroll-behavior: smooth;
  /* The spine is painted as a background gradient on the *list*, not only on the rows, so the rule
   * runs past the last card to the foot of the column instead of stopping mid-air. A hard-stop
   * gradient rather than a border, because a border here would be on the wrong edge. */
  background:
    linear-gradient(to right, transparent calc(var(--mark-w) + var(--s-5)),
      var(--line-firm) calc(var(--mark-w) + var(--s-5)),
      var(--line-firm) calc(var(--mark-w) + var(--s-5) + 1px), transparent 0) no-repeat;
}
/* Trash has no numbers in the margin, so it has no column to close. */
.queue:has([data-testid="card"] .card-mark:empty) { background: none; }

/* ------------------------------------------------------------ the margin -- */

.card-mark {
  display: flex;
  justify-content: flex-end;
  align-self: start;
  padding-right: var(--mark-gap);
  min-height: 1.32em;
}

/* The number. Its *ink* steps with the band — nothing here is ever coloured, and nothing here is
 * ever a bar. A tint would turn the app's single accent into a five-stop scale, and a bar would make
 * sixty rows into sixty gauges; a weight-and-grey step does the same work with type.
 *
 * `—` for unscored, at `--muted`: the dash *means* "never scored", it carries an `aria-label` saying
 * so, and it is most of the real corpus. It recedes from the three scored steps by weight rather than
 * by dropping below AA. A 0 would be a claim the machine never made. */
.mark-num {
  font-size: var(--t-md);
  line-height: 1.32;
  letter-spacing: -0.006em;
  font-feature-settings: "tnum" 1, "cv01" 1;
}
.mark-num[data-band="strong"]   { color: var(--ink);   font-weight: var(--w-semi); }
.mark-num[data-band="related"]  { color: var(--ink-2); font-weight: var(--w-semi); }
.mark-num[data-band="weak"]     { color: var(--muted); font-weight: var(--w-medium); }
.mark-num[data-band="unscored"] { color: var(--muted); font-weight: var(--w-normal); }

/* The band rule. A sticky hairline label that says which pile of the machine's own judgement the
 * rows under it belong to — you scroll until the words change, and you know you have left the rows
 * the veille was sure about without reading a single number. It is the difference between a column of
 * numbers being *present* and being *legible across sixty items*.
 *
 * Opaque `--bg`, not a translucent blur: rows sliding under a frosted label is the same defect as
 * prose showing through a decision key, one control smaller. */
.band-head {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  padding: var(--s-4) var(--s-5) var(--s-2);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
.band:first-child .band-head { padding-top: var(--s-3); }
.band-name {
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--muted);
}
.band-count {
  margin-left: auto;
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  color: var(--muted);
  font-feature-settings: "tnum" 1, "cv01" 1;
}

/* The selection bar is an inset shadow, not a left border: a border would take 2px out of the
 * padding box and push every card title 2px right of the filter bar and the topbar above it. */
.card {
  display: grid;
  grid-template-columns: var(--mark-w) minmax(0, 1fr);
  width: 100%;
  /* 11/13, not 12/12. The row carries one more line than it used to — the machine's sentence, which
   * is the point — and 12/12 measured too loose over sixty repeats. This is the one deliberate break
   * from the 4px lattice on this screen, and it is here because this row repeats sixty times. */
  padding: 11px var(--s-5) 13px;
  column-gap: var(--s-5);
  border: none;
  /* --line, not --line-soft: the soft hairline is calibrated for panels, which sit on --surface.
   * The queue sits on --bg, which the new palette put within a point of --line-soft — the rules
   * disappeared and a hundred rows read as one block. */
  border-bottom: 1px solid var(--line);
  background: transparent;
  text-align: left;
  cursor: pointer;
  transition: background var(--fast) var(--ease);
}
.card:hover { background: var(--surface-2); }
/* The lit row, and the one place the cursor is a colour: the row goes to the reading column's own
 * surface and the number goes accent, so the margin also answers "where am I" when the row tint is
 * the palest thing in the theme. */
.card[aria-selected="true"] {
  background: var(--surface);
  box-shadow: inset 3px 0 0 var(--accent);
}
.card[aria-selected="true"] .mark-num { color: var(--accent); }
.card-main { min-width: 0; }
/* The loading skeleton is two bars in a `.card` with no margin and no main — `ui.js::skeletonRows`
 * predates the two-column row and should not have to know about it. Spanning both columns keeps the
 * placeholder looking like a row of text rather than like a number that failed to arrive. */
.card > .skeleton { grid-column: 1 / -1; }
.card.is-leaving {
  opacity: 0;
  transform: translateX(12px);
  transition: opacity var(--med) var(--ease), transform var(--med) var(--ease);
}

.card-title {
  font-size: var(--t-md);
  font-weight: var(--w-medium);
  line-height: 1.35;
  letter-spacing: -0.005em;
  color: var(--ink);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* The why-line: the machine's own sentence about this article, and the second-most-read thing in the
 * queue. It used to be low-contrast grey, which made the one piece of writing the app itself produced
 * the palest text on the row. It reads at `--ink-2` now, one step under the title, which is the step
 * that lets a hundred rows be scanned by hierarchy rather than by reading every word.
 *
 * One line where the row has the width for the title to breathe, two where it does not. */
.card-why {
  margin-top: 3px;
  font-size: var(--t-sm);
  line-height: 1.45;
  color: var(--ink-2);
  display: -webkit-box;
  -webkit-line-clamp: 1;
  line-clamp: 1;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The matched themes, set as a dateline in front of the sentence they explain — `TAD · RURAL — …`,
 * the way a wire service stamps a story with where it came from. Uppercase, tracked, one step
 * darker, and inline, so it costs no line of its own: four pills per row across sixty rows would be
 * sixty rows of furniture, and the reader needs to *read* these two words, not click them here.
 *
 * The em dash is drawn here rather than in the markup, so nothing in JS ever assembles a sentence
 * out of fragments — which is the rule that keeps this app translatable. */
.card-themes {
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--ink);
}
.card-themes::after {
  content: "\00a0\2014\00a0";
  font-weight: var(--w-normal);
  letter-spacing: normal;
  color: var(--muted);
}
/* Trash rows have no AI why-line — a discarded candidate never reaches enrichment — so they fall
 * back to the publisher's own RSS excerpt.
 *
 * Roman, not italic, and no rule beside it. The italic bought a "this is quoted" distinction with
 * legibility at 12.5px, and the hairline was a second vertical stroke inside the margin's own — a box
 * inside a box. The class survives purely as the hook for the clamp. */
.card-why.is-excerpt { color: var(--ink-2); }

/* Provenance, and the hour, as the row's second alignment edge. `--muted` rather than the `--faint`
 * this used to be: 11px at 2.6:1 was a line the design admitted nobody could read, and the source
 * name is how a triager recognises a feed they trust. Set as a label, not as language — which is what
 * keeps three different kinds of metadata from reading as a sentence. */
/* Flex, not grid, and the reason is measured. As a three-track grid
 * (`minmax(0,auto) auto minmax(0,1fr)`) the flag track could not shrink below its own max-content:
 * `discard.below_top_k` — "Hors des meilleurs candidats", 156px at 11px caps — ate the whole row,
 * the `1fr` spacer collapsed to 0px and the right-aligned hour drew *on top of* the chip (measured
 * -25px at 1440 and 390, -42px at 1920). Chip text is translated copy, so no track width is safe:
 * flex is, because flex items shrink but never overlap. The hour is the one item with
 * `flex-shrink: 0`, so it keeps its width and its right edge at every width and in every language. */
.card-meta {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  margin-top: var(--s-2);
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}
/* The source truncates first and hardest — a feed is recognisable from its first few letters, and in
 * Trash the chip beside it carries the only thing worth reading. `flex-shrink` is weighted by base
 * size, so the 40 is a ratio against the chip's 1, not a length. */
.card-meta .src { flex: 0 40 auto; min-width: 9ch; overflow: hidden; text-overflow: ellipsis; }
.card-flags { display: inline-flex; align-items: center; gap: var(--s-1); flex: 0 1 auto; min-width: 0; }
/* Live, so `markBodyMatches()` appending a chip brings the row back. */
.card-flags:empty { display: none; }

/* The favori star (TODO.md item Q) — discreet by construction, so it is unstyled chrome rather
 * than a second `.btn`: no border, no fill, just the glyph at the meta row's own size. `flex-shrink:
 * 0` keeps it from being the thing that gives way when the source name is long, and `align-self`
 * overrides the row's `baseline` alignment, which a 15px icon has none of. */
.fav-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: center;
  flex: 0 0 auto;
  padding: 0;
  border: none;
  background: none;
  color: var(--muted);
  cursor: pointer;
  transition: color var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.fav-toggle:hover { color: var(--ink-2); }
.fav-toggle:active { transform: scale(.9); }
.fav-toggle[aria-pressed="true"] { color: var(--accent); }
.card-fav { margin-right: 1px; }
/* A flag is a word in its own case, not a shouted one: the row's `text-transform` is for labels, and
 * "Réglementation" in caps at 11px inside a tinted pill is a smudge.
 *
 * `inline-block` rather than the `.flag` default `inline-flex`, because the last resort — once the
 * source is down to its floor — is the chip's own text ellipsing, and `text-overflow` does not reach
 * the anonymous flex item a flex container wraps its text in. A truncated reason still reads as a
 * reason; an overlapped one reads as a broken app. */
.card-flags .flag {
  display: inline-block;
  text-transform: none;
  letter-spacing: .01em;
  font-weight: var(--w-medium);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The hour, flush right. `relative()` — "il y a 13 h" — floats at a different width on every row, so
 * a column of them has no edge to align to; a tabular clock does. `margin-left: auto` is what makes
 * it the row's second alignment edge (G2) when there is slack, and `flex-shrink: 0` is what keeps it
 * legible when there is none. */
.card-time {
  flex: 0 0 auto;
  margin-left: auto;
  font-feature-settings: "tnum" 1, "cv01" 1;
  color: var(--muted);
}
.card-meta .dot::before { content: "·"; margin: 0 2px; }

/* The score chip the card used to carry, kept because `.score` is still rendered — `hidden` — on the
 * detail pane's meta row. It gets no accent at any value. */
.score {
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.flag {
  display: inline-flex;
  align-items: center;
  padding: 1px 6px;
  border-radius: var(--r-pill);
  font-size: var(--t-xs);
  font-weight: var(--w-medium);
  letter-spacing: .01em;
}
.flag-competitor { background: var(--reject-soft); color: var(--reject); }
.flag-regulation { background: var(--warn-soft);   color: var(--warn); }
.flag-customer   { background: var(--keep-soft);   color: var(--keep); }
/* In Trash this chip carries the only thing worth reading — *why the machine dropped it* — so it
 * cannot be the palest thing on the row. `--muted` on `--sunken` was 2.6:1. */
/* The three coloured flags carry their own tint; this one is grey, and --sunken sits four points
 * off the queue's ground, so it needs the hairline to read as a chip rather than as a smudge. */
.flag-discard    { background: var(--sunken);      color: var(--ink-2);
                   box-shadow: inset 0 0 0 1px var(--line); }
/* "matched in the body" — a fact about the *search*, not about the article, and it is added to the
 * meta row after the card renders. Same grey chip as discard, for the same reason: the three tinted
 * flags are a closed set and this is not one of them. */
.flag-body       { background: var(--sunken);      color: var(--muted);
                   box-shadow: inset 0 0 0 1px var(--line); }

/* --------------------------------------------------------------- detail -- */

.detail {
  overflow-y: auto;
  background: var(--surface);
  display: flex;
  flex-direction: column;
}
/* Centred, and centred *with* the decision bar.
 *
 * The old rule anchored this block to the left edge because the sticky bar underneath it could not be
 * centred with it, so Keep and Reject would have hung a hundred pixels left of the headline they act
 * on. Both take `--read-w` now and both centre, so the two edges agree at every width — and the slack
 * at 1920, which used to be five hundred pixels of nothing against the right edge of the window,
 * becomes an even page margin around a column exactly as wide as it should be.
 *
 * The block itself is two columns: the article's margin, then the article. */
.detail-body {
  display: grid;
  grid-template-columns: var(--mark-w-lg) minmax(0, 1fr);
  column-gap: var(--mark-gap);
  align-items: start;
  /* `align-content`, not only `align-items`. `flex: 1` below makes this grid taller than its content
   * whenever the article is short, and an auto-sized row's default `align-content: normal` behaves as
   * `stretch`: the free space is shared out between the rows. With one row (the two-column desktop
   * layout) that is invisible. Below 1280px the margin folds into a row of its own, so the *first*
   * row absorbed half the slack and pushed the headline down — measured 88px of dead band between the
   * folded margin and the title on a two-paragraph article at 390px, and none at all on an
   * eight-paragraph one, which is the signature of free space rather than of a margin. */
  align-content: start;
  padding: var(--s-8) var(--s-8) var(--s-10);
  max-width: var(--read-w);
  margin-inline: auto;
  width: 100%;
  /* Grow into whatever the pane has left, so the margin's hairline runs to the foot of the page
   * rather than stopping where the prose does. Half the real corpus is three paragraphs long; with
   * the rule carried down that reads as the foot of a page, and without it as a pane that failed to
   * finish loading. */
  flex: 1;
}
.detail-col { min-width: 0; }

/* The margin of the article: the score at display size, the band that number falls in, the themes it
 * matched, the flags, and the similarity. This is the same column as the queue's, one size up, and it
 * is the reason the reading column can be nothing but the article. All of it used to be strung along a
 * meta row under the headline, in the same grey and at the same size as the date, where "Non noté"
 * read as a rendering failure.
 *
 * `grid-template-columns: minmax(0, 1fr)` is **load-bearing**. A grid with no declared column sizes
 * its implicit one to `max-content`, so "RÉGLEMENTATION" made a 111px track inside a 132px box and
 * every child in the margin — the score included — aligned to the right edge of the *track*, shifted
 * into the headline. A capped 1fr column is what makes the margin a column rather than a suggestion;
 * alignment is then `text-align`, which wraps instead of overflowing. */
.detail-mark {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-content: start;
  gap: var(--s-1);
  text-align: right;
  padding-right: var(--s-4);
  border-right: 1px solid var(--line-firm);
  /* Optical alignment: the digits sit on the headline's cap height, not on its line box. */
  padding-top: 2px;
  align-self: stretch;
}
.mark-score {
  font-size: var(--t-mark);
  font-weight: var(--w-semi);
  line-height: 1;
  letter-spacing: -0.03em;
  font-feature-settings: "tnum" 1, "cv01" 1;
  color: var(--ink);
}
.detail-mark[data-band="related"]  .mark-score { color: var(--ink-2); }
.detail-mark[data-band="weak"]     .mark-score { color: var(--muted); font-weight: var(--w-medium); }
.detail-mark[data-band="unscored"] .mark-score { color: var(--muted); font-weight: var(--w-normal); }
.mark-band, .mark-themes, .mark-flags {
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--muted);
  text-wrap: balance;
}
.mark-band { color: var(--ink-2); margin-top: var(--s-1); }
.mark-themes { margin-top: var(--s-3); color: var(--ink-2); }
/* In the margin a flag is a word, not a pill: "Réglementation" in a chip is 105px wide and the column
 * is 132 with a gutter, so the pill hung over the headline. Stripped to tracked uppercase in its own
 * semantic colour it wraps like any other label, and the three tinted chips still exist as chips in
 * the queue, where the row has the width for them. */
.mark-flags { margin-top: var(--s-3); display: grid; gap: var(--s-1); }
.mark-flags .flag {
  display: block;
  padding: 0;
  background: none;
  box-shadow: none;
  border-radius: 0;
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  text-align: right;
  white-space: normal;
}
.mark-sim {
  margin-top: var(--s-2);
  font-size: var(--t-caps);
  letter-spacing: .04em;
  color: var(--muted);
  font-feature-settings: "tnum" 1, "cv01" 1;
}

/* The headline. Allowed to grow with the pane rather than pinned to one step of the scale: at 1920 a
 * 25px headline over an 18px body is not a hierarchy, it is two sizes of the same thing. */
.detail h2 {
  font-size: clamp(26px, 2.05vw, 32px);
  font-weight: var(--w-bold);
  line-height: 1.12;
  letter-spacing: -0.022em;
  text-wrap: balance;
  color: var(--ink);
}
.detail-orig {
  margin-top: var(--s-2);
  font-size: var(--t-base);
  line-height: 1.45;
  color: var(--muted);
  font-style: italic;
}
/* Provenance, and only provenance. The score, the themes and the flags all left this row for the
 * margin, so what is left is a byline: who published it, where, when — and the way out to the
 * publisher, pushed to the far edge. That is what lets a real byline, when the extractor starts
 * producing one, sit here without a fight. */
.detail-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-1) var(--s-2);
  margin-top: var(--s-3);
  padding-bottom: var(--s-4);
  border-bottom: 1px solid var(--line);
  font-size: var(--t-sm);
  color: var(--muted);
}
.detail-meta .dot::before { content: "·"; margin: 0 2px; }
.detail-meta .src { font-weight: var(--w-medium); color: var(--ink); }
.detail-open {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-left: auto;
  color: var(--accent);
  text-decoration: none;
  font-weight: var(--w-medium);
}
.detail-open:hover { text-decoration: underline; }

/* The honest state, and the one the layout has to make look deliberate: most of the real corpus never
 * reaches the scorer.
 *
 * The sentence lives in the *reading* column, in the slot the why-line would have occupied — because
 * that slot is empty for exactly these articles, and "why is this here" is the question it exists to
 * answer either way. It spent one draft in the margin, where 22ch inside 132px came out as five ragged
 * lines beside a headline: the right words in the wrong measure. Same rule as `.detail-why`, one step
 * quieter, because this is the app admitting something rather than asserting it. */
.detail-nowhy {
  margin-top: var(--s-5);
  padding-left: var(--s-4);
  border-left: 2px solid var(--line-soft);
  font-size: var(--t-base);
  line-height: 1.5;
  color: var(--muted);
  max-width: 52ch;
}

/* The machine's reason for surfacing this, quoted. It used to be an accent-filled panel, which
 * made the app's rationale louder than the article's own headline — and spent the one accent on
 * a paragraph the reader had already skimmed in the queue. A rule and a size are enough. */
.detail-why {
  margin-top: var(--s-5);
  padding-left: var(--s-4);
  border-left: 2px solid var(--line);
  font-size: var(--t-md);
  line-height: 1.5;
  color: var(--ink);
}
/* The reading block, and the one place in this app where type exists for prose rather than for
 * chrome. 15.5px was the chrome's own size doing double duty; 18px/1.68 at a 68ch measure is what
 * makes a thin body — two paragraphs from a publisher who gave us two paragraphs — read as a short
 * article rather than as a failed scrape.
 *
 * `hyphens` is deliberately unset: `hyphens: auto` produced four-hyphen ladders in French. */
.prose {
  margin-top: var(--s-6);
  font-size: var(--t-read);
  line-height: 1.68;
  color: var(--ink-2);
  max-width: min(var(--measure), 100%);
}
/* A lede. The first paragraph of a source text is the publisher's own excerpt — see `sourceParts` in
 * article-view.js — so it is genuinely a standfirst, and setting it as one tells the reader that the
 * paragraphs under it are the body and not a repetition. */
.prose > p:first-child {
  font-size: var(--t-lede);
  font-weight: var(--w-medium);
  letter-spacing: -.008em;
  line-height: 1.5;
  color: var(--ink);
}
/* G8. A one-paragraph extraction is not a standfirst with nothing after it — it is the whole article,
 * and at display size it reads as a giant orphan. When the lede is the only paragraph, the step is
 * reverted entirely.
 *
 * Two selectors, and the second is the one that actually fires. `:last-child` alone is dead whenever
 * `.prose-end` is printed — which is every time there is a paragraph at all — so `article-view.js`
 * states the count as `.is-single` instead; see `singleBlock()` there. `:last-child` is kept for the
 * placeholder branches, which render a `<p>` with no end-mark under it. */
.prose.is-single > p:first-child,
.prose > p:first-child:last-child {
  font-size: var(--t-read);
  font-weight: var(--w-normal);
  letter-spacing: 0;
  color: var(--ink-2);
}
.prose > p:first-child + p { margin-top: var(--s-5); }
.prose p + p { margin-top: 1.05em; }
/* A paragraph after a list is a paragraph. Without this it butted straight onto the last bullet and
 * read as a fourth item that had lost its dot. */
.prose ul + p { margin-top: var(--s-5); }
.prose ul { margin-top: var(--s-4); display: grid; gap: var(--s-2); }
.prose li { padding-left: var(--s-4); position: relative; }
.prose li::before {
  content: "";
  position: absolute;
  left: 2px; top: .62em;
  width: 4px; height: 4px;
  border-radius: 50%;
  background: var(--muted);
}

/* G3 — the end mark. A two-line RSS excerpt has to read *finished*, not truncated: half the real
 * corpus is two or three paragraphs long, and with nothing under them the pane looks like a page that
 * failed to load the rest. `article-view.js` prints it only when at least one real paragraph was
 * rendered, so it never appears under a skeleton or under a "nothing was retrieved" placeholder. */
.prose-end {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin-top: var(--s-8);
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--muted);
}
.prose-end::before, .prose-end::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--line);
}

/* ---------------------------------------------------------- the decision -- */

/* The whole screen exists for these two buttons, so they are the first thing in the pane and the only
 * filled thing on it.
 *
 * They used to sit at the bottom-left of a scrolling pane — below the fold on any real article — while
 * the loudest object on the screen was a blue Synchroniser in the top-right corner, an action used once
 * a day. So: the accent moved here, Synchroniser went quiet, and the bar sticks to the *top* of the
 * pane on a wide screen, level with the headline it acts on. `order: -1` rather than a change of markup
 * order, so nothing that selected these buttons has to know.
 *
 * **Opaque, and no blur — graft G5.** This surface was `color-mix(--surface 85%, transparent)` behind a
 * `backdrop-filter`, which on a phone put the article's own prose legibly *through* the Retenir key.
 * A frosted control that the content shows through is not a control, it is a suggestion; and a
 * decision surface is the last place in an app that can afford ambiguity about where it starts. No
 * translucency and no `backdrop-filter` may return to this rule at any width.
 *
 * It takes `--read-w` and centres, exactly like `.detail-body`, and its left padding is that block's
 * gutter *plus* the margin column and its gap — which puts the R of Retenir directly over the R of
 * Royal. The 2px back is the button's own optical inset: a bordered box's ink starts 2px inside its
 * edge. */
.actionbar {
  position: sticky;
  top: 0;
  z-index: 3;
  order: -1;
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 48px;
  padding: var(--s-2) var(--s-8);
  padding-left: calc(var(--s-8) + var(--mark-w-lg) + var(--mark-gap) - var(--s-4));
  max-width: var(--read-w);
  margin-inline: auto;
  width: 100%;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
  box-shadow: var(--shadow-1);
  flex: none;
}
/* Retenir is the primary action of the entire application, and the one place the accent is spent.
 * Écarter keeps its red as *ink only*: two filled buttons side by side is a dialog, and a dialog asks
 * a question rather than accepting sixty answers a minute. */
.actionbar .btn-keep {
  background: var(--accent);
  border-color: transparent;
  color: var(--accent-ink);
  padding-inline: var(--s-4);
  font-weight: var(--w-semi);
}
.actionbar .btn-keep:hover { background: color-mix(in srgb, var(--accent) 88%, black); }
/* An accent outline on an accent fill is invisible, so the ring goes inside, in the label's colour. */
.actionbar .btn-keep:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px var(--accent-ink);
}
.actionbar .btn-reject { padding-inline: var(--s-4); color: var(--ink-2); }
.actionbar .btn-reject:hover,
.actionbar .btn-reject:focus-visible { color: var(--reject); border-color: var(--reject); }
/* Annuler is permanently present, and quiet. `aria-disabled` rather than `disabled` and never
 * `display: none`: a control that vanishes when there is nothing to undo is a control nobody learns
 * exists, and the keystroke it teaches (`U`) is bound whether or not the stack is empty. */
.actionbar .btn-undo { color: var(--ink-2); border-color: transparent; }
.actionbar .btn-undo[aria-disabled="true"] { color: var(--muted); }
/* The star: quiet like Annuler until it is on, and then it takes the app's one accent — the same
 * ink the lit queue row and Retenir already use, so "this is marked" reads as one colour across
 * the whole screen rather than inventing a second one for a favori. */
.actionbar .btn-fav { color: var(--ink-2); border-color: transparent; }
.actionbar .btn-fav[aria-pressed="true"] { color: var(--accent); }
.actionbar .btn-back { padding-inline: var(--s-2); color: var(--ink-2); }
/* G11 — the keymap, printed on the bar. Below 1560px there is no aside to carry it, and a shortcut you
 * have to open a dialog to discover is a shortcut nobody learns. It goes at ≤1023px, where there is no
 * keyboard to teach. */
.actionbar-keys {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  flex: none;
}

kbd {
  display: inline-block;
  min-width: 18px;
  padding: 1px 4px;
  border: 1px solid var(--line);
  border-radius: 4px;
  /* --surface-2 is a two-point step off white; on the detail pane the keycap had no box at all. */
  background: var(--sunken);
  font-family: var(--font);
  font-size: var(--t-xs);
  font-weight: var(--w-medium);
  color: var(--muted);
  text-align: center;
  line-height: 1.5;
}
/* Inside a button the keycap is a reminder, not a second control. It borrows the button's own
 * colour at half strength instead of drawing its own box next to the verb. */
.btn kbd {
  border-color: transparent;
  background: none;
  color: inherit;
  opacity: .5;
  padding: 0 1px;
}

/* --------------------------------------------------------- article body -- */

/* Résumé IA / Texte source / Page. `.bodyswitch` is the `.segmented` control — same rule, up in
 * the controls section — because it is the same object doing the same job in a different pane. */
/* Three text labels on a hairline, not a filled trough. In the reading column a segmented control
 * with a sunken tray is a *form* — a box of options — and this is a table of contents: which of the
 * three readings of this article you are looking at. The active one takes `--ink` and a 2px underline
 * drawn as an inset shadow, so the label does not move when it becomes active.
 *
 * `align-self`, not `width`: in the two prose modes this is an `inline-flex` that sizes to its three
 * words, but `page` mode turns `.article-view` into a flex column so the frame can fill the pane — and
 * a flex column stretches its children. The switcher then ran the whole width of the detail pane. */
.bodyswitch {
  display: inline-flex;
  gap: var(--s-4);
  align-self: flex-start;
  flex: none;
  margin: var(--s-5) 0 var(--s-4);
  padding-bottom: var(--s-2);
  border-bottom: 1px solid var(--line);
}
.bodyswitch button {
  padding: 0 0 var(--s-2);
  border: none;
  border-radius: 0;
  background: none;
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--muted);
  cursor: pointer;
  transition: color var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
.bodyswitch button[aria-pressed="true"] {
  color: var(--ink);
  box-shadow: inset 0 -2px 0 var(--ink);
}
/* A mode this article does not have. It stays in place and stays readable — the row of three is a
 * map of what exists, and hiding the empty ones would make the control change width per article. */
.segmented button:disabled, .bodyswitch button:disabled {
  color: var(--muted);
  opacity: .55;
  cursor: not-allowed;
}
.segmented button:disabled:hover, .bodyswitch button:disabled:hover { color: var(--muted); }

/* The embedded page has to fill whatever the detail pane has left, which means every ancestor
 * between it and `.detail` has to be a flex column that can shrink — and `article-view.js` puts two
 * of its own wrappers in that chain. `.detail-body` is normally a capped reading measure; with a
 * frame inside it there is no measure to protect.
 *
 * `.article-host` is the fourth link: `triage.js::paintBody` swaps the prose slot for a wrapper of
 * its own, and while that wrapper was unclassed this list had a hole in it. Every ancestor or none
 * — one content-sized link and the frame stops hearing about the pane's height entirely. */
.detail-body:has(.frame-wrap),
.detail-col:has(.frame-wrap),
.article-host:has(.frame-wrap),
.article-view:has(.frame-wrap),
.article-body:has(.frame-wrap) {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
.detail-body:has(.frame-wrap) { max-width: none; }
.detail-col:has(.frame-wrap) { max-width: none; }

/* `page` mode compacts the article's own header, so the frame gets the pixels instead — see
 * `publishMode` in article-view.js for why the mode is readable from here at all. Nothing that
 * disappears is lost: the translated subtitle and the machine's `why` are both on the card the
 * reader just clicked, and the title stays as one clamped line so the pane still says *which*
 * article is framed. Measured at 1440×900 the frame goes from 358px to 486px. */
/* The margin goes too. In `page` mode the frame *is* the content, and the veille's judgement about an
 * article the reader has chosen to read on the publisher's own terms is one column of pixels the frame
 * can have instead. It is one keystroke away in the other two modes. */
.detail-body[data-body-mode="page"] {
  padding: var(--s-5) var(--s-6) var(--s-5);
  grid-template-columns: minmax(0, 1fr);
}
.detail-body[data-body-mode="page"] h2 {
  font-size: var(--t-lg);
  line-height: 1.3;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.detail-body[data-body-mode="page"] .detail-orig,
.detail-body[data-body-mode="page"] .detail-mark,
.detail-body[data-body-mode="page"] .detail-nowhy,
.detail-body[data-body-mode="page"] .detail-why { display: none; }
.detail-body[data-body-mode="page"] .detail-meta {
  margin-top: var(--s-2);
  padding-bottom: var(--s-2);
}
.detail-body[data-body-mode="page"] .bodyswitch { margin: var(--s-3) 0; }

/* Open-in-tab and the phone toggle: the frame's own controls, not the article's, so they sit on the
 * frame rather than beside the keep/reject bar. Right-aligned, because the switcher above them is
 * left-aligned and two left-aligned control rows read as one row that wrapped. */
.frame-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s-2);
  flex: none;
  margin-bottom: var(--s-2);
}
.frame-actions .btn[aria-pressed="true"] {
  background: var(--accent-soft);
  border-color: var(--accent-line);
  color: var(--accent);
}

/* The line under the frame that explains what a blank panel means. `bindFrame` cannot detect a
 * refused embed — see its comment — so this sentence is the whole failure story most of the time,
 * and it needs the air to be read as one. */
.frame-wrap + p { flex: none; margin-top: var(--s-2); }

.frame-wrap {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 360px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--surface);
}
/* White in both schemes, on purpose: the framed document brings its own colours and a dark box
 * behind a light page shows as a rim, not as dark mode. */
.frame-wrap iframe {
  flex: 1;
  display: block;
  width: 100%;
  min-height: 0;
  border: 0;
  background: #ffffff;
}

/* A blank white panel reads as a bug, so refusal gets a state of its own. Same shape as `.empty`. */
.frame-blocked {
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--s-2);
  flex: 1;
  padding: var(--s-8);
  text-align: center;
  color: var(--muted);
  background: var(--surface);
}
.frame-blocked h3 {
  font-size: var(--t-md);
  font-weight: var(--w-semi);
  letter-spacing: -0.01em;
  color: var(--ink);
}
.frame-blocked p { max-width: 40ch; font-size: var(--t-sm); line-height: 1.5; }
.frame-blocked .btn { margin-top: var(--s-2); }

/* Preview-as-phone. The frame narrows to a phone column on the pane's own ground so the width is
 * legible as a *choice* rather than as a layout that failed to fill. */
.frame-wrap[data-mobile="true"] {
  align-items: center;
  padding: var(--s-5) var(--s-4);
  background: var(--bg);
}
.frame-wrap[data-mobile="true"] iframe {
  width: var(--phone-w);
  max-width: 100%;
  border: 1px solid var(--line);
  border-radius: var(--r);
  box-shadow: var(--shadow-2);
}
.frame-wrap[data-mobile="true"] .frame-blocked { background: none; }

/* ---------------------------------------------------------------- pages -- */

/* Anchored left, at exactly the topbar's own inset. Centring the column put the page heading
 * 150px to the right of the topbar heading that names the same screen — two versions of the word
 * "Sources", neither of them lined up with anything. The reading measure is kept by capping the
 * width, which is what the max-width was for; the auto margins were never doing that work. */
.page {
  overflow-y: auto;
  flex: 1;
  padding: var(--s-6) var(--s-5) var(--s-16);
}
.page-inner { max-width: 780px; }

.section { margin-bottom: var(--s-10); }
.section > h2 {
  font-size: var(--t-lg);
  font-weight: var(--w-semi);
  letter-spacing: -0.015em;
  margin-bottom: var(--s-1);
}
.section > .hint { color: var(--muted); font-size: var(--t-sm); margin-bottom: var(--s-4); }
/* A heading with no subtitle under it still owes the block below a full step of air. */
.section > h2 + :not(.hint) { margin-top: var(--s-4); }

/* Explanatory prose set to the full width of a form is 130 characters a line. Cap every hint at
 * a measure the eye can return from. */
.hint { max-width: 62ch; }

.panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r);
  overflow: hidden;
}
.row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--line-soft);
}
.row:last-child { border-bottom: none; }
.row-main { min-width: 0; flex: 1; }
.row-title { font-weight: var(--w-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-sub {
  font-size: var(--t-sm);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Thirty rows × four bordered buttons is a hundred and twenty boxes, with a red word repeated all
 * the way down the right edge. The controls stay in the tab order and in the same place; they
 * just stop drawing themselves until the row they belong to is the row under the cursor. */
.row-actions {
  display: flex;
  align-items: center;
  gap: var(--s-1);
  flex: none;
}
.row-actions .btn {
  border-color: transparent;
  background: none;
  color: var(--muted);
}
.row:hover .row-actions .btn,
.row:focus-within .row-actions .btn {
  border-color: var(--line);
  background: var(--surface);
  color: var(--ink);
}
.row:hover .row-actions .btn-reject,
.row:focus-within .row-actions .btn-reject { color: var(--reject); }
.row-actions .btn:hover { background: var(--sunken); }
.row-actions .btn-reject:hover { background: var(--reject-soft); border-color: var(--reject-soft); }

/* A `.row` used as a selectable option (`views/digest.js`), the same treatment as `.card`'s
 * `[aria-selected="true"]`: an inset accent bar, never a border, so the row's own hairlines and
 * padding do not shift under the cursor. */
.row[role="option"] { cursor: pointer; }
.row[role="option"]:hover { background: var(--surface-2); }
.row[role="option"][aria-selected="true"] {
  background: var(--surface);
  box-shadow: inset 3px 0 0 var(--accent);
}

/* The digest screen's right pane: a whole rendered file, so it gets the full column rather than a
 * measured reading width — `.digest-frame` fills `.detail` exactly as the empty state's `.empty`
 * does (both `flex: 1`), and the border comes from the pane already having one on its own edges. */
.digest-frame {
  flex: 1;
  width: 100%;
  border: 0;
}
/* `.queue`'s spine gradient (above) is drawn at `--mark-w`, the *card*'s score-margin column — this
 * screen has no cards and no margin, so left alone the gradient would draw a stray vertical rule
 * with nothing on either side of it to justify it. `views/triage.js`'s own trash view silences the
 * same background with a `:has()` on an *empty* mark; here there is no mark at all to select on, so
 * the override is scoped to the digest view instead. */
[data-testid="digest-view"] .queue { background: none; padding: var(--s-5); }

/* --------------------------------------------------------- spaces screen -- */

/* The permission model is the one thing on this screen nobody can infer from it — a membership row
 * grants elevation, not access — so it is stated rather than implied, quietly, above the list it
 * explains. */
.space-note {
  margin-bottom: var(--s-4);
  padding: var(--s-3) var(--s-4);
  border: 1px solid var(--line-soft);
  border-radius: var(--r);
  background: var(--surface-2);
  color: var(--muted);
  font-size: var(--t-sm);
  line-height: 1.5;
  max-width: 62ch;
}

/* The expanded half of a space row. On `--surface-2` under a hairline, so a panel with three spaces
 * open still reads as three rows with detail rather than as twelve rows. */
.members {
  display: grid;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4) var(--s-4);
  border-top: 1px solid var(--line-soft);
  background: var(--surface-2);
}
.member-row { display: flex; align-items: center; gap: var(--s-3); }
.member-main { flex: 1; min-width: 0; }
.member-name { font-weight: var(--w-medium); }
.member-mail { font-size: var(--t-sm); color: var(--muted); }
.member-row .select { width: auto; min-width: 150px; }

/* Bottom-aligned: the email field carries a label and the two controls beside it do not, so aligning
 * on the top edge would leave the button floating a line above the field it submits. */
.member-add { display: flex; align-items: flex-end; gap: var(--s-2); padding-top: var(--s-2); }
.member-add .field { flex: 1; margin: 0; }
.member-add .select { width: auto; min-width: 150px; }

/* Run-on metadata — "Récupérés 30 Nouveaux 17 Notés 15" — is six facts the eye has to segment by
 * itself. A separator does that work; a gap does not. */
.facts {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-1) var(--s-2);
  min-width: 0;
}
.facts > * + *::before {
  content: "·";
  margin-right: var(--s-2);
  color: var(--faint);
}
.facts .nums { color: var(--ink-2); }

.field { margin-bottom: var(--s-4); }
.field > label {
  display: block;
  margin-bottom: 5px;
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  color: var(--ink-2);
}
.field > .hint { margin-top: 4px; font-size: var(--t-sm); color: var(--muted); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-4); }

/* The `enrich_langs` checkbox group (TODO.md item T) and any other loose checkbox-plus-word row
 * that is not a full `.row` list item — no health dot, no secondary line, just a control and its
 * label sitting on the same baseline. */
.checkbox-row {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: var(--t-sm);
  color: var(--ink);
  cursor: pointer;
}
.checkbox-row:has(input:disabled) { color: var(--muted); cursor: default; }

/* A collapsible group of `.field`s. `<details>` keeps its content in the DOM (and therefore in the
 * form's `FormData`) whether open or closed, so collapsing a section for a cleaner page never
 * drops a value the save button was about to submit. */
.settings-section {
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--surface);
  margin-bottom: var(--s-4);
  overflow: hidden;
}
.settings-section > summary { padding: var(--s-3) var(--s-4); font-weight: var(--w-semi); }
.settings-section[open] > summary { border-bottom: 1px solid var(--line-soft); }
.settings-section-body { padding: var(--s-4); }
.settings-section-body .field:last-child { margin-bottom: 0; }
/* Every hint in a section reads as a hint, however deeply it is nested. The scoped rules above this
 * (`.field > .hint`, `.section > .hint`) are direct-child only, so a hint rendered by a mounted
 * widget — `digestPromptSection`'s, a grandchild of its host div — fell back to body text and
 * shouted louder than the field it explains. */
.settings-section-body .hint { font-size: var(--t-sm); color: var(--muted); }

/* Sticky rather than fixed, so it never floats over the sidebar or the topbar — only over `.page`,
 * its own scroll container. This is the actual fix for the bug it exists to close: the button no
 * longer has to be *found* by scrolling past whichever sections are open, on this page or the
 * sections that follow it (sources, sync history, danger zone).
 *
 * `bottom` is negative, not `0`. A sticky box is never allowed to sit inside its scroll
 * container's own padding — `.page` carries `var(--s-16)` of bottom padding meant as trailing air
 * *after every section on the page*, and without this offset the bar snaps up short of it,
 * riding 64px higher than its resting spot and overlapping whatever this form's own last section
 * is whenever the form is shorter than the viewport (true on a common 1440×900 window with only
 * two sections open). Negating the same token cancels exactly that padding back out, so the bar's
 * pinned position lines up with its natural one and the two never fight. */
.settings-save-bar {
  position: sticky;
  bottom: calc(-1 * var(--s-16));
  z-index: 1;
  margin-top: var(--s-4);
  padding: var(--s-3) 0;
  background: var(--surface);
  border-top: 1px solid var(--line);
  box-shadow: var(--shadow-1);
}

/* A health dot beats a word: it is scannable down a column of thirty sources. Three states, and
 * they differ in *shape* as well as hue — hollow means never tried, which is a different fact from
 * a grey fill and the one most rows are in on a fresh install. Aligned to the title's optical
 * centre rather than to the middle of a two-line row, where it sat between the two lines. */
.dot-health {
  width: 8px; height: 8px;
  border-radius: 50%;
  flex: none;
  align-self: start;
  margin-top: 6px;
  background: none;
  box-shadow: inset 0 0 0 1.5px var(--faint);
}
.dot-health[data-status="ok"]    { background: var(--keep);   box-shadow: none; }
.dot-health[data-status="error"] { background: var(--reject); box-shadow: none; }

.diff {
  font-family: var(--mono);
  font-size: var(--t-sm);
  line-height: 1.65;
  white-space: pre-wrap;
  word-break: break-word;
  padding: var(--s-4);
  background: var(--sunken);
  border: 1px solid var(--line);
  border-radius: var(--r);
  max-height: 460px;
  overflow-y: auto;
}
.diff ins { background: var(--keep-soft);   color: var(--keep);   text-decoration: none; }
.diff del { background: var(--reject-soft); color: var(--reject); text-decoration: line-through; }

/* ------------------------------------------------------------- schedule -- */

/* Plain language instead of a cron field. The rows are a two-column grid rather than flex lines so
 * "Fréquence", "Jour" and "Heure" share one left edge however wide their controls are — a form
 * whose labels wander is a form nobody trusts they have finished filling in. */
.sched { display: grid; gap: var(--s-3); }
.sched-row {
  display: grid;
  grid-template-columns: var(--sched-label-w) minmax(0, 1fr);
  align-items: center;
  gap: var(--s-3);
}
/* "February simply skips" belongs to the select above it, not to the section — so it starts where
 * that select starts. Only in the branch that has rows: the read-only custom-cron branch has no
 * label column to align to, and its hint is a paragraph about the whole block. */
.sched > .hint { font-size: var(--t-sm); color: var(--muted); }
.sched:has(.sched-row) > .hint { margin-left: calc(var(--sched-label-w) + var(--s-3)); }
.sched-row > label {
  font-size: var(--t-sm);
  font-weight: var(--w-medium);
  color: var(--ink-2);
}
.sched-row .select, .sched-row .input { max-width: 240px; }
/* A bare `<select>` inside the picker is painted by the OS — on a dark theme Chromium draws a
 * near-white field with black text. The picker's controls get the field treatment whether or not
 * the markup remembered the class. */
.sched select:not(.select), .sched input:not(.input) {
  width: 100%;
  max-width: 240px;
  padding: 7px var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  color: var(--ink);
  font-size: var(--t-base);
  line-height: 1.5;
}
.sched select:not(.select) {
  appearance: none;
  -webkit-appearance: none;
  padding-right: var(--s-8);
  background-image: var(--icon-chevron);
  background-repeat: no-repeat;
  background-position: right var(--s-2) center;
  background-size: 14px;
  cursor: pointer;
}
.sched select:not(.select):focus, .sched input:not(.input):focus {
  outline: none;
  border-color: var(--accent-line);
  box-shadow: 0 0 0 3px var(--accent-soft);
}

/* The section's Save button is the picker's next sibling in the form, with nothing between them —
 * so at rest it sat 4px under the preview and read as part of the sentence rather than as the act
 * of committing it. The host is addressed by its `data-part`, which is the only handle the markup
 * offers between the two. */
[data-part="sched-host"] + .btn { margin-top: var(--s-4); }

/* The picker's output, restated as a sentence. Set on --sunken so it reads as something the app
 * computed rather than as another caption the user is expected to have written. */
.sched-preview {
  margin-top: var(--s-1);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-sm);
  background: var(--sunken);
  color: var(--muted);
  font-size: var(--t-sm);
  line-height: 1.5;
}

/* ----------------------------------------------------------- date range -- */

/* The dash is generated, not marked up: two equally-flexed inputs separated by one gap put the
 * gap's centre at the wrapper's centre, so an absolutely-centred glyph lands between them at every
 * width. `:has` stands aside if the markup supplies its own separator. */
.daterange {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--s-5);
}
.daterange > input, .daterange > .input { flex: 1 1 0; min-width: 0; width: auto; }
.daterange:not(:has(.dash))::after {
  content: "–";
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  color: var(--faint);
  pointer-events: none;
}
.daterange .dash { flex: none; color: var(--faint); }

/* Each option is a card, because each one carries a sentence of explanation and a bare radio list
 * gives that sentence no left edge to hang from. */
.range-choice { display: grid; gap: var(--s-2); }
.range-choice > label {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  column-gap: var(--s-3);
  padding: var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--surface);
  cursor: pointer;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.range-choice > label:hover { background: var(--sunken); }
.range-choice > label:focus-within { border-color: var(--accent-line); }
.range-choice > label:has(input:checked) {
  background: var(--accent-soft);
  border-color: var(--accent-line);
}
/* "Since the last export" with no last export. The card stays — the option exists, it is simply not
 * reachable yet, and its own sentence says why. Dimming the whole card would take that sentence,
 * which is the only useful thing on it, down with the title. */
.range-choice > label:has(input:disabled) {
  background: var(--sunken);
  cursor: not-allowed;
}
.range-choice > label:has(input:disabled):hover { background: var(--sunken); }
.range-choice > label:has(input:disabled) .t { color: var(--muted); }
.range-choice input[type="radio"] {
  grid-column: 1;
  grid-row: 1 / span 2;
  align-self: start;
  margin: 3px 0 0;
}
.range-choice .t {
  grid-column: 2;
  font-size: var(--t-base);
  font-weight: var(--w-medium);
  color: var(--ink);
}
.range-choice .d, .range-choice .hint {
  grid-column: 2;
  margin-top: 2px;
  font-size: var(--t-sm);
  line-height: 1.45;
  color: var(--muted);
}
/* The date pair belongs to "between two dates", but `settings-digest.js` renders it as a `.field`
 * *after* the radio group rather than inside the option's label — so the relationship has to be
 * drawn instead of nested. Two moves: a full step of air, because at 4px the pair read as the
 * beginning of the next question rather than as the end of this one; and an indent to the option's
 * text column, which is what says "this belongs to the card above" without a box or a rule. The
 * left edge is the option's text column — the card's padding, the radio, and the label's gap come
 * to 37px, and --s-10 is the grid step that lands on it. */
.range-choice + .field {
  margin-top: var(--s-3);
  padding-left: var(--s-10);
}

/* --------------------------------------------------------------- states -- */

/* `padding: 64px 32px` on a box that is also `place-content: center` does not centre it — it
 * centres it and then shoves it down by half of 64. Symmetric padding and a min-height put the
 * queue's message and the detail pane's message on the same baseline instead of 300px apart. */
.empty {
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--s-2);
  padding: var(--s-8);
  text-align: center;
  color: var(--muted);
  min-height: 100%;
  flex: 1;
}
.empty-mark {
  width: 42px; height: 42px;
  border-radius: var(--r-lg);
  background: var(--sunken);
  display: grid; place-items: center;
  margin-bottom: var(--s-2);
}
.empty h3 {
  font-size: var(--t-lg);
  font-weight: var(--w-semi);
  letter-spacing: -0.015em;
  color: var(--ink);
}
.empty p { max-width: 36ch; font-size: var(--t-base); line-height: 1.5; }
.empty .btn { margin-top: var(--s-2); }

/* The detail pane with no article in it. Not an `.empty`: that box centres one line in the middle
 * of whatever it is given, which is right for a 320px-wide queue column and wrong for 790×750 of
 * detail pane — it spent 97% of it on white. This is a briefing instead, anchored to the same left
 * gutter as `.detail-body` so the two panes agree on where a column starts, and top-aligned so
 * adding the sync row a moment later does not slide the whole block up the screen. */
.briefing {
  display: grid;
  align-content: start;
  gap: var(--s-6);
  padding: var(--s-8);
  max-width: 620px;
}
.briefing h3 {
  font-size: var(--t-lg);
  font-weight: var(--w-semi);
  letter-spacing: -0.015em;
  color: var(--ink);
}

/* Where the work went. Four cells on one row: the point is the *contrast* between them, and a
 * vertical list makes the eye compare each number to the label beside it instead of to the other
 * three. Each is the link to its own bucket, so this row is also the way out of an empty one. */
.tally {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--s-2);
}
/* Numbers on a rule, not four filled boxes. A rounded tinted tray around each cell made the briefing
 * the most decorated block in an application whose whole argument is that the type carries the
 * hierarchy — and four trays read as four buttons of equal weight, which is the opposite of a
 * comparison. The rule at the top is the only furniture. */
.tally-cell {
  display: grid;
  gap: var(--s-1);
  padding: var(--s-3) 0 0;
  text-align: left;
  background: none;
  border: none;
  border-top: 2px solid var(--line);
  border-radius: 0;
  cursor: pointer;
  transition: border-color var(--fast) var(--ease), color var(--fast) var(--ease);
}
.tally-cell:hover { border-color: var(--muted); }
/* The bucket being looked at. It is the empty one, so it reads as the zero in a row of numbers rather
 * than as a selected tab — the rule goes accent, the number does not. */
.tally-cell[aria-current="page"] { border-color: var(--accent); }
.tally-num {
  font-size: var(--t-xl);
  font-weight: var(--w-semi);
  font-feature-settings: "tnum" 1, "cv01" 1;
  letter-spacing: -0.02em;
  line-height: 1.1;
  color: var(--ink);
}
.tally-label {
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--muted);
}

/* One line: when the last run was, what it brought back, and the way to run another. Quiet, because
 * it is a fact about the app rather than about the articles — but the button is here because the
 * topbar's is 700px away from the sentence that says the queue is empty. */
.briefing-sync {
  display: flex;
  align-items: center;
  gap: var(--s-2) var(--s-3);
  flex-wrap: wrap;
  padding: var(--s-3) 0;
  border-top: 1px solid var(--line-soft);
  border-bottom: 1px solid var(--line-soft);
  font-size: var(--t-sm);
  color: var(--ink-2);
}
.briefing-sync .btn { margin-left: auto; }

.briefing-keys { display: grid; gap: var(--s-3); }
.briefing-keys h4 {
  font-size: var(--t-xs);
  font-weight: var(--w-semi);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--faint);
}
/* The reference card is a dialog-sized two-column grid; in the pane it has less room, and a
 * shortcut whose label wrapped under its own key read as two entries. */
.briefing-keys .shortcuts { gap: var(--s-2) var(--s-4); font-size: var(--t-sm); }
.briefing-keys .shortcuts > div { display: flex; align-items: center; gap: var(--s-2); }

.skeleton {
  background: linear-gradient(90deg, var(--sunken) 25%, var(--line-soft) 50%, var(--sunken) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.4s infinite linear;
  border-radius: var(--r-sm);
}
@keyframes shimmer { to { background-position: -200% 0; } }

.spinner {
  width: 14px; height: 14px;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .7s linear infinite;
  flex: none;
}
@keyframes spin { to { transform: rotate(360deg); } }

.progress {
  position: relative;
  height: 3px;
  background: var(--sunken);
  border-radius: var(--r-pill);
  overflow: hidden;
}
.progress > i {
  display: block;
  height: 100%;
  background: var(--accent);
  border-radius: inherit;
  transition: width var(--med) var(--ease);
}

/* ---------------------------------------------------------- sync progress - */

/* The topbar's sync bar. It carries three things a bare percentage cannot: which stage the run is
 * on, which counters have moved, and — the one that answers "I don't see anything happening" —
 * proof that it is not wedged.
 *
 * The wrapper exists for two reasons. `.progress` clips its fill, so the tooltip cannot live
 * inside it; and the bar is 4px tall, which is a fine thing to look at and an impossible thing to
 * point at, so the padding here is the hover target.
 *
 * `--sync-i` is the stage index, written by `triage-markup.js`. It is the only thing the markup
 * gets to say about colour — the ramp itself is tokens, so the bar cannot leave the palette. */
.syncbar {
  position: relative;
  flex: none;
  width: var(--sync-w);
  padding: var(--s-2) 0;
  --sync-h: calc(var(--sync-h0) + var(--sync-i, 0) * var(--sync-h-step));
}
.syncbar > .progress { height: 4px; }

/* The stage in words, beside the bar. Without `nowrap`, "Récupération des sources" breaks over two
 * lines and the whole topbar grows a row taller for the duration of the sync — a bar that makes the
 * page jump is worse than one that says nothing. It ellipsises instead: the title beside it already
 * gives up width the same way, and the tooltip carries the full text regardless. */
.sync-stage {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.syncbar > .progress > i {
  background: hsl(var(--sync-h) var(--sync-s) var(--sync-l));
  transition: width var(--med) var(--ease), background-color var(--sync-hue-t) var(--ease);
}

/* The stage the run is *inside*, shimmering. It is deliberately not part of the fill: `embed` is
 * one Bedrock call per article and owns a single stage index for minutes, so anything that advanced
 * during it would be inventing progress. A band that pulses in place says "working" and claims
 * nothing about how far through it is — which is the honest version of the same reassurance. */
.syncbar > .progress > .seg {
  position: absolute;
  top: 0;
  height: 100%;
  background-image: linear-gradient(90deg,
    transparent 15%,
    hsl(var(--sync-h) var(--sync-s) var(--sync-l) / .5) 50%,
    transparent 85%);
  background-size: 240% 100%;
  background-repeat: no-repeat;
  animation: sync-seg 1.9s var(--ease) infinite;
  transition: left var(--med) var(--ease), width var(--med) var(--ease);
}
@keyframes sync-seg {
  from { background-position: 140% 0; }
  to   { background-position: -140% 0; }
}

/* The generic tooltip drops to the right of a rail icon. This one hangs under a bar at the top of
 * the window, so the whole geometry — side, arrow, wrapping — is re-stated rather than nudged. */
.syncbar > .tip {
  left: 50%;
  right: auto;
  top: calc(100% - var(--s-1));
  display: grid;
  gap: 1px;
  width: max-content;
  max-width: 260px;
  padding: var(--s-2) var(--s-3);
  white-space: nowrap;
  text-align: left;
  transform: translate(-50%, -3px);
}
.syncbar > .tip::before {
  right: auto;
  left: 50%;
  top: auto;
  bottom: 100%;
  margin: 0 0 0 -4px;
  border-right-color: transparent;
  border-bottom-color: var(--tip-bg);
}
/* `:has(:focus-visible)` and not `:focus-within`, for the reason spelled out at the base `.tip` rule:
 * the focusable part here is the `tabindex="0"` progress bar *inside* the syncbar, so this is the one
 * tooltip in the app that genuinely needs the descendant form — but a mouse click on that bar would
 * otherwise pin its tip open exactly as clicking a rail item used to. */
.syncbar:hover > .tip,
.syncbar:has(:focus-visible) > .tip { opacity: 1; transform: translate(-50%, 0); }
.syncbar > .tip b {
  font-size: var(--t-sm);
  font-weight: var(--w-semi);
}
/* Label left, number right, so a run that is only moving one counter shows it moving in a column
 * instead of shuffling the whole block sideways under the reader. */
.syncbar > .tip > span {
  display: flex;
  justify-content: space-between;
  gap: var(--s-5);
  font-weight: var(--w-normal);
  opacity: .78;
}
.syncbar > .tip > span .nums { font-variant-numeric: tabular-nums; }

/* The global reduce-motion block forces every animation to .01ms, which for an infinite loop is
 * not "off" but "as fast as the compositor will go". An indeterminate shimmer is exactly the kind
 * of ambient motion that rule exists to remove, so it is stopped outright and the segment stays as
 * a static tint — still visible, still saying which stage is running. */
@media (prefers-reduced-motion: reduce) {
  .syncbar > .progress > .seg {
    animation: none !important;
    background-image: none;
    background-color: hsl(var(--sync-h) var(--sync-s) var(--sync-l) / .3);
  }
}

/* --------------------------------------------------------------- overlay - */

.toasts {
  position: fixed;
  bottom: var(--s-5);
  left: 50%;
  transform: translateX(-50%);
  display: grid;
  gap: var(--s-2);
  z-index: 60;
  pointer-events: none;
}
.toast {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-4);
  border-radius: var(--r-pill);
  background: var(--ink);
  color: var(--bg);
  font-size: var(--t-base);
  font-weight: var(--w-medium);
  box-shadow: var(--shadow-2);
  pointer-events: auto;
  animation: rise var(--med) var(--ease);
}
/* --bg, not #fff: in dark mode --reject is a light salmon and white text on it is 2.5:1. */
.toast[data-kind="error"] { background: var(--reject); color: var(--bg); }
.toast button { background: none; border: none; color: inherit; font-weight: var(--w-semi); cursor: pointer; text-decoration: underline; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }

dialog {
  padding: 0;
  border: none;
  border-radius: var(--r-lg);
  background: var(--surface);
  color: var(--ink);
  box-shadow: var(--shadow-3);
  max-width: min(560px, calc(100vw - var(--s-8)));
  width: 100%;
}
dialog::backdrop { background: rgba(20, 20, 44, .45); backdrop-filter: blur(3px); }
dialog[open] { animation: pop var(--med) var(--ease); }
@keyframes pop { from { opacity: 0; transform: scale(.97) translateY(6px); } }

.dialog-head {
  padding: var(--s-5) var(--s-6) var(--s-3);
  font-size: var(--t-lg);
  font-weight: var(--w-semi);
  letter-spacing: -0.015em;
}
.dialog-body { padding: 0 var(--s-6) var(--s-5); }
.dialog-foot {
  display: flex;
  justify-content: flex-end;
  gap: var(--s-2);
  padding: var(--s-4) var(--s-6);
  border-top: 1px solid var(--line);
  background: var(--sunken);
}

/* The versioned welcome tour (TODO.md item S): a `.dialog-*`-styled `<dialog>` that stays open
 * across steps instead of resolving once, so it needs its own progress dots and a "skip" pushed to
 * the opposite end of the foot from "back"/"next" — `margin-right: auto` is the whole difference
 * from an ordinary dialog's footer. */
.tour-dialog { max-width: min(440px, calc(100vw - var(--s-8))); }
.tour-dialog .dialog-body p { margin: 0 0 var(--s-4); color: var(--ink-2); line-height: 1.55; }
.tour-skip { margin-right: auto; }
.tour-dots { display: flex; gap: var(--s-2); margin-bottom: var(--s-4); }
.tour-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--line); }
.tour-dot[data-active="true"] { background: var(--accent); }
.tour-progress { font-size: var(--t-xs); color: var(--muted); margin: 0; }

/* Two real columns. Laid out as flex rows, a line with four keycaps pushed its label 90px further
 * right than a line with one, so the eight descriptions had eight different left edges — the one
 * thing a reference table must not have. The keys are flushed right against the gutter so the
 * labels start at a single x. */
.shortcuts {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: var(--s-3) var(--s-4);
  align-items: center;
}
.shortcut-keys { display: flex; align-items: center; gap: var(--s-1); justify-content: flex-end; }
.shortcuts kbd { flex: none; }
.shortcuts > span { color: var(--ink-2); }

/* --------------------------------------------------- the third column ---- */

/* What 1920px buys.
 *
 * Nothing, before this: two panes stopped around 1410px and five hundred pixels of white sat to the
 * right of them. The tempting fix is to let the reading column keep growing, which makes the article
 * *harder* to read at 120 characters a line. So the extra column becomes the one thing this app has
 * that a feed reader does not — its own reasoning — in the order a reader asks for it: what is this
 * watch looking for, which of those themes did today's batch match, what shape is the queue in, what
 * have I decided, and what can the keyboard do.
 *
 * Emphatically not a dashboard. No gauge, no ring, no bar, no sparkline: every row is a label and a
 * tabular number, hairline-separated, and the numbers that mean "filter by this" are buttons wired to
 * the same handler the dropdowns use. */
.veille-aside {
  display: grid;
  gap: var(--s-8);
  padding: var(--s-6) var(--s-6) var(--s-16);
}
.aside-block { display: grid; gap: var(--s-2); }
.aside-title {
  font-size: var(--t-caps);
  font-weight: var(--w-semi);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1.2;
  color: var(--muted);
  padding-bottom: var(--s-2);
  border-bottom: 1px solid var(--line);
}
.aside-prose {
  margin: 0;
  font-size: var(--t-sm);
  line-height: 1.55;
  color: var(--ink-2);
}
.aside-rows { list-style: none; margin: 0; padding: 0; }
.aside-row {
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  width: 100%;
  padding: 5px 0;
  border: none;
  background: none;
  text-align: left;
  color: var(--ink-2);
  font-size: var(--t-sm);
  font-family: inherit;
  cursor: pointer;
  border-radius: 4px;
}
.aside-row.is-static { cursor: default; }
button.aside-row:hover, button.aside-row:hover .aside-row-num { color: var(--ink); }
.aside-row[aria-pressed="true"],
.aside-row[aria-pressed="true"] .aside-row-num { color: var(--accent); }
.aside-row-label { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aside-row-num {
  margin-left: auto;
  flex: none;
  color: var(--muted);
  font-feature-settings: "tnum" 1, "cv01" 1;
}
/* The band rows, in the same ink steps as the margin's numbers — so the column and the queue are
 * visibly talking about the same four piles. */
.aside-row[data-band="strong"]   .aside-row-num { color: var(--ink);   font-weight: var(--w-semi); }
.aside-row[data-band="related"]  .aside-row-num { color: var(--ink-2); font-weight: var(--w-semi); }
.aside-row[data-band="weak"]     .aside-row-num { color: var(--muted); font-weight: var(--w-medium); }
.aside-row[data-band="unscored"] .aside-row-num { color: var(--muted); }
.aside-foot {
  margin: var(--s-2) 0 0;
  font-size: var(--t-caps);
  line-height: 1.45;
  color: var(--muted);
}
/* One row per shortcut, label first and keys flush right — a reference list, not the two-column card
 * the `?` dialog shows. The shared `max-content 1fr` grid packs two per row in a 320px column and
 * pushes the second one off the window; the version that tried wrapped "Annuler la dernière décision"
 * onto three lines under a keycap. */
.veille-aside .shortcuts {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 6px;
  font-size: var(--t-sm);
}
.veille-aside .shortcuts > div {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  color: var(--ink-2);
}
.veille-aside .shortcuts > div > span {
  order: -1;
  flex: 1 1 auto;
  min-width: 0;
  color: var(--ink-2);
}
.veille-aside .shortcuts kbd { flex: none; }

/* -------------------------------------------------------------- utility -- */

.stack { display: grid; gap: var(--s-3); }
.cluster { display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap; }
.muted { color: var(--muted); }
.small { font-size: var(--t-sm); }
.nums { font-variant-numeric: tabular-nums; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
[hidden] { display: none !important; }

/* --------------------------------------------------------------- narrow -- */

/* The article's margin folds into a baseline row above the headline: a 132px column out of a 620px
 * pane is a fifth of the reading width spent on one number. Same objects, same order, laid on their
 * side — and the decision bar gives back the padding that used to reserve the column. */
@media (max-width: 1280px) {
  .detail-body { grid-template-columns: minmax(0, 1fr); row-gap: var(--s-4); }
  .detail-mark {
    grid-template-columns: none;
    grid-auto-flow: column;
    grid-auto-columns: max-content;
    align-items: baseline;
    justify-content: start;
    gap: var(--s-4);
    text-align: left;
    padding-right: 0;
    padding-bottom: var(--s-3);
    border-right: none;
    border-bottom: 1px solid var(--line);
    align-self: auto;
  }
  .mark-score { font-size: 24px; }
  .mark-band, .mark-themes, .mark-flags { margin-top: 0; }
  .mark-flags { grid-auto-flow: column; grid-template-columns: none; }
  .mark-flags .flag { text-align: left; }
  /* Two objects that cannot share a horizontal line with four others. */
  .mark-sim { display: none; }
  .actionbar { padding-left: var(--s-8); }
}

@media (max-width: 1000px) {
  .triage { grid-template-columns: 1fr; }
  /* On a narrow screen the detail pane takes over rather than squeezing both. */
  .triage[data-pane="detail"] .queue { display: none; }
  .triage[data-pane="list"] .detail { display: none; }

  /* G7. The one state in which a 44px band of narrowings is provably useless is the state where the
   * reader is reading. `is-reading` is a class on `#view`, set wherever `data-pane` is, so this does
   * not depend on `:has()` support. */
  #view.is-reading .filterband { display: none; }

  /* The decision moves to the bottom edge, because that is where the thumb is. Same bar, same
   * testids, opposite edge — and 44px targets, the smallest thing a thumb hits reliably. This is the
   * affordance the phone had *none* of: Retenir and Écarter were at the bottom of a scrolling pane,
   * below a full-length article, on a screen with no keyboard to shortcut them.
   *
   * Opaque, and no blur (G5): this is the width at which the translucent version put the article's
   * own prose legibly through the Retenir key. */
  .actionbar {
    order: 1;
    top: auto;
    bottom: 0;
    min-height: 52px;
    padding-left: var(--s-5);
    padding-block: var(--s-2);
    padding-bottom: max(var(--s-2), env(safe-area-inset-bottom));
    background: var(--surface);
    border-top: 1px solid var(--line);
    border-bottom: none;
    box-shadow: 0 -1px 2px rgba(20, 20, 50, .06);
  }
  .actionbar .btn { min-height: 44px; }
  .actionbar .btn-keep, .actionbar .btn-reject { flex: 1 1 auto; justify-content: center; }
  /* Two verbs, the two keys that do them, and Annuler reduced to its keycap — its accessible name is
   * on the button, so the word going does not take the meaning with it. The `↑↓ K R U` legend and the
   * `?` card go: there is no keyboard here to teach. */
  .actionbar .btn-undo .btn-label {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .actionbar .btn-undo { padding-inline: var(--s-3); flex: none; }
  .actionbar-keys { display: none; }
  .actionbar [data-action="shortcuts"] { display: none; }
  /* No keyboard here to teach — the star stays tappable, its keycap does not. */
  .actionbar .btn-fav kbd { display: none; }
  /* G9. The toast stack carries the undo for the decision just made; drawn over the bar it would be
   * covering the very keys it reverses. */
  .toasts { bottom: calc(var(--s-5) + 64px + env(safe-area-inset-bottom)); }

  /* The row has less width, so the machine's sentence gets the second line it needs. */
  .card-why { -webkit-line-clamp: 2; line-clamp: 2; }
}

@media (max-width: 720px) {
  .app, .app:has(> .rail) { grid-template-columns: 1fr; }
  .sidebar, .rail { display: none; }
  .app[data-menu="open"] .sidebar,
  .app[data-menu="open"] .rail {
    display: flex;
    position: fixed;
    inset: 0 auto 0 0;
    z-index: 50;
    box-shadow: var(--shadow-3);
  }
  .app[data-menu="open"] .sidebar { width: var(--sidebar-w); }
  .app[data-menu="open"] .rail { width: var(--rail-w); }
  /* An overlay with the page still fully lit behind it does not read as an overlay — it reads as a
   * layout that broke, with the topbar's title half-hidden under a column that appeared from
   * nowhere. The scrim is also the affordance for the dismissal `shell.js` already binds: tapping
   * anywhere outside closes the drawer, and nothing else on screen says so. A pseudo-element, so
   * the shell needs no extra node and there is nothing to tidy up when the attribute goes away —
   * and it is not a hit target of its own, so the tap still lands on `.app` where that handler is. */
  .app[data-menu="open"]::after {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 49;
    background: rgba(20, 20, 44, .45);
    animation: fade-in var(--fast) var(--ease);
  }
  @keyframes fade-in { from { opacity: 0; } }
  /* A 132px label column on a 360px screen leaves no room for the control it labels. */
  .sched-row { grid-template-columns: minmax(0, 1fr); gap: var(--s-1); }
  .sched:has(.sched-row) > .hint { margin-left: 0; }
  .member-row, .member-add { flex-wrap: wrap; }
  .member-row .select, .member-add .select { min-width: 0; flex: 1; }
  /* The address is the only thing anyone types on this screen, and beside a role select and an Add
   * button it was left about 100px — not enough to see the end of what you typed. It takes the
   * line; the select and the button share the next one. */
  .member-add .field { flex: 1 1 100%; }
  /* A row's own actions are wider than the row here, and `.row-main` is the only item in it that
   * can shrink — so a source list rendered thirty names as "AD…", "Bu…", "bus…" beside three
   * buttons that kept every letter of theirs. The row stacks: what it is, then what you can do to
   * it. Costs a line, and a line is cheaper than a catalogue you cannot read.
   *
   * The basis is a whole line *minus the health dot and its gap*, not 100%: at 100% the dot cannot
   * share the line with the title it belongs to and is stranded above it, which is the one place a
   * red dot means nothing. */
  .row { flex-wrap: wrap; }
  .row-main { flex: 1 1 calc(100% - var(--s-5)); }
  .ms-panel, .period-menu { width: min(var(--pop-w), calc(100vw - var(--s-8))); min-width: 0; }
  /* The detail column and the decision bar keep agreeing on a gutter — just a smaller one. The bar's
   * vertical padding is set in the 1000px block, where it grew a safe-area inset; only the horizontal
   * gutter belongs here. */
  .detail-body { padding: var(--s-4) var(--s-4) var(--s-10); }
  .actionbar { padding-inline: var(--s-4); }
  /* One control height for a thumb rather than for a mouse. */
  :root { --ctl-h: 34px; --mark-w: 40px; --mark-gap: var(--s-3); }
  .card { padding-inline: var(--s-4); column-gap: var(--s-4); }
  .band-head { padding-inline: var(--s-4); }
  .queue-head { padding-inline: var(--s-4); }
  :root { --t-read: 17px; --t-lede: 19px; }
  .page { padding: var(--s-5) var(--s-4) var(--s-10); }
  /* A whole line, not a share of one. Every row this field sits in — the triage filter bar, the
   * sources toolbar — also holds controls that refuse to shrink, so a flexible basis left the field
   * at the width of its own magnifier: a search box with nowhere to type. A line costs less. */
  .search { flex: 1 1 100%; width: auto; min-width: 0; }

  /* A sentence of prose in a 420px bar leaves no width for the title it qualifies, and the title is
   * the half that says which screen this is. */
  .topbar-sub { display: none; }

  /* **The phone's fold.**
   *
   * Four bands of chrome — tabs, search, the dropdowns, a Période chip — took 460px of an 844px screen
   * before a single article appeared. More than half the viewport was filter furniture in front of a
   * reader whose entire job is to read sixty headlines.
   *
   * So at this width the row splits in two and only the first half is standing: the four status tabs,
   * which are the one control that changes what you are looking at, and a `Filtres · n` disclosure that
   * holds everything else. The count is the honesty clause — a narrowing you cannot see is a narrowing
   * you forget is on, so the button says how many are.
   *
   * `display: contents` comes off here: the second group needs to be a real box again to be
   * collapsible, and a row of its own when it is open. */
  .filters { row-gap: var(--s-2); padding-inline: var(--s-4); position: relative; }
  /* `wrap`, and it has to be. The search field lives inside this group, and with `nowrap` its
   * `flex-basis: 100%` could not start a line — so opening `Filtres` put the field on the tab strip's
   * own row and, because the strip is `flex-basis: 0`, it took every pixel: measured, the strip went
   * from 286px to 6px and all four bucket tabs vanished from a control the reader had just opened.
   * Wrapping puts them where §6.2 asks — tabs and the disclosure on the first line, the field on the
   * second — and the strip still does not wrap off on its own, because its hypothetical size is the
   * zero basis rather than its ~355px of content. */
  .filters-main {
    display: flex;
    flex: 1 1 100%;
    flex-wrap: wrap;
    row-gap: var(--s-2);
    align-items: center;
    gap: var(--s-2);
    min-width: 0;
  }
  .filters-facets {
    display: flex;
    flex: 1 1 100%;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--s-2);
    min-width: 0;
    order: 2;
  }
  .filters[data-open="false"] .filters-facets { display: none; }
  .filters-toggle { display: inline-flex; flex: 0 0 auto; order: 2; }
  /* **The tab strip.** It is the only thing guaranteed a place on this line, so it is what scrolls if
   * four French labels with their counts do not fit. Sideways on the tabs alone; never on the bar,
   * which has a popover opening out of it.
   *
   * `flex-basis: 0`, not `auto`. On `auto` the strip's hypothetical size is its full content width —
   * four labels with counts is ~355px of a 358px line — so flex *wrapped* it onto a line of its own and
   * pushed Filtres to a second row rather than shrinking it. A zero basis makes it take what is left
   * after the button and scroll inside that, which is the whole point of a strip.
   *
   * The fade *is* the scrollbar: a tab strip cut off mid-word with no scrollbar (iOS hides them) reads
   * as a layout that broke, and the same cut under a 24px fade reads as "there is more, push". */
  [data-testid="filter-status"] {
    flex: 1 1 0;
    min-width: 0;
    order: 1;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent);
    -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent);
  }
  [data-testid="filter-status"]::-webkit-scrollbar { display: none; }
  /* One line per tab, always. Left to shrink, "Corbeille 600" broke into two lines and turned a 44px
   * band into a 120px one — the exact cost this rework is about. They scroll instead. */
  [data-testid="filter-status"] button { flex: none; white-space: nowrap; padding-inline: 9px; }
  /* Open, the group is a third row and the search field is a second one. The field takes a whole line
   * because every row it could share also holds controls that refuse to shrink — a flexible basis left
   * it at the width of its own magnifier, a search box with nowhere to type. */
  .filters .search { flex: 1 1 100%; order: 3; }
  .filters[data-open="false"] .search { display: none; }
  .filters .topbar-spacer { display: none; }

  /* The title stops being the first thing the topbar gives up. "Veille marke…" is not a title; with
   * Synchroniser down to its glyph and the count gone there is room for the whole name, and two lines
   * of 14px beats one clipped line of 15.5. The label is clipped rather than removed, so the button
   * keeps its accessible name. */
  .topbar { gap: var(--s-2); padding-inline: var(--s-4); }
  .topbar h1 {
    flex: 0 1 auto;
    min-width: 0;
    font-size: var(--t-base);
    white-space: normal;
    line-height: 1.25;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
  }
  .topbar [data-testid="btn-sync"] span {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .topbar [data-testid="btn-sync"] { padding-inline: var(--s-3); }
  .topbar [data-testid="topbar-count"] { display: none; }

  /* A facet panel anchored to its trigger opens past the right edge here, and an overflowing
   * popover does not get clipped — it drags the whole shell sideways, so the filter bar scrolls out
   * from under the trigger the user just pressed. At this width there is no left or right side of
   * the bar to open from, so the panel stops tracking its trigger: `.ms` gives up its containing
   * block and the panel drops from the filter bar as a sheet, inset to the bar's own gutter. */
  .filters-facets .ms, .filters-facets .period { position: static; }
  .filters-facets .ms-panel, .filters-facets .period-menu {
    top: 100%;
    left: var(--s-4);
    right: var(--s-4);
    width: auto;
  }
}

/* Icons as data-URI masks: no network request, no icon font, and they inherit currentColor.
 * The chevron carries a literal stroke colour as well, because a `<select>` cannot host a
 * pseudo-element and so has to paint its arrow as a background-image, where alpha masking is not
 * available. `#5c646b` is `--muted`, hand-copied because a data-URI cannot read a custom property;
 * it is the one grey that reads correctly against both themes' field fill. Keep the two in step. */
:root {
  --icon-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' d='M7 1.8a5.2 5.2 0 1 0 0 10.4A5.2 5.2 0 0 0 7 1.8zm3.8 8.9L14.2 14'/%3E%3C/svg%3E");
  --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%235c646b' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round' d='M3.5 6 8 10.5 12.5 6'/%3E%3C/svg%3E");
}
