/* ============================================================================
 * THE SEAM — PhotoPool's public design language.
 *
 * Ink, paper, and one rainbow.
 *
 * The base is neutral black and white and carries every surface, every rule and
 * every word. Colour is rationed to two things: real content (photographs,
 * persona art), and the spectrum, which appears only where separate things meet.
 *
 * That rule is the product's own. PhotoPool's mechanic is many separate people's
 * cameras becoming one shared thing, and the page is built as strip-weaving to
 * say so: independent panels, joined, with the join left visible. So the seam —
 * the join itself — is the one place the rainbow is allowed to live. Colour means
 * "this is where separate things meet." Nowhere else earns it.
 *
 * WHY A FULL SPECTRUM IS NOT THE GRADIENT EVERY AI SITE SHIPS: the tell is two or
 * three ADJACENT hues in the blue-violet-magenta arc, rendered as a soft
 * background wash. This is ten stops spread evenly around the whole circle, and
 * it is never a wash — only ever a hairline, an edge, or a mark. Evenness makes it
 * read as a prism. Precision keeps it from reading as a glow.
 *
 * Zero runtime dependencies. Plain CSS. Registered in DESIGN_FILE_RE (app.js).
 * ========================================================================== */

/* ==========================================================================
 * 1. THE TOKEN LAYER
 *
 * Every token is SEMANTIC — --bg, --fg, --line — and none of them names a
 * lightness. That is what makes the theme genuinely invertible rather than a
 * dark skin bolted onto a light one. Swapping the block below swaps the whole
 * site, and nothing downstream needs to know which way round it is.
 *
 * Neutrals are truly neutral (R=G=B). The previous version tinted them warm,
 * which is exactly the cream the brand is moving away from.
 * ====================================================================== */

:root {
  color-scheme: light dark;

  /* --- ground and ink (light is the authored default) ------------------ */
  --bg:        #ffffff;
  --bg-raised: #f5f5f5;
  --bg-sunk:   #ebebeb;
  --fg:        #0a0a0a;
  --fg-muted:  #595959;   /* 7.0:1 on white */
  --fg-quiet:  #767676;   /* 4.5:1 on white — the floor for real text */
  --line:      #dcdcdc;
  --line-strong: #9a9a9a;

  /* --- THE SPECTRUM — ten stops, evenly spaced around the hue circle.
   * Tuned to stay legible as a 2px hairline on BOTH grounds, which is why
   * none of them is very pale or very dark. Never a text colour, never a
   * fill: only hairlines, edges and marks. ---- */
  --s1:  #ff3b30;  /* red      ~4deg   */
  --s2:  #ff7a00;  /* orange   ~29deg  */
  --s3:  #ffc400;  /* amber    ~46deg  */
  --s4:  #b4d900;  /* lime     ~73deg  */
  --s5:  #22c55e;  /* green    ~145deg */
  --s6:  #00c9b1;  /* teal     ~173deg */
  --s7:  #22a9ff;  /* azure    ~203deg */
  --s8:  #4f6bff;  /* blue     ~231deg */
  --s9:  #9d5cff;  /* violet   ~264deg */
  --s10: #ff4fa3;  /* pink     ~331deg */

  /* The spectrum as one reusable image. Hard stops, not a blend: a smooth
   * multi-hue fade is the wash this system exists to avoid. Ten discrete
   * bands read as separate threads lying side by side, which is the point. */
  --spectrum: linear-gradient(90deg,
    var(--s1) 0 10%, var(--s2) 10% 20%, var(--s3) 20% 30%, var(--s4) 30% 40%,
    var(--s5) 40% 50%, var(--s6) 50% 60%, var(--s7) 60% 70%, var(--s8) 70% 80%,
    var(--s9) 80% 90%, var(--s10) 90% 100%);
  --spectrum-v: linear-gradient(180deg,
    var(--s1) 0 10%, var(--s2) 10% 20%, var(--s3) 20% 30%, var(--s4) 30% 40%,
    var(--s5) 40% 50%, var(--s6) 50% 60%, var(--s7) 60% 70%, var(--s8) 70% 80%,
    var(--s9) 80% 90%, var(--s10) 90% 100%);

  /* --- motion (unchanged: real values, one personality) ---------------- */
  --ease-enter: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-move:  cubic-bezier(0.77, 0, 0.175, 1);
  --dur-enter: 420ms;
  --dur-move:  480ms;
  --dur-hover: 140ms;
  --dur-exit:  300ms;   /* 65-80% of entry, always */

  /* --- type ------------------------------------------------------------ */
  --stack-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
              "Helvetica Neue", Arial, sans-serif;
  --stack-num: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
               "Liberation Mono", monospace;

  /* --- measure and rhythm ---------------------------------------------- */
  --measure: 34rem;
  --gut: clamp(1.25rem, 3vw, 2.5rem);
  --strip-min: 6.5rem;

  /* The rolling square. Sized so several quarter-turns fit in one sticky stage:
   * the square advances exactly one side-length per 90 degrees, so a smaller
   * square buys more tips down the spine. Around 180px at a 900px viewport gives
   * three, which is enough to read as rolling without becoming a spin. */
  --sq: clamp(8rem, 20vh, 13rem);
}

/* The inverted ground. Identical token NAMES, so every rule downstream is
 * written once. Applied by system preference, and overridable by the toggle in
 * the bar via [data-theme] — which must win in both directions, hence both
 * explicit blocks below. */
@media (prefers-color-scheme: dark) {
  :root {
    --bg:        #0a0a0a;
    --bg-raised: #161616;
    --bg-sunk:   #050505;
    --fg:        #fafafa;
    --fg-muted:  #a3a3a3;   /* 8.9:1 on near-black */
    --fg-quiet:  #8a8a8a;   /* 6.1:1 */
    --line:      #282828;
    --line-strong: #575757;
  }
}

:root[data-theme="dark"] {
  --bg: #0a0a0a; --bg-raised: #161616; --bg-sunk: #050505;
  --fg: #fafafa; --fg-muted: #a3a3a3; --fg-quiet: #8a8a8a;
  --line: #282828; --line-strong: #575757;
}

:root[data-theme="light"] {
  --bg: #ffffff; --bg-raised: #f5f5f5; --bg-sunk: #ebebeb;
  --fg: #0a0a0a; --fg-muted: #595959; --fg-quiet: #767676;
  --line: #dcdcdc; --line-strong: #9a9a9a;
}

@media (max-width: 40rem) { :root { --strip-min: 2.75rem; } }

/* ==========================================================================
 * 2. RESET AND GROUND
 * ====================================================================== */

*, *::before, *::after { box-sizing: border-box; }
html { overflow-x: hidden; -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg-muted);
  font-family: var(--stack-ui);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* height:auto is load-bearing: with width/height attributes present (required,
 * to reserve layout space), max-width alone shrinks the width and keeps the
 * attribute height, stretching the image. */
img, svg, canvas { max-width: 100%; height: auto; display: block; }

a { color: var(--fg); text-decoration-thickness: 1px; text-underline-offset: 0.2em; }

:focus-visible { outline: 2px solid var(--s7); outline-offset: 3px; }

.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--fg); color: var(--bg); padding: 0.75rem 1.25rem; font-weight: 600;
}
.skip-link:focus { left: 0; }

.vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* ==========================================================================
 * 3. THE SEAM — the one place colour lives.
 *
 * A join, rendered as the full spectrum. Used at five scales: between hero
 * strips, under the section rule, around the QR plate, at the edge of the
 * cloth, and beneath the wordmark on the one earned moment.
 * ====================================================================== */

.seam-rule {
  height: 2px;
  border: 0;
  margin: 0;
  background-image: var(--spectrum);
}

/* A hairline that is ink by default and spectrum only where it separates
 * genuinely distinct things. */
.rule { height: 1px; border: 0; margin: 0; background: var(--line); }

/* ==========================================================================
 * 4. LAYOUT PRIMITIVES
 * ====================================================================== */

.wrap { width: min(72rem, 100% - (var(--gut) * 2)); margin-inline: auto; }
.wrap--wide  { width: min(84rem, 100% - (var(--gut) * 2)); margin-inline: auto; }
.wrap--tight { width: min(52rem, 100% - (var(--gut) * 2)); margin-inline: auto; }

/* Text beside the generated mark. Deliberately asymmetric and not centred: the
 * centred-hero-over-a-single-visual is the most reproduced composition there is. */
.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
  gap: clamp(2rem, 5vw, 4rem);
  align-items: center;
}
@media (max-width: 56rem) {
  .hero-grid { grid-template-columns: 1fr; }
  /* The mark leads on narrow screens, where a tall text block above it would
   * push it entirely below the fold and waste it. */
  .hero-grid > .rosette { order: -1; max-width: 22rem; }
}

.band { padding-block: clamp(3.5rem, 9vw, 7rem); }
.band + .band { border-top: 1px solid var(--line); }
.band--open  { padding-block: clamp(4.5rem, 12vw, 9rem); }
.band--dense { padding-block: clamp(2.5rem, 6vw, 4.5rem); }

.h-sec {
  font-size: clamp(1.75rem, 4vw, 2.6rem);
  line-height: 1.15; font-weight: 300; letter-spacing: -0.015em;
  color: var(--fg); margin: 0 0 0.75em; max-width: 22ch;
}
.h-sub { font-size: clamp(1.1rem, 2vw, 1.3rem); font-weight: 400; color: var(--fg); margin: 0 0 0.5em; }

/* Plain monospace label — the register of a part number, deliberately not the
 * uppercase wide-tracked marketing eyebrow. */
.eyebrow { font-family: var(--stack-num); font-size: 0.75rem; color: var(--fg-quiet); margin: 0 0 1.25rem; }

.lede { font-size: clamp(1.05rem, 2vw, 1.2rem); max-width: 46ch; }
p { max-width: 62ch; }

/* ==========================================================================
 * 5. THE LOOM — the hero construction, cut as a film strip.
 *
 * There is no hero photograph, because PhotoPool has none that are honestly its
 * own. So the hero is a construction: independent strips of hatched ink at
 * different densities, joined by spectrum seams.
 *
 * Density, not hue, distinguishes the strips (a real technique: modulate the
 * density of a mark field instead of reaching for a gradient). Every strip is
 * the same ink; what differs is how much of it there is.
 *
 * THE FILM READING. The eleven strips were already eleven frames in a row with
 * the joins left showing, which is a film strip in everything but its edges — so
 * the edges were the thing missing. It now carries the three features that make
 * a strip of film unmistakable, and nothing beyond them:
 *
 *   1. PERFORATIONS along both margins. This is the whole tell; a rectangle with
 *      sprocket holes reads as film before anything else is looked at.
 *   2. A HAIRLINE where each margin meets the image area — the edge of the frame
 *      gate, and the reason the margins read as clear base rather than as more
 *      picture.
 *   3. FRAME LINES between frames. These already existed: the spectrum seams.
 *      That is the happy part of this — the brand's rule is that colour lives
 *      only where separate things meet, and on real film the join between two
 *      frames is LITERALLY called the frame line. The two ideas wanted the same
 *      mark, so nothing had to be invented or spent to get it.
 *
 * What it deliberately does NOT do is dress up as photographic stock: no black
 * or amber base, no sprocket shadows, no grain, no torn ends. Those would drag
 * in the warm dark palette this system exists to avoid (see the header). This is
 * a film strip DRAWN in the same ink as everything else on the page.
 *
 * PERFORATION PITCH IS DERIVED, NOT PICKED. Real film runs four perforations per
 * 35mm frame, and a strip whose holes do not line up with its frames is the kind
 * of wrong nobody can name but everybody sees.
 *
 * THE HOLES BELONG TO THE FRAME, not to the strip as a whole. They started life
 * on .loom as two masked bands spanning the full width, with the pitch computed
 * from a frame count that had to be counted out of the markup with :has() because
 * the two pages that build a loom disagree about it (eleven frames on the home
 * page, nine on /weddings).
 *
 * Giving each frame its own four holes deletes that entire problem — a frame
 * always has four, so there is no count to keep in sync and no :has() ladder to
 * maintain. It is also the only structure the hover works on: the magnification
 * scales each frame independently, and perforations that stayed evenly pitched
 * while the frames they sit on compressed would read as the holes sliding along
 * the film. Bonded to the frame, they foreshorten with it, which is what stock
 * actually does.
 * ====================================================================== */

.loom {
  --perfs-per-frame: 4;
  /* Margin depth. Real 35mm gives each margin about a fifth of the total width;
   * a fifth of this element top and bottom would eat the hero, so it is pulled
   * back to roughly a tenth — enough to read as film, cheap enough to afford.
   *
   * It tracks the VIEWPORT rather than being a flat pixel value, because the
   * perforation pitch is a fraction of the element's width and the hole is a
   * fraction of both. Pin the margin and the holes stretch or squash as the
   * window moves; scale them together and a hole stays about 1.4 times as wide
   * as it is tall at every size, which is the real proportion. */
  --perf-h: clamp(16px, 2.4vw, 30px);

  display: flex;
  /* The frames stand off the page so the bend has somewhere to lean into.
   * Perspective lives HERE rather than on the frames, so all of them are
   * projected from one vanishing point and the row reads as a single object
   * being handled rather than as eleven separately-tilted cards. */
  perspective: 1400px;
  /* The margin tone, which the holes are read against.
   *
   * It is MIXED TOWARD THE INK rather than named, and that is load-bearing for a
   * theme this system insists stays invertible. The obvious choice, --bg-sunk,
   * works in light and dies in dark: sunk is darker than --bg in BOTH themes, so
   * flipping the theme leaves a #0a0a0a hole on a #050505 margin — five values
   * apart, which is no perforation at all.
   *
   * Stepping away from --bg-raised toward --fg instead inverts with the theme for
   * free, and lands the right way round each time: on paper the margin goes down
   * to ~#e1e1e1 under a white hole, and in the dark it comes UP to ~#262626 under
   * a near-black one. Both match the physical thing — a punched hole shows the
   * ground behind the strip, whichever way round the ground happens to be.
   *
   * The frames each carry their own copy (see .strip), because every part of the
   * film has to bend WITH the film. This element paints nothing at all. */
  --perf-margin: color-mix(in srgb, var(--fg) 8%, var(--bg-raised));

  /* .loom draws NOTHING. It used to own the film's top and bottom edges and its
   * margin bands, which meant a hard rectangle stayed pinned to the page while
   * the film bent inside it — the element's own box showing through the illusion,
   * and the reason the top and bottom edges fell apart on hover. Every mark that
   * belongs to the film now travels with a frame; this is a clip region and a
   * perspective, nothing more.
   *
   * clip-path, NOT overflow: hidden. The clip has to be horizontal-only — the
   * strip runs off both sides of the screen and must be cut there, but as it
   * comes forward perspective makes it taller, and a box that cut that off would
   * flatten the one cue that sells the depth. overflow-x cannot express this
   * (setting it forces overflow-y to scroll); inset() with negative block edges
   * can: cut the sides, leave the top and bottom open. */
  clip-path: inset(-140px 0px);
  position: relative;
}

/* --bow is the up-and-down tilt, written by seam.js from where the pointer sits
 * vertically in the strip: hold it near the top edge and the top comes toward
 * you, near the bottom and the bottom does. It is inherited by every frame and
 * applied about the axis they all share, so it tips the strip as one sheet.
 * Cleared on leave, which falls back to the 0deg in the frame's transform. */

.strip {
  flex: 1 1 var(--strip-min);
  backface-visibility: hidden;   /* frames never turn past 90deg; skip the back face */
  /* The clamp is the IMAGE height, as it always was; the two margins are added on
   * top of it. Under border-box they would otherwise be subtracted from it, and
   * the hero would silently lose a margin's worth of picture at both ends. */
  min-height: calc(clamp(10rem, 26vh, 17rem) + 2 * var(--perf-h));
  padding-block: var(--perf-h);      /* the margins this frame's holes sit in */
  position: relative;

  /* Three layers, and the per-layer clip is what makes them stack correctly:
   *   1-2  the frame gate — a hairline at each image-area boundary, drawn on the
   *        border box so it can sit exactly on the padding edge
   *   3    the hatch, clipped to the CONTENT box so the ink stops at the gate and
   *        never runs out into a perforated margin
   * The margin tone and the base underneath both come from .loom, which the
   * frames leave uncovered precisely because of that content-box clip. */
  background-image:
    linear-gradient(var(--line), var(--line)),          /* the film's own top edge */
    linear-gradient(var(--line), var(--line)),          /* and its bottom edge */
    linear-gradient(var(--line), var(--line)),          /* frame gate, top */
    linear-gradient(var(--line), var(--line)),          /* frame gate, bottom */
    repeating-linear-gradient(                          /* the picture */
      var(--hatch-angle, 90deg),
      color-mix(in srgb, var(--fg) var(--hatch-ink, 14%), transparent) 0 1px,
      transparent 1px var(--hatch-gap, 6px)),
    linear-gradient(var(--perf-margin), var(--perf-margin)),   /* margin, top */
    linear-gradient(var(--perf-margin), var(--perf-margin));   /* margin, bottom */
  background-repeat: no-repeat, no-repeat, no-repeat, no-repeat, repeat, no-repeat, no-repeat;
  background-size:
    100% 1px, 100% 1px, 100% 1px, 100% 1px, auto auto, 100% var(--perf-h), 100% var(--perf-h);
  background-position:
    0 0, left 0 bottom 0, 0 var(--perf-h), left 0 bottom var(--perf-h), 0 0, top, bottom;
  background-origin: border-box;
  /* Only the picture is held inside the gate; everything else is the film itself
   * and runs to its edges. */
  background-clip: border-box, border-box, border-box, border-box, content-box, border-box, border-box;
  background-color: var(--bg-raised);                   /* the base it is all printed on */

  /* Set by seam.js on pointer move; see section 9 there. Declared with defaults
   * so the frame is correct with the script absent, and so the transform below
   * is a no-op at rest rather than an identity matrix that still promotes a
   * layer for every frame on the page. */
  /* The perforations — four to a frame, bonded to it, and PUNCHED rather than
   * painted.
   *
   * They used to be a pseudo-element filled with the page ground and masked down
   * to the hole shapes: ground-coloured holes lying on top of the film. That was
   * fine while there was nothing behind the strip, and wrong the moment there
   * was — a hole is not a colour, it is an absence, and with a photograph on the
   * stage behind you have to be able to see it through them.
   *
   * So the mask goes on the FRAME and cuts the holes out of it. Three layers,
   * unioned with the default composite rather than any subtract trick: the two
   * margin bands carry a tile that is opaque everywhere EXCEPT the hole, and the
   * middle band is a plain fill sized to exactly the image area. Every layer is
   * additive, so there is no compositing operator whose meaning differs between
   * engines. The tile's hole is cut with an SVG <mask> so it comes out ALPHA
   * ZERO, which is what a hole has to be — a white shape would still be opaque.
   *
   * Masking the frame also masks its picture and its seam, which is correct:
   * both live in the image area, and the holes are only ever in the margins.
   *
   * The cell is one perforation PITCH wide and the margin tall, and the hole is a
   * fraction of each — 60% x 40%, which are 35mm's own numbers (a 2.8mm hole on a
   * 4.75mm pitch, 1.98mm tall in a 7mm margin). rx and ry differ because the cell
   * is stretched unequally: 11% of the pitch and 10% of the margin both land near
   * two pixels, so the corner comes out round rather than oval.
   *
   * preserveAspectRatio='none' is required. Without it the tile letterboxes
   * inside its cell instead of stretching to it, and the holes drift out of
   * pitch. */
  --perf-band: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Cdefs%3E%3Cmask id='h'%3E%3Crect width='100' height='100' fill='white'/%3E%3Crect x='20' y='30' width='60' height='40' rx='11' ry='10' fill='black'/%3E%3C/mask%3E%3C/defs%3E%3Crect width='100' height='100' fill='black' mask='url(%23h)'/%3E%3C/svg%3E");
  --perf-solid: linear-gradient(#000, #000);
  -webkit-mask-image: var(--perf-band), var(--perf-band), var(--perf-solid);
          mask-image: var(--perf-band), var(--perf-band), var(--perf-solid);
  -webkit-mask-repeat: repeat-x, repeat-x, no-repeat;
          mask-repeat: repeat-x, repeat-x, no-repeat;
  -webkit-mask-size: calc(100% / var(--perfs-per-frame)) var(--perf-h),
                     calc(100% / var(--perfs-per-frame)) var(--perf-h),
                     100% calc(100% - 2 * var(--perf-h));
          mask-size: calc(100% / var(--perfs-per-frame)) var(--perf-h),
                     calc(100% / var(--perfs-per-frame)) var(--perf-h),
                     100% calc(100% - 2 * var(--perf-h));
  -webkit-mask-position: 0 top, 0 bottom, 0 var(--perf-h);
          mask-position: 0 top, 0 bottom, 0 var(--perf-h);

  --x: 0px;    /* where this frame's leading edge sits along the row */
  --z: 0px;    /* how far forward it has been pushed */
  --ry: 0deg;  /* how far it has turned away from the viewer */

  /* The hinge. Every frame turns about its own LEADING EDGE, which is the edge
   * it shares with the frame before it — so the row behaves like film: a chain
   * of panels joined at the frame lines, not a set of cards each spinning about
   * its own middle. */
  transform-origin: left center;
  /* Read right to left, this is: turn the frame away from the viewer, carry it
   * to its place on the bend, then tip the whole row. The bow is outermost and
   * uniform, about an axis every frame shares, so it tilts the sheet as one
   * object rather than tilting eleven cards individually. */
  transform: rotateX(var(--bow, 0deg)) translate3d(var(--x), 0, var(--z)) rotateY(var(--ry));
  transition: transform var(--dur-hover) var(--ease-enter);
}
/* Settling back is slower than tracking — a strip that snaps flat the instant the
 * cursor leaves reads as a light switch rather than as something with weight. */
.loom:not(:hover) .strip { transition-duration: var(--dur-exit); }

/* The film is CUT WIDER THAN THE WINDOW. seam.js measures the row and sets --fw
 * to a frame width that makes the strip overhang both edges, so there is always
 * stock parked off-screen — which is what slides into view when the bend draws
 * the ends inward. Without the script the frames just divide the row as before,
 * so a scriptless page still gets a correct strip, only one that ends where the
 * element does. */
.loom.is-measured .strip { flex: 0 0 var(--fw); }

/* The frame's picture. Sits in the image area only — between the two margins —
 * because that is the part of a frame that carries a photograph. It paints over
 * the hatch, which stays underneath as the no-image fallback. */
.strip > img {
  position: absolute;
  top: var(--perf-h); left: 0; right: 0;
  width: 100%;
  /* Stated, not left to the insets. An <img> is a replaced element, so height:auto
   * resolves to the file's own intrinsic height rather than stretching to fill a
   * top/bottom-anchored box — which left every frame showing its photograph in a
   * band across the top and bare hatch underneath. */
  height: calc(100% - 2 * var(--perf-h));
  object-fit: cover;
  display: block;
}

/* The join, which is also the frame line. Each frame carries its own slice of the
 * spectrum on its leading edge, so the ten threads read left to right across the
 * hero. It is inset by the margin depth: a frame line that ran on through the
 * perforated margins would not be one. */
.strip + .strip::before {
  content: ""; position: absolute; inset-block: var(--perf-h); left: -1px; width: 2px;
  background: var(--seam-slice, var(--s7));
  z-index: 1;
}

.strip:nth-child(1)  { --hatch-gap: 4px;  --hatch-ink: 18%; --hatch-angle: 90deg; }
.strip:nth-child(2)  { --hatch-gap: 7px;  --hatch-ink: 12%; --seam-slice: var(--s1); }
.strip:nth-child(3)  { --hatch-gap: 3px;  --hatch-ink: 22%; --seam-slice: var(--s2); }
.strip:nth-child(4)  { --hatch-gap: 9px;  --hatch-ink: 10%; --seam-slice: var(--s3); }
.strip:nth-child(5)  { --hatch-gap: 5px;  --hatch-ink: 16%; --seam-slice: var(--s4); }
.strip:nth-child(6)  { --hatch-gap: 11px; --hatch-ink: 9%;  --seam-slice: var(--s5); }
.strip:nth-child(7)  { --hatch-gap: 4px;  --hatch-ink: 20%; --seam-slice: var(--s6); }
.strip:nth-child(8)  { --hatch-gap: 8px;  --hatch-ink: 11%; --seam-slice: var(--s7); }
.strip:nth-child(9)  { --hatch-gap: 6px;  --hatch-ink: 15%; --seam-slice: var(--s8); }
.strip:nth-child(10) { --hatch-gap: 3px;  --hatch-ink: 24%; --seam-slice: var(--s9); }
.strip:nth-child(11) { --hatch-gap: 10px; --hatch-ink: 10%; --seam-slice: var(--s10); }

/* On a phone, eleven frames across ~360px puts the pitch at eight pixels and the
 * holes at under five — at that size a row of perforations stops being holes and
 * becomes a dotted line. Halving the count restores a hole you can actually read
 * as one, and two per frame still lands every hole on the frame grid. The margins
 * shrink with it so the strip keeps its proportions. */
@media (max-width: 40rem) {
  .loom {
    --perfs-per-frame: 2;
    /* Halving the count doubles the pitch, so the margin has to grow with it or
     * the hole comes out twice as wide as it should be tall. The multiplier is
     * the one that keeps the 1.4:1 across this whole range rather than at one
     * width in the middle of it. */
    --perf-h: clamp(14px, 4.8vw, 32px);
  }
}

/* ==========================================================================
 * 6. THE ROSETTE — a mark generated from the product's own numbers.
 *
 * An engine-turned guilloché figure: r(theta) = R0 + a1*sin(n1*theta) +
 * a2*sin(n2*theta), the rose-engine lathe's actual construction, drawn as fine
 * ink hairlines. Its harmonics are seeded from real PhotoPool constants (96
 * animals, 12 palettes, 5 plans), so the figure is derived rather than
 * decorative, and it is the same family of mark as the holographic foil the
 * iOS app really renders.
 *
 * Drawn ONCE to a canvas on load. It never animates: an ambient moving
 * background is precisely the decoration this system refuses.
 * ====================================================================== */

/* ==========================================================================
 * 6b. THE STAGE BACKGROUND — a photograph behind the top of the page only.
 *
 * Fixed rather than scrolled, so the film strip and the projector card travel up
 * OVER it the way content moves over a page background. It is faded out as the
 * projector opens (seam.js section 7), which is what keeps it off the rest of
 * the site: by the time the card has taken the screen the photograph has gone,
 * and everything below is the same paper it always was.
 *
 * EMPTY AND HIDDEN BY DEFAULT. seam.js fills it only under ?photos, because the
 * Care Label on this page states in writing that it ships no photographs. The
 * flag is the same one the tiles and the projector scene already use, so there
 * is one switch for stand-in imagery on this page rather than two.
 * ====================================================================== */

.stage-bg {
  display: none;
  position: fixed;
  inset: 0;
  z-index: -1;              /* behind all content, above the body's own ground */
  background-position: center;
  background-size: cover;
  opacity: var(--stage-o, 1);
  pointer-events: none;
}
.stage-bg.is-on { display: block; }

/* A flat veil, NOT a gradient scrim. Type and hairline ink over a photograph
 * need the contrast knocked back, and the reflex is a radial or linear scrim —
 * which the research catalogued as a tell and which this system uses nowhere
 * else (see .proj-plate for the same argument made about the same problem). A
 * single even tint of the page's own ground is the plate version of the idea:
 * it reads as the photograph sitting UNDER the paper rather than as a shadow
 * airbrushed onto it, and it inverts with the theme for free. */
.stage-bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--bg) 62%, transparent);
}

/* Scaffolding for the blanked hero — see site/pages/home.html. Holds the space
 * the hero occupied so the film strip keeps its position on the page. */
.hero-blank { min-height: clamp(18rem, 40vw, 34rem); }

/* Scaffolding for the section after the roll, so its exit has something to hand
 * over to. Goes when the real section arrives. */
.next-blank { min-height: 70vh; }

.rosette { width: 100%; max-width: 34rem; aspect-ratio: 1; margin-inline: auto; }
.rosette canvas { width: 100%; height: 100%; display: block; }

/* ==========================================================================
 * 7. THE CAST — persona art at scale.
 *
 * PhotoPool's own drawings, and the only warm thing on the page besides real
 * photographs. Shown in bulk because bulk IS the claim: 96 animals across 96
 * adjectives across 12 palettes. A row of six asserts it; a wall of forty
 * shows it.
 * ====================================================================== */

/* Fixed column counts, every one a divisor of the 36 personas shown — the same
 * rule the cloth follows, for the same reason. auto-fill leaves a ragged last
 * row, and because the container paints through the gaps that row reads as a
 * bare patch rather than as an ending. 36 = 12x3 = 9x4 = 6x6 = 4x9. */
.cast {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  list-style: none; padding: 0; margin: 2rem 0 0;
}
@media (max-width: 64rem) { .cast { grid-template-columns: repeat(9, 1fr); } }
@media (max-width: 44rem) { .cast { grid-template-columns: repeat(6, 1fr); } }
@media (max-width: 26rem) { .cast { grid-template-columns: repeat(4, 1fr); } }
.cast li { aspect-ratio: 1; background: var(--bg-raised); display: grid; place-items: center; }
.cast img { width: 84%; height: auto; }

/* ==========================================================================
 * 8. THE CLOTH — the woven grid and its five settings.
 *
 * Tiles are INK, in graded tones, never colour: when real photographs exist
 * they drop straight in and become the only colour in the section, which is the
 * whole rule of this system. Twelve tiles, so every column count below is a
 * divisor of 12 and every span is compensated — otherwise the leftover cells
 * render as holes in a weave, on a page arguing the cloth is whole.
 * ====================================================================== */

.cloth {
  display: grid; gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  grid-auto-flow: dense;
  position: relative;
}
/* The cloth's own edge is a seam: spectrum, because this is where twelve
 * separate things are joined into one surface. */
.cloth::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 2px;
  background: var(--spectrum);
}

.tile { position: relative; background: var(--bg-raised); overflow: hidden; }
.tile > img { width: 100%; height: 100%; object-fit: cover; }

.tile[data-tone="1"] { background: color-mix(in srgb, var(--fg) 6%,  var(--bg)); }
.tile[data-tone="2"] { background: color-mix(in srgb, var(--fg) 12%, var(--bg)); }
.tile[data-tone="3"] { background: color-mix(in srgb, var(--fg) 20%, var(--bg)); }
.tile[data-tone="4"] { background: color-mix(in srgb, var(--fg) 30%, var(--bg)); }
.tile[data-tone="5"] { background: color-mix(in srgb, var(--fg) 44%, var(--bg)); }

.cloth[data-setting="wall"] { grid-template-columns: repeat(6, 1fr); grid-auto-rows: 7rem; }
@media (max-width: 60rem) { .cloth[data-setting="wall"] { grid-template-columns: repeat(4, 1fr); } }
@media (max-width: 40rem) { .cloth[data-setting="wall"] { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 26rem) { .cloth[data-setting="wall"] { grid-template-columns: repeat(2, 1fr); } }

/* hero — tile 1 spans 2x2 (4 cells) and tile 2 spans 2 cols (2 cells):
 * 4 + 2 + 10 = 16 = exactly 4 rows. */
.cloth[data-setting="hero"] { grid-template-columns: repeat(4, 1fr); grid-auto-rows: 5.5rem; }
.cloth[data-setting="hero"] .tile:first-child { grid-column: span 2; grid-row: span 2; }
.cloth[data-setting="hero"] .tile:nth-child(2) { grid-column: span 2; }

.cloth[data-setting="duet"] { grid-template-columns: repeat(2, 1fr); grid-auto-rows: 8rem; }
.cloth[data-setting="triptych"] { grid-template-columns: repeat(3, 1fr); grid-auto-rows: 7.5rem; }

/* constellation — 6x2, no spans, distinct via an uneven row rhythm. Row-spans
 * were tried twice and both left holes the 12 tiles could not fill. */
.cloth[data-setting="constellation"] {
  grid-template-columns: repeat(6, 1fr);
  grid-template-rows: 6rem 3.25rem; grid-auto-rows: 3.25rem;
}
@media (max-width: 40rem) {
  .cloth[data-setting="constellation"] {
    grid-template-columns: repeat(3, 1fr); grid-template-rows: 5rem 3rem 5rem 3rem;
  }
}

/* --- the settings control ------------------------------------------- */
.setting-bar { display: none; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
.js .setting-bar { display: flex; }

.setting-btn {
  font-family: var(--stack-num); font-size: 0.8rem;
  color: var(--fg-muted); background: transparent;
  border: 1px solid var(--line);
  min-height: 44px; padding: 0.45rem 1rem; cursor: pointer;
  transition: color var(--dur-hover) ease, border-color var(--dur-hover) ease;
}
.setting-btn:hover { color: var(--fg); border-color: var(--line-strong); }
.setting-btn:active { transform: translateY(1px); }
.setting-btn[aria-pressed="true"] { color: var(--bg); background: var(--fg); border-color: var(--fg); }
@media (forced-colors: active) { .setting-btn[aria-pressed="true"] { text-decoration: underline 2px; } }

/* ==========================================================================
 * 9. THE LEDGER — claims as rows, including the ones that are not true yet.
 * ====================================================================== */

.ledger { border-top: 1px solid var(--line); }
.row {
  display: grid; grid-template-columns: 3.25rem 1fr auto; gap: 1rem;
  align-items: baseline; padding: 0.9rem 0; border-bottom: 1px solid var(--line);
}
.row-n { font-family: var(--stack-num); font-size: 0.75rem; color: var(--fg-quiet); font-variant-numeric: tabular-nums; }
.row-claim { color: var(--fg); margin: 0; max-width: 54ch; }
.row-claim small { display: block; color: var(--fg-muted); font-size: 0.9rem; margin-top: 0.2rem; }
.row-state { font-family: var(--stack-num); font-size: 0.7rem; white-space: nowrap; color: var(--fg-quiet); }

/* "built" is the one status that earns a mark of colour, because a live claim
 * is the closest thing on this page to real content. */
.row[data-state="built"] .row-state { color: var(--fg); }
.row[data-state="built"] .row-n::after {
  content: ""; display: inline-block; width: 6px; height: 6px;
  margin-left: 0.4rem; background: var(--s5); border-radius: 50%;
  vertical-align: middle;
}
.row[data-state="not-yet"] .row-claim { color: var(--fg-muted); }

/* ==========================================================================
 * 10. THE SELVEDGE — price as a table of facts, not cards with a badge.
 * ====================================================================== */

.selvedge-scroll { overflow-x: auto; }
.selvedge-scroll:focus-visible { outline: 2px solid var(--s7); outline-offset: 3px; }
.selvedge { width: 100%; border-collapse: collapse; font-size: 0.95rem; min-width: 40rem; }
.selvedge th, .selvedge td {
  text-align: left; padding: 0.7rem 0.9rem;
  border-bottom: 1px solid var(--line); vertical-align: baseline;
}
.selvedge thead th { color: var(--fg); font-weight: 500; border-bottom: 2px solid var(--fg); }
.selvedge tbody th { font-weight: 400; color: var(--fg-muted); font-size: 0.85rem; }
.selvedge td { font-family: var(--stack-num); font-variant-numeric: tabular-nums; color: var(--fg); }
.selvedge caption { text-align: left; color: var(--fg-muted); font-size: 0.85rem; padding-bottom: 0.75rem; }

/* ==========================================================================
 * 11. CONTROLS
 * ====================================================================== */

.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.75rem; }

.act {
  display: inline-block; font: inherit; font-weight: 500;
  padding: 0.7rem 1.4rem; border: 1px solid var(--fg);
  background: var(--fg); color: var(--bg);
  text-decoration: none; cursor: pointer;
  transition: background var(--dur-hover) ease, color var(--dur-hover) ease;
}
.act:hover { background: transparent; color: var(--fg); }
.act:active { transform: translateY(1px); }

.act-quiet { background: transparent; color: var(--fg); border-color: var(--line-strong); }
.act-quiet:hover { border-color: var(--fg); background: transparent; color: var(--fg); }

/* The single most saturated element on the site: the primary action, underlined
 * by the full spectrum. One instance per page. */
.act-live { position: relative; background: var(--fg); color: var(--bg); border-color: var(--fg); }
.act-live::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 3px;
  background: var(--spectrum);
}
.act-live:hover { background: transparent; color: var(--fg); }

/* ==========================================================================
 * 12. CHROME
 * ====================================================================== */

.bar {
  position: sticky; top: 0; z-index: 50;
  display: flex; align-items: center; gap: 1rem;
  padding: 0.75rem var(--gut);
  background: var(--bg); border-bottom: 1px solid var(--line);
}
.bar::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -2px; height: 2px;
  background: var(--spectrum); opacity: 0.9;
}
.bar-brand {
  display: flex; align-items: center; gap: 0.6rem; text-decoration: none;
  color: var(--fg); min-height: 44px; padding-right: 0.5rem;
}
.bar-nav { margin-left: auto; display: flex; align-items: center; gap: 1rem; }
/* nowrap is load-bearing, not tidiness. Without it a narrow bar breaks INSIDE the
 * links — "The / Seam", "How it / works", "Log / in" — and a three-line bar pushes
 * the hero below it off the bottom of the screen. Whole links that move as a unit
 * are the only acceptable failure mode. */
.bar-nav a { color: var(--fg-muted); text-decoration: none; font-size: 0.95rem; white-space: nowrap; }
.bar-nav a:hover { color: var(--fg); text-decoration: underline; }

/* The current page. Full ink plus a spectrum underline rather than a pill or a
 * background: the bar is a hairline instrument and a filled chip would be the
 * loudest object in it. The rule is the same 2px seam used everywhere else, so
 * "you are here" is spoken in the language the site already has.
 *
 * The bar gained a third link on 2026-08-23 (The Seam moved off /), which is
 * what made an active state worth having at all — with two links there was
 * nothing to get lost in. */
.bar-nav a.active { color: var(--fg); position: relative; }
.bar-nav a.active::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -0.35em; height: 2px;
  background: var(--spectrum);
}

.theme-btn {
  font-family: var(--stack-num); font-size: 0.7rem; color: var(--fg-muted);
  background: transparent; border: 1px solid var(--line);
  min-height: 44px; min-width: 44px; padding: 0 0.7rem; cursor: pointer;
}
.theme-btn:hover { color: var(--fg); border-color: var(--line-strong); }

.foot { border-top: 1px solid var(--line); padding: clamp(2.5rem, 6vw, 4rem) 0; font-size: 0.9rem; }
.foot-in { display: flex; flex-wrap: wrap; gap: 1.5rem 2.5rem; align-items: baseline; }
.foot-nav { display: flex; flex-wrap: wrap; gap: 1.25rem; margin-left: auto; }
.foot-nav a { color: var(--fg-muted); text-decoration: none; }
.foot-nav a:hover { color: var(--fg); text-decoration: underline; }
.foot-mark { margin: 0; color: var(--fg); }

/* ==========================================================================
 * 13. THE WORDMARK — drawn, not set.
 *
 * An SVG lockup built from narrow rectangular logs, the same strip-and-seam
 * unit as the page. Ink by default. It fills with the spectrum on exactly one
 * moment sitewide, which is what makes that moment worth anything.
 * ====================================================================== */

.mark { display: block; color: var(--fg); }
.mark rect { fill: currentColor; }

/* The bar's instance. Sized here rather than in the partial so one source of
 * geometry can serve the bar and the lab at different scales.
 *
 * The 132px slot is RESERVED and never animates, even once the bar has collapsed
 * to a ~16px mark. Width is a layout property, and the alternative — transitioning
 * it — would put layout work on the compositor's critical path during a scroll,
 * which is the one moment it cannot afford. Nothing is lost: the nav is pinned
 * right by margin-left:auto, so the unused slot is invisible and the bar simply
 * reads as mark-left / nav-right. */
.bar-brand .mark { width: 132px; height: 18px; }

/* On a phone the three links plus the toggle stop fitting beside a 132px lockup:
 * at 375px the row wants ~425px. Nothing goes behind a menu — for three links a
 * hamburger is more chrome than content — so the space is found instead by
 * shrinking the reserved lockup slot, the gaps and the type, which together buy
 * back the ~60px needed.
 *
 * The mark's own geometry is in viewBox units, so narrowing the slot rescales the
 * whole lockup and the collapse transforms with it; nothing has to be re-authored.
 *
 * IT HAS TO LIVE HERE, below §13's 132px rule for .bar-brand .mark. Both
 * selectors are (0,2,0) and a media query adds no specificity, so authored above
 * that rule the slot override loses and the bar silently keeps wrapping — which
 * is exactly how it was first written.
 *
 * flex-wrap on .bar is the safety net for windows narrower than anything here can
 * save (320px and below). Combined with nowrap on the links it means the nav drops
 * to a second row AS A GROUP rather than the bar shattering into five lines. */
@media (max-width: 30rem) {
  /* Measured, not guessed. At 375px the untouched bar wants 425px and the first
   * pass still wanted 378.7 — three pixels over, which wraps just as completely as
   * fifty. These numbers land it at ~358, which also clears the 360px Android
   * width with a little to spare. The single biggest saving is the lockup slot;
   * the rest is gaps. */
  .bar { gap: 0.45rem; padding: 0.6rem 0.75rem; flex-wrap: wrap; }
  .bar-brand { gap: 0.4rem; padding-right: 0.2rem; }
  .bar-brand .mark { width: 88px; height: 12px; }
  .bar-nav { gap: 0.5rem; flex-wrap: nowrap; }
  .bar-nav a { font-size: 0.8rem; }
  .bar-nav a.active::after { bottom: -0.3em; }
  /* Still 44px of touch target tall (min-height is on the base rule); only the
   * horizontal footprint gives way. */
  .theme-btn { font-size: 0.56rem; padding: 0 0.35rem; min-width: 34px; }
}

/* ---- the collapse ------------------------------------------------------
 * Transitions, deliberately not keyframes. A scroll state has to be reversible
 * and interruptible: scroll up halfway and the logs must run back from wherever
 * they are, which a keyframe animation cannot do without being torn down and
 * rebuilt mid-flight.
 *
 * The geometry and the per-log transforms live in templates/seam-wordmark.html.
 * Only transform and opacity are touched. --bar-d is set per log by seam.js from
 * the log's real x position, so the word clears right-to-left ahead of the o
 * travelling through it. */
.bar .mark rect,
.bar .mark .roller {
  transition:
    transform 620ms var(--ease-move) var(--bar-d, 0ms),
    opacity 300ms linear var(--bar-d, 0ms);
}

/* Everything except the stem that stays and the o that rolls. :not() rather than
 * a class on 25 rects, so adding a letter needs no bookkeeping. */
.bar.is-collapsed .mark rect:not(.keeps):not(.o-log) {
  opacity: 0;
  transform: translate(var(--bar-lx, 0), var(--bar-ly, 0));
}

/* The o's four logs land on F3a exactly. Each scales along its long axis only,
 * so the 6-unit stroke survives the journey. */
.bar.is-collapsed .mark .o-log {
  transform: translate(var(--dx), var(--dy)) scale(var(--kx), var(--ky));
}

/* The roll. -135deg, not the lab's -405: a full extra revolution is right for a
 * once-per-visit set piece and wearing on a bar that does this every time you
 * scroll. A square ring is 4-fold symmetric, so -135 comes to rest looking
 * identical to +45 while still reading as a roll rather than a snap. */
.bar.is-collapsed .mark .roller { transform: rotate(-135deg); }

.bar .mark .roller { transform-box: fill-box; transform-origin: center; }
.bar .mark rect { transform-box: fill-box; transform-origin: 0 0; }

/* Reduced motion keeps the STATE — the bar still collapses, because that is
 * information — and drops only the travel. */
@media (prefers-reduced-motion: reduce) {
  .bar .mark rect, .bar .mark .roller { transition-duration: 0.01ms; transition-delay: 0ms; }
}

/* Per-element transforms inside an SVG resolve against the whole viewBox unless
 * told otherwise, which makes every rotate and scale swing wildly off-centre.
 * fill-box scopes each one to its own geometry. Set once, globally, so no
 * animation has to remember it. */
.mark .gl, .mark rect { transform-box: fill-box; transform-origin: center; }

.mark--spectrum rect:nth-child(10n+1) { fill: var(--s1); }
.mark--spectrum rect:nth-child(10n+2) { fill: var(--s2); }
.mark--spectrum rect:nth-child(10n+3) { fill: var(--s3); }
.mark--spectrum rect:nth-child(10n+4) { fill: var(--s4); }
.mark--spectrum rect:nth-child(10n+5) { fill: var(--s5); }
.mark--spectrum rect:nth-child(10n+6) { fill: var(--s6); }
.mark--spectrum rect:nth-child(10n+7) { fill: var(--s7); }
.mark--spectrum rect:nth-child(10n+8) { fill: var(--s8); }
.mark--spectrum rect:nth-child(10n+9) { fill: var(--s9); }
.mark--spectrum rect:nth-child(10n)   { fill: var(--s10); }

/* ==========================================================================
 * 14. THE QR — structural, not incidental.
 *
 * The product's central mechanic and the one owned object that cannot be
 * swapped to another company. It stays pure black on white at any theme,
 * because scannability is not negotiable; the spectrum frames it instead.
 * ====================================================================== */

.code {
  position: relative; display: inline-block;
  background: #fff; padding: 1.25rem;
  border: 1px solid var(--line);
}
.code::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 3px;
  background: var(--spectrum);
}
.code img { width: min(20rem, 60vw); height: auto; }

/* ==========================================================================
 * 14a. THE PROJECTOR — a widget that opens to fill the room.
 *
 * Starts as a contained panel and grows to the edges of the browser as you
 * scroll toward it. The move is borrowed, and it is only worth borrowing because
 * this product has a projector: a host curates a rotation and it plays on the
 * wall at the venue. Filling the frame is not a flourish here, it is the feature
 * being described.
 *
 * Scaled with transform, not sized with width/height. A projector genuinely does
 * magnify its content, so the contents growing with the panel is correct rather
 * than a compromise — and it keeps the whole thing on the compositor.
 *
 * The panel deliberately carries NO border. A 1px edge scaled 2.4x becomes a
 * 2.4px edge, and the frame would visibly fatten as it opened. The surface
 * colour does the work instead.
 * ====================================================================== */

.proj-track { height: 300vh; }
.proj-stage {
  position: sticky; top: 0; height: 100vh; overflow: hidden;
  display: block;
}

/* THE SCREEN. Always full-bleed; what changes is how much of it you are allowed
 * to see. Clipped in from the sides when closed and opened to the edges as you
 * scroll — so the CONTENT never moves or resizes, the frame just widens off it.
 *
 * That is the difference from scaling, and it is the whole feel: scale shrinks
 * the picture and grows it back, which reads as a zoom. Clipping holds the
 * picture still and opens the window onto it, which reads as something taking
 * over the screen.
 *
 * The corner radius is the one in this system — everything else is square — and
 * it exists only while the thing is a card. It reaches 0 exactly as the screen
 * reaches the edges, so the state that persists has none.
 */
.proj-screen {
  position: absolute; inset: 0;
  clip-path: inset(0 var(--inx, 8%) 0 var(--inx, 8%) round var(--r, 16px));
  display: flex; flex-direction: column;
  background: var(--bg-raised);
  will-change: clip-path;
}
/* The edge fade. Sits inside the clip so it travels with the opening frame, and
 * is the only soft gradient in the system — every other one is a hard-stop band.
 * Kept to 5% a side: enough to stop the imagery butting hard into the browser
 * edge, not enough to read as a vignette effect. */
.proj-screen::before {
  content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
  background: linear-gradient(90deg,
    var(--bg) 0, transparent 5%, transparent 95%, var(--bg) 100%);
}
.proj-screen::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; z-index: 3;
  background: var(--spectrum);
}

/* The product's OWN constellation geometry: 1.4fr/1fr columns, two rows, first
 * cell spanning both. Three cells, not five — five collapsed the last two to
 * zero height and let their contents escape. */
.proj-scene {
  flex: 1; min-height: 0;
  display: grid; gap: 2px;
  grid-template-columns: 1.4fr 1fr; grid-template-rows: 1fr 1fr;
  background: var(--line);
}
.proj-scene > i:first-child { grid-row: 1 / 3; }
.proj-scene > i { display: block; background: var(--bg); overflow: hidden; }
.proj-scene > i[data-tone="1"] { background: color-mix(in srgb, var(--fg) 8%,  var(--bg)); }
.proj-scene > i[data-tone="2"] { background: color-mix(in srgb, var(--fg) 16%, var(--bg)); }
.proj-scene > i[data-tone="3"] { background: color-mix(in srgb, var(--fg) 26%, var(--bg)); }
.proj-scene > i[data-tone="4"] { background: color-mix(in srgb, var(--fg) 38%, var(--bg)); }

/* A solid plate, not a gradient scrim over the imagery. The repeated
 * radial-scrim-for-legibility is one of the patterns the research catalogued as
 * a tell, and this system has no decorative gradients anywhere else. The plate
 * scales with the screen, so at 0.42 it reads as a widget's caption bar and
 * becomes readable as the thing opens. */
.proj-plate {
  flex: none;
  background: var(--bg);
  border-top: 1px solid var(--line);
  /* Padding tracks the clip. --inx is a percentage of this element's own width
   * and so is padding-inline, so adding them keeps every word inside the visible
   * window at any stage of the opening. Without this the plate's first and last
   * items are cut off while the screen is still a card — "01 — the projector"
   * renders as "e projector". */
  padding: clamp(1rem, 3.2vh, 2.2rem) calc(var(--gut) + var(--inx, 0%));
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem 1.5rem;
  transition: padding 0s;
}
.proj-plate h2 { margin: 0; font-size: clamp(1.2rem, 3.4vh, 2.2rem); font-weight: 300;
                 letter-spacing: -.015em; color: var(--fg); max-width: none; }
.proj-plate .eyebrow { margin: 0; }
.proj-plate p { margin: 0; color: var(--fg-muted); font-size: clamp(.8rem, 1.7vh, 1rem);
                max-width: 46ch; }
.proj-plate .cap { margin-left: auto; font-family: var(--stack-num);
                   font-size: .68rem; color: var(--fg-quiet); }

/* No JS, reduced motion, or narrow: the screen is simply open, in normal flow. */
.proj-track { height: auto; }
.proj-stage { position: static; height: auto; }
.proj-screen { position: relative; inset: auto; clip-path: none; height: 78vh; }

.js .proj-track { height: 300vh; }
.js .proj-stage { position: sticky; height: 100vh; }
.js .proj-screen { position: absolute; inset: 0; clip-path: inset(0 var(--inx, 8%) 0 var(--inx, 8%) round var(--r, 16px)); height: auto; }

@media (prefers-reduced-motion: reduce) {
  .js .proj-track { height: auto; }
  .js .proj-stage { position: static; height: auto; }
  .js .proj-screen { position: relative; inset: auto; clip-path: none; height: 78vh; }
}
@media (max-width: 48rem) {
  .js .proj-track { height: auto; }
  .js .proj-stage { position: static; height: auto; }
  .js .proj-screen { position: relative; inset: auto; clip-path: none; height: 62vh; }
  .proj-plate { flex-direction: column; align-items: flex-start; }
  .proj-plate .cap { margin-left: 0; }
}

/* ==========================================================================
 * 14b. THE ROLL — the mark's geometry as a section.
 *
 * The stem becomes the section's spine and the bowl rolls down it. Same two
 * parts as the compact mark, at page scale.
 *
 * The square does NOT slide down at a constant rate while spinning. It pivots
 * about each corner in turn, so its centre traces a circular arc per quarter
 * turn and it visibly lurches as it tips over. Every number is computed in
 * seam.js initRoll(); this file only positions what that produces.
 * ====================================================================== */

.roll { position: relative; }
.roll-stage { display: grid; place-items: center; overflow: hidden; }
.roll-inner { position: relative; width: min(72rem, 100% - 2rem); height: 100%; }

.roll-line { position: absolute; left: 3rem; top: 0; bottom: 0; width: 6px; background: var(--fg); }
.roll-line::after {
  content: ""; position: absolute; inset: 0;
  background: var(--spectrum-v); opacity: 0; transition: opacity 400ms linear;
}
/* The spine takes the spectrum only while the O is actually travelling it —
 * colour where separate things meet, and nowhere else. */
.roll.is-rolling .roll-line::after { opacity: 1; }

.roll-o {
  position: absolute;
  left: calc(3rem + 6px);          /* the line's right face — the rolling surface */
  top: 0;
  width: var(--sq); height: var(--sq);
  transform: translate(var(--x, 0px), var(--y, 0px)) rotate(var(--deg, 0deg));
  transform-origin: center;
  will-change: transform;
}
/* A stroked SVG outline, not a CSS border: the body changes shape as it rolls,
 * and a border can only ever be a rectangle. non-scaling-stroke keeps the line
 * exactly 6 units no matter which shape it currently is. */
.roll-frame { position: absolute; inset: 0; }
.roll-frame svg { width: 100%; height: 100%; overflow: visible; display: block; }
.roll-poly { fill: none; stroke: var(--fg); stroke-width: 6; vector-effect: non-scaling-stroke; }
.roll-card { position: absolute; inset: 0; display: grid; place-items: center;
             transform: rotate(calc(-1 * var(--deg, 0deg))); }
.roll-card .n { font-family: var(--stack-num); font-size: clamp(1.1rem, 3vh, 1.8rem);
                color: var(--fg); font-variant-numeric: tabular-nums; }

/* The words sit in a column beside the spine. Content inside the square and a
 * meaningful roll are in direct conflict — rotation is (travel/side)*90, so a
 * full revolution needs travel = 4x the side, and a square big enough to hold a
 * sentence can only turn ~140deg inside one sticky viewport. The square carries
 * the step number; the column carries the words. */
.roll-copy { position: absolute; left: 40%; right: 0; top: 50%; transform: translateY(-50%); max-width: 34rem; }
.roll-copy > div { position: absolute; inset: 0; opacity: 0; transform: translateY(10px);
                   transition: opacity 260ms linear, transform 420ms var(--ease-enter); }
.roll-copy > div[data-on="1"] { opacity: 1; transform: none; position: relative; }
.roll-copy .n { font-family: var(--stack-num); font-size: .7rem; color: var(--fg-quiet); display: block; margin-bottom: .75rem; }
.roll-copy p { margin: 0; font-size: clamp(1.15rem, 2.6vw, 1.9rem); line-height: 1.25;
               font-weight: 300; color: var(--fg); max-width: none; }

/* The LIST is the default and the roll is opted into. Fail-safe in the same
 * direction as the rest of the motion system: no JS, reduced motion, or a narrow
 * screen gets readable content rather than a tall empty scroll past a sticky box
 * that never moves. Written as single-condition queries because the build's CSS
 * parse gate mis-reads a comma'd condition list. */
.roll-track { height: auto; }
.roll-stage { position: static; height: auto; }
.roll-inner { display: none; }
.roll-fallback { display: block; }

/* 500vh, not 420: the last 16% of the track is the exit below, and the roll
 * itself keeps the 420vh of scroll it has always had. */
.js .roll-track { height: 500vh; }
.js .roll-stage { position: sticky; top: 0; height: 100vh; }

/* THE EXIT — the panel lifts and tilts away to hand over to the next section.
 *
 * Real depth, not a slide dressed up as one: perspective() puts the panel in
 * space, and rotateX turns its top edge away from you while it rises. So it
 * recedes as it goes rather than sliding flat up the page, which is what makes
 * it read as an object clearing out of the way instead of a rectangle moving.
 *
 * --exit is written by initRoll on the same scroll number the square rolls on,
 * so the panel cannot start leaving while the shape is still turning. At 0 every
 * function below is its own identity, so the resting panel is untransformed.
 *
 * The perspective lives IN the transform rather than on .roll-track, because the
 * track is a plain scroll runway and giving it a 3D context would drag the sticky
 * positioning into the same containing block. */
.js .roll-stage {
  --exit: 0;
  transform:
    perspective(1600px)
    translate3d(0, calc(var(--exit) * -52%), 0)
    rotateX(calc(var(--exit) * 20deg))
    scale(calc(1 - var(--exit) * 0.1));
  /* All the way to nothing, not to 0.35. The strands are pinned underneath by
   * now (§14c) and a panel that stops a third visible is still covering them
   * when the handover is meant to be over. */
  opacity: calc(1 - var(--exit));
}
.js .roll-inner { display: block; }
.js .roll-fallback { display: none; }

@media (prefers-reduced-motion: reduce) {
  .js .roll-track { height: auto; }
  /* No pin here, so there is no panel to clear away — and initRoll returns before
   * it ever writes --exit. The transform is cleared rather than left resting at
   * identity so the fallback list is not sitting on a promoted 3D layer. */
  .js .roll-stage { position: static; height: auto; transform: none; opacity: 1; }
  .js .roll-inner { display: none; }
  .js .roll-fallback { display: block; }
}
@media (max-width: 48rem) {
  .js .roll-track { height: auto; }
  .js .roll-stage { position: static; height: auto; transform: none; opacity: 1; }
  .js .roll-inner { display: none; }
  .js .roll-fallback { display: block; }
}

.roll-fallback ol { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--line); }
.roll-fallback li { border-bottom: 1px solid var(--line); padding: 1.1rem 0;
                    display: grid; grid-template-columns: 3rem 1fr; gap: 1rem; }
.roll-fallback .n { font-family: var(--stack-num); font-size: .72rem; color: var(--fg-quiet); }

/* ==========================================================================
 * 14c. THE STRANDS — the transition the roll hands over to.
 *
 * The roll's spine thickens into a rope of ten colour bands, and the rope comes
 * apart: nine peel off and leave the frame, red runs straight down the axis the
 * whole way and ends at a phone. A DIVERGENCE — it opens already bundled.
 *
 * The geometry is all in seam.js initStrands (a camera panning down a fixed
 * world; see the comment there). This file only positions the stage, holds the
 * two layers in the right order, and owns the colour — which is the point of
 * the section. It is the spectrum doing the one thing this system allows it to
 * do: appearing at a join, as separate threads, never as a blend.
 *
 * THE REVEAL. .strands is pulled up UNDER the last screen of the roll and given
 * a lower z-index, so its stage is already pinned and drawing while the roll's
 * panel is still lifting and tilting away above it. That is what makes the roll
 * clear away to show something rather than simply scroll past.
 *
 * Two consequences for the roll, both deliberate:
 *   - .roll-stage needs a real background or it never covered anything to begin
 *     with. It resolves to the same --bg the page already had behind it, so
 *     nothing about the roll looks different; it is just opaque now.
 *   - the exit fades to nothing rather than to 0.35. A panel that stops at a
 *     third of its opacity is still sitting on top of the strands when the
 *     handover is supposed to be finished.
 * ====================================================================== */

/* No-JS ground: no pin, no camera, no runway. The section carries no content of
 * its own — it is a join — so what is left when the motion cannot run is the
 * join itself, stated in one static line. */
.strands { position: relative; }
.strands-track { height: auto; }
.strands-stage { position: static; height: auto; }
.strands-svg { display: none; }
.strands-fallback { display: block; padding-block: var(--sp-6, 3rem); }

.js .strands { z-index: 0; margin-top: -100vh; }
.js .roll { z-index: 1; }
.js .roll-stage { background: var(--bg); }
/* 420vh, not 340: the last 28% of it is the phone's slide, and the camera pan
 * keeps roughly the runway it had before the split (see CAM_BY in initStrands). */
.js .strands-track { height: 420vh; }
.js .strands-stage { position: sticky; top: 0; height: 100vh; }
.js .strands-svg { display: block; width: 100%; height: 100%; }
.js .strands-fallback { display: none; }

/* BANDS, not hairlines. --band is measured and written by initStrands (it has
 * to be, because the bundle's width is derived from it — five bands either side
 * of red, so the ten abut into one solid bar); this owns what is done with it.
 * Round caps and joins so a band leaving the frame mid-curve ends like a ribbon
 * rather than a cut edge. */
.thread {
  fill: none;
  stroke-width: var(--band, 20px);
  stroke-linecap: round;
  stroke-linejoin: round;
}
.thread[data-s="1"]  { stroke: var(--s1); }
.thread[data-s="2"]  { stroke: var(--s2); }
.thread[data-s="3"]  { stroke: var(--s3); }
.thread[data-s="4"]  { stroke: var(--s4); }
.thread[data-s="5"]  { stroke: var(--s5); }
.thread[data-s="6"]  { stroke: var(--s6); }
.thread[data-s="7"]  { stroke: var(--s7); }
.thread[data-s="8"]  { stroke: var(--s8); }
.thread[data-s="9"]  { stroke: var(--s9); }
.thread[data-s="10"] { stroke: var(--s10); }

/* Red needs no extra weight or fade to be the one you follow — it is the only
 * band still in the frame by the end. The nine leave; that is the statement. */

/* ⚑ STUB — a rectangle marking where the phone goes. Not a design decision.
 * Filled with the page ground so red terminates at its edge instead of showing
 * through it. */
.strands-phone {
  fill: var(--bg);
  stroke: var(--line-strong);
  stroke-width: 1.5;
}

@media (prefers-reduced-motion: reduce) {
  /* No pin and no overlap: initStrands returns before it draws anything, so a
   * pulled-up empty stage would just punch a screen-tall hole into the roll. */
  .js .strands { margin-top: 0; }
  .js .strands-track { height: auto; }
  .js .strands-stage { position: static; height: auto; }
  .js .strands-svg { display: none; }
  .js .strands-fallback { display: block; }
}
@media (max-width: 48rem) {
  .js .strands { margin-top: 0; }
  .js .strands-track { height: auto; }
  .js .strands-stage { position: static; height: auto; }
  .js .strands-svg { display: none; }
  .js .strands-fallback { display: block; }
}

/* ==========================================================================
 * 15. MOTION
 *
 * Written INSIDE prefers-reduced-motion: no-preference as the enabling
 * condition, never as a reduce override bolted on after. This fails safe: a
 * missed query means no animation, never unwanted animation. The resolved state
 * is what the stylesheet ships, so no-JS and reduced-motion visitors get the
 * finished page rather than one stuck at opacity 0.
 * ====================================================================== */

.rise { opacity: 1; transform: none; }

@media (prefers-reduced-motion: no-preference) {
  .is-armed .rise { opacity: 0; transform: translateY(12px); }
  .is-armed .rise.is-in {
    opacity: 1; transform: none;
    transition: opacity var(--dur-enter) var(--ease-enter),
                transform var(--dur-enter) var(--ease-enter);
    transition-delay: var(--rise-delay, 0ms);
  }
  .tile { transition: transform var(--dur-move) var(--ease-move); }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important; scroll-behavior: auto !important;
  }
  /* The film strip's magnification is already dead here — seam.js returns out of
   * initLoom() before it binds a single listener, so --x/--z/--ry keep the resting
   * defaults and nothing ever adds .is-lifted. This pins it in CSS as well, so
   * the guarantee is visible in the stylesheet rather than only in a script, and
   * so a future hover rule cannot reintroduce the movement by accident. */
  .strip { transform: none !important; }
}

/* ==========================================================================
 * 16. FORCED COLORS
 * ====================================================================== */

@media (forced-colors: active) {
  .strip, .tile, .cloth, .loom, .code { border: 1px solid CanvasText; }
  .act { border: 1px solid ButtonText; }
  .seam-rule, .bar::after, .cloth::after, .act-live::after, .code::after {
    background: CanvasText; forced-color-adjust: none;
  }
}

/* ==========================================================================
 * 17. PRINT
 *
 * Retint the tokens themselves rather than chasing selectors: the previous
 * version reset only headings and claims, which left the pricing table and the
 * ledger's built/not-yet words near-invisible on paper.
 * ====================================================================== */

@media print {
  :root {
    --bg: #fff; --bg-raised: #fff; --bg-sunk: #fff;
    --fg: #000; --fg-muted: #1a1a1a; --fg-quiet: #333;
    --line: #999; --line-strong: #666;
  }
  body { background: #fff; color: #000; }
  .bar, .setting-bar, .theme-btn { display: none; }
  .act { border: 1px solid #000; background: transparent; color: #000; }
  .selvedge-scroll { overflow: visible; }
  .selvedge { min-width: 0; font-size: 0.8rem; }
  .band { padding-block: 1.5rem; break-inside: avoid; }
  .row { break-inside: avoid; }
}
