/* latin-ext */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(/static/fonts/inter-var-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(/static/fonts/inter-var-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Admin design system. Tokens in :root; nothing below :root hardcodes a hex.
   Class names are a contract - the test suite asserts on them. See
   docs/superpowers/specs/2026-07-25-admin-redesign-design.md */
:root{
  --paper:#f5f7fa;
  --surface:#ffffff;
  --ink:#141922;
  --soft:#5c6675;
  --faint:#6b7480;
  --line:#e8ecf2;
  --hair:#f0f3f7;
  /* The one grey between --line and --faint: a structural line that is
     meant to be SEEN. All FOUR of its users draw the boundary of ONE
     CLIENT'S BLOCK in the PPC proposal queue, which is the same object in
     two shapes - the group table and the request card above it - and each
     shape draws that boundary twice, once down the side and once across:
     the group rail's right edge (`tr.group-head > td.queue-rail`), the
     striped band between two groups
     (`.queue-group:not(:first-of-type) … td:not(.queue-rail)::before`), the
     card rail's right edge (`.request-rail`) and the striped band between
     two of the card's items (`.request + .request::before`). Why no
     status token does the job there is argued at
     .queue-group below. Why no EXISTING grey does is contrast, against
     --paper, which is what all four are painted on: --rail 2,375,
     --line 1,105, --hair 1,037, --gry-bg 1,046. That edge is ONE pixel wide
     since the rail became a cell (it was a 3px band before), which raises
     the bar rather than lowering it: a hairline at 1,1 is a hairline nobody
     sees. --faint (4,412) is body-text weight and would read as a
     border around the group rather than an edge to it.
     It shares its hex with --cohort-none by COINCIDENCE - that one is „we
     do not know a size" in an ordered ramp whose whole argument is
     lightness steps, and the two must not be merged: moving the ramp would
     silently move the rail, and vice versa. */
  --rail:#9aa3b0;
  --brand:#2563eb;
  --brand-soft:#eef3ff;
  --ok:#12734a;      --ok-bg:#e8f6ee;
  --warn:#95610a;    --warn-bg:#fdf3e2;
  --bad:#b3261e;     --bad-bg:#fdecea;     --bad-wash:#fdeaea55;
  --gry:#5c6675;     --gry-bg:#eff2f6;
  --purple:#6d4bd8;  --purple-bg:#f1ecfe;
  /* Channel triples for the two `td.heat-*` ramps below, which tint a cell
     with an alpha of these colours - and rgba() takes three channels, not a
     hex. Each MUST stay in sync with its own hex above (--purple one line
     up, --bad four): one colour written twice is exactly where silent drift
     starts, and nothing renders both side by side. `color-mix(in srgb, …)`
     would need no duplicate at all, but this stylesheet has no precedent
     for it yet and the heat ramp is not the place to set one. */
  --purple-rgb:109,75,216;
  --bad-rgb:179,38,30;
  /* The annual-turnover ramp of /admin/performance's size-band card. An
     ORDERED scale, not a categorical palette: colors.series in admin-charts.js
     is picked so that its first four slots are as UNLIKE each other as
     possible, which is the opposite of what eight nested bands need.
     Multi-hue on purpose. Nine shades of one hue is the trap `td.heat-*`
     below already had to argue a floor and a ceiling for at FIVE steps; here
     the hue rotates 208 -> 320 degrees while L* falls 90 -> 30 in eight even
     steps, so the scale survives greyscale and a red-green deficiency on
     lightness alone. Measured: the tightest adjacent pair (1-2) is 1,26:1 and
     dE76 12,3; the lightest band sits dE76 18,9 off --surface, well clear of
     the „7 units reads as untinted" floor the heat ramp paid for.
     --cohort-none is NOT a ninth step: „bez odhadu" is our not knowing, not a
     size. Its chroma is C* 7,8 in CIE LCh(ab): under half the 16,1 of the
     palest band, an eighth of the 66,1 of the deepest, and the only step of
     this scale below 10. It sits dE76 >= 23,5 from every band, and it carries
     a second cue rather than a shade - a --soft outline in the chart where
     the bands get a white one, and its own rule above the row in the table.
     Mirrored in admin-charts.js as colors.cohorts / colors.cohortNone;
     a test pins the two together, count included - the ramp has eight shades
     and config.REVENUE_COHORTS has eight bands, and nothing else in the tree
     ties those two numbers to each other. */
  --cohort-1:#bbebf0;
  --cohort-2:#91d5e8;
  --cohort-3:#6abde2;
  --cohort-4:#4ca4dc;
  --cohort-5:#4189d3;
  --cohort-6:#4d6cc3;
  --cohort-7:#604bab;
  --cohort-8:#701f8a;
  --cohort-none:#9aa3b0;
  --row-hover:#fafbfd;
  --radius:14px;
  --radius-sm:9px;
  /* Geometry of the PPC proposal queue, which is ONE queue in two shapes -
     the request card at the top of /admin/proposals/ppc and the group table
     under it. Both numbers live in THIS file only - the responsive sheet
     resets the declarations by hand and the templates carry class names, so
     `grep queue-rail-w` outside admin.css finds nothing, and a token is
     therefore no help there. What they buy is inside this file: without them
     the same two numbers sit in six rules apiece, the two shapes' bands and
     rails a few hundred pixels apart on one page, and nothing would say so
     when they drifted.
     --queue-rail-w replaces the literal 26px in `th.queue-rail-head`,
     `tr.group-head > td.queue-rail` and the card's own `.request-rail`
     (plus the negative offset both boundary bands take to reach back over
     that column). --queue-band-h holds the boundary between two clients'
     blocks at ONE height in both shapes.
     12px, not 16: the gap between two groups was 16 and the card's band was
     12, and one token cannot carry two numbers. 12 won because only it has a
     measured argument - the card holds up to QUEUE_MAX_ITEMS = 60 blocks, so
     16 would have made it ~236px longer. The 16 was inherited from the
     page-local <style> block the group family used to live in, and the
     comment beside it argued the band's COLOUR, never that number. */
  --queue-rail-w:26px;
  --queue-band-h:12px;
  --shadow:0 1px 2px rgba(20,25,40,.06),0 1px 3px rgba(20,25,40,.08);
  /* Tooltip. The surface is Pišta's own ink - the same #1a2233 the logo SVG
     uses for the hat and eyes (see the `pista_logo` macro) - so the one dark
     element in a light admin reads as part of the brand rather than as a new
     grey. Dark on purpose: a white bubble over a white .metric card needs a
     heavy border to separate at all, while this says „transient overlay, not
     content" at a glance. */
  --tip-ink:#1a2233;
  --tip-fg:#eef1f6;
  --tip-shadow:0 8px 20px rgba(20,25,40,.16),0 2px 5px rgba(20,25,40,.10);
  /* The second level of a tooltip: dimmer than the lead, under a hairline.
     Both are tuned against --tip-ink, not against the light admin. */
  --tip-dim:#a9b4c6;
  --tip-rule:rgba(255,255,255,.13);
  /* Non-production marker (ENVIRONMENT_LABEL) - the two stripe colours and
     the text colour of .env-banner. */
  --env-banner-stripe-1:#b45309;
  --env-banner-stripe-2:#92400e;
  --env-banner-fg:#fff;
  --font-sans:'Inter',-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,
    "Helvetica Neue",Arial,sans-serif;
  /* Machine text - a tool name, an argument name, a copyable command, a raw
     prompt. A token rather than a second literal stack: `.code-block` had the
     only copy until /admin/tools needed the same face for `.tool-name` and the
     argument column, and the run transcript would have been the third, and two
     hardcoded lists drift the moment one of them gains a fallback. */
  --font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
body{margin:0;font-family:var(--font-sans);background:var(--paper);color:var(--ink);
  font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
a{color:var(--brand);text-decoration:none}
a:hover{text-decoration:underline}
.pista-logo{flex:none;position:relative;transform-origin:left top;
  transition:transform .25s ease}
.pista-logo:hover{transform:scale(2.5);z-index:30}
.pista-logo:hover .stache-l{animation:pista-stache-l 1.1s ease-in-out infinite}
.pista-logo:hover .stache-r{animation:pista-stache-r 1.1s ease-in-out infinite;
  animation-delay:.15s}
.pista-logo .stache{transform-box:fill-box}
.pista-logo .stache-l{transform-origin:right center}
.pista-logo .stache-r{transform-origin:left center}
.pista-logo .eye{animation:pista-blink 3.4s infinite;
  transform-box:fill-box;transform-origin:center}
@keyframes pista-blink{0%,93%,100%{transform:scaleY(1)}95%,97%{transform:scaleY(.08)}}
@media (prefers-reduced-motion:reduce){.pista-logo .eye{animation:none}}
.pista-loader-wrap{padding:28px 16px;text-align:center}
.pista-loader .eye{animation:pista-blink 3.4s infinite;
  transform-box:fill-box;transform-origin:center}
.pista-loader .stache{transform-box:fill-box}
.pista-loader .stache-l{transform-origin:right center;
  animation:pista-stache-l 1.1s ease-in-out infinite}
.pista-loader .stache-r{transform-origin:left center;
  animation:pista-stache-r 1.1s ease-in-out infinite;animation-delay:.15s}
.pista-loader-text{margin-top:8px;color:var(--soft);font-size:13px}
/* The quip line under the loader. Separate from .pista-loader-text on
   purpose: that one carries the server's information and must not move. */
.pista-loader-quip{margin-top:6px;color:var(--soft);font-size:12px;
  font-style:italic;opacity:0;transition:opacity .4s ease}
.pista-loader-quip.on{opacity:1}
@keyframes pista-stache-l{0%,100%{transform:rotate(0)}50%{transform:rotate(9deg)}}
@keyframes pista-stache-r{0%,100%{transform:rotate(0)}50%{transform:rotate(-9deg)}}
@media (prefers-reduced-motion:reduce){
  .pista-loader .eye,.pista-loader .stache{animation:none}
  .pista-logo:hover .stache{animation:none}
  .pista-logo{transition:none}
  .pista-loader-quip{transition:none}
}
/* Public landing on `/` - Pišta alone, centred, nothing else on the page.
   The waving moustache comes from .pista-loader above, including its
   prefers-reduced-motion guard. */
.landing-body{display:grid;place-items:center;min-height:100vh}
/* blanket floor for everything else (chart transitions, hover states,
   the label skeleton shimmer) - the rules above stay for the two Pišta
   components that need to fully turn their keyframes off, not just speed
   them up */
@media (prefers-reduced-motion:reduce){
  *{animation-duration:.01ms !important;animation-iteration-count:1 !important;
    transition-duration:.01ms !important}
}
/* Waiting state of a started run - see _run_waiting.html. Lived as an
   inline <style> in run_status.html until all three run pages started
   sharing the slot. */
.run-waiting{padding:56px 24px;text-align:center}
.run-waiting strong{display:block;font-size:15px;margin-bottom:6px}
.run-waiting .phase{color:var(--soft);font-size:13.5px;
  font-variant-numeric:tabular-nums}
/* the bulk page injects its metrics grid here - undo the centring */
.run-waiting-extra{margin-top:24px;text-align:left}
/* run-poll.js appends this after N dead polls and removes it on recovery;
   it sits outside [data-run-body] so the next swap cannot wipe it */
.run-waiting-error{margin-top:20px;color:var(--bad);font-size:13px}
/* The bulk run's worker slots - one cell per pool slot, occupied or free,
   redrawn every 2.5 s. The cell count is constant for the whole run
   (`p.slot_count`), so only the text inside a cell ever changes.
   NOT `.cards-grid` of `.metric` tiles: eight more tiles would double the
   summary row above and make the page one long column of boxes. NOT
   `.states`/`.state-item` either - their hairline is `+ .state-item{
   border-left}`, which in a wrapping grid draws a line before the first
   cell of every row after the first. */
.run-slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));
  gap:10px}
/* min-width:0 so a long e-shop name can be ellipsised instead of stretching
   its grid track; min-height keeps a free cell the same size as a busy one,
   so the grid does not jump as clients finish. That floor is a DESKTOP
   concern - it evens out a multi-column grid - and admin-responsive.css
   drops it for `.run-slot-idle` at phone width, where one column of empty
   58px cells is just a hole in the page. */
.run-slot{min-width:0;min-height:58px}
.run-slot-name{display:block;font-size:13.5px;font-weight:600;color:var(--ink);
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
a.run-slot-name:hover{text-decoration:underline}
.run-slot-meta{display:flex;align-items:center;gap:6px;margin-top:4px;
  font-size:12px;color:var(--faint);min-width:0}
/* the age is the one number in the cell that ticks, so it is pushed to the
   far edge and set in tabular figures - it must not shove the name around */
.run-slot-age{margin-left:auto;flex:none;font-variant-numeric:tabular-nums}
.run-slot-free{color:var(--faint);font-weight:500}
/* Header search over the e-shop mirror. Replaced the old e-shop-id/project
   run form (e-shop id + project + Analyzovat) - the analysis now starts
   from the e-shop detail.
   The browser's own outline is never suppressed here (tests/test_admin_a11y.py
   asserts as much over the whole file), so the focus ring is added next to
   it, never instead of it. */
/* No margin-left:auto here: that is a cross-axis auto margin inside
   .page-aside's flex-direction:column, which suppresses stretch (CSS
   Flexbox 9.4 step 11) and absorbs the free space into itself (8.1) - the
   form shrank to its content width even under .page-aside{align-items:
   stretch} at <=1024px. .page-aside{align-items:flex-end} already
   right-aligns the form at desktop widths; no margin is needed here. */
.search-form{position:relative;display:flex;align-items:center}
.search-form .search-icon{position:absolute;left:11px;color:var(--faint);
  pointer-events:none}
.search-form input{width:280px;padding:7px 34px;border:1px solid var(--line);
  border-radius:var(--radius-sm);font-size:13px;background:var(--surface);
  color:var(--ink);transition:border-color .12s ease,box-shadow .12s ease}
.search-form input::-webkit-search-cancel-button{display:none}
.search-form input:focus{border-color:var(--brand);
  box-shadow:0 0 0 3px var(--brand-soft)}
.search-form .search-kbd{position:absolute;right:9px;padding:1px 6px;
  border:1px solid var(--line);border-radius:6px;background:var(--gry-bg);
  color:var(--faint);font-family:inherit;font-size:11px;pointer-events:none}
.search-form input:focus ~ .search-kbd{visibility:hidden}

.search-suggest{position:absolute;top:calc(100% + 6px);right:0;z-index:40;
  min-width:400px;max-width:min(520px,90vw);padding:4px;
  background:var(--surface);border:1px solid var(--line);
  border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;
  animation:search-pop .12s ease}
.search-suggest[hidden]{display:none}
@keyframes search-pop{from{opacity:0;transform:translateY(-2px)}
  to{opacity:1;transform:none}}
/* fetching the next query - the previous rows stay readable instead of
   being replaced by a spinner that would make the panel jump */
.search-suggest.is-loading{opacity:.55}
.search-hit{display:grid;grid-template-columns:minmax(0,1fr) auto;
  gap:1px 10px;padding:8px 10px;border-radius:var(--radius-sm);color:var(--ink)}
.search-hit:hover{background:var(--row-hover);text-decoration:none}
/* keyboard selection, deliberately distinct from :hover */
.search-hit.on{background:var(--brand-soft)}
.search-hit-name{font-weight:600;overflow:hidden;white-space:nowrap;
  text-overflow:ellipsis}
.search-hit-tags{grid-row:1;grid-column:2;display:flex;align-items:center;
  gap:8px;color:var(--soft);font-size:12px;white-space:nowrap}
.search-hit-meta{grid-column:1;color:var(--faint);font-size:12.5px;
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
/* a marker, not a highlighter - weight carries it, no background */
.search-hit mark,.card mark{background:none;color:var(--ink);font-weight:650}
.search-empty{padding:14px 10px;color:var(--faint);font-size:13px}
.search-more{display:block;margin-top:4px;padding:9px 10px;
  border-top:1px solid var(--hair);color:var(--soft);font-size:12.5px}
.search-more:hover{background:var(--row-hover);text-decoration:none}

.card{background:var(--surface);border:1px solid var(--line);
  border-radius:var(--radius);box-shadow:none}
.card + .card,.section{margin-top:16px}
.card-head{display:flex;align-items:center;gap:10px;padding:14px 18px;
  border-bottom:1px solid var(--hair);font-weight:600;font-size:14px;
  letter-spacing:-.01em}
/* The quiet suffix inside a card head („· 3 nástroje", „· BigQuery", „7 z 8
   slotů") - the head's own 600 weight would otherwise make the count read as
   a second title. Deliberately identical to the `<span class="muted"
   style="font-weight:400">` that 29 heads across ten templates still
   hand-roll: same colour, same 400, and NO font-size, so it inherits the
   head's 14px exactly as `.muted` does. That sameness is the point - the
   swap of those 29 sites onto this class is then a provable no-op, and
   `tests/e2e/test_tools_page.py` measures the two in a browser. The „·"
   itself stays in the markup, like theirs; a `::before` here would double
   every one of them on the day of the swap. */
.card-head-note{font-weight:400;color:var(--soft)}
/* Two-line card head: title row (with .push-right actions) over a quiet
   note. A modifier, not a new component - same 18px grid, hairline and
   weight as .card-head; only the flow changes. Declared AFTER .card-head:
   both are single-class selectors, so source order is what lets
   display:block win over .card-head's display:flex (see Pitfalls). The
   note keeps .card-head-note's colour/weight and only here drops to
   12.5px - the base class stays byte-identical for the tools-page e2e
   measurement. */
.card-head-stack{display:block}
.card-head-stack .card-head-title{display:flex;align-items:center;gap:10px}
.card-head-stack .card-head-note{display:block;margin-top:2px;
  font-size:12.5px;line-height:1.35}
.card-body{padding:15px 18px}
.table-wrap{overflow-x:auto;border-radius:var(--radius)}

/* /admin/churn - the cohort grid is wide (up to CHURN_COHORT_WIDTH+2
   columns), so its cells stay narrow instead of inheriting the default
   table's roomy padding; .unconfirmed marks the cells whose month end still
   falls inside the 30-day confirmation window. */
.cohort-table td,.cohort-table th{padding:8px 10px;text-align:center}
.cohort-table td:first-child,.cohort-table th:first-child{text-align:left}
.unconfirmed{opacity:.55}

/* /admin/sales - a table whose rows come in main+sub PAIRS sharing one
   4-column grid under a two-row header. One subject per column, two stacked
   slots each: e-shop over rating, PNO 7 over PNO 30, Revenue 7 over Revenue
   30, deadline over the contact form - so eight facts sit under eight
   labels on the same x, and the two PNO gauges share a width and are
   therefore comparable by eye. Pairing is what fits nine columns' worth of
   data into a 1440px viewport with no horizontal scroll; `.cohort-table`
   was not reused because it narrows a grid but keeps one <tr> per entity.
   Borders are what bind a pair: none inside it, one --line between pairs
   (the global --hair per-row border would slice every pair in half). Row
   hover is off - without :has() only the hovered half of a pair would light
   up, which reads as two entities. Every selector carries `.table-paired`,
   so each rule outranks the global element-level th/td rules below by
   specificity, not by source order (the styleguide's Pitfalls). */
.table-paired th,.table-paired td{padding-left:10px;padding-right:10px}
.table-paired th:first-child,.table-paired td:first-child{padding-left:18px}
.table-paired th:last-child,.table-paired td:last-child{padding-right:18px}
/* `vertical-align:top` in the same rule, not a second one re-declaring it:
   `th` defaults to middle, so the one tier-1 cell carrying a .th-stack (two
   lines) grew the whole tier and re-centred every single-line label 8px
   below its own first line - a vertical stagger introduced by the very cell
   meant to remove a horizontal one. Top puts tier 1 back on one baseline. */
.table-paired thead tr:first-child th{border-bottom:0;padding-bottom:2px;
  vertical-align:top}
/* The two header tiers must read as two registers, not one wrapped line:
   tier 1 names the pair-main row (sentence case, global th style), tier 2
   names the pair-sub row beneath - the uppercase micro register of
   .chart-cell-head, half a pixel smaller so four labels fit their columns
   at 1440px. One rule, not two: a second tr:last-child rule re-declaring
   font-size would win by source order and leave a dead declaration behind
   (the exact trap the /admin/churn comment below warns about). Empty sort
   indicators are hidden in BOTH tiers - deliberately, for consistency: two
   labels stacked in one column must start AND end where the column does,
   and the reserved 1.1em pads the shorter of the two into a different
   shape (the global rule keeps it to avoid a shift on sort, a trade this
   table refuses). The ⓘ moves after the arrow (flex order; DOM and tab
   order unchanged) and shrinks to 13px so eight glyphs stop outweighing
   the small labels. */
.table-paired thead tr:last-child th{padding-top:0;padding-bottom:8px;
  border-bottom:1px solid var(--line);
  font-size:10.5px;text-transform:uppercase;letter-spacing:.05em}
.table-paired thead .sort-ind:empty{display:none}
.table-paired thead .sort-link .info{order:2}
.table-paired thead .info,.table-paired thead .info svg{width:13px;height:13px}
.table-paired thead .info{margin-left:4px}
.table-paired .pair-main td{border-bottom:0;padding-bottom:2px}
.table-paired .pair-sub td{padding-top:2px;border-bottom:1px solid var(--line)}
.table-paired .pair-sub:last-child td{border-bottom:0}
/* The one column that spans the pair („Režim": a property of the e-shop, not
   of a window). Both rules above draw the grid's horizontal lines on the row
   the cell STARTS in, so a cell that reaches down into the next row has to
   restate them, or the header loses its underline over this column and every
   pair loses its divider under it. Specificity, not source order: the two
   selectors these outrank are (0,2,3) and (0,2,1) - `[rowspan]` alone would
   tie and lose, hence `tr:first-child` / `.pair-main` in the new ones too.
   The third rule is the exception the second one needs: `.pair-sub:last-child`
   above drops the line under the LAST pair (a table does not end in a rule),
   and the spanning cell of that pair is in the row before it, so it has to be
   silenced by its own selector - `:nth-last-child(2)` is that row.
   No `vertical-align` here: the global `td` rule already says `top`, and a
   restatement would only look like it was doing something. */
.table-paired thead tr:first-child th[rowspan]{
  border-bottom:1px solid var(--line)}
.table-paired .pair-main td[rowspan]{border-bottom:1px solid var(--line)}
.table-paired .pair-main:nth-last-child(2) td[rowspan]{border-bottom:0}
.table-paired tbody tr:hover{background:transparent}
/* The six columns, sized here rather than left to the browser. THREE
   things depend on `table-layout:fixed`, so it is not an optimisation:

   1. Without it the widths below are only requests. `min-width` is not a
      cap and `table{width:100%}` hands every spare pixel to whichever
      column can still grow, so column 1 took 472px at 1440 - 234 of them
      empty - while the metric columns stayed at their content width.
   2. Percentages of a fixed layout are honoured exactly, so the grid keeps
      its proportions at every width instead of re-solving per viewport.
   3. Column widths stop depending on cell CONTENT, which is what makes
      opening one „zpětně…" disclosure a no-op for the layout. Under auto
      layout the extra 144px of native date picker re-solved the whole
      table and slid every column 42.5px left, mid-interaction.

   The metric columns are deliberately NOT `.num`: right-aligning the whole
   cell parked every header label above the delta badge instead of above
   the value (the v2 stagger). Each value keeps its own fixed slot at the
   START of its window instead - `.pno-val` and `.metric-line .metric-val`
   below - so the digits line up under their own label.

   `min-width` is the fifth column's doing, and the sixth („Režim") raised
   it to 1040px. Percentages divide whatever width there is, so under that
   floor the six labels stop fitting side by side and the header tiers start
   overlapping their neighbours instead of getting narrower. Below it the
   wrapper scrolls (that is what
   `.table-wrap` is for) - a grid that overlaps is unreadable, a grid that
   scrolls is merely inconvenient. Phone width undoes it in
   admin-responsive.css, where the table is not a table at all. */
.table-paired{table-layout:fixed;min-width:1040px}
.table-paired .col-shop{white-space:normal;width:19%}
/* „maximální růst" is two words and the column is the narrowest on the board,
   so it wraps rather than pushing the grid wider - the same trade `.col-measure`
   makes below. The floor above grew with the sixth column (percentages divide
   whatever width there is, and at 1000px six labels no longer fit side by
   side), but only to 1040: the board's wrapper is 1162px wide at the 1440px
   desktop this admin is designed for, and a floor near that width would make
   the whole table scroll where nothing may scroll at all
   (tests/e2e/test_sales_page.py measures both ends). */
.table-paired .col-mode{white-space:normal;width:9%}
.table-paired .col-pno{width:20%}
.table-paired .col-rev{width:15%}
/* white-space:normal because both header tiers over this column are two
   words („Měřicí pixel", „NEZMĚŘENÁ REVENUE") and the global th/td nowrap
   would push them into the next column instead of wrapping them. The
   amount underneath opts back into nowrap - see `.pixel-money`. */
.table-paired .col-measure{white-space:normal;width:14%}
/* 23 %, not a share the sixth column may nibble at: with a recorded contact
   this cell holds „25.09.2026" over „naposledy 12.07.2026, pepa@example.com"
   and needs 256px of the 1162 the wrapper really has at 1440px. At 19 % it
   spilled 35px into nothing - measured, and only AFTER somebody had pressed
   „Ozval/a jsem se" (the empty state fits in anything). */
.table-paired .col-contact{width:23%}
.table-paired .col-shop .review-note{margin-left:8px}
/* /admin/sales' „Měřicí pixel" column. Two slots, and everything in both
   of them starts on the column's own left edge: the dot, the state, the
   span of the break and the „~" all share one vertical line with the two
   header labels above them. That is the whole layout - no flex on the cell
   itself, so the card mode of admin-responsive.css (which turns every `td`
   into a right-justified flex ROW) needs no undoing here.

   The dot is the existing `.dot` with the existing `.badge-red` /
   `.badge-orange` palette; what separates it from the „Výpadek měření"
   PILL a few columns to the left is its SILHOUETTE, not its shade - the
   same argument that shapes `.sort-reset` against `.sort-chip`. Two
   colours and no green: „in order" is ~90 % of the board, and a wall of
   green dots is exactly what would train the eye to stop seeing red ones.

   The ⓘ sits INSIDE the word's span, not beside it as a third flex item:
   as an item of its own it went to the far right of the column the moment
   the state wrapped onto two lines (1280px, „Maskováno GA4"), which reads
   as a glyph belonging to nothing. Inside, it flows after the last word
   wherever that word ends. */
.pixel-cell{line-height:1.35}
.pixel-verdict{display:flex;align-items:center;gap:6px}
/* …and it shrinks to 13px, the same size and for the same reason the two
   header tiers of this table already use: this is the NARROWEST column on
   the board (16 % - 140px at the 1000px floor), and at the default 16px
   „Maskováno GA4 ⓘ" measured 127.3px against 127.3px of room and dropped
   the glyph onto a line of its own. Scoped to this component rather than to
   `.table-paired td`, which would also resize the outage pill's glyph in
   column 1 - a different cell with room to spare. */
/* Scoped to the table cell (.pixel-cell): the e-shop detail's facts row
   renders the same verdict component but is not the narrowest column on a
   board - its ⓘ keeps the standard 16px. */
.pixel-cell .pixel-verdict .info,.pixel-cell .pixel-verdict .info svg{
  width:13px;height:13px}
/* On the e-shop detail's facts row the verdict is a FRAGMENT of one `.mi`
   item (dot + state, with the break span and the client-block trigger
   flowing after it), not a table cell of its own: the block-level flex
   above would force it onto a row of its own inside the item and make the
   defect row taller than the healthy one. Inline-flex keeps the dot
   aligned with the word while the item keeps flowing; /admin/sales is
   unaffected - its verdict never sits inside `.eshop-meta`.
   `vertical-align:bottom`, because the default `baseline` synthesizes the
   inline-flex's baseline from its FIRST flex item - the 7px dot, whose
   baseline is its bottom edge - and rides the whole box ~1.4px above the
   line, growing the defect row over the healthy one after all. The box is
   exactly one line-height tall, so pinning it to the line box's bottom
   changes nothing visible and keeps the row height. */
.eshop-meta .pixel-verdict{display:inline-flex;vertical-align:bottom}
.pixel-range{display:block;font-size:12px;margin-top:2px}
/* `money` separates thousands with a plain space, so without this the
   estimate breaks in the middle of the figure („1 234" / „567 Kč") in the
   narrowest column on the board. */
.pixel-money{white-space:nowrap}
/* „it is an estimate", carried by one glyph rather than by a sentence -
   the sentence is in the header's ⓘ. Quieter and lighter than the amount,
   so it reads as a qualifier on the number and not as part of it. */
.est-mark{color:var(--faint);font-weight:400}
/* The window fills its column now that the column is proportional - which
   is also what widens the gauge (220 -> ~282px at 1440). */
.table-paired .pno-stack{width:100%}
.table-paired .pno-stack .pno-val{flex:none;width:62px;text-align:right}
.table-paired .pno-stack .pno-target{flex:1}
/* Same idiom as .pno-val one line up, and it has to STAY the same idiom:
   `flex:1` gave the amount a 104px slot aligned to its right edge, which
   pushed it 42-95px away from the header label it belongs under, while the
   PNO value sat 62px wide at the left of its window. `min-width`, not
   `width`, so „2 145 000 Kč" still fits without wrapping. */
.table-paired .metric-line .metric-val{flex:none;min-width:74px;
  margin-right:auto}
.table-paired .review-form{margin-top:0;min-width:0}
.table-paired .review-edit{margin:0;align-self:center}
.table-paired .review-edit input[type=date]{width:auto;min-width:0;margin-top:4px}

/* /admin/performance - metrics down, periods across. Two things no other
   table here needs. The metric name has to survive horizontal scrolling:
   twelve money columns plus the name measure 1732px - and twenty-four
   measure 3232px - while a 1440px laptop offers 1162px inside the card
   (the ladder is below), so the first column is sticky and the rest
   scrolls inside .table-wrap - never the page. And the running period has to
   read as „still open" without a legend, which is what .is-current is:
   --brand-soft, the wash .search-hit.on and .badge-blue already use for
   „this is the one you are looking at". Deliberately NOT a status colour
   (the column is not a defect) and deliberately not --gry-bg, which is the
   group band two rules below - one token for both would make the running
   column indistinguishable from a separator.
   table-layout:fixed with a per-view floor, the same pair .table-paired
   argues for: under auto layout a column whose cells are all „-" (a month
   that has not happened yet) shrinks to its header width and the twelve
   months stop being comparable by eye. The floors are measured, not chosen:
   „Počet aktivních PPC e-shopů" is 190px at 13.5px Inter (+18+10 padding =
   232px), and a period column is 125px - 105px of content plus its 2x10
   padding. 105px is what „15 120 384 Kč" (101px, the widest string this
   page can print at today's portfolio) needs; the 92px this ladder used to
   be computed from was the CONTENT width of a seven-figure month with no
   padding added, and at that floor „5 040 128 Kč" overflowed 20px into its
   neighbour. So the whole ladder is 232 + n x 125:

     cols-4 732 · cols-6 982 · cols-8 1232 · cols-12 1732 · cols-24 3232

   ONE column width for all five, deliberately: cols-12 is reachable with
   both granularities (twelve months and twelve quarters), so telling them
   apart would need a second class beside cols-N - and that pair would be
   (0,3,0), out-specifying the (0,2,0) phone reset below and handing a 390px
   screen a 1732px document. Never put a second class on cols-N.
   Every count in `performance.PERIOD_COUNTS` needs a rule here; a test
   reads this file and says so, because a count without one does not fail,
   it dissolves the columns to 51px. All of them are reset in
   admin-responsive.css, where the table becomes cards and a floor would
   only stretch the document past the viewport. */
.table-periods{table-layout:fixed}
.table-periods.cols-4{min-width:732px}
.table-periods.cols-6{min-width:982px}
.table-periods.cols-8{min-width:1232px}
.table-periods.cols-12{min-width:1732px}
.table-periods.cols-24{min-width:3232px}
.table-periods th,.table-periods td{padding-left:10px;padding-right:10px}
.table-periods th:first-child,.table-periods td:first-child{padding-left:18px}
.table-periods th:last-child,.table-periods td:last-child{padding-right:18px}
.table-periods thead th:first-child{width:232px}
/* A sticky cell needs its own opaque background or the scrolled columns show
   through it. The right edge is an inset box-shadow rather than a border:
   table{border-collapse:collapse} resolves adjoining borders once at layout
   time and a stuck cell is known not to repaint one at its stuck position -
   the same reason .review-cell in admin-responsive.css draws its edge this
   way. */
.table-periods thead th:first-child,.table-periods tbody th{position:sticky;
  left:0;z-index:1;background:var(--surface);box-shadow:inset -1px 0 0 var(--line)}
.table-periods tbody th{font-size:13.5px;font-weight:500;color:var(--ink);
  text-align:left;white-space:nowrap;vertical-align:top;
  padding-top:12px;padding-bottom:12px;border-bottom:1px solid var(--hair)}
.table-periods tbody tr:last-child th{border-bottom:0}
/* Restated for the sticky cell: `tbody tr:hover` paints the ROW, and an
   opaque first column would stay white while the rest of the row lights up -
   which reads as two rows. Same fix, same wording, as
   `.table-wrap tbody tr:hover td.review-cell` on the phone sheet. */
.table-periods tbody tr:hover th{background:var(--row-hover)}
/* The running period: a --brand-soft wash PLUS a --brand left edge down
   the whole column, header cell included. The wash alone was enough while
   it was the only tint on the table; next to the heat ramp (`td.heat-*`
   below) it is one more pale wash of much the same lightness, and the
   column stopped reading as a different KIND of thing rather than one more
   value. The edge is an inset box-shadow, never a border-left: this table
   is border-collapse plus table-layout:fixed, so a real border would take
   its pixel out of the column's own width and shift every figure - the
   same reason the sticky first column draws its right edge this way. */
.table-periods .is-current{background:var(--brand-soft);
  box-shadow:inset 1px 0 0 var(--brand)}
/* The group heading is a row of this table, not a card of its own, so the
   columns stay aligned across both groups (spec). Its label lives in the
   sticky first cell and the band is ONE spanning cell beside it: a single
   colspan-13 cell carries its text off-screen with the scroll and leaves an
   empty grey band behind (measured in chromium at scrollLeft 500). */
.table-periods .period-group th,.table-periods .period-group td{
  background:var(--gry-bg);color:var(--soft);font-size:11px;font-weight:600;
  text-transform:uppercase;letter-spacing:.06em;
  padding-top:7px;padding-bottom:7px;border-bottom:0}
.table-periods tbody tr.period-group:hover th,
.table-periods tbody tr.period-group:hover td{background:var(--gry-bg)}
/* The year-on-year change, on its own line under the value. `.delta` is an
   inline badge everywhere else (`margin-left:8px` beside a stat number), and
   this is a modifier INSIDE the .table-periods block rather than a new
   component: same badge, same two colour classes, one line of layout. The
   selector is (0,2,1) against `.delta`'s (0,1,0), so it wins wherever either
   is declared - the ordering trap this admin has already lost three
   overrides to.
   No phone reset belongs here. In card mode `.table-wrap td` is display:flex
   with `gap:6px`, which blockifies its children, so `display:inline` would
   be a dead letter (the computed value stays `block`) and a `margin-left`
   would ADD to the gap - 14px, the only cell in the admin with a different
   one. Left alone, the badge sits after the value with the card's own 6px,
   exactly like every other multi-part cell. */
.table-periods td .delta{display:block;margin-left:0}

/* „do 23. 8." under the running period's name - the quiet second line of a
   two-line header. display:block rather than a <br>, because the phone
   sheet's card labels come from the header's textContent
   (admin-responsive.js) and the two lines have to stay separate text nodes
   with whitespace between them, or the card row reads „srpendo 23. 8." */
.period-open{display:block;font-weight:400;color:var(--soft)}

/* /admin/churn - „Odchody po měsících". One <details> per month; the summary
   is a flex row so the month, the count and the lost MRR line up down the
   collapsed list. `details>summary{cursor:pointer}` is already set below.
   Nothing here layers a second class over an existing one: two single-class
   selectors setting the same property are decided by source order, which is
   how this admin has lost three overrides at runtime already.
   `display:flex` on the summary suppresses the UA `::marker` (the disclosure
   triangle), so the chevron below is hand-drawn instead - without it a
   collapsed month is plain text with a pointer cursor, in a card whose whole
   interaction is "click to expand". */
.churn-month{border-top:1px solid var(--line)}
.churn-month:first-child{border-top:0}
.churn-month>summary{display:flex;gap:18px;align-items:baseline;
  padding:10px 2px;font-size:13.5px}
.churn-month>summary::before{content:"▸";color:var(--faint);font-size:11px}
.churn-month[open]>summary::before{content:"▾"}
.churn-month>summary .m-name{font-weight:600;min-width:74px}
.churn-month>summary .m-mrr{color:var(--soft)}
.churn-month>summary .m-flag{margin-left:auto;color:var(--soft);font-size:12px}
.churn-month-empty{padding:8px 2px 14px;font-size:13px}

/* A whole card folded behind its own head - the proposal queue's „Rozpad
   dle akcí", which a specialist reads rarely but scrolls past every time.
   The <summary> IS a `.card-head`, so the fold keeps the 18px grid, the
   14px weight and the `.card-head-note` suffix every other head on the page
   uses; only three things are added here. `.card-head`'s own `display:flex`
   is what suppresses the UA `::marker`, exactly as on `.churn-month` above,
   so the chevron is hand-drawn - without it a collapsed card is a bold line
   with a pointer cursor and no affordance at all.
   The chevron is ABSOLUTE, sitting in the head's own left padding. As a
   flex item it pushes the title 16px off the grid the cards above and below
   it sit on, and one head out of line is visible in a vertical scan
   (measured: 269px for every head with this rule, 285px without).
   And the hairline goes away while the card is closed - `.card-head`'s
   border-bottom under a collapsed card is a rule with nothing under it.
   Every rule here out-specifies `.card-head`'s (0,1,0) - (0,1,1) for
   `.card-fold>summary`, (0,1,2) with the `::before`, (0,2,1) for the
   `:not([open])` one - so all four win on specificity and not on file
   order. `details>summary{cursor:pointer}` is already set below.
   `left:6px` is one value for both widths, checked at the narrow one:
   `admin-responsive.css` drops the head's padding to 13px on a phone, and
   the 5.4px glyph then sits at 6-11.4px against a title starting at 13
   (a 1.6px gap, against 6.6 on the desktop's 18px padding). Tight, but
   still inside the head's own padding and clear of both the title and the
   card edge - measured in chromium at 390px, not guessed. */
.card-fold>summary{position:relative}
.card-fold>summary::before{content:"▸";position:absolute;left:6px;top:50%;
  transform:translateY(-50%);font-size:11px;font-weight:400;
  color:var(--faint)}
.card-fold[open]>summary::before{content:"▾"}
.card-fold:not([open])>summary{border-bottom:0}

table{width:100%;border-collapse:collapse;font-size:13.5px}
th{font-size:11.5px;text-transform:none;letter-spacing:.04em;font-weight:500;
  color:var(--faint);text-align:left;padding:11px 18px;
  border-bottom:1px solid var(--hair);white-space:nowrap}
td{padding:12px 18px;border-bottom:1px solid var(--hair);white-space:nowrap;
  vertical-align:top}
tbody tr:last-child td{border-bottom:0}
/* The two rows of the size-band table that are not a band, both detached by
   a rule ABOVE them: „bez odhadu" because it is a different kind of thing,
   „Celkem" because it is the sum of everything above it. No background here:
   the global `tbody tr:hover` paints the ROW, and a td background would
   leave these two rows dead to hover while every band lights up. */
.row-apart td{border-top:1px solid var(--line)}
.row-total td{border-top:2px solid var(--line);font-weight:600}
tbody tr:hover{background:var(--row-hover)}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td.wrap{white-space:normal;min-width:280px;max-width:520px}
/* An error message inside a `td.wrap`, clamped to three lines, plus the
   sample of clients under it (the bulk-run failure summary).
   The clamp sits on a <span> INSIDE the cell and not on the cell itself on
   purpose: admin-responsive.css has `.table-wrap td.wrap{display:block}` at
   (0,2,0), so `display:-webkit-box` on the same cell at (0,1,0) would lose
   on a phone and the clamp would silently stop working exactly where the
   text is longest. */
.err-text{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;
  overflow:hidden;font-size:13.5px;font-weight:500}
.fail-clients{display:block;margin-top:4px;font-size:12.5px;font-weight:400;
  color:var(--soft);white-space:normal}
/* A name column that must not decide the table's width. `td` is
   white-space:nowrap, so one long e-shop name IS that column's min-content
   contribution, and on /admin/season the name is a `<strong>` inside a link
   (Inter 13.5px/700) with five more columns beside it.
   The cap hangs on an inline-block INSIDE the cell, never on the `td`:
   chromium ignores `max-width` on a cell under auto layout, which is why
   `td.wrap`'s own `max-width:520px` has never done anything (`.tool-arg-desc`
   sidesteps it the same way, and says so).
   The only numbers here that anything measures are the ones
   tests/e2e/test_season_page.py reads off the rendered page, and it is that
   test - not this comment - that fails when they move: at a 1440px viewport
   /admin/season's wrap is 1162px and the table's min-content is 1098px, so
   the cap buys 64px of headroom; deleting the `max-width` below takes the
   same seed to 1229px, i.e. 67px past the wrap, which is the defect
   /admin/clients has today (124px, its last column off screen).
   `vertical-align:bottom` keeps the ellipsis on the text's own baseline
   instead of lifting the box by the descender. */
.name-clip{display:inline-block;max-width:280px;overflow:hidden;
  white-space:nowrap;text-overflow:ellipsis;vertical-align:bottom}
/* The narrow cap, for an identifier that is not the row's name - an order
   number on the pixel tab, where eight columns share the width and the full
   value is one click away in the raw event. The phone override
   `.table-wrap td .name-clip` (0,2,1) still beats this (0,1,0). */
.name-clip-narrow{max-width:140px}
/* A product thumbnail's cell: a fixed, narrow slot that must not grow with
   the image Merchant happens to return. `object-fit:contain` keeps a
   portrait photo whole inside the square instead of cropping it, and
   `display:block` on the <img> removes the inline line box under it (the
   engine-dependent gap the styleguide's Pitfalls warn about).
   Was a page-local <style> block in client_ads.html until the outage card
   became its second consumer - two tables drawing the same 40px square is
   exactly what belongs here rather than in either page. Its card-mode
   behaviour lives in admin-responsive.css, where the rules it has to beat
   are. */
.thumb-cell{width:44px;padding:2px 4px}
.thumb-cell img{width:40px;height:40px;object-fit:contain;border-radius:4px;
  background:var(--surface);display:block}
/* A quiet second line inside a table cell - the countdown under the onset
   date on /admin/season. `.muted` alone is NOT this: it sets colour and no
   size, so the second line comes out at the cell's own 13.5px and reads as
   loudly as the fact above it (the argument `.eshop-meta.queue-sums` makes
   about the same trap one component over).
   font-weight is load-bearing, not cosmetic: at phone width
   `admin-responsive.css` gives `.table-wrap td:first-child` weight 600 and a
   child would INHERIT it, so a sub-line in a first column would come out
   bold exactly where it has to be quieter than the name above it.
   Tabular figures because this line is a column of numbers read downwards
   („za 9 dní" over „za 91 dní"), the same reason `.run-slot-age` sets them. */
.cell-sub{display:block;margin-top:3px;font-size:12.5px;font-weight:400;
  color:var(--soft);font-variant-numeric:tabular-nums}
/* Modifier for a sub-line long enough to decide its column's width. `td` is
   nowrap and the span inherits it, so a 22-character line („cast kampani
   nezmerena" on /admin/potential) becomes the column's min-content
   contribution: measured at 1280px, that column went 126px -> 207px and the
   table 1690px -> 1770px inside a 1002px wrap, i.e. 80px of extra sideways
   scrolling bought by one line of explanation. Wrapping spends the height
   the cell has instead. Opt-in, because the short sub-lines („stary vzorec",
   „(9 dni)") fit on one line and would only get ragged. */
.cell-sub-wrap{white-space:normal}

/* Money and percentages line up on Inter's tabular figures - a mono face was
   tried on figures and rejected as too terminal-looking for this admin. Mono
   survives only where a string IS an identifier (see `--font-mono`). */
td.num,.metric .value{font-variant-numeric:tabular-nums}
/* Heat: a cell tinted by where its value ranks inside its OWN row (see
   src/performance.py::heat_classes for the ranking; the class arrives
   pre-computed, never as an inline style). A cell component, the same
   family as `td.num` above - not a /admin/performance rule, even though
   that table is its only consumer today.

   --purple, not --brand. At the alpha step 1 needs (.07), --brand renders
   #f0f4fe against the #eef3ff of --brand-soft - the „still open" wash
   `.is-current` puts on the running column of that very table - i.e. 2, 1
   and 1 units apart per channel, two meanings in one colour on one table.
   (At .05, where this ramp used to start, the distance was 6, 4, 1: still
   far too close, but the earlier version of this comment quoted the .07
   figure against the .05 colour and was wrong about which.) Not --gry-bg
   either, for the reason .period-group already gives. --purple is the one
   token in this palette that carries no status, which is exactly what
   „bigger" needs. --bad on the negative family is the opposite choice on
   purpose: a negative bilance IS a status.

   Both ramps are strictly increasing, and the FLOOR is as load-bearing as
   the ceiling: at .05 the first step landed 7 units from white and was
   indistinguishable from an untinted cell beside it, which made a
   five-bucket scale read as four buckets and an empty one. The floors
   below put step 1 at 10/13/3 (purple, #f5f2fc) resp. 5/13/14 (red,
   #faf2f2) units off white, while keeping it lighter than the --gry-bg of
   the group band: relative luminance .9016 resp. .9028 against the band's
   .8851. The band must stay the darkest horizontal thing in the table.

   Those two figures are over the EXACT composite, like the contrast pair
   below; rounded to the 8 bits a browser paints (which is what the guard
   test computes) the same two read .8995 and .9024, and the red step's
   blue channel - 241.5 on the nose - comes out 13 units off white rather
   than 14. Both conventions clear their thresholds with room, but say
   which one a number is: an unlabelled figure in this comment has been
   read as wrong twice already.

   The ceilings (.17 purple, .14 for the red family, whose hue is darker at
   equal alpha) are where --soft still reads at 4.55:1 resp. 4.61:1 over
   the exact composite - 4.53:1 and 4.62:1 once the channels are rounded to
   the 8 bits a browser actually paints. That is the same AA bar
   test_body_text_tokens_meet_wcag_aa_contrast_on_surface holds, and
   test_the_heat_tints_keep_body_text_at_wcag_aa keeps holding it here (on
   the 8-bit composite, the stricter of the two). The text that actually
   lands in these cells is --ink, at 13.7:1 in the deepest step.

   One consequence of the approved „rank by magnitude" rule, deliberate and
   not a defect: a SMALL loss is the palest cell of its row, in the month
   somebody most wants to act on. The raised floor is what keeps it
   visible at all.

   Specificity (0,1,1) is order-independent by design: it loses to
   `.table-periods .is-current` (0,2,0) and to `.table-periods
   .period-group td` (0,2,1) wherever any of them is declared, so the
   running column stays blue and the group bands stay grey. `tbody
   tr:hover` paints the ROW, so a tinted cell's own background wins by
   paint order rather than specificity and keeps its tint under the cursor;
   the sticky first column is a <th> and no tint reaches it. */
td.heat-1{background:rgba(var(--purple-rgb),.07)}
td.heat-2{background:rgba(var(--purple-rgb),.095)}
td.heat-3{background:rgba(var(--purple-rgb),.12)}
td.heat-4{background:rgba(var(--purple-rgb),.145)}
td.heat-5{background:rgba(var(--purple-rgb),.17)}
td.heat-n1{background:rgba(var(--bad-rgb),.06)}
td.heat-n2{background:rgba(var(--bad-rgb),.08)}
td.heat-n3{background:rgba(var(--bad-rgb),.1)}
td.heat-n4{background:rgba(var(--bad-rgb),.12)}
td.heat-n5{background:rgba(var(--bad-rgb),.14)}
/* an eshop/campaign ID next to its name - colour comes from whatever class
   (.muted, an <a>) sits alongside it; this only drops the size */
.id{font-size:11.5px}
/* The ⓘ trigger. A 16px drawn circle (the SVG lives in the `info` macro in
   admin/_ui.html), never a font character - see the macro's comment.

   The floor that keeps `span.state-key{display:flex}` on /admin/potential
   from squeezing this into a 9.3px ellipse with the letter cut off is the
   explicit `width:16px;height:16px` below: a flex item's automatic minimum
   size defaults to its specified size, not 0, so `.info` cannot shrink under
   ordinary flex-shrink pressure regardless of `flex:none` - confirmed by
   deleting `flex:none` alone and watching
   tests/e2e/test_tooltips.py::test_every_info_glyph_is_a_circle_at_every_width
   keep passing on every glyph this markup currently renders. `flex:none` is
   belt-and-braces here, not the load-bearing part for *that* squeeze - but
   it is still load-bearing against a different one: an ancestor or `.info`
   itself gaining `overflow:hidden` resets the automatic minimum back to 0,
   and `flex:none` (unlike the specified size alone) keeps protecting the
   glyph even then, because `flex-shrink:0` disables shrinking outright
   rather than relying on a floor the shrink algorithm has to respect. The
   original squeeze this comment documents was almost certainly against an
   older, non-SVG glyph that had no specified size to fall back on either
   way - which is why both properties are kept. */
.info{display:inline-flex;align-items:center;justify-content:center;flex:none;
  position:relative;width:16px;height:16px;margin-left:6px;
  color:var(--faint);line-height:1;vertical-align:middle;cursor:help;
  transition:color .12s ease}
.info svg{display:block;width:16px;height:16px}
/* A 26px finger target, invisible and outside the layout. Only on a coarse
   pointer: on the desktop this ring would swallow the click that sorts the
   column, because the script eats clicks on a tooltip trigger. */
@media (pointer:coarse){
  .info::before{content:"";position:absolute;left:50%;top:50%;width:26px;
    height:26px;transform:translate(-50%,-50%);border-radius:50%}
}
/* The glyph acknowledges hover, keyboard focus, and - on touch, where there is
   no hover at all - the fact that its own bubble is the one that is open. */
.info:hover,.info:focus-visible,.info.info-on{color:var(--brand)}
.info:focus-visible{outline:2px solid var(--brand);outline-offset:2px}

/* Tooltips - one shared bubble that admin-tooltip.js moves and fills; every
   `title=` and `data-tip=` in the admin goes through it. `position:fixed` (and
   living in <body>) is what keeps it clear of `.table-wrap`'s overflow
   clipping on header rows. z-index sits above the whole ladder: 30 logo,
   40 topbar/suggest, 50 nav overlay, 60 sidebar. */
.tip{display:none;position:fixed;left:0;top:0;z-index:70;
  padding:9px 12px;border-radius:var(--radius-sm);
  background:var(--tip-ink);color:var(--tip-fg);box-shadow:var(--tip-shadow);
  font-size:13.5px;line-height:1.5;font-weight:450;letter-spacing:.002em;
  text-align:left;
  /* Never let the bubble take the pointer: it hangs over the trigger's
     neighbours, and eating a hover there would break the table underneath. */
  pointer-events:none;
  opacity:0;transform:translateY(3px);
  transition:opacity .1s ease-out,transform .1s ease-out}
.tip.tip-shown{display:block}
.tip.tip-on{opacity:1;transform:none}
/* A rotated square, half of it behind the bubble. `--tip-arrow-x` is set per
   trigger, so the arrow points at the glyph even when the bubble has been
   pushed sideways to stay on screen. */
.tip::after{content:"";position:absolute;left:var(--tip-arrow-x,50%);
  bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:1px;
  background:var(--tip-ink);transform:rotate(45deg)}
.tip.tip-below::after{bottom:auto;top:-4px}
/* Two levels. The lead answers „what is this", the detail „where does it come
   from" - and the reader has to be able to stop after the first one, which is
   what the rule and the dimmer colour are for. */
.tip .tip-lead{display:block}
.tip .tip-more{display:block;margin-top:7px;padding-top:7px;font-size:12.5px;
  color:var(--tip-dim);border-top:1px solid var(--tip-rule)}
/* No reduced-motion block of its own: the blanket floor above (`*{
   transition-duration:.01ms}`) already collapses both fades, and the bubble is
   painted at its final position either way - `placeAt()` sets left/top before
   the class that fades it in, and neither is transitioned. That floor covers
   the chart bubble below for the same reason - it is the same `.tip`. */
/* The queue chart's tooltip (admin-proposal-charts.js): the same bubble,
   holding one day of a 30-day chart. Chart.js's own tooltip is switched off
   there, because it draws INTO the canvas - and the canvas is 88px tall,
   where a 247x85 bubble is 96,6 % of the height, gets clamped to the top of
   the chart wherever the cursor is, and covers the very day being read.

   No `max-width` here, and that is the second version of this rule: it
   said `min(340px,calc(100vw - 20px))`, which is a COPY of
   admin-tooltip.js's `MAX_WIDTH_PX` and `GUTTER_PX` with nothing holding
   the two together - dropping MAX_WIDTH_PX to 300 left the whole suite
   green and the two `.tip` bubbles on this page at two widths. The cap now
   lives in `placeAt`, which every bubble goes through. */
.tip-chart .tip-chart-day{display:block;font-weight:600;margin-bottom:6px}
/* Two columns - the series (swatch + name) and its figure. The figure's
   column is `auto` and right-aligned, so the three amounts stand under each
   other and can be compared without being read. `align-items:baseline`,
   because the swatches are the LEGEND's own: `.chart-legend-swatch-line`'s
   translateY tuning is set against a baseline row and lands 2px off in a
   centred one. */
.tip-chart .tip-chart-rows{display:grid;grid-template-columns:1fr auto;
  gap:3px 16px;align-items:baseline}
.tip-chart .tip-chart-name{display:flex;align-items:baseline;gap:7px;
  color:var(--tip-dim)}
.tip-chart .tip-chart-value{text-align:right;font-weight:600;
  color:var(--tip-fg);white-space:nowrap;font-variant-numeric:tabular-nums}
/* The dotted swatch draws its gaps in the colour BEHIND it (it cannot make
   them transparent - its own background-color is the series colour), and in
   here that is the bubble's ink, not the page's --surface.

   (0,3,0), which beats the base `.chart-legend-swatch-line.is-dotted`
   (0,2,0) on specificity rather than on file order. It is NOT competing
   with `tr.group-head .chart-legend-swatch-line.is-dotted` further down:
   that one is (0,3,1) - an earlier version of this comment said (0,2,1) -
   and it could not reach this element anyway, which is a <body> child with
   no <tr> above it. */
.tip-chart .chart-legend-swatch-line.is-dotted{
  background-image:repeating-linear-gradient(to right,
    transparent 0 2px,var(--tip-ink) 2px 4px)}
.excluded-names{display:flex;flex-wrap:wrap;gap:4px 16px;margin:6px 0 10px 2px;
  font-size:13px}
details>summary{cursor:pointer}
/* Shared by every page that hangs a Chart.js canvas off a lazily-loaded
   fragment (client_ads.html, potential.html, …) - `position:relative` is
   what Chart.js's `responsive`+`maintainAspectRatio:false` resize logic
   measures against; a page's own <style> only overrides height/padding. */
.chart-wrap{position:relative}
/* A coloured legend swatch next to a chart series label - moved here from
   client_ads.html's local <style> so a second chart-bearing page
   (potential.html) doesn't need to redeclare it to get a visible dot. */
.mark-dot{display:inline-block;width:8px;height:8px;border-radius:2px;
  margin-right:4px;vertical-align:middle}
/* The band swatch in the size-band table's first cell. The table is that
   chart's legend, so the dot and the stacked segment must be the same
   colour - which is why the colour comes from a CLASS INDEX written by the
   template, not from an inline style: the chart payload deliberately
   resolves no colour (src/revenue_cohort_chart.py) and there is no hex to
   inline. */
.cohort-dot{display:inline-block;width:10px;height:10px;border-radius:3px;
  margin-right:8px;vertical-align:-1px;flex:none}
.cohort-dot-1{background:var(--cohort-1)}
.cohort-dot-2{background:var(--cohort-2)}
.cohort-dot-3{background:var(--cohort-3)}
.cohort-dot-4{background:var(--cohort-4)}
.cohort-dot-5{background:var(--cohort-5)}
.cohort-dot-6{background:var(--cohort-6)}
.cohort-dot-7{background:var(--cohort-7)}
.cohort-dot-8{background:var(--cohort-8)}
/* The inset ring, not just a grey fill: „bez odhadu" differs from the bands
   in SILHOUETTE as well as in shade, the same argument .sort-reset makes
   against .sort-chip. */
.cohort-dot-none{background:var(--surface);
  box-shadow:inset 0 0 0 2px var(--cohort-none)}
/* Lazily-loaded chart card: the slot holds a loader until the fragment
   arrives, then the fragment's own markup. Shared by the e-shop overview and
   the Google Ads tab through admin/_white_cards.html - a page's own <style>
   only overrides height/padding. */
.ps-slot{min-height:150px;display:flex;align-items:center;justify-content:center}
.ps-wrap{display:flex;gap:28px;align-items:center;padding:8px 16px 16px;flex-wrap:wrap}
.ps-chart{position:relative;width:170px;height:170px;flex:none}
.ps-chart .ps-total{position:absolute;inset:0;display:flex;flex-direction:column;
  align-items:center;justify-content:center;pointer-events:none}
.ps-chart .ps-total b{font-size:22px;line-height:1;font-variant-numeric:tabular-nums}
.ps-chart .ps-total span{font-size:11px;color:var(--soft)}
.ps-legend{list-style:none;margin:0;padding:0;font-size:13.5px;min-width:220px}
.ps-legend li{display:flex;align-items:center;gap:8px;margin:7px 0}
.ps-legend .dot{width:12px;height:12px;border-radius:3px;flex:none}
.ps-legend .cnt{margin-left:auto;padding-left:16px;font-weight:600;
  font-variant-numeric:tabular-nums}
@media (max-width:699px){
  .ps-wrap{flex-direction:column;align-items:stretch;gap:14px;
    padding:8px 13px 16px}
  .ps-chart{margin:0 auto}
  .ps-legend{min-width:0}
}
/* The stock trend chart (stock_cards()) and the merchant trend chart
   (merchant_status_cards()), both from admin/_white_cards.html, need an
   explicit height wherever they render - .chart-wrap above only sets
   position:relative. */
#stock-trend-card .chart-wrap,
#merchant-trend-card .chart-wrap{height:300px;padding:12px 16px 16px}
@media (max-width:699px){
  #stock-trend-card .chart-wrap,
  #merchant-trend-card .chart-wrap{height:220px;padding:8px 10px 12px}
}
/* The size-band card's stacked chart. .chart-wrap only sets
   position:relative, so this is the fixed height that stops Chart.js's
   responsive+maintainAspectRatio:false loop from growing the canvas, its
   parent, and then the canvas again. (0,2,0) against .chart-wrap's (0,1,0),
   so it wins on specificity, not on source order.
   360px, not the 260-300px the other chart cards use: this one draws NINE
   stacked datasets and, at 24 months, x labels Chart.js rotates - at 300px
   the thinnest band was under 2px of a ~180px plot. */
.chart-wrap.chart-wrap-cohorts{height:360px;padding:12px 16px 16px}
@media (max-width:699px){
  .chart-wrap.chart-wrap-cohorts{height:260px;padding:8px 10px 12px}
}
/* The queue's per-e-shop chart, inside the group head's colspan cell.
   Two classes, like .chart-wrap-cohorts above: (0,2,0) beats .chart-wrap's
   (0,1,0) whatever the source order. The height is on the WRAPPER - under
   `responsive` + `maintainAspectRatio:false` Chart.js measures its parent.
   88px because twenty of these share one page: at 120 the queue grows a
   screen and a half of scroll, at 60 the three series stop being told apart.
   No mobile height variant on purpose: at 390px the chart is 338 wide and
   what is missing is WIDTH, which compact axis labels solve, not height -
   20 groups x 22px would be 440px of scroll for nothing.

   The absolute canvas is the LOAD-BEARING half of this rule, not a tidy-up.
   Chart.js writes the width it measured onto the canvas as an inline px
   value; inside an auto-layout table that value becomes the colspan cell's
   min-content contribution, so the table can never get narrower than the
   widest the window has ever been. Measured in chromium: draw at 1920, pull
   the window to 1100, and the table stays 1642px - 438px wider than the
   same queue with no chart, with the Aplikuji/Zamítám column off the right
   edge. Out of flow, the canvas contributes nothing and Chart.js
   re-measures down. A load-time screenshot cannot see this; only a resize
   after the chart has drawn can. */
.chart-wrap.chart-wrap-queue{height:88px}
.chart-wrap.chart-wrap-queue>canvas{position:absolute;left:0;top:0}
/* The block the chart and its legend live in. Its own class because the
   note under the chart sits in a TABLE CELL, not in a card body:
   `.chart-legend-note` would indent it 16px past the e-shop name above it
   (another 13px on a phone, admin-responsive.css) and spend 24px of padding
   twenty times over. */
.queue-chart{margin-top:9px}
/* --- /admin/season's expandable 24-month turnover chart ---------------
   The same family as .chart-wrap-queue above: a Chart.js canvas inside a
   table cell, with the same two rules doing the same load-bearing job.

   The height is on the WRAPPER - under `responsive` +
   `maintainAspectRatio:false` Chart.js measures its parent - and the canvas
   is taken OUT OF FLOW. That second line is the load-bearing half, not a
   tidy-up: Chart.js writes the width it measured onto the canvas as an
   inline px value, and inside an auto-layout table that value becomes the
   colspan cell's min-content contribution, so the table could never get
   narrower than the widest the window has ever been. The season table's
   whole headroom is 64 px (tests/e2e/test_season_page.py), so it has none
   of that to give. Out of flow the canvas contributes nothing and Chart.js
   re-measures down. A load-time screenshot cannot see this; only a resize
   AFTER the chart has drawn can.

   200px, against the queue's 88: one of these is open at a time rather than
   twenty per page, it draws 24 bars instead of 30 days of lines, and the
   x labels Chart.js rotates at that count need the room. */
.chart-wrap.chart-wrap-season{height:200px}
.chart-wrap.chart-wrap-season>canvas{position:absolute;left:0;top:0}
/* The strip itself. `td` is white-space:nowrap for the table's sake (the
   name column's min-content is one long e-shop name), which the subtitle
   below the chart title must not inherit - it is a sentence. */
tr.season-detail>td{white-space:normal;background:var(--hair);
  padding:12px 18px 14px}
/* The affordance, and the class is added by admin-season.js rather than
   rendered by the server: without the script there is no expander, and a
   row that looks clickable and is not is worse than a row that does not. */
tr.is-expandable{cursor:pointer}
tr.is-expandable.is-open>td{background:var(--hair)}
/* The block the chart, its title and its legend live in - the counterpart
   of .queue-chart above, and its own class for the same reason: this sits
   in a TABLE CELL, not in a card body.

   900px is a CEILING ON WHAT THE STRIP CAN COST THE TABLE, not a taste
   about chart width, and it is why the strip cannot overflow: 900 plus the
   cell's 36px of padding is 936, against a table whose own min-content
   floor is 1 098 px at 1440 (both measured in tests/e2e/
   test_season_page.py, which spells the arithmetic out). Without a cap the
   widest line in here - the subtitle, one unbroken sentence - would set the
   colspan cell's min-content and could push the table past its wrap. The
   cost is dead space to the right of the chart on a wide screen; the table
   staying inside its wrap is worth more than filling it. */
.season-chart{max-width:900px}
.season-chart-head{margin-bottom:8px}
.season-chart-head strong{display:block;font-size:13px;color:var(--ink)}
/* Not `.muted`: this is a component of its own and borrowing a utility
   would tie its size to every other use of that class - and inside
   `.chart-legend-inline` that utility is dead anyway, out-specified (0,2,0)
   against (0,1,0) by the legend's own name rule.
   `display:block` because the head stacks two of these under the title -
   what the marking means, and which months the axis spans - and two inline
   spans would run together into one sentence that is not one. */
.season-chart-note{display:block;font-size:12px;color:var(--soft);
  line-height:1.5}
/* --- the proposal queue's group head ---------------------------------
   These four rules lived in a <style> block in proposals.html until
   2026-08-29. That block loaded AFTER admin-responsive.css, so anything in
   it silently beat the phone sheet at equal specificity - which is a trap
   whoever writes the next phone override walks into, not a tidiness
   question: the 699px reset of the group rail below would have had to win
   against a stylesheet that is not even the one it is written in. The
   styleguide bans page-local <style> for exactly this reason. Values are
   unchanged except the padding (see the 18px grid note on the next rule)
   and the heading size two rules down. */
/* 18px, not the 12px this carried in the page block: the horizontal grid of
   this admin is 18px (`th`, `td`, `.card-head`, `.filters` all share it) and
   at 12 the e-shop name started 6px left of the „Kampaň" <th> and of every
   campaign name under it - measured in chromium at 1440, x=263 against
   x=269. Costs 12px of the chart's width out of 1138. */
tr.group-head td{background:var(--paper);padding:10px 18px}
/* The lazy stats strip under the e-shop name. It reuses .eshop-meta for the
   dot separators, but sits inside a bold-ish table header row, so it resets
   the weight and steps the size down a notch. */
tr.group-head .eshop-meta{margin-top:4px;font-weight:400}
/* The e-shop name is the title of everything under it and was rendering at
   the table's own 13.5px - the same size as the „· 2 návrhy · analýza…"
   suffix beside it, so the head had no title at all, just a long grey line
   with two bold words in it. 15px is `.table-wrap td:first-child`'s phone
   heading size, which is the same job at the other width. Costs 2.4px of
   group height. The weight is restated because the size makes it a
   heading: <strong> alone would inherit 600 from the UA and lose it the
   day this cell gets a weight of its own. */
tr.group-head td a strong{font-size:15px;font-weight:600}
.group-impact{float:right;font-weight:600}
/* Drops the impact figure onto its own line under the e-shop name instead
   of floating beside it. It sits in admin.css rather than in
   admin-responsive.css only because the whole family lives here; nothing in
   the phone sheet touches `.group-impact`, so there is no cascade to lose. */
@media (max-width:699px){
  .group-impact{float:none;display:block;margin-top:3px}
}
/* The group rail: one real cell spanning the whole group, carrying the
   e-shop's name set on its side. It replaces the 3px `box-shadow:inset` that
   used to sit on the first cell of every row - that one could say where a
   group REACHED but never whose it was, and after two rounds of reviewers
   asking again, the span alone turned out not to be enough to tell two
   clients apart. Still no status token: a group is not ok, warn or bad, and
   painting it in one of those colours would claim a verdict the queue has
   not got.

   Every declaration here has to beat `tr.group-head td` (0,1,2, ~55 lines
   up), because the rail cell sits in that very row: `td.queue-rail{padding:0}`
   is (0,1,1) and LOSES - measured in chromium, computed padding-left stayed
   18px and the column bottomed out at 36px instead of 26.

   `min-width`, not `width` alone. In an auto layout `width` is only a
   preference: once the table overflows its wrapper (measured on the e2e seed,
   `.table-wrap` scrolls 43px at 1280 and 223px at 1100) the column collapses
   to 0px and the rail, name and all, disappears exactly where the queue is at
   its most cramped - measured at 1100, 26,0px shipped against 0px with the
   `min-width` dropped. With it the column is 26,0px at 1920, 1440, 1280 and
   1100 alike, and it costs the table exactly 26px of MIN-content (1118 ->
   1144 at 1440) while `table.scrollWidth` does not move at all (1162).

   One consequence of the per-group <tbody> is deliberate and worth naming:
   `tbody tr:last-child td{border-bottom:0}` above applies to the last row of
   EVERY group, not just the last of the table - which is what keeps a
   hairline from stacking under the gap below. */
th.queue-rail-head{width:var(--queue-rail-w);min-width:var(--queue-rail-w);padding:0}
/* `position:sticky` pins the rail while `.table-wrap` scrolls sideways
   (at 1280 it scrolls by 43px): measured at 1280, `scrollLeft=40` leaves the
   rail at x=251, exactly the wrap's own x, while with `position:static` the
   same scroll puts it at x=211, i.e. entirely off-screen -
   losing the one thing this change exists for at the width where the queue
   needs it most. It doubles as the containing block the absolutely
   positioned name below needs, so no `position:relative` beside it: the two
   are the same property and the second would simply overwrite the first.
   The sticky + `border-collapse` warning in admin-responsive.css (a stuck
   cell does not repaint a COLLAPSED BORDER at its stuck position) does not
   reach this cell, and that is a property of the cell rather than luck: its
   edges are a background and an inset shadow, both painted as part of the
   cell's own box - verified in chromium, scrolled to 40px, the rail keeps
   its --paper fill and its hairline. The <th> above it deliberately does NOT
   pin: it has nothing to keep, and an empty pinned cell would only clip the
   „Kampaň" label sliding under it.
   The hairline right edge was the token's first user and is now one of
   four - the group's band below, the card's rail and the card's band all
   draw the same boundary in the same grey (see the token itself). It is an
   inset shadow rather than the `border-right` the design asked for,
   for the reason the old rail already had to argue: on a
   `border-collapse:collapse` table a real border is centred on the dividing
   line and paints half of itself outside, and here it would be a collapsed
   border on a sticky cell as well - the exact pair the warning above names.
   The pinned review column and `.table-periods`' sticky first column draw
   their edges the same way.
   `background` is a deliberate RESTATEMENT, not a missing colour: the same
   --paper already reaches this cell from `tr.group-head td` above, and
   deleting the declaration here changes no pixel today. It stays because
   what needs the fill is the STICKY cell - an unpainted stuck cell lets the
   scrolled content run under the name - and that requirement has nothing to
   do with the rule that gives the head ROW its colour, which is free to
   change. */
tr.group-head > td.queue-rail{width:var(--queue-rail-w);
  min-width:var(--queue-rail-w);padding:0;
  position:sticky;left:0;z-index:1;background:var(--paper);
  box-shadow:inset -1px 0 0 var(--rail)}
/* `writing-mode:vertical-rl` + `rotate(180deg)` is the European book spine -
   the text starts at the bottom and grows upwards. The rotation turns the
   inline axis with it, so inline-end is visually at the TOP: `text-align:end`
   is what seats a SHORT name up by the head that owns it (measured at 1440
   on the seeded PPC queue, whose groups carry a chart: its top edge 9,5px
   below the cell's, against „Alfa Shop" sitting on the rail's bottom edge
   with `start` - 472,9px down, i.e. 463px from its own title. The distance
   is the rail's height minus the name, so a group without a chart moves it
   less). A name too long to fit is clipped at the top either way, with
   the ellipsis at the end - chromium ignores the alignment once the line
   overflows, so `start` is NOT the sabotage that shows this up; the short
   name is.
   A flex `justify-content:flex-end` looks identical for a short name and does
   the exact OPPOSITE for a long one - an anonymous flex item cannot do
   `text-overflow` and overflows towards the start, so in the sales queue
   „JednorazovaElektronickaCigareta.cz" came out as
   „zovaElektronickaCigareta.cz" with no ellipsis at all, against the shipped
   „JednorazovaElektronickaCi…". Photographed side by side, thrown away.
   11,5px/500/.04em is `th`'s own typography, letter-spacing included - the
   quietest label in the system, and the whole set rather than two thirds of
   it: the head right beside this cell carries the SAME e-shop name at 15px
   600 in --brand, so a rail anywhere near that turns one title into two.
   `--soft`, not `--faint`: 5,42:1 on --paper against
   4,41:1, and rotated 11,5px text is exactly the case where one does not go
   under 4,5:1.
   The name on the spine of the book - ONE rule for both shapes of the
   queue. The body is unchanged from what `td.queue-rail > span` carried
   before the request card grew a rail of its own; the selector is a PAIR so
   the table variant keeps its (0,1,2), which is what it needs to beat
   `tr.group-head td`. */
td.queue-rail > .rail-name,.request-rail > .rail-name{
  position:absolute;top:9px;bottom:9px;left:0;right:0;
  display:block;writing-mode:vertical-rl;transform:rotate(180deg);
  text-align:end;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
  font-size:11.5px;font-weight:500;letter-spacing:.04em;color:var(--soft)}
/* The boundary between two groups: a 12px band of --rail stripes over
   --paper, hairlined top and bottom - the SAME boundary the request card
   draws between two of its items (`.request + .request::before`), because
   the card and the groups are one queue in two shapes. It replaced a plain
   16px band of `var(--surface)`, which replaced a 2px `--line` on
   `tr.group-head td`; the first group still gets none, <thead> already drew
   its hairline there.

   `border-top` stays SOLID var(--surface) and does NOT become transparent -
   as the substrate under the band, and as the fallback for forced-colors,
   where `background-image` AND `background-color` are both dropped (measured:
   the two hairlines survive as system ink, the interior goes plain).
   It is no longer PHOTOGRAPHABLE on this page, and the reason is this rule's
   own `position:relative` two lines down: a positioned cell paints as its
   own layer, and its background then stops bleeding into the collapsed
   border area. Measured at 1440 with the band switched off,
   `var(--queue-band-h) solid transparent` is byte-identical to `solid
   var(--surface)` while the cell is positioned - and comes out half --paper
   the moment it is `position:static`, which is exactly the „half white, half
   grey" this comment used to quote for the plain 16px band.
   tests/e2e/test_proposals_charts.py takes both away, in that order, and
   photographs the difference.

   The band is drawn by a PSEUDO-ELEMENT OF THE HEAD CELL, and both halves of
   that were measured before they were written:
   - not a background on the cell: in the collapse model the 12px edge lies
     exactly on the row boundary with each half in a different cell, so
     `background-clip:border-box` can only colour one of them - the layered
     variant came out as the band drawn BESIDE the boundary (8px down, inside
     the group head), at y+20 in the rail column against y+17 in the content
     column, i.e. one band at two heights, with 16px of the head's own
     --paper painted over;
   - not a pseudo-element on `tr.group-head`: chromium grew a phantom column
     for it, `table.scrollWidth` 1162 -> 1421 and the rail 26 -> 148px. A
     screenshot at one width would not have shown either.
   `top:calc(-1 * var(--queue-band-h))` is the cell's own edge, and it is the
   same token as the border's width so the pair cannot drift.
   `left:calc(-1 * var(--queue-rail-w))` carries the band across the rail's
   column too - interrupting the rail is this gap's second job, and it is why
   the `border-top` rule above is written for EVERY cell of the row, the rail
   included, while only the others get the band.
   `z-index:2` is the rule SAYING the band stands above the sticky rail cell,
   which declares `z-index:1` - not the rule that makes it do so today, and
   the difference is measured: at 1280 with the wrap scrolled 43px chromium
   paints the band over the stuck cell at `z-index:auto` and at `0` alike,
   and raising the rail cell to `5` does not change it either. Only a
   NEGATIVE z-index puts the band under the gap, and then what shows in the
   rail's own 26px is the `var(--surface)` substrate above, not the cell's
   --paper. It stays because per spec 2 beats 1 and this stylesheet should
   not leave the order of two overlapping boxes to one engine's painting of
   sticky table cells; the design's claim that dropping it breaks the page
   in chromium did not reproduce (see the e2e test, which sabotages what
   actually carries the band across that column - `left`).
   Cost, measured at 1440 on the e2e seed: `table.scrollWidth` 1162 -> 1162,
   the head row 197,27 -> 197,27px, `canvas.style.width` 1100 -> 1100. The
   2px the head row DID lose against main is the gap going 16 -> 12, not
   this. */
.queue-group:not(:first-of-type) > tr.group-head > td{
  border-top:var(--queue-band-h) solid var(--surface)}
.queue-group:not(:first-of-type) > tr.group-head > td:not(.queue-rail){
  position:relative}
.queue-group:not(:first-of-type) > tr.group-head > td:not(.queue-rail)::before{
  content:"";position:absolute;z-index:2;box-sizing:border-box;
  left:calc(-1 * var(--queue-rail-w));right:0;
  top:calc(-1 * var(--queue-band-h));height:var(--queue-band-h);
  border-top:1px solid var(--line);border-bottom:1px solid var(--line);
  background-color:var(--paper);
  background-image:repeating-linear-gradient(135deg,
    var(--rail) 0 2px, transparent 2px 8px)}
/* Two cards that belong together side by side (the Merchant stock doughnut and
   its trend, from stock_cards()). auto-fit rather than a fixed `1fr 1fr`: when
   only one of the pair renders - no extended snapshots yet, so no trend - it
   takes the whole width instead of leaving an empty column. The pair carries
   the outer margin, so the cards inside must not also be `.section`. */
.card-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));
  gap:16px;align-items:start}
/* `.card + .card{margin-top:16px}` above stacks cards in normal flow; inside
   the grid it only pushed the second card 16px out of line with the first.
   `.section` on a card inside a pair is reset for the same reason. */
.card-pair > .card{margin-top:0}
/* One card needs only a little room (a doughnut and its legend), its partner
   wants every pixel for a time axis. Collapses to one column before the wide
   one gets too narrow to read. */
.card-pair-13{grid-template-columns:minmax(260px,1fr) minmax(420px,2fr)}
@media (max-width:1100px){.card-pair-13{grid-template-columns:1fr}}

/* Two cards that must stay the same width whether or not both have content -
   champion next to challenger on /admin/agentic/{id}. `.card-pair`'s auto-fit
   would give a lone card the whole row, which is right for a doughnut and its
   trend and wrong for a comparison: the reader has to see that one side is
   empty. Lived in a <style> block on that page until 2026-08-16; the values
   are unchanged, including the 900px collapse, which is where two columns of
   proposal reasoning stop fitting. `client_ads.html` still carries a
   page-local copy with its own 1024px breakpoint - it wins there by source
   order, so this move changed nothing on that page. */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px}
/* Same reset, same reason, as `.card-pair > .card` above: `.card + .card`
   is normal-flow rhythm, and inside a grid it does not stack anything - it
   just drops the second card 16px below the first, which on a side-by-side
   comparison is the one thing that must not happen. Both pages carrying
   `.two-col` have been rendering that half-step since long before this
   rule moved out of their <style> blocks. */
.two-col > .card{margin-top:0}
@media (max-width:900px){.two-col{grid-template-columns:1fr}}

/* E-shop detail cockpit: client-safe charts left, account-health charts
   right. 7fr/5fr, not 1fr 1fr - the left column carries the widest table
   (campaigns) and the turnover chart's six stacked scales, the right one
   only needs ~460px for a doughnut row and 260px-tall line charts.
   Neither .card-pair (auto-fit would rebalance columns per row) nor
   .two-col (equal halves) fits. Cards inside rely on normal-flow
   `.card + .card` rhythm; the :first-child reset absorbs the `section`
   class the shared card macros emit. min-width:0 lets .table-wrap
   shrink instead of stretching the grid. Collapses at 1100px like
   .card-pair-13 - below that the 5fr column (~340px) cannot hold the
   channel table's five columns. */
.detail-cols{display:grid;grid-template-columns:7fr 5fr;gap:16px;
  align-items:start;margin-top:16px}
.detail-cols > .detail-col{min-width:0}
.detail-cols > .detail-col > :first-child{margin-top:0}
@media (max-width:1100px){.detail-cols{grid-template-columns:1fr}}

/* E-shop detail, above .detail-cols: the „Shrnutí pro klienta" card and the
   narrow „Cena vs. trh" tile on one row (redesign phase ④). The tile column
   is a FIXED 280px rather than a fraction - it holds a word and two short
   hints, and a fraction would stretch it to half the page on a wide screen
   while squeezing the summary's 72ch measure on a narrow one. `minmax(0,1fr)`
   on the summary side, not a bare 1fr: the quote block's long unbroken URLs
   would otherwise blow the column past the grid.
   `.solo` is the summary-less case - the tile keeps its own width instead of
   spanning the page, which would read as a broken card.
   The `> .section` reset is the same one .detail-cols above needs and for the
   same reason: cards emit `section` (margin-top:16px) for normal flow, and in
   a grid that pushes the first row's cells down unevenly. It has to stay
   declared after `.card + .card,.section` near the top of this file - equal
   property, higher specificity, but source order is what makes the intent
   safe if either side is ever loosened. */
.summary-price-row{display:grid;grid-template-columns:minmax(0,1fr) 280px;
  gap:16px;align-items:start}
.summary-price-row.solo{grid-template-columns:280px}
.summary-price-row > .section{margin-top:0}
/* One breakpoint of its own, not .detail-cols' 1100px: this row survives
   narrower viewports fine (a card plus a 280px tile), and stacking it at the
   same width would leave a 280px tile alone on a 1000px-wide page. */
@media (max-width:900px){.summary-price-row,.summary-price-row.solo{
  grid-template-columns:1fr}}

/* E-shop-detail settings row: state, promotion type, project, limit, links,
   sales rep. Separators are drawn by CSS so a missing item cannot leave a
   dangling „·" behind, and a long e-mail wraps with the row - it used to sit in
   a `.metric .value` (25px, bold), where every ordinary address overflowed.
   The „·" hangs on `.mi`, never on the badge or the <a> inside it: on a badge
   it rendered inside the pill, and on a link the dot became part of the link,
   underlined and clickable. */
.eshop-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0}
.eshop-meta .mi + .mi::before{content:"·";margin-right:8px;color:var(--faint)}
/* The turnover figure keeps its own line. „~1,2 mld. Kč" is four tokens
   split by three plain spaces - one before the unit, one before the symbol,
   and the tilde in front - so without this the item breaks between the
   number and its unit and reads as two facts. (Not the thousands separator:
   `revenue_view.short_money` only reaches `fmt_money` below a thousand.)
   `.pixel-money` on /admin/sales solves the same problem; a separate name
   because that one is pinned by five assertions and renaming it is a change
   to a different page. */
.eshop-meta .money-nowrap{white-space:nowrap}
/* The campaign's 30-day totals, under the account line in the queue's first
   column. A quieter variant of the fact row, exactly like
   `.eshop-meta.tool-stats` below: the base class carries no font-size and
   no colour of its own, so on its own it prints the sums in 13.5px --ink -
   as loudly as the campaign name above them and louder than the account
   line between them.
   Deliberately NOT placed under `tr.group-head`: the group-head family
   above carries `tr.group-head .eshop-meta{margin-top:4px;font-weight:400}`
   at (0,2,1), which would silently claim these too.
   The weight is the phone half of that, and it is not cosmetic: on a phone
   `admin-responsive.css` gives the campaign cell
   `.table-wrap td:first-child{font-weight:600}` and these sums INHERIT it,
   so the line came out bold at 390px and 400 at 1440 - the same row, two
   voices, and the loud one exactly where it must be quieter than the
   campaign name above it. Set on the span, so it beats the inherited value
   whatever the td's own cascade does; `:not([data-label])` next door is no
   help, because this cell HAS a data-label. */
.eshop-meta.queue-sums{gap:6px;font-size:12.5px;color:var(--soft);
  font-weight:400}
/* The „změřeno" line above the outage table on the e-shop detail. Plain
   `.eshop-meta` carries neither size nor colour, so the provenance of the
   measurement would shout as loudly as the table under it - the same trap
   `.queue-sums` above and `.tool-stats` below each document. */
.eshop-meta.outage-meta{font-size:12.5px;color:var(--soft)}

/* „Výkon za období" tiles. Flex, not the shared grid: with eight tiles in
   seven auto-filled tracks the eighth sat alone on a second row with six
   empty slots beside it. `flex:1 1 205px` fits a whole row of tiles at
   desktop width instead - sized when the row held seven (the card measured
   at 1606px inner width, 1606 - 6*14px gaps = 1522px, /7 = ~217px
   available), so today's five-tile client row and three-tile internal row
   fit with room to spare, and the last row still spreads to fill the width
   (no orphan, no gap) at narrower widths where a full row does not fit.
   205px is not free to shrink further: it is already sized against the
   floor below it, not an arbitrary round number.
   Floor: a six-digit amount plus its delta badge does not fit on one
   25px line in a track much under 200px, and the badge breaks mid-token
   below that - „Obrat přes nás" (750 000 Kč plus its share hint) is the
   tile that hits this floor first. */
.stat-cards{display:flex;flex-wrap:wrap;gap:14px}
.stat-cards > .metric{flex:1 1 205px;min-width:0}
.stat-cards .value{display:flex;flex-wrap:wrap;align-items:baseline;
  column-gap:8px}
.stat-cards .stat-num{white-space:nowrap}
.stat-cards .value .delta{margin-left:0}

/* A share of a known whole, drawn under the number. Neutral by default -
   colour is a verdict here, not decoration, so only a figure measured
   against something (spend over its limit, a bilance in the red) gets one. */
.meter{position:relative;height:6px;border-radius:3px;background:var(--gry-bg);
  margin-top:10px;overflow:hidden}
.meter > i{position:absolute;left:0;top:0;bottom:0;border-radius:3px;
  background:var(--brand)}
.meter-ok > i{background:var(--ok)}
.meter-bad > i{background:var(--bad)}
/* A share that came out OVER its whole - the fill is clamped to 100 %, so
   without a second signal 101 % and 300 % draw the same full bar. Purple
   because --purple is the one token in this palette with no status meaning:
   spending more than the daily limits allowed is not a fault of the client's
   (--bad) and not a pass (--ok), it is the case where negotiating a higher
   ceiling is worth a conversation. The colour is the SECOND signal either
   way - the number above the bar says it first.
   Directly under `.meter-bad > i` and it must stay there: at (0,1,1) this
   ties `.meter > i` exactly, so source order is the only thing deciding the
   winner - moved above it, the bar goes back to --brand with no rule
   removed and nothing overridden. */
.meter-over > i{background:var(--purple)}
/* A share meter INSIDE a table cell: the percentage in a fixed slot, the bar
   filling the rest of the column. .meter on its own is the block a .metric
   tile hangs under its value (margin-top:10px), which in a ten-row table
   would add 100px of height and stagger every bar's left edge under a
   proportional figure. The fixed 52px slot is the same device .pno-stack
   uses for .pno-val: the bars then all start at one x and ten of them
   become comparable at a glance.
   `.share-cell .meter` is (0,2,0) against `.meter`'s (0,1,0), so the
   margin reset wins wherever either is declared - the source-order trap this
   admin has already lost three overrides to. */
.share-cell{display:flex;align-items:center;justify-content:flex-end;gap:10px}
.share-cell .share-pct{flex:none;width:52px;text-align:right;
  font-variant-numeric:tabular-nums}
.share-cell .meter{flex:1;min-width:64px;margin-top:0}
/* „Celkem" is a share of itself and has no bar to draw, but dropping the
   element would let its percentage slide to the cell's right edge while
   every band's sits 52px + gap + bar to the left of it - the one column
   where the sum would not stand on the same vertical as the rows it adds
   up. `visibility`, not `display`, is what keeps the slot and paints
   nothing in it: an empty grey track under a total reads as a bar at
   zero. */
.share-cell .meter-ghost{visibility:hidden}

.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;
  border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap;
  color:var(--gry);background:var(--gry-bg)}
.badge-green{color:var(--ok);background:var(--ok-bg)}
.badge-orange{color:var(--warn);background:var(--warn-bg)}
.badge-red{color:var(--bad);background:var(--bad-bg)}
.badge-gray{color:var(--gry);background:var(--gry-bg)}
.badge-blue{color:var(--brand);background:var(--brand-soft)}
.badge-purple{color:var(--purple);background:var(--purple-bg)}
/* Solid silhouette of the red pill; today its only user is „Chybí ve feedu"
   on the outage card, the harshest of that card's three reasons - two pale
   red pills side by side could not say which one is worse. The only FILLED
   member of the `.badge` family (the admin has filled surfaces elsewhere -
   `.btn-primary`, `.sidebar-count` - but no other pill), so it stands out
   without a new token. White on --bad measures 6,54:1, i.e. AA even at this
   size. Written as `.badge.badge-solid` (0,2,0) rather than `.badge-solid`
   (0,1,0) so the pale and solid variants cannot swap places when somebody
   reorders this file - the same argument the comment on `td.heat-*` already
   makes. */
.badge.badge-solid{color:var(--surface);background:var(--bad)}

/* coloured numbers - the colour rule: pills for status, text for values */
.val-ok{color:var(--ok);font-weight:600}
.val-warn{color:var(--warn);font-weight:600}
.val-bad{color:var(--bad);font-weight:600}

.filters{display:flex;gap:10px;flex-wrap:wrap;align-items:center;
  padding:14px 18px;border-bottom:1px solid var(--hair)}
/* `.toolbar form select`, not `.toolbar select`: /admin/spend's and
   /admin/potential's manager pickers are `<select class="btn">` sitting
   directly in a toolbar, and the broader selector would out-specify .btn
   (0,1,1 vs 0,1,0) and shrink their padding - the source-order/specificity
   trap this file already documents elsewhere. Form-embedded selects (the
   /admin/reviews filter form) are exactly the ones that want field look. */
.filters input[type=text],.filters input[type=number],.filters select,
.toolbar input[type=date],.toolbar form select,
.role-form select{padding:8px 10px;border:1px solid var(--line);
  border-radius:var(--radius-sm);font-size:13px;font-family:inherit;
  background:var(--surface);color:var(--ink)}
.filters input[type=text]{min-width:220px}
.filters input[type=number]{width:230px}
/* A boolean modifier INSIDE `form.filters` - the first checkbox in this
   admin. /admin/proposals uses it to widen the type filter from „this row is
   of type X" to „this row's campaign has a proposal of type X", so it
   qualifies the select beside it and belongs on the same row, not under it.
   The component is the WRAPPER, not the label: the ⓘ has to stay outside the
   <label>, because a click anywhere inside a label toggles the control it
   points at - the glyph would tick the filter on its way to opening the
   bubble.
   Deliberately NOT a field: the shared field look above lists
   `input[type=text]`, `input[type=number]` and `select` one by one and does
   not reach a checkbox - a frame around a tick box and its text would read
   as a fourth dropdown. The look is therefore carried by geometry alone:
   `min-height` is the fields' measured height (36px = 8+8 padding, 1+1
   border, an 18px line box at 13px), so the box and the text land on the
   centre line `.filters{align-items:center}` draws and the row keeps one
   rhythm.
   `accent-color` is the only way to paint a native checkbox from a token -
   no new colour enters this file this way.
   `min-height`, never `height`: `white-space:nowrap` keeps the label on one
   line, so this is not about wrapping - it is that a reader with a larger
   browser font gets a taller line box, and a fixed height would crop it. */
.filter-check{display:inline-flex;align-items:center;min-height:36px;
  font-size:13px;font-weight:500;color:var(--ink);
  -webkit-user-select:none;user-select:none}
.filter-check label{display:inline-flex;align-items:center;gap:8px;
  cursor:pointer;white-space:nowrap}
.filter-check input[type=checkbox]{width:15px;height:15px;margin:0;flex:none;
  accent-color:var(--brand);cursor:pointer}
/* Chromium's native focus ring is fine, but the rest of the admin draws its
   own from a token (see `.feedback-sentiment input:focus-visible`), so make
   the two match. */
.filter-check input[type=checkbox]:focus-visible{outline:2px solid var(--brand);
  outline-offset:2px}
/* Secondary action of a filter bar (a CSV export, say) belongs at the far
   right, away from the submit button. On a phone admin-responsive.css turns
   .filters into a full-width column, where margin-left:auto is a no-op -
   the link simply becomes the last full-width row. */
/* One-line reason a control on the filter row is disabled, sitting beside it.
   `.muted` alone would carry the page's font size next to the row's 13px
   fields and read as a second label; this matches the fields, for the same
   „one rhythm on the row" reason `.filter-check` argues its geometry from. */
.filters .filter-note{font-size:13px;color:var(--soft)}
.filters .push-right{margin-left:auto}
/* The same secondary action in a card's head - „Detail analýzy →" or
   „Všechny běhy →" beside the title. `.card-head` is already the flex row
   `.sort-chips` pushes itself right in (see the /admin/proposals rule further
   down); one class for both containers rather than a second one named after
   its position. Replaced two hand-written `style="margin-left:auto"` links. */
.card-head .push-right{margin-left:auto}
/* A BARE link inherits the head's 600 and reads as a second title, so it goes
   back to normal weight. A `.btn` brings its own 600 and keeps it - hence the
   :not(), because `.card-head .push-right` outranks `.btn` on specificity and
   would otherwise repaint every button that adopts this class. */
.card-head a.push-right:not(.btn){font-weight:400}
/* A filter bar that is a card's only child would leave its own bottom
   hairline orphaned one pixel above the card's edge. */
.card > .filters:last-child{border-bottom:none}

.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;
  border-radius:var(--radius-sm);font-size:13px;font-weight:600;
  border:1px solid var(--line);background:var(--surface);color:var(--ink);
  cursor:pointer;line-height:1.2}
.btn:hover{text-decoration:none;background:var(--paper)}
/* A .btn hidden until JS arms it (the copy button): the display above beats
   the UA [hidden] rule, so restate it - same fix as .search-suggest[hidden]. */
.btn[hidden]{display:none}
.btn-primary{background:var(--brand);border-color:var(--brand);
  color:var(--surface)}
.btn-primary:hover{background:var(--brand);filter:brightness(.95)}
/* The opposite of „links styled as buttons are fine": a <button> that has
   to read as an inline link, for an action sitting inside a dense text
   context (the facts row's „blok pro klienta" trigger) where a bordered
   .btn would shout over the facts around it. The element stays a button
   because it navigates nowhere - it opens a modal - and an <a href="#">
   would put a dead URL in the status bar and break middle-click. Underlined
   at rest, not only on hover: unlike a nav link its whole claim to
   clickability is the styling. */
.linklike{border:0;background:none;padding:0;font:inherit;
  color:var(--brand);text-decoration:underline;cursor:pointer}

.pagination{display:flex;align-items:center;gap:12px;justify-content:flex-end;
  padding:12px 16px;color:var(--soft);font-size:13px}
.empty{padding:56px 24px;text-align:center;color:var(--soft)}
.empty strong{display:block;color:var(--ink);margin-bottom:6px;font-size:15px}
/* a quiet "nothing happened" line - replaces green pills used as sentences */
.quiet{display:flex;align-items:center;gap:9px;color:var(--soft);font-size:13.5px}
.quiet::before{content:"";width:7px;height:7px;border-radius:50%;
  background:var(--ok);flex:none}
.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
  gap:14px}
/* The per-e-shop potential card stands alone at the page's full width, and
   its three tiles split it in thirds. .cards-grid's auto-fill would make
   eight 200px tracks on a wide page, fill three and leave five empty.
   ORDER IS LOAD-BEARING: the element carries both `cards-grid` and
   `potential-tiles`, two single-class selectors of equal (0,1,0)
   specificity setting the same `grid-template-columns` property, so source
   order is the only thing that decides which one wins - this rule must
   stay declared after `.cards-grid`, or `auto-fill` silently wins again (as
   it did until this comment was written; see the `.stat-cards` fix a commit
   above for the identical bug). Both responsive blocks in
   admin-responsive.css out-rank this (same specificity, later stylesheet),
   so the phone still gets two columns and then one. */
.potential-tiles{grid-template-columns:repeat(3,minmax(0,1fr))}
/* The bulk-run progress page's summary row - exactly four tiles, always the
   same four. auto-fill would fit five 200px tracks on a wide page and leave
   the fifth empty, and (worse here) it re-flows the moment a tile's content
   changes width, on a page that redraws itself every 2.5 s.
   ORDER IS LOAD-BEARING for the same reason as `.potential-tiles` above:
   equal (0,1,0) specificity setting the same property, so this must stay
   declared AFTER `.cards-grid`. The two responsive blocks in
   admin-responsive.css come later still and keep the phone at two columns
   and then one. */
.bulk-tiles{grid-template-columns:repeat(4,minmax(0,1fr))}
.metric{background:var(--surface);border:1px solid var(--line);
  border-radius:var(--radius);box-shadow:none;padding:15px 17px 16px}
.metric .label{font-size:12px;text-transform:none;letter-spacing:0;
  color:var(--faint);font-weight:500;margin-bottom:10px}
.metric .value{font-size:25px;font-weight:700;letter-spacing:-.03em}
/* a value that's a name/label rather than a number reads better small */
.metric .value.value-text{font-size:15px;font-weight:700;letter-spacing:-.02em}
.metric .hint{font-size:12px;color:var(--faint);margin-top:8px}
/* A hint that carries a WARNING, not a footnote - the „Cena vs. trh" tile's
   trend line („podíl produktů nad trhem vzrostl o 24 p.b."). The rule has to
   exist because `.metric .hint` above is (0,2,0) and `.val-bad` is (0,1,0),
   so putting the token class on a hint silently does nothing and the line
   renders grey; source order cannot save it. Only the red token is restated
   - `.muted` on a hint is meant to stay quiet, and `.val-ok`/`.val-warn`
   have no hint consumer, so adding them would be three dead rules instead
   of one live one. */
.metric .hint.val-bad{color:var(--bad)}
/* The "no measurement" card is an <a> (the delegated [data-diagnosis]
   listener needs it clickable without a reload) - every other .metric is a
   <div>. Scoped to `a.metric` specifically, not `.metric`: a bare
   `.metric .value{color:...}` would out-specify .val-ok/.val-bad
   (proposals.html's impact tile, single-class selectors) and silently kill
   their green/red regardless of which element carries them. `a.metric
   .value` only ever matches this one anchor-based card, so div.metric
   .value elsewhere (including .val-ok/.val-bad) are untouched. */
a.metric .value{color:var(--ink)}
/* Same anchor-as-tile leak, hover side: `a:hover{text-decoration:underline}`
   would otherwise underline the whole card on hover - .state-item:hover
   resets it the same way. Scoped to `a.metric` for the same reason as
   above, though a bare `.metric:hover` would have been harmless here (no
   div.metric selector conflict) - keeping both rules symmetrically scoped
   to the anchor avoids leaving a footgun for the next .metric added. */
a.metric:hover{text-decoration:none}
/* markActive() toggles .on on [data-diagnosis] elements, which now includes
   this card - same active-state treatment as .state-item.on. Single-class
   selector, but .on only ever gets added by JS to [data-diagnosis]
   elements (this card and .state-item rows), so there is no div.metric.on
   in the wild for it to clash with. */
.metric.on{background:var(--brand-soft)}
.summary{padding:14px 18px;font-size:13.5px;border-left:3px solid var(--brand);
  background:var(--brand-soft);border-radius:0 var(--radius) var(--radius) 0}
/* Red variant of .summary; exists because pages used to hand-roll the look
   inline (border-left-color/background style attributes) before it had a
   name - every template consumer carries this class now. */
.summary-bad{border-left-color:var(--bad);background:var(--bad-bg)}
/* Amber variant, added 2026-08-16 for the two caveats on the agentic run
   page - which were still hand-rolling it inline, the same way the red one
   was hand-rolled before it got a name. The bulk progress page is the second
   consumer: a run that finished *despite* an error, i.e. "one worker died,
   the other seven carried the queue". */
.summary-warn{border-left-color:var(--warn);background:var(--warn-bg)}
/* Client-facing AI summary as a quotation (e-shop detail + analysis detail):
   the one text the salesperson reads to the client verbatim, so it renders
   as quoted prose behind a quiet brand quote mark - not `.summary`, which is
   a status callout. 72ch caps the measure: the card spans the full page
   width until phase ④ puts the price tile next to it. */
.quote-block{display:flex;align-items:flex-start;gap:12px}
.quote-mark{flex:none;width:20px;height:20px;color:var(--brand);opacity:.35}
.quote-text{margin:0;font-size:13.5px;line-height:1.65;max-width:72ch}
.muted{color:var(--soft)}
/* Text for the screen reader only. Its first user is #pista-live, the
   page's single polite live region (admin-review.js announces the saved
   verdict into it - an AJAX swap navigates nowhere and the reader would
   otherwise learn nothing). ONE region per page on purpose: role="status"
   on every cell would turn a thirty-row queue into thirty announcers. The
   same recipe is written out by hand for `.table-wrap thead` in
   admin-responsive.css; that one is not converted to this class because it
   is a selector on an element inside a media query, and giving it a class
   means touching ~20 templates. */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);
  white-space:nowrap;border:0}
/* /admin/potential: allowed-but-untakeable money. Greyed rather than hidden -
   the amount is real, it just is not an opportunity (see POTENTIAL_HELP) */
.unused-no-room{color:var(--faint);font-style:italic}
/* explanatory note under a table/chart inside a card */
.chart-legend-note{padding:10px 16px 14px;font-size:12px;color:var(--soft);line-height:1.5}
.crumbs{font-size:13px;color:var(--soft);margin-bottom:12px}
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);
  margin:0 0 20px}
.tab{padding:8px 16px;font-weight:600;font-size:13px;color:var(--soft);
  border:1px solid transparent;border-bottom:none;
  border-radius:var(--radius) var(--radius) 0 0}
.tab:hover{color:var(--ink);text-decoration:none}
.tab.active{color:var(--brand);background:var(--surface);
  border-color:var(--line);margin-bottom:-1px}
/* a tab that has nothing to show yet - visibly present, deliberately dead */
.tabs .tab-off{color:var(--faint);cursor:default}
/* 18px side padding matches `.filters` and table cells, so the toolbar's
   first control lines up with the first column below it. */
.toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:center;
  padding:12px 18px;border-bottom:1px solid var(--hair)}
/* Same orphan-hairline rule as `.card > .filters:last-child` above: the
   period-filter cards (/admin/spend, /admin/ga4) hold only a toolbar. */
.card > .toolbar:last-child{border-bottom:none}
.toolbar form{display:flex;gap:6px;align-items:center}
.toolbar .spacer{flex:1}
/* Role picker on /admin/users - the select and its save button on one line,
   so the actions cell doesn't grow a row taller than the others. */
.role-form{display:flex;gap:6px;align-items:center;margin-bottom:6px}
.toolbar .meta{font-size:12px;color:var(--soft)}
.delta{font-size:12px;font-weight:600;margin-left:8px}
.delta-good{color:var(--ok)}
.delta-bad{color:var(--bad)}
/* /admin/spend's per-row sparkline: a fixed-size box for the inline SVG
   polyline admin-spend.js draws into it from the `data-values` attribute. */
.spark{display:inline-block;width:90px;height:24px;vertical-align:middle;
  color:var(--soft)}
.banner{padding:10px 16px;border-radius:var(--radius);margin-bottom:16px;
  font-size:13px}
.banner-warn{background:var(--warn-bg);color:var(--warn)}
.banner-ok{background:var(--ok-bg);color:var(--ok)}
.review-inline{display:inline}
.review-inline .btn{padding:4px 10px;font-size:12.5px}
.review-edit{margin-top:4px}
.review-edit summary{cursor:pointer;color:var(--brand);font-size:12.5px;
  list-style:none}
.review-edit summary:hover{text-decoration:underline}
/* Místo pro „Nastavit budget" nad hodnoticím formulářem. Rezervuje se,
   i když tlačítko ještě není vidět: odkrytí až po prvním vykreslení je
   pozdní reflow a posunul by celou frontu pod sebou - na témž mechanismu
   zhavaroval e2e test test_review_ajax.py::test_the_form_dims_while_the
   _request_is_out, který měří window.scrollY (spadl na 713 == 712). Týž
   obchod dělá .request-chart{min-height:110px}. 34px je výška .btn
   (13px x 1,2 + 16 paddingu + 2 rámečku, naměřeno 33,59). Cena je 34px
   prázdna nad formulářem v prohlížeči bez JS, a platí ji jen NEohodnocený
   řádek - u ohodnoceného je tlačítko v zavřeném <details>.

   Platí i V KARTĚ POŽADAVKU: od 2026-10-05 nabízí karta tatáž tlačítka
   jako řádek fronty a tohle sdílené pravidlo jí dává tutéž rezervu.
   Karta-specifické `.request .review-budget{min-height:0}` sem nepatří -
   rezerva je tam ze stejného důvodu jako ve frontě. Dřív karta tlačítko
   vůbec nevykreslovala, aby se první požadavek vešel do výřezu 1440x900;
   vlastník produktu ten nárok zrušil, protože skryté tlačítko nechalo
   hodnocení „Aplikováno" stát za zápisem, který nikdy neproběhl. */
/* Přirážka má vlastní slot i vlastní třídy, ale tytéž hodnoty: tlačítka
   vypadají stejně a v jedné buňce může stát nejvýš jedno z dvojice (typy
   návrhu se vylučují), takže se dvě rezervy nikdy nesečtou - `budget_button`
   vykresluje svůj <div> UVNITŘ podmínky na `p.type == 'budget'`. Řádek
   s návrhem přirážky žádný slot dodneška neměl a těch 34px je na něm nový;
   fronta se pro ty řádky protáhne.
   Skupiny selektorů mají tutéž specificitu jako dnešní jednotlivá pravidla.
   Proč se nepíše `.review-edit .btn`: potomkovský selektor chytne i obě
   tlačítka hodnoticího formuláře - ta sedí o tři úrovně níž
   (details.review-edit > form.review-form > div.review-actions > button).
   Dětský `.review-edit > .btn` by je NEchytl (obě modalová tlačítka jsou
   přímí potomci `<details>`, formulář je sourozenec), takže je dnes
   ekvivalentní tomu, co tu stojí - ale nejmenuje žádné tlačítko jmenovitě,
   a to je celý rozdíl: každý modal má vlastní skript a ten, který se
   nenačte, nesmí dostat rozsvícené tlačítko toho druhého. */
.review-budget,.review-surcharge{min-height:34px}
.review-edit > .budget-open,.review-edit > .surcharge-open{margin-top:6px}
/* Mrtvý ovládací prvek je horší než žádný: bez JS se <dialog> neotevře.
   Třída na <html> místo atributu `hidden` na každém tlačítku, protože
   admin-review.js po každém uložení překresluje celý obsah buňky - tlačítko
   odkryté jednou při načtení se v té čerstvé značce vrátí skryté a už ho
   nic nerozsvítí. `.btn` je display:inline-flex, takže obě pravidla musí
   display přepsat (týž důvod jako u .btn[hidden]). Dvě třídy, ne jedna
   sdílená: každý modal má vlastní skript a ten, který se nenačte, nesmí
   rozsvítit tlačítko toho druhého. Od 2026-09-21 mají oba skripty společnou
   půlku (admin-modal.js): pád TÉ schová obě tlačítka naráz, protože se
   nepřidá ani jedna třída - pořád ta bezpečná strana. Dvě třídy chrání ten
   druhý případ, který sdílený modul nezměnil: selhání jednoho per-modal
   skriptu tlačítko toho druhého rozsvítit nemůže. */
.budget-open,.surcharge-open{display:none}
.budget-ready .budget-open,.surcharge-ready .surcharge-open{display:inline-flex}
.review-form{display:flex;flex-direction:column;gap:6px;margin-top:8px;
  min-width:220px}
.review-form input,.review-form textarea{padding:6px 8px;
  border:1px solid var(--line);border-radius:8px;font-size:12.5px;
  font-family:inherit;background:var(--surface);color:var(--ink)}
.review-form .btn{align-self:flex-start}
.review-actions{display:flex;gap:8px}
.review-actions .btn{align-self:auto}
/* ⓘ vedle tlačítka v pásu akcí. `.info` má pevných 16px na výšku, takže se
   ve `stretch` řadě nenatáhne a bez tohohle pravidla by sedla k HORNÍ hraně
   34px tlačítka. Jediný uživatel je dnes „Odeslat do Slacku" ve slackové
   větvi fronty požadavků; kdekoli jinde je pravidlo neúčinné, protože tam
   žádná ⓘ v `.review-actions` není. */
.review-actions .info{align-self:center}
.review-hint{display:none;color:var(--bad);font-size:12px}
.review-invalid .review-hint{display:block}
.review-invalid textarea{border-color:var(--bad)}
td.review-cell{min-width:260px}
.review-note{font-size:12px}
.review-comment{white-space:normal;max-width:260px;font-size:12.5px;
  font-style:italic}
/* --- Uložení hodnocení bez reloadu ------------------------------------
   Until 2026-08 the confirmation that a review saved was the row vanishing
   from the page. The review is submitted by fetch now and only one cell is
   redrawn, so nothing moves at all - and without these three pieces the
   reviewer would get no signal that anything happened.

   The loader is DELIBERATELY not used here: .pista-loader-wrap is a 64px
   figure with padding:28px inside a .ps-slot 150px tall, which inside a
   224px cell would double the row height and reshuffle the table - and its
   escalating messages are written for tens of seconds of BigQuery, not for
   one UPDATE. */

/* The form in flight. It touches no dimension (no label change, no spinner)
   so the buttons keep their width and the row keeps its height. The real
   guard against a double click is `disabled` on both buttons, set by JS
   immediately; this class arrives only after 150 ms and is merely what is
   visible, so an answer within 150 ms never flashes it. Opacity .55 is the
   same one `.search-suggest.is-loading` uses 900 lines above - two numbers
   for „waiting on the server" in one system are not two decisions, only
   drift. Selected on the attribute, not on `.review-form`: that class is
   also worn by the sales board's „Ozval/a jsem se" form, which is exactly
   why admin-review.js hooks on [data-review-form] - the stylesheet has no
   business drawing a wider circle than the script. BOTH hooks are listed,
   for the same reason: the request card's verdict forms wear
   [data-request-form] and are saved by the same code, so an
   attribute-selector for one of them alone leaves the other one adding a
   class nothing renders - measured, opacity 1 against the .55 the proposal
   below it was showing at the same moment. The two modal forms are the
   third and fourth, listed for the same reason and worth more there than
   anywhere else: each POST is a synchronous write through White into a live
   system - the budget's into a Google Ads campaign, the surcharge's into
   OUR margin - so they are the waits on this page that can last seconds.
   The surcharge one was missing here until 2026-09-20 and the class it put
   on itself drew nothing: measured, opacity 1 and „Zrušit" clickable while
   the PATCH was out. `pointer-events:none` takes „Zrušit" with it on
   purpose - cancelling would not stop a PATCH that is already out. */
[data-review-form].is-saving,
[data-request-form].is-saving,
[data-budget-form].is-saving,
[data-surcharge-form].is-saving{opacity:.55;pointer-events:none}

/* The transient „saved" mark, rendered ONLY by the fragment
   (_review_cell_fragment.html), never by a full page. It sits inline after
   the verdict badge, so removing it shrinks the line horizontally and the
   row does not move. The ✓ is deliberately the same glyph as on the
   „✓ Aplikuji" button just pressed - the confirmation reads as an echo of
   the click, not as a new element. Colour --ok rather than a badge: the
   badge next to it already is a status pill, and a second pill on the same
   line would claim these are two states. */
.review-saved-mark{display:inline-flex;align-items:center;gap:4px;
  /* Aligned to the top of the line box, not to the baseline. The badge is an
     inline-flex box with 4px of vertical padding, so baseline alignment put
     the mark's top 3px below the badge's - visibly the same line, but not the
     same line to a measuring test. The badge is also the tallest thing on
     that line, so its top IS the line box top and the two now start
     together. Deliberately not solved by giving the mark the badge's padding:
     that would make the mark the taller box, and removing it at the end of
     the flash would then change the row's height. */
  vertical-align:top;
  margin-left:8px;color:var(--ok);font-size:12.5px;font-weight:600;
  white-space:nowrap;opacity:1;transition:opacity .48s ease-out}
.review-saved-mark.is-out{opacity:0}

/* The verdict badge is `.badge-green`, whose own background IS var(--ok-bg)
   - the flash colour exactly. For the 1.1s the row holds that colour the
   pill would dissolve into it and „Aplikováno" would read as bare green
   text, which is not a component this system has. A hairline in the badge's
   own ink keeps the silhouette; it is drawn as an inset shadow rather than a
   border so it takes no space and the row cannot change height when it goes.
   Scoped to the lit row, so the badge is unchanged everywhere else - and on
   the request card to the verdict inside `.request-actions`, never to the
   source pill in the head above it: that one is not green, has not just
   changed, and a ring round it would read as a second thing having
   happened. */
tr.review-saved .review-cell .badge,
.request.review-saved .request-actions .badge{box-shadow:inset 0 0 0 1px currentColor}

/* The row flash. An ANIMATION, not a `background` in the normal cascade
   origin - and that is the whole trick: animated declarations outrank
   normal ones regardless of specificity, so this one (0,1,2) rule beats
   even `.table-wrap td.review-cell{background:var(--surface)}` (0,2,1) from
   admin-responsive.css, which paints the pinned column in the 700-1024px
   band. Without it the whole row would light up EXCEPT the one cell where
   something happened. Do not add a restatement to admin-responsive.css - it
   needs none and would only sit there as a trap.
   `background-color` is animated, not the `background` shorthand, so the
   cell's other background-* properties are not wiped.
   The end is var(--surface), NOT transparent: the pinned cell in the
   700-1024px band has to stay opaque, or scrolled-away columns would show
   through it for half a second at the end of the fade. On a non-hovered row
   and inside a phone card --surface is exactly what is under the cell
   anyway; over a :hover row the last frame is 1.5% lighter than
   --row-hover, which is below the visible threshold. */
@keyframes review-saved-flash{
  0%,70%{background-color:var(--ok-bg)}
  100%{background-color:var(--surface)}
}
tr.review-saved > td{animation:review-saved-flash 1.6s ease-out}
/* The same confirmation for a resolved request, on the whole `.request`
   block rather than on the `.request-actions` that was actually swapped: the
   item is what the reader has been reading, and lighting only its foot would
   put the confirmation on the part of it they have just stopped looking at -
   the row flash above lights the whole row for the same reason, not the one
   cell. The keyframe needs no second copy: `.request` declares no background
   of its own, so what is under it IS the card's --surface the animation ends
   on. */
.request.review-saved{animation:review-saved-flash 1.6s ease-out}
/* No prefers-reduced-motion block here, and that is deliberate. The blanket
   floor at the top of this file (`*{animation-duration:.01ms !important;
   transition-duration:.01ms !important}`) already wins over any non-important
   declaration whatever its specificity or position, so for those readers this
   animation is over in 0.01ms with `animation-fill-mode:none` - the flash
   never becomes visible at all. A rule here restating a longer hold would be
   unreachable code; the earlier draft of this file carried one, plus a
   `.review-saved-mark{transition:none}` that only repeated the floor.
   What those readers get instead is not motion at all and needs none: the
   form is permanently replaced by the verdict badge, „✓ Uloženo" sits next
   to it at full opacity until FADE_AT_MS (1.12s, admin-review.js) - the fade
   is a transition, so the floor collapses it into an instant disappearance
   there rather than removing the signal; the node itself goes at 1.6s, by
   which time it is already invisible - and
   #pista-live announces the verdict. tests/test_admin_tooltip.py's
   `test_the_fade_is_covered_by_the_blanket_reduced_motion_floor` is the
   guard on that floor; if it is ever narrowed, this rule needs its own
   media query and that test is the reminder. */
/* The review log (/admin/reviews) shows a reviewer's comment as its own
   row under the review, not inside the review-cell it was styled for
   elsewhere - tighten the gap so the two rows read as one entry. */
.review-comment-row td{padding-top:0}

/* --- Poslední změna páky pod kampaní (detail e-shopu) --------------------
   Jedna entita = dva <tr>: kampaň a pod ní jedna věta o její poslední změně.
   Vzor je `.review-comment-row` (inventář styleguide), ale ten se reusovat
   nedá - `.review-comment` je kurzíva s max-width:260px pro citaci.
   `white-space:normal` je celá ochrana tabulky: `td` je nowrap kvůli sloupci
   s názvem, a věta v nowrap colspan buňce by přispěla svou celou šířkou do
   min-content tabulky (viz měření u `.cell-sub-wrap`). Barvu i velikost
   deklaruje komponenta sama, ne utilita `.muted` - `.muted` nese jen barvu,
   takže věta by běžela na 13,5px a byla stejně hlasitá jako fakta nad ní
   (stejný argument jako u `.cell-sub` a `.season-chart-note`).
   Vlásenka entity patří pod pár, ne mezi jeho půlky - proto `border-bottom:0`
   na hlavním řádku; `tr.has-row-sub > td` (0,1,2) vyhrává nad globálním `td`
   bez ohledu na pořadí v souboru. Hover se schválně nespojuje: `tbody
   tr:hover` je globální a předchozího sourozence v CSS nastylovat nejde -
   `reviews.html` to má stejně. */
tr.has-row-sub > td{border-bottom:0;padding-bottom:4px}
tr.row-sub > td{white-space:normal;padding:0 18px 12px;font-size:12.5px;
  color:var(--soft);line-height:1.5}

/* --- Sestavy pod kampaní (detail e-shopu) -------------------------------
   Druhý blok TÉHOŽ `tr.row-sub`, ne vlastní řádek: dva sub-řádky pod jednou
   kampaní by od globálního `td{border-bottom}` dostaly vlásenku uprostřed
   entity, a v card modu (`admin-responsive.css`) se na kartu nad sebou lepí
   `margin-top:-10px` jen ten první - druhý by se odlepil do osiřelé karty
   bez jména kampaně. Styleguide to říká jako „one <tr> per entity".
   Velikost ani barvu tenhle blok NEDEKLARUJE - obojí nese `tr.row-sub > td`
   nad ním a `.eshop-meta` uvnitř ho schválně nepřebíjí, takže seznam mluví
   týmž tichým hlasem jako věta o poslední změně vedle něj.
   6 px je odstup od té věty: `.eshop-meta` má `margin:0` a bez něj se první
   sestava nalepí na její poslední řádek; větší mezera by z jednoho sdělení
   o kampani udělala dvě karty v jedné buňce.
   `overflow-wrap` na jméně, ne na celém řádku: jména ad group bývají jedno
   nedělitelné slovo („PLA_Brand_CZ_All_Products"), a na 390 px by takové
   slovo nastavilo podlahu šířky dokumentu a přineslo vodorovný posuvník.
   Předloha je `.tool-name`, která to má ze stejného důvodu. */
.group-lines{margin-top:6px}
.group-lines .group-name{overflow-wrap:anywhere}
/* Řádek fáze v téže buňce, nad větou o poslední změně. 6 px je tentýž odstup,
   jaký si o kus výš bere seznam sestav, a ze stejného důvodu: `.eshop-meta`
   má `margin:0`, takže bez něj se věta nalepí na odznak nad sebou. Odstup
   visí na řádku fáze, ne na větě pod ním - ta žádný vlastní blok nemá (je to
   holý text v buňce) a obalit ji jedním by znamenalo přepsat značky dílu B. */
.phase-line{margin-bottom:6px}

/* --- Expandable row (the e-shop's „Měřicí pixel" tab) -------------------
   A row that unfolds, on click, a `tr.row-sub` holding something long under
   it - here the raw pixel event exactly as White sent it. The existing
   pieces do not cover it: `.row-sub` is an always-visible sentence, and
   `details/summary` (the `.churn-month` pattern) cannot live in a table -
   a `<details>` cannot wrap two `<tr>`. The state is the `hidden` attribute
   on the sub-row, `.is-open` on the main row and `aria-expanded` on the
   button (admin-pixel-events.js). The toggle is a real `<button>`, so Enter
   and Space work with no extra JS.
   `tr.row-raw[hidden]` (0,2,1) is required: the phone stylesheet's
   `.table-wrap tr{display:block}` (0,1,1) in admin-responsive.css beats the
   UA's `[hidden]{display:none}`, and every closed event would show on a
   phone. The hairline under an open main row goes for the reason
   `.has-row-sub` drops it - the pair is one entity. */
tr.row-expand{cursor:pointer}
tr.row-expand.is-open > td{border-bottom:0;padding-bottom:4px}
.row-toggle{appearance:none;border:0;background:none;margin:0;
  padding:0 6px 0 0;font:inherit;line-height:1;color:var(--faint);
  cursor:pointer}
.row-toggle::before{content:"▸";font-size:11px}
.row-toggle[aria-expanded="true"]::before{content:"▾"}
.row-toggle:focus-visible{outline:2px solid var(--brand);outline-offset:2px;
  border-radius:4px}
tr.row-raw[hidden]{display:none}
/* The raw JSON is machine text, hence `--font-mono` (see the allowed set in
   tests/test_admin_assets.py). `overflow-wrap:anywhere`, not `break-word`:
   only `anywhere` lowers min-content, so a 600-character user agent or URL
   cannot widen the table - it wraps inside the <pre>. The height is a cap,
   not a size: a longer event scrolls inside, the page stays readable. */
.raw-json{margin:0;padding:11px 13px;max-height:360px;overflow:auto;
  background:var(--paper);border:1px solid var(--line);
  border-radius:var(--radius-sm);font-family:var(--font-mono);
  font-size:12.5px;line-height:1.5;color:var(--ink);white-space:pre-wrap;
  overflow-wrap:anywhere;tab-size:2;cursor:text}

/* --- Fronta požadavků na PPC specialistu (/admin/proposals/ppc) ---------
   Jedna karta, jeden `.request` blok na položku. Záměrně NE <table>: řádek
   nese odstavec, seznam událostí, sedmisloupcovou tabulku návrhů a dva
   formuláře, a tabulka v <td> přispívá svou celou min-content šířkou do
   sloupce nad sebou - vnější tabulka by se pak už nikdy nesmrskla a sloupec
   s akcemi by byl za pravou hranou na každé šířce (naměřeno: min-content
   vnořeného tvaru ~1750 px proti 1126 px karty na 1440px monitoru).
   Rodina `.tool` níž v tomhle souboru je stejný tvar ze stejného důvodu
   a je předlohou; bydlí to tady, protože všechno, co to obaluje, je rodina
   review nad tímhle komentářem. */
.request{position:relative;padding-left:var(--queue-rail-w);
  scroll-margin-top:70px}
/* Lišta ve tvaru karty - jméno e-shopu vysazené na výšku, aby šla karta
   svisle skenovat, stejně jako tabulka skupin pod ní. ABSOLUTNÍ blok, ne
   flex sloupec a ne buňka: `.request` schválně není tabulka (viz komentář
   nad tímhle blokem), takže rowspan neexistuje, a flex sloupec by
   z pseudoprvku `.request + .request::before` udělal FLEX ITEM, tedy druhý
   sloupec vedle lišty místo pásu přes celou šířku - a vyžadoval by nový
   obal kolem hlavičky, těla, tabulky a akcí. Absolutní blok je mimo tok,
   takže do min-content šířky nepřispívá ničím, a odsazení obsahu dělá
   padding na `.request` - jedno místo pro všech pět dětí včetně tabulky
   návrhů. Cena, naměřená na 1440: `.request-body` 1162 -> 1136 px,
   `.chart-wrap` 1126 -> 1100, min-content tabulky návrhů 1019 se do 1136
   pořád vejde.
   Hrana je inset shadow z téhož --rail jako u `td.queue-rail`; je to táž
   hrana skupiny v druhém tvaru. */
.request-rail{position:absolute;left:0;top:0;bottom:0;
  width:var(--queue-rail-w);box-shadow:inset -1px 0 0 var(--rail)}
/* Pás je `::before` v TOKU, takže box `.request` jím začíná - a lišta je
   pozicovaná k tomu boxu. Bez tohohle řádku se `top:9px` u jména měří od
   horní hrany PÁSU, ne od jeho spodní hranice, a pás (který lištu překrývá)
   uřízne první 3 px prvního písmene. Naměřeno: inkoust na dy=9 u bloku bez
   pásu, na dy=12 - nalepený na spodní hairline pásu - u bloku s pásem.
   Tabulka skupin to nikdy neměla, a ten rozdíl je celý důvod, proč tady
   jeden řádek být musí a tam ne: tam je pás `border-top` na buňce hlavičky
   a absolutně pozicovaný potomek se odsazuje od PADDING boxu, mimo který
   border už leží.
   Jen `.request + .request`, ne každá lišta: první blok ve skupině zdroje
   žádný pás nad sebou nemá a posun o 12 px by ho odsadil od hranice, která
   tam není. */
.request + .request > .request-rail{top:var(--queue-band-h)}
/* Oddělovač položek je pás, ne linka: karta nese až QUEUE_MAX_ITEMS=60
   bloků a 2px --line se ve svislém skenu neliší od hairline, kterou tabulka
   návrhů kreslí mezi svými řádky.
   ::before, ne background-image na .request: pás MUSÍ zabírat místo v
   layoutu (odtlačit další .request-head) a nesmí ležet pod textem. Přes
   background by to znamenalo padding-top + background-size + -repeat +
   -origin - čtyři svázané deklarace, které první úprava paddingu tiše
   rozejde - a hlavně: jediné `background` zkratkou na .request odkudkoli
   pás smaže (pitfall „vrstvená třída prohraje s pořadím"), zatímco
   pseudoprvek přepsat nejde.
   135deg = lomítka „/" (gradientní osa míří vpravo dolů, pruhy jsou na ni
   kolmé). 2px --rail / 6px --paper, perioda 8px = 25 % pokrytí, průměrný
   tón ~#dfe3e9. --rail je jediná šeď v paletě, která je nad --paper vidět
   (2,375 proti 1,105 u --line); je to strukturální hrana skupiny - přesně
   ta role, pro kterou --rail vznikla u `td.queue-rail` v tabulce dole. Je
   to táž fronta ve dvou tvarech, takže hranice bloku klienta má být tatáž
   šeď. Hierarchii to nepřevrací: .request-group je 34px vysoký pás s 600
   textem, tenhle je 10px bez písmene.
   Hairlines nahoře a dole nejsou dekorace, jsou to fallback pro
   forced-colors, kde se background-image zahazuje a pás by zmizel.
   box-sizing výslovně: globální `*{box-sizing:border-box}` na
   pseudoprvky NEPLATÍ a box-sizing se nedědí - a je nosné i pro `width`
   níž: ta je pak celý box včetně obou hairlines, takže
   `calc(100% + var(--queue-rail-w))` sedne na šířku karty na pixel
   (naměřeno 1162 px při kartě 1164 - 2x 1px rámu).
   Pás je POTOMEK `.request`, takže by ho padding odsadil o šířku lišty
   a přestal by být od hrany k hraně. Vrací se negativním marginem a šířkou,
   ne absolutní pozicí: pás MUSÍ zabírat místo v layoutu (odtlačit další
   .request-head) - to je celý důvod, proč je to pseudoprvek v toku.
   `position` + `z-index` jsou kvůli liště: ta běží přes celý `.request`,
   pás ji překryje a tím ji mezi položkami PŘERUŠÍ - stejně jako 12px hrana
   přerušuje lištu mezi skupinami v tabulce dole. */
.request + .request::before{
  content:"";display:block;box-sizing:border-box;height:var(--queue-band-h);
  position:relative;z-index:1;
  margin-left:calc(-1 * var(--queue-rail-w));
  width:calc(100% + var(--queue-rail-w));
  border-top:1px solid var(--line);border-bottom:1px solid var(--line);
  background-color:var(--paper);
  background-image:repeating-linear-gradient(135deg,
    var(--rail) 0 2px, transparent 2px 8px)}
.request-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;
  padding:14px 18px 0}
/* Třetí kopie téhož řádku (druhé dvě jsou .filters a .card-head):
   .push-right v tomhle souboru záměrně NENÍ globální utilita. Drží stav
   analýzy všech požadavků na jednom x, aby šel svisle skenovat. */
.request-head .push-right{margin-left:auto}
/* 15px/600 je registr `.table-wrap td:first-child` v režimu karet - jméno
   entity, ne nadpis karty. */
.request-name{font-size:15px;font-weight:600;letter-spacing:-.01em}
.request-body{padding:8px 18px 14px}
.request-body > * + *{margin-top:8px}
/* Próza uvnitř požadavku: dotaz obchodníka, a od 2026-08-30 už jen on -
   zkrácené `client_summary` je pryč, protože je psané PRO KLIENTA
   a specialistovi neodpovídá na nic, na co se fronta ptá.
   72ch je míra `.quote-text`, ne 68ch `.tool-desc` - tohle je něčí věta,
   ne referenční odstavec. pre-line je nosné: Slack zpráva chodí i s vlastními
   odřádkováními a `extract_question` je nechává být. */
.request-prose{max-width:72ch;white-space:pre-line;line-height:1.55}
/* Odkazy uvnitř těla požadavku. `display:block` je nosné, ne kosmetika:
   `.request-body > * + *{margin-top:8px}` na INLINE prvek nedělá nic -
   svislý margin se na inline non-replaced boxy neaplikuje - takže dva
   sousední <a> („otevřít ve Slacku ↗", „detail analýzy →") splynuly na
   jeden řádek, stejnou modrou, oddělené jedinou mezerou, a četly se jako
   jeden odkaz. Styleguide to má jako pitfall „Vertical space under an
   inline-level control". `width:fit-content`, aby klikací plocha zůstala
   na šířce textu a ne přes celou kartu. */
.request-link{display:block;width:fit-content}
/* Řádek faktů o analýze („detail analýzy →" · „Kampaně v Google Ads: …").
   `baseline`, ne `center` z `.eshop-meta`: druhá položka se na úzké kartě
   zalomí do víc řádků a odkaz vedle ní má sedět na jejím PRVNÍM řádku, ne
   uprostřed její výšky. */
.eshop-meta.request-analysis{align-items:baseline}
/* Popiska je registr popisu, ne obsahu - tichá, aby se četly názvy kampaní. */
.request-campaigns-label{color:var(--soft)}
/* Jedna kampaň = jedna nedělitelná položka seznamu: inline-block drží název
   s jeho ↗ pohromadě, aby se šipka neodtrhla na další řádek. Název bez mezer
   (`PMax_CZ_Vsechny_produkty_…`) by ale na telefonu vytlačil kartu do
   vodorovného scrollu. Nosné je `overflow-wrap:anywhere`: snižuje min-content
   šířku, takže se inline-block smrští do řádku a název se láme uvnitř.
   `max-width:100%` SÁM nestačí (naměřeno v chromiu 2026-10-04: box 486 px na
   390px výřezu, test_a_long_campaign_name_wraps_inside_a_phone) - zůstává
   jako strop, ne jako oprava. */
.request-campaign{display:inline-block;max-width:100%;overflow-wrap:anywhere}
.request-events{margin:0;padding:0;list-style:none}
.request-events li{padding:2px 0}
/* Časy pod sebou musí být srovnané, jinak se seznam změn nedá číst shora
   dolů. */
.request-events .request-when{margin-right:6px;color:var(--soft);
  font-variant-numeric:tabular-nums}
/* Pás sekce uvnitř karty. Dvě `.card-head` by byla vnořená hlava karty
   (inventář zakazuje vnořovat karty) a dvě karty by rozdělily identitu,
   kterou odznak i spec drží jako jednu - takže je to registr `th` nad
   --paper, tj. nad přesně tou plochou, kterou hlavičky skupin v tabulce
   pod tím už znamenají „tady začíná nové seskupení". */
.request-group{padding:9px 18px;background:var(--paper);
  border-top:1px solid var(--line);border-bottom:1px solid var(--hair);
  font-size:11.5px;letter-spacing:.04em;font-weight:600;color:var(--soft)}
.request-group:first-child{border-top:0}
/* Akční blok stojí VEN z jakéhokoli .table-wrap, a to je jeho smysl:
   tabulka návrhů nad ním na notebooku scrolluje vodorovně, a formulář uvnitř
   scrolleru je formulář, který jde odscrollovat z obrazu. */
.request-actions{display:flex;flex-wrap:wrap;align-items:flex-end;
  gap:10px 16px;padding:12px 18px 16px;border-top:1px solid var(--hair)}
.request-actions form{margin:0}
.request-actions .review-form{margin-top:0;flex:1 1 320px;max-width:620px}
/* Ack formulář slackové větve na vlastním řádku - komponenta, ne dekorace.
   `.request-actions` je flex-wrap a ack formulář by bez tohohle pravidla byl
   `flex:0 1 auto`, tedy ~169px; s 16px gapem a 620px `.review-form` je to
   805px, což se do bloku vejde. Ack by pak stál VEDLE formuláře odpovědi,
   srovnaný spodní hranou (`align-items:flex-end`) s „Odeslat do Slacku" -
   přesný opak sjednocení, kvůli kterému ack stojí první v obou větvích.
   Do 2026-09 ten řádek lámala popiska `.request-label{flex:1 1 100%}`;
   ta z bloku zmizela (co je formulář odpovědi zač, říká sám placeholderem
   a textem tlačítka) a s ní i tahle vedlejší funkce, kterou nikdy
   nepojmenovávala.
   Vlastní třída, ne `.request-actions form:first-child`: pozice v DOMu není
   to, o čem tohle pravidlo je, a druhý formulář v bloku by ho utišil.
   Změnová větev ji NEPOTŘEBUJE - její ack formulář je `.review-form` a je
   v bloku sám. */
.request-ack{flex:1 1 100%}
/* Souhrn změn: jeden řádek na pole, čistý výsledek. Kroky, které k němu
   vedly, jsou pod ním ve složeném <details> - specialista se rozhoduje
   podle toho, kde nastavení SKONČILO, ne podle stavu, který držel minutu.
   Pevný slot popisky je celá komponenta, stejně jako 52px .share-pct:
   „denní limit" měří ~72px a „cíl PNO" ~49px, takže bez slotu se šipky
   dvou řádků rozjedou o 23px a pár se přestane číst jako tabulka o dvou
   řádcích. min-width, ne width - neznámé pole si sáhne pro víc a jen ten
   jeden řádek se rozjede. */
.request-changes{margin:0;padding:0;list-style:none;display:grid;gap:4px}
.request-change{display:flex;align-items:baseline;gap:7px;line-height:1.5;
  font-variant-numeric:tabular-nums}
/* translateY(1px) je táž hodnota a týž důvod jako u .chart-legend-swatch:
   účaří SVG je jeho spodní hrana, takže bez posunu ikona sedí přesně na
   účaří textu a přečnívá 2,3px nad verzálky. */
.request-change svg{flex:none;color:var(--faint);transform:translateY(1px)}
.request-change-label{flex:none;min-width:88px;color:var(--soft)}
/* The value the setting CAME FROM steps back, so the row reads label ->
   where it was -> where it ended, quiet to loud. Weight alone was not
   enough: both halves were --ink and the pair read as two equally important
   figures with an arrow between them, which is the opposite of what a
   netted summary is for. */
.request-change-old{color:var(--soft)}
.request-change-new{font-weight:600;color:var(--ink)}
/* Pole, které se vrátilo tam, kde bylo. Ztlumený je text řádku, protože
   zpráva toho řádku je „tady se nic nestalo"; ikona se s ním neztlumí a
   nemá - `.request-change svg` si drží vlastní --faint, který je SVĚTLEJŠÍ
   než --soft, takže je na ztlumeném řádku pořád ta nejtišší věc. (Návrh
   tady sliboval ztlumení „včetně ikony přes currentColor" - to by ale
   znamenalo ikoně vlastní barvu vzít, a na normálním řádku by pak dědila
   --ink a byla hlasitější než popiska, kterou doprovází.)
   Hodnota v závorce ZÁMĚRNĚ není .request-change-new: nejhlasitější věc na
   řádku nesmí být číslo, o kterém řádek říká, že se nezměnilo. */
.request-change.is-unchanged{color:var(--soft)}
/* Složené <details> uvnitř požadavku - kroky, které vedly k nettovanému
   souhrnu, a od 2026-08-31 taky hodnocení analýzy v akčním bloku. `> summary`
   bez list-style a bez display:flex - obojí zabíjí nativní trojúhelník
   (pitfall z .tool-args). `cursor` tady není: globální
   `details>summary{cursor:pointer}` o pár set řádků výš ho už dává, stejně
   jako u .tool-args.
   Jmenuje se `.request-fold`, ne `.request-steps`, a to byl přesně ten
   okamžik, na který si předchozí verze tohohle komentáře nechávala poznámku:
   druhý sbalený blok v téhle kartě dorazil a třetí zápis téhož vzhledu
   nesmí vzniknout. Jméno pojmenovává TVAR, ne obsah, takže další <details>
   si ho jen vezme.
   ŽÁDNÝ z nich nesmí dostat `card-fold` ani `id`: admin-card-fold.js si stav
   `details.card-fold[id]` pamatuje v localStorage navždy, a je to totéž
   pravidlo, kvůli kterému celá karta požadavků není `card-fold`.
   Pořád je to záměrně druhá deklarace téhož vzhledu, ne použití
   `.tool-args`, a důvod nejsou ty pixely: `.tool-args` není holá komponenta
   pro <details>. Nese vlastní
   `margin-top:10px` - které je (0,1,0) stejně jako `.request-body > * + *`,
   ale stojí v souboru NÍŽ, takže by rytmus téhle karty přepsalo na pořadí,
   přesně ta past, kterou tenhle soubor jmenuje na třech místech - a dvě
   potomkovská pravidla popisující tabulku argumentů, kterou obaluje.
   Do 2026-08-31 tu stálo, že hodnoty jsou schválně STEJNÉ. Už nejsou a je to
   záměr: `> summary` a `> summary:hover` shodné zůstaly, ale vnitřní rytmus
   se rozešel - `.tool-args[open] > summary{margin-bottom:8px}` stačí na
   `<details>` s jediným dítětem, kdežto tady jsou pod summary tři (textarea,
   hint, tlačítka) a mezera je potřeba mezi všemi (viz pravidlo hned pod
   tímhle komentářem). Kdo ty dvě komponenty bude chtít slučovat, musí začít
   tímhle rozdílem - a NEMÁ, viz odstavec výše. */
.request-fold > summary{font-size:12.5px;color:var(--soft);padding:2px 0}
.request-fold > summary:hover{color:var(--ink)}
/* Jeden rytmus uvnitř foldu, a NAHRAZUJE `[open] > summary{margin-bottom:8px}`:
   textarea, hint a tlačítka hodnocení už nejsou flex items `.review-form`
   (leží o úroveň hloub), takže mezeru, kterou jim dával jeho `gap`, musí říct
   tohle pravidlo. 6px je rytmus, který ta trojice měla. Zavřený fold žádné
   další dítě nevykresluje, takže nad seznamem kroků i nad textareou platí
   tohle jedno číslo. */
.request-fold > * + *{margin-top:6px}
/* Ani jedno z těch dvou není kosmetika. `width:100%`: v toku si textarea bere
   šířku z `cols` - naměřeno 193px proti 620px formuláře, na 1440. (Na telefonu
   je to ~233px, protože `admin-responsive.css` dává textareám 16px kvůli iOS
   zoomu; číslo se nerozchází, jen se měří v jiném kuželu.) `display:block`:
   mezera pod inline-level textareou je leading řádkového boxu, tedy metrika
   fontu, na které se engine neshodnou (~5,7px chromium / ~2,3px Firefox) -
   past ze styleguide, kterou už zaplatil agentický modal.
   `box-sizing` tady NENÍ a nemá být: globální `*{box-sizing:border-box}`
   na začátku tohohle souboru už ho dává, takže lokální kopie by byla druhý
   zápis pravidla, které nic nedrží v páru - a e2e assertion o něm zůstane
   zelená i po jejím smazání, což z ní dělá pin globálního resetu, ne téhle
   komponenty. */
.request-fold > textarea{display:block;width:100%}
/* Slot grafu. Bez stylingu kromě rezervace výšky - jinak stránka ve chvíli,
   kdy fetch dorazí, poskočí o 91px (placeholder 19px -> graf 110px) a
   odsune tabulku návrhů i formulář pod ní. 110 = 88 wrapper + 22 jeden
   řádek inline legendy (naměřeno v chromiu na hlavičce skupiny, 1440px).
   Cena: degradovaný graf („data nedostupná") drží 110px vzduchu; stabilní
   layout to stojí za to. */
.request-chart{min-height:110px}
/* .request-proposals, .request-rating a .request-revert jsou bez stylingu,
   čistě úchyty - stejně jako `table.cohort-bands`. Jediným čtenářem je e2e
   test.
   `.request-rating` nese fold hodnocení v akčním bloku; VZHLED zůstává na
   `.request-fold` výš a nesmí se sem přestěhovat, protože je to týž tvar
   jako výčet kroků v `.request-body`. Existuje proto, že
   `.request-actions details.request-fold` je jednoznačné jen do třetího
   foldu v tom bloku - a ten by e2e úchyty na něm postavené UTIŠIL, ne
   shodil.
   `.request-revert` nese větu jističe („rozpočet: nyní 400 Kč → vrátit na
   500 Kč") v sekci Jistič. Vzhled dělá `.request-prose` na témže elementu
   a hlasitosti `.request-change-old`/`-new` uvnitř - táž dvojice „tichá
   hodnota → hlasitá hodnota" jako v řádku souhrnu změn, protože je to
   totéž tvrzení. Vlastní deklarace by byla třetí zápis téhož vzhledu. */

/* Modals (.app-modal family): the shared dialog component - the feedback
   modal in base.html and the agentic-question modal in _client_actions.html.
   The form rules (.modal-form, .modal-actions) are deliberately shared with
   the standalone feedback page too (feedback_new.html, no <dialog>), so the
   surfaces cannot drift apart visually. The dialog chrome - .app-modal,
   .modal-head, .modal-close, and the padding on .app-modal .modal-form -
   belongs to the modals alone; the standalone page has no <dialog> to dress.
   The sentiment pills below stay feedback's own. */
.app-modal{max-width:34rem;width:calc(100% - 32px);padding:0;border:0;
  border-radius:var(--radius);background:var(--surface);color:var(--ink);
  box-shadow:0 12px 40px rgba(20,25,40,.22)}
/* No-op with native <dialog> support (the UA sheet already hides a closed
   dialog) - without it, a browser lacking <dialog> renders this as a plain
   block, showing the whole form, unstyled-as-modal, at the foot of every page. */
.app-modal:not([open]){display:none}
.app-modal::backdrop{background:rgba(20,25,40,.45)}
.modal-head{display:flex;align-items:center;gap:10px;padding:14px 18px;
  border-bottom:1px solid var(--hair);font-weight:600;font-size:15px}
.modal-close{margin-left:auto;flex:none;border:none;background:none;
  padding:2px 6px;font-size:20px;line-height:1;color:var(--faint);
  cursor:pointer}
.modal-close:hover{color:var(--ink)}
.app-modal .modal-form{padding:16px 18px 18px}
.modal-form p{margin:0 0 10px}
/* Direct children only: the feedback form's sentiment pills are <label>s
   nested inside .modal-form too, and a bare `.modal-form label` gave them
   an unwanted 6px margin-bottom (measured in the browser). The agentic
   modal's field label is a direct child, so `>` scopes the rule to it. */
.modal-form > label{display:block;margin:0 0 6px;font-size:13px;
  font-weight:600;color:var(--soft)}
/* `display:block` is the point of this rule, not a tidy-up: a textarea is
   inline-level by default, so what sits under it is the line box's leading,
   and that is engine-dependent. Chromium leaves ~5.7px there; Firefox's UA
   sheet says `vertical-align:text-bottom` and leaves ~2.3px - which in the
   agentic modal, where .modal-actions follows the textarea directly, put the
   buttons (and their 2px focus outline at 2px offset) on top of the
   textarea's bottom border. Block removes the leading in both engines, and
   the gap below is then stated rather than inherited from a font metric. */
.modal-form textarea{display:block;width:100%;box-sizing:border-box;
  font:inherit;min-height:7rem;resize:vertical;padding:9px 11px;
  background:var(--surface);color:var(--ink);border:1px solid var(--line);
  border-radius:var(--radius-sm)}
/* Only where the actions follow the textarea directly - the feedback form's
   own gap comes from .feedback-sentiment's margin, and a bare margin-top on
   .modal-actions would add to it. */
.modal-form textarea + .modal-actions{margin-top:14px}
/* Text field in a modal. The shared field look above lists its consumers
   one by one and never reached a <dialog>: until the manual-turnover form
   there was no text input in a modal at all, only the agentic textarea and
   the feedback radios. Without this the field renders as a NATIVE widget -
   179px wide, a 2px inset border, Arial, and no gap to the buttons under it
   (measured in chromium, 2026-08-25) - the same defect the /admin/reviews
   filter bar shipped for weeks.
   Box and colours are the textarea's, so a dialog holding both draws one
   field, not two. `font-size` is NOT: 13px matches `.modal-form > label`
   right above the field and the `.filters` inputs elsewhere in the admin,
   while the textarea inherits 14px from the page. A dialog that ever holds
   both will therefore show two sizes - it is a deliberate half-pair, not
   an oversight, and 13px is the half that belongs next to the label. */
.modal-form > input[type=text]{display:block;width:100%;box-sizing:border-box;
  font:inherit;font-size:13px;padding:9px 11px;
  background:var(--surface);color:var(--ink);border:1px solid var(--line);
  border-radius:var(--radius-sm)}
/* `.modal-form > label` sets the shorthand `margin`, i.e. margin-top:0, at
   (0,2,0). These are (0,2,1) resp. (0,2,0)+order, so they win wherever they
   are declared - a bare `.modal-form label{margin-top:...}` would silently
   lose. 14px is what `.modal-form textarea + .modal-actions` already uses; a
   second value would give one dialog two rhythms.
   Only the combinations that exist: in the manual-turnover form the
   amount is followed by its `.field-hint` and the note by the actions. The
   `input + label` half matched nothing at all and was removed - a rule no
   markup reaches is a rule nothing can hold to its word. The
   `.field-hint + …` half was removed for the same reason and IS BACK, one
   line lower: the budget modal ends `.field-hint` -> `.budget-error` ->
   `.modal-actions`, and that chain is where its gap comes from. */
.modal-form > .field-hint + label{margin-top:14px}
.modal-form > input[type=text] + .modal-actions{margin-top:14px}
/* A consequence of the field, printed under it - not an explanation of what
   the field is (that is what `.modal-form p` at the top does). Quieter than
   the label, because it is read second.
   Scoped to `.modal-form >` and not written as a bare `.field-hint`, which
   is what dodatek D6 sketched: the hint IS a <p> inside the form, so
   `.modal-form p{margin:0 0 10px}` at (0,1,1) outranks a bare class at
   (0,1,0) whatever the file order, and the hint would keep the paragraph's
   10px gap under it instead of the 6px above it. */
.modal-form > .field-hint{margin:6px 0 0;font-size:12px;color:var(--faint)}
/* --- Hlava modalu a řádek faktů (dnes _budget_modal.html) -------------- */
/* Titulek v hlavě, která nese ještě DRUHOU položku. Bez `flex:none` se
   flex item smrskne a „Denní budget kampaně" se u dlouhého jména kampaně
   zalomí na dva řádky - hlava vyroste z 53 na 75,5px. Naměřeno 2026-09-19
   sabotáží; do té doby tu stálo „ze 47", což je číslo, které dnes v tom
   prohlížeči nic nevrací - jednořádková hlava měří 53px.
   Dvojice `.modal-head-title` + `.modal-head-note` je sdílená, ale pořád
   NENÍ `.modal-title`: `flex:none` je důsledek té druhé položky a hlava
   s jedinou položkou ho nosit nemá - zbylé čtyři modaly mají titulek bez
   třídy a mají ho tak nechat. */
.modal-head-title{flex:none}
/* Druhá položka hlavy, u rozpočtu jméno kampaně. Ona jediná se zkracuje,
   výpustkou.
   `min-width:0` je tu jako obvyklá pojistka proti automatické minimální
   velikosti flex itemu, ale v TÉHLE sestavě ho nic nepotřebuje: `overflow:
   hidden` o řádek dál dělá z prvku scroll container, čímž `min-width:auto`
   vyjde na nulu samo. Naměřeno 2026-09-09 sabotáží - po jeho odebrání
   zůstává e2e (test_budget_modal.py::test_a_long_campaign_name_is_clipped
   _and_the_head_stays_one_line) zelená, na rozdíl od odebrání
   `text-overflow` nebo `flex:none` výš. Zůstává pro den, kdy se `overflow`
   pohne; jen tu o něm nestojí nepravda. */
.modal-head-note{flex:0 1 auto;min-width:0;overflow:hidden;
  text-overflow:ellipsis;white-space:nowrap;font-weight:400;font-size:13px;
  color:var(--soft)}
/* Řádek faktů v modalu. Vlastní komponenta, ne `.request-changes`: ta má
   slot popisky pevných 88px, vyladěných na „denní limit" a „cíl PNO"
   v kartě požadavku, a „Dnes v Google Ads" měří 117px - buď by se řádky
   rozjely, nebo by se musela měnit karta požadavku.
   Pevnou výšku si drží KONKRÉTNÍ modal (`.budget-facts`, `.surcharge-facts`
   níž), ne tahle obecná část - každý jich má jiný počet.
   `align-content:start` je tu proto, že grid jinak rozpustí rozdíl mezi
   obsahem a rezervou do řádků: v době doptání je vidět o řádek nebo dva
   míň, `stretch` (default) je roztáhne a po doptání zase sklapnou. Blok
   ani pole pod ním se přitom nehnou, ale uvnitř to poskočí - a u přirážky
   je ten nepoměr mnohem větší než u rozpočtu. */
.modal-facts{margin:0 0 14px;display:grid;gap:4px;font-size:13px;
  line-height:1.5;font-variant-numeric:tabular-nums;align-content:start}
/* min-height drží ČTYŘI řádky (4 x 19,5 + 3 x 4 = 90), i když je vidět
   jen trojice: <dialog> je vystředěný, takže blok, který po doptání
   povyroste, posune pole pod sebou o polovinu rozdílu - přesně ve chvíli,
   kdy je v poli kurzor. Ze stejného důvodu tu NENÍ pišťácký loader
   (_pista_loader.html): je to 64px figura s 28px paddingem, celkem 148px
   proti 90, a modal by se při doptání posunul o ~29px. Doptání říká
   hodnota v řádku, ne obrázek. */
.budget-facts{min-height:90px}
/* Přirážkový modal má PĚT řádků: 114 = 5 x 19,5 + 4 x 4 (naměřená
   přirozená výška 113,5). Obě čísla jsou měřená v chromiu, ne dopočítaná -
   192px na telefonu NENÍ 114 plus konstanta: řádky „Dnes" a „Po změně" tam
   měří po 58,5px, protože závorka je na telefonu vlastní řádka
   (`.modal-fact-conv` níž) a v těch 190px se zalomí ještě jednou.
   Obě čísla vznikla v designerově prototypu a PŘEMĚŘIL je 2026-09-20 nad
   hotovým modalem e2e test
   tests/e2e/test_surcharge_modal.py::test_the_facts_block_reserves_room_for_five_rows:
   přirozená výška bloku s pěti řádky je 113,5px na 1280 a 191,5px na 390
   (e-shop s nejdelšími závorkami, 150 000 Kč/den při 65 %), tedy přesně
   tyhle rezervy zaokrouhlené nahoru.
   Týž test drží blok na nich ve všech třech stavech: před doptáním, po
   doptání se skrytým řádkem „Při analýze" i se všemi pěti řádky. */
.surcharge-facts{min-height:114px}
.modal-fact{display:flex;align-items:baseline;gap:8px}
/* display:flex výš přebíjí UA [hidden] - stejná past jako .btn[hidden]. */
.modal-fact[hidden]{display:none}
.modal-fact-label{flex:none;min-width:124px;color:var(--soft)}
.modal-fact-value{font-weight:600;color:var(--ink)}
/* „zjišťuji…" a „nepodařilo se zjistit" nejsou hodnoty, tak nemají váhu
   hodnoty. Nula se sem nikdy nevykreslí: nevědět a nula jsou různá fakta -
   u rozpočtu znamená `live_current: null` to první a rozpočet nula je
   něčí rozhodnutí. */
.modal-fact-value.is-unknown{font-weight:400;color:var(--soft)}
/* Závorka s odvozením vedle hlavní částky - u rozpočtu převod limitu
   klienta na to, co z něj smí do Google Ads. Slabší než částka vedle:
   hlavní číslo je fakt, tohle je jeho odvození.
   Na telefonu je z něj VLASTNÍ řádka, ne náhodné zalomení: na 322px
   obsahu zbývá hodnotě 190px a nejdelší reálný převod měří 294px, takže
   by se zalomil tak jako tak - jen by o tom nikdo nerozhodl a rezerva
   bloku (110px resp. 192px) by platila jen pro dnešní částky. `:empty`
   (0,2,0) přebíjí `display:block` z media query (0,1,0) nezávisle na
   pořadí - schválně: prázdný převod nesmí vyrobit prázdný blok. */
.modal-fact-conv{font-weight:400;color:var(--soft)}
.modal-fact-conv:empty{display:none}
@media (max-width:699px){
  .budget-facts{min-height:110px}
  .surcharge-facts{min-height:192px}
  .modal-fact-conv{display:block}
}
/* Meze pole jako jeden box o třech stavech: neutrální (meze jako fakt),
   žlutý (pod minimem), červený (nad zbytkem limitu klienta). ŽÁDNÝ z nich
   neblokuje uložení - člověk u obrazovky ví víc než Pišta - takže to není
   validační hláška a `.review-hint` se použít nedá ještě z jednoho
   důvodu: je display:none, dokud formulář nedostane `.review-invalid`
   (admin.css), a úklidový posluchač v admin-review.js matchuje jen
   textarea[name=comment], kterou tenhle formulář nemá.
   Box je v DOMu vždy a vždy s textem, takže se neobjevuje ani nemizí;
   min-height 32px je jedna řádka (17,4 + 14 paddingu = 31,4). Věty jsou
   schválně podobně dlouhé: na 508px obsahu je všech OSM na jeden
   řádek (32px), na telefonu na 322px všech osm na dva (49px). „Naměřeno"
   tu stojí právem teprve od 2026-09-09: do té doby to o telefonu tvrdil
   komentář a měřily se tam tři věty ze sedmi. Dnes je
   test_every_sentence_of_the_box_is_the_same_height parametrizovaný přes
   1280 i 390, projde všech osm a u každé věty čte i výšku BEZ rezervy
   (31,39px proti 32, 48,78px proti 49) - samotná výška boxu je jen
   rezerva sama, takže zkrácená věta by v ní zmizela. Modal drží 367,47px
   resp. 485,47px ve všech stavech (na telefonu o 0,22 víc, než měřil do
   zavedení rezervy 49px níž). */
.field-alert{margin:6px 0 0;padding:7px 10px;border-radius:var(--radius-sm);
  font-size:12px;line-height:1.45;min-height:32px;box-sizing:border-box;
  background:var(--gry-bg);color:var(--soft)}
.field-alert-warn{background:var(--warn-bg);color:var(--warn)}
.field-alert-bad{background:var(--bad-bg);color:var(--bad)}
/* The box's phone reserve, and it belongs to the COMPONENT, not to one
   modal. At 322px of content a sentence of either modal wraps onto two lines
   (48,78px measured), while the empty box the JS writes before the answer
   lands keeps the 32px above - so without this the dialog grows by ~17px at
   the very moment the specialist's cursor is in the field under it. 49 is
   above 48,78, so it is that two-line height rounded up: never below what
   the content needs and never a pixel more.
   Placement AFTER the base rule above is the mechanism, not tidiness: this
   media query and `.field-alert` are the SAME selector (0,1,0), so nothing
   but file order decides which `min-height` wins - written before it, the
   32px would overwrite the 49 at every width. (`.field-alert-bad` in
   between is only background and colour and is not what the tie-break is
   about; the facts' media block a few rules up is a different selector and
   stands before `.field-alert` for its own reasons.)
   Two tests measure it, one per modal, and both read the EMPTY box as well
   as the sentences - the empty one holds 49 by this reserve alone:
   tests/e2e/test_budget_modal.py::test_the_warning_box_holds_two_lines_on_a_phone
   (the empty box and three of the eight sentences; the parametrized
   test_every_sentence_of_the_box_is_the_same_height walks all eight at both
   widths) and
   tests/e2e/test_surcharge_modal.py::test_every_sentence_of_the_box_is_the_same_height_on_a_phone
   (the empty box and all SIX sentences). Both budget tests read every
   sentence a SECOND time with the `min-height` stripped off, because the
   reserve otherwise reports itself: a sentence shortened onto one line
   still measures 49 down here. The natural reading has to land under the
   reserve by less than a pixel - that is what „all of them wrap onto two
   lines" means, and 48,78 against 49 is the number. The surcharge test does
   the same for two of its six: the two-line one, and the one sentence that
   fits on a single line there
   („Přirážka pod 1 % White nepřijme. Uložení neprojde.", 31,39px measured,
   asserted as < 40) - that one holds 49 by the reserve alone, which is why
   it was never padded out for symmetry. Its siblings `same` and `inside`
   ARE deliberately longer than they need to be (the comment in
   `_surcharge_modal.html` says so): a shorter wording gave one pair 32px and
   another 48,78, and the field jumped whenever the value crossed the floor.
   49 came from the designer's prototype, like the facts' heights above, and
   was re-measured over the finished modals. */
@media (max-width:699px){
  .field-alert{min-height:49px}
}
/* Odmítnutý zápis. `.summary`/`.summary-bad` z inventáře, jen zasazené do
   rytmu modalu; 13,5px schválně zůstává - výsledek akce má být hlasitější
   než stav pole nad ním (12px). Mezeru nad tlačítky nese celý řetězec
   sousedních selektorů, ne `+ .modal-actions`: `+` čte pořadí v DOMu, ne
   viditelnost, takže skrytý box mezi nimi by pravidlo rozpojil. Proto se
   box nikdy nevytváří ani nemaže, jen přepíná `hidden`. */
.budget-error{margin-top:14px}
.modal-form > .field-hint + .budget-error + .modal-actions{margin-top:14px}
.feedback-sentiment{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0 16px}
/* The radio keeps its place in the tab order and its focus ring - hiding it
   with display:none would make the choice unreachable from the keyboard.
   Unpicked is a legitimate state: the sentiment is optional. */
.feedback-sentiment input{position:absolute;width:1px;height:1px;opacity:0;
  margin:0}
.feedback-sentiment label{position:relative;cursor:pointer}
.feedback-sentiment span{display:inline-flex;align-items:center;gap:6px;
  padding:7px 12px;border:1px solid var(--line);border-radius:999px;
  background:var(--surface);font-size:13px;font-weight:500;color:var(--soft)}
.feedback-sentiment label:hover span{background:var(--paper);color:var(--ink)}
.feedback-sentiment input:checked + span{background:var(--brand-soft);
  border-color:var(--brand);color:var(--brand);font-weight:600}
.feedback-sentiment input:focus-visible + span{outline:2px solid var(--brand);
  outline-offset:2px}
.modal-actions{display:flex;gap:8px;align-items:center;
  justify-content:flex-end}
/* /admin/feedback - pre-wrap so the paragraphs somebody wrote survive. */
.feedback-text{max-width:34rem;white-space:pre-wrap}

/* keyboard access - the design must not remove focus */
a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,
textarea:focus-visible,summary:focus-visible{
  outline:2px solid var(--brand);outline-offset:2px;border-radius:4px}

/* Non-production marker (ENVIRONMENT_LABEL) - loud on purpose, so a staging
   tab can never be mistaken for production. Sits above .shell in the flow;
   production never sets the variable, so the rule is inert there. */
.env-banner{background:repeating-linear-gradient(-45deg,var(--env-banner-stripe-1),var(--env-banner-stripe-1) 12px,var(--env-banner-stripe-2) 12px,var(--env-banner-stripe-2) 24px);color:var(--env-banner-fg);text-align:center;font-weight:700;letter-spacing:.2em;padding:6px 0;font-size:13px;text-transform:uppercase}

.shell{display:flex;min-height:100vh}
/* Pinned to the window, so the footer (the feedback trigger and the identity
   chip) is reachable on a page taller than the viewport - without this
   "bottom left" means "at the end of the table".
   align-self:flex-start is load-bearing: a flex child stretches to the
   container's height by default, and a stretched box has nothing to stick to.
   The scrolling element is .sidebar-nav, not the sidebar itself - in a short
   window the list of links scrolls and the footer keeps its place. */
.sidebar{width:224px;flex:none;background:var(--surface);
  border-right:1px solid var(--line);padding:14px 12px 20px;
  position:sticky;top:0;align-self:flex-start;height:100vh;
  display:flex;flex-direction:column;
  /* .pista-logo:hover scales from its own top-left corner; without this the
     zoom bleeds past the sidebar into the content column (flagged at Task 4,
     deferred here). It still fits comfortably inside the sidebar's own box,
     so containing it costs nothing visually. */
  overflow:hidden}
.sidebar-nav{flex:1;min-height:0;overflow-y:auto}
.brand{display:flex;align-items:center;gap:10px;padding:6px 10px 22px;
  color:var(--ink)}
.brand:hover{text-decoration:none}
.brand-text b{display:block;font-size:14px;font-weight:700;letter-spacing:-.01em;
  line-height:1.2}
.brand-sub{display:block;margin-top:3px;font-size:10.5px;font-weight:500;
  letter-spacing:.11em;text-transform:uppercase;color:var(--faint)}
.sidebar-nav a{display:flex;align-items:center;gap:10px;padding:8px 11px;
  border-radius:var(--radius-sm);font-size:13.5px;font-weight:500;
  color:var(--soft);margin-bottom:1px}
.sidebar-nav a:hover{text-decoration:none;background:var(--paper);color:var(--ink)}
.sidebar-nav a svg{flex:none;opacity:.75}
.sidebar-nav a.on{background:var(--brand-soft);color:var(--brand);font-weight:600}
.sidebar-nav a.on svg{opacity:1}
.sidebar-group{padding:18px 11px 8px;font-size:10.5px;font-weight:600;
  letter-spacing:.11em;text-transform:uppercase;color:var(--faint)}
.sidebar-count{margin-left:auto;font-size:11.5px;
  font-weight:600;color:var(--surface);background:var(--brand);
  border-radius:9px;padding:1px 7px}
/* Logged-in identity - only rendered once the OAuth login is on (see
   auth.require_admin_user). Sits at the end of the sidebar, after the nav,
   so the same one control is reachable on desktop and, through the
   existing hamburger drawer, on mobile - no second copy in the topbar. */
/* The footer block that holds the feedback trigger and the identity chip. */
.sidebar-foot{flex:none;margin-top:12px;padding-top:12px;
  border-top:1px solid var(--hair)}
/* Bordered and full width - "more visible" means a frame, not a darker text
   colour. Deliberately not .btn: that is the page content's language, and the
   footer stays quieter than a primary action. */
.sidebar-feedback{display:flex;align-items:center;gap:8px;padding:8px 11px;
  border:1px solid var(--line);border-radius:var(--radius-sm);
  background:var(--surface);font-size:13.5px;font-weight:500;color:var(--soft)}
.sidebar-feedback:hover{text-decoration:none;background:var(--paper);
  color:var(--ink)}
.sidebar-feedback svg{flex:none;opacity:.75}
/* Identity as a <details>: the summary is the whole chip, the menu sits above
   it. list-style:none plus the WebKit marker rule remove the disclosure
   triangle - the chip draws its own caret in ::after. */
/* position:relative on the <details>, not on .sidebar-foot: anchored to the
   footer the menu opened above the whole block, so a click on the e-mail put
   „Odhlásit" two rows up, over the feedback trigger. Against the details it
   opens directly above the chip that was clicked. */
.sidebar-user{margin-top:8px;position:relative}
.sidebar-identity{display:flex;align-items:center;gap:6px;padding:6px 4px;
  border-radius:var(--radius-sm);cursor:pointer;list-style:none}
.sidebar-identity::-webkit-details-marker{display:none}
.sidebar-identity::after{content:"▾";flex:none;font-size:11px;
  color:var(--faint)}
.sidebar-identity:hover{background:var(--paper)}
.sidebar-email{flex:1;min-width:0;font-size:12px;color:var(--soft);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* padding:0 plus overflow:hidden rather than an inset panel: the item fills
   the panel, so its own padding is the only thing indenting the label, and
   „Odhlásit" lines up with the e-mail above it instead of sitting 8px in. */
.sidebar-menu{position:absolute;left:0;right:0;bottom:100%;margin-bottom:6px;
  padding:0;overflow:hidden;background:var(--surface);
  border:1px solid var(--line);border-radius:var(--radius-sm);
  box-shadow:var(--shadow);z-index:10}
/* The menu body: an identity header, then uniform rows (<a> and <button>
   share .sidebar-menu-item so new entries land aligned by construction). */
.sidebar-menu-head{padding:9px 12px 7px;border-bottom:1px solid var(--hair)}
.sidebar-menu-email{display:block;font-size:12px;font-weight:600;
  color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sidebar-menu-role{display:block;font-size:11px;color:var(--faint);
  margin-top:1px}
.sidebar-menu-item{display:flex;align-items:center;gap:8px;width:100%;
  padding:8px 12px;border:none;background:none;font:inherit;font-size:12.5px;
  font-weight:550;color:var(--ink);text-decoration:none;cursor:pointer;
  text-align:left}
.sidebar-menu-item:hover{background:var(--paper);text-decoration:none}
.sidebar-menu-item svg{flex:none;color:var(--faint)}
.sidebar-menu-sep{height:1px;background:var(--hair);margin:4px 0}

main{flex:1;min-width:0;max-width:none;margin:0;padding:22px 26px 34px}
/* Three columns with equal side rails, so the middle one sits in the page's
   centre no matter how long the title or how wide the actions. A flex row
   would park the switch right after the title, where it drifts with the
   title's length and reads as misplaced. minmax(0,…) lets a long title
   ellipsise instead of pushing the switch off centre. */
.page-head{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);
  align-items:start;gap:14px;margin-bottom:20px}
.page-title{min-width:0}
.page-switch-slot{display:flex;justify-content:center;align-items:center;
  min-height:34px}
h1{font-size:23px;font-weight:700;letter-spacing:-.022em;margin:0}
.sub{color:var(--faint);font-size:13px;margin:4px 0 0}
/* Everything that hangs on the right of the header shares one grid cell:
   the e-shop search, and under it the page's own actions (the two run
   buttons on a client tab). Aligning the cell rather than the form is what
   lets the actions land under the search with no rule of their own. */
.page-aside{display:flex;flex-direction:column;align-items:flex-end;gap:8px;
  min-width:0}
.page-head .page-aside{justify-self:end}
.head-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}

/* `.toolbar .seg` (period-preset segmented control) shares this look -
   only the active-state class name differs (`.on` vs `.active`). */
.project-switch,.toolbar .seg{display:flex;background:var(--gry-bg);
  border-radius:10px;padding:3px}
.project-switch a,.toolbar .seg a,
.toolbar .seg button{padding:6px 14px;border-radius:8px;
  font-size:12.5px;font-weight:600;color:var(--soft)}
/* A <button> segment: the switch changes what the chart plots and navigates
   nowhere, so it cannot be a link. Four of these five take the UA button
   look off (border, background, and the font-family plus line-height a
   button does not inherit); `cursor:pointer` is the odd one out - it ADDS
   what the UA does not give a button, whose default cursor is `default`,
   and it is here because the link segments beside it in this same control
   get it from the UA for free. .linklike makes the same argument one page
   over, though not with the same declarations: it resets `font:inherit`
   whole. */
.toolbar .seg button{border:0;background:none;font-family:inherit;
  line-height:inherit;cursor:pointer}
.project-switch a:hover,.toolbar .seg a:hover,
.toolbar .seg button:hover{text-decoration:none;color:var(--ink)}
.project-switch a.on,.toolbar .seg a.active,
.toolbar .seg button.active{background:var(--surface);color:var(--ink);
  box-shadow:0 1px 2px rgba(20,25,40,.10)}
.toolbar .seg button:focus-visible{outline:2px solid var(--brand);
  outline-offset:2px}
/* `.seg-current` - the segment that is a LABEL, not a choice. /admin/
   performance's pager puts the window's range between its two arrows, and
   the range is not something you can pick: a self-link there (which is what
   the year it replaced was, back when the year really was one of three
   choices) claims an affordance the page does not have. It looks exactly
   like an active segment, because it occupies the same slot - the
   difference is that it is a <span> and has no href.
   tabular-nums because the label changes with every click of the pager, and
   proportional digits make the arrows either side jump by a pixel or two
   per step; white-space:nowrap because „prosinec 2024 - listopad 2026" has
   three break opportunities and a wrapped range would double the toolbar's
   height on a laptop. */
.toolbar .seg .seg-current{padding:6px 14px;border-radius:8px;font-size:12.5px;
  font-weight:600;color:var(--ink);background:var(--surface);white-space:nowrap;
  font-variant-numeric:tabular-nums;box-shadow:0 1px 2px rgba(20,25,40,.10)}

.states{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
/* min-width:0 overrides the grid item's default min-width:auto (= its
   content's max-content size), which otherwise forces the track wider than
   the minmax minimum above - admin-responsive.css additionally pins
   .states to exactly two fixed columns under 1024px, so a badge longer than
   half the card (e.g. DIAGNOSIS_META's "bez konverzí (celý e-shop)") would
   grow its column past the viewport and scroll the whole page sideways
   instead of just this card. With the item free to shrink, the badge's own
   white-space:normal override below wraps the label onto a second line
   instead of pushing the layout out - chosen over an ellipsis (tried first)
   because wrapping loses no information: a user on a narrow phone can
   still read the full diagnosis without a hover/tap they may not have. */
.state-item{display:block;padding:14px 18px;min-width:0}
.state-item + .state-item{border-left:1px solid var(--hair)}
.state-item:hover{text-decoration:none;background:var(--row-hover)}
.state-item.on{background:var(--brand-soft)}
/* Same `.on` class as the Diagnóza badge above, applied by markActive() to
   the chart legend's diagnosis links (a table row, not a .state-item) - a
   restrained cue is enough, the row already carries its own colour dot. */
.table-wrap a.on{font-weight:600}
.state-key{display:flex;align-items:center;gap:7px;font-size:12.5px;
  color:var(--soft);margin-bottom:8px;min-width:0}
/* Scoped to .state-key, not a bare `.badge` rule: every other badge use
   sits in a table cell or a fixed-width slot that already has room, so
   overriding `.badge`'s own white-space:nowrap there would be a no-op but
   still worth keeping narrow so it cannot surprise a future badge
   placement. min-width:0 lets the pill actually shrink inside the flex row
   above; white-space:normal is the actual override - .badge sets nowrap
   for every other (single-line, fixed-slot) use, which is what forced this
   pill's track wider than the grid's minmax minimum in the first place. */
.state-key .badge{min-width:0;max-width:100%;white-space:normal}
.state-count{font-size:20px;font-weight:700;letter-spacing:-.03em;
  color:var(--ink);font-variant-numeric:tabular-nums}
/* a status dot borrows the badge palette without the pill */
.dot{width:7px;height:7px;border-radius:50%;flex:none;display:block;
  background:var(--gry)}
.dot.badge-green{background:var(--ok)}
.dot.badge-orange{background:var(--warn)}
.dot.badge-red{background:var(--bad)}
.dot.badge-blue{background:var(--brand)}
.dot.badge-purple{background:var(--purple)}
.dot.badge-gray{background:var(--faint)}

/* Semafor e-shopu (health lights): pixel, Merchant, stav - three dots in a
   fixed order, beside the e-shop name in the proposal queue and at the head
   of the facts row on the e-shop detail. The ONE exception to the admin's
   „a dot means a defect" rule (docs/admin-styleguide.md): here the meaning is
   carried by the light's POSITION and the reader compares three slots, so
   „checked and fine" may be green. A lone `.dot` keeps the old rule.

   `.pixel-verdict` did not fit: one dot, deliberately no green, and the ⓘ
   inside the word's own span. `.badge` did not fit either: three pills beside
   the e-shop name lift the head's line from 20 to ~27px and claim three
   statuses where the reader wants one glance - the same argument `.eshop-meta`
   already makes against a fourth pill.

   Each `.hl` is its own tooltip trigger (`data-lead` + `title`, like the
   `info()` macro but with no glyph - three ⓘ beside a name would be six
   icons), hence the focus ring copied from `.info`.

   No colour and no font-size on `.hl` on purpose. In the queue the container
   sets 12.5px - the same step `.eshop-meta.queue-sums` and `.tool-stats`
   take - and the label inherits the head's `--ink`; on the detail both
   inherit `.sub` (13px, `--faint`), the register the facts row already
   speaks in. A size of its own would make the row's first three items a
   different size from the rest of the row. */
.health-lights{display:inline-flex;align-items:center;flex-wrap:wrap;
  gap:4px 10px;font-size:12.5px}
/* `flex-wrap` is insurance, not a case on the page: measured at 390px the
   widest trio („deaktivovaný affiliate", dates hidden) is 282,5px of 364px
   of card. Without it a longer state slug overflows a card that has no
   scrollbar. `display:inline-flex` on `.hl` is the load-bearing one, the
   same trap `.eshop-meta .pixel-verdict` documents: as a block-level flex
   the light becomes a row of its own inside its `.mi` and the facts row's
   first item grows from 20,1 to 40,3px (measured at 1920). */
.hl{display:inline-flex;align-items:center;flex-wrap:wrap;gap:2px 5px;
  cursor:help}
.hl .dot{width:8px;height:8px}
.hl-label{font-weight:500}
/* Neither half of a light's text may break - „nejméně od" / „20.06.2026"
   split across lines read as two facts. The LIGHT wraps instead, so a date
   that does not fit drops under its own label whole. Measured at 1100px on
   the detail: with nowrap on `.hl` the pixel light was 409,5px wide in a
   398px row and hung out of the page head's grid column. */
.hl-label,.hl-since{white-space:nowrap}
/* The dot-synthesized baseline of an inline-flex rides the line box - the
   same pin `.eshop-meta .pixel-verdict` carries. 0,1px here rather than its
   1,4px, because the label span is what exports the baseline; kept so the
   row height is a rule and not a coincidence of today's metrics. */
.eshop-meta .hl{vertical-align:bottom}
.hl:focus-visible{outline:2px solid var(--brand);outline-offset:2px;
  border-radius:4px}
/* The dates go first on a phone, on BOTH surfaces, and the detail's muted
   tail („kontrola 24.07.2026 14:00") goes with them - both stay one tap away
   in the light's own bubble, and a timestamp that survives the date only
   wraps mid-value in the space the date left. Measured at 390px on the
   detail: the longest `break_text` („nejméně od 05.08. do 12.08.2026") makes
   the pixel light 409,5px wide inside a 366px row and pushes
   document.scrollWidth to 422 - a scrollbar across the whole page. NOT
   scoped to `.health-lights`, which is exactly what produced that number. */
@media (max-width:699px){.hl-since,.hl-note{display:none}}

/* PNO against target - the tick is always at the midpoint, so a column of
   gauges is comparable across clients with different targets. */
.gauge{display:inline-block;position:relative;width:104px;height:6px;
  background:var(--gry-bg);border-radius:3px;vertical-align:middle}
.gauge-fill{position:absolute;left:0;top:0;bottom:0;border-radius:3px;
  background:var(--ok)}
.gauge-over .gauge-fill{background:var(--bad)}
.gauge-tick{position:absolute;left:50%;top:-3px;bottom:-3px;width:2px;
  background:var(--ink);border-radius:1px}
.gauge-nodata{background:repeating-linear-gradient(90deg,
  var(--gry-bg) 0 4px,transparent 4px 8px)}
td .gauge{margin-right:10px}
.metric .gauge{display:block;width:100%;margin-top:10px}

/* PNO number colour - a pill is for status, a number just gets coloured text */
/* One column for the whole PNO cell. The gauge is flex:none and comes first,
   the value grows and right-aligns, the target gets its own fixed slot - so
   the gauge lands at the same x on every row no matter whether the value is
   "9,4 %", "33,7 %" or "-". Fixed width matters: the cell is right-aligned,
   so without it a narrower value would drag the gauge along. */
.pno-cell{display:inline-flex;align-items:center;gap:10px;width:228px}
.pno-cell .gauge{flex:none;margin:0}
.pno-cell-bare{width:170px}
.pno-val{flex:1;text-align:right;
  font-weight:600;font-size:13.5px;font-variant-numeric:tabular-nums}
.pno-val.badge-green{color:var(--ok);background:none;padding:0}
.pno-val.badge-orange{color:var(--warn);background:none;padding:0}
.pno-val.badge-red{color:var(--bad);background:none;padding:0}
.pno-val.badge-gray{color:var(--faint);background:none;padding:0}
.pno-target{flex:none;min-width:52px;text-align:right;
  font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}

/* Paired-table PNO window (/admin/sales): figures over a full-width gauge,
   the .metric-card composition embedded in a table cell. Fixed slots for
   target and delta (the slot renders even when the delta is None) are what
   keep every value, target and gauge on one x down the column - the
   one-line .pno-cell + trailing delta could not fit at 1440px and either
   overflowed into the delta or drifted when the delta was missing. */
.pno-stack{display:inline-flex;flex-direction:column;gap:4px;width:184px;
  vertical-align:top;text-align:left}
.pno-stack-line{display:flex;align-items:baseline;gap:8px}
.pno-stack .pno-target{text-align:left;min-width:48px}
.pno-stack .gauge{display:block;width:100%;margin:0}
.pno-stack .delta-slot{margin-left:0}
/* Value + fixed delta slot on one line: `.pno-stack-line` above without the
   target and the gauge, so a Revenue window in a paired table lines up with
   the PNO window beside it. The two are deliberately the same measurements
   - flex row, baseline, gap:8px, .metric-val repeating .pno-val's weight,
   size and tabular figures - so if you change one, change the other and
   .table-paired's two overrides of them together. `.delta-slot`'s own
   margin-left is zeroed here exactly as `.pno-stack` zeroes it: the flex
   `gap` already spaces the slot, and leaving the margin on would add a
   second 8px on top of it. */
.metric-line{display:flex;align-items:baseline;gap:8px}
.metric-line .metric-val{flex:1;text-align:right;font-weight:600;
  font-size:13.5px;font-variant-numeric:tabular-nums}
.metric-line .delta-slot{margin-left:0}
/* A fixed slot for a delta badge, so rows with and without a delta align. */
.delta-slot{display:inline-block;min-width:58px;text-align:right;margin-left:8px}
.delta-slot .delta{margin-left:0}

.metric .value[class*="badge-"]{background:none;padding:0}
.metric .value.badge-green{color:var(--ok)}
.metric .value.badge-orange{color:var(--warn)}
.metric .value.badge-red{color:var(--bad)}
.metric .value.badge-gray{color:var(--faint)}

/* .chart-cell-head was defined twice (accounts.html, client_ads.html) with
   diverging rules - this is the one canonical shared definition. */
.chart-cell-head{font-size:11px;text-transform:uppercase;letter-spacing:.05em;
  color:var(--soft);font-weight:600;margin:0 0 6px}

/* Login page. The medallion overlaps the card's top edge, so the card carries
   the extra top padding and the medal is absolutely positioned inside it.
   The wash is one gradient between two existing tokens, not an image. */
.login-body{display:grid;place-items:center;min-height:100vh;padding:24px;
  background:radial-gradient(120% 90% at 50% -10%,var(--brand-soft) 0%,
    var(--paper) 60%)}
.login-card{position:relative;width:100%;max-width:22rem;margin-top:28px;
  padding:44px 32px 28px;text-align:center;background:var(--surface);
  border-radius:var(--radius);box-shadow:var(--shadow)}
.login-medal{position:absolute;top:-28px;left:50%;transform:translateX(-50%);
  width:56px;height:56px;padding:6px;border-radius:50%;
  background:var(--surface);box-shadow:var(--shadow)}
.login-card h1{margin:0 0 6px;font-size:20px;letter-spacing:-.2px}
.login-card .sub{margin:0 0 18px;color:var(--soft)}
.login-submit{width:100%;justify-content:center}
.login-note{margin:12px 0 0;font-size:12px;color:var(--faint)}
/* OAuth consent (/admin/oauth/authorize) - the login card's sibling, not a
   new surface: same medallion, same centred head, rendered through
   auth.templates without the sidebar, because the person arrives from
   Claude rather than from the admin. Two things differ from login.
   The card is wider - the page carries a sentence of scope, an optional
   warning and two buttons, and at 22rem the scope sentence wraps to five
   centred lines. And the body under the head is left-aligned - centred
   prose over three lines reads as a poster, not an explanation. The
   two-class selector outranks .login-card on specificity, not file order. */
.login-card.consent-card{max-width:26rem}
/* An e-mail is one unbreakable word; on a phone a long one would push the
   card wider than the viewport. */
.consent-email{overflow-wrap:anywhere}
/* One flex column with one gap owns the rhythm, so the <p>, the
   .summary-warn callout and the form need no margins of their own. */
.consent-body{display:flex;flex-direction:column;gap:14px;text-align:left;
  font-size:13.5px}
.consent-body > *{margin:0}
/* Two equal buttons on one row. flex-basis 6rem (not 0) plus wrap: where
   the row gets too narrow for both labels, the second button drops under
   the first. Where that happens depends on the font - a local Chromium
   wraps at a 320px viewport, CI's runner keeps one row there - and both
   labels fit at 320px even without the wrap, so the wrap is a safety net
   for narrower screens that no test measures. The e2e tests pin one row
   at 335px and "no overflow, no spilled label" at 320px. */
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
.consent-actions .btn{flex:1 1 6rem;justify-content:center}

/* Sortable table headers. The whole header cell is the click target; the
   indicator keeps a fixed width so switching columns doesn't shift the row. */
.sort-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
.sort-link:hover { text-decoration: underline; }
.sort-link.on { color: var(--brand); }
/* A header cell carrying a primary and a secondary sort key, for a column
   whose cell states two facts on two lines. Colour is deliberately NOT set -
   `.sort-link.on` (same specificity) must keep winning. The sub register
   repeats `.table-paired thead tr:last-child th` on purpose: the secondary
   key sits between the two header tiers and has to read as the lower one.
   Change the two together. */
.th-stack{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.th-stack .sort-link-sub{font-size:10.5px;text-transform:uppercase;
  letter-spacing:.05em;font-weight:600}
.sort-ind {
  display: inline-block;
  min-width: 1.1em;
  font-size: 0.85em;
  opacity: 0.85;
}
/* Leaving a non-default sort. One control for both sorting UIs: the seven
   <th> tables put it in its own right-aligned row above .table-wrap,
   /admin/proposals drops it at the end of the .sort-chips row - and that
   second context is what dictates the look. Next to .sort-chip the exit has
   to differ in SILHOUETTE, not in shade: the first version kept the chip's
   1px --line outline and only swapped the fill to --paper, a ~2% step that
   at 12.5px read as one more sort option (screenshot review, 2026-08-16).
   So the outline goes and the fill carries the shape - the same borderless
   --gry-bg surface .project-switch, .toolbar .seg, .badge-gray and .meter
   already use for "a control surface, not a bordered option". Padding
   absorbs the dropped border (3/11 instead of 2/10), so the box and every
   geometry assertion on it are unchanged. Hover goes DARKER, never lighter:
   --surface would erase the one thing separating this from a chip at the
   moment of clicking it, and on a borderless pill it would erase the pill.
   --line as a fill is the cross-role move --paper already makes as the
   hover fill of every --surface control here (.btn, .sidebar-nav a).
   text-decoration:none is repeated on :hover because `a:hover` is (0,1,1)
   and beats a single class (0,1,0) - the base declaration alone underlines,
   which is exactly why .sort-chip underlines on hover today.
   Row padding is symmetric 10px vertically, not the old `0 … 8px`: with a 0
   top the pill sat glued to the filter bar's bottom hairline. Horizontal is
   18px, the admin's grid (card-head, card-body, filters, th, td) - the old
   16px was 2px off it. */
.sort-reset-row{display:flex;justify-content:flex-end;padding:10px 18px}
.sort-reset{display:inline-flex;align-items:center;gap:5px;
  padding:3px 11px;border-radius:12px;
  background:var(--gry-bg);color:var(--soft);
  font-size:12.5px;font-weight:500;line-height:1.5;text-decoration:none}
.sort-reset:hover{background:var(--line);color:var(--ink);
  text-decoration:none}
/* The glyph is decoration - the label already says what the control does,
   and „křížek Zrušit řazení" tells a screen reader nothing. aria-hidden
   sits in the macro, the size here. */
.sort-reset-x{font-size:11px;line-height:1}

/* Group-order chips on /admin/proposals - the queue paginates by shop, so the
   sortable attributes (shop, count, impact, date) describe the group, not a
   table column, and have no <th> to live in. */
.sort-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 16px 12px; font-size: 13px; }
.sort-chip { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border: 1px solid var(--line); border-radius: 12px; text-decoration: none; color: inherit; }
.sort-chip.on { border-color: var(--brand); color: var(--brand); }
/* The proposal queue puts this control inside a .card-head, where the row's
   own bottom padding would double the head's and push the chips off centre. */
.card-head .sort-chips { padding: 0; margin-left: auto; font-weight: 400; }
/* Inside the chip row the reset is one flex item among the sort options, so
   it keeps no row padding of its own and buys air instead: the row's 8px
   gap plus 6px reads as „the options end here", which at chip size the fill
   colour alone does not do. */
.sort-chips .sort-reset{margin-left:6px}

/* /admin/churn - the two movement charts render their own legend so each
   series can carry an explanation. Own class names throughout, none layered
   onto an existing class: two single-class selectors setting the same
   property are decided by declaration order, and this admin has lost four
   such overrides at runtime already. */
/* No horizontal or bottom padding: the <ul> sits inside .card-body, which
   already insets 18px (13px on phones, via admin-responsive.css) on every
   side - a second inset here doubled the left/right gap against the canvas
   above it and stacked extra whitespace under the last row. */
.chart-legend{list-style:none;margin:0;padding:2px 0 0;display:grid;gap:5px}
.chart-legend-row{display:flex;align-items:baseline;gap:8px;font-size:12.5px}
/* `.chart-legend-row` sets its own display, which beats the UA [hidden]
   rule - same fix as .btn[hidden] and .search-suggest[hidden]. Without it
   `li.hidden = true` in admin-proposal-charts.js is a dead write and the
   legend keeps naming a series the chart did not draw. */
.chart-legend-row[hidden]{display:none}
.chart-legend-swatch{width:10px;height:10px;border-radius:2px;flex:none;
  transform:translateY(1px)}
/* A line series gets a line, not a square: the two net-change series differ
   only in stroke, and a square swatch cannot say which is which. Declared
   after .chart-legend-swatch on purpose - same specificity, later wins. */
.chart-legend-swatch-line{width:14px;height:2px;border-radius:0;
  transform:translateY(-2px)}
/* Dots drawn as gaps in the paper colour, so the element's own
   background-color stays the series colour - the e2e swatch/dataset colour
   check reads exactly that property. */
.chart-legend-swatch-line.is-dotted{
  background-image:repeating-linear-gradient(to right,
    transparent 0 2px,var(--surface) 2px 4px)}
.chart-legend-name{font-weight:600;min-width:104px}
.chart-legend-explain{color:var(--soft)}
@media (max-width:699px){
  .chart-legend-row{flex-wrap:wrap;gap:4px 8px}
  .chart-legend-name{min-width:0}
}
/* The queue chart's legend has to be ONE line: twenty of them share a page,
   and `.chart-legend`'s grid (a row per series) would spend 57px per group
   where this spends 22. Everything else - the swatches, the dotted stroke,
   the line shape - is the same component; only the flow changes, so this is
   a modifier on `.chart-legend` and NOT a second legend. (0,2,0) against
   `.chart-legend`'s (0,1,0), like `.chart-wrap-cohorts`, so it wins on
   specificity and not on file order. */
.chart-legend.chart-legend-inline{display:flex;flex-wrap:wrap;
  align-items:center;gap:3px 14px;padding:5px 0 0}
.chart-legend-inline .chart-legend-row{font-size:11px;gap:6px}
/* The ⓘ is a 16px inline-flex box whose baseline is its own bottom edge, so
   on `.chart-legend-row`'s `align-items:baseline` it hangs 16px ABOVE the
   text baseline and the descenders below it - measured 21px of row against
   the 13px the 11px text needs, and 26px of legend against a 24px budget
   twenty group heads share. Centring the glyph alone (never the row: the
   swatches' translateY tuning is set against the baseline) brings the row
   back to the glyph's own 16px. */
.chart-legend-inline .info{align-self:center}
.chart-legend-inline .chart-legend-name{font-weight:400;min-width:0;
  color:var(--soft)}
/* The outage rows („Revenue Srovnáme nedostupné", „Útrata (GA) a konverze
   nedostupné")
   are the ONLY signal that a source is missing rather than at zero, and the
   quiet modifier above out-specifies the token class that carries them:
   (0,2,0) against `.val-bad`'s (0,1,0), so without this rule they render
   grey, 400, exactly like every other legend row - the same trap
   `.metric .hint.val-bad` above exists for, and the one the styleguide's
   Pitfalls section inventories. (0,3,0), so it wins over the modifier on
   specificity and not on file order. Only the red token is restated: the
   legend has no consumer for `.val-ok` or `.val-warn`, and adding them
   would be two dead rules. */
.chart-legend-inline .chart-legend-name.val-bad{color:var(--bad);
  font-weight:600}
/* The window total, in the legend row that names its own series.
   Deliberately NOT in the `.eshop-meta` strip above the chart: the Revenue
   there comes from `get_pno_by_eshop` over ALL positions, while the chart's
   is `click_log_aggregated.toll` over Shopping only (97,7 % of it), so a
   conversion count standing next to THAT Revenue would invite a PNO whose
   numerator and denominator belong to different populations.
   --ink/600 against its own --soft/400 row: the number is what the reader
   came for, the label only says which curve it is. Its own class rather
   than a <strong> inside `.chart-legend-name` - that one carries
   `.chart-legend-inline .chart-legend-name{color:var(--soft)}` at (0,2,0)
   and would hand the figure this grey. `nowrap`, because `money` separates
   thousands with a plain space - the same reason
   `.eshop-meta .money-nowrap` exists.
   Measured: three totals lift the desktop legend's content from 565px to
   721 (typical) / 758 (a seven-digit value beside a four-digit count), on
   one line at every desktop width - the narrowest slot the queue renders is
   992px, because the table sits at its own min-content there. */
.chart-legend-total{font-weight:600;color:var(--ink);white-space:nowrap;
  font-variant-numeric:tabular-nums}
/* The scope note is not a series, and with the totals in front of it it now
   follows a bare number: „Počet konverzí 412 30 dní do včerejška" reads as
   one figure. „·" is the same separator `.eshop-meta` draws eight pixels
   above it. Measured: +9px of legend width, no change to the row count at
   any width. */
.chart-legend-inline .chart-legend-scope::before{content:"·";
  color:var(--faint)}
/* The dotted swatch draws its gaps in the page background, and in the
   queue's group head that is --paper, not the --surface every other chart
   in this admin sits on. The gaps CANNOT be transparent: the element's own
   background-color is the series colour (which is what the e2e swatch check
   reads), and the gradient paints over it, so a see-through gap shows the
   series colour and the swatch stops being dotted at all.
   Which means the pair below is load-bearing rather than tidy: at phone
   width `admin-responsive.css` turns `tr.group-head td` background off
   (the head becomes a title for the cards under it), so the ground goes
   back to --surface. Measured on a 14x3px screenshot of the swatch at 390:
   olive, #f5f7fa gaps and #ffffff around them - three colours where 1440
   has two. The drift is barely visible today (dRGB 10/8/5 over 2px gaps)
   and would become obvious the day --paper moves. Same 699px breakpoint as
   the rule that causes it. */
tr.group-head .chart-legend-swatch-line.is-dotted{
  background-image:repeating-linear-gradient(to right,
    transparent 0 2px,var(--paper) 2px 4px)}
@media (max-width:699px){
  tr.group-head .chart-legend-swatch-line.is-dotted{
    background-image:repeating-linear-gradient(to right,
      transparent 0 2px,var(--surface) 2px 4px)}
}
/* Easter egg - the Konami code rains moustaches (pista-egg.js).
   pointer-events:none is what keeps the admin usable while they fall; it is
   load-bearing, not cosmetic, and tests/e2e/test_pista_drobnosti.py
   measures it in the browser.

   65 sits above the sidebar (60) and below .tip (70), which owns the top of
   the ladder - tests/test_admin_tooltip.py asserts that a tooltip slides
   under nothing. A joke covering a tooltip someone opened to read is not a
   trade this overlay gets to make. */
.pista-egg{position:fixed;inset:0;z-index:65;pointer-events:none;
  overflow:hidden}
.pista-egg-stache{position:absolute;top:-140px;width:120px;height:120px;
  fill:var(--ink);animation-name:pista-egg-fall;
  animation-timing-function:linear;animation-fill-mode:forwards}
@keyframes pista-egg-fall{
  from{transform:translateY(0) rotate(0)}
  to{transform:translateY(calc(100vh + 280px)) rotate(var(--spin,360deg))}
}
@media (prefers-reduced-motion:reduce){.pista-egg{display:none}}

/* Copyable snippets (/admin/mcp). `.code-block` is a copyable URL or command:
   monospace on the page's paper tint so it reads as "not prose", wrapping
   anywhere (pre-wrap + break-all) so a long URL or command stays inside its
   own box on a phone instead of widening the page, with room reserved on
   the right for the copy button. */
.code-block{position:relative;background:var(--paper);border:1px solid var(--line);
  border-radius:var(--radius-sm);padding:11px 110px 11px 14px;margin:12px 0;
  font-family:var(--font-mono);
  font-size:13px;line-height:1.55;overflow-x:auto;white-space:pre-wrap;
  word-break:break-all}
.copy-btn{position:absolute;top:7px;right:7px;padding:4px 10px;font-size:12px;
  font-family:var(--font-sans);color:var(--soft);background:var(--surface);
  border:1px solid var(--line);border-radius:var(--radius-sm);cursor:pointer}
.copy-btn:hover{color:var(--ink);background:var(--row-hover)}
.card-body > p:first-child{margin-top:0}
.card-body > :last-child{margin-bottom:0}

/* /admin/tools - the tool catalog. One card per family, one `.tool` block per
   tool inside it, separated by a hairline. Not a table and not a card each:
   the tool's description IS the content (it is verbatim what the model
   reads, up to nine lines), so it cannot be a detail behind a disclosure,
   and a card per tool would bury the family the six sections are built
   around. `.card-body` was the near miss - it pads prose once for the whole
   card, and every tool here needs its own padded, separable block. */
.tool{padding:16px 18px}
.tool + .tool{border-top:1px solid var(--hair)}
/* Name and the two pills on one wrapping line, aligned on the name's
   baseline so a pill does not ride above a 14.5px monospace name. */
.tool-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px}
.tool-name{margin:0;font-family:var(--font-mono);font-size:14.5px;
  font-weight:600;letter-spacing:-.01em;color:var(--ink);overflow-wrap:anywhere}
/* 68ch is a hard cap on the measure: at 1920px the card is ~1600px wide and
   an unconstrained paragraph would run to ~200 characters a line. */
.tool-desc{margin:8px 0 0;max-width:68ch;line-height:1.6}
/* The usage line reuses `.eshop-meta`'s dot-separated fact row (its „·" is
   drawn by CSS, so a missing item leaves no dangling separator) at a quieter
   size. Two classes on purpose: a single-class `.tool-stats` would have the
   same specificity as `.eshop-meta` and win on file order alone - which is
   how three overrides in this admin have already been lost. */
.eshop-meta.tool-stats{margin-top:8px;font-size:12.5px;color:var(--soft)}
/* Arguments are a reference, not the point of the page - a native <details>,
   collapsed. No `display:flex` on the summary and no `list-style:none`: both
   suppress the native ::marker, and the triangle is the only affordance
   saying the row opens (`.churn-month` draws its own arrow precisely because
   it needed flex). */
.tool-args{margin-top:10px}
.tool-args > summary{font-size:12.5px;color:var(--soft);padding:2px 0}
.tool-args > summary:hover{color:var(--ink)}
.tool-args[open] > summary{margin-bottom:8px}
/* The argument table sits inside a `.tool` block, not directly on the card,
   so it needs its own outline to read as a table rather than as more prose.
   Two classes for the same specificity reason as `.tool-stats` above. */
.tool-args .table-wrap{border:1px solid var(--line);border-radius:var(--radius-sm)}
/* Off the admin's 18px horizontal grid on purpose, and the only rule here
   that is: this table is nested two levels inside a `.tool` block that
   already pays the 18px inset, so cells on the outer grid would sit 36px
   from the card edge and read as a second, deeper indent rather than as a
   table. 12px keeps the columns visibly inside their box. */
.tool-args th,.tool-args td{padding:8px 12px}
.tool-args td:first-child{font-family:var(--font-mono);font-size:12.5px}
/* The measure cap on the description column, the same argument `.tool-desc`
   makes two elements above: at 1920px the column grew to ~740px and a
   115-character description sat on a single line. It has to hang on an
   inline-block inside the cell, because chromium ignores `max-width` on a
   table cell under auto layout - which is why `td.wrap`'s global
   `max-width:520px` was doing nothing here. (That inertness is global and
   pre-existing; this rule sidesteps it rather than fixing it.) 68ch of the
   table's 13.5px face measures 612px, one notch under `.tool-desc`'s own
   line, which is the point: the two paragraphs sit on the same measure. */
.tool-arg-desc{display:inline-block;max-width:68ch}
/* Up to 1024px the sticky `.topbar` (~53px) covers whatever an in-page
   anchor scrolls to, so the family links at the top of the page would land
   on a heading hidden under it. */
.tool-group{scroll-margin-top:70px}

/* --- Run transcript (/admin/agentic/{id}) --------------------------------
   The agentic run's timeline, read as a conversation. Everything visual
   hangs off the *register* the Python side maps an event kind to
   (transcript_register): twelve one-class selectors layered over one
   element is the source-order trap this file's Pitfalls section is about,
   while eight registers with one rule each keep every declaration in one
   place. The kind reaches the DOM only as data-kind, for the e2e asserts.

   920px is a line measure, not a round number: `main` is flex:1 with no
   cap, so on a 1600px monitor the model's prose would set ~200 characters
   to the line; 920px at 14px Inter is ~95. Left-aligned, because the whole
   admin is, and a centred column would break with the card-head above it.
   No inner scroller either - the page scrolls; a chat that scrolls inside
   a scrolling page is two gestures for one finger. */
.transcript{display:flex;flex-direction:column;gap:10px;max-width:920px}
/* A flex item's min-width defaults to auto, so one unbreakable token inside
   a <pre> would push the whole column past its parent instead of scrolling. */
.transcript > *{min-width:0}

.tr-msg{padding:9px 14px;background:var(--surface);
  border-left:3px solid var(--line);
  border-radius:0 var(--radius-sm) var(--radius-sm) 0}
.tr-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px}
.tr-role{flex:none;font-size:10.5px;font-weight:700;letter-spacing:.06em;
  text-transform:uppercase;color:var(--faint)}
.tr-title{font-size:13.5px;font-weight:600;min-width:0;overflow-wrap:anywhere}
.tr-facts{display:flex;flex-wrap:wrap;gap:10px;margin-left:auto;
  font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
/* transcript_facts() is a whitelist, so most bubbles have none - and an
   empty flex item still eats the `margin-left:auto` and shoves the time. */
.tr-facts:empty{display:none}
.tr-time{flex:none;font-size:11.5px;color:var(--faint);
  font-variant-numeric:tabular-nums}
.tr-text{margin-top:6px;font-size:13.5px;white-space:pre-wrap;
  overflow-wrap:anywhere}
.tr-args{display:block;margin-top:5px;font-family:var(--font-mono);
  font-size:12.5px;color:var(--soft);overflow-wrap:anywhere}

/* The voices. A tool call and its result share the purple rail on purpose:
   they are one hand in two beats, and parallel calls interleave under
   asyncio.gather, so nesting them is impossible and the shared colour is
   the only thing holding the pair together. */
.tr-say{background:transparent;border-left-color:var(--ink)}
.tr-say .tr-text{font-size:14px;color:var(--ink)}
.tr-ask{background:var(--brand-soft);border-left-color:var(--brand)}
.tr-tool{border-left-color:var(--purple)}
.tr-out{background:var(--paper);border-left-color:var(--purple)}
.tr-tool .tr-title{font-family:var(--font-mono);font-size:13px;font-weight:600}
/* Same red family as .summary-bad - one alarm colour in the admin, not two. */
.tr-alert{background:var(--bad-bg);border-left-color:var(--bad)}
.tr-alert .tr-title,.tr-alert .tr-role{color:var(--bad)}
/* The whisper: what the harness says about itself. Three quarters of the
   volume and nothing to learn when read top to bottom, so it gets a dot
   instead of a bubble - but it keeps the time on the right, because a
   sixty-second gap between two grey lines is the most valuable thing on
   the page. */
.tr-note{padding:1px 0 1px 17px;position:relative;background:none;border:0;
  font-size:12.5px;color:var(--soft)}
.tr-note .tr-title{font-size:12.5px;font-weight:500}
/* …and its body with it. `.tr-text` is 13,5px for everyone else, which left
   a whisper's body louder than its own title and half a pixel under the
   model's speech - the register split coming apart on exactly the rows a
   reader is meant to skim. The colour was already suppressed; the size was
   not, and only a measurement of `.tr-text` itself can see that. */
.tr-note .tr-text{font-size:12.5px}
.tr-note::before{content:"";position:absolute;left:4px;top:.65em;width:5px;
  height:5px;border-radius:50%;background:var(--line)}

/* A campaign is a chapter, not a message - as a bubble it would compete
   with the conversation it introduces. */
.tr-sep{display:flex;align-items:center;gap:12px;margin:12px 0 2px}
.tr-sep::before,.tr-sep::after{content:"";flex:1;height:1px;
  background:var(--line)}
.tr-sep-label{flex:none;max-width:100%;font-size:11px;font-weight:600;
  letter-spacing:.05em;text-transform:uppercase;color:var(--soft);
  overflow-wrap:anywhere}
.tr-sep-note{margin-left:8px;font-weight:500;letter-spacing:0;
  text-transform:none;color:var(--faint)}

/* The verdict. A full strip, so it stays visible while scrolling; the
   colour comes from meta["status"], and a run written before that field
   existed lands on the neutral grey base rather than looking done. */
.tr-end{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px;
  margin-top:6px;padding:10px 14px;border-radius:var(--radius-sm);
  background:var(--gry-bg);color:var(--gry)}
.tr-end-title{font-weight:700;font-size:13.5px}
.tr-end-note{font-size:13px}
.tr-end-done{background:var(--ok-bg);color:var(--ok)}
.tr-end-failed{background:var(--bad-bg);color:var(--bad)}

/* Bulk data stays folded: a prompt and a tool result are tens of thousands
   of characters by definition. The height cap plus the inner scroller is
   what keeps opening one from pushing the rest of the page out of reach. */
.tr-body{margin-top:8px}
.tr-body > summary{font-size:12.5px;color:var(--brand);list-style:none}
.tr-body > summary::-webkit-details-marker{display:none}
.tr-body > summary::before{content:"▸";margin-right:6px;font-size:11px;
  color:var(--faint)}
.tr-body[open] > summary::before{content:"▾"}
.tr-body > summary:hover{text-decoration:underline}
.tr-body pre{margin:8px 0 0;padding:11px 13px;max-height:360px;overflow:auto;
  background:var(--paper);border:1px solid var(--line);
  border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12.5px;
  line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}
/* A tool result is a table drawn with pipes - wrapping it destroys the
   columns, so this one scrolls sideways inside its own box instead. */
.tr-out .tr-body pre{white-space:pre;overflow-wrap:normal}

/* „Agent pracuje" - the loader and the ticking counter, always the last
   child of .transcript (agentic-transcript.js inserts before it). */
.tr-working{margin-top:8px;padding-top:4px;border-top:1px dashed var(--line);
  text-align:center}
.tr-working .pista-loader-wrap{padding:14px 12px 4px}
.tr-working-line{font-size:13px;color:var(--soft);
  font-variant-numeric:tabular-nums}
.tr-working-line b{font-weight:700;color:var(--ink)}
.tr-error{margin-top:10px;color:var(--bad);font-size:13px;text-align:center}

/* The question card on the run page (/admin/agentic/{id}): the operator's
   question in the quiet register above the answer the synthesis wrote.
   pre-line keeps the paragraphs either text was typed with; 68ch caps the
   measure like .tool-desc, so a long answer stays readable in a full-width
   card. */
.qa-question{margin:0 0 10px;color:var(--soft);white-space:pre-line;
  max-width:68ch}
.qa-answer{margin:0;white-space:pre-line;max-width:68ch}
