/* atelier.css — the craft layer. Loaded AFTER engine.css, BEFORE the skin.

   This is the layer that makes a demo read as commissioned rather than configured. It ships
   four bundles — paper, editorial type, choreography and keepsake objects — as generic,
   opt-in utilities. It knows nothing about any site: every colour, distance and duration
   comes from a token the skin already defines, plus the small set of atelier tokens below
   (all of which have engine defaults, so a skin that sets none still renders).

   Contract, same as engine.css: no site-specific selectors, no colour literals except where
   a value is genuinely achromatic (paper highlight, shadow). A skin composes these classes
   onto its own elements — it never edits this file.

   Switches, on <html>:
     data-atelier="paper"     grain + letterpress ink on display type
     data-atelier="motion"    scroll-linked choreography (progressive; see the @supports)
   Both may be listed together: data-atelier="paper motion".
*/

:root {
  --a-grain-opacity: .5;
  --a-grain-size: 120px;
  --a-press-high: rgba(255, 255, 255, .85);   /* the sheet catching light above the letter */
  --a-press-low: rgba(0, 0, 0, .13);          /* the ink pressed into it */
  --a-deckle: 10px;                           /* nibble diameter on a torn edge */
  --a-tilt: 1.6deg;                           /* pinboard rotation */
  --a-corner: 18px;                           /* photo-corner size */
  --a-lift: 18px;                             /* how far a revealed block travels */
  --a-shadow: 0 14px 28px -22px rgba(0, 0, 0, .6);
  --a-shadow-deep: 0 16px 30px -22px rgba(0, 0, 0, .7);
  --a-seal-size: 92px;
  --a-bleed-h: min(78vh, 40rem);              /* a full-bleed interlude's height */
  --a-scrim: rgba(20, 15, 11, .52);           /* the light end of a scrim on a photograph */
  --a-scrim-strong: rgba(20, 15, 11, .86);
  --a-scrim-ink: #fff;
  --a-drift: 1.075;                           /* how far a big frame drifts across its scroll */
  --on-accent: #fff;                          /* ink that sits ON --accent; dark skins override */
}

/* ============================================================ A · paper ============= */

/* Grain is one generated SVG tile: no licence, no attribution, ~2 KB, recolours with the
   sheet because it multiplies. Fixed attachment so it does not swim while the page scrolls. */
[data-atelier~="paper"] body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 60;   /* over the page, under the lightbox */
  pointer-events: none;
  opacity: var(--a-grain-opacity);
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='.32'/%3E%3C/svg%3E");
  background-size: var(--a-grain-size) var(--a-grain-size);
}
/* A dark sheet takes ink by screening, not multiplying. */
[data-atelier~="paper"][data-atelier-sheet="dark"] body::after { mix-blend-mode: screen; opacity: calc(var(--a-grain-opacity) * .5); }

/* Letterpress: a highlight above the letter, ink below it. Two shadows, no filter. */
.a-press { text-shadow: 0 1px 0 var(--a-press-high), 0 -1px 0 var(--a-press-low); }

/* An embossed rule instead of a border: a groove and the sheet rising beside it. */
.a-rule {
  height: 3px;
  border: 0;
  background: linear-gradient(transparent 0 1px, var(--stone-deep, currentColor) 1px 2px, var(--a-press-high) 2px 3px);
}

/* Deckled edge — EDGE ONLY. Three unioned mask layers: a solid middle band, plus a row of
   half-circle nibbles centred exactly on the top and bottom edges, so the paper tears
   instead of perforating. Works at any width, any colour, no image. */
.a-deckle {
  --a-nib-top: radial-gradient(circle at calc(var(--a-deckle) / 2) 0, transparent calc(var(--a-deckle) * .42), #000 calc(var(--a-deckle) * .46));
  --a-nib-bot: radial-gradient(circle at calc(var(--a-deckle) / 2) var(--a-deckle), transparent calc(var(--a-deckle) * .42), #000 calc(var(--a-deckle) * .46));
  --a-fill: linear-gradient(#000, #000);
  -webkit-mask-image: var(--a-nib-top), var(--a-nib-bot), var(--a-fill);
  mask-image: var(--a-nib-top), var(--a-nib-bot), var(--a-fill);
  -webkit-mask-size: var(--a-deckle) var(--a-deckle), var(--a-deckle) var(--a-deckle), 100% calc(100% - var(--a-deckle) * 2);
  mask-size: var(--a-deckle) var(--a-deckle), var(--a-deckle) var(--a-deckle), 100% calc(100% - var(--a-deckle) * 2);
  -webkit-mask-repeat: repeat-x, repeat-x, no-repeat;
  mask-repeat: repeat-x, repeat-x, no-repeat;
  -webkit-mask-position: top left, bottom left, center;
  mask-position: top left, bottom left, center;
}

/* Wax seal. The glyph is content the skin authors (initials, a crest, a kanji).
   The wax itself is one custom property so the arrival's two broken halves can be poured
   from exactly the same recipe as the intact disc — and so a skin can swap the whole thing
   for a photograph by setting --a-seal-img. */
.a-seal {
  --a-wax:
    radial-gradient(42% 38% at 33% 26%, rgba(255, 255, 255, .34), transparent 72%),
    radial-gradient(80% 80% at 74% 82%, rgba(0, 0, 0, .48), transparent 66%),
    radial-gradient(126% 126% at 38% 30%, var(--accent-lit, var(--accent)) 0%, var(--accent) 54%, var(--a-wax-deep, rgba(58, 8, 18, .96)) 100%);
  position: relative;
  display: grid;
  place-items: center;
  width: var(--a-seal-size);
  height: var(--a-seal-size);
  /* Wax squeezes out unevenly. A perfect circle is the tell of a CSS circle. */
  border-radius: var(--a-seal-round, 49% 51% 52% 48% / 51% 47% 53% 49%);
  background-color: var(--accent);
  background-image: var(--a-seal-img, var(--a-wax));
  background-size: cover;
  background-position: center;
  color: var(--on-accent);
  font-family: var(--display);
  font-size: calc(var(--a-seal-size) * .28);
  letter-spacing: .06em;
  text-shadow: 0 1px 0 rgba(255, 255, 255, .18), 0 -1px 1px rgba(0, 0, 0, .5);
  box-shadow:
    var(--a-shadow),
    inset 0 0 0 2px rgba(255, 255, 255, .10),
    inset 0 2px 3px rgba(255, 255, 255, .30),
    inset 0 -4px 8px rgba(0, 0, 0, .42);
  transition: transform 260ms var(--ease, ease), box-shadow 260ms var(--ease, ease);
}
/* The pressed rim: wax pushed out past the stamp. */
.a-seal::after {
  content: "";
  position: absolute;
  inset: calc(var(--a-seal-size) * .085);
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .22), inset 0 1px 0 rgba(255, 255, 255, .16);
  pointer-events: none;
}
.a-seal:hover { transform: translateY(-2px) rotate(-2deg); box-shadow: var(--a-shadow-deep), inset 0 2px 3px rgba(255, 255, 255, .32), inset 0 -4px 8px rgba(0, 0, 0, .42); }

/* ============================================================ B · editorial ========= */

p.a-dropcap::first-letter,
.a-dropcap > p:first-of-type::first-letter {
  float: left;
  font-family: var(--display);
  font-size: 3.9em;
  line-height: .8;
  padding: .06em .12em 0 0;
  color: var(--accent);
}
.a-hang { hanging-punctuation: first last; text-wrap: pretty; }
.a-sc { font-variant-caps: all-small-caps; letter-spacing: .09em; }
.a-onum { font-variant-numeric: oldstyle-nums; font-feature-settings: "onum" 1; }
.a-lnum { font-variant-numeric: lining-nums tabular-nums; }

/* Running head + folio: the line that says a page was set, not filled in. */
.a-folio {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: .8rem;
  padding-bottom: .4rem;
  border-bottom: var(--rule-w, 1px) solid var(--rule);
  font: 600 12px/1 var(--ui);
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Marginalia: an aside in the outer column, folded under the text on small screens. */
.a-withmargin { display: grid; gap: var(--gap); }
.a-marginalia {
  border-top: 2px solid var(--accent);
  padding-top: .45rem;
  font: italic 13px/1.5 var(--body);
  color: var(--muted);
}
@media (min-width: 48em) {
  .a-withmargin { grid-template-columns: minmax(0, 1fr) 11rem; }
}

/* Museum matting: the image raised on a mat, the caption set IN the mat. */
.a-mat {
  margin: 0;
  padding: 1rem 1rem .7rem;
  background: var(--bg-raised);
  box-shadow: 0 1px 0 var(--a-press-high) inset, var(--a-shadow);
}
.a-mat > img { display: block; width: 100%; height: auto; border: 1px solid var(--stone-deep, var(--rule)); }
.a-mat > figcaption {
  display: flex;
  justify-content: space-between;
  gap: .6rem;
  margin-top: .55rem;
  font: 12px/1.4 var(--ui);
  color: var(--muted);
}

/* ============================================================ C · choreography ====== */

/* Scroll-linked, not scroll-triggered: the playhead is bolted to scroll position, so it
   rewinds when the visitor scrolls back. Everything is inside @supports + a motion query,
   so where it is unsupported or unwanted the content is simply, statically, there. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-reveal {
      animation: a-lift linear both;
      animation-timeline: view();
      animation-range: entry 5% entry 70%;
    }
    [data-atelier~="motion"] .a-unveil > img,
    [data-atelier~="motion"] img.a-unveil {
      animation: a-unveil linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 40%;
    }
    [data-atelier~="motion"] .a-unveil { overflow: hidden; }
    /* .a-unroll unrolls the WHOLE element, not its image: a matted or framed photograph, whose frame
       must never stand complete around a half-drawn picture (2026-10-01). The sides and the end
       open past the box so the element's own shadow is never cut. */
    [data-atelier~="motion"] .a-unroll {
      animation: a-unroll linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 40%;
    }
  }
}
@keyframes a-lift { from { opacity: 0; transform: translateY(var(--a-lift)); } to { opacity: 1; transform: none; } }
@keyframes a-unveil { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes a-unroll { from { clip-path: inset(0 -4rem 100% -4rem); } to { clip-path: inset(-4rem); } }

/* Rolling counter. atelier.js rebuilds a numeric cell as stacked digit columns; until it
   does — and if it never does — the cell keeps rendering as plain text. */
.a-odo { --a-digit-h: 1.16em; display: inline-flex; gap: 2px; vertical-align: baseline; }
.a-odo-win {
  display: block;
  overflow: hidden;
  height: var(--a-digit-h);
  min-width: .62ch;
}
/* Normal flow, not absolute: the column is simply ten digits tall and slides. */
.a-odo-col {
  display: block;
  transition: transform var(--motion-slow, 520ms) var(--ease, ease);
}
.a-odo-col > span {
  display: block;
  height: var(--a-digit-h);
  line-height: var(--a-digit-h);
  text-align: center;
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) { .a-odo-col { transition: none; } }

/* Photo toning: one warm pass over every photograph so a mixed set of sources reads as one
   commission. Skins set the values; `none` opts out. */
[data-atelier~="toned"] :is(img, video):not([data-tone="off"]) {
  filter: var(--a-tone, saturate(.93) contrast(1.02) sepia(.06));
}

/* Page-to-page continuity: a short entry animation, and nothing else.
   NOT @view-transition — cross-document view transitions need a same-origin http(s)
   navigation, and these demos open from a file path, so the browser skips the transition on
   every load and its promise rejects unhandled. A hosted deploy could opt in; the file-path
   delivery this project ships cannot. */
@media (prefers-reduced-motion: no-preference) {
  /* The rise lives on the page's content, never on <body>. A transform animation with a
     retained fill makes its element the containing block for every position:fixed descendant,
     which resolved the arrival's inset:0 against the whole 4000px document instead of the
     viewport. body fades; #main moves. */
  [data-atelier~="motion"] body { animation: a-page-fade 420ms var(--ease, ease) both; }
  [data-atelier~="motion"] #main { animation: a-page-in 420ms var(--ease, ease) both; }
}
@keyframes a-page-fade { from { opacity: 0; } to { opacity: 1; } }
/* 18px, not 6px: at 6px a page arrival is a repaint. Raised 2026-08-20 with section S — the
   whole point of the departure/arrival pair is that a click reads as travel. */
@keyframes a-page-in { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
/* Paper never waits on an animation. */
@media print { body, #main, .a-arrival, .a-arrival-inner { animation: none !important; } }

/* ---- the arrival: a sealed envelope, opened once ----
   Four beats, driven by [data-stage] on the envelope: sealed → cracked → opening → rising.
   Every value a skin might want to own is a token; the geometry is the engine's. */
.a-arrival {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: grid;
  place-items: center;
  overflow: hidden;
  text-align: center;
  color: var(--a-arr-ink, var(--ivory, #f6f1e8));
  background-color: var(--a-arr-bg, var(--ink, #221a15));
  background-image:
    radial-gradient(112% 78% at 50% 6%, var(--a-arr-lift, rgba(255, 255, 255, .13)), transparent 62%),
    radial-gradient(120% 100% at 50% 108%, var(--a-arr-floor, rgba(0, 0, 0, .55)), transparent 58%);
}
/* One width drives the whole stage: the envelope is sized from it, and the headroom the
   thrown-back flap needs (62% of the envelope's height) is reserved from the same number. */
.a-arrival { --a-env-w: min(86vw, 30rem, 46vh); }
.a-arrival::before {
  content: "";
  position: absolute;
  inset: -12%;
  background-image: var(--a-arr-tile,
    repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px var(--a-arr-tile-step, 26px)),
    repeating-linear-gradient(-45deg, currentColor 0 1px, transparent 1px var(--a-arr-tile-step, 26px)));
  background-size: var(--a-arr-tile-size, auto);
  opacity: var(--a-arr-tile-opacity, .07);
  pointer-events: none;
}
.a-arrival[hidden] { display: none; }
.a-arrival-inner {
  position: relative;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: clamp(.85rem, .6rem + 1vw, 1.5rem);
  width: 100%;
  height: 100%;
  max-width: 42rem;
  padding: var(--gap);
  /* .5 of the envelope's height, which with the eyebrow above it clears the 62% the flap
     sweeps through, without pushing the note off a 540px-tall viewport. Derived from the
     envelope's own ratio token, so changing the format cannot silently un-reserve the fold. */
  padding-top: calc(var(--gap) + var(--a-env-w, 30rem) / var(--a-env-ratio, 1.38) * .5);
  /* The stage's own perspective. It has to live on the ancestor: .a-env's perspective applies
     to .a-env's CHILDREN (the flap needs it), never to .a-env's own tilt. */
  perspective: var(--a-stage-perspective, 2200px);
  perspective-origin: 50% 44%;
  /* room for the disclosure, which is pinned to the floor of the stage rather than stacked
     under the controls: three greys in a column read as one paragraph of small print. */
  padding-bottom: calc(var(--gap) + 3.4rem);
}
.a-arrival-note {
  position: absolute;
  left: 0;
  right: 0;
  bottom: clamp(.9rem, 3vh, 2rem);
  margin: 0;
}
.a-arrival button.a-seal { border: 0; cursor: pointer; }
html[data-arrival="open"] { overflow: hidden; }

/* the envelope.
   Proportion is A7 (5.25 × 7.25in laid landscape = 1.38:1), the size a wedding invitation is
   actually posted in — 3:2 was a letter envelope and read as a bank statement.
   The stage is tipped a few degrees off flat: an envelope photographed square-on is a rectangle,
   an envelope seen slightly from above is an object on a table. */
.a-env {
  position: relative;
  width: var(--a-env-w, min(86vw, 30rem));
  aspect-ratio: var(--a-env-ratio, 1.38);
  /* ~1.25× the envelope's own rendered width (~480px), not the 2.9× the old 1400px was. At that
     ratio the projection is nearly orthographic and a rotateX reads as a vertical squash rather
     than as a sheet turning through space — most of what made the swing look drawn. */
  perspective: var(--a-env-perspective, 600px);
  perspective-origin: 50% 34%;
  transform-style: preserve-3d;
  rotate: x var(--a-env-tilt, 7deg);
}
.a-env > * { position: absolute; }
/* the table under it: a soft contact shadow, wider and fainter than the envelope, which is what
   makes the thing read as resting on a surface rather than floating in a colour field. */
.a-env::after {
  content: "";
  position: absolute;
  left: -9%;
  right: -9%;
  bottom: -7%;
  height: 22%;
  z-index: 0;
  background: radial-gradient(50% 50% at 50% 50%, rgba(0, 0, 0, .58), rgba(0, 0, 0, .22) 52%, transparent 76%);
  filter: blur(6px);
  transition: opacity 620ms var(--ease, ease), scale 620ms var(--ease, ease);
  pointer-events: none;
}
.a-env-back,
.a-env-front,
.a-env-flap {
  left: 0;
  right: 0;
  background-color: var(--a-env-paper, #efe7d8);
}
.a-env-back {
  top: 0;
  bottom: 0;
  z-index: 1;
  border-radius: 3px;
  /* The throat of the envelope is inside a paper box: it cannot be as bright as the front.
     Layer order: veil, then the photographed inside-back if a skin supplies one, then the
     envelope's own paper. The veil is a token because a skin whose inner photograph already
     carries its own falloff needs to lift it, or the throat goes to mud. */
  background-image:
    var(--a-env-throat-veil, radial-gradient(150% 100% at 50% -10%, rgba(0, 0, 0, .52), rgba(0, 0, 0, .3) 40%, rgba(0, 0, 0, .12) 74%, transparent 96%)),
    var(--a-env-inner-img, none),
    var(--a-env-paper-img, none);
  background-size: auto, cover, cover;
  background-position: center, center top, center;
  box-shadow: 0 46px 64px -44px rgba(0, 0, 0, .9), inset 0 1px 0 rgba(255, 255, 255, .3);
}
/* Paper has a thickness. The lit top edge and the dark inside of the throat's mouth are two
   1px lines, and they are the cheapest thing on this stage that reads as real. */
.a-env-back::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .42),
    inset 0 -1px 0 rgba(0, 0, 0, .3),
    inset 1px 0 0 rgba(255, 255, 255, .12),
    inset -1px 0 0 rgba(0, 0, 0, .22);
  pointer-events: none;
}
/* the card, held inside the pocket until it is drawn out.
   A card is smaller than the envelope it was posted in, and it is dropped in with the throat
   above it: at 7% from the top its deckle edge sat all but flush with the envelope's own top
   edge, so the reveal was a sheet wedged in rather than a sheet inside. The insets are tokens
   because the format is the skin's (a square card in a square envelope wants its own). */
.a-env-card {
  left: var(--a-card-inset-x, 9%);
  right: var(--a-card-inset-x, 9%);
  top: var(--a-card-inset-top, 13%);
  bottom: var(--a-card-inset-bottom, 5%);
  z-index: 3;
  display: grid;
  align-content: center;
  gap: .55rem;
  padding: clamp(.5rem, .2rem + 2vw, 1.6rem);
  /* The card's type scales with the envelope, which is now capped in vh: fixed steps overflowed
     the card on a short viewport. Everything inside is set in em off this. */
  font-size: clamp(10px, calc(var(--a-env-w, 30rem) * .05), 24px);
  color: var(--a-card-ink, var(--ink, #221a15));
  transition: transform 980ms cubic-bezier(.16, .84, .24, 1), opacity 620ms var(--ease, ease);
}
/* The card's paper is a layer of its own, not the card's background, so a deckle-edged cut-out
   can be dropped in: its shadow has to follow the torn silhouette, and only `drop-shadow` does
   that — a box-shadow would draw the straight rectangle the deckle exists to avoid. The type
   lives on .a-env-card above it. */
.a-env-card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: var(--a-card-radius, 2px);
  /* Separate from --a-card-paper, which stays the ink-contrast reference for the skin: a
     cut-out sheet needs NO fill at all, or the colour paints the rectangle the alpha was cut
     to avoid. Default keeps the plain-paper card working. */
  background-color: var(--a-card-paper-bg, var(--a-card-paper, #f8f3e9));
  background-image: var(--a-card-paper-img, none);
  background-size: var(--a-card-paper-fit, cover);
  background-position: center;
  /* the top hairline of light, off for a cut-out sheet where it would cross open air */
  box-shadow: var(--a-card-edge, inset 0 1px 0 rgba(255, 255, 255, .6));
  filter: drop-shadow(0 14px 18px rgba(0, 0, 0, .5));
}
/* the front pocket. Its V is the flap's own diagonal — same two lines, or the card shows
   through the wedge between them while the envelope is still sealed. */
.a-env-front {
  top: 0;
  bottom: 0;
  z-index: 4;
  clip-path: polygon(0 0, 50% 62%, 100% 0, 100% 100%, 0 100%);
  /* Four layers, back to front: the throat's shadow falling on the pocket, the raking light
     that tells you which way the room is lit, the two side flaps' own tone, the paper. A flat
     rectangle of paper colour is what made the old envelope read as a drawing of one. */
  background-image:
    linear-gradient(180deg, rgba(0, 0, 0, .16), transparent 24%),
    linear-gradient(104deg, rgba(255, 255, 255, .13), transparent 46%),
    var(--a-env-paper-img, none);
  background-size: auto, auto, cover;
  /* The cut edge of the pocket. A box shadow is drawn on the element's rectangle, so the V —
     which only exists in the clip-path — got none of it, and the throat behind read as a flat
     grey wedge. drop-shadow follows the clipped silhouette: one shadow falling into the throat,
     one hairline of light on the cut itself. */
  filter:
    drop-shadow(0 3px 4px rgba(0, 0, 0, .42))
    drop-shadow(0 -1px 0 rgba(255, 255, 255, .34));
}
/* The two seams a real envelope back actually shows: the bottom flap's straight top edge lying
   across the middle, and the pair of diagonals where the side flaps are folded in under it.
   Drawn as light-and-shadow pairs — a fold is never a line, it is an edge catching the light
   with its own shadow under it. */
.a-env-front::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: var(--a-env-seam, 63%);
  height: 10%;
  /* The fold's own catch of light, drawn as a background layer rather than a border so it can
     die away before it reaches the paper's cut edges. A 1px border ran the full width with
     square ends, which on a photographed sheet reads as a ruled line, not as a fold. */
  background:
    linear-gradient(90deg, transparent, rgba(255, 255, 255, .3) 14%, rgba(255, 255, 255, .3) 86%, transparent) top left / 100% 1px no-repeat,
    linear-gradient(180deg, rgba(0, 0, 0, .17), rgba(0, 0, 0, .05) 26%, transparent 62%);
  pointer-events: none;
}
.a-env-front::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to bottom right, transparent calc(50% - .6px), rgba(255, 255, 255, .2) 50%, transparent calc(50% + .6px)),
    linear-gradient(to bottom left, transparent calc(50% - .6px), rgba(255, 255, 255, .2) 50%, transparent calc(50% + .6px));
  background-size: 50% var(--a-env-seam, 63%), 50% var(--a-env-seam, 63%);
  background-position: left top, right top;
  background-repeat: no-repeat;
  /* .8 drew a second bright line beside the drop-shadow already falling along the same cut,
     which is what made the V read as two pencil strokes rather than one folded edge. */
  opacity: .5;
  pointer-events: none;
}
/* The flap's own shadow, cast down the pocket while it swings. Lives on the front because that
   is the surface it falls on; it comes and goes with the beat rather than being painted in. */
.a-env-shade {
  left: 0;
  right: 0;
  top: 0;
  height: 68%;
  z-index: 5;
  clip-path: polygon(0 0, 50% 92%, 100% 0);
  background: linear-gradient(180deg, rgba(0, 0, 0, .5), rgba(0, 0, 0, .16) 62%, transparent);
  opacity: 0;
  pointer-events: none;
}
.a-env[data-stage="cracked"] .a-env-shade { opacity: .5; transition: opacity 420ms var(--ease, ease); }
.a-env[data-stage="opening"] .a-env-shade,
.a-env[data-stage="rising"] .a-env-shade { animation: a-shade-off 900ms var(--ease, ease) both; }
@keyframes a-shade-off {
  from { opacity: .5; }
  38% { opacity: .28; }
  to { opacity: 0; }
}
/* the flap: two faces on one plane, so the lining shows once it is thrown back.
   The hinge turns on the independent `rotate` property, which leaves `transform` free for the
   beat that sets the envelope down while the card rises out of it. */
.a-env-flap {
  top: 0;
  height: 62%;
  z-index: 5;
  background-color: transparent;
  transform-origin: 50% 0;
  transform-style: preserve-3d;
  rotate: x 0deg;
  transition: rotate 1040ms cubic-bezier(.24, .62, .22, 1), z-index 0s 520ms;
}
.a-env-flap::before,
.a-env-flap::after {
  content: "";
  position: absolute;
  inset: 0;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  backface-visibility: hidden;
  background-size: cover;
}
.a-env-flap::before {
  background-color: var(--a-env-paper, #efe7d8);
  background-image: linear-gradient(180deg, rgba(0, 0, 0, .05), rgba(0, 0, 0, .18)), var(--a-env-flap-img, var(--a-env-paper-img, none));
  /* A photographed flap is a triangle whose own silhouette has to land ON the clip-path, and
     `cover` cannot promise that: the flap box is 2.22:1, the photograph 2.38:1, so cover crops
     ~18px off each side and takes the triangle's two top corners with it — the photographed
     deckle edge then runs beside the clipped one instead of along it, which is the ragged
     double edge the review saw. A skin with a triangle cut-out sets `100% 100%` and the two
     edges coincide. Default stays cover for a skin using plain paper. */
  background-size: auto, var(--a-env-flap-fit, cover);
  box-shadow: 0 6px 10px -8px rgba(0, 0, 0, .5);
}
/* Thrown back, the flap is a lit sheet of paper standing away from the stage: it needs a
   shadow that follows its triangle, which only drop-shadow does. Carried on the faces rather
   than on .a-env-flap itself — a filter on the flap would flatten its preserve-3d.
   Declared AT REST at zero strength, with the VALUES animating: applied at
   [data-stage="opening"] instead, it created a compositing layer on the exact frame the swing
   starts, which is a hitch at the worst possible moment. */
.a-env-flap::before,
.a-env-flap::after { filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
.a-env-flap::after {
  transform: rotateX(180deg);
  /* The lining is revealed by a keyframe-synced STEP, never by backface culling alone: a culled
     face is a rendering-order bet, and it lost once already — the closed envelope showed navy
     where paper belongs. Culling stays as the braces; the step is the belt. */
  opacity: 0;
  background-color: var(--a-env-lining, var(--accent-soft, #e9d9d9));
  /* A lining is silk seen from the *back* of an open flap: it is in its own shadow and it is
     never the brightest thing on the stage. Without the veil a printed liner reads as bunting.
     The veil is a token so a skin can lift or drop it for its own pattern. */
  background-image:
    var(--a-env-lining-veil, linear-gradient(0deg, rgba(0, 0, 0, .12), rgba(0, 0, 0, .34) 48%, rgba(0, 0, 0, .52))),
    var(--a-env-lining-img, none);
  background-size: auto, cover;
}
/* ---- the relight -----------------------------------------------------------
   Both faces used to carry a STATIC gradient, so the sheet turned through 101° at constant
   luminance. That is the tell, and no easing curve, frame rate, video or photograph fixes it:
   paper turning away from a light goes lit → edge-on → dark, and its lining does the reverse.
   One light direction — above and behind the reader — and every value below obeys it.
   Driven off a-flap-open's OWN percentages and its OWN duration token, so a retime moves the
   swing and its lighting together. The lining's reveal used to be a hardcoded 460ms delay meant
   to land at ~55%; the skin's retime stretched the swing to 1560ms and left the delay alone, so
   it fired at 29% — the lining appeared while the flap still leaned toward the reader. It is a
   step at 52% now, which is where these keyframes actually cross -90°: the swing's easing is
   front-loaded, so most of the 48%→78% segment's -72°→-112° is spent in its first few percent
   (measured: -70.6° at 37%, -105° at 59%). The relight carries the SAME easing, so its
   percentages and the swing's describe the same angles, and no future duration can desync a
   percentage. */
.a-env[data-stage="opening"] .a-env-flap::before {
  animation: a-flap-face-out var(--a-flap-dur, 1280ms) cubic-bezier(.3, .66, .2, 1) both;
}
.a-env[data-stage="opening"] .a-env-flap::after {
  animation: a-flap-face-in var(--a-flap-dur, 1280ms) cubic-bezier(.3, .66, .2, 1) both;
}
/* The outer face: lit while it faces the reader, dark by the time it is edge-on, stepped out at
   the vertical. Its cast shadow grows as it lifts off the pocket. */
@keyframes a-flap-face-out {
  from  { opacity: 1; filter: brightness(1.04) drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
  12%   { opacity: 1; filter: brightness(1.03) drop-shadow(0 -1px 3px rgba(0, 0, 0, .26)); }
  48%   { opacity: 1; filter: brightness(.88) drop-shadow(0 -5px 9px rgba(0, 0, 0, .4)); }
  51.9% { opacity: 1; filter: brightness(.72) drop-shadow(0 -7px 12px rgba(0, 0, 0, .45)); }
  52%   { opacity: 0; filter: brightness(.72) drop-shadow(0 -7px 12px rgba(0, 0, 0, .45)); }
  to    { opacity: 0; filter: brightness(.6) drop-shadow(0 -10px 16px rgba(0, 0, 0, .5)); }
}
/* The lining: in its own shadow at the fold, coming into the light as the flap comes over. It
   ends below 1 on purpose — a lining is never the brightest thing on the stage. */
@keyframes a-flap-face-in {
  from  { opacity: 0; filter: brightness(.5) drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
  48%   { opacity: 0; filter: brightness(.55) drop-shadow(0 -4px 8px rgba(0, 0, 0, .34)); }
  51.9% { opacity: 0; filter: brightness(.6) drop-shadow(0 -7px 12px rgba(0, 0, 0, .44)); }
  52%   { opacity: 1; filter: brightness(.6) drop-shadow(0 -7px 12px rgba(0, 0, 0, .44)); }
  78%   { opacity: 1; filter: brightness(.9) drop-shadow(0 -11px 17px rgba(0, 0, 0, .5)); }
  to    { opacity: 1; filter: brightness(.96) drop-shadow(0 -9px 15px rgba(0, 0, 0, .46)); }
}

/* the seal sits on the flap's point */
.a-arrival .a-seal {
  top: 56%;
  left: 50%;
  translate: -50% -50%;
  z-index: 7;
  background-image: none;
  background-color: transparent;
  box-shadow: none;
}
.a-arrival .a-seal::after { content: none; }
/* The focus ring, drawn as a ring and not as the button's box. The engine's a11y floor outlines
   the element's rectangle in --accent, which on a photographed cut-out seal reads as a coloured
   square floating on the wax. Same 2px, same visibility, following the disc instead. */
.a-arrival .a-seal:focus-visible { outline: none; }
.a-env[data-stage="sealed"] .a-seal:focus-visible::after {
  content: "";
  position: absolute;
  inset: calc(-1 * var(--focus-off, 3px) - 4%);
  z-index: 4;
  border-radius: 50%;
  border: var(--focus-w, 2px) solid var(--a-seal-focus, rgba(255, 252, 246, .85));
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .35);
  pointer-events: none;
}
/* The intact disc, laid over the two fragments while the seal is still sealed. The reshot pair
   was photographed already parted, so its fracture faces are lit and visible from above: butting
   the halves together leaves a dark bar down the middle, and a sealed seal cannot show a seam.
   This layer fades in 220ms as the halves swing apart, which is short enough that the two
   photographs' silhouettes never have to agree. */
.a-arrival .a-seal::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background-image: var(--a-seal-intact-img, none);
  background-size: cover;
  background-position: center;
  filter: var(--a-seal-half-filter, none);
  transition: opacity 220ms var(--ease, ease);
}
.a-env:not([data-stage="sealed"]) .a-seal::before { opacity: 0; }
.a-seal-half {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  /* A photographed pair is exported in one shared box so the two stay in register, which leaves
     transparent margin around each fragment: the skin zooms it back up to the intact disc's size. */
  background-size: var(--a-seal-half-fit, cover);
  background-position: center;
  /* Off for a photographed cut-out half, where a box shadow would draw the rectangle instead of
     the fracture. That case sets --a-seal-half-filter to a drop-shadow instead. */
  box-shadow: var(--a-seal-half-shadow,
    var(--a-shadow),
    inset 0 2px 3px rgba(255, 255, 255, .3),
    inset 0 -4px 8px rgba(0, 0, 0, .42));
  filter: var(--a-seal-half-filter, none);
  transition: transform 700ms cubic-bezier(.3, .7, .2, 1), opacity 620ms var(--ease, ease);
}
/* The default halves are one wax image cut by two answering zigzags. A skin that owns a real
   photograph of a seal snapped in two supplies a registered pair instead and nulls the clips. */
.a-seal-half--l {
  background-image: var(--a-seal-half-l-img, var(--a-seal-img, var(--a-wax)));
  clip-path: var(--a-seal-clip-l, polygon(0 0, 52% 0, 45% 17%, 55% 33%, 44% 51%, 54% 69%, 46% 85%, 51% 100%, 0 100%));
}
.a-seal-half--r {
  background-image: var(--a-seal-half-r-img, var(--a-seal-img, var(--a-wax)));
  clip-path: var(--a-seal-clip-r, polygon(52% 0, 100% 0, 100% 100%, 51% 100%, 46% 85%, 54% 69%, 44% 51%, 55% 33%, 45% 17%));
}
/* A photographed pair was shot already parted, so the disc has to be mended while it is still
   sealed: each half slides in by half the gap and the crack closes. --a-seal-mend is that half
   gap, 0 for the clip-path pair, which has none.
   `translate`, not a transform: a skin may run a breathing animation on these halves in exactly
   this state, and a running animation's transform beats any normal declaration in the cascade —
   the sanagustin skin's seal-breathe was silently eating the shift. */
.a-env[data-stage="sealed"] .a-seal-half--l { translate: var(--a-seal-mend, 0%); }
.a-env[data-stage="sealed"] .a-seal-half--r { translate: calc(-1 * var(--a-seal-mend, 0%)); }
.a-seal-mono {
  position: relative;
  z-index: 2;
  transition: opacity 420ms var(--ease, ease);
}
/* `scale`, not a transform: the sealed state holds a translateX to keep the crack shut, and a
   transform here would throw it away and split the disc on hover. */
.a-arrival .a-seal:hover .a-seal-half { scale: 1.035; box-shadow: var(--a-seal-half-shadow, var(--a-shadow-deep), inset 0 2px 3px rgba(255, 255, 255, .34), inset 0 -4px 8px rgba(0, 0, 0, .42)); }
.a-arrival .a-seal:hover { transform: none; }
.a-env[data-stage="sealed"] .a-seal:active { scale: .965; animation: none; }

/* the beats.
   The wax does not slide apart, it breaks and falls: a short recoil as the crack goes through,
   then gravity. Accelerating easing, because nothing that falls does it evenly. */
.a-env[data-stage="cracked"] .a-seal-half--l,
.a-env[data-stage="opening"] .a-seal-half--l,
.a-env[data-stage="rising"] .a-seal-half--l { animation: a-wax-fall-l 1240ms cubic-bezier(.36, .04, .74, .68) both; }
.a-env[data-stage="cracked"] .a-seal-half--r,
.a-env[data-stage="opening"] .a-seal-half--r,
.a-env[data-stage="rising"] .a-seal-half--r { animation: a-wax-fall-r 1240ms cubic-bezier(.36, .04, .74, .68) both; }
@keyframes a-wax-fall-l {
  0% { transform: none; opacity: 1; }
  12% { transform: translate(-11%, -7%) rotate(-9deg); opacity: 1; }
  62% { opacity: 1; }
  100% { transform: translate(-38%, 210%) rotate(-64deg); opacity: 0; }
}
@keyframes a-wax-fall-r {
  0% { transform: none; opacity: 1; }
  12% { transform: translate(12%, -5%) rotate(8deg); opacity: 1; }
  62% { opacity: 1; }
  100% { transform: translate(34%, 226%) rotate(58deg); opacity: 0; }
}
.a-env:not([data-stage="sealed"]) .a-seal-mono { opacity: 0; }
/* Barely past the vertical. Thrown further back — -163° was the old value — the triangle
   unfolds to nearly its full length ABOVE the envelope and reads as a pennant taped to the
   stage, which is exactly what it looked like. At -101° the sheet stands almost upright and
   is foreshortened to a shallow band, so the eye reads a hinge and depth. Keyframes, not the
   base transition: paper carries a little past its rest and settles back, and the overshoot
   is what makes the opening read as one smooth motion rather than a shape appearing. */
.a-env[data-stage="opening"] .a-env-flap,
.a-env[data-stage="rising"] .a-env-flap {
  z-index: 0;
  animation: a-flap-open var(--a-flap-dur, 1280ms) cubic-bezier(.3, .66, .2, 1) both;
}
/* Paper resists before it gives. The first 12% barely moves — the gum is still holding along the
   fold — then the sheet swings, carries past its rest and settles back. */
@keyframes a-flap-open {
  from { rotate: x 0deg; }
  12% { rotate: x -7deg; }
  48% { rotate: x -72deg; }
  78% { rotate: x -112deg; }
  90% { rotate: x -96deg; }
  to { rotate: x -101deg; }
}
/* Beat 3 is a keyframe animation, not a transition. A transition triggered by an attribute flip
   that lands inside the flap's preserve-3d animation gets its style recalc deferred and starts
   several hundred ms late; a CSSAnimation runs on its own clock. */
.a-env[data-stage="rising"] .a-env-card {
  animation: a-card-out 1320ms cubic-bezier(.16, .84, .24, 1) both;
}
/* The card stays BEHIND the pocket the whole way out — no z-index change — which is the one
   thing that decides whether a sheet is coming out of an envelope or sitting on top of a
   picture of one. It clears the pocket's mouth by the time the envelope is set down.
   The half-degree of rotation is the hand: paper drawn out by one corner is never square. */
/* -70/-66, not -66/-62: the card now starts 13% down instead of 7% and is shorter, so the same
   percentages of its own height left it lower on the stage than the reserved headroom expects. */
@keyframes a-card-out {
  from { transform: none; }
  26% { transform: translateY(-15%) rotate(.55deg); }
  84% { transform: translateY(-70%) rotate(-.2deg) scale(1.035); }
  to { transform: translateY(-66%) rotate(0deg) scale(1.04); }
}
/* the shadow deepens on the paper layer, where it can follow the sheet's own edge */
.a-env[data-stage="rising"] .a-env-card::before { animation: a-card-lift 1320ms cubic-bezier(.16, .84, .24, 1) both; }
@keyframes a-card-lift {
  from { filter: drop-shadow(0 14px 18px rgba(0, 0, 0, .5)); }
  55% { filter: drop-shadow(0 30px 34px rgba(0, 0, 0, .66)); }
  to { filter: drop-shadow(0 44px 46px rgba(0, 0, 0, .76)); }
}
/* The envelope is set down as the card comes up. Two motions in opposite directions read as
   one hand at work; the old single motion left a large empty pocket sharing the frame with the
   thing you were meant to be reading. The stage also comes square to the reader here — the
   tilt is what made it an object on a table, and the card is not an object, it is a text. */
.a-env[data-stage="rising"] { rotate: x 0deg; }
.a-env[data-stage="rising"]::after { animation: a-fade-away 720ms var(--ease, ease) both 420ms; }
.a-env[data-stage="rising"] .a-env-back,
.a-env[data-stage="rising"] .a-env-front { animation: a-env-away 940ms cubic-bezier(.4, 0, .3, 1) both 420ms; }
/* Two animations, in this order. The relight has to keep running here or its retained values
   vanish the instant the stage flips and both faces pop back to their sealed lighting; the fade
   is second in the list, so it is the one that owns opacity by the end. A skin retiming this
   pair must give BOTH slots a value — see skin.css section 16. */
.a-env[data-stage="rising"] .a-env-flap::before {
  animation: a-flap-face-out var(--a-flap-dur, 1280ms) cubic-bezier(.3, .66, .2, 1) both,
             a-fade-away 720ms var(--ease, ease) both 420ms;
}
.a-env[data-stage="rising"] .a-env-flap::after {
  animation: a-flap-face-in var(--a-flap-dur, 1280ms) cubic-bezier(.3, .66, .2, 1) both,
             a-fade-away 720ms var(--ease, ease) both 420ms;
}
@keyframes a-env-away {
  from { transform: none; opacity: 1; }
  to { transform: translateY(15%); opacity: 0; }
}
@keyframes a-fade-away { to { opacity: 0; } }
.a-arrival[data-open] .a-arrival-eyebrow,
.a-arrival[data-open] .a-arrival-hint,
.a-arrival[data-open] .a-arrival-skip { opacity: 0; transition: opacity 320ms var(--ease, ease); }

@media (prefers-reduced-motion: no-preference) {
  .a-arrival-inner { animation: a-page-in 520ms var(--ease, ease) both; }
  .a-env { animation: a-env-in 1000ms cubic-bezier(.16, .84, .24, 1) both 120ms; transition: rotate 1020ms cubic-bezier(.16, .84, .24, 1); }
  @keyframes a-env-in {
    from { opacity: 0; transform: translateY(34px) scale(.93); }
    to { opacity: 1; transform: none; }
  }
  .a-arrival[data-leaving] { animation: a-arrival-out 780ms cubic-bezier(.4, 0, .2, 1) both; }
  /* Also an animation, or a-card-out's retained fill would hold the card where it landed. */
  .a-arrival[data-leaving] .a-env-card { animation: a-card-away 780ms cubic-bezier(.4, 0, .2, 1) both; }
  @keyframes a-card-away {
    from { transform: translateY(-66%) scale(1.04); opacity: 1; }
    to { transform: translateY(-66%) scale(1.34); opacity: 0; }
  }
  /* The seal asks to be pressed. `scale` is the independent property, so it composes with the
     centring `translate` instead of fighting it, and it stops the moment the stage leaves
     `sealed` — an animation that had to be turned off by a later declaration would win. */
  .a-env[data-stage="sealed"] .a-seal { animation: a-seal-breathe 3.8s var(--ease, ease) 1.6s infinite; }
  @keyframes a-seal-breathe { 0%, 100% { scale: 1; } 50% { scale: 1.04; } }
  @keyframes a-arrival-out {
    from { opacity: 1; transform: scale(1); }
    to { opacity: 0; transform: scale(1.05); }
  }
}

/* ============================================================ D · keepsakes ========= */

/* Photo corners. Two triangles, no image, sized by token. */
.a-corners { position: relative; }
.a-corners::before,
.a-corners::after {
  content: "";
  position: absolute;
  width: var(--a-corner);
  height: var(--a-corner);
  background: linear-gradient(135deg, var(--stone, var(--rule)) 48%, transparent 50%);
  pointer-events: none;
  z-index: 1;
}
.a-corners::before { left: 0; top: 0; }
.a-corners::after { right: 0; bottom: 0; transform: rotate(180deg); }

/* Pinboard: prints tipped in a degree or two off square. */
.a-pin {
  background: var(--bg-raised);
  box-shadow: var(--a-shadow-deep);
  transform: rotate(calc(var(--a-tilt) * -1));
}
.a-pin:nth-child(2n) { transform: rotate(var(--a-tilt)); }
.a-pin:nth-child(3n) { transform: rotate(calc(var(--a-tilt) * .4)); }
.a-pin:hover { transform: rotate(0deg); transition: transform 260ms var(--ease, ease); }

/* Fill-in card: dotted rules under answers, the way a printed reply card is set. */
.a-fill { display: grid; gap: .5rem 1rem; grid-template-columns: 7rem 1fr; align-items: end; }
.a-fill > dt, .a-fill > .a-fill-label {
  font: 600 12px/1.2 var(--ui);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
}
.a-fill > dd, .a-fill > .a-fill-value {
  margin: 0;
  font-family: var(--display);
  font-size: var(--step-1);
  border-bottom: 1px dotted var(--stone-deep, var(--rule));
  padding-bottom: .2rem;
}

/* Cancellation stamp. The skin decides which state shows it; the engine only draws it. */
.a-stamp {
  position: absolute;
  right: 1rem;
  top: 1rem;
  transform: rotate(-11deg);
  border: 2px solid var(--accent);
  border-radius: 3px;
  padding: .3rem .55rem;
  font: 700 12px/1.15 var(--ui);
  letter-spacing: .14em;
  text-transform: uppercase;
  text-align: center;
  color: var(--accent);
  opacity: .85;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .a-stamp { animation: a-stamp 320ms var(--ease, ease) both; }
  @keyframes a-stamp { from { opacity: 0; transform: rotate(-11deg) scale(1.35); } to { opacity: .85; transform: rotate(-11deg) scale(1); } }
}

/* Kept for assistive tech, taken out of sight: the odometer mirrors these cells visually. */
.wsd-visually-hidden {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; white-space: nowrap;
  clip: rect(0 0 0 0); clip-path: inset(50%);
}

/* ============================================================ E · format ============
   The moves the photography pass added (J · K · N in `tools/Worth The Price.html`). These are
   FORMAT utilities: how large a photograph is, how a page is paced, how a spread is divided.
   They carry no colour of their own — every value is a token the skin already defines. */

/* J · the full-bleed interlude. A passage of the page that IS a photograph, with the type set
   inside the frame on its own scrim — never overhanging onto the page ground. */
.a-bleed {
  position: relative;
  display: grid;
  min-height: var(--a-bleed-h);
  overflow: clip;
  isolation: isolate;
}
.a-bleed > img,
.a-bleed > picture > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Type sits over the TOP of a frame, so a crop behind it comes from the top. */
  object-position: var(--a-bleed-crop, 50% 30%);
}
/* The scrim belongs to the FRAME, not to a panel: a bordered rectangle of grey floating on a
   photograph reads as a box of type, which is the brochure problem again. This is one gradient
   across the whole frame, in the direction the type sits. */
.a-bleed::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(var(--a-scrim-dir, to top),
    var(--a-scrim-strong) 0%, var(--a-scrim) 34%, transparent 72%);
}
.a-bleed-over {
  position: relative;
  z-index: 1;
  align-self: var(--a-bleed-y, end);
  justify-self: var(--a-bleed-x, start);
  max-width: min(34rem, 100% - 2 * var(--gap, 1rem));
  margin: clamp(1.4rem, 4.5vw, 3.4rem);
  color: var(--a-scrim-ink);
  text-shadow: 0 1px 18px rgba(0, 0, 0, .45);
}
.a-bleed--center { --a-bleed-x: center; --a-bleed-y: center; text-align: center; }
.a-bleed--top { --a-bleed-y: start; --a-scrim-dir: to bottom; }
.a-bleed--tall { --a-bleed-h: min(88vh, 48rem); }
.a-bleed--short { --a-bleed-h: min(56vh, 26rem); }
/* Type may not hang outside the frame: at 390px it would count as page overflow. */
@media (max-width: 30em) {
  .a-bleed-over { margin: 1rem; max-width: calc(100% - 2rem); }
}

/* K · pacing. One dark passage per page. The skin supplies the four values; the utility only
   re-points the tokens everything inside it already reads, so no child needs a modifier. */
.a-ink {
  background: var(--band-ink-bg, var(--ink));
  color: var(--band-ink-fg, var(--bg-raised));
  --ink: var(--band-ink-fg, var(--bg-raised));
  --bg: var(--band-ink-bg, var(--ink));
  --bg-raised: var(--band-ink-raised, var(--band-ink-bg, var(--ink)));
  --muted: var(--band-ink-muted, currentColor);
  --rule: var(--band-ink-rule, currentColor);
  --stone-deep: var(--band-ink-rule, currentColor);
  --accent: var(--band-ink-accent, var(--accent-soft));
  --a-press-high: rgba(255, 255, 255, .1);
  --a-press-low: rgba(0, 0, 0, .5);
}

/* N · the editorial grid. Twelve columns; the SKIN places its own children, from the same
   breakpoint the grid appears at. Before that breakpoint it is one column, in source order. */
.a-grid12 { display: grid; gap: var(--gap); }
@media (min-width: 46em) {
  .a-grid12 {
    grid-template-columns: repeat(12, minmax(0, 1fr));
    column-gap: clamp(.9rem, 2vw, 1.8rem);
    align-items: end;
  }
}

/* O · the photograph as a surface, at the size it is legible at. Scroll-linked, so it rewinds. */
/* A container that scales its own child must clip it: the drift is a crop moving inside a frame,
   not an image growing past the page. .a-bleed already clipped; .a-drift did not, and the one
   drift frame without .a-bleed pushed 13px of horizontal overflow onto the home page. */
.a-drift { overflow: clip; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-drift > img,
    [data-atelier~="motion"] img.a-drift {
      animation: a-drift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
      will-change: transform;
    }
  }
}
@keyframes a-drift { from { transform: scale(var(--a-drift)); } to { transform: scale(1); } }

/* ============================================================ F · interaction =======
   Everything the pointer and the keyboard touch answers back. All of it is transition-based,
   so reduced motion flattens it through engine.css's blanket rule without a second query. */

/* `translate`, not `transform`: an element that also carries a scroll-linked .a-reveal keeps a
   filled `transform` from that animation long after it has finished, and a filled animation beats
   any normal declaration in the cascade — the hover would compute to nothing. Independent
   properties, same standing rule as the seal's mend. */
.a-lift { transition: translate 320ms var(--ease, ease), box-shadow 320ms var(--ease, ease); }
.a-lift:hover { translate: 0 -4px; box-shadow: var(--a-shadow-deep); }
.a-lift:active { translate: 0 -1px; }

/* A frame the pointer leans into. The scale is small on purpose — 3.5%, not a zoom. */
.a-zoom { overflow: clip; }
.a-zoom > img, .a-zoom > picture > img {
  transition: transform 620ms var(--ease, ease), filter 620ms var(--ease, ease);
}
.a-zoom:hover > img, .a-zoom:hover > picture > img { transform: scale(1.035); }

/* A rule that draws itself under a link rather than blinking on. */
.a-sweep {
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0% var(--rule-w, 1px);
  transition: background-size 320ms var(--ease, ease);
  text-decoration: none;
}
.a-sweep:hover, .a-sweep:focus-visible { background-size: 100% var(--rule-w, 1px); }

/* A control that acknowledges the press itself, rather than waiting for the page to answer. */
.a-press-fx { transition: transform 120ms var(--ease, ease), background-color 180ms var(--ease, ease), color 180ms var(--ease, ease), border-color 180ms var(--ease, ease); }
.a-press-fx:active { transform: translateY(1px) scale(.995); }

/* ---- page-to-page continuity, the other half ----
   a-page-in has always been here. This is the departure: motion.js sets data-leaving on <html>
   and waits out the animation before it navigates, so a click leads somewhere instead of
   stopping the page dead. Still not @view-transition — see the note above. */
@media (prefers-reduced-motion: no-preference) {
  /* Same rule as the arrival: the departure's transform goes on #main, so <body> never becomes
     a containing block while an overlay is on screen. body carries the fade. */
  html[data-leaving] body { animation: a-page-fade-out 260ms var(--ease, ease) both; }
  html[data-leaving] #main { animation: a-page-out 260ms var(--ease, ease) both; }
}
@keyframes a-page-fade-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes a-page-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-8px); } }

/* ============================================================ P · the overture =======
   Added 2026-08-19. The scroll-linked layer (C, O) only answers a scroll; a page that is still
   sitting in its first viewport had nothing at all. These three are the load-time half:

     .a-cue     one-shot entrance with an ORDER. Set --a-cue: 0,1,2… and a hero can hand its own
                elements a sequence — eyebrow, then names, then the card laid down.
     .a-settle  a photograph that arrives: it comes to rest out of a slight over-scale.
     .a-breathe a photograph that never stops: a very slow crop drift, so the first screen is
                alive while a visitor reads it. Composed with .a-settle it takes over at the
                moment the settle ends, from exactly the scale the settle finished on.

   All three are held behind html:not([data-arrival]): while the sealed invitation is on screen
   the page behind it must not spend its entrance unseen — the cues start when the arrival lands.
   A container that scales its own child clips it (same rule .a-drift learned the hard way). */
.a-settle, .a-breathe { overflow: clip; }
@media (prefers-reduced-motion: no-preference) {
  html[data-atelier~="motion"]:not([data-arrival]) .a-cue {
    animation: a-cue-rise var(--a-cue-dur, 1020ms) var(--a-ease-out, cubic-bezier(.16, .84, .24, 1)) both;
    animation-delay: calc(var(--a-cue-in, 220ms) + var(--a-cue, 0) * var(--a-cue-step, 150ms));
  }
  html[data-atelier~="motion"]:not([data-arrival]) .a-settle > img,
  html[data-atelier~="motion"]:not([data-arrival]) .a-settle > picture > img,
  html[data-atelier~="motion"]:not([data-arrival]) img.a-settle {
    animation: a-settle var(--a-settle-dur, 2400ms) var(--a-ease-out, cubic-bezier(.16, .84, .24, 1)) both;
  }
  html[data-atelier~="motion"]:not([data-arrival]) .a-breathe > img,
  html[data-atelier~="motion"]:not([data-arrival]) .a-breathe > picture > img,
  html[data-atelier~="motion"]:not([data-arrival]) img.a-breathe {
    animation: a-breathe var(--a-breathe-dur, 30s) ease-in-out infinite alternate;
  }
  html[data-atelier~="motion"]:not([data-arrival]) .a-settle.a-breathe > img,
  html[data-atelier~="motion"]:not([data-arrival]) .a-settle.a-breathe > picture > img {
    animation:
      a-settle var(--a-settle-dur, 2400ms) var(--a-ease-out, cubic-bezier(.16, .84, .24, 1)) both,
      a-breathe var(--a-breathe-dur, 30s) ease-in-out var(--a-settle-dur, 2400ms) infinite alternate;
  }
}
@keyframes a-cue-rise { from { opacity: 0; transform: translateY(var(--a-cue-y, 22px)); } to { opacity: 1; transform: none; } }
@keyframes a-settle { from { transform: scale(var(--a-settle-from, 1.085)); } to { transform: scale(1); } }
@keyframes a-breathe { from { transform: scale(1); } to { transform: scale(var(--a-breathe-to, 1.06)); } }

/* A row of frames enters the viewport together, so view() hands every child of it the same
   range and they reveal in unison — which reads as one block appearing, not as a cascade. The
   offset is authored because nothing about the row can be measured. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-stagger > .a-reveal:nth-child(2) { animation-range: entry 12% entry 84%; }
    [data-atelier~="motion"] .a-stagger > .a-reveal:nth-child(3) { animation-range: entry 19% entry 96%; }
    [data-atelier~="motion"] .a-stagger > .a-reveal:nth-child(4) { animation-range: entry 26% entry 100%; }
  }
}

/* ============================================================ Q · ground + relief ===
   Added 2026-08-19 (the texture pass), after a user review asked for depth and texture
   "rich, whole engine". Five utilities, and one rule that needs no class.

   THE RULE THIS SECTION IS BUILT ON: every utility here renders from CSS alone and takes a
   PHOTOGRAPH as an enhancement. If the token naming an image is unset — or the file 404s —
   the base still reads. An asset that fails to load is invisible to the acceptance gate, so
   nothing structural may depend on one.

   Token paths, as always: a url() in a custom property resolves against the stylesheet that
   USES it, and that is this file — so a skin writes ../assets/<slug>/… , never ./assets/… . */

:root {
  /* the ground */
  --a-paper-img: none;                 /* skin: url("../assets/<slug>/paper-laid.jpg") */
  --a-paper-size: 460px;
  --a-paper-blend: multiply;
  --a-fibre: rgba(0, 0, 0, .022);      /* the CSS-only laid ground under the image */
  --a-laid: 3px;                       /* laid-line pitch */
  --a-chain: 24px;                     /* chain-line pitch */
  /* the engraved panel */
  --a-engrave-rule: rgba(255, 255, 255, .55);
  --a-engrave-inner: rgba(255, 255, 255, .26);
  --a-engrave-inset: 6px;
  --a-engrave-plate: linear-gradient(rgba(20, 15, 11, .24), rgba(20, 15, 11, .46));
  /* corner marks */
  /* A corner ornament turns a right angle, so the four corners are four images. Set --a-mark-img
     for a symmetrical mark; set the four when the motif has an orientation (the skin ships the
     rotations as files — CSS cannot flip a background layer). */
  --a-mark-img: none;                  /* skin: url("../assets/<slug>/azulejo-corner-tl.webp") */
  --a-mark-size: 16px;
  --a-mark-len: 15px;
  --a-mark-inset: 6px;
  --a-mark-rule: currentColor;
  --a-mark-opacity: .6;
  /* seams, vignette, parallax */
  --a-seam-h: 12px;
  --a-seam-rule: rgba(0, 0, 0, .1);
  --a-seam-img: none;
  --a-vig: rgba(20, 15, 11, .42);
  --a-vig-blur: clamp(2.5rem, 7vw, 6rem);
  --a-parallax: 12%;
}

/* Q1 · the ground. Laid paper is two rulings at right angles — close-pitch laid lines and a
   wide chain line — plus the sheet's own scan when the skin names one. No class: any site
   already asking for paper gets the ground its grain sits in. */
[data-atelier~="paper"] body {
  background-image:
    var(--a-paper-img),
    repeating-linear-gradient(0deg, var(--a-fibre) 0 1px, transparent 1px var(--a-laid)),
    repeating-linear-gradient(90deg, transparent 0 calc(var(--a-chain) - 1px), var(--a-fibre) calc(var(--a-chain) - 1px) var(--a-chain));
  background-size: var(--a-paper-size) auto, auto, auto;
  background-blend-mode: var(--a-paper-blend), normal, normal;
  background-attachment: fixed, scroll, scroll;
}

/* Q2 · the engraved panel. Type set INSIDE a fine double rule, directly on a photograph — no
   sheet of paper, nothing to cut out. The outer rule is the element's border; the inner one is
   ::before, inset by --a-engrave-inset. Pairs with .a-marks, which owns ::after. */
.a-engrave {
  position: relative;
  border: 1px solid var(--a-engrave-rule);
  background-image: var(--a-engrave-plate);
}
.a-engrave::before {
  content: "";
  position: absolute;
  inset: var(--a-engrave-inset);
  border: 1px solid var(--a-engrave-inner);
  pointer-events: none;
}
@supports (backdrop-filter: blur(2px)) {
  .a-engrave--veil { backdrop-filter: blur(2px) saturate(.94); }
}

/* Q3 · corner marks. One bordered rectangle masked down to four corner squares, so the four
   marks are one element and cannot drift apart. The same four squares crop --a-mark-img when a
   skin names a motif — set --a-mark-rule: transparent and the drawn bracket steps aside for it.
   Uses ::after: never put .a-marks on an element whose ::after is already spoken for. */
.a-marks { position: relative; }
.a-marks::after {
  --a-mask: linear-gradient(#000, #000);
  content: "";
  position: absolute;
  inset: var(--a-mark-inset);
  pointer-events: none;
  opacity: var(--a-mark-opacity);
  border: 1px solid var(--a-mark-rule);
  background-image:
    var(--a-mark-tl, var(--a-mark-img)), var(--a-mark-tr, var(--a-mark-img)),
    var(--a-mark-bl, var(--a-mark-img)), var(--a-mark-br, var(--a-mark-img));
  background-size: var(--a-mark-size) var(--a-mark-size);
  background-repeat: no-repeat;
  background-position: left top, right top, left bottom, right bottom;
  -webkit-mask-image: var(--a-mask), var(--a-mask), var(--a-mask), var(--a-mask);
  mask-image: var(--a-mask), var(--a-mask), var(--a-mask), var(--a-mask);
  -webkit-mask-size: var(--a-mark-len) var(--a-mark-len);
  mask-size: var(--a-mark-len) var(--a-mark-len);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: left top, right top, left bottom, right bottom;
  mask-position: left top, right top, left bottom, right bottom;
}

/* Q4 · the seam. A printed edge where one passage meets the next: a hairline and, when the skin
   names one, a repeating motif hanging off it. Uses ::before. */
.a-seam { position: relative; }
.a-seam::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: var(--a-seam-h);
  pointer-events: none;
  background-image: linear-gradient(var(--a-seam-rule) 0 1px, transparent 1px), var(--a-seam-img);
  background-repeat: repeat-x, repeat-x;
  background-position: top left, top left;
  background-size: auto, auto var(--a-seam-h);
}

/* Q5 · the vignette. Light falls off at the edge of a lens; a flat rectangle of photograph is
   the tell of a stock crop. Pseudo, not inset box-shadow: a shadow on the frame paints under the
   <img> child and would never be seen. */
.a-vignette { position: relative; isolation: isolate; }
.a-vignette::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  box-shadow: inset 0 0 var(--a-vig-blur) var(--a-vig);
}

/* Q6 · parallax. The travelling half of the scroll-linked pair: .a-drift scales a crop, .a-parallax
   moves it. The child is grown by --a-parallax so the frame stays covered at both ends of the
   range. One mechanism per element: never both classes on the same frame. */
.a-parallax { overflow: clip; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-parallax > img,
    [data-atelier~="motion"] .a-parallax > picture > img {
      height: calc(100% + var(--a-parallax));
      animation: a-parallax linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
      will-change: translate;
    }
  }
}
@keyframes a-parallax {
  from { translate: 0 calc(var(--a-parallax) * -1); }
  to { translate: 0 0; }
}

/* ============================================================ R · ornament + ambient =
   Added 2026-08-20. Two things a page gets that it did not have: printed ornament between
   passages, and the small set of motions allowed to run for as long as the page is open.

   The standing constraints this section is written under:
   · every ornament renders from CSS; --a-*-img is an enhancement, never a dependency (Q's rule)
   · every continuous animation lives behind [data-atelier~="motion"] AND a reduced-motion query,
     is slower than 8s, moves less than the eye tracks, and never runs on type
   · a page may carry at most three ornament rules and two ambient surfaces. The reason .a-breathe
     was allowed as "the one continuous animation" was that a page full of them is a screensaver;
     that reasoning is unchanged, so the count is the constraint rather than the effect. */

/* R1 · the ornament rule. Two hairlines with a mark held between them — the thing a printer sets
   between one passage and the next when a rule alone would be too loud. The mark is a lozenge
   drawn in CSS; a skin that owns a motif points --a-orn-img at it and the lozenge steps aside. */
.a-orn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--a-orn-gap, clamp(.7rem, 2vw, 1.3rem));
  width: min(100%, var(--a-orn-w, 30rem));
  margin: var(--a-orn-space, clamp(1.8rem, 4vw, 3rem)) auto;
  color: var(--a-orn-ink, var(--rule));
}
.a-orn::before,
.a-orn::after {
  content: "";
  flex: 1 1 auto;
  height: var(--rule-w, 1px);
  background: currentColor;
}
/* Not centred by flex alone: the mark is optically heavier below its own middle, so the rules
   are lifted a hair rather than the mark being nudged down. */
.a-orn-mark {
  flex: 0 0 auto;
  width: var(--a-orn-size, 15px);
  height: var(--a-orn-size, 15px);
  rotate: 45deg;
  border: var(--rule-w, 1px) solid currentColor;
  background:
    var(--a-orn-img, none) center / contain no-repeat,
    radial-gradient(circle at 50% 50%, currentColor 0 1.5px, transparent 1.6px);
  color: var(--a-orn-mark-ink, var(--accent, currentColor));
}
/* With a motif the frame comes off: two ornaments on one mark is the box-and-bracket mistake. */
.a-orn--motif .a-orn-mark {
  rotate: none;
  border: 0;
  width: var(--a-orn-size, 26px);
  height: var(--a-orn-size, 26px);
  background-image: var(--a-orn-img, none);
}
.a-orn--short { --a-orn-w: 13rem; }
.a-orn--wide { --a-orn-w: min(100%, 46rem); }

/* R2 · motes in the light. An element, not a pseudo — every frame this belongs inside already
   spends ::before on a scrim and ::after on a vignette. Two tiled fields of soft dots at
   different sizes and speeds; the parallax between them is what stops it reading as a texture.
   Drop it as the last child of any positioned frame:  <span class="a-dust"></span> */
.a-dust {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--a-dust-opacity, .5);
  mix-blend-mode: screen;
  background-image:
    radial-gradient(1.5px 1.5px at 18% 32%, var(--a-dust-ink, rgba(255, 244, 224, .9)), transparent 100%),
    radial-gradient(1px 1px at 62% 14%, var(--a-dust-ink, rgba(255, 244, 224, .8)), transparent 100%),
    radial-gradient(1.5px 1.5px at 84% 62%, var(--a-dust-ink, rgba(255, 244, 224, .75)), transparent 100%),
    radial-gradient(1px 1px at 34% 78%, var(--a-dust-ink, rgba(255, 244, 224, .8)), transparent 100%),
    radial-gradient(2px 2px at 72% 88%, var(--a-dust-ink, rgba(255, 244, 224, .6)), transparent 100%);
  background-size: var(--a-dust-step, 340px 300px);
}

/* R3 · the warm bloom. A dark passage lit by something off the page: one soft radial that
   swells and fades on a long clock. Candlelight, not a pulse. Same element pattern as R2. */
.a-glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(var(--a-glow-shape, 60% 70% at 22% 84%),
    var(--a-glow-ink, rgba(255, 196, 128, .22)), transparent 70%);
  opacity: .7;
}

@media (prefers-reduced-motion: no-preference) {
  /* The dust drifts up and across on a 96s round trip: a mote should never be seen to arrive. */
  [data-atelier~="motion"] .a-dust { animation: a-dust-drift 96s linear infinite; }
  @keyframes a-dust-drift {
    from { background-position: 0 0, 0 0, 0 0, 0 0, 0 0; }
    to { background-position: 40px -300px, -60px -300px, 30px -300px, -40px -300px, 70px -300px; }
  }
  /* The bloom breathes on an 18s clock, which is slower than a resting breath and reads as a
     flame steadying rather than as a fade. */
  [data-atelier~="motion"] .a-glow { animation: a-glow-breathe 18s ease-in-out infinite; }
  @keyframes a-glow-breathe {
    0%, 100% { opacity: .55; scale: 1; }
    50% { opacity: .85; scale: 1.06; }
  }
  /* R4 · an ornament is never quite still. 1.5px and 11s: felt at the edge of the eye, invisible
     to anyone looking straight at it. `translate`/`rotate`, never `transform` — a reveal or a
     hover may be holding one on the same element. */
  [data-atelier~="motion"] .a-sway { animation: a-sway 11s ease-in-out infinite; }
  @keyframes a-sway {
    0%, 100% { translate: 0 -1.5px; }
    50% { translate: 0 1.5px; }
  }
  [data-atelier~="motion"] .a-orn .a-orn-mark { animation: a-orn-turn 24s ease-in-out infinite; }
  @keyframes a-orn-turn {
    0%, 100% { scale: 1; opacity: .9; }
    50% { scale: 1.09; opacity: 1; }
  }
}

/* ============================================================ S · emphasis ==========
   Added 2026-08-20 (later), answering "add more noticeable animations on the pages". Section P
   made the first screen move and C made blocks arrive at 18px, which is the amount a page moves
   when it settles rather than when something happens. This section is the louder register.

   Everything here is a ONE-SHOT: it is spent as an element arrives and is not running afterwards,
   so section R's budget — three ornament rules and two ambient surfaces per page — is untouched
   however much of this a page uses. The constraints that do apply:
   · scroll-linked through view(), inside @supports + a reduced-motion query, like all of C
   · the keyframes own `transform` and `clip-path` only; every hover/state effect stays on the
     independent properties (translate/scale/rotate), because a filled animation beats a normal
     declaration in the cascade — fifth time that has been paid for, see DECISIONS.md
   · reduced motion, or no view() support, gets the finished state and nothing else

     .a-rise   the loud entrance: 54px of travel. For the block a section is about. Travel only:
               anything that rests in its from-state off screen may not change an element's size.
     .a-draw   a rule that draws itself out from --a-draw-from (default: its left end).
     .a-wipe   type uncovered left to right behind its own edge. Eyebrows, short heads.
     .a-mask   a photograph uncovered upwards out of a slight over-scale. A louder .a-unveil. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-rise {
      animation: a-rise linear both;
      animation-timeline: view();
      animation-range: entry 2% entry 64%;
    }
    [data-atelier~="motion"] .a-draw {
      transform-origin: var(--a-draw-from, left center);
      animation: a-draw linear both;
      animation-timeline: view();
      animation-range: entry 8% entry 58%;
    }
    [data-atelier~="motion"] .a-wipe {
      animation: a-wipe linear both;
      animation-timeline: view();
      animation-range: entry 6% entry 52%;
    }
    [data-atelier~="motion"] .a-mask > img,
    [data-atelier~="motion"] .a-mask > picture > img,
    [data-atelier~="motion"] img.a-mask {
      animation: a-mask linear both;
      animation-timeline: view();
      animation-range: entry 4% cover 34%;
    }
    /* A frame whose child scales must clip it — .a-drift and .a-settle both learned this. */
    [data-atelier~="motion"] .a-mask { overflow: clip; }
    /* An ordered group. view() hands every child of one row the same range, so a row arrives as
       a block unless the offset is authored — nothing about a row can be measured. Same shape as
       .a-stagger, one register louder. */
    [data-atelier~="motion"] .a-order > *:nth-child(2) { animation-range: entry 9% entry 72%; }
    [data-atelier~="motion"] .a-order > *:nth-child(3) { animation-range: entry 16% entry 80%; }
    [data-atelier~="motion"] .a-order > *:nth-child(4) { animation-range: entry 23% entry 88%; }
    [data-atelier~="motion"] .a-order > *:nth-child(n + 5) { animation-range: entry 30% entry 96%; }
  }
}
@keyframes a-rise {
  /* Travel and opacity ONLY — no scale. A scroll-linked entrance holds its from-state for as long
     as the element is below the fold, so a 1.2% scale in here shrinks every hit target inside it
     for the whole time it is off screen: the gate read three 44px card links as 43.34px. A
     one-shot that is allowed to rest in its from-state may not change an element's SIZE. */
  from { opacity: 0; transform: translateY(var(--a-rise-y, 54px)); }
  to { opacity: 1; transform: none; }
}
@keyframes a-draw { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes a-wipe {
  from { opacity: 0; clip-path: inset(0 100% 0 0); }
  14% { opacity: 1; }
  to { opacity: 1; clip-path: inset(0 0 0 0); }
}
@keyframes a-mask {
  from { clip-path: inset(0 0 100% 0); transform: scale(var(--a-mask-from, 1.07)); }
  to { clip-path: inset(0 0 0 0); transform: scale(1); }
}

/* ============================================================ T · the hinge ==========
   Added 2026-08-22 (track B). The abstraction the arrival's envelope turned out to be is not
   "envelope": it is **a hinged cover over a container with contents that rise, and a catch you
   break to start it**. Site 1 opens an envelope; other sites open a box lid, an album cover, a
   folded programme, a locket. Those are one motion with different axes and angles.

   HONEST LIMIT, recorded so it is not promised later: a hinge covers roughly five of the eight
   sites. A hand fan is a *spread* and a lantern is a *light-up* — different primitives, to be
   written when a site actually asks for one and never speculatively.

   THE PARTS. A skin says only what its parts are; everything below is the engine's.

     .a-open            the object. Carries data-stage. Owns the perspective.
     .a-open-body       the container. As many layers as the skin wants (back, pocket, front).
     .a-open-shade      the cover's shadow, cast on the body while it swings. Optional.
     .a-open-lid        the hinged cover. Two real child faces, not pseudo-elements, so a skin
                        can put a photograph, a monogram or a lining pattern on either side.
       .a-open-lid-out  the face you see closed.
       .a-open-lid-in   the face you see once it is thrown back. The engine flips it.
     .a-open-fill       the contents, which rise out as the cover opens.
     .a-open-catch      the thing you press. Its own break is the skin's — wax, a clasp, a ribbon.
                        The engine fades it once broken; a skin that breaks it into pieces keeps it
                        opaque and animates the pieces.

   THE STAGES, the same vocabulary the arrival already uses, so one driver can run either:
     sealed → cracked → opening → rising, plus still-open for the reduced-motion still.

   THE TOKENS. Every one is read with a fallback at its use site and declared nowhere, so a skin
   can set them on any ancestor. Defaults ARE site 1's envelope.

     --a-open-dur      1280ms   the swing. THE retiming contract: see the note below.
     --a-open-angle    -101deg  where the cover comes to rest. Every beat is a fraction of it.
     --a-open-turn     .52      the fraction of the swing at which the cover crosses 90°.
                                A MEASUREMENT, per angle — read it off the rendered rotation at
                                real times; do not infer it from the keyframe list. The easing is
                                front-loaded, so the 48%→78% beat spends most of its angle in its
                                first few percent.
     --a-open-persp    600px    ~1.25× the object's rendered width. At 3× a rotate reads as a
                                vertical squash rather than a turn through space.
     --a-open-lid-size 62%    the cover's height (x hinge) or width (y hinge).
     --a-open-ease              the swing's curve. Shared by the relights, so they cannot drift.
     --a-open-lit / -edge / -back        the outer face's luminance: facing you, edge-on, away.
     --a-open-in-fold / -in-lit          the inner face's: in the fold, then in the light.
     --a-open-shadow            the cast shadow's colour.
     --a-open-rise-y   -66%     how far the contents come out.
     --a-open-fill-dur          the contents' beat. Defaults to 1.03× the swing.

   THE RETIMING CONTRACT. A skin retimes a hinge by setting `--a-open-dur` and NOTHING else. The
   swing, both relights and the reveal all derive from it — the reveal as
   `calc(dur * var(--a-open-turn))`, so it stays on the cover's 90° crossing at any duration. This
   exists because the envelope was retimed by overriding `animation-duration` on the cover alone,
   which left the lining's reveal on a hardcoded delay and desynced it to 29% of the swing.

   ONE LIGHT DIRECTION, above and behind the reader, and every value here obeys it. Paper turning
   away from a light goes lit → edge-on → dark while its lining does the reverse; a cover that
   turns at constant luminance is the tell that no easing curve, frame rate or photograph fixes.

   NOT YET WORN BY A SITE. Site 1's `.a-env-*` block above is untouched and still ships: its
   opening is out for the user's judgement, and migrating it while it is being judged would change
   the thing under review. `tools/Hinge Specimen.html` is what proves this section. */

.a-open {
  position: relative;
  perspective: var(--a-open-persp, 600px);
  perspective-origin: 50% 34%;
  transform-style: preserve-3d;
}
.a-open-body,
.a-open-shade,
.a-open-lid,
.a-open-fill {
  position: absolute;
  inset: 0;
}
.a-open-fill { z-index: 2; }
.a-open-body { z-index: 3; }
.a-open-shade { z-index: 4; opacity: 0; pointer-events: none; }
.a-open-lid { z-index: 5; }
.a-open-catch { z-index: 7; }

/* The cover. It turns on the independent `rotate` property, which leaves `transform` free for
   whatever beat sets the object down while its contents come out. */
.a-open-lid {
  transform-style: preserve-3d;
  rotate: var(--a-open-axis, x) 0deg;
}
.a-open[data-open-axis="y"] .a-open-lid { --a-open-axis: y; }
/* An x hinge is along the top edge; a y hinge is along the left. */
.a-open-lid { inset: 0 0 auto 0; height: var(--a-open-lid-size, 62%); transform-origin: 50% 0; }
.a-open[data-open-axis="y"] .a-open-lid {
  inset: 0 auto 0 0; height: auto; width: var(--a-open-lid-size, 62%); transform-origin: 0 50%;
}
.a-open-lid-out,
.a-open-lid-in {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  background-size: cover;
  background-position: center;
  /* Declared AT REST at zero strength, with the VALUES animating. Switched on at a stage instead,
     a filter creates a compositing layer on the exact frame the swing starts. */
  filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
}
/* The engine flips the inner face; the skin only says what is on it. */
.a-open-lid-in { transform: rotateX(180deg); opacity: 0; }
.a-open[data-open-axis="y"] .a-open-lid-in { transform: rotateY(180deg); }

/* ---- the beats ----------------------------------------------------------
   Anticipation, swing, overshoot, settle — every angle a fraction of --a-open-angle, so one token
   moves the whole motion and the curve's shape is preserved at any angle. The fractions are the
   envelope's own tuned numbers (7 / 71 / 111 / 95 / 100 percent of -101°). */
.a-open[data-stage="opening"] .a-open-lid,
.a-open[data-stage="rising"] .a-open-lid {
  animation: a-open-turn-x var(--a-open-dur, 1280ms) var(--a-open-ease, cubic-bezier(.3, .66, .2, 1)) both;
}
.a-open[data-open-axis="y"][data-stage="opening"] .a-open-lid,
.a-open[data-open-axis="y"][data-stage="rising"] .a-open-lid {
  animation-name: a-open-turn-y;
}
@keyframes a-open-turn-x {
  from { rotate: x 0deg; }
  12% { rotate: x calc(var(--a-open-angle, -101deg) * .07); }
  48% { rotate: x calc(var(--a-open-angle, -101deg) * .71); }
  78% { rotate: x calc(var(--a-open-angle, -101deg) * 1.11); }
  90% { rotate: x calc(var(--a-open-angle, -101deg) * .95); }
  to { rotate: x var(--a-open-angle, -101deg); }
}
@keyframes a-open-turn-y {
  from { rotate: y 0deg; }
  12% { rotate: y calc(var(--a-open-angle, -101deg) * .07); }
  48% { rotate: y calc(var(--a-open-angle, -101deg) * .71); }
  78% { rotate: y calc(var(--a-open-angle, -101deg) * 1.11); }
  90% { rotate: y calc(var(--a-open-angle, -101deg) * .95); }
  to { rotate: y var(--a-open-angle, -101deg); }
}

/* ---- the relight and the reveal -----------------------------------------
   Two animations per face, and they do not overlap: the *-light pair owns luminance and shadow on
   the swing's own clock, the *-face pair owns opacity and fires as a single step at the 90°
   crossing. A step, not a crossfade: dissolving between two faces of one plane mid-rotation reads
   as a glitch. Backface culling stays underneath as the braces — a filtered face is a
   rendering-order bet, and that bet has been lost here before. */
.a-open[data-stage="opening"] .a-open-lid-out,
.a-open[data-stage="rising"] .a-open-lid-out {
  animation:
    a-open-out-light var(--a-open-dur, 1280ms) var(--a-open-ease, cubic-bezier(.3, .66, .2, 1)) both,
    a-open-face-out calc(var(--a-open-dur, 1280ms) * var(--a-open-turn, .52)) steps(1, end) both;
}
.a-open[data-stage="opening"] .a-open-lid-in,
.a-open[data-stage="rising"] .a-open-lid-in {
  animation:
    a-open-in-light var(--a-open-dur, 1280ms) var(--a-open-ease, cubic-bezier(.3, .66, .2, 1)) both,
    a-open-face-in calc(var(--a-open-dur, 1280ms) * var(--a-open-turn, .52)) steps(1, end) both;
}
@keyframes a-open-face-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes a-open-face-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes a-open-out-light {
  from { filter: brightness(var(--a-open-lit, 1.04)) drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
  12% { filter: brightness(var(--a-open-lit, 1.04)) drop-shadow(0 -1px 3px var(--a-open-shadow, rgba(0, 0, 0, .5))); }
  48% { filter: brightness(var(--a-open-edge, .72)) drop-shadow(0 -5px 9px var(--a-open-shadow, rgba(0, 0, 0, .5))); }
  to { filter: brightness(var(--a-open-back, .6)) drop-shadow(0 -10px 16px var(--a-open-shadow, rgba(0, 0, 0, .5))); }
}
@keyframes a-open-in-light {
  from { filter: brightness(var(--a-open-in-fold, .5)) drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
  48% { filter: brightness(var(--a-open-in-fold, .5)) drop-shadow(0 -4px 8px var(--a-open-shadow, rgba(0, 0, 0, .5))); }
  78% { filter: brightness(var(--a-open-in-lit, .96)) drop-shadow(0 -11px 17px var(--a-open-shadow, rgba(0, 0, 0, .5))); }
  to { filter: brightness(var(--a-open-in-lit, .96)) drop-shadow(0 -9px 15px var(--a-open-shadow, rgba(0, 0, 0, .5))); }
}

/* ---- the cover's shadow on the body ------------------------------------- */
.a-open[data-stage="cracked"] .a-open-shade { opacity: .5; transition: opacity 420ms var(--ease, ease); }
.a-open[data-stage="opening"] .a-open-shade,
.a-open[data-stage="rising"] .a-open-shade {
  animation: a-open-shade-off calc(var(--a-open-dur, 1280ms) * .72) var(--ease, ease) both;
}
@keyframes a-open-shade-off {
  from { opacity: .5; }
  38% { opacity: .28; }
  to { opacity: 0; }
}

/* ---- the contents ------------------------------------------------------
   A keyframe animation, not a transition: a transition triggered by an attribute flip that lands
   inside the cover's preserve-3d animation gets its style recalc deferred and starts several
   hundred ms late. The contents stay BEHIND the body the whole way out — no z-index change — which
   is the one thing that decides whether something is coming out of a container or sitting on top of
   a picture of one. */
.a-open[data-stage="rising"] .a-open-fill {
  animation: a-open-rise var(--a-open-fill-dur, calc(var(--a-open-dur, 1280ms) * 1.03)) cubic-bezier(.16, .84, .24, 1) both;
}
@keyframes a-open-rise {
  from { transform: none; }
  26% { transform: translateY(calc(var(--a-open-rise-y, -66%) * .23)) rotate(.55deg); }
  84% { transform: translateY(calc(var(--a-open-rise-y, -66%) * 1.06)) rotate(-.2deg) scale(1.035); }
  to { transform: translateY(var(--a-open-rise-y, -66%)) rotate(0deg) scale(1.04); }
}

/* ---- the catch ---------------------------------------------------------
   The engine gives it the affordance and takes it away once it is broken. What breaking LOOKS like
   — wax in four pieces, a clasp springing, a ribbon falling — is the skin's, because it is the one
   part of a hinge that is never the same twice. */
.a-open-catch { position: absolute; cursor: pointer; }
.a-open[data-stage="sealed"] .a-open-catch:active { scale: .965; animation: none; }
.a-open:not([data-stage="sealed"]) .a-open-catch { pointer-events: none; }
/* Once broken it is gone. A skin that breaks its catch into PIECES — wax in four fragments, a
   clasp springing — sets `.a-open-catch { opacity: 1; transition: none }` and animates the pieces
   instead, or the container's fade takes them with it half way down. */
.a-open-catch { transition: opacity 320ms var(--ease, ease) 120ms; }
.a-open:not([data-stage="sealed"]) .a-open-catch { opacity: 0; }

/* ---- reduced motion: a still, not a skip -------------------------------
   Nothing travels, nothing rotates: the cover and the catch fade away and the contents are already
   in their opened position. Opt-in per site, the same way the arrival's still is. */
.a-open[data-stage="still-open"] .a-open-lid,
.a-open[data-stage="still-open"] .a-open-catch { opacity: 0; }
.a-open[data-still] .a-open-lid,
.a-open[data-still] .a-open-catch { transition: opacity 720ms var(--ease, ease); }
.a-open[data-still] .a-open-fill { transform: translateY(var(--a-open-rise-y, -66%)); opacity: 0; transition: opacity 880ms var(--ease, ease); }
.a-open[data-still][data-stage="still-open"] .a-open-fill { opacity: 1; }


/* ══════════════════════════════════════════════════════════════════════════════
   PETALS — the ambient surface engine/petals.js paints on.  Added 2026-08-23 for
   site 2, engine-side by decision so sites 3-8 inherit it.

   The engine owns only that it is a fixed, un-hittable, un-selectable full-page
   layer beneath the chrome.  Colour, size, density and fall speed are the skin's,
   read off this element by the module.  A skin that does not list "petals" in
   <html data-atelier> gets no canvas painted at all — the module returns early —
   so the element may be left in the markup harmlessly.

   It counts as ONE of the two ambient surfaces a page is allowed (section R).
   ══════════════════════════════════════════════════════════════════════════════ */
.a-petals {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
  user-select: none;
  z-index: 3;
}
@media print { .a-petals { display: none; } }

/* ============================================================ U · the clip arrival ==
   Added 2026-08-26. Section T is the DEFAULT arrival: a CSS hinge, drawn and animated. This is the
   exception a site earns — an arrival carried by a **video the build pauses on a measured frame**,
   with the contents rising out of the frame it stopped on. Site 1 is the first to wear it.

   IT IS THE SAME MODULE. Same `[data-wsd="arrival"]`, same store key, same Skip / Escape / focus
   contract, same "a moment, never a gate". A site opts in with `data-arrival-clip` on the arrival
   node and the markup below; `atelier.js` takes the clip branch and never runs the four beats.

   REISSUING IT FOR ANOTHER SITE swaps four things and nothing else — the four variables the prompt
   sheet is written around:

     · THE WALL      --a-clip-wall        the ground the clip was shot against, ALSO the plate's own
                                          background, colour-matched to the clip by measurement
     · THE ENVELOPE  the clip itself      <video> src, plus its first and last frames
     · THE WAX       --a-clip-seal-*      where the press target sits, measured off frame 0
     · THE CARD      the card image       plus --a-clip-card-*, measured off the last frame

   Everything else — the plate, the pocket clip, the rise, the lift, the fallbacks — is the route.

   THE GEOMETRY IS MEASURED, NEVER GUESSED, AND IT IS PER-CLIP. Every default below is site 1's,
   measured off `arrival-wall.mp4`. A second site re-measures its own and sets the tokens; it does
   not nudge these. See `DECISIONS.md` → *The arrival's assets, accepted*.

     --a-clip-plate     1140px   the plate's ideal width. It is not the viewport: on a phone the
                                 plate is DELIBERATELY wider than the screen and the sides — which
                                 are only wall — crop away, because an envelope at 34% of 390px is
                                 a postage stamp. Capped at 170vh so it can never outgrow the height.
     --a-clip-mouth     37.4%    the pocket's mouth, as a fraction of the plate's height. The card
                                 is clipped here and can never be seen below it.
     --a-clip-seal-x/y  50.4% / 61.95%   the wax's centroid in frame 0: the press target.
     --a-clip-seal-size 9%       of the plate's width, with a 52px floor — a legal target on a phone.
     --a-clip-card-w    28.2%    of the plate's width.
     --a-clip-card-x    35.9%    the card's left edge; card and envelope share a centre.
     --a-clip-card-over 8.3%     how far the card still sits inside the pocket at rest, as a
                                 fraction of the MOUTH's height. Zero would read as a card floating
                                 above an envelope rather than one standing in it.
     --a-clip-card-ar   2176/1632   the card render's own ratio.
     --a-clip-rise      1750ms   the card's rise. Also read by atelier.js, which lands after it.
     --a-clip-read      1600ms   the beat the card is left alone to be read. Then it lifts.
     --a-clip-lift      2400ms   the exit. Set --a-arr-exit to match, or the node is removed early.

   NO SECOND PRESS. One gesture opens it and the sequence plays itself out: card rises, a beat to
   read it, then the whole camera lifts up and out of frame and the wall it was shot against fades
   into the page underneath. Skip and Escape still cut it short. */

.a-arrival--clip {
  display: grid;
  place-items: center;
  overflow: hidden;
  /* The ground around the plate. The plate covers the width on any desktop; on a short landscape
     screen a band of this shows above and below, and the plate's edges are feathered into it. */
  background: var(--a-arr-bg, #14100c);
  transition: opacity var(--a-clip-fade, 1000ms) var(--ease, ease) var(--a-clip-fade-wait, 900ms);
}
/* The lattice belongs to the drawn stage; here the plate carries the real photographed wall. */
.a-arrival--clip::before { content: none; }
.a-arrival--clip[data-leaving] { opacity: 0; }

.a-clip {
  position: relative;
  width: max(100%, min(var(--a-clip-plate, 1140px), 170vh));
  aspect-ratio: 16 / 9;
  max-height: 100%;
  overflow: hidden;
  background: var(--a-clip-wall, none) center / cover, var(--a-arr-bg, #14100c);
  /* Only the top and bottom edges can ever meet the ground — the sides are either cropped by the
     overlay or flush with the viewport — so the plate is feathered vertically and nowhere else. */
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 5%, #000 95%, transparent);
  mask-image: linear-gradient(180deg, transparent, #000 5%, #000 95%, transparent);
}

/* The camera holds the clip AND the card, so the exit lifts both as one shot. */
.a-clip-cam { position: absolute; inset: 0; z-index: 2; will-change: transform; }
.a-clip-film { position: absolute; inset: 0; }
.a-clip-film > * {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The clip's wall and the plate's wall are the same photograph at the same scale, so a feathered
     side edge has nothing to give it away. This is the whole payoff for locking the camera. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
}
.a-clip-still { opacity: 0; transition: opacity 140ms linear; }
[data-clip-still] .a-clip-still { opacity: 1; }
/* The poster is the clip's own first frame: a sealed envelope is on screen before a byte of video
   decodes, and it is a real element because it carries the press target. */
.a-clip-poster { z-index: 2; transition: opacity 620ms var(--ease, ease) 60ms; }
.a-arrival--clip[data-open] .a-clip-poster { opacity: 0; }

/* the press target, on the wax */
.a-arrival--clip .a-clip-seal {
  position: absolute;
  left: var(--a-clip-seal-x, 50.4%);
  top: var(--a-clip-seal-y, 61.95%);
  transform: translate(-50%, -50%);
  width: var(--a-clip-seal-size, 9%);
  min-width: 52px;
  aspect-ratio: 1;
  z-index: 5;
  border: 0;
  padding: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: opacity 400ms var(--ease, ease);
}
.a-clip-seal::after {
  content: "";
  position: absolute;
  inset: -18%;
  border-radius: 50%;
  background: radial-gradient(circle, var(--a-clip-hint, rgba(255, 226, 170, .32)), transparent 68%);
  animation: a-clip-breathe 3600ms ease-in-out infinite;
}
.a-clip-seal:hover::after,
.a-clip-seal:focus-visible::after { animation-play-state: paused; opacity: 1; }
.a-arrival--clip[data-open] .a-clip-seal { opacity: 0; pointer-events: none; }
@keyframes a-clip-breathe {
  0%, 100% { transform: scale(1); opacity: .5; }
  50%      { transform: scale(1.13); opacity: 1; }
}

/* The mouth ends exactly at the pocket's mouth, so the card is a sheet inside a pocket rather than
   a rectangle laid on a video. */
.a-clip-mouth {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: var(--a-clip-mouth, 37.4%);
  overflow: hidden;
  z-index: 3;
  pointer-events: none;
}
.a-clip-card {
  position: absolute;
  left: var(--a-clip-card-x, 35.9%);
  width: var(--a-clip-card-w, 28.2%);
  bottom: calc(var(--a-clip-card-over, 8.3%) * -1);
  aspect-ratio: var(--a-clip-card-ar, 2176 / 1632);
  container-type: size;
  filter: drop-shadow(0 14px 26px rgba(0, 0, 0, .55));
  transform: translateY(118%);
  /* Promoted from mount, not from the rise — 2026-08-26, fourth defect.

     The card is a large cut-out (1400px of WebP downscaled ~5×) wearing a colour filter on the image
     and a wide drop-shadow on this element, and it sits outside the pocket's `overflow: hidden` until
     it rises. Without promotion none of that raster work happens until the first frame of the rise
     — which is the same instant the clip pauses — so it landed as a ~1s main-thread block that ate
     the whole read beat and starved the rAF holding the cut. `will-change` gives the card its own
     compositor layer at mount: the filters rasterize once, early, and the rise is then a transform
     on an already-painted layer instead of a per-frame re-filter. */
  will-change: transform;
}
.a-clip-card > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; z-index: 1; }
[data-risen] .a-clip-card {
  animation: a-clip-rise var(--a-clip-rise, 1750ms) cubic-bezier(.16, .84, .24, 1) both;
}
@keyframes a-clip-rise { from { transform: translateY(118%); } to { transform: translateY(0); } }

/* The live text layer. It mirrors the printed card word for word and is NOT optional: on a blank
   render it is what is seen, and on a lettered one it is still what a screen reader, a search index
   and a text selection get. The skin owns the type; the engine owns where the block sits.
   Layer order is the skin's one call: --a-clip-ink-z defaults to 2, ABOVE the paper, which is what
   a blank render needs. A skin shipping the lettered render sets it to 0 and the same markup goes
   behind the printed words, where it is read but not seen. */
.a-clip-ink {
  position: absolute;
  inset: 0;
  z-index: var(--a-clip-ink-z, 2);
  display: grid;
  align-content: center;
  justify-items: center;
  padding: var(--a-clip-ink-pad, 12% 11% 13%);
  text-align: center;
}
.a-clip-vig {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  background: radial-gradient(82% 96% at 50% 52%, transparent 46%, var(--a-clip-vig-ink, rgba(0, 0, 0, .62)));
  transition: opacity var(--a-clip-lift, 2400ms) var(--ease, ease);
}

/* ---- the exit: one lift, no second press --------------------------------------------
   The camera takes one small breath in and then leaves the frame with everything in it. The wall
   does not move: it was the page all along, and the overlay fades off it. */
[data-leaving] .a-clip-cam {
  animation: a-clip-lift var(--a-clip-lift, 2400ms) cubic-bezier(.58, 0, .24, 1) both;
}
[data-leaving] .a-clip-vig { opacity: .25; }
@keyframes a-clip-lift {
  0%   { transform: scale(1) translateY(0); }
  26%  { transform: scale(1.035) translateY(1.2%); }
  100% { transform: scale(1.075) translateY(-124%); }
}

/* Skip and the honesty mark sit on the ground, not on the plate: they belong to the page, and a
   design study says so where the lift cannot carry it away.

   ⚠ NOT IN A CORNER, and above the docks. Two lessons from 2026-08-26, both found by hand after the
   gate passed 40/40:

   · Both bottom corners of a wedsite are already taken — the honesty dock tab sits bottom-right and
     the music dock bottom-left, both `position: fixed; z-index: 88`. A Skip pinned to either corner
     renders at the right size, reports a 44px box, and is *completely unclickable*: the dock wins
     the hit test. So Skip goes in the centre column, stacked directly above the note, where nothing
     is pinned.
   · `.a-arrival` is z-index 80 — UNDER those docks. Fine for the drawn arrival, which lays its
     controls out inside a centred column; wrong for a full-bleed clip, where a fixed dock floats
     over the film. The clip variant is raised above them. The docks are not lost: the arrival is a
     full-screen moment that carries its own honesty note, and it leaves on its own.

   A target's size is not its reachability. `Probe Sweep` measures size; occlusion is a hit test. */
.a-arrival--clip { z-index: 90; }
.a-arrival--clip .a-arrival-skip {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  /* clear of the note, which is pinned to the same floor */
  bottom: calc(clamp(.9rem, 3vh, 2rem) + 2.4rem);
  z-index: 7;
}
.a-arrival--clip .a-arrival-note { z-index: 6; text-align: center; }

@media (prefers-reduced-motion: reduce) {
  /* The still is a picture, not a skip: the poster, one crossfade to the last frame, the card, out.
     Nothing travels except the card, and that beat is the invitation itself. */
  .a-clip-seal::after { animation: none; }
  [data-leaving] .a-clip-cam { animation: none; }
}
@media print { .a-arrival--clip { display: none !important; } }


/* ============================================================ X · the titles arrival ==
   Added 2026-10-01 for site 7. Placed here, beside T and U, because it is the same module: the same
   `[data-wsd="arrival"]`, store key, Skip / Escape / focus contract and "a moment, never a gate". A
   site opts in with `data-arrival-titles`; atelier.js runs titles() and neither the hinge nor the clip.

   WHAT IT IS: a drama's opening titles with no video. Shots and credit cards dissolve one into the
   next, each still pushes in slowly, and the last shot is flown into an element on the page (site 7:
   the cover's poster), so the opening ends AS the page rather than in front of it. Nothing here is a
   rendered clip, so a skin re-dresses all of it and every word stays live text.

   THE PARTS (markup is the skin's)
     .a-arrival--titles   the overlay. data-start="auto|press", data-landing while the last shot flies
     .a-titles            the stage. data-stage: held → rolling → landing; data-shot="<n>"
     .a-titles-shot       one shot or card, stacked in order. data-on is current, data-past was shown
     .a-titles-pic        a still. Pushes in while its shot is up
     .a-titles-credit     words over a shot. Its CHILDREN enter in turn; the element itself is what
                          fades on landing (an animated element ignores a static opacity)
     .a-titles-frame      the last shot's picture: laid out at the target, scaled to cover, flown home
     .a-titles-bar        the letterbox, --top and --foot. Leaves on landing
     .a-titles-hold       the press start's held frame, with [data-wsd-el="play"]
     .a-arrival-skip, .a-arrival-note   in the foot bar's centre column, never a corner (§U)

   TOKENS, all with a fallback at the use site
     --a-titles-ground --a-titles-ink --a-titles-bar-ink --a-titles-bar --a-titles-scrim
     --a-shot-hold (per shot, read by the engine) --a-shot-fade --a-shot-from --a-shot-to --a-shot-pos
     --a-credit-delay --a-credit-step --a-frame-to --a-land-pos --a-land-radius
     --a-titles-land (read by the engine) --a-titles-land-ease --a-titles-wait (read by the engine)

   THE CROP CONTRACT. The frame lands on its target only if both crop the same picture the same way:
   give `.a-titles-frame > img` the target's object-fit (cover) and object-position (--a-land-pos), and
   --a-land-radius the target's corner. Covering a cover-cropped box shows what covering the viewport
   would, so the held last shot looks like every other full-bleed shot. */

.a-arrival--titles {
  z-index: 90;   /* above every dock, as §U: a full-bleed moment out-stacks the page's furniture */
  --a-arr-exit: 700ms;
  display: block;
  color: var(--a-titles-ink, #f3f5f7);
  background-color: var(--a-titles-ground, #10141b);
  transition: background-color var(--a-titles-land, 1400ms) var(--ease, ease);
}
.a-arrival--titles::before { content: none; }
.a-arrival--titles[data-landing] { background-color: transparent; }
.a-arrival--titles[data-leaving] { opacity: 0; transition: opacity 700ms var(--ease, ease); }
/* The page's ambient layer (petals, snow) plays OVER the titles for as long as they are on screen,
   their fade out included, so it is one fall from the first card into the page. It cannot be hit,
   so it buries nothing. */
html:has(.a-arrival--titles:not([hidden])) .a-petals { z-index: 91; }

.a-titles { position: absolute; inset: 0; overflow: hidden; }

/* ---- the shots: a dissolve, never a cut to black ------------------------------------------
   The outgoing shot is HELD under the incoming one for the length of the dissolve, then let go, so
   the two never pass through the ground together. */
.a-titles-shot {
  position: absolute;
  inset: 0;
  overflow: hidden;
  opacity: 0;
  transition: opacity var(--a-shot-fade, 700ms) var(--ease, ease) var(--a-shot-fade, 700ms);
}
.a-titles-shot[data-on] { opacity: 1; z-index: 1; transition-delay: 0s; }
.a-titles-shot--card { background: var(--a-titles-ground, #10141b); }
.a-titles-shot::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: var(--a-titles-scrim,
    linear-gradient(180deg, rgba(0, 0, 0, .34), transparent 28%, transparent 52%, rgba(0, 0, 0, .62)));
  transition: opacity 500ms var(--ease, ease);
}
.a-titles-shot--card::after { content: none; }

.a-titles-pic,
.a-titles-frame > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  will-change: transform;
}
.a-titles-pic { object-position: var(--a-shot-pos, 50% 50%); }
/* One rule for current AND past, so the push does not restart or snap back while the shot is held
   under the next one. */
.a-titles-shot:is([data-on], [data-past]) .a-titles-pic {
  animation: a-titles-push calc(var(--a-shot-hold, 1800ms) + 2 * var(--a-shot-fade, 700ms)) linear both;
}
@keyframes a-titles-push {
  from { transform: var(--a-shot-from, scale(1.02)); }
  to   { transform: var(--a-shot-to, scale(1.1)); }
}

/* ---- the credits ---------------------------------------------------------------------------- */
.a-titles-credit { position: absolute; z-index: 2; margin: 0; transition: opacity 450ms var(--ease, ease); }
.a-titles-credit > * { display: block; }
.a-titles-shot[data-on] .a-titles-credit > * {
  animation: a-titles-credit 1000ms var(--ease, ease) both;
  animation-delay: calc(var(--a-credit-delay, 250ms) + var(--a-credit-step, 220ms) * var(--a-i, 0));
}
.a-titles-credit > :nth-child(2) { --a-i: 1; }
.a-titles-credit > :nth-child(3) { --a-i: 2; }
.a-titles-credit > :nth-child(4) { --a-i: 3; }
@keyframes a-titles-credit {
  from { opacity: 0; transform: translateY(.35em); filter: blur(4px); }
  to   { opacity: 1; transform: none; filter: none; }
}

/* ---- the frame that lands -------------------------------------------------------------------- */
.a-titles-frame {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: var(--a-land-radius, 0);
  transform-origin: 50% 50%;
  will-change: transform;
}
.a-titles-frame > img { object-position: var(--a-land-pos, 50% 50%); }
.a-titles-frame[data-placed] { inset: auto; transform: var(--a-land-from, none); }
/* the last shot's push is a TRANSITION, not an animation, so the flight can run it back to none from
   wherever it has reached instead of snapping */
.a-titles-shot[data-on] .a-titles-frame > img {
  transform: var(--a-frame-to, scale(1.04));
  transition: transform calc(var(--a-shot-hold, 1800ms) + var(--a-shot-fade, 700ms)) linear;
}
.a-arrival--titles[data-landing] .a-titles-shot[data-on] .a-titles-frame,
.a-arrival--titles[data-landing] .a-titles-shot[data-on] .a-titles-frame > img {
  transform: none;
  transition: transform var(--a-titles-land, 1400ms) var(--a-titles-land-ease, cubic-bezier(.65, 0, .25, 1));
}
.a-arrival--titles[data-landing] .a-titles-shot::after,
.a-arrival--titles[data-landing] .a-titles-credit { opacity: 0; }

/* ---- the letterbox ------------------------------------------------------------------------------ */
.a-titles-bar {
  position: absolute;
  left: 0;
  right: 0;
  z-index: 3;
  height: var(--a-titles-bar, clamp(5.5rem, 12vh, 8rem));
  background: var(--a-titles-bar-ink, #05070a);
  transition: transform var(--a-titles-land, 1400ms) var(--a-titles-land-ease, cubic-bezier(.65, 0, .25, 1));
}
.a-titles-bar--top { top: 0; }
.a-titles-bar--foot { bottom: 0; }
[data-landing] .a-titles-bar--top { transform: translateY(-100%); }
[data-landing] .a-titles-bar--foot { transform: translateY(100%); }

/* ---- the press start's held frame ------------------------------------------------------------- */
.a-titles-hold {
  position: absolute;
  inset: 0;
  z-index: 4;
  display: none;
  place-content: center;
  justify-items: center;
  gap: 1.1rem;
  padding: var(--a-titles-bar, clamp(5.5rem, 12vh, 8rem)) 1rem;
  text-align: center;
}
.a-titles[data-stage="held"] .a-titles-hold { display: grid; }

/* ---- Skip and the honesty mark: the foot bar's centre column (§U: never a corner) ------------- */
.a-arrival--titles .a-arrival-skip {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(clamp(.6rem, 2vh, 1.1rem) + 2rem);
  z-index: 6;
}
.a-arrival--titles .a-arrival-note {
  bottom: clamp(.6rem, 2vh, 1.1rem);
  z-index: 6;
  padding-inline: 1rem;
  text-align: center;
}
.a-arrival--titles :is(.a-arrival-skip, .a-arrival-note) { transition: opacity 400ms var(--ease, ease); }
.a-arrival--titles[data-landing] :is(.a-arrival-skip, .a-arrival-note) { opacity: 0; pointer-events: none; }

@media (prefers-reduced-motion: reduce) {
  /* The still: the last shot and its title, one crossfade, out. Nothing pushes, nothing flies. */
  .a-titles-shot:is([data-on], [data-past]) .a-titles-pic,
  .a-titles-shot[data-on] .a-titles-credit > * { animation: none; }
  .a-titles-shot[data-on] .a-titles-frame > img { transform: none; transition: none; }
}
@media print { .a-arrival--titles { display: none !important; } }


/* ============================================================ Y · the doors arrival ===
   Added 2026-10-02 for site 4 (the user: the lift doors and the brownout). A lift arriving: a floor
   display counts, the car may stall in the dark while a line of the couple's story is read, the
   power comes back, and the doors part on the page. No pictures; atelier.js doors() drives it.

   THE PARTS (markup is the skin's)
     .a-arrival--doors   the overlay. data-start, data-rolling, data-line (the line is up), data-leaving
     .a-doors            the stage. data-stage: held → travel → stall → dark → restore → arrived → parted
     .a-doors-head       the band over the doors that holds the display (and anything else: a lamp)
     .a-doors-leaf--l / --r   the two doors. They part on [data-stage="parted"]
     .a-doors-dark       the brownout's veil over the doors (stall and dark), flickering off on restore
     .a-doors-line       the line read in the dark. It stays up after the power comes back and rises
                         to --a-doors-line-to as the doors part, where the page's names will land
     .a-doors-call       holds the press start's [data-wsd-el="play"]; gone once the car moves
     .a-arrival-skip, .a-arrival-note   in the foot's centre column, never a corner (§U), above the dark

   TOKENS, all with a fallback at the use site
     --a-doors-head --a-doors-head-bg --a-doors-leaf-l --a-doors-leaf-r --a-doors-seam
     --a-doors-dark-alpha --a-doors-move (the doors' slide) --a-doors-line-at --a-doors-line-to
     --a-doors-rise (the line's climb) --a-doors-call-at --a-doors-fade --a-doors-fade-delay (the overlay
     leaving; --a-arr-exit, when the node goes, must cover both)
     beats read by the engine: --a-doors-step --a-doors-step-after --a-doors-dark --a-doors-line
     --a-doors-back --a-doors-ding --a-doors-part (all ms)

   THE PAGE UNDER THE DOORS is the skin's to dress: html[data-arrival] while the doors are up (site 4
   darkens its city there), html[data-arrived] once they have played, for that page view only. */

.a-arrival--doors {
  z-index: 90;   /* above every dock, as §U and §X */
  --a-arr-exit: 1100ms;
  display: block;
  background: none;
  color: var(--a-doors-ink, #f3ede2);
}
.a-arrival--doors::before { content: none; }
/* The fade is its own token, with an optional delay a skin can set for a landing that should linger
   (--a-arr-exit is only when the node is removed, so keep it at least delay + fade). */
.a-arrival--doors[data-leaving] {
  animation: none;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--a-doors-fade, 900ms) var(--ease, ease) var(--a-doors-fade-delay, 0ms);
}
.a-doors { position: absolute; inset: 0; overflow: hidden; }
.a-doors-head {
  position: absolute; left: 0; right: 0; top: 0; z-index: 3;
  height: var(--a-doors-head, 7.5rem);
  display: grid; place-items: center;
  background: var(--a-doors-head-bg, #0e0d0c);
  transition: translate 1100ms var(--ease, ease) 500ms, opacity 700ms var(--ease, ease) 500ms;
}
.a-doors-leaf {
  position: absolute; top: var(--a-doors-head, 7.5rem); bottom: 0; width: 50%;
  overflow: hidden;
  transition: translate var(--a-doors-move, 1400ms) cubic-bezier(.65, 0, .25, 1);
}
.a-doors-leaf--l { left: 0; background: var(--a-doors-leaf-l, #24211d); border-right: 1px solid var(--a-doors-seam, #000); }
.a-doors-leaf--r { right: 0; background: var(--a-doors-leaf-r, #24211d); }
.a-doors[data-stage="parted"] .a-doors-leaf--l { translate: -101% 0; }
.a-doors[data-stage="parted"] .a-doors-leaf--r { translate: 101% 0; }
.a-doors[data-stage="parted"] .a-doors-head { translate: 0 -100%; opacity: 0; }
.a-doors-dark {
  position: absolute; inset: 0; z-index: 2;
  background: #000; opacity: 0; pointer-events: none;
  transition: opacity 450ms linear;
}
.a-doors:is([data-stage="stall"], [data-stage="dark"]) .a-doors-dark { opacity: var(--a-doors-dark-alpha, .84); }
.a-doors[data-stage="restore"] .a-doors-dark { animation: a-doors-power 700ms linear both; }
@keyframes a-doors-power {
  0% { opacity: var(--a-doors-dark-alpha, .84); } 25% { opacity: .2; } 35% { opacity: .7; }
  55% { opacity: .1; } 62% { opacity: .5; } 100% { opacity: 0; }
}
.a-doors-line {
  position: absolute; left: 50%; translate: -50% 0; z-index: 4;
  top: var(--a-doors-line-at, 62%);
  width: min(26em, calc(100% - 3rem));
  margin: 0; text-align: center; text-wrap: balance;
  opacity: 0; pointer-events: none;
  transition: opacity 700ms var(--ease, ease), top var(--a-doors-rise, 1500ms) cubic-bezier(.65, 0, .25, 1);
}
.a-doors-line > * { display: block; }
.a-arrival--doors[data-line] .a-doors-line { opacity: 1; }
.a-doors[data-stage="parted"] .a-doors-line { top: var(--a-doors-line-to, 18vh); }
.a-doors-call {
  position: absolute; left: 50%; translate: -50% 0; z-index: 3;
  bottom: var(--a-doors-call-at, 7.5rem);
  margin: 0;
  transition: opacity 400ms var(--ease, ease);
}
/* gone, not just transparent, once the car moves: out of the tab order and out of a hit test */
.a-doors:not([data-stage="held"]) .a-doors-call {
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity 400ms var(--ease, ease), visibility 0s 400ms;
}
.a-arrival--doors .a-arrival-skip {
  position: absolute; left: 50%; transform: translateX(-50%); z-index: 6;
  bottom: calc(clamp(.6rem, 2vh, 1.1rem) + 2rem);
}
.a-arrival--doors .a-arrival-note {
  bottom: clamp(.6rem, 2vh, 1.1rem); z-index: 6;
  padding-inline: 1rem; text-align: center;
}
@media (prefers-reduced-motion: reduce) {
  /* only reached by a site that opts into the still: nothing moves, the doors stay shut */
  .a-doors-leaf, .a-doors-head, .a-doors-line, .a-doors-dark { transition: none; animation: none; }
}
@media print { .a-arrival--doors { display: none !important; } }


/* ============================================================ Z · the end credits ===
   Added 2026-10-03 for site 7 (credits.js). The opening titles' bookend: the cast rolls up the
   screen by role, then a last card, then out. The parts are the skin's markup; the engine measures the
   list and sets --a-credits-travel and --a-credits-dur, so the speed is a token at any cast size.

     .a-credits          the overlay. data-stage: rolling → end; data-leaving; data-still (reduced motion)
     .a-credits-roll     the window the list rolls through (feathered top and bottom)
     .a-credits-list     the column that rolls. The skin sets its own groups and rows inside it
     .a-credits-end      the last card, over the same ground
     .a-arrival-skip, .a-credits-note   the foot's centre column, never a corner (§U)

   TOKENS: --a-credits-ground --a-credits-ink --a-credits-fade --a-credits-feather --a-credits-w --a-credits-foot,
   and, read by the engine, --a-credits-speed (px a second) --a-credits-wait --a-credits-hold
   --a-credits-overlap --a-credits-exit (ms). */
.a-credits {
  position: fixed;
  inset: 0;
  z-index: 95;   /* above every dock and the arrival, as §U, §X */
  overflow: hidden;
  color: var(--a-credits-ink, #f3f5f7);
  background: var(--a-credits-ground, #0e131b);
  opacity: 0;
  transition: opacity var(--a-credits-fade, 900ms) var(--ease, ease);
}
.a-credits[hidden] { display: none; }
.a-credits[data-stage] { opacity: 1; }
.a-credits[data-leaving] { opacity: 0; }
html[data-credits] { overflow: hidden; }
.a-credits-roll {
  position: absolute;
  inset: 0 0 var(--a-credits-foot, 0) 0;   /* ends above Skip and the note, so no line passes behind them */
  overflow: hidden;
  -webkit-mask-image: linear-gradient(transparent, #000 var(--a-credits-feather, 16%), #000 calc(100% - var(--a-credits-feather, 16%)), transparent);
  mask-image: linear-gradient(transparent, #000 var(--a-credits-feather, 16%), #000 calc(100% - var(--a-credits-feather, 16%)), transparent);
}
.a-credits-list {
  position: absolute;
  top: 100%;
  left: 50%;
  width: min(var(--a-credits-w, 34rem), calc(100% - 2rem));
  translate: -50% 0;
  will-change: transform;
}
.a-credits[data-stage="rolling"] .a-credits-list,
.a-credits[data-stage="end"] .a-credits-list {
  animation: a-credits-roll var(--a-credits-dur, 20s) linear both;
}
@keyframes a-credits-roll { from { transform: translateY(0); } to { transform: translateY(var(--a-credits-travel, -200vh)); } }
.a-credits-end {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  padding: 1rem;
  text-align: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 1200ms var(--ease, ease);
}
.a-credits[data-stage="end"] .a-credits-end { opacity: 1; }
.a-credits .a-arrival-skip {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(clamp(.6rem, 2vh, 1.1rem) + 2rem);
  z-index: 2;
}
.a-credits-note {
  position: absolute;
  left: 0;
  right: 0;
  bottom: clamp(.6rem, 2vh, 1.1rem);
  z-index: 2;
  margin: 0;
  padding-inline: 1rem;
  text-align: center;
}
/* reduced motion: nothing rolls, the last card is the credits */
.a-credits[data-still] .a-credits-roll { display: none; }
@media (prefers-reduced-motion: reduce) { .a-credits, .a-credits-end { transition: none; } }
@media print { .a-credits { display: none !important; } }

/* Z2 · a still that is a scene (2026-10-03, site 7's story). Two utilities for a framed photograph.
     .a-push        the picture pushes in slowly as it crosses the screen, scroll-linked so it
                    rewinds: the opening titles' move. --a-push is how far (scale, default 1.08).
     .a-letterbox   two thin bars close over the top and bottom of the frame as it arrives, like a
                    scene going widescreen. --a-letterbox (each bar's height, default 7%), and
                    --a-letterbox-ink. At rest, and under reduced motion, the bars are simply there.
   Both clip, and both leave the element's size alone (§L2's rule: travel and paint only). */
.a-push, .a-letterbox { position: relative; overflow: clip; }
.a-letterbox::before,
.a-letterbox::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  z-index: 1;
  height: var(--a-letterbox, 7%);
  background: var(--a-letterbox-ink, #05070a);
  pointer-events: none;
}
.a-letterbox::before { top: 0; transform-origin: 50% 0; }
.a-letterbox::after { bottom: 0; transform-origin: 50% 100%; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-push > img {
      animation: a-push linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
      will-change: transform;
    }
    [data-atelier~="motion"] .a-letterbox::before,
    [data-atelier~="motion"] .a-letterbox::after {
      animation: a-letterbox linear both;
      animation-timeline: view();
      animation-range: entry 30% cover 45%;
    }
  }
}
@keyframes a-push { from { transform: scale(1); } to { transform: scale(var(--a-push, 1.08)); } }
@keyframes a-letterbox { from { transform: scaleY(0); } to { transform: scaleY(1); } }
/* the observer register (§L2): reveal.js stamps data-seen; the bars close on it instead */
[data-reveal="on"] .a-letterbox::before,
[data-reveal="on"] .a-letterbox::after { animation: none !important; }
@media (prefers-reduced-motion: no-preference) {
  [data-reveal="on"][data-atelier~="motion"] .a-letterbox::before,
  [data-reveal="on"][data-atelier~="motion"] .a-letterbox::after {
    transform: scaleY(0);
    transition: transform calc(var(--a-reveal-dur, 900ms) * 1.6) var(--a-reveal-ease, ease) 200ms;
  }
  [data-reveal="on"][data-atelier~="motion"] .a-letterbox[data-seen]::before,
  [data-reveal="on"][data-atelier~="motion"] .a-letterbox[data-seen]::after { transform: scaleY(1); }
}


/* ============================================================ V · gloss ============
   Added 2026-08-27, on the user's engine-wide override of section R's ornament budget (see
   DECISIONS.md → Review of 2026-08-20 (later), superseded in part). Section Q gave a surface its
   material; this gives it a **wet** one, plus the two things a long scroll wanted: an edge that
   dissolves instead of cropping, and a margin that keeps count.

   Six utilities, all inert until a skin points a token at something — an unfilled --a-field-img
   or --a-glaze-spec renders nothing, so sites 2-8 inherit the vocabulary without wearing it.
   Every continuous animation here is gated on [data-atelier~="motion"] AND a reduced-motion
   query, and comes to rest as a STILL highlight rather than disappearing.

   What this section deliberately does NOT contain:
   · corner marks — the skins already have them (--a-mark-*, section Q), four to a plate.
   · backdrop-filter — a blur is clipped to its own box, so it draws the exact rectangle a wash
     exists to avoid. Site 1's skin refuses it outright and section V does not reintroduce it.
   · anything on the arrival. §U is measured against its own clip and is not decorated here. */

/* V1 · the travelling specular. An element, not a pseudo: by the time a frame is worth glossing
   it has already spent ::before on a vignette and ::after on a scrim. Drop it as the first child
   of any positioned host:  <span class="a-sheen" aria-hidden="true"></span> */
.a-sheen {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow: clip;
  border-radius: inherit;
  pointer-events: none;
}
.a-sheen::after {
  content: "";
  position: absolute;
  inset: -45% -70%;
  /* At rest this is a static diagonal highlight sitting just off-centre — the reduced-motion
     answer and the no-support answer are the same one: a lit surface that is not moving. */
  transform: translateX(-14%) rotate(var(--a-sheen-tilt, 9deg));
  background: linear-gradient(90deg,
    transparent 0,
    var(--a-sheen-a, rgba(255, 255, 255, 0)) 34%,
    var(--a-sheen-b, rgba(255, 255, 255, .42)) 50%,
    var(--a-sheen-a, rgba(255, 255, 255, 0)) 66%,
    transparent 100%);
}
.a-sheen--wide::after { inset: -60% -80%; }
@media (prefers-reduced-motion: no-preference) {
  [data-atelier~="motion"] .a-sheen::after {
    animation: a-sheen-sweep var(--a-sheen-time, 15s) cubic-bezier(.45, 0, .55, 1) infinite;
    will-change: transform;
  }
}
@keyframes a-sheen-sweep {
  0%   { transform: translateX(-115%) rotate(var(--a-sheen-tilt, 9deg)); }
  100% { transform: translateX(115%) rotate(var(--a-sheen-tilt, 9deg)); }
}

/* V2 · the glaze. Section Q's .plate shadow says "a sheet of paper catches light at its top
   edge"; this says "the sheet is glazed" — a steep highlight off the top-left, a rim, and a
   cool sink at the foot. Applied by a skin to its own components, never by class in markup. */
.a-glaze {
  background-image:
    linear-gradient(158deg, var(--a-glaze-spec, rgba(255, 255, 255, .9)) 0,
      rgba(255, 255, 255, .3) 24%, rgba(255, 255, 255, 0) 46%),
    radial-gradient(130% 90% at 10% 2%, var(--a-glaze-spec, rgba(255, 255, 255, .9)), transparent 62%);
  box-shadow:
    inset 0 1px 0 var(--a-glaze-rim, rgba(255, 255, 255, .8)),
    inset 0 0 0 1px var(--a-glaze-rim-soft, rgba(255, 255, 255, .38)),
    inset 0 -14px 24px -22px var(--a-glaze-sink, rgba(34, 26, 21, .3)),
    var(--a-shadow, 0 20px 44px -32px rgba(34, 26, 21, .6));
}

/* V3 · the dissolved crop line. Four-side feather for a framed photograph; --block for a
   full-bleed frame, whose sides ARE the window and whose seam is against the paper. */
.a-feather {
  mask-image:
    linear-gradient(to right, transparent 0, #000 var(--a-feather, 0px),
      #000 calc(100% - var(--a-feather, 0px)), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 var(--a-feather, 0px),
      #000 calc(100% - var(--a-feather, 0px)), transparent 100%);
  mask-composite: intersect;
}
.a-feather--block {
  mask-image: linear-gradient(to bottom, transparent 0, #000 var(--a-feather-frame, 0px),
    #000 calc(100% - var(--a-feather-frame, 0px)), transparent 100%);
  mask-composite: add;
}

/* V4 · the soft seam. A band that used to announce itself with two hairlines instead fades its
   own ground in and out. The rules are not hidden — they are replaced. */
.a-seam {
  border-block-color: transparent;
  background-color: transparent;
  background-image: linear-gradient(to bottom,
    var(--a-seam-ground-0, transparent) 0,
    var(--a-seam-ground, transparent) 6%,
    var(--a-seam-ground, transparent) 94%,
    var(--a-seam-ground-0, transparent) 100%);
}

/* V5 · the margin keeps count. A numeral in the sheet's own margin with a stem that draws itself
   as the passage arrives. Hidden below 64em: under that there is no margin to set it in, and a
   numeral that overlaps the measure is page overflow with a serif. */
.a-num { display: none; }
@media (min-width: 64em) {
  .a-num {
    display: block;
    position: absolute;
    z-index: 2;
    margin: 0;
    top: var(--a-num-top, calc(var(--pad-band, 4rem) * .72));
    left: max(1rem, calc((100% - var(--wrap, 68rem)) / 2 - var(--a-num-pull, 3.6rem)));
    font: 600 var(--step--1, .8rem)/1 var(--ui, sans-serif);
    letter-spacing: .18em;
    color: var(--a-num-ink, currentColor);
  }
  .a-num::after {
    content: "";
    position: absolute;
    left: .1em;
    top: 2em;
    width: 1px;
    height: var(--a-num-stem, clamp(3rem, 7vw, 6rem));
    background: linear-gradient(to bottom, currentColor, transparent);
    transform-origin: top center;
  }
  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      [data-atelier~="motion"] .a-num::after {
        animation: a-num-draw linear both;
        animation-timeline: view();
        animation-range: entry 10% cover 40%;
      }
    }
  }
}
@keyframes a-num-draw { from { scale: 1 0; } to { scale: 1 1; } }

/* V6 · the drifting field. A skin's own motif behind a band, scroll-linked so it rewinds and
   never runs where it is not being read. Masked away before the middle: this is a margin
   texture, not wallpaper, and type never sits on pattern. */
.a-field {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: var(--a-field-img, none) 0 0 / var(--a-field-step, clamp(240px, 32vw, 420px)) auto repeat;
  opacity: var(--a-field-opacity, .07);
  mask-image: linear-gradient(to bottom, #000, transparent var(--a-field-fade, 62%));
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-atelier~="motion"] .a-field {
      animation: a-field-drift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
  }
}
@keyframes a-field-drift { from { background-position: 0 -9%; } to { background-position: 0 9%; } }

/* A host of V1 or V6 has to be a positioning context, and a host of V6 has to isolate so a
   z-index:-1 field stays inside the band instead of sliding under the page ground. */
:where(.a-sheen, .a-field, .a-num) { pointer-events: none; }
:has(> .a-field) { isolation: isolate; }

@media print { :is(.a-sheen, .a-field, .a-num) { display: none; } }


/* ============================================================================
   W · THE FLOAT LAYER — added 2026-09-02, for site 1, inherited by sites 3-8
   Structure only: a full-height layer inside the scrolling container, and a piece that paints
   the cut-out in one of TWO RENDER MODES. No colour, no size, no density and no image lives
   here — see engine/float.js's header for the token list and DECISIONS.md -> Site 1's floating
   ornaments for why the layer exists.

   TWO RENDER MODES, chosen with data-float-render on <html>:

     mask   (default)  the piece is a <span> whose MASK is the cut-out, so a skin recolours it
                       with 'color' alone and one layer can read warm white over a photograph,
                       lit over a dark passage and inked on the ivory ground.
     photo             the piece is the photographed pixels themselves. One appearance, not
                       three: it cannot invert itself over a dark passage, so the skin
                       separates it with a drop shadow instead of a colour. Chosen by the user
                       for site 1 on 2026-09-03 after seeing all three candidates on real
                       pixels; see DECISIONS.md -> The ornament render mode.

   Only the SWITCH is here. What a photographic piece needs to be visible on a given ground —
   shadow, weight, any correction — is per-skin and belongs in the skin, because it depends
   entirely on how close the cut-out's own tones sit to that skin's ground.
   ========================================================================== */
.a-float {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow: clip;
  pointer-events: none;
}
.a-float-p {
  position: absolute;
  display: block;
  pointer-events: none;
  will-change: transform;
  /* the engine writes --float-w per piece (depth); a skin may scale it per tone */
  opacity: var(--float-w, .5);
  background: currentColor;
  mask-image: var(--float-src);
  mask-size: contain;
  mask-repeat: no-repeat;
  mask-position: center;
  -webkit-mask-image: var(--float-src);
  -webkit-mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-position: center;
}
/* photo mode: the same box, painted rather than masked. The tone map still runs and data-tone
   is still stamped — a skin uses it to vary the separation per ground, which is the only thing
   left for it to vary once the pixels are fixed. */
[data-float-render="photo"] .a-float-p {
  background: var(--float-src) center / contain no-repeat;
  mask-image: none;
  -webkit-mask-image: none;
}
/* A layer that paints over its own host has to be inside a positioning context, and the host
   has to clip, or twelve pieces widen the document and the gate reports overflow at 390. */
:has(> .a-float) { position: relative; }

@media print { .a-float { display: none; } }


/* ═════════════════════════════════════════════════════════════════════════════════════════
   L2 · THE OBSERVER REGISTER (2026-09-07)

   §L drives the reveal vocabulary from `animation-timeline: view()`. This block drives the SAME
   vocabulary from `data-seen`, which `reveal.js` stamps with an IntersectionObserver, for the
   case §L cannot answer: a browser that declines the scroll timeline and therefore shows no
   motion at all. Reported verbatim on 2026-09-07 — "I see nothing move".

   ⚠ OPT-IN is `data-atelier~="reveal"` on <html>, but every rule below is keyed on
   `[data-reveal="on"]` — which `reveal.js` sets ON ITSELF STARTING. The distinction is the whole
   safety property: the from-states only exist while the driver that clears them is demonstrably
   alive, so a failed script, a thrown error or an ancient browser hides nothing. Keying these on
   the opt-in token instead is what shipped 18 invisible headings on 2026-09-07 — do not
   "simplify" it back. Site 1 is signed off and is not opted in.

   ⚠ The two drivers are MUTUALLY EXCLUSIVE: the first rule below takes the view() animations off
   every element the observer driver is handling, so nothing is ever driven twice.

   ⚠ Every from-state here except `.a-rise` collapses the element to ZERO AREA. That is fine for
   a transition and fatal for an IntersectionObserver, whose intersection rect is computed after
   the target's own clip and transform — see the rewrite note at the top of `reveal.js`. Any
   future driver for this vocabulary must test the LAYOUT BOX, never the painted area.

   ⚠ Transitions, not animations, on purpose. A transition has no from-state to "hold", so an
   element that never gets stamped stays fully visible and legible — the failure mode is a
   missing animation, never invisible content. Anything authored as @keyframes here would fail
   the other way.

   ⚠ TRAVEL AND OPACITY ONLY. An unstamped element rests in its from-state for as long as it is
   below the fold, so nothing in this block may change an element's SIZE — the acceptance gate
   measures hit targets off screen and a 1.2% scale once read three 44px links as 43.34px.
   `.a-mask` and `.a-brush` are the exceptions and they are contained: both scale a CHILD
   image or clip a glyph, neither of which is a hit target.

   ⚠ `--a-i` is the authored child index (reveal.js writes it); `--a-step` is the skin's
   per-child offset. An observer fires a whole row within one frame, so without this a row
   arrives as a block. */
[data-reveal="on"] .a-rise,
[data-reveal="on"] .a-wipe,
[data-reveal="on"] .a-draw,
[data-reveal="on"] .a-mask > img,
[data-reveal="on"] .a-mask > picture > img,
[data-reveal="on"] img.a-mask {
  animation: none !important;
}
[data-atelier~="reveal"] {
  --a-step: 90ms;
  --a-reveal-dur: 900ms;
  --a-reveal-ease: cubic-bezier(.16, .84, .24, 1);
}
@media (prefers-reduced-motion: no-preference) {
  [data-reveal="on"][data-atelier~="motion"] .a-rise,
  [data-reveal="on"][data-atelier~="motion"] .a-wipe,
  [data-reveal="on"][data-atelier~="motion"] .a-draw,
  [data-reveal="on"][data-atelier~="motion"] .a-flip,
  [data-reveal="on"][data-atelier~="motion"] .a-brush,
  [data-reveal="on"][data-atelier~="motion"] .a-mask > img,
  [data-reveal="on"][data-atelier~="motion"] .a-mask > picture > img,
  [data-reveal="on"][data-atelier~="motion"] img.a-mask {
    transition-duration: var(--a-reveal-dur);
    transition-timing-function: var(--a-reveal-ease);
    transition-delay: calc(var(--a-i, 0) * var(--a-step));
    will-change: transform, opacity;
  }

  /* the loud entrance — the block a section is about */
  [data-reveal="on"][data-atelier~="motion"] .a-rise {
    opacity: 0; transform: translateY(var(--a-rise-y, 54px));
    transition-property: opacity, transform;
  }
  [data-reveal="on"][data-atelier~="motion"] .a-rise[data-seen] { opacity: 1; transform: none; }

  /* type uncovered left to right behind its own edge — eyebrows, short heads */
  [data-reveal="on"][data-atelier~="motion"] .a-wipe {
    opacity: 0; clip-path: inset(0 100% 0 0);
    transition-property: opacity, clip-path;
    transition-duration: calc(var(--a-reveal-dur) * 1.15);
  }
  [data-reveal="on"][data-atelier~="motion"] .a-wipe[data-seen] { opacity: 1; clip-path: inset(0 0 0 0); }

  /* a rule that draws itself out from --a-draw-from */
  [data-reveal="on"][data-atelier~="motion"] .a-draw {
    transform: scaleX(0); transform-origin: var(--a-draw-from, left center);
    transition-property: transform;
  }
  [data-reveal="on"][data-atelier~="motion"] .a-draw[data-seen] { transform: scaleX(1); }

  /* a photograph uncovered upwards out of a slight over-scale. The scale is on the IMAGE, and
     .a-mask keeps §L's clip so the over-scale cannot spill past the frame. */
  [data-reveal="on"][data-atelier~="motion"] .a-mask { overflow: clip; }
  [data-reveal="on"][data-atelier~="motion"] .a-mask > img,
  [data-reveal="on"][data-atelier~="motion"] .a-mask > picture > img,
  [data-reveal="on"][data-atelier~="motion"] img.a-mask {
    clip-path: inset(0 0 100% 0); transform: scale(var(--a-mask-from, 1.07));
    transition-property: clip-path, transform;
    transition-duration: calc(var(--a-reveal-dur) * 1.45);
  }
  [data-reveal="on"][data-atelier~="motion"] .a-mask[data-seen] > img,
  [data-reveal="on"][data-atelier~="motion"] .a-mask[data-seen] > picture > img,
  [data-reveal="on"][data-atelier~="motion"] img.a-mask[data-seen] {
    clip-path: inset(0 0 0 0); transform: scale(1);
  }

  /* a number or a date turned up onto its face. For figures ONLY — it rotates in 3D, and type
     that has to be read while it is off screen may not be edge-on. */
  [data-reveal="on"][data-atelier~="motion"] .a-flip {
    opacity: 0; transform: perspective(420px) rotateX(-62deg); transform-origin: 50% 100%;
    transition-property: opacity, transform;
  }
  [data-reveal="on"][data-atelier~="motion"] .a-flip[data-seen] { opacity: 1; transform: none; }

  /* a seal or a stamp brushed on — clipped from the top, with the ink settling.
     ⚠ the transform here is on a GLYPH, never on a control. */
  [data-reveal="on"][data-atelier~="motion"] .a-brush {
    opacity: 0; clip-path: inset(0 0 100% 0); transform: rotate(-7deg) translateY(3px);
    transition-property: opacity, clip-path, transform;
    transition-duration: calc(var(--a-reveal-dur) * .82);
  }
  [data-reveal="on"][data-atelier~="motion"] .a-brush[data-seen] {
    opacity: 1; clip-path: inset(0 0 0 0); transform: none;
  }
}
/* ⚠ The finished state is also the DEFAULT state — no @supports, no media query, no JS. If
   reveal.js never runs, or the observer never fires, or motion is reduced, everything above is
   simply never applied and the page is the page. */
@media print {
  [data-reveal] .a-rise,
  [data-reveal] .a-wipe,
  [data-reveal] .a-draw,
  [data-reveal] .a-flip,
  [data-reveal] .a-brush,
  [data-reveal] .a-mask > img,
  [data-reveal] img.a-mask {
    opacity: 1 !important; transform: none !important; clip-path: none !important;
  }
}
