/* ==========================================================================
   EDITOR — application UI only.
   Fonts and design tokens live in tokens.css, which index.html links FIRST.
   That file is shared with the marketing pages; this one is not.
   ========================================================================== */


* { box-sizing: border-box; }

body {
  margin: 0;
  padding: 2rem 1rem;
  font: 400 15px/1.5 var(--font-ui);
  color: var(--fg);
  background: var(--bg);
}

/* The shell lives on <body>, not on <main>. <header> and <footer> have to be
   siblings of <main> to expose the banner and contentinfo landmarks at all —
   nested inside it they are plain containers. */
body {
  max-width: 70rem;
  margin: 0 auto;
}

/* Visible only when focused. Standard technique: parked off-screen rather than
   display:none, which would take it out of the tab order entirely. */
.skip-link {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 100;
  padding: 0.6rem 1rem;
  border-radius: 0 0 var(--radius-sm) 0;
  background: var(--accent);
  color: #fff;
  font-size: 0.85rem;
  font-weight: 700;
  text-decoration: none;
  transform: translateY(-120%);
}

.skip-link:focus { transform: none; }

h1, h2, h3, .wordmark {
  font-family: var(--font-display);
  font-weight: 700;
}

/* ==========================================================================
   Buttons
   This block sits HIGH in the file on purpose. It is element-level
   (`button`, 0-2-1 once :hover:not(:disabled) is counted), which ties with any
   single-class component rule like `.tool-tabs button[aria-selected="true"]` —
   and ties are broken by source order. Defined here, components below always
   win. Defined after them, the generic style bleeds into every tab, pill and
   caret in the app.

   The bare element is also deliberately NEUTRAL rather than a filled primary.
   Making `button` accent-filled meant every component that is a button but
   should not look like one — tabs, the zoom pill, segment carets, colour
   swatches, the settings nav — had to fight it, and lost wherever specificity
   tied. Filled is opt-in via .btn-primary.

   Weight is 700, not 600: Lato ships 400/700 with nothing between, so a 600
   would silently render as 700 anyway. Stating it avoids implying a weight
   scale that does not exist. */
button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--control-h);
  padding: 0 1.1rem;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  font-size: 0.85rem;
  font-weight: 700;
  line-height: 1.2;
  cursor: pointer;
}

button:hover:not(:disabled) { background: var(--surface); border-color: var(--muted); }
button:active:not(:disabled) { background: var(--surface-sunken); }

button:disabled {
  background: var(--surface-sunken);
  border-color: var(--surface-sunken);
  color: var(--muted);
  cursor: not-allowed;
}

/* The filled accent button — the primary action of a view. */
button.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
button.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
button.btn-primary:active:not(:disabled) { background: var(--accent-active); border-color: var(--accent-active); }

/* Deliberately smaller than --control-h: these are dense, in-row affordances,
   not standalone actions. Grouped here so the exceptions to the one-height rule
   are visible in one place rather than scattered through the components. */
.seg-grip,
.seg-disclosure,
.preview-pill button,
.font-actions button,
.config-actions button,
.settings-close,
.settings-nav button,
.swatches .swatch {
  min-height: var(--control-h-sm);
}

.swatches .swatch { min-height: 0; }

/* A <label> that looks and behaves like a button, and a filled variant of it.
   Kept here beside the button block rather than with the components, since the
   whole point is that they are pixel-identical to `button` / `button.btn-primary`
   — the file pickers must not read as a different control from Save PDF. */
.btn-like {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--control-h);
  padding: 0 1.1rem;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  font-size: 0.85rem;
  font-weight: 700;
  line-height: 1.2;
  white-space: nowrap;
  cursor: pointer;
}

.btn-like:hover { background: var(--surface); border-color: var(--muted); }

.btn-like-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

.btn-like-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }

/* Square, icon-only. Same height as every other control so a gear sitting
   beside a <select> lines up with it by construction. */
.icon-btn {
  width: var(--control-h);
  min-width: var(--control-h);
  padding: 0;
  flex-shrink: 0;
}

/* `flex: 0 0 auto` is load-bearing, not tidiness. An <svg> is a flex item with
   `min-width: auto`, whose min-content size is 0 — so in any container whose
   content box ends up narrower than the icon, it shrinks to nothing and the
   button renders empty. That is exactly what happened to the header gear:
   `.topbar-right button` (0-1-1) beat `.icon-btn` (0-1-0) on padding, leaving a
   3px content box and an invisible icon. */
.icon {
  flex: 0 0 auto;
  display: block;
}

.icon-btn .icon { width: 16px; height: 16px; }

/* Scoped past `.topbar-right button`, which sets a text button's padding. */
.topbar-right .icon-btn { padding: 0; }

/* The <symbol> sprite in index.html. Takes no space and paints nothing itself. */
.sprite {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.layout {
  display: grid;
  grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
  gap: 1.5rem;
  align-items: start;
}

@media (max-width: 55rem) {
  .layout { grid-template-columns: minmax(0, 1fr); }

  /* The bar stays reachable while the page scrolls as one — on a phone the
     wordmark and Sign in are otherwise gone the moment you start working. */
  .topbar {
    position: sticky;
    top: 0;
    z-index: 20;
    height: auto;
    padding: 0.5rem 0;
    margin-bottom: 1rem;
    background: var(--bg);
    border-bottom: 1px solid var(--border);
  }

  /* The credits readout is the first thing to go: it is a placeholder, and a
     narrow bar has no room for text that means nothing yet. */
  .credits { display: none; }

  .wordmark { font-size: 1.25rem; }

  /* Three flex slots at `flex: 1 1 0` plus a nowrap privacy line forced this
     into a tall stack. Let it wrap deliberately instead, centred, with the
     slots sized to their contents. */
  .appfooter {
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.3rem 1rem;
    margin-top: 1.25rem;
    padding: 0.5rem 1rem;
  }

  .appfooter .footer-slot { flex: 0 0 auto; }
  .appfooter .privacy-note { order: -1; flex: 1 1 100%; justify-content: center; }
}

/* The pill is absolutely positioned and is a SIBLING of #preview-wrap, so it
   needs a positioned ancestor here in the base rules, not only in the desktop
   media query. Without it, below 55rem the pill escaped to the initial
   containing block and floated over the top bar. */
.preview-panel {
  position: relative;
}

.preview-wrap {
  position: relative;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: #f3f4f6;
  /* Top padding clears the pill, same reason as the desktop rule. */
  padding: 3.25rem 0.75rem 0.75rem;
  /* Bounded height so the preview area is a FIXED viewport: the page stack
     inside scrolls, this box does not resize with the document or zoom. On
     desktop the app shell overrides this with flex:1 to fill the column; on
     small screens this cap keeps it from ballooning. */
  height: 65svh;
  display: flex;
  overflow: auto;
  /* Reserve the scrollbar's width whether or not it is showing. computeScale()
     in ui/pages.js measures clientWidth to fit page 1, and it necessarily runs
     BEFORE the page stack exists — so without this it measured a box with no
     scrollbar, then building the stack raised one, took ~15px off, and left the
     page fractionally too wide, which is enough to raise a horizontal scrollbar
     of its own. Only bites when the fit is width-limited (a tall or narrow
     window); zooming away and back masked it by re-measuring with the
     scrollbars already there. */
  scrollbar-gutter: stable;
  /* `safe` centring: centre the stack while it fits, fall back to start-aligned
     the moment it overflows, so the top-left of the document stays reachable by
     scrolling. Plain `center` strands it — `margin: auto` on .pages was the old
     workaround for exactly that, and this replaces it. */
  justify-content: safe center;
  align-items: safe center;
}

/* The vertical stack of pages. Centring lives on .preview-wrap now, as `safe
   center` — see there. This element is only as wide as its widest slot, which
   is why computeScale() in ui/pages.js has to fit the widest PAGE: fitting page
   1 while a landscape page sets this width is what made the stack overflow
   sideways. The gap here MUST match GAP_PX in ui/pages.js, since that module
   measures slot offsets for scrolling. */
.pages {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  /* Default cursor lives here (not on .page-canvas) so the dynamic cursor
     interact.js sets on #pages inherits down onto the canvases instead of
     being overridden by a more specific rule. */
  cursor: crosshair;
}

/* One page. Sized in JS to page-points × scale; white with a drop shadow so it
   reads as paper even before (or between) rasters. A <canvas> is attached only
   while the slot is near the viewport (see ui/pages.js). */
.page-slot {
  position: relative;
  flex: 0 0 auto;
  background: #ffffff;
  box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
}

.page-canvas {
  position: absolute;
  inset: 0;
  display: block;
  user-select: none;
}

.placeholder {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  margin: 0;
  color: var(--muted);
  font-size: 0.875rem;
}

/* Lives inside #preview-wrap now (moved out of the flow below the canvas), so
   it doesn't compete with the preview for vertical space — it floats over the
   top-left corner instead, only shown when the current page is out of scope. */
#scope-note {
  position: absolute;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 1;
  margin: 0;
  padding: 0.35rem 0.6rem;
  max-width: calc(100% - 1rem);
  background: rgba(255, 255, 255, 0.92);
  border: 1px solid var(--border);
  border-radius: 6px;
}

/* ==========================================================================
   Top bar
   ========================================================================== */

.topbar {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  margin-bottom: 1.5rem;
}

/* An <a> to the landing page. main.js intercepts the click when there is work
   to lose, so this is a real link (middle-click, copy link address) rather than
   a button pretending to be one. */
.brand {
  /* One size drives the whole lockup. The brand guidelines' horizontal lockup
     is a 40px mark beside 30px type with a 14px gap, so the mark is 1.33x the
     wordmark's font-size and the gap is roughly half of it. Deriving both from
     this token keeps that ratio if the wordmark is ever resized, instead of the
     mark staying put and the lockup silently going out of proportion. */
  --wordmark-size: 1.6rem;

  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-right: auto;
  text-decoration: none;
  color: inherit;
  border-radius: var(--radius-sm);
}

.brand:hover .wordmark { color: var(--accent-ink); }

/* The mark's two rectangles reference the RAMP tokens (--purple / --red), not
   the semantic aliases (--accent / --danger). The aliases exist so a palette
   change is a few edits in :root — but the logo is identity, not role. If
   --accent were ever remapped off purple, every button should follow and the
   logo must not. Same reason the guidelines say "purple under, red over —
   always". */
.brand-mark {
  width: calc(var(--wordmark-size) * 1.33);
  height: calc(var(--wordmark-size) * 1.33);
  flex: none;
  /* On top of .brand's 0.5rem, bringing the mark-to-wordmark gap to the ~0.47x
     the lockup uses. It is a margin rather than a bigger gap on .brand because
     the Beta badge on the other side of the wordmark wants the 0.5rem. */
  margin-right: 0.25rem;
}
.brand-mark-page    { fill: var(--purple); }
.brand-mark-overlay { fill: var(--red); }

/* An <h1>: the page had none, and its first heading was an <h3>, so the outline
   both started at the wrong level and skipped two. Needs an explicit margin
   because an h1's UA margin would push the top bar apart.
   Aleo BOLD and purple, per the brand guidelines — it rendered at 900 in --fg
   while there was no real logo to sit beside. Family and weight now come from
   the shared `h1, h2, h3, .wordmark` rule above (Aleo 700), which is exactly
   what the guidelines ask for, so they are not re-declared here. */
.wordmark {
  margin: 0;
  font-size: var(--wordmark-size);
  letter-spacing: -0.01em;
  color: var(--purple);
  line-height: 1;
}

/* Deliberately not the accent colour — BETA is a caveat, not a feature. It
   should read as a quiet annotation on the wordmark, not compete with it. */
.badge-beta {
  font-family: var(--font-ui);
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.15rem 0.35rem;
  line-height: 1;
  transform: translateY(-0.35em); /* sits with the cap height, not the baseline */
}

.topbar-right {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

/* Export credits. PLACEHOLDER — no auth, no payment, nothing behind this yet.
   It counts nothing and blocks nothing; see main.js. Do not wire it to the
   download path until there is a real entitlement to check. */
.credits {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-size: 0.8rem;
  color: var(--muted);
  white-space: nowrap;
}

.credits-meter {
  display: block;
  width: 3rem;
  height: 5px;
  border-radius: 999px;
  background: var(--surface-sunken);
  overflow: hidden;
}

/* Full, because nothing is metered during beta. The low/red state that used to
   live here was removed with the fake counter — it will come back with a real
   entitlement to reflect, not before. */
.credits-meter i {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 999px;
  background: var(--accent);
}

/* A dark band. It reads as the edge of the application rather than as more
   panel, and it lets the privacy claim sit on its own ground instead of
   competing with the controls above it. */
.appfooter {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-top: 1.5rem;
  padding: 0.35rem 1rem;
  background: var(--surface-dark);
  font-size: 0.75rem;
  color: var(--on-dark-muted);
}

.footer-slot {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  gap: 0.35rem;
  min-width: 0;
}

.footer-end { justify-content: flex-end; }

/* The privacy line is the reason this bar exists. Centred and always present:
   it is the product's core claim, and a claim that scrolls away is a claim the
   user has to remember rather than one they can see. */
.privacy-note {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  white-space: nowrap;
  color: var(--on-dark-go);
}

.privacy-note .icon {
  width: 13px;
  height: 13px;
  flex-shrink: 0;
}

.appfooter a,
.footer-link {
  color: var(--on-dark-muted);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.appfooter a:hover,
.appfooter .footer-link:hover {
  color: var(--on-dark);
  background: none;
  border-color: transparent;
}

/* A <button> that reads as a link — Feedback opens a dialog, it does not
   navigate, so it must not be an <a>. */
.footer-link {
  min-height: 0;
  padding: 0.15rem 0.2rem;
  border: none;
  background: none;
  font-family: var(--font-ui);
  font-size: 0.75rem;
  font-weight: 400;
}

/* Undo/redo. Icon-only and quiet — they are always present but rarely the thing
   you are looking at, and a disabled pair should not draw the eye at all. */
.footer-icon {
  width: 1.6rem;
  min-width: 0;
  min-height: 1.6rem;
  padding: 0;
  border: none;
  border-radius: 4px;
  background: none;
  color: var(--on-dark-muted);
}

.footer-icon .icon { width: 15px; height: 15px; display: block; }

.appfooter .footer-icon:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.12);
  border-color: transparent;
  color: var(--on-dark);
}

.appfooter .footer-icon:disabled {
  background: none;
  border-color: transparent;
  color: rgba(255, 255, 255, 0.28);
  cursor: default;
}

/* The focus ring is drawn in the accent purple, which is too dark to see on
   this band — white is the only readable option here. */
.appfooter :where(button, a):focus-visible {
  outline-color: var(--on-dark);
}

.hint {
  font-weight: 400;
  color: var(--muted);
  font-size: 0.85em;
}

/* Selection is off for CHROME and on for anything that is a value.
   Blanket `user-select: none` is the usual mistake: it stops people copying the
   things they most want to copy — a hex code they are matching in another tool,
   an error message they are pasting into a support request, a filename. So the
   default is off, and everything below opts back in. Form controls are exempt
   by necessity, not by choice. */
body { user-select: none; }

input,
textarea,
select,
[contenteditable],
.color-hex,
.stamp-preview,
.status,
.file-name,
.file-meta,
.settings-lede,
.modal-message,
.font-table,
.config-table {
  user-select: text;
}

/* The one legitimate !important in the file. `hidden` is a *state*, set from JS
   on elements that belong to many different components (the image Remove
   button, the custom-pages field, the stamp's separator/align labels, the tool
   panels), and it only lives in the UA stylesheet — which every author rule
   beats, however weak. `button { display: inline-flex }` and
   `.controls label { display: flex }` were therefore both silently un-hiding
   their elements. Scoping cannot fix this: the state is orthogonal to every
   component selector, so there is nothing to scope through. */
[hidden] { display: none !important; }

/* Visually hidden but still focusable and still a real form control — used for
   the file inputs, which are driven by their own <label> instead. Not
   `display:none`, which would take them out of the accessibility tree and stop
   the label from activating them. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Focus lands on the hidden <input>, so the ring has to be drawn on the label
   that stands in for it. */
.sr-only:focus-visible + .btn-like,
input[type="file"].sr-only:focus-visible ~ .btn-like {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ==========================================================================
   Controls panel — three regions
   File bar pinned top, tools scroll, everything from Placement down pinned
   bottom. The pinning is flex, not position:sticky: the panel is a fixed-height
   flex column, so the middle is the only thing that can overflow.
   ========================================================================== */

.file-card { flex: 0 0 auto; }

.file-bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.file-info {
  min-width: 0; /* let the name truncate instead of pushing the button out */
  flex: 1;
}

.file-name {
  margin: 0;
  font-weight: 700;
  font-size: 0.95rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.file-meta {
  margin: 0.1rem 0 0;
  font-size: 0.8rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* The Overlay section. It is the one card allowed to take the slack, and the
   one whose contents may overflow — so the card itself is a flex column and
   #tool-panels inside it is what actually scrolls. Everything else in the panel is
   fixed-height, which is what pins Document to the top and the three bottom
   cards to the bottom without position:sticky. */
.tools-card {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* The tab strip stands on its own: it is a fixed-height flex child here and
   #tool-panels below it is what scrolls. It used to be `position: sticky` inside
   a scrolling .tools, which is nearly the same picture but not the same object —
   the strip travelled with the content until it hit the top, tool content could
   slide behind it, and the scrollbar ran the full height of the section
   including the strip. Now the strip is outside the scroll entirely, which is
   what a tab bar is. */
/* No `overflow: hidden` here: #tool-panels below deliberately bleeds outward
   into the card's padding (see there), and hiding overflow on this wrapper
   would clip exactly what that bleed is for. Nothing can overflow it anyway —
   the strip is fixed-height and the panels scroll. */
.tools {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.tool-tabs {
  flex: 0 0 auto;
  display: flex;
  gap: 0.25rem;
  padding: 0.25rem;
  background: var(--surface-sunken);
  border-radius: var(--radius);
}

/* The only scrolling region inside the Overlay card.
   A scroll container clips its own padding box, and the focus ring is
   `2px outline + 2px offset` = 4px OUTSIDE the control — so a full-width select
   or number field had its ring sliced off left and right, and the gear at the
   end of a row sat flush against the scrollbar with nothing between them.

   The fix is the --scroll-gutter pattern: inset the content by a gutter, then
   pull the box outward by the same amount with a negative margin. The gutter
   gives the ring room and keeps controls clear of the scrollbar; the negative
   margin cancels the indent, so tool controls stay on the same left edge as the
   tab strip above them, and moves the scrollbar out toward the card border
   instead of leaving it floating in the middle of the padding. */
.tool-panels {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 0 var(--scroll-gutter) var(--scroll-gutter);
  margin: 0 calc(-1 * var(--scroll-gutter));
}

.tool-tabs button {
  flex: 1;
  padding: 0.4rem 0.5rem;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
  font-size: 0.85rem;
}

/* Hover uses the purple ramp's own light/dark pair, which is contrast-checked
   in Figma. The previous white-wash background with default black text was an
   invented combination and failed against the ramp's standard. */
.tool-tabs button:hover:not([aria-selected="true"]) {
  background: var(--purple-light);
  color: var(--purple-dark);
  border-color: transparent;
}

.tool-tabs button[aria-selected="true"] {
  background: var(--bg);
  color: var(--accent-ink);
  border-color: var(--border);
  box-shadow: 0 1px 2px rgba(27, 0, 89, .08);
}

/* A tool with content gets a dot, so switching away does not hide the fact that
   something is still being stamped from the other two slots.
   The dot alone is invisible to a screen reader, so ui/tools.js also writes a
   visually-hidden " — in use" into the tab (see .tab-state). Presence/absence of
   a shape is not a colour-only signal, but it was a signal only sighted users
   could receive, which is the same failure by another route. */
.tool-tabs button[data-present="true"]::after {
  content: "";
  display: inline-block;
  width: 5px;
  height: 5px;
  margin-left: 0.35rem;
  border-radius: 50%;
  background: var(--accent);
  vertical-align: middle;
}

.tool-panel {
  padding: 0.85rem 0 0;
}

/* Bounded so a long overlay text cannot push the rest of the tool out of the
   height budget — the panel must not scroll on a normal monitor. */
#overlay-text {
  max-height: 5.5rem;
}

/* ---- image tool ---- */

.image-tool {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
}

.image-thumb {
  flex: 0 0 auto;
  width: 8rem;
  height: 8rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-sm);
  background: var(--surface);
  border: 1px solid var(--border);
  overflow: hidden;
}

.image-thumb[data-empty="true"] {
  border-style: dashed;
  border-color: var(--border-strong);
}

/* Dragging a file over it. The dashed border applies whether or not an image is
   already loaded, since dropping onto a filled thumbnail replaces it and the
   target has to read as a target either way. */
.image-thumb.is-dragover {
  border-style: dashed;
  border-color: var(--accent);
  background: var(--accent-soft);
}

.image-thumb.is-dragover .image-thumb-empty { color: var(--accent-ink); }

/* The pointer has to be able to leave the box for dragleave to fire on it —
   with a child under the cursor the events target the child instead, and the
   highlight would stick after the drag left. */
.image-thumb * { pointer-events: none; }

.image-thumb img {
  max-width: 100%;
  max-height: 100%;
  display: block;
  object-fit: contain;
}

.image-thumb-empty {
  padding: 0 0.4rem;
  font-size: 0.7rem;
  line-height: 1.3;
  text-align: center;
  color: var(--muted);
}

.image-thumb:not([data-empty="true"]) .image-thumb-empty { display: none; }

.image-body {
  flex: 1;
  min-width: 0;
}

.image-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* ---- panel bottom ---- */

/* Placement, saved configurations and Save PDF are unrelated concerns that
   happen to share the bottom of the panel — so they are three cards with real
   separation, not a run of rows crammed against each other. */
.panel-bottom {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

/* THE card treatment. Every panel section — Document, Overlay, Placement,
   Configuration, Save PDF — uses it unmodified: same padding, same border, same
   surface. No section gets a tint of its own. Save PDF used to be accent-tinted
   and that read as a different KIND of thing sitting among the others, when it
   is one more section; its primary button is what marks it as the end of the
   workflow. */
.panel-card {
  padding: var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

/* A card ending in a reserved message slot needs less bottom padding than one
   ending in a control: the slot is two lines tall and usually holds one, so its
   unused second line is already acting as padding. At the full --space-3 these
   two cards had visibly more air below their message than above their title. */
.panel-card:has(.status-slot) { padding-bottom: var(--space-2); }

/* One gap between every pair of cards, including the two above .panel-bottom,
   which are separate flex children of the panel. */
#controls-panel > .panel-card { margin-bottom: var(--space-3); }

/* Aleo, sentence case, at a size where the display face actually registers.
   These were 0.68rem uppercase letterspaced Lato — eyebrow labels rather than
   headings, which is why simply setting them in Aleo would have made the face
   read as decoration. A section title is a real heading, so it is styled as one
   and the display face is earned rather than applied. */
.card-title {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: none;
  color: var(--fg);
}

/* A row of controls inside a card. align-items: flex-end so a button sits on
   the same baseline as the field beside it once that field carries a caption
   above it — the captions have their own height, and stretch/center would
   either inflate the button or float it into the caption's line. */
.card-row {
  display: flex;
  align-items: flex-end;
  gap: var(--space-2);
  flex-wrap: wrap;
}

/* Caption above the field, everywhere. Shared with `.controls label` (the tool
   grids) so a select in Placement is laid out exactly like one in the Text
   tool — with the caption beside the field, every input started wherever its
   own label happened to end and nothing lined up down the column. */
.field-label,
.controls label,
.bates-segment-fields label {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  min-width: 0;
}

.field-label > span:first-child,
.controls label > span:first-child,
.bates-segment-fields label > span:first-child {
  font-size: 0.72rem;
  line-height: 1.35;
  color: var(--muted);
}

.card-row .field-label { flex: 1 1 8rem; }
.card-row button { flex: 0 0 auto; }

/* Its own row: "e.g. 2, 5-7, 10" is longer than half a panel, and a truncated
   placeholder is the only documentation this field has. */
#scope-custom-label { flex: 1 1 100%; }

/* Reserved, never collapsed — the space is the point. These messages appear
   the instant the user presses the button right above them, so growing the
   card at that moment shifts the thing they are still looking at.
   `:empty` is matched explicitly because the earlier collapse rule would
   otherwise hide the reserved box and reintroduce the jump.
   Two lines for every card, not a per-card guess: the Save PDF slot used to
   reserve three and left that section visibly bottom-heavy next to the others.
   The messages are written to fit (see main.js and ui/configs.js). */
.panel-card .status-slot,
.panel-card .status-slot:empty {
  display: block;
  margin: var(--space-1) 0 0;
  font-size: 0.72rem;
  line-height: 1.3;
  min-height: calc(0.72rem * 1.3 * 2);
}

/* There is deliberately NO `.panel-bottom .status:empty { display: none }` here.
   It used to sit at this point in the file, tied `.panel-card .status-slot:empty`
   on specificity (0-3-0 both), and won on source order — collapsing the very box
   that exists to reserve space, so the message still shoved the card on save.
   Every status down here is a .status-slot now; the slot rules govern. */

/* ---- colour fields and swatches (rows populated by ui/swatches.js) ---- */

/* The picker is a small square, not a full-width bar. A 100%-wide colour input
   claimed the visual weight of a primary control for what is one attribute
   among a dozen, and it was the only field in the app whose size said nothing
   about the size of its value. The hex readout beside it carries the answer
   the bar used to have to carry by being big. */
/* Picker beside the swatches rather than above them, when the panel is wide
   enough to hold both. Plain flex-wrap does the deciding: .swatches asks for
   12rem and drops to its own line if it cannot have it, so this needs no media
   or container query and degrades to the old stacked layout by itself.
   Overrides the flex COLUMN it inherits from .field-label, so it has to come
   after that rule — it does. */
.color-field {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1) var(--space-4);
}

.color-field > span:first-child { flex: 1 1 100%; } /* caption keeps its own line */

/* The picker and its hex are ONE control, so they are boxed as one — that box
   is what separates them from the swatch rows beside them. A divider between
   the two would not survive .swatches wrapping to its own line on a narrow
   panel; a bordered field reads as a unit in either arrangement. Height is
   --control-h, so the field still matches every other control on the row even
   though the colour chip inside it is smaller. */
.color-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex: 0 0 auto;
  height: var(--control-h);
  padding: 0 var(--space-2) 0 3px;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: var(--bg);
}

/* A chip inside the field, not a bordered control of its own — the field around
   it already carries the border. The inset ring is the same treatment the
   swatches use, so a pale or white colour still reads as a chip. */
.controls .color-row input[type="color"] {
  width: 1.6rem;
  height: 1.6rem;
  flex: 0 0 auto;
  padding: 1px;
  border: 1px solid rgba(0, 0, 0, 0.18);
  border-radius: 3px;
  background: var(--bg);
  cursor: pointer;
}

/* FIXED width, and this is the point of the rule, not tidiness.
   The hex is always 7 characters, but hex LETTERS (A-F) are not digits, so
   `font-variant-numeric: tabular-nums` does not equalise them — #81465D and
   #FFFFFF measure differently in a proportional face. At content width the row
   therefore changed size as the user dragged around the picker, and the swatch
   rows beside it twitched left and right on every mouse move.
   Also clears `.controls output`, the opacity readout's rule (right-aligned in
   a 3rem box), which matches this element too — same tag, different job. */
.color-row .color-hex {
  flex: 0 0 auto;
  width: 3.9rem;
  text-align: left;
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  color: var(--muted);
}

/* Empty until ui/swatches.js populates it — collapse rather than leaving a gap
   under every colour picker. */
.swatches:empty { display: none; }

.swatches {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  /* flex-basis 12rem is the "do I fit beside the picker?" threshold above. */
  flex: 1 1 12rem;
}

/* Named rows. Unlabelled, the two groups were one undifferentiated run of chips
   with a hairline in it, and nothing said which side meant "already in this
   document" and which meant "you used this recently" — different questions with
   the same answer shape. The label column is a fixed width so the two rows'
   chips line up under each other. */
.swatch-group {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.swatch-group-label {
  flex: 0 0 3.4rem;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}

.swatch-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  min-width: 0;
}

/* 1.5rem = 24px, the WCAG 2.5.8 minimum. They were 20px, and the 4px gap is too
   small for the spacing exception to apply — 24px targets centred on 24px chips
   would have overlapped. */
.swatches .swatch {
  width: 1.5rem;
  height: 1.5rem;
  min-width: 0;
  min-height: 1.5rem;
  padding: 0;
  border-radius: 4px;
  /* An inset ring rather than a solid border, so a white or very pale swatch
     still reads as a chip without a dark outline distorting its colour. */
  border: 1px solid rgba(0, 0, 0, 0.18);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
  cursor: pointer;
}

/* Hover and selected states are drawn INSIDE the chip — border plus an inset
   ring — rather than as an outline. An outline sits outside the box, so the
   selected chip in the first column bulged past the left edge everything else
   in the field is aligned to, which is what made the row look misaligned. */
.swatches .swatch:hover {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--bg);
}

.swatches .swatch[data-current="true"] {
  border-color: var(--accent-ink);
  box-shadow: inset 0 0 0 2px var(--bg);
}

/* Element-level fallback for any field without a context rule. Deliberately
   uses the SAME tokens as the form-control block, so if it ever wins a
   specificity tie the result is indistinguishable — the previous version had
   its own padding, border colour and radius, which is how a stray rule could
   quietly restyle a field without looking broken enough to notice. */
input[type="text"],
textarea {
  width: 100%;
  min-height: var(--control-h);
  padding: 0.3rem 0.45rem;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  font-size: 0.8rem;
  line-height: 1.4;
}

#overlay-text {
  resize: vertical;
}

/* The inline editor is a real textarea laid over the canvas so that caret,
   selection, clipboard and IME all work. Everything visible about it is set
   from JS to match the canvas text it stands in for. */
#inline-editor {
  position: absolute;
  margin: 0;
  padding: 0;
  width: auto;
  border: none;
  border-radius: 0;
  background: transparent;
  overflow: hidden;
  resize: none;
  white-space: pre;
  text-align: center;
  transform-origin: center center;
  outline: 1px dashed var(--accent);
  outline-offset: 2px;
}

/* ==========================================================================
   Form controls
   One base style, so a <select> in the config bar looks like a <select> in a
   tool. Previously each context restyled its own fields and they drifted.
   ========================================================================== */

.field,
.controls input[type="number"],
.controls input[type="text"],
.controls select,
.bates-segment-fields input[type="number"],
.bates-segment-fields input[type="text"],
.bates-segment-fields select,
.card-row select,
.card-row input[type="text"],
.config-save-row input[type="text"],
.settings-body input[type="text"] {
  width: 100%;
  min-width: 0;
  min-height: var(--control-h); /* matches buttons, so pairs on a row align */
  padding: 0.3rem 0.45rem;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  font-size: 0.8rem;
  line-height: 1.3;
}

/* Native select arrows differ enough between platforms to look like a
   different control; one drawn chevron keeps them consistent. */
.controls select,
.bates-segment-fields select,
.card-row select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 1.5rem;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23565656' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' d='M3 4.5 6 7.5 9 4.5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.45rem center;
  background-size: 11px;
}

input[type="number"] { font-variant-numeric: tabular-nums; }

/* Explicit, because the UA default is around #757575 in Chrome — close enough
   to the 4.5:1 line that it varies by browser. --muted is 7.34:1 on white and
   6.86:1 on --surface. Placeholder text is still not a label (see the
   aria-label on #config-name). */
::placeholder { color: var(--muted); opacity: 1; }

/* The opacity sliders were rendering in the browser's default blue, which is
   the one colour in the app that belongs to no ramp. accent-color paints the
   filled track and the thumb from the palette without reaching for the
   ::-webkit-slider-* pseudo-elements, which would mean re-implementing the
   whole control per engine. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] { accent-color: var(--accent); }

/* Stacked "label over field" cells. The stacking itself is defined once, up
   with .field-label — this block only lays the cells out.
   auto-fit rather than a fixed two columns: the column count then follows the
   panel's own width with no media or container query. Two columns at the 21rem
   floor, three around 24rem, four at the 31.5rem ceiling — which is what makes
   a wider panel actually SHORTER rather than merely emptier. */
.controls {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
  gap: var(--space-2);
  margin-top: var(--space-3);
  align-items: end;
}

/* Not `label.wide`: the Style group is a <div class="field-label wide"> for the
   click-forwarding reason noted in index.html, and it still has to span. */
.controls .wide { grid-column: 1 / -1; }

/* For fields that need more than one cell but not the whole row — a font
   picker, a "Behind content" select, an opacity slider. At two columns this
   collapses to the full row, so narrow panels stay correct for free. */
.controls .span-2 { grid-column: span 2; }

/* A pair of inputs under ONE caption — Center [%] over X and Y. Two captions
   reading "Center X [%]" and "Center Y [%]" cost a whole extra cell each and
   still had to fit ~104px at three columns; one caption over an axis-labelled
   pair is both narrower and more explicit about what the number means. */
.field-pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-2);
}

/* A <label> around exactly one input, so clicking the axis letter focuses that
   field. The letter leads rather than trails: a glyph after the input reads as
   a stray character (which is why the "°" moved into the caption), one before
   it reads as a label. */
/* Scoped through .controls, not a bare `.axis-field`. This IS a <label> inside
   .controls, so `.controls label` (0-1-1) matches it and would win against a
   single class — the letter would stack above the field as a caption, which is
   exactly what the shared stacking rule is for and exactly wrong here. */
.controls .axis-field {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 0.3rem;
  min-width: 0;
}

.axis-field > i {
  flex: 0 0 auto;
  font-style: normal;
  font-size: 0.7rem;
  font-weight: 700;
  color: var(--muted);
}

.axis-field > input { flex: 1; min-width: 0; }

/* Pairs a field with something that belongs on the same row — the opacity
   readout, or the gear beside a font picker.
   It no longer carries UNITS. A "°" glyph parked to the right of the Angle
   field was the only such marker in the app, so it read as a stray character
   rather than as a unit, and it sat where the eye expects a control. Units are
   in the label now, bracketed: "Angle [°]", "Size [pt]", "Center X [%]". */
.field-unit {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}

.field-unit > input { flex: 1; min-width: 0; }
.field-unit > select { flex: 1; min-width: 0; width: auto; }

.field-unit > output {
  min-width: 2.6rem;
  text-align: right;
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

/* A checkbox row is as wide as its own words. It is a real <label> wrapping its
   input — which is what makes the text clickable, and is right — but as a
   full-width grid item that clickable area ran to the far edge of the panel, so
   a click in the empty space beside it silently toggled the box. justify-self
   shrinks the item to its content, matching what the user can see. */
.controls .checkbox-label {
  flex-direction: row;
  align-items: center;
  justify-self: start;
  gap: var(--space-2);
  font-size: 0.78rem;
  color: var(--fg);
}

.controls .checkbox-label input { flex: 0 0 auto; width: auto; }
.controls .checkbox-label > span:first-child { font-size: 0.78rem; color: var(--fg); }

/* The gear beside a font picker. Same control as the one in the Configuration
   card, opening the same dialog on the pane that manages this list — an
   underlined "Add…" link was a third kind of button doing a job the app
   already had a shape for. */
.field-unit .icon-btn { flex: 0 0 auto; }

/* Sub-heading inside a tool, separating groups of controls. Aleo like the card
   titles, one step down — it sits under one, so it must not compete with it. */
.tool-sub {
  margin: var(--space-3) 0 var(--space-2);
  font-family: var(--font-display);
  font-size: 0.85rem;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: none;
  color: var(--fg);
}

.tool-panel > .tool-sub:first-child { margin-top: 0; }

/* B / I / U / S sit on a row with the font picker and its gear, so they are
   full --control-h like every other control — they were on --control-h-sm and
   read as a different, smaller class of thing next to a 2.2rem select.
   `flex` not `inline-flex`, and each button `flex: 1`: the group then fills its
   grid cell, which is the same width as the Font cell beside it, so the two
   line up on both edges instead of only on the left. */
.style-buttons {
  display: flex;
  gap: var(--space-1);
}

.style-buttons button {
  flex: 1;
  width: auto;
  min-width: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  /* --border-strong, matching the select and the gear, not the lighter
     --border it used before. */
  border: 1px solid var(--border-control);
  font-weight: 400;
}

.style-buttons button[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

.style-buttons button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

#font-remove {
  padding: 0.3rem 0.6rem;
  font-size: 0.85rem;
}

#font-remove:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

#style-bold { font-weight: 700; }
#style-italic { font-style: italic; }
#style-underline { text-decoration: underline; }
#style-strike { text-decoration: line-through; }

/* Segments collapse to one row each. Four expanded blocks plus the block-styling
   grid overshoots the panel's height budget, and a disabled segment's fields are
   not worth the space — so fields show only when the segment is open. Enabling a
   segment opens it (see ui/bates.js); the caret toggles it independently. */
.bates-segments {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.bates-segment {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 0.4rem 0.5rem;
  background: var(--bg);
}

.bates-segment.is-open {
  background: var(--surface);
  border-color: var(--border-strong);
}

.bates-segment-header {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.bates-segment-header > input[type="checkbox"] { flex: 0 0 auto; }

/* The name strip IS the disclosure — the whole width between the checkbox and
   the reorder arrows opens and closes the segment. Previously the header was
   one <label>, so the obvious gesture (click the row) ticked the box and the
   only way to reach the fields was a 1.6rem caret in the corner. */
.seg-disclosure {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  min-height: var(--control-h-sm);
  padding: 0 0.3rem;
  border: 1px solid transparent;
  border-radius: 4px;
  background: transparent;
  color: var(--fg);
  font-size: 0.82rem;
  font-weight: 700;
  text-align: left;
}

.seg-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Neutral, not accent. The caret was accent-tinted precisely because it was the
   only route into a collapsed row; now that the whole strip opens it, that
   justification is gone and accent goes back to meaning selection and primary
   actions. The one thing in this row that IS accent-coloured is the checkbox,
   via accent-color — which is the right thing to emphasise. */
.seg-caret {
  flex: 0 0 auto;
  display: flex;
  color: var(--muted);
  transition: transform .12s ease;
}

.seg-caret .icon { width: 15px; height: 15px; }

.seg-disclosure[aria-expanded="true"] .seg-caret { transform: rotate(180deg); }

/* Scoped through the header for specificity — a bare `.seg-disclosure:hover` is
   0-2-0 and loses to `button:hover:not(:disabled)` (0-2-1) on element count. */
.bates-segment-header .seg-disclosure:hover {
  background: var(--surface-sunken);
  border-color: var(--border);
}

/* A segment that is off should read as off without becoming unreachable. */
.bates-segment:not(.is-enabled) .seg-disclosure { color: var(--muted); font-weight: 400; }

/* The grip replaced a pair of up/down arrows. It leads the row because that is
   where a reorder handle is expected, and it is a real <button> so it takes
   focus and answers the arrow keys — which is the only reason removing the
   visible arrows was acceptable. */
.seg-grip {
  flex: 0 0 auto;
  /* 1.5rem = 24px, the WCAG 2.5.8 minimum target size. It was 1.4rem (22.4px)
     and its only visual affordance was --border-strong at 1.55:1, which failed
     1.4.11 — an interactive control has to be perceivable, and a grip with no
     border or fill IS its icon. --muted is 7.34:1. */
  width: 1.5rem;
  min-width: 0;
  min-height: 1.5rem;
  padding: 0;
  border: none;
  border-radius: 4px;
  background: transparent;
  color: var(--muted);
  cursor: grab;
  /* Or the browser scrolls the panel instead of letting the drag happen. */
  touch-action: none;
}

.seg-grip .icon { width: 14px; height: 14px; display: block; }

.bates-segment-header .seg-grip:hover { background: var(--surface-sunken); color: var(--muted); }
.seg-grip:active { cursor: grabbing; }

/* The row being dragged. Lifted rather than tinted: it is the same object in a
   temporary state, not a selected or active one, so it borrows no palette
   meaning. */
.bates-segment.is-dragging {
  cursor: grabbing;
  border-color: var(--border-strong);
  box-shadow: 0 4px 14px rgba(27, 0, 89, .16);
}

/* While a drag is in progress the rows underneath must not react to the pointer
   passing over them — a hover highlight chasing the cursor reads as if the row
   under it were the thing being moved. */
.bates-segments.is-reordering .bates-segment:not(.is-dragging) { pointer-events: none; }
.bates-segments.is-reordering { cursor: grabbing; }

/* The composed stamp text, above the segment list. Inset and monospaced-ish so
   it reads as output rather than as another control — it is the only place the
   left-to-right order of an inline stamp is visible, since the segment list
   below is necessarily stacked. */
.stamp-preview {
  margin-bottom: var(--space-3);
  padding: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--fg);
  /* Aleo here, not Lato. This is the one thing in the panel that is OUTPUT
     rather than chrome — it is what the document will say — so it earns the
     display face where the eyebrow labels around it do not. */
  font-family: var(--font-display);
  font-size: 0.82rem;
  line-height: 1.4;
  /* pre-wrap, not pre-line: stacked layout joins segments with \n, and inline
     layout's separator commonly carries spaces (" | ") that pre-line would
     collapse — the preview has to show the string as it will actually stamp.
     Still wraps, so a long free-text segment cannot widen the panel. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* Empty, it is a hint, not output — so it drops the box entirely rather than
   sitting in a filled, bordered panel that implies there is something in it. */
.stamp-preview[data-empty="true"] {
  padding: 0 0 var(--space-1);
  border: none;
  background: none;
  color: var(--muted);
  font-style: italic;
}

/* Same auto-fit as .controls, so Prefix / Start / Digits / Suffix land on ONE
   row at a wide panel instead of two — the single biggest row saving in the
   Stamp tool, since it applies to every open segment. */
.bates-segment-fields {
  display: none;
  grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
  gap: var(--space-2);
  margin-top: var(--space-2);
}

.bates-segment.is-open .bates-segment-fields { display: grid; }

/* Stacking comes from the shared .field-label rule; only the grid placement is
   specific to this container. `flex: 1` would be actively wrong here — the
   label is a *column* now, so the main axis is vertical and growing the field
   would stretch its height rather than its width. */
.bates-segment-fields label.wide { grid-column: 1 / -1; }

.upload-row input[type="file"] {
  flex: 1;
  min-width: 0;
  font-size: 0.85rem;
}

.controls input[type="range"] {
  flex: 1;
  min-width: 6rem;
}

.controls output {
  min-width: 3rem;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* ==========================================================================
   Preview pill
   Page navigation and zoom float over the document instead of occupying a bar
   above it, so the preview area is the entire right-hand column. Pinned to the
   viewport (position: sticky against the scroll container) rather than to the
   page stack, so it stays put while the document scrolls under it.
   ========================================================================== */

.preview-pill {
  position: absolute;
  top: 0.85rem;
  left: 50%;
  z-index: 3;
  transform: translateX(-50%);
  width: max-content;
  display: flex;
  align-items: center;
  gap: 0.15rem;
  padding: 0.3rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.92);
  backdrop-filter: blur(8px);
  border: 1px solid var(--border);
  box-shadow: 0 4px 18px rgba(27, 0, 89, .14);
  font-size: 0.8rem;
  color: var(--muted);
}

.preview-pill button {
  padding: 0 0.5rem;
  min-width: 1.9rem;
  border: none;
  border-radius: 999px;
  background: transparent;
  color: var(--fg);
  font-size: 0.85rem;
  line-height: 1.2;
}

/* These were ‹ › − + as characters. Stroked icons match the rest of the app and,
   unlike a glyph, do not change weight and optical centring with whatever font
   happens to render them. #zoom-reset keeps its text — it is a value, not an
   action. */
.preview-pill .icon { width: 15px; height: 15px; }

.preview-pill button:hover:not(:disabled) {
  background: var(--accent-soft);
  color: var(--accent-ink);
}

.preview-pill button:disabled {
  background: transparent;
  color: var(--border-strong);
}

.pill-page {
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  padding: 0 0.15rem;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.pill-page input[type="number"] {
  width: 2.6rem;
  padding: 0.15rem 0.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font: inherit;
  text-align: center;
  font-variant-numeric: tabular-nums;
  /* Spinners would double the pill's height budget for no benefit; the field is
     2-3 characters wide and prev/next already exist. */
  appearance: textfield;
  -moz-appearance: textfield;
}

.pill-page input::-webkit-outer-spin-button,
.pill-page input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.pill-sep {
  width: 1px;
  align-self: stretch;
  margin: 0.15rem 0.25rem;
  background: var(--border);
}

#zoom-reset {
  width: 3.1rem; /* fixed so 75% / 100% / 400% doesn't jitter the pill's width */
  font-variant-numeric: tabular-nums;
}


button.secondary {
  background: var(--accent-soft);
  border-color: var(--accent-soft);
  color: var(--accent-ink);
}
button.secondary:hover:not(:disabled) {
  background: var(--purple-light-hover);
  border-color: var(--purple-light-hover);
}
button.secondary:active:not(:disabled) {
  background: var(--purple-light-active);
  border-color: var(--purple-light-active);
}

/* Filled at rest, white on Red Normal — destructive actions should read as
   destructive before you touch them, not only on hover. Hover and active walk
   down the ramp's own steps (Normal → :hover → :active) rather than inventing
   darker values. */
button.danger {
  background: var(--red);
  border-color: var(--red);
  color: #fff;
}
button.danger:hover:not(:disabled) {
  background: var(--red-hover);
  border-color: var(--red-hover);
  color: #fff;
}
button.danger:active:not(:disabled) {
  background: var(--red-active);
  border-color: var(--red-active);
  color: #fff;
}

/* Neutral outline — for actions that are neither primary nor destructive
   (Sign in, Replace). Reads as available without claiming the eye. */
button.ghost {
  background: var(--bg);
  border-color: var(--border-control);
  color: var(--fg);
}
button.ghost:hover:not(:disabled) { background: var(--surface); border-color: var(--muted); }

/* Green is reserved for "unlock / get more" — the one action that adds
   capability rather than spending it. Using it anywhere else would dilute the
   only signal that means "go". */
button.cta {
  background: var(--go);
  border-color: var(--go);
  color: #fff;
}
button.cta:hover:not(:disabled) { background: var(--go-hover); border-color: var(--go-hover); }
button.cta:active:not(:disabled) { background: var(--green-active); border-color: var(--green-active); }

/* At --topbar-h: 5rem there is room for full-size controls, so these are not
   shrunk down any more — one control height across the app, top bar included. */
.topbar-right button { padding: 0 var(--space-4); }

/* One focus treatment for everything, using the accent so it reads as part of
   the system rather than as the browser's default blue. */
:where(button, a, input, select, textarea):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.status {
  margin: 0.5rem 0 0;
  font-size: 0.875rem;
  color: var(--muted);
}

.status.error {
  color: #dc2626;
}

/* Modal dialogs (see js/ui/modal.js). The <dialog> element centers itself and
   traps focus; these rules only handle appearance. */
.modal {
  padding: 0;
  border: none;
  border-radius: 10px;
  max-width: 26rem;
  width: calc(100% - 2rem);
  color: var(--fg);
  background: var(--bg);
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
  /* The dialog itself does not scroll — .modal-card below does.
     This replaces a `scrollbar-gutter: stable`, which fixed the same problem
     (a <dialog> is `overflow: auto` by UA default, so its content width moved
     with the scrollbar) at a real cost: on Windows the gutter is reserved even
     when nothing scrolls, shrinking the content box by ~15px. Everything
     positioned against that box then sat inside the dialog's visual edge — most
     obviously the settings close button, which looked misaligned in its corner
     while being exactly where it was told to go.
     Moving the scroll inside means the content box always matches the visible
     box, on every platform, with no reserved strip. */
  overflow: hidden;
}

.modal::backdrop {
  background: rgba(15, 23, 42, 0.45);
}

/* Fade only — deliberately NO transform.
   A transformed child contributes its post-transform box to its scroll
   container's scrollable overflow, and the dialog scrolls by default. The old
   `translateY(6px)` therefore made the dialog overflow by 6px for the 120ms of
   the animation: a scrollbar appeared, took ~15px off the content width, the
   wrapping .modal-actions row reflowed inside the narrower box, and everything
   snapped back at the end. Opacity has no layout effect whatsoever.

   `> *` rather than `.modal-card`, so every modal in the app gets the same
   entrance — the settings dialog's child is .settings-shell and was previously
   not animated at all. */
.modal[open] > * {
  animation: modal-in 0.12s ease-out;
}

@keyframes modal-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* The scroll container, now that .modal is not. Without this a prompt with a
   long message on a short window would be unreadable below the fold and
   unreachable — the dialog's own overflow used to be the escape hatch. */
.modal-card {
  padding: 1.25rem;
  max-height: 80svh;
  overflow-y: auto;
}

.modal-title {
  margin: 0 0 0.5rem;
  font-size: 1.05rem;
  font-weight: 700;
}

.modal-message {
  margin: 0 0 1rem;
  font-size: 0.925rem;
  color: var(--muted);
}

.modal-message strong {
  color: var(--fg);
}

/* Scoped to .modal-card — the small prompt cards built by ui/modal.js — NOT to
   .modal, which is also the settings dialog. As `.modal input[type="text"]`
   this tied the settings field rule on specificity, won on source order, and
   applied its `margin-bottom: 1rem` to the config name field. That margin made
   the flex line taller than the input, and `align-items: stretch` then stretched
   the Save button to fill it, so the button looked oversized when the real
   cause was a stray margin on its neighbour. */
.modal-card input[type="text"] {
  width: 100%;
  margin-bottom: 1rem;
  padding: 0.5rem;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  font: inherit;
}

.modal-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.5rem;
}

.modal-actions button {
  padding: 0 1rem;
}

/* ==========================================================================
   Settings dialog
   Left nav, right pane. Unlike the confirm/conflict prompts this one has real
   markup in index.html — it is a page of controls, not a question.
   ========================================================================== */

.modal-wide {
  max-width: 56rem;
  width: calc(100% - 3rem);
}

/* Height-bounded so nothing here can grow the dialog: the nav and each pane's
   header stay fixed, and the only thing that scrolls is .settings-scroll. That
   is also what keeps the dialog's rounded corners — a scrollbar on the panel
   edge squared off the right-hand side. */
.settings-shell {
  display: grid;
  grid-template-columns: 11rem minmax(0, 1fr);
  height: min(34rem, 82svh);
  overflow: hidden;
  border-radius: 10px;
}

.settings-nav {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 1rem 0.6rem;
  background: var(--surface);
  border-right: 1px solid var(--border);
  overflow-y: auto;
}

.settings-brand {
  margin: 0 0.65rem 0.7rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1rem;
  color: var(--fg);
}

.settings-nav button {
  /* The base button is inline-flex with justify-content:center, which lays out
     the label centred regardless of text-align. Nav items are list rows, not
     actions, so they start at the left edge. */
  justify-content: flex-start;
  text-align: left;
  padding: 0.35rem 0.65rem;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
  font-size: 0.85rem;
}

.settings-nav button:hover:not([aria-selected="true"]) {
  background: var(--bg);
  color: var(--fg);
  border-color: transparent;
}

.settings-nav button[aria-selected="true"] {
  background: var(--accent-soft);
  color: var(--accent-ink);
  border-color: var(--purple-light-active);
}

.settings-body {
  position: relative;
  min-height: 0;
  overflow: hidden; /* the scroll belongs to .settings-scroll, not to this */
  display: flex;
}

/* Each pane is header + scrolling list. */
/* The pane's side padding is short by one --scroll-gutter, which the header and
   the scrolling list each add back. Net effect: both start on the same left
   edge, and the list's scrollbar sits a gutter in from the pane edge instead of
   hard against the controls in the last column. */
.settings-pane {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 1.1rem calc(1.3rem - var(--scroll-gutter)) 0;
}

.settings-pane[hidden] { display: none; }

.settings-head {
  flex: 0 0 auto;
  padding: 0 var(--scroll-gutter) 1.1rem;
  border-bottom: 1px solid var(--border);
}

/* Reserved height, same principle as the panel's .status-slot: these appear
   directly under the control that triggered them, so growing would push the
   table below. Two lines, since a save error can carry a message from the
   storage layer. */
.settings-status,
.settings-status:empty {
  display: block;
  margin: 0.6rem 0 0;
  min-height: calc(0.78rem * 1.4 * 2);
  font-size: 0.78rem;
  line-height: 1.4;
}

.settings-lede {
  margin: 0 0 0.9rem;
  max-width: 46ch;
  font-size: 0.8rem;
  line-height: 1.45;
  color: var(--muted);
}

/* Same gutter as .tool-panels — the per-row Apply / Rename / Delete buttons sit
   at this container's right edge, so without it their focus rings clip and they
   touch the scrollbar. Taken out of .settings-pane's padding rather than pulled
   back with a negative margin: the pane header above is NOT a scroll container,
   so a negative margin here would have left the tables 8px wider than the title
   and lede they sit under. Header and list both inset by the gutter instead,
   which keeps one left edge down the pane. */
.settings-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: var(--space-3) var(--scroll-gutter) var(--space-4);
}

/* Equal inset on both axes — it was 0.6/0.7, which is exactly enough to read as
   "not quite in the corner". */
.settings-close {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  width: var(--control-h-sm);
  height: var(--control-h-sm);
  min-width: 0;
  padding: 0;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
}

.settings-close .icon { width: 16px; height: 16px; }

/* Scoped for specificity against the generic button hover — see the note on
   the base button block. */
.settings-body .settings-close:hover { background: var(--surface-sunken); color: var(--fg); }

/* Aleo via the base h1/h2/h3 rule, and large enough for it to register — at
   1.15rem the display face was doing no work. This is a page title, which is
   exactly what the pairing reserves Aleo for. */
.settings-title {
  margin: 0 0 1rem;
  font-size: 1.45rem;
  letter-spacing: -0.01em;
}

/* A banded header, not a faint caption. As plain uppercase grey these were
   almost invisible against the table rows they were supposed to separate. */
.settings-sub {
  margin: 1.4rem 0 0;
  padding: 0.35rem 0.6rem;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-bottom: none;
  font-family: var(--font-ui);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--fg);
}

.settings-sub:first-of-type { margin-top: 0; }

/* The table continues the band's box, so header and rows read as one section. */
.settings-sub + .font-table,
.settings-sub + .config-table {
  border: 1px solid var(--border);
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
  overflow: hidden;
}

.settings-actions {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex-wrap: wrap;
}

.settings-actions .hint { font-size: 0.75rem; }

/* table-layout: fixed is doing real work here, not just tidiness.
   With auto layout the columns sized themselves to their content, so the name
   column moved with the longest family name — leaving the previews starting at
   a different x in each table — and an uploaded font called something long
   pushed the whole table wider than the dialog, cutting off the Remove button
   and adding a horizontal scrollbar. Fixed layout means the columns are set by
   the widths below and content has to fit them, so a filename of any length
   truncates instead of resizing the table. */
.font-table,
.config-table {
  width: 100%;
  max-width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
  font-size: 0.85rem;
}

.font-table .font-name { width: 10rem; }
.font-table .font-meta { width: 6rem; }
.font-table .font-actions { width: 12.5rem; }

/* Uploaded font labels come from filenames, which have no length limit. */
.font-name,
.config-name-cell {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.config-table .config-actions { width: 15rem; }

.font-table td,
.config-table td {
  padding: 0.6rem 0.5rem;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

.font-table tr:last-child td,
.config-table tr:last-child td { border-bottom: none; }

.font-name { font-weight: 700; }

/* The preview is the point of the row, so it takes the slack. Rendered in the
   font it describes — the only honest way to show a typeface. Sized down and
   held to one line: a wrapped specimen tells you nothing useful and doubles
   the row height. */
.font-preview {
  width: 100%;
  font-size: 0.95rem;
  line-height: 1.3;
  color: var(--fg);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.font-meta {
  white-space: nowrap;
  color: var(--muted);
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
}

.font-actions,
.config-actions {
  white-space: nowrap;
  text-align: right;
}

.font-actions button,
.config-actions button {
  padding: 0 0.55rem;
  font-size: 0.75rem;
  margin-left: 0.25rem;
}

.tag-default {
  display: inline-block;
  padding: 0.15rem 0.45rem;
  border-radius: 4px;
  background: var(--go-soft);
  color: var(--go-ink);
  font-size: 0.65rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* align-items: center, not stretch. Both children already carry --control-h, so
   stretch bought nothing — and it actively propagated any stray margin on one
   child into the height of the other. */
.config-save-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  max-width: 30rem;
}

.config-save-row input[type="text"] { flex: 1; min-width: 0; }

/* No padding or font-size override: this is a primary action and must look
   exactly like Save PDF. The two used to differ, which is the whole reason
   there is a rule about it in docs/design-system.md. */
.config-save-row button { flex: 0 0 auto; }

/* No explicit width: with table-layout:fixed the actions column is pinned and
   this takes whatever is left. A `width: 100%` here would fight that. */
.config-name-cell { font-weight: 700; }
.config-name-cell input[type="text"] { width: 100%; font-weight: 400; }

/* Below 55rem the page flows and scrolls normally — small screens don't have
   room to spare on a fixed shell. At 55rem and up, the whole app becomes a
   fixed-viewport shell instead: the preview must stop growing/shrinking with
   whatever PDF or zoom level happens to be loaded, so only the two panels
   scroll internally, never the page itself. Placed last in the file so these
   overrides win the cascade against the equal-specificity base rules above,
   regardless of source order elsewhere. */
@media (min-width: 55rem) {
  /* The fixed-viewport shell. This was split between `body` and `main` until
     <header> and <footer> became siblings of <main> so they could be landmarks
     — the flex column moved up here with them. .layout IS <main> now and takes
     flex: 1 below. */
  body {
    max-width: none;
    padding: 0;
    height: 100svh;
    overflow: hidden;
    display: flex;
    flex-direction: column;
  }

  /* Top bar and footer are fixed-height bookends; only .layout flexes. Their
     heights are tokens (--topbar-h / --footer-h) because the tool panel's
     "everything fits without scrolling" budget is computed against them. */
  .topbar {
    flex: 0 0 auto;
    height: var(--topbar-h);
    margin: 0;
    padding: 0 1.25rem;
    flex-wrap: nowrap;
    border-bottom: 1px solid var(--border);
  }

  /* Keeps the dark band from the base rules — only the box model changes here.
     Re-declaring `background` would have quietly undone it. */
  .appfooter {
    flex: 0 0 auto;
    height: var(--footer-h);
    margin: 0;
    padding: 0 1.25rem;
  }


  .layout {
    flex: 1;
    /* Roughly a one-third/two-thirds split; minmax keeps the controls usable
       at the narrower end of "desktop" rather than enforcing a strict fraction. */
    /* The controls column GROWS TO A CEILING and then stops; everything past it
       goes to the preview. The old `minmax(20rem, 1fr) 2fr` gave the panel a
       third of whatever it was handed, so at 2560px it was 853px of card
       holding a column of ~100px number fields — the panel does not get better
       past a point, it just gets emptier, while the preview genuinely uses
       every pixel. 31.5rem is the width the four-column control grid was laid
       out against; 21rem is the floor for the band just above the 55rem
       breakpoint. */
    grid-template-columns: clamp(21rem, 38vw, 31.5rem) minmax(0, 1fr);
    /* The single implicit row defaults to `auto`, which grows to fit its
       tallest child — so a big (zoomed) canvas would stretch the row and, with
       it, the whole preview area. minmax(0, 1fr) pins the row to the available
       height instead, forcing the panels to scroll internally. This is the
       rule that actually makes the preview a fixed viewport. */
    grid-template-rows: minmax(0, 1fr);
    gap: 0;
    align-items: stretch;
    min-height: 0; /* let the grid's own height govern children instead of their content */
  }

  /* The panel itself never scrolls — it is a fixed-height flex column whose
     middle region (#tool-panels) is the only thing allowed to overflow. That is what
     keeps the file bar pinned to the top and Placement/Configs/Download pinned
     to the bottom without position:sticky. */
  #controls-panel {
    height: 100%;
    min-height: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    padding: 0 var(--space-4) var(--space-3);
    border-right: 1px solid var(--border);
  }

  #controls-panel > .file-card { margin-top: var(--space-3); }

  .preview-panel {
    position: relative; /* the pill and hint are absolutely positioned to this */
    display: flex;
    flex-direction: column;
    height: 100%;
    min-height: 0;
    overflow: hidden; /* nothing here scrolls except preview-wrap itself */
    background: var(--surface-sunken);
  }

  .preview-wrap {
    flex: 1;
    height: auto;  /* override the base fixed height; flex governs it here */
    min-height: 0; /* let it actually fill/scroll instead of growing to fit the canvas */
    margin: 0;
    border: none;
    border-radius: 0;
    background: transparent;
    /* Extra headroom clears the pill, which is pinned to the top of the panel —
       without it the first page starts underneath the controls. */
    padding: 3.5rem 1.25rem 1.25rem;
  }
}

/* ==========================================================================
   Reduced motion
   Last in the file so it beats every animation and transition above it,
   whatever their specificity. The app's motion is decorative throughout — a
   120ms modal fade, a caret rotation, a grip cursor — so there is nothing here
   that carries meaning a user would lose by switching it off.
   `0.01ms` rather than `none`: animations still run and still fire their end
   events, they just finish immediately.
   ========================================================================== */
@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;
  }
}
