/* Plain by default, and deliberately so — this is still spike-regime code and the
   engine is the product. But no longer plain everywhere: where the interface has to
   carry meaning it is allowed to, and the export gate is the clearest case. See the
   dialogs block, and the note in README.md about what would move src/ui/ out of the
   spike regime. */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font:
    14px/1.4 system-ui,
    sans-serif;
  background: #f4f4f5;
  color: #18181b;
}

/*
 * The header and the hint strip stick as one block.
 *
 * Sticky moved here from .bar so the strip below it cannot be scrolled away while
 * the header stays — which is what happened when the strip was in normal flow, and
 * meant a hint fired on page five rendered somewhere the user had long since
 * scrolled past. Sticking the two separately would need the strip to know the
 * header's height, and the header wraps to two rows in a narrow window, so no
 * fixed offset is right at every width. One sticky parent needs no such number.
 */
.topmatter {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #fff;
  /* The block's one bottom edge. It sits here rather than on .bar because what is
     last in the block changes — the state row and the hint strip each appear and
     disappear — and an edge on each child would draw a line through the middle of
     the block whenever two of them were showing at once. */
  border-bottom: 1px solid #d4d4d8;
}

/*
 * Grouped by lifetime, with two gap scales.
 *
 * A flex container has exactly one column-gap, so a bar whose controls need to read
 * as three sets cannot say so with gap alone — everything comes out equidistant and
 * the eye has to read the labels to find the seams. The groups exist to give a
 * second number: wide between lifetimes, tight within one. That is the whole
 * mechanism, and it is why there are no dividers — a divider orphaned by a wrapped
 * row reads as a mistake, and this bar is explicitly allowed to wrap.
 *
 * row-gap is much smaller than column-gap on purpose. A wrapped row is the same
 * toolbar continued, so it belongs close; the wide number is horizontal separation
 * between groups, not vertical separation between rows.
 */
.bar {
  display: flex;
  align-items: center;
  gap: 6px 22px;
  flex-wrap: wrap;
  padding: 10px 14px;
  background: #fff;
}

.group {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px 8px;
  /* Load-bearing. A flex item's automatic minimum size is its content's, so without
     this the group refuses to shrink below its widest child and the ellipsis on the
     filename and the tool note inside it never engages — they work today only
     because they are direct children of .bar, and nesting them one level deeper
     would silently break that. */
  min-width: 0;
}

/*
 * The far end of the bar. Export is the only control here that cannot be undone,
 * and it must never sit shoulder to shoulder with Redo, which is pressed by reflex.
 *
 * An auto margin absorbs the free space on its own flex line, so this holds at every
 * width where the row does not wrap, and right-aligns the group on its own line when
 * it does. The constraint it carries: nothing else in the bar may be given
 * flex-grow, or that would eat the free space first and leave Export sitting against
 * Clear all at exactly the widths where the separation matters.
 */
.group-output {
  margin-left: auto;
}

/* A button that breaks its own label across two lines reads as broken long before
   the window is narrow enough to excuse it. */
.bar button {
  white-space: nowrap;
}

/*
 * The three channels, on their own row below the controls.
 *
 * They left the bar because they are the only things in it that are read rather than
 * pressed, and three variable-length strings competing with the buttons meant the
 * bar's wrap point moved with whatever the app happened to be saying. On their own
 * row the width available is the window's, and the `margin-left: auto` that used to
 * push them clear of the controls is gone with the competition — left-packed, so a
 * message always starts in the same place instead of sliding with its own length.
 *
 * Hidden until the first message and never hidden again; see revealStateBar.
 */
.statebar {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 12px;
  padding: 5px 14px 7px;
  background: #fff;
  /* Reserved, so the row does not change height as the transient channel fills and
     empties — which it does on every rendered page and every flattened one. */
  min-height: 28px;
}

.statebar[hidden] {
  display: none;
}

.statebar > * {
  min-width: 0;
}

/*
 * Only the transient channel truncates. With a whole row to spread across there is
 * room for the other two to stay whole, and the scanned-page warning is the one
 * message in this app that must never be cut off.
 */
.status {
  color: #52525b;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.warning,
.counter {
  flex: none;
}

/*
 * A failure in the transient channel. The same red as the dropzone's own rejection
 * message, so a refusal reads as a refusal wherever it lands — in plain black beside
 * the render and scan progress it read as more narration.
 */
.status.error {
  color: #991b1b;
  font-weight: 500;
}

/*
 * The scanned-page warning. Persistent for the session, so it is coloured to read
 * as a standing condition rather than as something that just happened — it is the
 * reminder that most of the document may be box-draw only.
 */
.warning {
  color: #92400e;
  background: #fef3c7;
  border: 1px solid #fde68a;
  border-radius: 4px;
  padding: 2px 8px;
  font-size: 12px;
}

/* The region count. Persistent while marks exist; tabular figures so the number
   changing does not shift the text beside it. */
.counter {
  color: #18181b;
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  white-space: nowrap;
}

.pages {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  padding: 18px;
}

.page {
  position: relative;
  line-height: 0;
  background: #fff;
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.2);
}

.page canvas {
  display: block;
}

.overlay {
  position: absolute;
  inset: 0;
  cursor: crosshair;
  touch-action: none;
  /* Above pdf.js's text layer, which sets z-index 1. Without this the text
     layer covers the regions and swallows clicks on their delete buttons. */
  z-index: 2;
}

.box {
  position: absolute;
  background: #000;
  cursor: grab;
  touch-action: none;
  /* A region is a thing to grab, not text to select. */
  user-select: none;
  -webkit-user-select: none;
}

/* The mark itself is solid black, so the selected state has to be drawn outside
   it. The offset keeps the ring clear of the ink, so it reads as "this region"
   rather than as part of the coverage. Every box of a grouped region gets one,
   which is also how the group makes itself visible as one thing. */
.box.selected {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
  cursor: move;
}

/* Grips sit ON the ring rather than inside the mark, so grabbing an edge never
   means pressing on the coverage itself. They are drawn for every box of the
   selected region, because in a grouped text selection any line might be the one
   whose coverage needs correcting. */
.grip {
  position: absolute;
  width: 10px;
  height: 10px;
  border: 1px solid #fff;
  border-radius: 2px;
  background: #2563eb;
  /* Half the grip overhangs the box, so an edge can be grabbed from outside it
     — the side a shrink drag approaches from. */
  margin: -5px 0 0 -5px;
}

.grip-nw {
  top: 0;
  left: 0;
}
.grip-n {
  top: 0;
  left: 50%;
}
.grip-ne {
  top: 0;
  left: 100%;
}
.grip-e {
  top: 50%;
  left: 100%;
}
.grip-se {
  top: 100%;
  left: 100%;
}
.grip-s {
  top: 100%;
  left: 50%;
}
.grip-sw {
  top: 100%;
  left: 0;
}
.grip-w {
  top: 50%;
  left: 0;
}

.box .kill {
  position: absolute;
  /* Pushed diagonally clear of the north-east grip, which now sits centred on
     that corner. Close enough to still read as belonging to this region. */
  top: -28px;
  right: -28px;
  width: 18px;
  height: 18px;
  padding: 0;
  border: 1px solid #fff;
  border-radius: 50%;
  background: #dc2626;
  color: #fff;
  font:
    12px/1 system-ui,
    sans-serif;
  cursor: pointer;
}

.drawing {
  position: absolute;
  background: rgb(0 0 0 / 0.55);
  outline: 1px solid #fff;
  pointer-events: none;
}

.page-number {
  position: absolute;
  top: 100%;
  left: 0;
  padding-top: 4px;
  font-size: 11px;
  line-height: 1;
  color: #71717a;
}

.page-note {
  position: absolute;
  top: 100%;
  right: 0;
  padding-top: 4px;
  font-size: 11px;
  line-height: 1;
  color: #a16207;
}

/* --- text layer -----------------------------------------------------------
   Lifted from pdfjs-dist/web/pdf_viewer.css, trimmed to what a bare text layer
   needs. Copied rather than linked because that stylesheet also styles .page,
   which this app already uses for something else. If pdf.js changes how
   TextLayer positions spans, this is the block to re-check.                  */

.textLayer {
  position: absolute;
  inset: 0;
  overflow: clip;
  line-height: 1;
  text-align: initial;
  text-size-adjust: none;
  forced-color-adjust: none;
  transform-origin: 0 0;
  z-index: 1;
  --min-font-size: 1;
  --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
  --min-font-size-inv: calc(1 / var(--min-font-size));

  /* THE DRAG-STUTTER FIX. One line, and it is worth the comment above it.
   *
   * Every page is in the DOM at once — 390 of them on the test ebook, each with a full
   * text layer, ~156,000 spans in total. `onPointerMove` calls `selection.extend()`,
   * which dirties layout; the next move's `getBoundingClientRect` and `elementFromPoint`
   * then have to flush a layout spanning every one of those spans before they can answer.
   * That flush is the whole cost. Measured in headless Chromium at 390 pages:
   *
   *     clean layout                                 0.8µs   — free
   *     dirty layout, no containment               125.4ms   — the stutter
   *     dirty, content-visibility on .textLayer      5.4ms   — 23x better
   *     dirty, content-visibility on .page           1.0ms   — 124x, but see below
   *     dirty, pointer-events:none off-screen      123.7ms   — no effect at all
   *
   * `pointer-events` doing nothing is the useful negative: the cost is NOT the hit-test
   * search through 390 layers, which is what it looked like from the call counts. It is
   * the layout flush. That is why this is a containment fix and not a hit-testing one.
   *
   * ON THE TEXT LAYER, NOT ON `.page`, although `.page` measures better.
   * `.textLayer` is `position: absolute; inset: 0`, so its size comes from the page box
   * and size containment cannot collapse it. Putting this on `.page` would need
   * `contain-intrinsic-size` to stop 390 pages collapsing to nothing, and page geometry
   * is load-bearing here — marks are stored as fractions of it. 23x is already far more
   * than the target needs, so the safer element wins.
   *
   * CHECKED, NOT ASSUMED, because skipped content that answers geometry queries wrongly
   * would fail silently and in the dangerous direction:
   *   - `Range.getClientRects()` across skipped layers returns the same rect count as
   *     without it, so the harvest is unaffected and no mark can be lost.
   *   - `removeCrossOrientationSpans` measures every span at load, when most pages are
   *     off-screen. Span rects inside a skipped subtree still measure correctly — an
   *     explicit geometry query forces layout on it — so the rotated-header removal
   *     still runs on every page.
   *   - Export never reads these canvases. `flattenToPdf` renders to a detached canvas
   *     of its own, and the peek check reads that, so the redaction guarantee cannot be
   *     touched by anything on the display layer. */
  content-visibility: auto;
}

.textLayer :is(span, br) {
  color: transparent;
  position: absolute;
  white-space: pre;
  cursor: text;
  transform-origin: 0% 0%;
}

.textLayer > :not(.markedContent),
.textLayer .markedContent span:not(.markedContent) {
  z-index: 1;
  --font-height: 0;
  font-size: calc(var(--text-scale-factor) * var(--font-height));
  --scale-x: 1;
  --rotate: 0deg;
  transform: rotate(var(--rotate)) scaleX(var(--scale-x))
    scale(var(--min-font-size-inv));
}

.textLayer .markedContent {
  display: contents;
}

.textLayer ::selection {
  background: rgb(0 100 255 / 0.3);
}
.textLayer br::selection {
  background: transparent;
}

/* --- which tool owns a page ------------------------------------------------
   Box-draw is the default everywhere, because it is the tool that cannot be
   fooled. Text-selection is switched on only for a page that has been
   classified as genuine text — so a page that is unclassified, still being
   classified, or image-backed stays on box-draw by falling through.          */

.overlay {
  pointer-events: auto;
}
.textLayer {
  pointer-events: none;
  user-select: none;
}

/* The canvas and the overlay are not text, so a gesture has no business
   painting a highlight across them.

   Read the limit before reaching for this rule again: it does NOT stop a
   cross-page range ENCLOSING these two, and enclosure is what puts a page-sized
   border box into the harvest. `user-select` governs what a gesture may select
   and what gets painted; `Range.getClientRects()` is pure geometry over whatever
   the range encloses, and is unmoved by it. That is the same finding
   removeCrossOrientationSpans records, arrived at the same way.

   So this is hygiene, not the fix. The page-sized rects are closed at source in
   selectionToRegions by clipping the range to each text layer. */
.page canvas,
.page .overlay {
  user-select: none;
  -webkit-user-select: none;
}

/* Regions stay grabbable whichever tool is active, and whether or not the
   overlay beneath them is taking pointer events. A press on a region is always
   about that region: in box-draw that means it selects instead of starting a new
   box on top of it, and under the text tool it selects instead of starting a
   selection in text the region is covering up anyway. */
.box {
  pointer-events: auto;
}

[data-tool="text"] .page[data-selectable="true"] .textLayer {
  pointer-events: auto;
  user-select: text;
}

[data-tool="text"] .page[data-selectable="true"] .overlay {
  pointer-events: none;
  cursor: default;
}

.tools {
  display: inline-flex;
  gap: 10px;
}
.tools label {
  display: inline-flex;
  align-items: center;
  gap: 3px;
}
.tools input:disabled + *,
.tools label:has(input:disabled) {
  color: #a1a1aa;
  cursor: not-allowed;
}

/*
 * Why the greyed tool is greyed, for the page in view. Deliberately quiet — it is
 * an explanation sitting next to a control, not a warning about the document, and
 * the amber warning channel is what carries the document-level condition.
 */
.tool-note {
  color: #71717a;
  font-size: 12px;
  font-style: italic;
  /* Clamped like the filename beside it, and clamped to fit rather than to truncate.
     The reason strings are now short enough to sit inside this whole, so the note
     widens the marking group by a bounded amount and the output group's auto margin
     keeps Export on the first row. Unclamped, this one element decided where the bar
     wrapped. Anything that does overflow is still recoverable — the same text is the
     hover tip on the control it describes, and it is printed under the page too. */
  max-width: 26ch;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* An empty note is still a flex item, so without this it leaves its own gap behind
   as a hole in the group. */
.tool-note:empty {
  display: none;
}

/*
 * Page arrows. Monospaced glyph box so the two buttons are the same width and the
 * pair does not shift as their disabled state changes at the ends of a document.
 */
.nav {
  display: inline-flex;
  gap: 4px;
}

.nav button {
  min-width: 2.2em;
  font-variant-numeric: tabular-nums;
}

/* --- file input ------------------------------------------------------------ */

/*
 * The real file input, clipped out of view but still in the layout and still
 * clickable programmatically. Not display:none and not hidden: those have a
 * history of refusing to open a picker on .click(), and the button in front of it
 * has nothing else to delegate to.
 */
.offscreen {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The app's own answer to "which file is open" — the question the native control
   used to answer wrongly for the whole session. */
.filename {
  color: #18181b;
  font-size: 12px;
  max-width: 22ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.filename:empty {
  display: none;
}

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

.empty {
  display: flex;
  justify-content: center;
  padding: 48px 14px;
}

.empty[hidden] {
  display: none;
}

.dropzone {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  max-width: 46ch;
  padding: 40px 48px;
  border: 2px dashed #d4d4d8;
  border-radius: 12px;
  text-align: center;
  color: #52525b;
  /* The zone is a second way to reach the picker, not decoration — so it says so. */
  cursor: pointer;
  /* Only the border colour moves, so the box does not shift as it lights up. */
  transition: border-color 120ms ease-in-out;
}

/*
 * Needed because `display: flex` above out-ranks it otherwise.
 *
 * `hidden` is a UA-stylesheet rule, so ANY author rule that sets `display` on the same
 * element beats it — the attribute goes on, and the element stays visible. Nothing hid
 * the drop zone until the asset gate did, so the omission was invisible until then: the
 * refusal panel appeared BESIDE a drop zone that still said "Drop a PDF here" while
 * refusing every file dropped on it, which is precisely the invitation-it-will-refuse
 * that the gate exists to prevent.
 *
 * Every other hideable element in this file carries the same pairing for the same reason.
 */
.dropzone[hidden] {
  display: none;
}

.dropzone-icon {
  width: 44px;
  height: 44px;
  color: #a1a1aa;
}

.dropzone-primary {
  margin: 8px 0 0;
  font-size: 18px;
  font-weight: 600;
  color: #18181b;
}

.dropzone-secondary {
  margin: 0 0 12px;
}

.dropzone-privacy {
  margin: 0;
  font-size: 13px;
  color: #71717a;
}

/*
 * A refused file, said where the user is already looking. This used to go to the
 * header status line, which is 12px in a crowded bar at the opposite end of the
 * screen from the drop they just made — so the drop appeared to do nothing at all.
 */
.dropzone-error {
  margin: 14px 0 0;
  padding: 6px 12px;
  border-radius: 4px;
  background: #fef2f2;
  border: 1px solid #fecaca;
  color: #991b1b;
  font-size: 13px;
}

.dropzone-error[hidden] {
  display: none;
}

/*
 * Ready-to-receive. Hover is the affordance for the click path; the drag state is
 * the same colour deliberately, because it answers the same question — a file
 * dropped here will be taken. Driven from a body class rather than :hover during a
 * drag, since a drag over a child of the box would otherwise flicker the border as
 * the pointer crosses each line of text.
 */
.dropzone:hover,
body.dropping .dropzone {
  border-color: #16a34a;
}

body.dropping .dropzone-icon {
  color: #16a34a;
}

/*
 * The window will take this drop — framed on the viewport, because the window is
 * genuinely the drop target and a document is open for most of a session, when
 * there is no dropzone left to light up.
 *
 * Not an outline on .pages, which was the first attempt and was wrong: .pages is a
 * full-width flex column, so outlining it drew a rectangle as wide as the window
 * whose vertical edges sit at the very edges of the screen — all that reads is two
 * horizontal lines. With no document open it is worse, because the column is an
 * empty 36px strip and the "outline" is a band across the top of the page.
 *
 * Fixed to the viewport and pointer-events: none, so it frames whatever is on
 * screen without intercepting the drop it is advertising.
 */
body.dropping::after {
  content: "";
  position: fixed;
  inset: 0;
  border: 3px dashed #16a34a;
  border-radius: 4px;
  pointer-events: none;
  /* Above the sticky header, so the frame is unbroken. */
  z-index: 50;
}

/* --- first-use hints -------------------------------------------------------
 *
 * The fourth state channel. Sits below the bar rather than in it, because a hint
 * is a sentence and the three header channels are already ellipsising to fit.
 */

.hintbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 14px;
  background: #dbeafe;
  /* No bottom edge of its own — .topmatter draws the block's single bottom border,
     and one here would double it whenever the strip is the last thing showing. */
  /* A solid edge down the leading side, so the strip reads as a band that has
     arrived rather than as another row of the header it sits under. The header is
     white and the page behind it near-white; this is the only blue in the app. */
  border-left: 4px solid #2563eb;
  color: #1e3a5f;
  font-size: 13px;
}

/* A tip appears in response to something the user just did, possibly several pages
   into a document, in a strip that was not there a moment ago. Moving once on
   arrival is what separates it from furniture that was always there and merely
   overlooked. */
@keyframes hint-arrive {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.hintbar:not([hidden]) {
  animation: hint-arrive 180ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .hintbar:not([hidden]) {
    animation: none;
  }
}

.hintbar[hidden] {
  display: none;
}

/* A quiet marker, not a warning triangle: a hint is an offer, and the scanned-page
   warning above it is the thing in this app entitled to look alarming. */
.hintbar-mark {
  flex: none;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #2563eb;
  color: #fff;
  font-size: 11px;
  font-style: italic;
  font-weight: 700;
}

.hintbar-text {
  margin: 0;
  min-width: 0;
}

/* Pushed to the right and understated. It has to be findable — turning hints off
   is a stated requirement, not a hidden preference — without competing with the
   hint it sits beside. */
.hintbar-off {
  margin-left: auto;
  flex: none;
  background: none;
  border: 0;
  padding: 2px 4px;
  color: #1d4ed8;
  font-size: 12px;
  text-decoration: underline;
  cursor: pointer;
}

.hintbar-close {
  flex: none;
  background: none;
  border: 0;
  padding: 0 2px;
  color: #1e3a5f;
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
}

/* --- update bar -------------------------------------------------------------
 *
 * A new build is cached and waiting. Green, where the hint strip is blue and the
 * scanned-page warning amber: this is neither guidance nor a problem, it is an offer
 * of something better, and it should not borrow the colour of either.
 *
 * Same shape as the hint strip on purpose — a leading edge, a mark, a sentence, a
 * control — because it appears in the same place and a reader who has learned to
 * read one strip should not have to learn a second grammar to read this one.
 */
.updatebar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 14px;
  background: #dcfce7;
  border-left: 4px solid #16a34a;
  color: #14532d;
  font-size: 13px;
}

.updatebar:not([hidden]) {
  /* Reuses the hint strip's arrival, which is the same problem: a band that was not
     there a moment ago, needing to read as having arrived rather than been missed. */
  animation: hint-arrive 180ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .updatebar:not([hidden]) {
    animation: none;
  }
}

.updatebar[hidden] {
  display: none;
}

.updatebar-mark {
  flex: none;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #16a34a;
  color: #fff;
  font-size: 12px;
  line-height: 1;
}

.updatebar-text {
  margin: 0;
  min-width: 0;
}

/* A real button, unlike the hint strip's understated link. Reloading is the action
   this strip exists to offer, and it may cost the reader their marks — so it should
   look like something you decide to press, not something you brush against. */
.updatebar-go {
  margin-left: auto;
  flex: none;
  border: 1px solid #15803d;
  border-radius: 4px;
  padding: 3px 12px;
  background: #16a34a;
  color: #fff;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
}

.updatebar-go:hover {
  background: #15803d;
}

.updatebar-close {
  flex: none;
  background: none;
  border: 0;
  padding: 0 2px;
  color: #14532d;
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
}

/* --- toast -----------------------------------------------------------------
 *
 * For a refusal with a document open, where the dropzone that would normally say
 * it is off screen. Bottom-centre and fixed: away from the header, which is busy,
 * and away from the pointer, which is over the page.
 */

.toast {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 60;
  max-width: 60ch;
  padding: 10px 16px;
  border-radius: 6px;
  background: #fef2f2;
  border: 1px solid #fecaca;
  color: #991b1b;
  font-size: 13px;
  box-shadow: 0 4px 12px rgb(0 0 0 / 12%);
}

.toast[hidden] {
  display: none;
}

/* --- dialogs ---------------------------------------------------------------
 *
 * One mechanism for all four: the welcome overlay, the Tips reference, the discard
 * prompt and the export gate. A native <dialog> rather than a div, because
 * showModal() brings the focus trap, the inert background, the Escape binding and
 * the top layer with it — and hand-rolled focus trapping is where home-made modals
 * go wrong.
 *
 * ONE MECHANISM, BUT NOT ONE APPEARANCE. Three of them are plain white sheets. The
 * export gate is not, and that is the point of .sheet-export below: it is the only
 * irreversible step in the app, so it is the only dialog that does not look like the
 * others. It was a native confirm() until the header pass, on the reasoning that it
 * should not look like ordinary app furniture — which mistook "looks like a system
 * dialog" for "looks careful", when on a privacy tool it reads as "looks
 * unfinished". Being distinct from every other dialog in the app does that job
 * without spending the app's credibility to do it.
 */

.sheet {
  max-width: 58ch;
  padding: 24px 28px;
  border: 1px solid #d4d4d8;
  border-radius: 10px;
  background: #fff;
  color: #18181b;
  box-shadow: 0 12px 32px rgb(0 0 0 / 18%);
}

.sheet-wide {
  max-width: 76ch;
  /* The reference outgrows the viewport on a short window, so it scrolls inside
     itself rather than pushing its own footer off the bottom of the screen. */
  max-height: 84vh;
  overflow-y: auto;
}

.sheet::backdrop {
  background: rgb(24 24 27 / 45%);
}

.sheet h2 {
  margin: 0 0 14px;
  font-size: 18px;
}

.sheet h3 {
  margin: 20px 0 6px;
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: #71717a;
}

.sheet ul,
.sheet ol {
  margin: 0;
  padding-left: 20px;
}

.sheet li {
  margin: 4px 0;
}

.sheet p {
  margin: 8px 0 0;
}

.welcome-steps li {
  margin: 10px 0;
}

.sheet-aside {
  color: #71717a;
  font-size: 13px;
}

/* The overlay's opt-out. Its own row above the actions, and quiet: it is a standing
   preference, not the thing the dialog is asking. Sized and spaced so the whole label
   is a comfortable target, since the checkbox alone is a small one. */
.welcome-optout {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 20px;
  color: #52525b;
  font-size: 13px;
  cursor: pointer;
}

.welcome-optout input {
  cursor: pointer;
}

/* Follows the opt-out row directly, so it does not also carry the full gap. */
.welcome-optout + .sheet-actions {
  margin-top: 12px;
}

.sheet-actions {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 10px;
  margin-top: 22px;
}

/* A secondary action that reads as a way out rather than as a second button
   competing with the primary one. */
.linkish {
  margin-right: auto;
  background: none;
  border: 0;
  padding: 2px 4px;
  color: #1d4ed8;
  text-decoration: underline;
  cursor: pointer;
}

/* The discard prompt's confirm. Outlined, not filled: losing marks is bad and
   undoable-by-remarking, which is a different weight from losing the text itself. */
.danger {
  border: 1px solid #fecaca;
  background: #fef2f2;
  color: #991b1b;
}

/* --- the export gate ------------------------------------------------------
 *
 * Everything here exists to make this one dialog unmistakably not the other three.
 * A heavier border and a red-brown cast against their plain white; a filled confirm
 * against their outlined or default ones. Nothing else in the app is filled red, so
 * the button cannot be confused with an ordinary action even at a glance.
 */
.sheet-export {
  max-width: 52ch;
  border: 2px solid #b45309;
  box-shadow: 0 16px 40px rgb(120 53 15 / 28%);
}

/* Only when something is actually going to be destroyed. With nothing marked this
   dialog is an ordinary confirmation and is coloured like one. */
.sheet-export.is-destructive {
  border-color: #b91c1c;
  box-shadow: 0 16px 40px rgb(127 29 29 / 30%);
}

.sheet-export::backdrop {
  /* Heavier than the other dialogs' backdrop, so what is behind it recedes further
     and the gate reads as the only thing on screen. */
  background: rgb(69 26 3 / 62%);
}

.sheet-export h2 {
  color: #7c2d12;
}

.sheet-export.is-destructive h2 {
  color: #7f1d1d;
}

/* The consequence, stated before the settings rather than after them. A warning
   under the controls it applies to is read after the decision it should inform. */
.export-summary {
  margin: 0 0 18px;
  padding: 12px 14px;
  border-radius: 6px;
  background: #fffbeb;
  border: 1px solid #fde68a;
  color: #78350f;
}

.sheet-export.is-destructive .export-summary {
  background: #fef2f2;
  border-color: #fecaca;
  color: #7f1d1d;
  font-weight: 500;
}

.export-settings {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;
}

.export-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 0;
  min-width: 20ch;
  flex: 1 1 20ch;
}

.export-field label {
  font-size: 12px;
  font-weight: 500;
}

/* Filled, and the only filled red in the app. The one button here that does
   something permanent should not be a sibling of the one that does not. */
.destructive {
  border: 1px solid #991b1b;
  background: #b91c1c;
  color: #fff;
  font-weight: 500;
}

.destructive:hover {
  background: #991b1b;
}

/* The shortcuts card. A two-column grid so the keys line up in a column of their
   own and the list can be read down rather than across. */
.shortcuts dl {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 6px 14px;
  margin: 0;
}

.shortcuts dt,
.shortcuts dd {
  margin: 0;
}

kbd {
  display: inline-block;
  padding: 1px 6px;
  border: 1px solid #d4d4d8;
  border-bottom-width: 2px;
  border-radius: 4px;
  background: #fafafa;
  font:
    12px/1.4 ui-monospace,
    monospace;
}

/* The way back from a one-way switch. Set apart from the reference above it,
   because it is a control among prose and would otherwise read as one more entry
   in the list it follows. */
.tips-toggle {
  margin-top: 22px;
  padding-top: 16px;
  border-top: 1px solid #e4e4e7;
}

.tips-toggle h3 {
  margin-top: 0;
}

.tips-toggle p {
  margin: 0 0 10px;
}

/* --- hover tips ------------------------------------------------------------
 *
 * Replaces the native title attribute on the controls. The native tooltip could
 * not be used for this: it waits about a second before appearing, cannot be
 * styled, and — the reason it had to go — it renders the title of a DISABLED
 * control unreliably across engines, which is exactly the case this layer exists
 * to explain. A greyed Select text that cannot say why it is greyed is the
 * affordance defect one step removed.
 *
 * One element, moved and refilled per hover, rather than one per control: the
 * text of at least one of these changes at runtime, and a per-control tooltip
 * built once at bind time would still be describing the document that was open
 * when it was built.
 */

.tooltip {
  position: fixed;
  z-index: 70;
  max-width: 40ch;
  padding: 6px 10px;
  border-radius: 4px;
  background: #18181b;
  color: #fafafa;
  font-size: 12px;
  line-height: 1.35;
  pointer-events: none;
}

.tooltip[hidden] {
  display: none;
}

.tooltip-key {
  color: #a1a1aa;
}

/* --- the asset gate --------------------------------------------------------
 *
 * Shown in place of the drop zone when the app's own files could not be loaded. Styled
 * as a refusal rather than as a warning: this is not a degraded mode the user can work
 * around, it is the app declining to be trusted with a document it cannot fully draw.
 */
.assets-broken {
  max-width: 52ch;
  margin: 0 auto;
  padding: 24px 28px;
  border: 2px solid #b91c1c;
  border-radius: 10px;
  background: #fef2f2;
  color: #7f1d1d;
  text-align: left;
}

.assets-broken h2 {
  margin: 0 0 12px;
  font-size: 18px;
}

.assets-broken p {
  margin: 0 0 10px;
}

.assets-broken ul {
  margin: 0 0 12px;
  padding-left: 20px;
}

.assets-broken li {
  margin: 4px 0;
}

/* The reasoning, weighted so it is read rather than skimmed past — it is the part that
   explains why a missing font file stops a redaction tool. */
.assets-broken-stakes {
  font-weight: 500;
}

.assets-broken .sheet-aside {
  color: #991b1b;
  margin-bottom: 0;
}
/* cache test */
/* test 2 */
/* t4 */
/* t5 */
/* t6 */
/* t7 */

/* The peek check's controls in the export dialog. Quiet: this is a setting on the way
   past, not the question the dialog is asking. */
.export-check {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 18px;
  font-size: 13px;
  cursor: pointer;
}

.export-check input {
  cursor: pointer;
}

.export-check-note {
  margin: 4px 0 0 24px;
}

/* --- the peek finding, the loudest dialog in the app -------------------------
 *
 * Ranked ABOVE the export gate deliberately, and the ordering is the point.
 *
 * The gate is outlined red with a filled destructive button, and that is right for what
 * it does: it confirms an intention the reader arrived with. They placed the marks and
 * chose to export; nothing it says is news.
 *
 * This one is news. It reports content showing past a mark the reader believed covered
 * it — something they could not see and did not know. It is the only dialog in this app
 * that can be carrying a leak, so it escalates rather than matching: the same red family,
 * because the seriousness is the same order, but FILLED where the gate is outlined, with
 * a solid band the gate does not have and a heavier backdrop behind it.
 *
 * Read them side by side and the ranking should need no explanation.
 */
.sheet-peek {
  max-width: 54ch;
  padding: 0 0 24px;
  border: 3px solid #7f1d1d;
  box-shadow: 0 20px 52px rgb(69 10 10 / 42%);
}

.sheet-peek::backdrop {
  /* Darker than the gate's 62%: what is behind this matters less than what is in it. */
  background: rgb(28 8 8 / 78%);
}

/* The band the export gate does not have — solid, not a tint. This is the single
   strongest signal available short of colour nobody else uses, and it costs the dialog
   nothing but its top edge. */
.peek-band {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 10px 28px;
  background: #7f1d1d;
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.peek-band span {
  display: grid;
  place-items: center;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: #fff;
  color: #7f1d1d;
  font-size: 12px;
  font-weight: 700;
}

/* The band supplies the top padding the sheet gave up, so everything below it sits on
   the sheet's normal measure. */
.sheet-peek h2,
.sheet-peek p:not(.peek-band),
.sheet-peek footer {
  margin-left: 28px;
  margin-right: 28px;
}

.sheet-peek h2 {
  margin-top: 20px;
  font-size: 20px;
}

/* The finding itself, weighted so it is read before the caveats under it. */
.peek-summary {
  color: #7f1d1d;
  font-weight: 500;
}

/* THE SAFE ACTION IS THE FILLED ONE, inverting the export gate.
 *
 * There, the filled button destroys, because destroying is what the reader came to do.
 * Here the filled button is Review, because the reader did not come to do this and the
 * useful next step is to go and look. "Export anyway" stays an ordinary button beside it,
 * plainly available — this warns and steps aside, it does not obstruct. */
.peek-go {
  border: 1px solid #7f1d1d;
  border-radius: 4px;
  padding: 3px 12px;
  background: #7f1d1d;
  color: #fff;
  font-weight: 500;
}

.peek-go:hover {
  background: #601414;
}
