/* PREPRINT — core.
 *
 * What every surface gets, read or used. Loaded after `styles.css` (the
 * tokens) and before exactly one surface sheet:
 *
 *   <link rel="stylesheet" href="…/preprint/styles.css">    tokens
 *   <link rel="stylesheet" href="…/preprint/core.css">      this file
 *   <link rel="stylesheet" href="…/preprint/reading.css">   OR app.css
 *   <link rel="stylesheet" href="…/your-site.css">          the surface's own
 *
 * Nothing here reads a colour, a face or a control height that is not a token.
 *
 * Core is deliberately small. A rule earns a place here only when both a
 * reading page and an app want it in the same form. Where the two surfaces
 * want the same NAME in different forms — `.note`, `.rail`, `.head__row` —
 * the name is left to the surface sheets on purpose, because this system is
 * for pages that are read and apps that are used, and those are not the same
 * object wearing two hats.
 *
 * `.btn` was on that list and has come off it, in one direction only. A
 * button's SIZE, face, tracking and case are still the surface's and always
 * will be. What is shared is its ANATOMY — a mark, a label, and the gap
 * between them — and what it does when it runs out of room, because a control
 * that drops its label under pressure is a mechanism rather than a look, and
 * both surfaces had written it separately.
 */

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

body { background: var(--pp-paper); }

/* The system underlines every link. Most links on a surface are structural — a
   row, a button, an icon — so the underline is opted into, not out of. The
   colour is the surface's call: a reading page inherits it, an app draws links
   in plate 3. */
a { border-bottom: 0; }

button { background: transparent; border: 0; font: inherit; color: inherit; }

:focus-visible { outline: 3px solid var(--pp-selection); outline-offset: 1px; }

/* Said to a screen reader and to nobody else. Here rather than in a surface
   sheet because a part whose markup carries a hidden span cannot be shared
   unless the hiding is: the mode switch below is exactly that, and the three
   surfaces that had written it had also written this, under three names. */
.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* An ink square: the system's punctuation for a button label. Surfaces resize
   it in context (a compact button takes .4rem, a ghost .35rem) but never
   restyle it. */
.sq { width: .45rem; height: .45rem; background: currentColor; flex: none; display: block; }
.sq--r { background: var(--pp-plate-2); }

/* ── type utilities ─────────────────────────────────────────────────────── */
.quiet { font-size: .7rem; color: var(--pp-faint); }
.lede { font-size: .95rem; line-height: 1.6; color: var(--pp-muted); max-width: 72ch; }
/* The label above a thing. Surfaces add their own layout and colour: a reading
   page sets it inline-flex beside a square, an app paints it faint. */
.eyebrow { font-size: .68rem; letter-spacing: .2em; text-transform: uppercase; }

/* ── the marker ─────────────────────────────────────────────────────────────
   The second ink family. It is here rather than on the reading surface because
   invariants.md calls the three markers identity, in the same breath as the
   plates and the ground — and an identity only one of the two surfaces can
   reach is not an identity. An app that wanted the annotated half of "printed
   first, then annotated" was left copying four lines out of a 300-line sheet
   it could not link, which is how the workshop ended up writing
   `background: var(--pp-marker-citron)` by hand.

   A marker is always a field behind type and never type itself. Citron as text
   on eggshell is 1.6:1; as a field behind ink, 14:1. Same colour, opposite
   outcome — which is why the ink above one never changes.

   `.mkmenu` did not come down with it. Raising a menu to mark up a selection
   is a reading page's job, and that sheet keeps it. */
.mk, [data-pp-mark] { color: inherit; padding: .04em 0; }
.mk--citron, [data-pp-mark='citron'] { background: var(--pp-marker-citron); }
.mk--pink, [data-pp-mark='pink'] { background: var(--pp-marker-pink); }
.mk--cyan, [data-pp-mark='cyan'] { background: var(--pp-marker-cyan); }

/* ── the mark ───────────────────────────────────────────────────────────────
   Three offset plate squares, multiplied. This is the whole mark. The system
   does not have a logo and does not draw one. */
.mark { position: relative; display: block; width: 22px; height: 22px; flex: none; }
.mark i { position: absolute; width: 14px; height: 14px; mix-blend-mode: multiply; }
.mark i:nth-child(1) { left: 0; top: 0; background: var(--pp-plate-1); }
.mark i:nth-child(2) { left: 8px; top: 2px; background: var(--pp-plate-2); }
.mark i:nth-child(3) { left: 4px; top: 8px; background: var(--pp-plate-3); }
/* Multiply darkens toward the ground, which on a dark ground erases the mark.
   Screen is the same gesture the other way up. */
[data-mode='dark'] .mark i { mix-blend-mode: screen; }

/* ── the button ─────────────────────────────────────────────────────────────
   Anatomy, and nothing else. A button is a row of at most three things: a
   mark, a label, and a collapsed mark that is not shown yet. No height, no
   padding, no face, no type — every one of those is the surface's, because a
   door on a masthead and a Save in a console are not the same size object.

   Put the height on a rung of the ladder (--pp-target and its neighbours in
   tokens/rules.css) and the ladder does the rest. */
.btn { display: inline-flex; align-items: center; justify-content: center; }
.btn__lbl { display: block; }
.btn__mark { display: none; }

/* ── the ink treatment ──────────────────────────────────────────────────────
   The one button FACE both surfaces agree on: a filled block of edge with
   paper on top. */
.btn--ink {
  background: var(--pp-edge); color: var(--pp-paper);
  border: var(--pp-rule-structure) solid var(--pp-edge);
}

/* ── the collapse ───────────────────────────────────────────────────────────
   A button that opts in keeps its mark and drops its label when the screen is
   a phone, and becomes a square on the floor rung. This is a mechanism and not
   a look: WHICH buttons opt in, and what the mark is, are the surface's.

   The mark replaces the label rather than joining it, so the accessible name
   has to come from somewhere that display:none cannot reach. A collapsing
   button carries an aria-label. That is a requirement, not a suggestion: the
   `.sq` punctuation goes with the label, and a button whose entire content is
   one aria-hidden letter announces as nothing at all.

   560px and not the ladder's 820: an iPad in portrait has room for the words,
   and a control that hides its label while a whole label's worth of space sits
   beside it reads as a bug rather than as a fit.

   `.btn.btn--collapse` and not `.btn--collapse`, deliberately. A surface sets
   the padding on `.btn` and a surface sheet loads after this one, so at equal
   specificity the surface's horizontal padding survives the collapse and the
   square comes out an oblong — measured at 44x49 before this doubled up. The
   surface opted in by adding the class, and what it opted into is a square, so
   the mechanism outranks the base. Everything it wins is geometry the collapse
   is defined by; the face is not touched and stays the surface's. */
@media (max-width: 560px) {
  .btn.btn--collapse {
    width: var(--pp-target); min-width: var(--pp-target);
    aspect-ratio: 1; padding: 0;
  }
  .btn--collapse .sq,
  .btn--collapse .btn__lbl { display: none; }
  .btn--collapse .btn__mark { display: block; line-height: 1; }
}

/* ── the mode switch ────────────────────────────────────────────────────────
   The one control that appears in the corner of every page in the system, so
   it is the one that cannot be left to the surfaces. It had been written three
   times: a reading page's, a console rail's small square and a word, and a
   workshop corner's bare swatch. Three corners is how a family stops looking
   related, and a part used three times is not a breach, it is a part.

   A rectangle three quarters full of ink, with the paper showing in the
   quarter left over. Dark inverts both colours and drops the ink to the
   bottom, so the control is a swatch of the mode you are IN, not a picture of
   the mode it would give you. That is the question a lone sun or moon can
   never answer, and drawing both answers takes two icons to say what one
   filled rectangle says by itself.

   CSS fills it from `data-mode`, which `js/mode.js` writes before the first
   paint, so the switch is right on the first frame and never flashes through a
   neutral state. The script owns the click and the accessible name. It draws
   nothing.

   The markup, and all of it:

     <button class="modeswitch" type="button" data-mode-toggle>
       <span class="modeswitch__box" aria-hidden="true">
         <span class="modeswitch__fill"></span></span>
       <span class="modeswitch__label" aria-hidden="true"></span>
       <span class="sr">Switch between light and dark mode</span>
     </button>

   No `[data-mode-label]`, deliberately, though the script offers it: that hook
   is filled at DOMContentLoaded, which swaps the word in front of the reader
   on every dark load. The word is CSS, for the same reason the fill is. */
.modeswitch {
  display: inline-flex; align-items: center; gap: .55rem;
  height: var(--pp-target); flex: none; padding: 0;
}

/* The button is the hit box and the box inside it is the drawing. They were
   one element once, which made the smallest target in the system the control a
   reader on a phone is most likely to want: 26x34 against a floor of 44.
   Growing the rectangle was not an option (it is a swatch, and a swatch reads
   as a swatch at swatch size), so the target grew around it and nothing
   visible moved. The overflow that clips the sliding block has to travel with
   the drawing, which is why this is a wrapper rather than padding on the
   button: padding would have been clipped away with it.

   No radius, and that is not an oversight. This is not a shape on the cut
   scale, it is a swatch of the two grounds, and a corner would make it a
   button drawn around an idea instead of the idea itself. */
.modeswitch__box {
  display: flex; flex-direction: column;
  width: 26px; height: 34px;
  border: var(--pp-rule-line) solid var(--pp-line);
  background: var(--pp-paper); overflow: hidden;
}
.modeswitch:hover .modeswitch__box { border-color: var(--pp-edge); }

/* The word, and the only part of the control that someone who has never seen
   it can read. It names the DESTINATION, which is what the script's
   `aria-label` and `[data-tip]` name too, so the three never disagree: in
   light it says `dark mode`, and pressing it gets you one.

   A swatch with no word is legible only on a surface that draws its own
   tooltips, because the system sets no `title` on purpose. Most surfaces do
   not, so the word is on the page rather than behind a hover.

   Uppercase because every other mono label in the system is. Set lowercase it
   was the one word on the surface breaking the convention, and it read as a
   caption rather than as a control. */
.modeswitch__label {
  font-family: var(--pp-font-mono); font-size: var(--pp-size-label);
  letter-spacing: var(--pp-track-label); text-transform: uppercase;
  color: var(--pp-muted); white-space: nowrap;
}
.modeswitch__label::after { content: 'dark mode'; }
[data-mode='dark'] .modeswitch__label::after { content: 'light mode'; }
.modeswitch:hover .modeswitch__label { color: var(--pp-ink); }

/* The block slides and does nothing else. It had a squash on the way, which
   read as bounce and made a two-position control look springy. One property
   changes: the block's vertical offset. `translate` rather than `transform`
   because it is the narrower statement and nothing else needs to compose.

   An ordinary flex child moved by `translate`, NOT an absolutely positioned
   one. A <button> wraps its children in an anonymous box that is not a
   containing block the way `position: relative` on the button implies, so an
   absolute child silently lands on its static position and `top` does nothing.
   Measured, not guessed. Moving a block that is 75% tall by a third of its own
   height slides it exactly the leftover quarter, on the compositor. */
.modeswitch__fill {
  height: 75%; flex: none; background: var(--pp-ink);
  transition: translate var(--pp-dur-state) var(--pp-ease-out);
}
[data-mode='dark'] .modeswitch__fill { translate: 0 33.3333%; }

/* It arrives instead of sliding. Nothing else changes. */
@media (prefers-reduced-motion: reduce) {
  .modeswitch__fill { transition: none; }
}

/* ── the pull cord ──────────────────────────────────────────────────────────
   The second mode control, and a different answer to the same question. The
   swatch above SHOWS the mode you are in and says nothing about how it got
   there. The cord shows nothing and is entirely about the getting there: a
   1px line hanging off the top edge with a bead on the end, pulled once, and
   the light in the room changes. It is the switch you already know how to
   use, and the only control in the system whose whole meaning is the gesture.

   Both are shipped because they suit different surfaces. A console with a
   rail of controls wants the swatch, which sits in a row and stays quiet. A
   page whose top edge is mostly empty can afford a cord, which does not sit
   in a row at all — it hangs off the edge and belongs to the room rather
   than to the toolbar. A surface picks one. Two mode controls on one page
   is drift, not choice.

   It takes two gestures. Click it and it plays a short scripted pull. Or take
   hold of the bead and PULL: the cord follows the hand, and the mode changes
   when the detent trips at 42px of pull, not when you touch it. Short of that
   you can let go and nothing happens, which is the reason a threshold is worth
   having: the gesture stays abandonable until it lands.

   The pull is not confined to the vertical. The cord hangs from a fixed point
   and follows the hand in whatever direction the hand goes, swinging as it
   lengthens, because that is what a cord on a ceiling does and a control that
   only answered straight down would be a slider drawn as a string. It resists
   as it goes, harder the further out you are, and it comes home on release.
   Two numbers say all of that: `--pp-cord-h`, the length, and `--pp-cord-a`,
   the angle off vertical. Both are the script's to write, and everything below
   is those two numbers being drawn.

   The markup, and all of it:

     <button class="pullcord" type="button" data-pullcord>
       <span class="pullcord__cord" aria-hidden="true"></span>
       <span class="pullcord__bead" aria-hidden="true"></span>
     </button>

   `js/pullcord.js` owns both gestures, the phases and the accessible name; it
   calls `js/mode.js` to do the actual flip, so there is still exactly one
   file that knows what dark mode is. Without the script the button is inert
   — deliberately, because a cord that flips with no pull is a worse control
   than the swatch and there is no reason to ship it as a fallback. */

/* The button is the hit area and nothing else: 30x132, which is a comfortable
   thumb around a 1px line and a 7px bead. z-index because a header's own
   content is laid out after it and would otherwise take the clicks — this is
   the one number here that is load-bearing rather than aesthetic.

   `position: absolute`, so the surface must give it a positioned ancestor.
   That ancestor is the thing the cord hangs FROM, and it should be the
   element whose top edge is the top of the page. Hang it off a header with
   padding above it and the cord starts in mid-air.

   The box is where a pull STARTS, not where it happens: the gesture is 42px
   and the script takes a pointer capture, so the hand is expected to leave
   here and the cord to follow it out, downward and to either side.

   How far to either side is not a number in this file, because it depends on
   how much page there is next to wherever the surface hung the cord. The script
   measures that at the moment of the press and never lets the bead reach the
   edge, which is also what keeps a hard sideways pull from growing the document
   and flashing a horizontal scrollbar for the length of the gesture.

   `touch-action: none` is what makes the pull possible on a phone at all. The
   gesture starts as a drag downward and so does a scroll, and without this the
   browser claims it, cancels the pointer stream and the cord dies in the
   reader's hand. It has to be `none` rather than `pan-x`, because the gesture
   goes sideways too and a browser given either axis takes the whole stream.
   `user-select: none` for the same reason on a desktop, where the drag would
   otherwise select the header's text behind it. */
.pullcord {
  --pp-cord-h: 72px;
  --pp-cord-a: 0deg;
  --pp-cord-swing: 0deg;
  position: absolute; z-index: 5;
  top: 0; right: var(--pp-cord-inset, 48px);
  width: 30px; height: 132px;
  padding: 0; border: 0; background: transparent;
  cursor: var(--pp-cursor-point);
  touch-action: none;
  -webkit-user-select: none; user-select: none;
}


/* Held. The transitions come off so the cord tracks the hand exactly: a
   transition here is not smoothing, it is lag between the bead and the finger
   holding it, and 130ms of it makes the string feel like elastic.

   The script writes `--pp-cord-h` and `--pp-cord-a` inline while this is on,
   which beats the phase rules below without either knowing about the other. */
.pullcord[data-dragging] { cursor: var(--pp-cursor-grab); }
.pullcord[data-dragging] .pullcord__cord,
.pullcord[data-dragging] .pullcord__bead { transition: none; }

/* Cord and bead are two elements reading ONE length and ONE angle. The cord
   spends the length on height and the bead on a translate along its own
   rotated axis, so there is no arrangement of dropped frames or mismatched
   easing in which the bead comes off the end of the string. That was the
   failure worth designing against: everything else here is taste, and a
   detached bead is a bug the eye catches instantly.

   Both rotate about the SAME point, (14.5px, 0), which is the middle of the
   1px line at the very top of the box. That point is the screw in the ceiling.
   The cord grows and swings, and its top never moves, because a `top` that
   animated would say it was hanging from nothing. */
.pullcord__cord {
  position: absolute; top: 0; left: 14px;
  width: 1px; height: var(--pp-cord-h);
  background: var(--pp-ink);
  transform-origin: 50% 0;
  transform: rotate(var(--pp-cord-a));
}

/* A capsule, not a circle. A knurled pull is longer than it is wide and that
   is most of what makes it read as something to grip rather than as a dot
   somebody left on the page. The radius is the cut scale's own 4px, which at
   this width is simply half of it — the shape is a capsule because of its
   proportions, so law 01 goes unspent.

   It sits at `top: 0` and is carried down the cord by the translate, not by an
   offset, so one rotation about the shared origin turns the capsule to lie
   along the string AND puts it at the end of it. Offsetting first and rotating
   after would need the angle twice and get it wrong once.

   The -1px on the translate is the seam: it is there so no gap can open
   between the line and the bead on a fractional device pixel. */
.pullcord__bead {
  position: absolute; left: 11px; top: 0;
  width: 7px; height: 11px;
  border-radius: var(--pp-radius-lg);
  background: var(--pp-ink);
  transform-origin: 50% 0;
  transform: rotate(var(--pp-cord-a)) translateY(calc(var(--pp-cord-h) - 1px));
}

/* Four phases, and the timing on each governs the move INTO it. Total travel
   is 13px down and 1px past rest on the way back. That is small on purpose:
   the pull was 40px at one point and the page read as hectic, because a cord
   with real slack in it is a cord you are yanking rather than clicking. What
   sells the mechanism is the overshoot past rest, not the distance.

   The script drives `data-phase` on a fixed clock and flips the mode on the
   way into phase 2, which is the release — the moment the detent lets go is
   the moment the light changes, and putting the flip at the click instead
   makes the cord look like it is reacting to the room rather than the room
   to it.

   rest and settle are the same length, so 3 → 0 is a reset with nothing to
   animate. It exists so the next pull starts from phase 0's easing.

   A dragged pull comes home through the same two beats, and this is where the
   swing comes back with it. `--pp-cord-swing` is written inline at the moment
   of release and is a small FRACTION of the angle the hand let go at, negated:
   phase 2 carries the cord a little past vertical the other way, phase 3 brings
   it to rest. Two beats is a pendulum, and a pendulum is all the eye needs to
   read weight on the end of a string. Three would be a physics engine and a
   second timing language to keep in step with the click's.

   It is one variable rather than a spring per axis because the recoil and the
   counter-swing are the same event: the cord passing rest on its way to
   stopping. Its rest value is declared above with the other two rather than
   left to a `var()` fallback, so all three numbers the cord runs on are visible
   in one place and none of them is a handshake you have to read the script to
   find. A click never writes it, so phase 2 there is the pure recoil it always
   was. */
.pullcord__cord { transition: height 220ms cubic-bezier(.33, 0, .5, 1), transform 220ms cubic-bezier(.33, 0, .5, 1); }
.pullcord__bead { transition: transform 220ms cubic-bezier(.33, 0, .5, 1); }

.pullcord[data-phase='1'] { --pp-cord-h: 85px; }
.pullcord[data-phase='1'] .pullcord__cord { transition-duration: 130ms; transition-timing-function: cubic-bezier(.3, .6, .35, 1); }
.pullcord[data-phase='1'] .pullcord__bead { transition-duration: 130ms; transition-timing-function: cubic-bezier(.3, .6, .35, 1); }

.pullcord[data-phase='2'] { --pp-cord-h: 71px; --pp-cord-a: var(--pp-cord-swing); }
.pullcord[data-phase='2'] .pullcord__cord { transition-duration: 200ms; transition-timing-function: cubic-bezier(.25, .8, .3, 1); }
.pullcord[data-phase='2'] .pullcord__bead { transition-duration: 200ms; transition-timing-function: cubic-bezier(.25, .8, .3, 1); }

.pullcord[data-phase='3'] { --pp-cord-h: 72px; --pp-cord-a: 0deg; }
.pullcord[data-phase='3'] .pullcord__cord { transition-duration: 160ms; transition-timing-function: cubic-bezier(.4, 0, .5, 1); }
.pullcord[data-phase='3'] .pullcord__bead { transition-duration: 160ms; transition-timing-function: cubic-bezier(.4, 0, .5, 1); }

/* The cord does not move and the mode still flips. The script skips the
   phases entirely under this query, so the length never changes and there is
   nothing for a transition to catch. */
@media (prefers-reduced-motion: reduce) {
  .pullcord__cord, .pullcord__bead { transition: none; }
}

/* The room dimming, rather than the page reloading. Only on a surface that
   carries the cord: the flip is the cord's payoff and it wants watching, and
   a surface that flips from a swatch in a rail has not asked for half a
   second of its colours moving. `--pp-dur-mode` is 0 under reduced motion,
   which turns this back into the instant swap without a second rule. */
body:has(.pullcord) {
  transition: background-color var(--pp-dur-mode) ease, color var(--pp-dur-mode) ease;
}

/* ── the scrawl ─────────────────────────────────────────────────────────────
   A hand annotation that points at a control and says what it is. The arrow is
   the surface's SVG, because what it points at and from where is a drawing
   decision; what the system owns is the two arrangements, and which one is on.

   STACKED is the default, and that is the whole point of the part. The
   arrangement that works at any width is the one that should need no media
   query to reach, so a surface that writes nothing gets the one that cannot
   break. BESIDE is the enhancement: the text hung out to the side of the arrow
   on one nowrap line, which is the drawn look and needs real room to the left
   of the control to hold it.

   60rem is where that room exists: a full measure plus two gutters. Below it
   the text has nowhere to go but sideways into whatever the control sits next
   to, and a nowrap line does not reflow, it slides.

   No rotation here. Law 06 is a per-page budget and the system cannot know
   what else on the surface is spending it, so the tilt is the surface's to
   add. The face is not: an annotation is display type in every surface that
   has one, and which of the three faces does which job is not a local call.

   BOTH arrangements sit above the control and neither is in the flow. That is
   the part that makes this a annotation rather than a heading: it contributes
   no height, it takes no width from the row the control is in, and it is
   allowed to overlap whatever is above it. A scrawl that participates in
   layout is not cheap to add, and it was not: laid out in flow it made its
   parent as wide as the sentence, which pushed the control onto its own line
   and moved the very thing the arrow was pointing at.

   What differs is where the words sit relative to the arrow. STACKED, they are
   in the same line box and the arrow follows the sentence, because an
   annotation that points is read end-first and an arrow parked under the first
   word while the sentence runs on past it is a caption and a stray mark, not
   one gesture. BESIDE, the words leave the line box and hang off the side on
   one nowrap line, which is the drawn look and needs real room to hold.

   Which DIRECTION the arrow points, and where the block is anchored, are the
   surface's: they depend on where the control sits. */
.scrawl {
  position: absolute; bottom: 100%; pointer-events: none;
  font-family: var(--pp-font-display); font-weight: 400;
}
.scrawl__t { display: inline; }
.scrawl svg { display: inline-block; }

@media (min-width: 60rem) {
  .scrawl--beside { line-height: 0; }
  .scrawl--beside .scrawl__t {
    display: block;
    position: absolute; right: 100%; bottom: .55rem; margin-right: .15rem;
    white-space: nowrap; line-height: 1.2;
  }
  .scrawl--beside svg { display: block; }
}

/* ── overlays ───────────────────────────────────────────────────────────────
   A scrim, and a panel standing on it. Both surfaces want this in the same
   form: a reading page raising a plate to full size, an app raising a confirm
   or a settings sheet. The scrim moved here from app.css, where it could only
   be raised by an app. `.palette` and `.menu` stay there, because a command
   palette and a dropped menu are things an app has, and the ground they stand
   on is not.

   Law 04 licenses one hard cast per view for something temporarily on top of
   the work rather than part of it, which is exactly this, and one overlay open
   at a time holds the budget at one. Corners stay square, as the palette's and
   the menu's do: a panel is a region you are inside, not a thing you touch, so
   law 01 goes unspent here as well.

     <div class="modal" hidden>
       <div class="scrim"></div>
       <div class="modal__panel">
         <div class="modal__bar">…</div>
         <div class="modal__body">…</div>
       </div>
     </div>

   The bar is a row and the body is what scrolls. Neither states a type size,
   because what a modal is showing is the consumer's business.

   Behaviour is the consumer's too, as it already is for the palette: Escape to
   close, focus onto the panel when it opens and back onto the trigger when it
   shuts, and `inert` on the page behind, so that tabbing cannot wander under a
   thing that is covering it. */
.scrim { position: fixed; inset: 0; z-index: 40; background: rgba(0, 0, 0, .28); }

.modal {
  position: fixed; inset: 0; z-index: 45; display: flex;
  align-items: center; justify-content: center; padding: var(--pp-space-3);
}
/* The UA rule for [hidden] is display:none at the weakest specificity there
   is, so the display above would defeat it and every modal would be open on
   load. Said here rather than left to the surface, because core is the one
   file a page cannot skip. */
.modal[hidden] { display: none; }

/* The scrim carries a z-index and the panel stands on it, so the panel has to
   name one too: source order does not settle it, and a panel left on auto is
   painted over by the very thing it is supposed to be standing on. 45 over 40
   is the ladder the palette already uses. */
.modal__panel {
  position: relative; z-index: 45; display: flex; flex-direction: column;
  gap: var(--pp-space-2); max-width: 100%; max-height: 100%;
  padding: var(--pp-space-2); background: var(--pp-surface);
  border: var(--pp-rule-structure) solid var(--pp-edge);
  box-shadow: 5px 5px 0 var(--pp-shadow);
}
.modal__bar { display: flex; align-items: center; gap: var(--pp-space-2); }
/* min-height on a flex child is auto, which refuses to shrink below its
   content and would push the panel past the bottom of the window instead of
   scrolling inside it. */
.modal__body { min-height: 0; overflow: auto; }

/* ── the toast ──────────────────────────────────────────────────────────────
   Says what just happened and leaves. Identical on both surfaces, so it lives
   here whole. */
.toast {
  position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
  z-index: 60; padding: .5rem .9rem; background: var(--pp-ink);
  color: var(--pp-paper); font-family: var(--pp-font-mono); font-size: .72rem;
  letter-spacing: .06em;
}
.toast.is-bad { background: var(--pp-plate-2); color: #fff; }
