/* ===================================================================
   StoneSky brand — the ONE place SkyKeep's palette, type and lockup are
   defined (Q2).

   Before this file there were four copies of the palette: index.html,
   admin.html, audit.html and help.py's `_PAGE_CSS`. Four copies of a
   truth is four places it can disagree, and this repo has already paid
   for that. Every page now links this file and spends TOKENS; a page's
   own <style> block holds layout, not colour.

   Nothing here carries authority. The vault decides who may see what on
   the server, on every request, and always did — a stylesheet cannot
   grant or withhold anything. Colour here is only how the answer looks.

   --------------------------------------------------------------------
   FONTS ARE SELF-HOSTED, ON PURPOSE.

   SkyKeep is a local-first, air-gappable vault whose only published
   entry point is the TLS front door (Gate IF.5). A <link> to a web-font
   CDN would hand that deployment a runtime dependency on somebody
   else's host — a request leaving the engagement network on every page
   load, carrying the referrer with it. So the faces are vendored into
   the repo, served from the app's own static route, and @font-face'd
   below from app-relative URLs.

   `test_brand.py` asserts mechanically that no shipped surface names an
   external asset host, because a promise in a comment is not a control.
   That test greps for the CDN hostnames as literal strings, which is
   why this comment does not spell them: a file that says the words is
   indistinguishable, to a grep, from a file that uses them.

   Provenance (all three faces are SIL Open Font License 1.1, so
   redistribution inside this repo is permitted; the licence text ships
   beside the binaries in static/fonts/):

     Archivo                fonts/archivo-latin-var.woff2
       Google Fonts v25 · https://fonts.google.com/specimen/Archivo
       Licence: fonts/OFL-Archivo.txt
       (upstream https://github.com/Omnibus-Type/Archivo)
     JetBrains Mono         fonts/jetbrains-mono-latin-var.woff2
       Google Fonts v24 · https://fonts.google.com/specimen/JetBrains+Mono
       Licence: fonts/OFL-JetBrainsMono.txt
       (upstream https://github.com/JetBrains/JetBrainsMono)
     Saira Semi Condensed   fonts/saira-semi-condensed-latin-{400,600,700}.woff2
       Google Fonts v15 · https://fonts.google.com/specimen/Saira+Semi+Condensed
       Licence: fonts/OFL-SairaSemiCondensed.txt
       (upstream https://github.com/Omnibus-Type/Saira)

   SUBSETTING: the `latin` slice only (U+0000-00FF plus the punctuation
   and symbol ranges Google's `latin` unicode-range covers — which
   includes U+2122 TRADE MARK SIGN, so "Keep the watch™" renders in the
   real face). Latin-ext, Greek, Cyrillic and Vietnamese are NOT
   vendored: 121 KB total instead of roughly 600 KB. The portal's own
   chrome is English. Anything outside the slice — including the ✓/✗
   marks some panels use — falls through to the fallback stack, which is
   why every family below names real fallbacks rather than a bare
   `sans-serif`.
   =================================================================== */

/* Archivo and JetBrains Mono are variable (400–700 in one file), so one
   @font-face each covers every weight the product uses. Saira Semi
   Condensed has no variable cut on Google Fonts, so it ships as three
   static weights. `font-display: swap` keeps text readable while the
   face loads — on a local deployment that window is milliseconds, but
   a blank heading is never the right failure. */
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 400 700;
  font-stretch: 100%;
  font-display: swap;
  src: url("/static/fonts/archivo-latin-var.woff2") format("woff2");
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/jetbrains-mono-latin-var.woff2") format("woff2");
}
@font-face {
  font-family: "Saira Semi Condensed";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/static/fonts/saira-semi-condensed-latin-400.woff2") format("woff2");
}
@font-face {
  font-family: "Saira Semi Condensed";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/static/fonts/saira-semi-condensed-latin-600.woff2") format("woff2");
}
@font-face {
  font-family: "Saira Semi Condensed";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/static/fonts/saira-semi-condensed-latin-700.woff2") format("woff2");
}

/* --------------------------------------------------------------------
   TOKENS.

   Every colour in the product has its definition HERE, on bare `:root`.
   Not inside a media query, not inside a `[data-theme]` block. A colour
   whose only definition is conditional has no value at all in the
   condition nobody tested, and the failure shows up as an invisible
   control rather than as an error.

   The palette is dark-first because the brand is: StoneSky's own ground
   is basalt. `color-scheme: dark` tells the user agent so, which is what
   makes native scrollbars, form controls and the canvas behind the page
   match instead of flashing white.
   -------------------------------------------------------------------- */
:root {
  color-scheme: dark;

  /* -- brand primitives (verbatim from stonesky.ai, 2026-08-25) ------ */
  --sk-basalt:        #0E1116;  /* deepest ground */
  --sk-card-hover:    #12161C;
  --sk-graphite:      #1A1F26;  /* surface */
  --sk-skyhigh:       #1B2A44;  /* deep blue */
  --sk-stone:         #E7E2D6;  /* primary text */
  --sk-accent:        #E0682B;
  --sk-secondary:     #C2521E;
  --sk-sealcrim:      #9E2B1B;

  /* -- dark-theme primitives (TH-1) ---------------------------------- *
     These were literals in the semantic block below until Phase 17. They
     are named here so the `[data-theme="dark"]` re-assert further down can
     point at the SAME source rather than repeating the number — a second
     copy of a colour is the defect Q2 removed and this file must not
     quietly reintroduce it while adding a theme.                        */
  --sk-d-text-muted:      #A9A294;
  --sk-d-border:          #2A303A;
  --sk-d-border-strong:   #3A4250;
  --sk-d-border-control:  #616C7C;
  --sk-d-accent-hover:    #E87E44;
  --sk-d-ok:              #5FD39B;
  --sk-d-warn:            #E3C766;
  --sk-d-danger:          #E8746A;
  /* NW-MINDMAP-KEY (user-directed 2026-09-06): the keyword map's three
     coloured frequency bands. They USED to be tints of danger, warn and
     accent — red, yellow-brown and orange, three points on the one hue
     axis that deuteranopia and protanopia (the common kinds, ~8% of men)
     collapse into "brownish". These are Okabe & Ito's vermillion, sky
     blue and bluish green, the palette built to stay apart under both,
     lightened for the dark ground (6.5:1 / 8.3:1 / 8.4:1 on graphite).
     Colour is never the only cue: index.html gives each band its own
     border style too. */
  --sk-d-band-top:        #F0883E;
  --sk-d-band-half:       #6FC0F0;
  --sk-d-band-several:    #3ECFA4;
  --sk-d-warn-surface:    #241E12;
  --sk-d-shadow-overlay:  0 8px 30px rgba(0, 0, 0, 0.6);

  /* -- light-theme primitives (TH-1) --------------------------------- *
     Warm paper rather than white: the brand's ground is stone, and a pure
     #FFF page beside basalt chrome reads as a different product. Every
     value here was MEASURED into place by `brandcontrast.py` against the
     pairs the product actually ships, not chosen and hoped for.          */
  --sk-l-paper:           #F7F4EF;  /* the page */
  --sk-l-paper-sunk:      #EBE6DC;  /* header, rail, table head */
  --sk-l-card:            #FFFFFF;  /* cards, panels, inputs */
  --sk-l-elevated:        #DDE7F5;  /* selected row, callout */
  --sk-l-ink:             #13171D;  /* primary text */
  --sk-l-ink-muted:       #55606E;
  --sk-l-border:          #DDD6C8;
  --sk-l-border-strong:   #A79E8C;
  --sk-l-border-control:  #6B7482;
  --sk-l-accent-ink:      #9C3F0C;  /* accent as TEXT: the fill colour is
                                       3.05:1 on paper and cannot carry a
                                       sentence; this can              */
  --sk-l-ok:              #12653F;
  --sk-l-warn:            #6A4E00;
  --sk-l-danger:          #A32517;
  --sk-l-band-top:        #B84A00;  /* 5.2:1 on card */
  --sk-l-band-half:       #005A9C;  /* 7.1:1 */
  --sk-l-band-several:    #00704F;  /* 6.1:1 */
  --sk-l-danger-surface:  #F8E2DE;
  --sk-l-on-danger:       #6B190F;
  --sk-l-warn-surface:    #FBF1D4;
  --sk-l-shadow-overlay:  0 8px 30px rgba(14, 17, 22, 0.18);

  /* -- roles ---------------------------------------------------------
     Primitives say what a colour IS; roles say what it is FOR. Pages
     spend roles, so a primitive can be re-pointed in one edit. */
  --sk-bg:              var(--sk-basalt);
  --sk-chrome:          var(--sk-card-hover);   /* header, rail, table head */
  --sk-surface:         var(--sk-graphite);     /* cards, panels, inputs */
  --sk-surface-hover:   var(--sk-card-hover);   /* a card settling toward the ground */
  --sk-elevated:        var(--sk-skyhigh);      /* selected row, callout */

  --sk-text:            var(--sk-stone);
  /* Stone, dimmed rather than greyed, so muted copy stays in the same
     family as the primary. 7.46:1 on basalt, 6.53:1 on graphite — AA
     body everywhere it is used, because "secondary" must never mean
     "unreadable". */
  --sk-text-muted:      var(--sk-d-text-muted);

  --sk-border:          var(--sk-d-border);  /* hairline between like surfaces */
  --sk-border-strong:   var(--sk-d-border-strong);  /* a divider that should be noticed */
  /* Form-control boundaries carry meaning (WCAG 1.4.11 non-text
     contrast, 3:1). #616C7C is 3.55:1 on basalt, 3.41:1 on the chrome,
     3.11:1 on graphite. The two hairlines above are decorative and do
     not carry that duty. */
  --sk-border-control:  var(--sk-d-border-control);

  /* Accent is the interactive colour: links, focus, the primary action.
     5.57:1 on basalt / 5.34:1 on chrome / 4.88:1 on graphite — AA body
     on all three. It is 4.23:1 on --sk-elevated, which is AA LARGE
     only, so accent text is not placed on skyhigh. */
  --sk-link:            var(--sk-accent);
  --sk-focus:           var(--sk-accent);

  /* An accent-filled button takes BASALT text, not stone. Stone on
     accent is 2.63:1 and fails outright; basalt on accent is 5.57:1.
     This is the pair most likely to be got wrong by eye, because pale
     text on a warm fill LOOKS fine. */
  --sk-on-accent:       var(--sk-basalt);
  /* Hover LIGHTENS rather than darkens, which is the opposite of the
     usual instinct and the only direction that works here: the text on
     an accent fill is basalt, so a darker fill takes the pair DOWN.
     Secondary (#C2521E) was the obvious hover and reaches only 4.07:1
     with basalt on it — AA large, not AA body. #E87E44 is 6.75:1. */
  --sk-accent-hover:    var(--sk-d-accent-hover);
  /* Secondary keeps its brand role as a RULE and an edge — the core of
     the mark, a left border, a divider under a heading. It is 4.07:1 on
     basalt, so it is never asked to carry body text. */

  /* Status. Green and amber are outside the brand primitives (StoneSky
     has no "success" colour) and are chosen to sit in the palette
     without competing with accent — the amber is deliberately yellower
     than #E0A336 so "warning" never reads as "link". */
  --sk-ok:              var(--sk-d-ok);   /* 10.16:1 basalt / 8.89:1 graphite */
  --sk-warn:            var(--sk-d-warn);   /* 11.37:1 basalt / 9.96:1 graphite */
  /* Danger has two colours because sealcrim cannot be text: #9E2B1B is
     2.54:1 on basalt and fails even the 3:1 large-text floor. It is a
     SURFACE and a RULE; the readable tint is what carries words. */
  --sk-danger:          var(--sk-d-danger);   /* 6.43:1 basalt / 5.63:1 graphite */
  --sk-band-top:        var(--sk-d-band-top);
  --sk-band-half:       var(--sk-d-band-half);
  --sk-band-several:    var(--sk-d-band-several);
  --sk-danger-surface:  var(--sk-sealcrim);
  --sk-on-danger:       var(--sk-stone);   /* stone on sealcrim: 5.77:1 */
  /* A row the audit view tints to say "this happened under a waiver".
     Stone on it is 12.80:1, so the tint is a signal and never a cost to
     whoever has to read the row. */
  --sk-warn-surface:    var(--sk-d-warn-surface);

  /* Elevation is a shadow token rather than a hex spelled inline, for
     the same reason the colours are: one definition, one place. */
  --sk-shadow-overlay:  var(--sk-d-shadow-overlay);

  /* Q3. A destructive button is FILLED, because an outline that differs
     from its neighbours only in hue says "danger" by colour alone (WCAG
     1.4.1) — and Revoke, Merge, Reject and Disable sat in the console
     and the workbench looking exactly like Grant, Create and Release.
     Sealcrim is a surface and never text (see --sk-danger above), which
     is precisely the role it takes here; stone on it is 5.77:1. The
     hover lightens for the same reason the accent's does: the text on
     the fill is pale, so a darker fill takes the pair down. #B8392704
     would be the instinct and is wrong; #B83927 is 4.34:1 with stone,
     which is AA large only, so the hover goes the other way — a RING
     rather than a new fill, leaving the 5.77:1 pair intact. */
  --sk-danger-ring:     var(--sk-danger);

  /* Q3. Where a help chip lands. Every chip in the product deep-links to
     an anchor, and before this the reader arrived at a page with nothing
     saying which paragraph they were sent to. Skyhigh is already the
     "this one" colour for a selected row; :target is the same idea. */
  --sk-target-surface:  var(--sk-elevated);

  /* Q3. Corner radii were spelled 6px/8px by hand in four files. Not a
     colour, so nothing failed when they disagreed — and they did. */
  --sk-radius:          6px;
  --sk-radius-lg:       8px;

  /* -- type ---------------------------------------------------------
     Real fallback stacks, not a bare generic: outside the latin subset,
     and on any deployment whose static route is unreachable, these are
     what the reader actually gets. */
  --sk-font-display: "Saira Semi Condensed", "Saira", "Archivo Narrow",
                     "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
  --sk-font-body:    "Archivo", system-ui, -apple-system, "Segoe UI", Roboto,
                     "Helvetica Neue", Arial, sans-serif;
  --sk-font-mono:    "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo,
                     Consolas, "Liberation Mono", monospace;
}

/* --------------------------------------------------------------------
   THE LOCKUP.

   Deliberately built from an INLINE <svg> plus text, never an <img>.
   That is not a style preference: Gate PR asserts
   `header.app-header img` is empty, because a hostile identifier of the
   form `<img src=x onerror=...>` must arrive in the header as text and
   never as an element. A brand <img> in that region would make the gate
   pass for the wrong reason forever. The gate wins; the mark is inline.
   -------------------------------------------------------------------- */
.sk-lockup {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  text-decoration: none;
  color: var(--sk-text);
}
.sk-lockup .sk-glyph {
  width: 1.5rem;
  height: 1.5rem;
  flex: none;
  display: block;
}
.sk-lockup .sk-wordmark {
  font-family: var(--sk-font-display);
  font-weight: 700;
  letter-spacing: 0.02em;
  font-size: 1.12rem;
  line-height: 1;
  color: var(--sk-text);
}
.sk-lockup .sk-wordmark .sk-word-sky { color: var(--sk-accent); }
.sk-lockup .sk-surface-name {
  font-family: var(--sk-font-display);
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-size: 0.78rem;
  color: var(--sk-text-muted);
}

/* "Keep the watch™" — the tagline goes where a tagline belongs: under
   the mark on the surfaces a person arrives at, and in the footer
   everywhere else. Never beside a control, where it would read as a
   label for it. */
.sk-tagline {
  font-family: var(--sk-font-display);
  font-weight: 400;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-size: 0.72rem;
  color: var(--sk-text-muted);
}

.sk-footer {
  border-top: 1px solid var(--sk-border);
  padding: 0.9rem 1.2rem;
  display: flex;
  gap: 0.75rem;
  align-items: baseline;
  flex-wrap: wrap;
  background: var(--sk-chrome);
  color: var(--sk-text-muted);
  font-size: 0.8rem;
}
.sk-footer .sk-tagline { margin-left: auto; }

/* Focus is a correctness property on a page whose controls are the only
   way in: it must be visible on every ground the product uses.

   `tabindex="-1"` is excluded on purpose. Those elements are not tab
   stops — they are where the skip link LANDS — and the ring round one of
   them is a ring round the whole of <main>, which is a box rather than
   an indicator. They get their own arrival marker below. */
:where(a, button, input, select, textarea, summary,
       [tabindex]:not([tabindex="-1"])):focus-visible {
  outline: 2px solid var(--sk-focus);
  outline-offset: 2px;
}
/* Where the skip link put you: a rule along the top edge of the content,
   whether that content is a heading or the whole main region. */
#content:focus { outline: none; }
#content:focus-visible {
  outline: none;
  box-shadow: inset 0 3px 0 var(--sk-accent);
}

/* ====================================================================
   Q3 — THE SHARED COMPONENT LAYER.

   Q2 moved the palette into one file and left LAYOUT in the pages. That
   was right for colour and wrong for the chrome: the app header existed
   four times over (index.html, admin.html, help.py's _PAGE_CSS, and not
   at all on the audit view), and the four copies had already drifted —
   the console's buttons hovered amber while the portal's hovered
   accent, and the help pages' header did not stick while the other two
   did. Same component, three behaviours, one of them missing.

   What lives here now is anything that must be IDENTICAL on every
   surface: the header, the skip link, the button hierarchy, the way a
   refusal is shown, the way a wide table behaves in a narrow window.
   What stays in a page is what is genuinely that page's own shape.

   Nothing below carries authority, and nothing below is allowed to
   start carrying it. A `[data-state="denied"]` rule shows a refusal the
   SERVER already made; the page cannot refuse anything, and a rule that
   hid a control would be hiding it from someone the routes would refuse
   anyway. Q3 changed a great deal on the pages and nothing behind them.
   ==================================================================== */

/* --------------------------------------------------------------------
   BYPASS BLOCKS (WCAG 2.4.1).

   The portal puts a sticky header and a rail of up to eight buttons
   between the top of the document and the work, and re-renders the work
   in place on every view switch — so a keyboard user crossed all of it
   again each time. The skip link is the first focusable thing in the
   document and is invisible until it is focused.
   -------------------------------------------------------------------- */
.sk-skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  background: var(--sk-accent);
  color: var(--sk-on-accent);
  font-family: var(--sk-font-display);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  font-size: 0.8rem;
  text-decoration: none;
  padding: 0.55rem 1rem;
  border-radius: 0 0 var(--sk-radius) 0;
}
.sk-skip-link:focus {
  left: 0;
}

/* Present to a screen reader, absent to an eye. Used for the table
   captions the tables never had, and for the test markers that were
   being rendered to users as page furniture. */
.sk-visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --------------------------------------------------------------------
   THE APP HEADER.

   One definition. The lockup is an inline <svg> and never an <img> —
   see THE LOCKUP above; Gate PR's claim rests on that and this file is
   where a designer reaching for a brand image will meet the rule.
   -------------------------------------------------------------------- */
header.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.55rem 1.1rem;
  box-sizing: border-box;
  width: 100%;
  border-bottom: 1px solid var(--sk-border);
  background: var(--sk-chrome);
  font-size: 0.85rem;
  position: sticky;
  top: 0;
  z-index: 20;
}
header.app-header .brand {
  color: var(--sk-text);
  text-decoration: none;
  letter-spacing: 0.02em;
}
header.app-header .brand b { color: var(--sk-link); }
header.app-header nav {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  flex-wrap: wrap;
}
/* The nav links were separated by a left margin, which put the gap on
   the wrong side of the first one and collapsed to nothing when the row
   wrapped. `gap` is the whole job. */
header.app-header nav a {
  color: var(--sk-text-muted);
  text-decoration: none;
  /* A 0.85rem link on a 0.55rem-tall bar is a small target. Padding it
     out to a real hit area costs nothing and is the difference between
     "help" being clickable and being aimed at (WCAG 2.5.8). */
  padding: 0.35rem 0.1rem;
}
header.app-header nav a:hover { color: var(--sk-text); }
/* Which surface you are ON, said in the header rather than left to the
   reader to infer from the page body. `aria-current` is the semantic;
   this is what it looks like. */
header.app-header nav a[aria-current="page"] {
  color: var(--sk-text);
  box-shadow: inset 0 -2px 0 var(--sk-accent);
}
header.app-header #session-status a {
  color: var(--sk-link);
  text-decoration: none;
}
header.app-header #session-logout {
  font: inherit;
  font-size: 0.85rem;
  cursor: pointer;
  background: none;
  border: 1px solid var(--sk-border-strong);
  border-radius: var(--sk-radius);
  color: var(--sk-text-muted);
  padding: 0.15rem 0.6rem;
}
header.app-header #session-logout:hover {
  color: var(--sk-text);
  border-color: var(--sk-border-control);
}

/* --------------------------------------------------------------------
   THE HELP CHIP (HL-3).

   The way from a control to the paragraph that explains it. Small and
   quiet — a control's help is a footnote, not a competitor — but a REAL
   link: an <a href>, so it is in the tab order and works with no script
   at all, which matters because the first surface a visitor meets is a
   sign-in box and the reader who needs the manual most is the one who
   has not signed in. The page carries only a topic KEY; `helptopics`
   supplies the URL, the label and the accessible name at serve time.

   Q3 moved it here from three identical copies, one per shipped page.
   -------------------------------------------------------------------- */
a.help-chip {
  display: inline-block;
  box-sizing: border-box;
  width: 1.25rem;
  height: 1.25rem;
  line-height: 1.1rem;
  margin-left: 0.4em;
  vertical-align: middle;
  border: 1px solid var(--sk-border-strong);
  border-radius: 50%;
  background: var(--sk-chrome);
  color: var(--sk-text-muted);
  font-size: 0.72rem;
  font-weight: 600;
  text-align: center;
  text-decoration: none;
}
a.help-chip:hover {
  color: var(--sk-on-accent);
  background: var(--sk-accent);
  border-color: var(--sk-accent);
}
/* An un-injected placeholder is an <a> with no href: inert, invisible,
   and linking nowhere. Better than a chip that goes somewhere wrong. */
a.help-chip:not([href]) { display: none; }

/* --------------------------------------------------------------------
   BUTTON HIERARCHY.

   Before Q3 every button in the product rendered identically: a grey
   outlined box. `Upload`, `Search`, `Merge compartments`, `Revoke` and
   `Disable account` were the same object to the eye, so the page said
   nothing about which one it expected you to press and nothing about
   which ones you cannot take back.

   Two modifiers, opted into per control by an attribute in the markup,
   so a button that is neither keeps exactly the look it already had:

     data-variant="primary"  the one action this block is FOR
     data-variant="danger"   an action that widens reach, removes it,
                             or destroys something

   Presentation, and nothing more: a `danger` button posts to exactly
   the route it always did, and that route decides.
   -------------------------------------------------------------------- */
button[data-variant="primary"] {
  background: var(--sk-accent);
  border-color: var(--sk-accent);
  color: var(--sk-on-accent);
  font-weight: 600;
}
button[data-variant="primary"]:hover:not(:disabled) {
  background: var(--sk-accent-hover);
  border-color: var(--sk-accent-hover);
  color: var(--sk-on-accent);
}
button[data-variant="danger"] {
  background: var(--sk-danger-surface);
  border-color: var(--sk-danger-surface);
  color: var(--sk-on-danger);
  font-weight: 600;
}
/* The hover LIGHTENS nothing and darkens nothing: stone on sealcrim is
   5.77:1 and any repaint of the fill costs contrast. It grows a ring
   instead, in the readable danger tint. */
button[data-variant="danger"]:hover:not(:disabled) {
  border-color: var(--sk-danger-ring);
  box-shadow: 0 0 0 1px var(--sk-danger-ring);
}
button:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

/* --------------------------------------------------------------------
   WIDE THINGS IN NARROW WINDOWS (WCAG 1.4.10 Reflow).

   Every table in the product was a bare <table> in the document flow:
   the audit trail is six columns of monospace, the console's users
   panel is six, the portal's is five. Below roughly 900px each of them
   pushed the BODY sideways, which takes the header, the rail and every
   other page with it. A table may scroll; a page may not.

   `tabindex="0"` in the markup is not decoration — a scroll container
   that only a mouse can reach is a region a keyboard user cannot read.
   -------------------------------------------------------------------- */
.sk-scroll-x {
  overflow-x: auto;
  max-width: 100%;
  /* A scrollable region needs to say it is one before it is scrolled. */
  scrollbar-color: var(--sk-border-control) transparent;
}
.sk-scroll-x > table { min-width: 100%; }
/* A wrapper round a table the page is not showing is an empty focus
   stop. It goes away with the thing it wraps. */
.sk-scroll-x:has(> [hidden]) { display: none; }

/* --------------------------------------------------------------------
   A REFUSAL, IN WORDS.

   The admin console marked a refused panel with `data-state="denied"`
   and painted its border red. That is a state conveyed by colour alone
   (WCAG 1.4.1) around a table that is simply empty — so the claim Gate
   IF.3 rests on ("a refused caller sees each panel render a denied
   state rather than a blank page") was true of the DOM and false of the
   person reading it.

   The wording lives in the markup, always present, revealed by the
   state the SERVER's 403 already set. No script change, no new
   decision: the panel is refused exactly as often as it was before, and
   now it says so.
   -------------------------------------------------------------------- */
.panel-denied {
  display: none;
  border: 1px solid var(--sk-danger);
  border-left-width: 3px;
  border-radius: var(--sk-radius);
  /* The ground, not the card: a refusal is something set down ON the
     panel, the same way a `.result` is. */
  background: var(--sk-bg);
  color: var(--sk-danger);
  padding: 0.6rem 0.85rem;
  margin: 0.8rem 0 0;
  font-size: 0.88rem;
  /* A refusal is a sentence, and a sentence across a 72rem console is
     not one anybody reads. */
  max-width: 78ch;
}
[data-state="denied"] > .panel-denied { display: block; }
.panel-denied .why {
  display: block;
  color: var(--sk-text-muted);
  font-size: 0.82rem;
  margin-top: 0.2rem;
}

/* --------------------------------------------------------------------
   WHERE A HELP CHIP LANDS.

   Every chip in the product is a deep link into a help anchor, and the
   reader arrived at a wall of prose with nothing marking the paragraph
   they were sent to. The most-used path into the manual had no arrival.
   -------------------------------------------------------------------- */
:target {
  scroll-margin-top: 4.5rem;
}
main :target {
  background: var(--sk-target-surface);
  box-shadow: -0.8rem 0 0 var(--sk-target-surface),
               0.8rem 0 0 var(--sk-target-surface);
  border-radius: 2px;
}

/* --------------------------------------------------------------------
   NARROW WINDOWS.

   The product shipped `<meta name="viewport">` on all four surfaces and
   not one media query behind it: it claimed to be responsive and was
   not. Nothing below redefines a token — a colour whose only definition
   is conditional has no value in the condition nobody tested, which is
   the rule the tokens block above states and this block obeys.
   -------------------------------------------------------------------- */
@media (max-width: 48rem) {
  header.app-header {
    flex-wrap: wrap;
    gap: 0.4rem 0.9rem;
    padding: 0.5rem 0.9rem;
  }
  header.app-header nav { gap: 0.75rem; }
  .sk-lockup .sk-surface-name { display: none; }
  .sk-footer { padding: 0.8rem 0.9rem; }
  .sk-footer .sk-tagline { margin-left: 0; }
}

/* Nothing in the product animates today. This is here so that the next
   thing that does is already covered, rather than shipping motion to a
   reader who asked their operating system for none. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}


/* ===================================================================
   THEMES — light and dark, and the third state people actually have
   (Phase 17, TH-2).

   Three states, not two. `system` is the default and follows the
   reader's own `prefers-color-scheme`; `light` and `dark` are explicit
   choices that override it. The choice is stored per browser and applied
   before first paint — NOT in a profile column, because the sign-in page
   needs a theme too and a page rendered before authentication cannot
   read a per-principal preference.

   A preference carries no authority and never has (UX-1…UX-7). Nothing
   server-side re-decides anything because of what is set here; this is
   how the answer LOOKS, never what the answer is.

   Every token below already has a definition on bare `:root` above. That
   is Q2's rule and it survives this phase untouched: a token whose only
   definition sits inside a condition has no value in every other
   condition, and the symptom is an invisible control rather than an
   error. `test_no_brand_colour_is_defined_only_inside_a_media_query`
   still passes — if it ever has to be edited to make a palette green,
   the palette is wrong, not the test.
   -------------------------------------------------------------------- */

/* The reader whose browser prefers light and who has expressed no choice
   to us. `:not([data-theme="dark"])` is what lets an explicit dark
   choice win over the system preference — without it, a light-preferring
   browser would out-specify the toggle. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
  color-scheme: light;

  --sk-bg:              var(--sk-l-paper);
  --sk-chrome:          var(--sk-l-paper-sunk);
  --sk-surface:         var(--sk-l-card);
  --sk-surface-hover:   var(--sk-l-paper-sunk);
  --sk-elevated:        var(--sk-l-elevated);

  --sk-text:            var(--sk-l-ink);
  --sk-text-muted:      var(--sk-l-ink-muted);

  --sk-border:          var(--sk-l-border);
  --sk-border-strong:   var(--sk-l-border-strong);
  --sk-border-control:  var(--sk-l-border-control);

  /* The accent stays the brand orange as a FILL — `--sk-on-accent` is
     basalt in both themes and measures 5.57:1 on it — but as TEXT it
     becomes the darker ink. A link is a sentence; a button is a shape. */
  --sk-link:            var(--sk-l-accent-ink);
  --sk-focus:           var(--sk-l-accent-ink);
  /* Hover LIGHTENS here too, and that is measurement rather than taste:
     the instinct on a light page is to darken, and darkening to
     `--sk-secondary` puts basalt text at 4.07:1 — the value Q2 rejected
     in the dark theme for exactly this reason. The lighter hover measures
     6.75:1 against the same text colour, so both themes shift the same
     way and the pair is one already proven. */
  --sk-accent-hover:    var(--sk-d-accent-hover);

  --sk-ok:              var(--sk-l-ok);
  --sk-warn:            var(--sk-l-warn);
  --sk-danger:          var(--sk-l-danger);
  --sk-band-top:        var(--sk-l-band-top);
  --sk-band-half:       var(--sk-l-band-half);
  --sk-band-several:    var(--sk-l-band-several);
  --sk-danger-surface:  var(--sk-l-danger-surface);
  --sk-on-danger:       var(--sk-l-on-danger);
  --sk-warn-surface:    var(--sk-l-warn-surface);

  --sk-shadow-overlay:  var(--sk-l-shadow-overlay);
  }
}

/* The explicit choices. Both are stated, including dark: a reader whose
   system prefers light and who chose dark is served by this block, and
   without it they would get the media query above. */
:root[data-theme="light"] {
  color-scheme: light;

  --sk-bg:              var(--sk-l-paper);
  --sk-chrome:          var(--sk-l-paper-sunk);
  --sk-surface:         var(--sk-l-card);
  --sk-surface-hover:   var(--sk-l-paper-sunk);
  --sk-elevated:        var(--sk-l-elevated);

  --sk-text:            var(--sk-l-ink);
  --sk-text-muted:      var(--sk-l-ink-muted);

  --sk-border:          var(--sk-l-border);
  --sk-border-strong:   var(--sk-l-border-strong);
  --sk-border-control:  var(--sk-l-border-control);

  /* The accent stays the brand orange as a FILL — `--sk-on-accent` is
     basalt in both themes and measures 5.57:1 on it — but as TEXT it
     becomes the darker ink. A link is a sentence; a button is a shape. */
  --sk-link:            var(--sk-l-accent-ink);
  --sk-focus:           var(--sk-l-accent-ink);
  /* Hover LIGHTENS here too, and that is measurement rather than taste:
     the instinct on a light page is to darken, and darkening to
     `--sk-secondary` puts basalt text at 4.07:1 — the value Q2 rejected
     in the dark theme for exactly this reason. The lighter hover measures
     6.75:1 against the same text colour, so both themes shift the same
     way and the pair is one already proven. */
  --sk-accent-hover:    var(--sk-d-accent-hover);

  --sk-ok:              var(--sk-l-ok);
  --sk-warn:            var(--sk-l-warn);
  --sk-danger:          var(--sk-l-danger);
  --sk-band-top:        var(--sk-l-band-top);
  --sk-band-half:       var(--sk-l-band-half);
  --sk-band-several:    var(--sk-l-band-several);
  --sk-danger-surface:  var(--sk-l-danger-surface);
  --sk-on-danger:       var(--sk-l-on-danger);
  --sk-warn-surface:    var(--sk-l-warn-surface);

  --sk-shadow-overlay:  var(--sk-l-shadow-overlay);
}

:root[data-theme="dark"] {
  color-scheme: dark;

  --sk-bg:              var(--sk-basalt);
  --sk-chrome:          var(--sk-card-hover);
  --sk-surface:         var(--sk-graphite);
  --sk-surface-hover:   var(--sk-card-hover);
  --sk-elevated:        var(--sk-skyhigh);

  --sk-text:            var(--sk-stone);
  --sk-text-muted:      var(--sk-d-text-muted);

  --sk-border:          var(--sk-d-border);
  --sk-border-strong:   var(--sk-d-border-strong);
  --sk-border-control:  var(--sk-d-border-control);

  --sk-link:            var(--sk-accent);
  --sk-focus:           var(--sk-accent);
  --sk-accent-hover:    var(--sk-d-accent-hover);

  --sk-ok:              var(--sk-d-ok);
  --sk-warn:            var(--sk-d-warn);
  --sk-danger:          var(--sk-d-danger);
  --sk-band-top:        var(--sk-d-band-top);
  --sk-band-half:       var(--sk-d-band-half);
  --sk-band-several:    var(--sk-d-band-several);
  --sk-danger-surface:  var(--sk-sealcrim);
  --sk-on-danger:       var(--sk-stone);
  --sk-warn-surface:    var(--sk-d-warn-surface);

  --sk-shadow-overlay:  var(--sk-d-shadow-overlay);
}

/* The theme control (TH-3). Injected by /static/theme.js into whatever
   header the surface already has, so it is styled once here rather than
   four times in four pages. Deliberately quiet: it is a preference, not
   an action, and it should not compete with the controls that do things. */
.sk-theme-toggle {
  font-family: var(--sk-font-body);
  font-size: 0.78rem;
  line-height: 1;
  padding: 0.45rem 0.7rem;
  margin-left: auto;
  color: var(--sk-text-muted);
  background: transparent;
  border: 1px solid var(--sk-border-control);
  border-radius: var(--sk-radius);
  cursor: pointer;
}
.sk-theme-toggle:hover {
  color: var(--sk-text);
  background: var(--sk-surface-hover);
}
.sk-theme-toggle:focus-visible {
  outline: 2px solid var(--sk-focus);
  outline-offset: 2px;
}
/* The announcement region the control writes into: available to a screen
   reader, invisible to everybody else, and never `display: none` — a
   hidden live region is one that never announces. */
.sk-visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The processing queue's table (WF-9). Layout only — every colour is a
   token, and the stage badge takes its meaning from the state rather
   than from anything the page decides. */
table.wf-table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; }
table.wf-table th, table.wf-table td {
  text-align: left;
  padding: 0.45rem 0.6rem;
  border-bottom: 1px solid var(--sk-border);
  vertical-align: top;
  font-size: 0.9rem;
}
table.wf-table thead th {
  background: var(--sk-chrome);
  color: var(--sk-text-muted);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
table.wf-table tbody tr:hover { background: var(--sk-surface-hover); }
.wf-stage {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: var(--sk-radius);
  border: 1px solid var(--sk-border-strong);
  font-size: 0.8rem;
}
/* Three families, because a person scanning this page is asking one of
   three questions: is it moving, has it stopped, is it done. */
.wf-stage[data-stage="quarantined"],
.wf-stage[data-stage="failed"],
.wf-stage[data-stage="abandoned"] {
  border-color: var(--sk-danger);
  color: var(--sk-danger);
}
.wf-stage[data-stage="admitted"] {
  border-color: var(--sk-ok);
  color: var(--sk-ok);
}

/* NW-PROC-TABLE: the processing QUEUE, made short enough to work through.

   Here rather than in index.html, and that is the whole first lesson of
   this change: `.wf-table` was ALREADY defined in this file, so a copy in
   the page's own <style> was a second answer to one question — and it lost
   the argument silently, because `table.wf-table td` (0,1,2) outranks
   `.wf-table td` (0,1,1). Half an hour went into wondering why a padding
   change did nothing.

   Scoped to `#processing-list`, because `.wf-table` is also the upload
   result table and the held-documents table and neither of those was
   asked about. Everything the row said before, it still says; what
   changed is the height it says it in.

   Measured at every step, because a row height is a number with nine
   possible causes and the first two guesses were both wrong. 144.8px
   before; 45.6px after, on a 1280px viewport, and one line on a wide one.

   What actually mattered was column WIDTH, not padding and not the
   filename button. An auto-layout table honours every column's
   MIN-CONTENT width before it distributes anything, so the nowrap
   "Approve Each Stage / Toggle All" heading took 248px for itself, and
   the filename column — whose `overflow-wrap: anywhere` makes its
   min-content about one character wide — was crushed to 97px and wrapped
   a nineteen-character name over seven lines.

   THE WRAPPING HEADINGS ARE THE FIX, and that is a mutation result rather
   than an opinion: put `white-space: normal` back to nowrap on the mode
   heading and the browser test goes red at 58px with the badge on a
   second line. Removing the name column's `min-width` floor, by contrast,
   changed NOTHING and was not caught — once the heading wraps there is
   slack enough without it. The floor stays as a guard for a narrower
   window than any test frames, and is recorded here as unproven rather
   than credited with a fix it did not make. The tighter padding and the
   badge's line-height are worth a few pixels each; the button chrome was
   worth nothing, because `button.wf-open` had already stripped it. */
#processing-list table.wf-table th,
#processing-list table.wf-table td {
  padding: 0.15rem 0.4rem;
  vertical-align: middle;
  font-size: 0.85rem;
  line-height: 1.3;
}
#processing-list table.wf-table thead th { text-transform: none; }
/* The one column that may wrap takes the slack; every other column asks
   for as little as it can and refuses to wrap. `min-width` is the part
   that holds: without it the name column's min-content is one character. */
#processing-list .wf-name-cell { width: 100%; min-width: 12rem; }
#processing-list .wf-pick-cell { width: 1%; padding-right: 0; }
#processing-list .wf-stage-cell,
#processing-list .wf-mode-cell,
#processing-list .wf-comp-cell,
#processing-list .wf-when-cell { width: 1%; white-space: nowrap; }
/* The HEADINGS may wrap; the cells under them may not. "Converted to
   Markdown" and "Approve Each Stage" are the widest text on this page,
   and holding either to one line sets its column's width for every row
   beneath it — a cost paid once in the head is not a cost paid per row. */
#processing-list th.wf-stage-cell,
#processing-list th.wf-mode-cell { white-space: normal; }
#processing-list .wf-stage-cell { text-align: center; }
#processing-list input.wf-pick,
#processing-list input.wf-stage-box { margin: 0; vertical-align: middle; }
/* The badge is `inline-block` with its own padding and border, so it sets
   the line box it sits in. Pinning its line height is what keeps the name
   and the badge on ONE line rather than one-and-a-bit. */
#processing-list .wf-stage { line-height: 1.2; }
/* The filename's own chrome is already stripped by `button.wf-open`
   further down this file, which is where it belongs and where it has been
   all along. A second copy was written here first, on the theory that a
   UA-chromed button was what made these rows tall; it was not, the copy
   changed nothing, and it is recorded rather than quietly dropped because
   the wrong theory survived two measurements. The only thing the queue
   needs on top of that rule is a break opportunity for a filename with no
   spaces in it. */
#processing-list .wf-open { overflow-wrap: anywhere; }
#processing-list .doc-meta {
  color: var(--sk-text-muted);
  font-size: 0.78rem;
}
/* NW-PROC-TABLE's per-row Proceed. It sits in the LEFTMOST stage the
   document has not reached — the one it is waiting to do — so the control
   and the question a reader is already asking are in the same cell. */
.wf-proceed {
  font: inherit;
  font-size: 0.72rem;
  line-height: 1.4;
  margin-left: 0.3rem;
  padding: 0 0.35rem;
  cursor: pointer;
  border: 1px solid var(--sk-border-control);
  border-radius: var(--sk-radius);
  background: var(--sk-surface);
  color: var(--sk-text);
}
.wf-proceed:hover { background: var(--sk-surface-hover); }
/* `enabled` and `disabled` are opposite answers to one question, so they
   must not look alike: the pressed state is a filled box, not a different
   word in the same colour. */
.wf-mode-toggle {
  font: inherit;
  font-size: 0.75rem;
  line-height: 1.4;
  cursor: pointer;
  padding: 0.05rem 0.45rem;
  border-radius: var(--sk-radius);
  border: 1px solid var(--sk-border-control);
  background: var(--sk-surface);
  color: var(--sk-text-muted);
}
.wf-mode-toggle[aria-pressed="true"] {
  background: var(--sk-accent);
  color: var(--sk-on-accent);
  border-color: var(--sk-accent);
}
/* The countdown. Muted and small on purpose: it is a guess drawn from
   this vault's own recent behaviour, and it must not read as loudly as
   the recorded times beside it. */
.wf-eta {
  display: block;
  color: var(--sk-text-muted);
  font-size: 0.7rem;
  line-height: 1.3;
}

/* PP-1..PP-4: the processing queue's sort headers, action bar and pager.

   HERE rather than in index.html's own <style>, for the reason the
   NW-PROC-TABLE block above records at length: `.wf-table` is already
   defined in this file, and a copy in the page loses the specificity
   argument silently. One home for one table.

   The row-height finding above still governs everything below. An
   auto-layout table honours every column's MIN-CONTENT width first, so a
   heading that refuses to wrap buys its own width out of the filename
   column and the rows grow. The sort buttons are therefore `flex` with
   their label allowed to wrap INSIDE them, and the one new body column
   (`Uploaded datetime`, nineteen fixed characters) is the only thing here
   that asks for width it will actually use. Measured, not assumed — a
   browser test asserts the row is one band and the Document column keeps
   its floor at 1280px. */

/* ---- the controls row above the table ---- */
#processing-controls {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.9rem;
  margin: 0.4rem 0;
}
#processing-controls > label { font-size: 0.8rem; color: var(--sk-text-muted); }
#processing-controls select {
  background: var(--sk-bg); color: var(--sk-text);
  border: 1px solid var(--sk-border-control); border-radius: var(--sk-radius);
  padding: 0.2rem 0.4rem; font: inherit; font-size: 0.8rem;
  margin-left: 0.3rem;
}
#processing-pager { display: flex; align-items: center; gap: 0.4rem; }
#processing-controls button {
  background: var(--sk-surface); color: var(--sk-text);
  border: 1px solid var(--sk-border-control); border-radius: var(--sk-radius);
  padding: 0.25rem 0.7rem; margin: 0; font: inherit; font-size: 0.8rem;
  cursor: pointer;
}
#processing-pager button:disabled { opacity: 0.45; cursor: default; }
#processing-page-note { font-size: 0.8rem; color: var(--sk-text-muted); }
/* PP-1's one line of prose. It is the affordance of a control, not an
   explanation of the queue — the shift-click that adds a column to a sort
   is otherwise discoverable only by trying it. */
#processing-sort-hint { margin: 0.1rem 0 0; font-size: 0.8rem; }

/* ---- PP-3: the action bar, as the table's own top row ---- */
#processing-list table.wf-table thead tr.wf-actionrow th {
  padding: 0.3rem 0.4rem;
  border-bottom: 1px solid var(--sk-border-strong);
  white-space: normal;
}
.wf-actionbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 1rem;
}
/* The divider is what makes "grouped by function" visible at a glance;
   the first group has nothing to its left to divide from. */
.wf-act-group {
  display: flex; align-items: center; gap: 0.35rem;
  padding-left: 1rem; border-left: 1px solid var(--sk-border);
}
.wf-act-count {
  font-size: 0.85rem; font-weight: 600; color: var(--sk-text);
  white-space: nowrap;
}
.wf-act-label {
  font-size: 0.8rem; color: var(--sk-text-muted); white-space: nowrap;
}
#processing-list .wf-actionbar button {
  margin: 0; padding: 0.25rem 0.7rem; font-size: 0.85rem;
}
#processing-list .wf-actionbar select {
  background: var(--sk-bg); color: var(--sk-text);
  border: 1px solid var(--sk-border-control); border-radius: var(--sk-radius);
  padding: 0.2rem 0.4rem; font: inherit; font-size: 0.85rem;
  max-width: 15rem;
}

/* ---- PP-1: the sort headers ---- */
#processing-list table.wf-table thead tr.wf-head th { padding: 0; }
#processing-list table.wf-table thead th.wf-pick-cell {
  padding: 0.15rem 0.3rem 0.15rem 0.4rem;
}
label.wf-all {
  display: inline-flex; align-items: center; gap: 0.25rem;
  font-size: 0.75rem; white-space: nowrap; cursor: pointer;
}
label.wf-all input { width: auto; margin: 0; }
.wf-sort {
  display: flex; align-items: center; gap: 0.25rem; width: 100%;
  background: none; border: none; margin: 0; padding: 0.2rem 0.4rem;
  font: inherit; font-size: 0.8rem; font-weight: 600;
  color: var(--sk-text-muted); text-align: left; cursor: pointer;
  /* The label wraps; that is the whole row-height lesson above. */
  white-space: normal;
}
.wf-sort:hover { color: var(--sk-text); }
.wf-arrow { font-size: 0.65rem; opacity: 0.35; }
.wf-rank {
  font-size: 0.6rem; font-weight: 700; color: var(--sk-accent);
}
#processing-list th[aria-sort="ascending"] .wf-sort,
#processing-list th[aria-sort="descending"] .wf-sort { color: var(--sk-accent); }
#processing-list th[aria-sort="ascending"] .wf-arrow,
#processing-list th[aria-sort="descending"] .wf-arrow { opacity: 1; }
/* The mode heading holds two controls side by side, so it is the one
   heading cell that lays out rather than filling. */
#processing-list th.wf-mode-cell {
  display: table-cell;
  padding: 0.15rem 0.3rem;
}
#processing-list th.wf-mode-cell .wf-sort { display: inline-flex; width: auto; }

/* ---- PP-1: `Uploaded datetime` ---- */
/* Read down the column, so tabular figures and no wrapping — and the
   heading above it wraps to two short words instead of buying 150px. */
#processing-list .wf-uploaded-cell {
  width: 1%; white-space: nowrap; font-variant-numeric: tabular-nums;
}
#processing-list th.wf-uploaded-cell { white-space: normal; }
#processing-list .wf-absent { color: var(--sk-text-muted); }

/* NW-PROC-VIEWERS: the two content viewers, in a dialog of their own.

   A SECOND top-level `<dialog>` rather than more markup inside
   `#processing-detail`, and the reason is UV-2's: a `<dialog>` inside a
   hidden ancestor opens without throwing and shows nothing. Both of
   these live outside every `hidden` section for that reason. They also
   stack — the content viewer opens on top of the filename pop-up, which
   stays open behind it, so closing the viewer returns a reader to the
   document they were already reading rather than to the table.

   `max-height: 50vh` on the view box is the STRUCTURAL half of the
   half-screen promise: whatever the paging arithmetic decides, and
   whatever a pathological single line does, the box can never be taller
   than half the viewport. The paging then measures the rendered height
   and cuts on line boundaries so that an ordinary page fits inside that
   cap without scrolling at all (see `wfContentPages` in index.html).

   `scrollbar-gutter: stable` is load-bearing rather than cosmetic: the
   measuring pass renders every line at once and therefore overflows, so
   without a reserved gutter the measurement would be taken at a narrower
   width than the render and every page would come out one line short. */
#processing-content { max-width: min(72rem, 92vw); }
#processing-content-view {
  /* `border-box`, and it is load-bearing rather than tidiness.
     `max-height` bounds the CONTENT box by default, so the padding and
     the border are added on top of it — measured at 378px of a 720px
     viewport, which breaks the "at most half the viewport" promise by
     the exact size of this box's own chrome. Found by the browser test
     asserting the ratio against a live viewport rather than reasoning
     about the stylesheet, which is the only way that gap is visible. */
  box-sizing: border-box;
  max-height: 50vh;
  overflow: auto;
  scrollbar-gutter: stable;
  border: 1px solid var(--sk-border);
  border-radius: var(--sk-radius);
  background: var(--sk-surface);
  padding: 0.5rem 0.7rem;
}
/* One element per source line, in BOTH the measuring pass and the render,
   so the two cannot disagree about where a long line wraps. `pre-wrap`
   keeps the document's own spacing; `anywhere` is what stops a 4,000
   character line with no spaces in it from setting the dialog's width. */
#processing-content-view .wf-content-line {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  min-height: 1.2em;
  font-family: var(--sk-font-mono);
  font-size: 0.82rem;
  line-height: 1.45;
  color: var(--sk-text);
}
/* The natively-rendered originals. Both are capped by the box above, and
   the frame carries no chrome of its own so the document fills it. */
#processing-content-view img.wf-content-image,
#processing-content-view iframe.wf-content-frame {
  display: block;
  width: 100%;
  border: 0;
  background: var(--sk-surface);
}
#processing-content-view iframe.wf-content-frame { height: 40vh; }
#processing-content-view img.wf-content-image {
  width: auto;
  max-width: 100%;
  height: auto;
}
/* Why this is not the file itself, when it is not. One sentence, in the
   muted voice every other hedge on this page uses — it is a statement
   about the BROWSER, not a warning about the document. */
#processing-content-note {
  color: var(--sk-text-muted);
  margin: 0 0 0.5rem 0;
}
#processing-content-pager { align-items: center; gap: 0.4rem; }
#processing-content-page { color: var(--sk-text-muted); }
/* NW-PROC-VIEWERS' third control: `convert` / `reconvert` in the
   "Converted to Markdown" stage cell. Deliberately the same size and
   weight as `Proceed` two columns to its left — they are both per-row
   controls that open or start one document's next step, and a reader
   should not have to learn two visual languages for that. */
.wf-convert {
  font: inherit;
  font-size: 0.72rem;
  line-height: 1.4;
  margin-left: 0.3rem;
  padding: 0 0.35rem;
  cursor: pointer;
  border: 1px solid var(--sk-border-control);
  border-radius: var(--sk-radius);
  background: var(--sk-surface);
  color: var(--sk-text);
}
.wf-convert:hover { background: var(--sk-surface-hover); }

/* The upload-mode choice (WF-4). A fieldset because it is one question
   with two answers, which is what a fieldset and legend are for — and
   what a screen reader announces as one question rather than two
   unrelated radios. */
fieldset.upload-mode {
  border: 1px solid var(--sk-border);
  border-radius: var(--sk-radius);
  padding: 0.7rem 0.9rem;
  margin: 0.9rem 0;
}
fieldset.upload-mode legend {
  font-size: 0.85rem;
  color: var(--sk-text-muted);
  padding: 0 0.35rem;
}
fieldset.upload-mode .checkline {
  display: flex;
  gap: 0.5rem;
  align-items: flex-start;
  margin: 0.35rem 0;
  font-size: 0.88rem;
}

/* A conversion warning is not an error: the document converted, and the
   vault noticed something about it that the person should know. Warn
   colour, not danger. */
.wf-warning { color: var(--sk-warn); }

/* The per-document pop-up (WF-7). A real <dialog>: the browser gives the
   focus trap, the Escape key and the inert background for free, and each
   of those is something a hand-built overlay gets wrong. */
#processing-detail {
  max-width: min(46rem, 92vw);
  border: 1px solid var(--sk-border-strong);
  border-radius: var(--sk-radius-lg);
  background: var(--sk-surface);
  color: var(--sk-text);
  padding: 1.2rem 1.4rem;
  box-shadow: var(--sk-shadow-overlay);
}
#processing-detail::backdrop { background: rgba(0, 0, 0, 0.5); }
#processing-detail h3 { margin-top: 0; }
#processing-detail h4 {
  margin-bottom: 0.3rem;
  color: var(--sk-text-muted);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
/* The document's name in the table opens the pop-up, so it looks like the
   control it is rather than like the text beside it. */
button.wf-open {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--sk-link);
  text-align: left;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}
button.wf-open:focus-visible {
  outline: 2px solid var(--sk-focus);
  outline-offset: 2px;
}

/* NW-IMAGE-VIEWER slice 1 — the picture on its own.

   The wrapper around an inline picture that makes it the way into the
   viewer. It is a real <button> (keyboard-reachable, named, focusable)
   and the reset below is what keeps it from LOOKING like one: the
   thumbnail must sit exactly where it sat before, or every screenshot
   and every reader's muscle memory changes for a control that did not
   need to announce itself. `zoom-in` is the cursor that says what the
   click does without a word of chrome. */
button.image-open {
  display: block;
  max-width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: zoom-in;
}
button.image-open:focus-visible {
  outline: 2px solid var(--sk-focus);
  outline-offset: 2px;
}

/* The viewer itself: a third top-level <dialog>, wider than either of the
   two it stacks on because a picture is the one thing here that is worth
   the whole window. */
#image-viewer {
  max-width: min(80rem, 96vw);
  border: 1px solid var(--sk-border-strong);
  border-radius: var(--sk-radius-lg);
  background: var(--sk-surface);
  color: var(--sk-text);
  padding: 1.1rem 1.3rem;
  box-shadow: var(--sk-shadow-overlay);
}
#image-viewer::backdrop { background: rgba(0, 0, 0, 0.6); }
#image-viewer h3 { margin-top: 0; }
/* The document's own name. `anywhere` because an uploader can name a file
   four thousand characters with no spaces in it, and that must not be
   allowed to set the dialog's width. */
#image-viewer-name {
  margin: 0 0 0.5rem 0;
  font-family: var(--sk-font-mono);
  overflow-wrap: anywhere;
}
#image-viewer-controls { align-items: center; gap: 0.4rem; }
#image-viewer-zoom {
  min-width: 4rem;
  font-variant-numeric: tabular-nums;
  color: var(--sk-text-muted);
}
/* `resize: both` IS the user's "resizable window", and `overflow: auto`
   is what makes a zoomed-in picture pannable — the zoom sets the
   picture's laid-out width, so the overflow is real and the scrollbars,
   the drag and the keyboard all move the same thing.

   The sizes are a starting shape, not a cap: the reader drags the corner
   from here. `min-*` stops a drag collapsing the frame to nothing a
   picture could be found in again. */
#image-viewer-frame {
  box-sizing: border-box;
  resize: both;
  overflow: auto;
  width: 100%;
  height: 60vh;
  min-width: 12rem;
  min-height: 8rem;
  border: 1px solid var(--sk-border);
  border-radius: var(--sk-radius);
  background: var(--sk-bg);
  padding: 0.3rem;
}
#image-viewer-frame:focus-visible {
  outline: 2px solid var(--sk-focus);
  outline-offset: 2px;
}
#image-viewer-frame[data-panning="true"] { cursor: grabbing; }
/* No `max-width` and no `width: 100%`: the script sets the width, and a
   stylesheet that also had an opinion about it would mean the rendered
   size and the published `data-scale` could disagree. */
.image-viewer-picture {
  display: block;
  cursor: grab;
  user-select: none;
  -webkit-user-drag: none;
}
#image-viewer-frame[data-panning="true"] .image-viewer-picture {
  cursor: grabbing;
}
#image-viewer-result { margin-top: 0.6rem; }
