/* /film — the reel, in theatre mode.

   Juan chose Theatre over Projection, and asked for somewhere real to put the
   credits rather than a one-line caption.

   CENTRED IN THE VIEWPORT, the same pattern as /code and the hero and every
   /work panel: the stage and its credits are one composition that sits in the
   middle of the screen, not a page that starts at the top.

   A REEL IS IMAGE-LED, so this one IS a slideshow, and that is not a
   contradiction of the tabbed trees on /code. The objection to carousels was
   never "one thing at a time" — it was auto-rotation and hidden slides. Here
   every film is named on a real thumbnail, the whole set is visible as a
   filmstrip the entire time, and the rotation stops the moment anyone touches
   it. What changes for a gallery is only the FORMAT: a frame wants the width,
   and five stacked would bury the last three. */

/* ⚠️ THE MAIN IS NOT `.wrap` ANY MORE, and that was the whole bug. `.wrap` is a
   READING measure — 940px max, minus 7vw of padding a side — so on a 1920px
   display it strangled the theatre monitor down to 671px and left the rest of
   the screen empty. Prose belongs in a reading measure; a screen does not. The
   stage is now sized to the VIEWPORT and only the text below it keeps a measure.

   And the header was sitting OUTSIDE this block, 272px of it, which is where the
   dead space above the monitor came from. It is inside the composition now. */
.doc__main.film {
  position: relative;
  min-height: 100svh;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: clamp(14px, 2.2vh, 26px);
  padding: 4vh 4vw 3vh;
}

.doc__head--sub {
  min-height: 0;
  padding-block: 0;
  text-align: center;
}

/* ─── SHORT VIEWPORTS: BUY THE CREDITS BACK OUT OF SPACING, NOT OUT OF THE
       MONITOR ────────────────────────────────────────────────────────────────
   🔴 THE HANDOFF SAID THE ONLY DIAL WAS --stage-h AND THAT ANY FIX SHRINKS THE
   MONITOR. Measured, that is true of --stage-h and FALSE of the page: at 1440
   wide the stage is WIDTH-capped above ~881px of viewport height
   (--stage-w = min(74vw, 1180px, --stage-h*16/9); 74vw = 1065.6px, so the height
   settles at 599.4px and stops following svh). Above that height, lowering
   --stage-h below 66.6svh does nothing at all until it drops under the width cap
   — so the monitor was never the thing paying for this in that band.
   ⇒ The gaps and the block padding are. They are pure whitespace, they scale on
   vh, and on a short screen they are exactly the wrong thing to spend height on.

   MEASURED, 1440 wide, stable samples (sampled twice and required to agree —
   a single reading right after a resize catches the credits mid-reflow and
   reports a height ~38px too tall):
     before   vh 900 -> credits bottom 920.3, 20.3px under the fold
              vh 940 -> credits bottom 925.2, visible
     The real break-even was ~921px, NOT the ~1010px the handoff carried.
     after    vh 900 -> credits bottom 893.3, VISIBLE with 6.7px to spare,
                        and the monitor is byte-identical at 1065.6x599.4
              vh 1010 -> 956.7, unchanged (the rule is off above 940)
   ⚠️ MEASURE WITH scrollY PINNED TO 0. getBoundingClientRect() is viewport-
   relative, so a page left scrolled from an earlier probe reports a credits
   bottom that has nothing to do with the layout — this read 1019.7 at vh 1010
   (apparently broken) and 956.7 (correct, 53.3px of slack) at the same size, and
   the only difference was scroll position. Two samples agreeing does NOT catch
   it; both agree on the same wrong number.
   ⚠️ THIS DOES NOT RESCUE EVERY HEIGHT, and that is Juan's call rather than a
   thing to fudge. At vh 800 — a 13" MacBook with browser chrome, so a genuinely
   common case — the stage is height-capped at 544px and the content is simply
   taller than the screen. Measured after: credits bottom 828.5, still 28.5px
   under the fold, improved from 52.5px. Closing that last 28.5px means shrinking
   the monitor, which is the trade the handoff described and it is his to make.
   ⚠️ Scoped `min-width: 901px` so it cannot touch the narrow branch, which has
   its own layout (film.css keys the static row on `max-width: 900px`). */
@media (min-width: 901px) and (max-height: 940px) {
  .doc__main.film {
    /* 1.4vh -> 1vh. There are TWO of these gaps above the credits (head/stage
       and stage/credits), so the saving doubles — which is why this was the last
       6.4px rather than the padding, and the padding-BOTTOM is worth nothing
       here at all: it sits BELOW the credits and cannot move their bottom edge. */
    gap: clamp(8px, 1vh, 16px);
    /* 2.6vh -> 1.2vh: the top padding is the least useful height on the page —
       it sits above a back link. */
    padding-block: 1.2vh 2vh;
  }
  .film__credits { row-gap: 10px; }

  /* 🔴 THE HEAD IS CHROME TOO, AND THE RULE ABOVE NEVER REACHED IT. Everything
     in this block was written for .doc__main and its credits; the HEAD sitting
     directly above the monitor was left at full height, and it is 113px of pure
     chrome at 1440x800 — a back link, the page mark, and a VISUALLY HIDDEN h1.
     Measured breakdown before:
         .back        17px + 14.4 margin
         .page-mark   56px + 17.6 margin
         .doc__h1.vh   1px +  8.0 margin   <- invisible, still in flow, still
                                              spending 8px of a short screen
     ⇒ Same argument as the note above, one region further up: this is whitespace
     that scales on vh, and on a short screen it is the wrong thing to spend
     height on. The monitor is not asked to pay anything.
     ⚠️ THE HIDDEN h1's MARGIN IS THE ONE WORTH NAMING. `.vh` here is the
     1px-clip variant, so the element is still IN FLOW and its margin-bottom is
     real height reserved for something nobody can see. Nothing would ever
     surface that: it renders correctly, measures correctly, and simply costs.
     ⚠️ The page mark keeps its 56px. It is the page's actual title — the h1 is
     hidden precisely because the mark carries it — so shrinking it would be
     paying out of the design, which is the trade this whole block exists to
     avoid. Only the gaps around it move.
     ⚠️ Scoped to .film so it cannot reach the other pages that share
     .doc__head--sub. */
  .film .doc__head--sub .back      { margin-bottom: 8px; }
  .film .doc__head--sub .page-mark { margin-bottom: 8px; }
  .film .doc__head--sub .doc__h1   { margin-bottom: 0; }
}
/* the title is chrome around the media, not a headline competing with it */
.doc__head--sub .doc__h1 { font-size: clamp(22px, 2.6vw, 34px); margin: 0 0 8px; }
.doc__head--sub .doc__lede { font-size: var(--t-md); }
.doc__head--sub .back { margin-bottom: clamp(12px, 1.8vh, 20px); }
.doc__head--sub .doc__h1 { max-width: none; margin-inline: auto; }
.doc__head--sub .doc__lede { margin-inline: auto; text-align: center; }

/* ------------------------------------------------------------- the stage --- */

:root {
  /* ⚠️ ONE SOURCE FOR THE STAGE WIDTH. The credits were min(92vw, 1500px) while
     the monitor was min(74vw, 1180px), so the film's name started 130px to the
     LEFT of the picture it belongs to and the panel ran wider than the thing it
     captions. Two independent widths for one visual object is a drift waiting
     to happen; both read this now. */

  /* 🔴 THE MONITOR WAS CROPPING 27.9% OFF EVERY FRAME, AND `aspect-ratio` WAS
     NOT WINNING THE ARGUMENT IT LOOKED LIKE IT HAD WON.
     The stage declared `width: var(--stage-w)`, `max-height: 48svh` and
     `aspect-ratio: 16 / 9` together, and the note beside them said aspect-ratio
     "reconciles" the two bounds. It does not: an explicit width plus a binding
     max-height simply defeats it. Measured at 1440x900 the box rendered
     1065.6 x 432 — a ratio of 2.467, not 1.778 — so `object-fit: cover` on
     16:9 sources threw away 27.9% of the picture, top and bottom.
     ⚠️ TWO NOTES IN THIS FILE WERE FALSE BECAUSE OF IT: the mask viewBox is
     16:9 "so preserveAspectRatio='none' stretches nothing", when it was in fact
     stretching the blur 1.387x horizontally; and the feather objection recorded
     below — that the old mask ate 18% of the height and "a cinematographer is
     hired on the frame" — was being made on a page that was silently eating
     27.9% by a different mechanism.

     ⇒ THE HEIGHT BUDGET IS NOW EXPRESSED AS A WIDTH, so there is ONE bound and
     aspect-ratio has nothing to fight. Whichever of the two ceilings is smaller
     wins, the box is always exactly 16:9, and `--stage-w` stays the single
     source the credits also read — so the panel can never drift wider than the
     picture again.
     ⚠️ The width ceiling is deliberately UNCHANGED at min(74vw, 1180px): the
     orbiting thumbnails live in the margin beside the stage, and this page's one
     rule is that nothing performs in front of the work. Widening the monitor
     would buy scale by taking the orbit's room. Growing it DOWNWARD does not.
     ⚠️ 68svh, not 48: at 1440x900 the width ceiling binds, so the monitor keeps
     the exact width it already had and gains the 167px of height it was being
     denied — 39% more monitor and 28% more frame for no horizontal cost. Below
     roughly 880px of viewport height the height ceiling takes over and it
     shrinks, still 16:9. svh rather than vh because iOS resolves vh to the LARGE
     viewport while a max-height matches the SMALL one. */
  --stage-h: 68svh;
  --stage-w: min(74vw, 1180px, calc(var(--stage-h) * 16 / 9));
}
.film__stage {
  /* the mask shape, stated once so the four prefixed declarations cannot drift.
     viewBox matches the stage's 16/9 so preserveAspectRatio='none' stretches
     nothing and the blur stays circular. */
  --stage-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 90' preserveAspectRatio='none'><defs><filter id='f' x='-25%25' y='-25%25' width='150%25' height='150%25'><feGaussianBlur stdDeviation='4'/></filter></defs><rect x='12' y='12' width='136' height='66' rx='30' ry='30' fill='black' filter='url(%23f)'/></svg>");
  position: relative;
  /* AS BIG AS IT CAN BE AND STILL FIT — but stated as ONE bound, not two.
     --stage-w already carries the height budget (see :root), so aspect-ratio is
     free to hold exactly and there is no max-height to defeat it. This is the
     content; it gets the room.
     🔴 DO NOT REINTRODUCE `max-height` HERE. That is precisely what made the
     box 2.467:1 while every note in this file assumed 16:9. */
  width: var(--stage-w);
  margin: 0 auto;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: #000;

  /* ⚠️ THE MONITOR FEATHERS AGAIN — Juan's call, and it REVERSES a decision
     recorded twice below. Read that note before touching this: the old mask
     dissolved 7% of each side and 9% of the top and bottom, which is 14% of the
     width and 18% of the height of every frame, and the objection was that a
     cinematographer is hired on the frame.

     What makes this a different trade is the AMOUNT and the SHAPE. It is 3.5%
     per edge rather than 7-9%, so ~93% of the composition is untouched, and the
     falloff is RECTANGULAR — it follows the picture's own box instead of an oval
     cutting across it, which is what Juan asked for and is also what a tube
     actually does: a defined picture area that loses brightness at its boundary,
     not a porthole.

     ⚠️ TWO GRADIENTS INTERSECTED, NOT ONE. A single linear-gradient can only
     feather one axis. Two masks composited with `intersect` (`source-in` on the
     -webkit- prefix, which is the older syntax and is NOT a synonym anywhere
     else in this file) give a rectangle soft on all four sides that inherits
     whatever aspect-ratio the stage has — so if the 16/9 above ever changes, the
     feather follows it with no second edit. */
  /* ⚠️ A BLURRED ROUNDED RECT, WITH THE INSET SIZED TO THE BLUR. Two earlier
     attempts failed in opposite directions and both are worth keeping:

       · an SVG rect inset 4 units with stdDeviation 4 — the blur was CLIPPED BY
         ITS OWN viewBox. A Gaussian needs about 3σ to reach zero; it had 4
         units of room for 12 units of ramp, so the mask stepped from ~35%
         straight to 0 at the element's boundary. A step is an edge, and that
         was the hard line on all four sides.
       · two linear gradients — these cannot clip, because a stop at 0% IS the
         element's edge. But they only feather the EDGES: the solid region stays
         a rectangle with slightly soft corners, which is why Juan read it as
         still too square. Feathering a rectangle does not round it.

     So: a rounded rect again, and the inset is now DERIVED from the blur rather
     than picked. σ = 4, inset = 12 = 3σ, so the ramp reaches zero exactly at the
     canvas edge and there is nothing left to truncate. rx 30 against a 136-wide
     rect is ~22% of its width and ~45% of its height — genuinely round, which is
     the ask, and the corner is round in the MASK rather than clipped by
     border-radius, so it is soft rather than a cut arc.

     ⚠️ Not a radial. A radial rounds the corners by darkening the middle of
     every edge too, which turned the picture into a vignette on an earlier pass
     and was rejected on sight. A rounded rect holds the centre fully opaque and
     only gives up the corners.

     Cost, stated plainly: 7.5% of the width and 13.3% of the height per side.
     That is more than the 7-9% a much earlier pass rejected — the difference is
     that Juan has now twice asked for the edge to stop reading as an edge, and
     this is what that costs. */
  -webkit-mask-image: var(--stage-mask);
          mask-image: var(--stage-mask);
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
          mask-repeat: no-repeat

  /* ⚠️ NOT FEATHERED ANY MORE, AND THE REASON IS THE CONTENT. The old mask
     dissolved 7% off each side and 9% off the top and bottom — 14% of the width
     and 18% of the height of every frame, gone. On a page whose whole job is
     showing that Juan can compose a shot, throwing away the edges of the
     composition is the one cost this treatment cannot justify. A cinematographer
     is hired on the frame.

     The instinct is to check the reference. It cannot answer: leonardomoreira.com.br
     has no photographs on it at all — 199 fillRect calls, everything a rectangle,
     all text mono. It is a terminal, not a hybrid.

     So the answer comes from the object being imitated. A CRT does not fade its
     picture to nothing at the edges; it has a DEFINED picture area with rounded
     corners and falls off in brightness toward them. That is a radius plus the
     vignette this stage already carries — both of which say "displayed by a
     tube" without deleting the image. The thumbnails keep their feather: they
     are ambience, and nobody is judging a composition at 70px.

     The 1.5% falloff is the phosphor edge, not a dissolve. */
  /* ⚠️ AND NOW NO FALLOFF AT ALL — Juan's call, and the right one. Even the
     1.5% edge was still softening the frame, and this page has exactly one job.
     The picture is crisp to its own boundary; what says "tube" is the CORNER
     RADIUS and a thin lit picture edge, not a dissolve.

     He named the reference's startup screen as the thing he liked about edges,
     with one exclusion: NOT its distortion. That is the same call the README
     already made against barrel warp — real CRT curvature baselines near 0.04
     and bending the picture costs legibility for nothing. So this takes the
     defined-picture-area half of that look and leaves the geometry alone.

     The inset rim is the phosphor edge: the picture area is slightly hotter at
     its boundary because the mask ends there. One pixel of amber at 30%, a soft
     inner bloom, and an outer halation that ties the frame to the machine
     around it. */
  /* ⚠️ THE RADIUS MATCHES THE MASK'S CORNER, and that is why it is this large.
     It was clamp(6px, 0.9vw, 14px) — about 1% of the width — while the mask
     above rounds at ~16%. Two different corner radii on one picture is what
     Juan kept seeing as "hard lines": the mask dissolved a big round corner and
     the box-shadow below drew a nearly-square one straight across it, so the
     eye read the mismatch as an edge. They describe the same shape now. */
  border-radius: 15%;
  /* ⚠️ "TOO CLEAN" HAD A MEASURABLE ANSWER, AND IT WAS NOT MORE SCANLINES.
     Read off the reference's own DOM rather than guessed at: it stacks THREE
     layers over its picture, and this frame had one of them.

       .crt        radial vignette, and warm-dark (#0C0608) rather than neutral
       .crt-bulge  a warm CENTRE LIFT — the tube is brighter in the middle of
                   its face. It is the bulge read WITHOUT the geometry, which
                   is exactly the half Juan wants and the half the README keeps
                   (barrel warp stays rejected)
       .crt-glass  a SPECULAR thrown from 22% / -8% — an origin OFF the glass
                   entirely, so it reads as a reflection of the room rather
                   than as a gradient

     The page already carries all three. The frame carried only the vignette, so
     the picture sat inside it as a perfectly even rectangle — which is what
     "clean" was. The emulation literature names the same two things this was
     missing: HALATION, light bouncing under the faceplate, and DIFFUSION, the
     glow of light passing out through imperfect glass. Both ADD light, which is
     why the layer below screens rather than overlays.

     And the glass has THICKNESS. There is a dark lip just inside the boundary
     before the lit edge — the tunnel of the faceplate. That inset shadow is
     doing more work here than anything else: a bright line on a crisp edge
     reads as a border, the same line over a dark lip reads as an edge lit from
     behind. */
  /* ⚠️ THE 1px AMBER RIM IS GONE. It was `inset 0 0 0 1px rgba(240,168,60,.34)`
     — a hairline, perfectly crisp by definition, drawn at the element's edge.
     Against a feathered picture that is the single hardest line on the page,
     and no amount of rounding the corners hides it. The phosphor edge it was
     imitating is now carried by the soft inner bloom alone, which is the half
     that survives a dissolve. Everything left here is a BLUR, so nothing in
     this stack can draw a line. */
  box-shadow: inset 0 0 12px rgba(0, 0, 0, .58),
              inset 0 0 34px rgba(240, 168, 60, .10),
              0 0 60px rgba(240, 168, 60, .10),
              0 0 150px rgba(240, 168, 60, .05);
}
/* the glass over the picture: the room reflected in it, and the face's own
   centre lift. Screen, because both are light being ADDED. */
.film__stage::before {
  content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
  border-radius: inherit;
  mix-blend-mode: screen;
  /* ⚠️ SCANLINES ADDED HERE Aug 31 2026, on Juan's note that the monitor looked
     too clean. The THUMBNAILS already carry this page's full CRT treatment — the
     stage had the glare and the vignette and no lines at all, so the one surface
     holding the actual work was the only one that did not look displayed.
     They go in THIS stack rather than a new layer because both pseudo-elements
     are already spoken for (::before is this glare, ::after is the vignette) and
     because screen-blended black is exactly what belongs with a glare: it does
     nothing to the dark parts of a frame and bites only on the lit ones. Kept
     light — this is the content, and it should read as displayed, not damaged. */
  background:
    repeating-linear-gradient(to bottom, rgba(0, 0, 0, .15) 0 1px, transparent 1px 3px),
    radial-gradient(80% 48% at 20% -10%, rgba(214, 234, 255, .075), transparent 60%),
    radial-gradient(74% 60% at 50% 44%, rgba(255, 220, 160, .055), transparent 66%);
}
.slide {
  position: absolute; inset: 0; margin: 0;
  opacity: 0; transition: opacity 1.6s ease;
}
.slide.is-on { opacity: 1; }
.slide picture, .slide img { display: block; width: 100%; height: 100%; }
.slide img {
  object-fit: cover;
  filter: saturate(.86) contrast(1.03) brightness(.88);
  /* the drift is what makes a still frame feel like it is being PROJECTED
     rather than displayed — it is the one motion a cinema has */
  animation: film-drift 20s ease-in-out infinite alternate;
}
@keyframes film-drift {
  from { transform: scale(1.04) translate(0, 0); }
  to   { transform: scale(1.11) translate(-1.4%, -1%); }
}

/* the room the frame sits in */
.film__stage::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2;
  /* the tube's own falloff. It does more work now that the mask is not
     dissolving the edges — this is what keeps the frame from reading as a
     rectangle pasted on the page. */
  /* warm-dark, not neutral — the reference's own vignette runs #0C0608 to
     #020001, and a neutral grey falloff is one of the things that reads as
     "a CSS effect" rather than as a tube. */
  background: radial-gradient(122% 96% at 50% 45%, transparent 46%,
              rgba(12, 6, 8, .42) 78%, rgba(2, 0, 1, .80) 100%);
}

/* ----------------------------------------------------------- the credits --- */

/* THE CREDITS ARE A PANEL, NOT A CAPTION. A caption fits a title; this has to
   carry the role, the format and the year — the things somebody hiring a
   cinematographer actually reads. It lives OUTSIDE the slides on purpose:
   inside, two of them rendered on top of each other through every dissolve. */
.film__credits {
  /* ⚠️ ONE SOURCE FOR THE LABEL METRICS. The film's name sat lower than the
     copy beside it because the two columns have different rhythms: the meta
     column starts with an 8.5px label and puts its value underneath, while the
     title is a 32px line starting at the top of the grid. `align-items: start`
     aligns the BOXES, which is exactly why it looked wrong — the ink does not
     start where the box does. The title is pushed down by one label row so the
     two read off the same line, and both sides take the label size and gap from
     these two properties, so nudging one can never desync them. */
  --cred-label: 8.5px;
  --cred-gap: 3px;
  width: var(--stage-w); margin: 0 auto;
  display: grid; gap: 14px clamp(20px, 3vw, 44px);
  grid-template-columns: 1fr;
  text-align: center;
}
@media (max-width: 779px) { .film__t { padding-top: 0; } }
/* ⚠️ CENTRED AT EVERY WIDTH NOW — the two-column, left-aligned desktop layout is
   gone. It existed to sit the film's name beside its credits in one line, which
   was right under a rectangular monitor: the block's left edge lined up with the
   picture's left edge and the pair read as one unit. The monitor is a soft round
   window now with no left edge to align to, so a left-aligned block underneath
   hangs off nothing and pulls the eye away from a centred image. Juan's call,
   and the geometry agrees: centre the copy under a centred round thing.

   `--cred-label`/`--cred-gap` and the .film__t padding that used them to sit the
   title on the credits' baseline are only meaningful in the side-by-side layout,
   so the padding is zeroed here rather than left doing invisible arithmetic. */
@media (min-width: 780px) {
  .film__credits { grid-template-columns: 1fr; text-align: center; justify-items: center; }
  .film__t { padding-top: 0; }
  .film__meta { justify-content: center; }
}
.film__t {
  margin: 0 !important;
  padding-top: calc(var(--cred-label) * 1.5 + var(--cred-gap));
  font-size: clamp(21px, 2.8vw, 32px); font-weight: 800;
  letter-spacing: -.018em; line-height: 1.05; color: var(--fg);
}
/* the logline. Reading face rather than the mono the meta rows use — this is a
   sentence about the film, not a spec value, and the two should not look like
   the same kind of fact. Held to 58ch so it breaks into two or three short
   lines under the monitor instead of one wide band the eye has to track. */
.film__syn {
  margin: 0; max-width: 58ch; justify-self: center;
  font-size: clamp(14px, 1.35vw, 16px); line-height: 1.5;
  color: var(--fg-dim);
}
.film__syn[hidden] { display: none; }   /* a grid child ignores `hidden` without this */

.film__meta {
  display: flex; flex-wrap: wrap; gap: 6px clamp(14px, 2vw, 26px);
  justify-content: center;
}
@media (min-width: 780px) { .film__meta { justify-content: flex-start; } }
/* ═══ ALSO ON SET ═════════════════════════════════════════════════════════════
   🔴 `.film__crew` HAD NO CSS AT ALL — zero rules in any stylesheet, found Sep 4
   2026 by the overlap sweep. It was rendering as a browser-default `<ul>`: disc
   bullets, default indent, and — because `<b>` and `<span>` are both inline with
   nothing between them — the two ran together into
   **"Function At Strawberry JunctionComposer"**. On the one page whose job is to
   show finished work, four credits were reading as broken markup.
   ⚠️ THE SWEEP FOUND IT WITHOUT MEANING TO, and the "overlap" it reported was a
   FALSE POSITIVE: `getBoundingClientRect()` on a WRAPPED INLINE returns the union
   of its line boxes, so the two-line `<b>` produced a box that the role's `<span>`
   sat inside. Glyph-level rects (Range.getClientRects per text node) reported ZERO
   collisions. ⇒ The overlap was not real; the unstyled list was. Worth keeping
   because a false positive that surfaces a true defect is still worth the run.
   ⚠️ TYPE TAKEN FROM `.film__meta` DIRECTLY ABOVE, not invented: a mono uppercase
   micro-label at `--fg-dim` against an `--fg` value is this page's own pairing, and
   `.sw` on /packaging is the same component one page over. The role sits BELOW its
   title because that is the DOM order and it reads correctly aloud — no `order`
   hack, so nothing decouples the visual sequence from the announced one. */
.film__crew {
  list-style: none; margin: 0 0 16px; padding: 0;
  display: grid; gap: 12px clamp(16px, 2.4vw, 34px);
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  max-width: var(--measure);
}
.film__crew li { display: grid; gap: 3px; }
.film__crew b { font-size: 14.5px; line-height: 1.28; font-weight: 600; color: var(--fg); }
.film__crew span {
  /* 🔴 A LITERAL, NOT `var(--cred-label)` — AND THE FIRST VERSION USED THE VAR AND
     SHIPPED THE BUG. `--cred-label` is declared on `.film__credits`, which is a
     SIBLING of this list, not an ancestor. So the property was undefined here, and
     an undefined custom property does not fall back — it kills the WHOLE
     declaration. `font-size` was dropped entirely and the roles inherited 16px,
     rendering the label LARGER than the title it belongs to. Caught by rendering
     it, not by reading it: every computed value except this one was correct.
     That is CLAUDE.md § CSS THAT FAILS SILENTLY, item 2, reproduced by hand
     inside the fix for another silent failure. 9px matches `.dtile__k`'s 10px and
     `.film__meta dt`'s 8.5px without borrowing either one's scope. */
  font-family: var(--mono); font-size: 9px; letter-spacing: .2em;
  line-height: 1.5; text-transform: uppercase; color: var(--fg-dim); opacity: .85;
}

.film__meta div { display: flex; flex-direction: column; gap: var(--cred-gap); }
.film__meta dt {
  font-family: var(--mono); font-size: var(--cred-label); letter-spacing: .2em;
  line-height: 1.5;
  text-transform: uppercase; color: var(--fg-dim); opacity: .7;
}
.film__meta dd {
  margin: 0;
  font-family: var(--mono); font-size: 10px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--amber);
}
.film__meta dd.plain { color: var(--fg); opacity: .85; }

/* ------------------------------------------------- CREDITS REVEAL ON SCROLL ---
   Juan's ask: the information about whatever is on the monitor should UNVEIL as
   you scroll rather than simply being present, so the page hands you the frame
   first and the facts second.

   ⚠️ EVERY STATE IS GATED ON .is-armed, WHICH ONLY film.js ADDS. Without JS the
   credits are exactly as they always were — visible, in the accessibility tree,
   read by a crawler. The enhancement switches a reveal ON; it never starts by
   hiding real content and hoping a script arrives to bring it back. Same
   direction as the /sipful spine and the three-tier product on that page: a
   failed script must cost polish, never information.

   The rows stagger from the title outward, which is what makes it read as
   information arriving rather than a block fading. Short delays — this sits
   under a frame the reader is already looking at, so anything slower turns into
   waiting. */
.film__credits.is-armed > * {
  opacity: 0;
  transform: translateY(10px);
}
.film__credits.is-armed.is-shown > * {
  opacity: 1;
  transform: none;
  transition: opacity .5s ease, transform .5s cubic-bezier(.2, .8, .25, 1);
}
.film__credits.is-armed.is-shown > .film__t        { transition-delay: 0s; }
.film__credits.is-armed.is-shown > .film__meta     { transition-delay: .09s; }
/* and the fields inside the meta list arrive one after another, so the credits
   assemble rather than appear */
.film__credits.is-armed .film__meta > div { opacity: 0; transform: translateY(8px); }
.film__credits.is-armed.is-shown .film__meta > div {
  opacity: 1; transform: none;
  transition: opacity .45s ease, transform .45s cubic-bezier(.2, .8, .25, 1);
}
.film__credits.is-armed.is-shown .film__meta > div:nth-child(1) { transition-delay: .14s; }
.film__credits.is-armed.is-shown .film__meta > div:nth-child(2) { transition-delay: .20s; }
.film__credits.is-armed.is-shown .film__meta > div:nth-child(3) { transition-delay: .26s; }
.film__credits.is-armed.is-shown .film__meta > div:nth-child(4) { transition-delay: .32s; }
.film__credits.is-armed.is-shown .film__meta > div:nth-child(5) { transition-delay: .38s; }

/* ⚠️ THE CONTENT IS INFORMATION, SO IT STAYS — ONLY THE TRAVEL GOES. Hiding it
   under this query would take the credits away from exactly the readers least
   able to afford losing them. */
@media (prefers-reduced-motion: reduce) {
  .film__credits.is-armed > *,
  .film__credits.is-armed .film__meta > div {
    opacity: 1; transform: none; transition: none;
  }
}

/* ---------------------------------------------------------- the filmstrip --- */

/* ------------------------------------------------------------- the orbit ---

   ⚠️ THE FIRST VERSION WAS NOT FLOATING, IT WAS A GRID. Two tidy arcs under the
   monitor, every thumbnail the same size, drifting six pixels — which reads as
   two rows that wobble, not as objects in space. Juan said so immediately.

   What actually makes a field read as floating is not the motion, it is the
   DEPTH. Things at different distances are different sizes, different
   brightnesses, and move different amounts — so this gives every thumbnail a
   depth from 0 to 1 and derives scale, opacity, blur and drift distance from it
   together. Near ones are bigger, brighter and travel further; far ones are
   small, dim, slightly soft and barely move. That is parallax, and it is the
   whole trick.

   They also surround the monitor now rather than sitting under it, which is what
   "floating around it" meant.

   Still deliberately quiet — this is the shape the README rejected four times,
   and eleven thumbnails whirling around a film frame would be that mistake a
   fifth time. Long cycles, low opacity, and nothing crosses the frame. */

.film__strip {
  position: absolute; inset: 0;
  z-index: 1;
  pointer-events: none;              /* the field is not a wall; only the thumbs catch clicks */

  /* 🔴 THE TWO THUMBNAIL SHAPES, STATED ONCE EACH, BECAUSE THE STILL AND THE
     CLIP EACH NEED THEIR OWN COPY AND THE COPIES DRIFTED.

     `.thumb`'s mask clips the <img> and DOES NOT reach the <video> — proven on
     the real page, not reasoned about: delete the video's own mask and the clip
     paints as a hard rectangle at full size while the still beside it stays a
     circle. So the video must carry the shape too, which means the shape exists
     twice and can disagree.

     It did. `.thumb` had a rest mask AND a recall mask; the video had only the
     REST one. So a remembered still dissolved to a shape of light while the
     footage inside the very same box kept the tight, hard-edged rest circle —
     which is the "playing clip is hard-edged among feathered circles" report.
     Nothing threw, every page rendered, and the two rules sat 240 lines apart.

     ⇒ Both shapes are tokens now, and every element that needs one references
     it. The old comment said "change one, change both"; a comment cannot be
     enforced, and this is the same fix --stage-mask already applies above. */
  --thumb-mask: radial-gradient(circle closest-side at 50% 50%,
            #000 48%, rgba(0,0,0,.74) 66%, rgba(0,0,0,.28) 82%, transparent 96%);
  /* 🔴 `closest-side`, NOT `140% 140%` — AND THIS WAS THE HARD RECTANGLE.
     Two explicit percentages do not scale a circle up, they declare an ELLIPSE
     whose rx is 140% of the box WIDTH and ry 140% of its HEIGHT. That is 2.8x
     the radius the stops were written for, so every stop landed far outside the
     box and the mask never came near transparent inside it: ~0.68 alpha at the
     middle of each flat edge, ~0.51 in the corners. The "shape of light" was a
     full-box wash with soft corners — i.e. a rectangle — and it has been one
     for every recalled memory, clip or still, since the day it was written.

     Measured, not read: a forced recall on a film with NO clip photographs as a
     hard-edged rectangle with no feather anywhere. That is the whole bug. It
     looked like a CLIP bug because the <video> was the one element that
     overrode it — it carries its own `closest-side` circle — so a playing clip
     showed a round picture inside .thumb's rectangular wash of scanlines, and
     a clipless memory showed the rectangle directly.

     ⚠️ SAME MISTAKE THE REST MASK ALREADY RECORDS 200 LINES UP, in the other
     direction: there the default farthest-corner made the stops resolve against
     the half-DIAGONAL and cut the flats; here an explicit size made them
     resolve against 1.4x the full width. In both cases the stops were tuned by
     eye afterwards, which hides the geometry error instead of fixing it —
     Juan softened these numbers twice while the shape stayed a rectangle.

     The stops are the author's, unchanged. Only the sizing is corrected, so
     they now mean what they were written to mean: opaque for the middle few per
     cent, falling the whole way out, transparent just inside the edge. */
  --memory-mask: radial-gradient(circle closest-side at 50% 50%,
            #000 2%, rgba(0,0,0,.62) 42%, rgba(0,0,0,.22) 74%, transparent 98%);
}

/* ⚠️ THE PAGE LABEL SITS ABOVE THE ORBIT. The ring passes over the top-centre
   of the page, where the vertical page mark lives — measured at 38 of 2200
   samples. There is nowhere else for the path to go: the band above the frame is
   189px tall and the label stands in the middle of it, so routing around it
   would mean not orbiting. A dim, blurred thumbnail drifting BEHIND the mark
   costs nothing and reads as depth; the mark going illegible would not. Recall
   never fires up there either — see atSide() in film.js. */
.doc__head--sub { position: relative; z-index: 6; }

.film__orbit button {
  /* the path itself is chosen by class — see the seven @keyframes below */
  position: absolute;
  pointer-events: auto;
  padding: 0; border: none; background: none; cursor: pointer;
  line-height: 0;
  /* --x/--y are PIXELS, set by the measured placement in film.js, and they are
     the top-left of the TRAVEL ENVELOPE rather than of the thumbnail — the
     wander is centred on where it was placed */
  left: var(--x, 0); top: var(--y, 0);
  /* ⚠️ DIM, NOT SEE-THROUGH — and the difference is the whole of Juan's note.
     Depth was carried by `opacity`, which does not just darken a thumbnail, it
     makes it TRANSPARENT: two of them crossing showed through each other, so a
     pass read as a glitch rather than as one thing going behind another. Dimness
     and translucency look similar alone and behave nothing alike when they
     overlap.

     Brightness carries the depth instead. A dim opaque thumbnail still reads as
     far away — that is how distance looks — while an occlusion now happens,
     which is the single strongest depth cue there is. z-index follows depth so
     the nearer one is the one in front, rather than whichever the DOM happened
     to list last. */
  opacity: 1;
  filter: blur(var(--soft, 0px)) brightness(var(--dimb, .55));
  z-index: var(--z, 1);
  /* ⚠️ THE SIZE LIVES ON THE .thumb CHILD, NOT HERE, AND THAT IS THE FIX FOR
     THE HOVER POPPING DOWNWARDS. The drift's second lane is `transform`, which
     composes AFTER `scale` in the individual-property order — so scaling this
     element on hover multiplied the drift offset too. Mid-travel at 40px down,
     a jump to 1.25 dragged the thumbnail another 10px DOWN as it grew, which is
     exactly the lopsided pop Juan reported. Scaling a child instead means the
     two never meet: this element only ever drifts, the child only ever grows,
     and the growth is symmetric about its own centre. */
  transition: opacity .4s ease, filter .4s ease;
  /* the wander, split across two periods that do not share a small common
     multiple — see film.js. One period on both axes is a straight line sliding
     back and forth, which is exactly what "it stays in the same place" looked
     like. Two makes a slow Lissajous that does not visibly repeat. */
  rotate: var(--tilt, 0deg);
  /* ⚠️ TWO LANES, EQUAL PERIODS, A QUARTER TURN APART — which is an ellipse.
     They used to run at different periods on purpose, to make a Lissajous that
     never repeats, because each thumbnail was drifting in place. They are
     orbiting a shared centre now, so the periods must MATCH: a cosine on
     `translate` and a sine on `transform`, one delay for both, and the phase
     offset lives in the keyframes rather than in the delay. `linear`, because
     an eased orbit speeds up and slows down twice a lap and reads as a wobble. */
  animation-duration: var(--dur, 168s);
  animation-timing-function: linear;
  animation-delay: var(--dly, 0s);
  animation-iteration-count: infinite;
  animation-direction: var(--dir, normal);
}

.film__orbit button.orb-p0 { animation-name: orb-p0; }
.film__orbit button.orb-p1 { animation-name: orb-p1; }
.film__orbit button.orb-p2 { animation-name: orb-p2; }
.film__orbit button.orb-p3 { animation-name: orb-p3; }
.film__orbit button.orb-p4 { animation-name: orb-p4; }
.film__orbit button.orb-p5 { animation-name: orb-p5; }
.film__orbit button.orb-p6 { animation-name: orb-p6; }

/* ---------------------------------------------------------------------------
   ⚠️ AND THEY HAVE TO LOOK DISPLAYED, NOT PASTED. Juan's read was that the
   thumbnails "felt too new" and did not belong in this world, and he was right
   for a reason the reference site cannot show him: leonardomoreira.com.br has
   no photographs on it at all. It is a terminal wholesale — 199 fillRect calls,
   everything a rectangle, all text mono. There is no thumbnail treatment there
   to copy.

   So the rule to apply is this site's OWN, already written for the portrait on
   /work: a photograph must look like it is being displayed BY the phosphor
   rather than sitting on top of it. The monitor gets that treatment and the
   thumbnails only got half of it — a mask and a brightness cut. They now get
   the rest of what this page already claims a CRT does to a picture:

   · scanlines, at a 3px pitch so they read at thumbnail size
   · a vignette, because a tube is dimmer at its corners
   · a phosphor tint — the amber the whole machine is lit in
   · doubled chromatic aberration, reusing the page's own #crt-chroma. It is
     tuned at 0.55px for text at full size; on a 70px thumbnail one pass is
     invisible, so the thumbnails take a second one.

   The mask moved from the img to the BUTTON so the overlay feathers with the
   picture instead of ending in a hard rectangle over a soft one. */

/* ⚠️ THE CLIP OCCUPIES THE STILL'S BOX RATHER THAN FOLLOWING IT. Appended as a
   sibling it laid itself out below the image, so a playing memory was a picture
   with a video under it — two things, when the whole idea is one thing
   remembered. Absolute, inset 0, object-fit: cover, and the still cross-fades
   out beneath it. Both sit inside .thumb, so the feather mask and the scanlines
   govern the footage exactly as they govern the picture. */
/* ⚠️ THE VIDEO CARRIES ITS OWN COPY OF THE MASK, AND IT HAS TO.
   The mask on .thumb clips the <img> perfectly and DOES NOT CLIP THE <video> —
   photographed, not theorised: a playing memory rendered as a hard rectangle
   sitting inside a ring of feathered circles. A <video> is a replaced element
   promoted to its own compositing layer (and .thumb additionally carries an SVG
   filter during recall, which forces the issue), and an ancestor mask does not
   reliably clip across that boundary.

   So the same circle is declared here. ⚠️ IT MUST STAY IDENTICAL TO THE ONE ON
   .thumb — two masks describing one shape is a duplication that can drift, and
   the failure mode is silent: the still would feather and the clip would not,
   which is exactly the bug this fixes. Change one, change both. */
.film__orbit .thumb video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity .5s ease;
  -webkit-mask-image: var(--thumb-mask);
          mask-image: var(--thumb-mask);
}
.film__orbit button.is-playing .thumb video { opacity: 1; }
.film__orbit button.is-playing .thumb img   { opacity: 0; }
.film__orbit .thumb img { transition: opacity .5s ease; }

/* ⚠️ THE MEMORIES ARE CIRCLES NOW, AND THE MASK IS WHAT MAKES THEM ONE — not
   border-radius. Juan asked for the thumbnails to stop being rectangles and to
   feather off into the background "like memories", and those are one change,
   not two: `border-radius: 50%` would give a circle with a HARD edge, which is
   the pasted-on look this page has been fighting since the first pass. A radial
   mask reaching transparent well inside the box gives a circle whose edge is
   dissolve rather than cut, so the picture ends by fading into the tube.

   The stops are doing specific work. Solid to 40% keeps the subject intact;
   most of the falloff is spent between 40% and 72%, which is where a rectangle's
   corners would otherwise show; fully transparent by 84% means the box's actual
   corners are never painted at all, so nothing can betray the underlying square.

   ⚠️ AND THE BOX HAS TO BE SQUARE FOR THIS TO BE A CIRCLE. A radial mask over a
   16:9 still is an ELLIPSE. The squareness comes from aspect-ratio + object-fit
   on the img below, and the orbit solver in film.js was told about it through
   THUMB_RATIO — it computes clearance from the thumbnail's height, so a shape
   change that skipped it would have the maths believing 4:3 while the page drew
   1:1. */
.film__orbit .thumb {
  display: block; position: relative; line-height: 0;
  scale: var(--scale, 1);
  transition: scale .4s cubic-bezier(.2,.8,.25,1), filter .4s ease;
  /* ⚠️ `closest-side` IS LOAD-BEARING — WITHOUT IT THE TOP AND BOTTOM ARE CUT.
     A radial-gradient with no sizing keyword defaults to FARTHEST-CORNER, so on
     a square box the 100% radius is the half-DIAGONAL (0.707w), not the half-
     width. Every stop then resolves against that: the old `transparent 84%` sat
     at 0.594w from the centre, while the flat top, bottom, left and right edges
     are at 0.50w. The fade therefore finished OUTSIDE those edges and the mask
     was still ~70% opaque when it reached them — a hard cut on all four flats,
     while the corners (0.707w) faded perfectly. That is exactly how it looked:
     feathered on the diagonals, sliced across the top and bottom.

     `closest-side` makes the 100% radius the half-WIDTH, so transparent at 96%
     lands at 0.48w — inside every edge, and equally far inside on all of them.
     The corners simply go transparent early, which is what a circle should do
     to a square anyway.

     ⚠️ AND THE STOPS HAD TO BE RE-TUNED, NOT JUST THE KEYWORD. Switching the
     sizing shrinks what every percentage means: the old `#000 40%` was 40% of
     the half-diagonal (0.283w of solid core), and the same 40% under
     closest-side is 40% of the half-width (0.20w). Carrying the numbers over
     unchanged made the memories far too dissolved to read. The solid core is
     48% here — 0.24w, near the original — with the whole falloff spent in the
     remaining half. Change the keyword and the stops together or not at all. */
  /* ⚠️ PERFECT CIRCLES — Juan's choice, made by comparing both side by side
     rather than described. The ellipse version kept each still's native ratio;
     he picked the circle, so the box is squared again below and this is a
     `circle` once more.

     `closest-side` stays and is still the load-bearing half. With the default
     farthest-corner the 100% radius is the half-DIAGONAL, so every stop resolves
     against 0.707w while the flat edges sit at 0.50w — the fade finishes outside
     them and the mask is still opaque when it arrives. That was the hard line
     across the top and bottom. closest-side makes 100% the half-width, so
     transparent at 96% lands at 0.48w: inside every edge, equally far in on all
     of them.

     ⚠️ AND THE MASK IS ON .thumb, WHICH IS WHY THE VIDEO GETS IT TOO. The clip
     is absolutely positioned inside this box, so the parent's mask clips the
     footage exactly as it clips the still — no separate rule, and no way for the
     two to drift apart. That is Juan's ask that a playing memory feather like a
     resting one, and it is satisfied by where the mask lives rather than by
     adding a second one. */
  -webkit-mask-image: var(--thumb-mask);
          mask-image: var(--thumb-mask);
}

/* ⚠️ A TUBE THAT IS NOT BEING WATCHED IS NOT PERFECTLY STEADY. Juan asked to try
   flicker on the thumbnails; this is the restrained version, because eleven
   thumbnails all pulsing is a fairground and the page has already rejected that
   once (see the note about whirling memories above).

   Each memory flickers on its OWN period and its own phase — the periods are
   deliberately not multiples of each other, so the set never syncs up into a
   single blink, which is the thing that would read as a bug rather than as a
   signal. The amplitude is tiny (brightness 0.86-1.06) because these already sit
   under a brightness cut for depth; anything stronger fought the depth ordering
   and made the far ones look near.

   `filter` is animated, and .thumb already transitions filter for the hover
   scale — the animation wins while it runs, which is correct: a memory being
   pointed at should stop misbehaving. */
@keyframes thumb-flicker {
  0%, 100%   { filter: brightness(1); }
  6%         { filter: brightness(.86); }
  9%         { filter: brightness(1.06); }
  12%        { filter: brightness(.92); }
  16%, 62%   { filter: brightness(1); }
  66%        { filter: brightness(.9); }
  69%        { filter: brightness(1.03); }
  73%        { filter: brightness(1); }
}
.film__orbit button:nth-child(3n+1) .thumb { animation: thumb-flicker 7.3s  infinite; }
.film__orbit button:nth-child(3n+2) .thumb { animation: thumb-flicker 9.1s  infinite; animation-delay: -2.6s; }
.film__orbit button:nth-child(3n)   .thumb { animation: thumb-flicker 11.7s infinite; animation-delay: -5.1s; }

/* the flicker is atmosphere, not information — it is the first thing to go */
@media (prefers-reduced-motion: reduce) {
  .film__orbit .thumb { animation: none; }
}
.film__orbit img {
  /* ⚠️ SMALLER AT REST, AND IT BUYS BOTH OF THE THINGS JUAN ASKED FOR AT ONCE.
     He wanted rounder orbits and bigger memories, and at 92px those two fight
     over the same margin — the page is 187px from frame to edge and 101px below
     the credits, and a 92px thumbnail leaves no room for either. Measured: at
     that size NO closed path could clear the block at any exponent, so every
     orbit fell back to the squarest shape, and a memory could only reach 1.9x.
     At ~63px an exponent of 5 clears — a visibly oval path rather than a traced
     rectangle — and the memory cap goes to 2.4x. The resting thumbnail giving up
     a third of its width is what pays for both, and it also makes the memory
     read as more of a jump when it opens. */
  /* ⚠️ THE WIDTH COMES FROM film.js, WHICH SOLVES IT AGAINST THE PAGE. A fixed
     clamp had to be small enough for the tightest window it might ever meet, so
     it threw away every pixel of room a bigger display offers. The fallback here
     is only for the no-JS path, where nothing orbits and nothing has to fit. */
  /* ⚠️ SQUARE BOX, BECAUSE A RADIAL MASK OVER A 16:9 STILL IS AN ELLIPSE.
     The circle Juan chose only exists if the box is square, so the 200x113
     source is centre-cropped by object-fit. That does discard the sides of each
     frame — the honest cost of the choice, and it is acceptable HERE and not on
     the monitor: nobody judges a composition at 70px, and these are ambience.
     film.js's THUMB_RATIO must agree with this (1) or the orbit clearance maths
     solves for the wrong height and the memories can reach the frame. */
  display: block; width: var(--thumb-w, 68px); height: auto;

  /* ⚠️ ONE aspect-ratio, AND IT IS 1 — SQUARE, SO THE CIRCLE MASK IS A CIRCLE.
     This block used to declare aspect-ratio TWICE: a 1 near the top and a 4/3
     eleven lines below it. The later one won, silently, so every "perfect
     circle" shipped as an ellipse on a 4:3 box and nothing anywhere said so.
     Caught by asking the page — computed aspectRatio came back "4 / 3" while
     the source read 1 — not by reading the file, where both looked present.
     ⇒ Declare a property ONCE per rule. A duplicate is not an override, it is a
     rule that quietly stops being true.

     The 4/3 had a real argument behind it and it is worth keeping on record:
     16:9 is a MODERN signal and most of why the stills read as new, while 4:3
     is the shape the machine around them broadcasts in. That reasoning is now
     moot — Juan compared circles against native-ratio ovals on device and chose
     circles, and a circle needs a square. The full frame is not lost either
     way: the monitor shows it at 1500px the moment one is picked.

     ⚠️ film.js's THUMB_RATIO must equal this number (1), or the orbit clearance
     maths solves for the wrong height and the memories can reach the frame. */
  aspect-ratio: 1;
  object-fit: cover;
  filter: saturate(.5) brightness(.66) sepia(.22) hue-rotate(-12deg)
          contrast(1.06) url(#crt-chroma);
}
/* scanlines + vignette, painted over the picture and travelling with it */
.film__orbit .thumb::after {
  content: ""; position: absolute; inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(to bottom,
      rgba(0, 0, 0, .34) 0 1px, transparent 1px 3px),
    radial-gradient(115% 115% at 50% 46%,
      transparent 34%, rgba(3, 5, 4, .5) 78%, rgba(3, 5, 4, .82) 100%);
  mix-blend-mode: multiply;
}
.film__orbit button:hover,
.film__orbit button:focus-visible { filter: blur(0) brightness(1.04); z-index: 40; }
/* ⚠️ HOVER OPENS TO EXACTLY THE SIZE A MEMORY OPENS TO, and that is Juan's
   call: a clip playing out here was one size when it started itself and another
   when he pointed at it, which reads as two different features. It was a flat
   1.25x here against the recall's `--open`. `--open` is not a constant — it is
   computed per thumbnail as TARGET / that thumbnail's own scale, precisely so
   every memory lands on ONE size regardless of its depth — so reusing it is
   what makes the two paths agree, where copying 2.05 would only have made them
   agree on average. */
.film__orbit button:hover .thumb,
.film__orbit button:focus-visible .thumb { scale: calc(var(--scale, 1) * var(--open, 2.05)); }
.film__orbit button:hover img,
.film__orbit button:focus-visible img {
  filter: saturate(.9) brightness(1) sepia(.06) contrast(1.02) url(#crt-chroma);
}
.film__orbit button:hover .thumb::after,
.film__orbit button:focus-visible .thumb::after { opacity: .55; }
/* ⚠️ THE FILM ON THE MONITOR IS NOT IN THE FIELD. Juan's note, and it is
   obviously right: the floating set is what you have NOT chosen, so showing the
   current film out there as well is the same picture twice, six inches apart.
   It fades rather than vanishing, and it keeps its place in the layout — the
   scatter is solved once and must not reshuffle every time somebody picks a
   different film. */
.film__orbit button.is-on {
  opacity: 0; pointer-events: none;
  transition: opacity .8s ease;
}

/* ---------------------------------------------------------------------------
   REMEMBERING. Juan's idea: the floating films should read as memories
   surfacing, not as a set of buttons — and when one surfaces it should be BIG,
   opening out into the space beside the monitor, with its edges dissolved.

   The literature on the flashback look is consistent about what sells it, and
   it is not the footage: a heavily FEATHERED vignette, a blurred copy of the
   image screened back over itself for the dream bloom, warm faded tone, and a
   soft rise rather than a cut. All four are here.

   ⚠️ IT GROWS SIDEWAYS, NEVER ACROSS THE STAGE. This page has exactly one rule
   it cannot break — nothing performs in front of the work — so a memory that
   expanded over the monitor would be the single worst thing this page could do.
   The margin beside the stage is (100vw - var(--stage-w)) / 2, which is 187px
   at 1440, and the growth is sized to live inside it. Each thumbnail also grows
   AWAY from the frame: film.js gives every one a --org matching the side it
   sits on, so a left thumbnail opens leftward and a right one rightward, and
   neither reaches inward.

   A film with no footage gets the same treatment on its still, so a memory is a
   memory whether or not there is a clip for it.
   ⚠️ Written when seven of the eleven had no clip. All eleven do now (Sep 3
   2026), so this rule no longer has a visible case on the page — it still
   catches a clip that fails to autoplay, which is why it stays. */
.film__orbit button.is-recalling {
  z-index: 2;
  /* a memory comes fully forward: no blur, and brightness past 1 so it reads as
     lit rather than merely un-dimmed. Opacity is no longer part of this — see
     the note above; it is opaque at every depth now. */
  opacity: 1;
  filter: blur(0) brightness(1.06);
  transition: filter 1.4s ease;
}
.film__orbit button.is-recalling .thumb {
  /* ⚠️ THE FALLBACKS HAVE TO BE THE SAFE ONES. film.js measures --org and --open
     per thumbnail so a memory opens AWAY from the monitor and only as far as the
     margin allows. If those never arrive — a script error, a stale cache, an
     entry path that skips placement — the animation still runs, and a generous
     default would open a memory straight across the stage. Rendered exactly that
     once: a centred origin at 2.4x put a film over the whole frame. So the
     defaults are the smallest thing that still reads as opening, and the
     measured values can only ever make it bigger. */
  transform-origin: 50% 50%;   /* the RING keeps it off the frame now, not a skewed origin */
  animation: recall-open 7s cubic-bezier(.25,.6,.2,1) both;
  filter: drop-shadow(0 0 26px rgba(255, 214, 150, .30))
          drop-shadow(0 0 60px rgba(240, 168, 60, .16));
  /* the edges dissolve much further than at rest — at rest the mask is opaque
     to 44%, remembering takes it to 22%, which is the feathering the flashback
     look actually turns on */
  /* ⚠️ FEATHERED FURTHER, ON JUAN'S READ THAT A MEMORY SHOULD NOT HAVE AN EDGE.
     At rest the mask is opaque to 44%; remembering now takes it to 8% and runs
     the falloff all the way out, so the frame has no border at all — it is a
     shape of light rather than a rectangle of video. This is the one place the
     feather can go this far: at rest a thumbnail still has to read as a
     selectable control, and dissolving its edges would make it look broken. */
  /* ⚠️ SOFTER AGAIN, on Juan's second read. The core is opaque for only the
     middle few per cent now and the falloff runs the whole way out, so a
     memory has no edge anywhere — it is a patch of light that happens to carry
     a picture. Anything harder reads as a video in a box. */
  -webkit-mask-image: var(--memory-mask);
          mask-image: var(--memory-mask);
}
/* ⚠️ THE DREAM BLOOM IS NOT A backdrop-filter. The first version screened a
   backdrop-filter layer over the frame, which is the technique every flashback
   guide describes — and it rendered as two grey RECTANGLES on the page. A
   backdrop-filter samples the page behind it and establishes its own containing
   context, so it ignored the parent's feather mask and painted a hard box out
   to the element's bounds. The mask is the whole effect here, so anything that
   escapes it is worse than no bloom at all.

   A drop-shadow glow plus a touch of blur on the frame itself gets the same
   read — light spreading out of a remembered image — using only properties the
   mask governs. */
.film__orbit button.is-recalling .thumb img,
.film__orbit button.is-recalling .thumb video {
  animation: recall-warm 7s ease-in-out both;
}
/* 🔴 AND THE CLIP TAKES THE MEMORY SHAPE TOO — THIS RULE DID NOT EXIST.
   The <img> needs no such rule: it has no mask of its own and simply inherits
   whatever .thumb is wearing. The <video> is not clipped by .thumb at all, so
   when .thumb changed shape for the recall the footage did not follow it, and
   a remembered clip kept the tight rest circle while everything around it had
   dissolved. Same box, two different edges, for as long as clips have played. */
.film__orbit button.is-recalling .thumb video {
  -webkit-mask-image: var(--memory-mask);
          mask-image: var(--memory-mask);
}
/* the scanlines drift downward through it — a tube locking onto a weak signal,
   which is the one motion that says "this is being played, not shown" */
.film__orbit button.is-recalling .thumb::after {
  animation: recall-roll 2.6s linear infinite;
  opacity: .34;
}
/* ⚠️ --open IS NOW A RATIO THAT LANDS EVERY MEMORY ON THE SAME SIZE, computed
   per thumbnail as TARGET / its own scale. It used to be a measured allowance of
   up to 3.4x on top of a scale that itself ran to 1.24, so one memory could open
   to four times another's — which is what "way too big" and "they should be the
   same size" were both describing. The ring is placed so a thumbnail at full
   TARGET still clears the frame, so this can no longer reach the monitor. */
@keyframes recall-open {
  0%   { scale: var(--scale, 1); }
  26%  { scale: calc(var(--scale, 1) * var(--open, 2.05)); }
  74%  { scale: calc(var(--scale, 1) * var(--open, 2.05)); }
  100% { scale: var(--scale, 1); }
}
@keyframes recall-bloom {
  0%, 100% { opacity: 0; }
  30%, 70% { opacity: .55; }
}
/* 🔴 NO url(#crt-chroma) IN HERE, AND THIS IS A NEW RULE ABOUT SVG FILTERS.
   This animation runs on .thumb — the element that carries the circle mask — and
   an SVG filter reference on a MASKED element SILENTLY DESTROYS THE MASK in
   WebKit. The clip rendered as a hard rectangle among feathered circles, which
   is what Juan reported three times.

   Proven on an iPhone with a four-case isolation page rather than reasoned
   about, because the obvious suspects were all innocent:
     A  mask on the <video> itself           → feathers correctly
     B  mask on the wrapper                  → feathers correctly
     E  mask + these filters WITH  url(#…)   → HARD RECTANGLE
     F  mask + these filters WITHOUT url(#…) → feathers correctly
   So it is not that video resists masking, and not the compositing layer. It is
   the SVG filter, and only when it shares an element with a mask.

   ⇒ THE EXISTING RULE WAS TOO NARROW. The tenth pass banned url(#crt-chroma) on
   PAGE-SIZED containers after it corrupted every page. The rule is bigger than
   size: never put it on an element that also carries a mask, at any size.

   Nothing visible is lost. The note further up already recorded that one pass of
   0.55px chroma is invisible at 70px — it is tuned for text at full size. The
   colour grade, the bloom and the blur all stay; only the reference goes. */
@keyframes recall-warm {
  0%   { filter: saturate(.5) brightness(.66) sepia(.22) hue-rotate(-12deg) contrast(1.06); }
  26%  { filter: saturate(.78) brightness(1.04) sepia(.16) contrast(1.02) blur(.5px); }
  74%  { filter: saturate(.78) brightness(1.02) sepia(.16) contrast(1.02) blur(.5px); }
  100% { filter: saturate(.5) brightness(.66) sepia(.22) hue-rotate(-12deg) contrast(1.06); }
}
@keyframes recall-roll { from { background-position: 0 0, 0 0; } to { background-position: 0 3px, 0 0; } }

/* a memory that arrives on its own schedule is ambience; one that arrives while
   you are reading is an interruption. Hover, focus and the chosen film all opt
   out, and reduced motion never starts it — see film.js. */
/* 🔴 PAUSED, NOT `animation: none` — AND THAT ONE WORD WAS A VISIBLE BUG.
   `animation: none` does not stop a memory, it DESTROYS it. Moving the cursor
   away re-applied `recall-open` from time zero, so the thumbnail collapsed to
   its resting size and replayed the entire seven-second open. Juan reported it
   as "it really restarts the animation ... whenever my cursor away from them",
   and it is plain CSS semantics rather than a browser quirk: re-adding an
   animation always starts a new one at 0.

   ⚠️ ALL FOUR OF THE MEMORY'S ANIMATIONS PAUSE TOGETHER, not just the scale.
   Freezing `recall-open` alone left the colour grade (recall-warm) and the
   scanline drift (recall-roll) running on underneath a held picture, so a
   paused memory still crept — which is the same "weird behaviour" one layer
   down. A memory is one thing; it stops as one thing.

   ⚠️ The EXPIRY pauses too, in film.js. A frozen animation under a timer that
   keeps running gets its class stripped mid-open and snaps to rest in one
   frame — that would have traded a replay for a jump. */
.film__orbit button:hover.is-recalling .thumb,
.film__orbit button:focus-visible.is-recalling .thumb,
.film__orbit button:hover.is-recalling .thumb img,
.film__orbit button:focus-visible.is-recalling .thumb img,
.film__orbit button:hover.is-recalling .thumb video,
.film__orbit button:focus-visible.is-recalling .thumb video,
.film__orbit button:hover.is-recalling .thumb::after,
.film__orbit button:focus-visible.is-recalling .thumb::after { animation-play-state: paused; }

/* two lanes, two periods. orbit-a owns `translate` and `rotate`; orbit-b owns
   `transform`. Amplitudes and periods are set per thumbnail from its measured
   envelope and its depth. */
/* ⚠️ SEVEN PATHS, NOT ONE, AND THE SET IS NOT DECORATION. Every thumbnail used
   to run the same superellipse, so the shape of the path became legible within
   seconds and four of them sat in the corners together — Juan's note, and the
   cause was that they were all the same curve.

   ⚠️ THE CLASS IS AN INDEX, NOT THE EXPONENT. It used to be built from the
   number — orb-n5.0 -> "orb-n50" — and that contract broke twice on number
   formatting alone: Python renders 5.0 as "5.0" and JavaScript renders it as
   "5", so the stylesheet said orb-n50 while the script asked for orb-n5 and
   those thumbnails simply never animated. They sat at the centre of the page,
   on the monitor, which read as a placement bug rather than a naming one. The
   exponent lives in the comment; the wire is an index.

   Each is a superellipse |x/rx|^n + |y/ry|^n = 1 at a different n: 3.4 is
   almost an oval, 10 hugs a rectangle. film.js does NOT pick one by taste — it
   takes the radius a thumbnail has been given and finds the roundest exponent
   that still clears the frame, because a rounder path has to be bigger to get
   around the same block. Outer thumbnails come out oval, inner ones stay
   squarer because nothing rounder fits.

   One lane, `translate: x y`. The two-lane split existed to make a Lissajous
   from independent oscillations; a closed parametric path is a list of points,
   and putting both coordinates in one keyframe is what allows the shape to vary
   per thumbnail at all. 24 stops — at 16 the flatter exponents visibly cut
   their corners. */
@keyframes orb-p0 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9798) calc(var(--ry, 200px) * 0.4515); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9189) calc(var(--ry, 200px) * 0.6652); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.8156) calc(var(--ry, 200px) * 0.8156); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.6652) calc(var(--ry, 200px) * 0.9189); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.4515) calc(var(--ry, 200px) * 0.9798); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.4515) calc(var(--ry, 200px) * 0.9798); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.6652) calc(var(--ry, 200px) * 0.9189); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.8156) calc(var(--ry, 200px) * 0.8156); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9189) calc(var(--ry, 200px) * 0.6652); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9798) calc(var(--ry, 200px) * 0.4515); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9798) calc(var(--ry, 200px) * -0.4515); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9189) calc(var(--ry, 200px) * -0.6652); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.8156) calc(var(--ry, 200px) * -0.8156); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.6652) calc(var(--ry, 200px) * -0.9189); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.4515) calc(var(--ry, 200px) * -0.9798); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.4515) calc(var(--ry, 200px) * -0.9798); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.6652) calc(var(--ry, 200px) * -0.9189); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.8156) calc(var(--ry, 200px) * -0.8156); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9189) calc(var(--ry, 200px) * -0.6652); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9798) calc(var(--ry, 200px) * -0.4515); }
  100%    { translate: var(--rx, 300px) 0; }
}
@keyframes orb-p1 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9836) calc(var(--ry, 200px) * 0.5254); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9338) calc(var(--ry, 200px) * 0.7189); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.8479) calc(var(--ry, 200px) * 0.8479); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.7189) calc(var(--ry, 200px) * 0.9338); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.5254) calc(var(--ry, 200px) * 0.9836); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.5254) calc(var(--ry, 200px) * 0.9836); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.7189) calc(var(--ry, 200px) * 0.9338); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.8479) calc(var(--ry, 200px) * 0.8479); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9338) calc(var(--ry, 200px) * 0.7189); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9836) calc(var(--ry, 200px) * 0.5254); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9836) calc(var(--ry, 200px) * -0.5254); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9338) calc(var(--ry, 200px) * -0.7189); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.8479) calc(var(--ry, 200px) * -0.8479); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.7189) calc(var(--ry, 200px) * -0.9338); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.5254) calc(var(--ry, 200px) * -0.9836); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.5254) calc(var(--ry, 200px) * -0.9836); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.7189) calc(var(--ry, 200px) * -0.9338); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.8479) calc(var(--ry, 200px) * -0.8479); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9338) calc(var(--ry, 200px) * -0.7189); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9836) calc(var(--ry, 200px) * -0.5254); }
  100%    { translate: var(--rx, 300px) 0; }
}
@keyframes orb-p2 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9862) calc(var(--ry, 200px) * 0.5824); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9441) calc(var(--ry, 200px) * 0.7579); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.8706) calc(var(--ry, 200px) * 0.8706); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.7579) calc(var(--ry, 200px) * 0.9441); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.5824) calc(var(--ry, 200px) * 0.9862); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.5824) calc(var(--ry, 200px) * 0.9862); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.7579) calc(var(--ry, 200px) * 0.9441); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.8706) calc(var(--ry, 200px) * 0.8706); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9441) calc(var(--ry, 200px) * 0.7579); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9862) calc(var(--ry, 200px) * 0.5824); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9862) calc(var(--ry, 200px) * -0.5824); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9441) calc(var(--ry, 200px) * -0.7579); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.8706) calc(var(--ry, 200px) * -0.8706); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.7579) calc(var(--ry, 200px) * -0.9441); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.5824) calc(var(--ry, 200px) * -0.9862); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.5824) calc(var(--ry, 200px) * -0.9862); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.7579) calc(var(--ry, 200px) * -0.9441); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.8706) calc(var(--ry, 200px) * -0.8706); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9441) calc(var(--ry, 200px) * -0.7579); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9862) calc(var(--ry, 200px) * -0.5824); }
  100%    { translate: var(--rx, 300px) 0; }
}
@keyframes orb-p3 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9881) calc(var(--ry, 200px) * 0.6275); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9516) calc(var(--ry, 200px) * 0.7874); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.8874) calc(var(--ry, 200px) * 0.8874); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.7874) calc(var(--ry, 200px) * 0.9516); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.6275) calc(var(--ry, 200px) * 0.9881); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.6275) calc(var(--ry, 200px) * 0.9881); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.7874) calc(var(--ry, 200px) * 0.9516); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.8874) calc(var(--ry, 200px) * 0.8874); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9516) calc(var(--ry, 200px) * 0.7874); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9881) calc(var(--ry, 200px) * 0.6275); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9881) calc(var(--ry, 200px) * -0.6275); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9516) calc(var(--ry, 200px) * -0.7874); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.8874) calc(var(--ry, 200px) * -0.8874); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.7874) calc(var(--ry, 200px) * -0.9516); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.6275) calc(var(--ry, 200px) * -0.9881); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.6275) calc(var(--ry, 200px) * -0.9881); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.7874) calc(var(--ry, 200px) * -0.9516); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.8874) calc(var(--ry, 200px) * -0.8874); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9516) calc(var(--ry, 200px) * -0.7874); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9881) calc(var(--ry, 200px) * -0.6275); }
  100%    { translate: var(--rx, 300px) 0; }
}
@keyframes orb-p4 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9899) calc(var(--ry, 200px) * 0.672); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9586) calc(var(--ry, 200px) * 0.8156); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.9031) calc(var(--ry, 200px) * 0.9031); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.8156) calc(var(--ry, 200px) * 0.9586); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.672) calc(var(--ry, 200px) * 0.9899); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.672) calc(var(--ry, 200px) * 0.9899); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.8156) calc(var(--ry, 200px) * 0.9586); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.9031) calc(var(--ry, 200px) * 0.9031); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9586) calc(var(--ry, 200px) * 0.8156); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9899) calc(var(--ry, 200px) * 0.672); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9899) calc(var(--ry, 200px) * -0.672); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9586) calc(var(--ry, 200px) * -0.8156); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.9031) calc(var(--ry, 200px) * -0.9031); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.8156) calc(var(--ry, 200px) * -0.9586); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.672) calc(var(--ry, 200px) * -0.9899); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.672) calc(var(--ry, 200px) * -0.9899); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.8156) calc(var(--ry, 200px) * -0.9586); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.9031) calc(var(--ry, 200px) * -0.9031); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9586) calc(var(--ry, 200px) * -0.8156); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9899) calc(var(--ry, 200px) * -0.672); }
  100%    { translate: var(--rx, 300px) 0; }
}
@keyframes orb-p5 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9914) calc(var(--ry, 200px) * 0.7133); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9647) calc(var(--ry, 200px) * 0.8409); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.917) calc(var(--ry, 200px) * 0.917); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.8409) calc(var(--ry, 200px) * 0.9647); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.7133) calc(var(--ry, 200px) * 0.9914); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.7133) calc(var(--ry, 200px) * 0.9914); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.8409) calc(var(--ry, 200px) * 0.9647); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.917) calc(var(--ry, 200px) * 0.917); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9647) calc(var(--ry, 200px) * 0.8409); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9914) calc(var(--ry, 200px) * 0.7133); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9914) calc(var(--ry, 200px) * -0.7133); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9647) calc(var(--ry, 200px) * -0.8409); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.917) calc(var(--ry, 200px) * -0.917); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.8409) calc(var(--ry, 200px) * -0.9647); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.7133) calc(var(--ry, 200px) * -0.9914); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.7133) calc(var(--ry, 200px) * -0.9914); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.8409) calc(var(--ry, 200px) * -0.9647); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.917) calc(var(--ry, 200px) * -0.917); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9647) calc(var(--ry, 200px) * -0.8409); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9914) calc(var(--ry, 200px) * -0.7133); }
  100%    { translate: var(--rx, 300px) 0; }
}
@keyframes orb-p6 {
  0%      { translate: var(--rx, 300px) 0; }
  4.1667% { translate: calc(var(--rx, 300px) * 0.9931) calc(var(--ry, 200px) * 0.7631); }
  8.3333% { translate: calc(var(--rx, 300px) * 0.9716) calc(var(--ry, 200px) * 0.8706); }
  12.5%   { translate: calc(var(--rx, 300px) * 0.933) calc(var(--ry, 200px) * 0.933); }
  16.6667%{ translate: calc(var(--rx, 300px) * 0.8706) calc(var(--ry, 200px) * 0.9716); }
  20.8333%{ translate: calc(var(--rx, 300px) * 0.7631) calc(var(--ry, 200px) * 0.9931); }
  25%     { translate: 0 var(--ry, 200px); }
  29.1667%{ translate: calc(var(--rx, 300px) * -0.7631) calc(var(--ry, 200px) * 0.9931); }
  33.3333%{ translate: calc(var(--rx, 300px) * -0.8706) calc(var(--ry, 200px) * 0.9716); }
  37.5%   { translate: calc(var(--rx, 300px) * -0.933) calc(var(--ry, 200px) * 0.933); }
  41.6667%{ translate: calc(var(--rx, 300px) * -0.9716) calc(var(--ry, 200px) * 0.8706); }
  45.8333%{ translate: calc(var(--rx, 300px) * -0.9931) calc(var(--ry, 200px) * 0.7631); }
  50%     { translate: calc(var(--rx, 300px) * -1) 0; }
  54.1667%{ translate: calc(var(--rx, 300px) * -0.9931) calc(var(--ry, 200px) * -0.7631); }
  58.3333%{ translate: calc(var(--rx, 300px) * -0.9716) calc(var(--ry, 200px) * -0.8706); }
  62.5%   { translate: calc(var(--rx, 300px) * -0.933) calc(var(--ry, 200px) * -0.933); }
  66.6667%{ translate: calc(var(--rx, 300px) * -0.8706) calc(var(--ry, 200px) * -0.9716); }
  70.8333%{ translate: calc(var(--rx, 300px) * -0.7631) calc(var(--ry, 200px) * -0.9931); }
  75%     { translate: 0 calc(var(--ry, 200px) * -1); }
  79.1667%{ translate: calc(var(--rx, 300px) * 0.7631) calc(var(--ry, 200px) * -0.9931); }
  83.3333%{ translate: calc(var(--rx, 300px) * 0.8706) calc(var(--ry, 200px) * -0.9716); }
  87.5%   { translate: calc(var(--rx, 300px) * 0.933) calc(var(--ry, 200px) * -0.933); }
  91.6667%{ translate: calc(var(--rx, 300px) * 0.9716) calc(var(--ry, 200px) * -0.8706); }
  95.8333%{ translate: calc(var(--rx, 300px) * 0.9931) calc(var(--ry, 200px) * -0.7631); }
  100%    { translate: var(--rx, 300px) 0; }
}
}

/* ⚠️ Under a phone, or under reduced motion, the field becomes a plain row. A
   scatter of absolutely-positioned thumbnails has no sensible small-screen
   layout, and a drifting field is precisely what prefers-reduced-motion asks us
   not to build. */
@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  .film__strip {
    position: static; inset: auto;
    width: min(94vw, 1100px); margin: clamp(12px, 2vh, 20px) auto 0;
  }
  .film__orbit {
    display: flex; flex-wrap: wrap; justify-content: center; gap: 7px;
  }
  .film__orbit button {
    /* relative, not static: the scanline/vignette overlay is an ::after with
       inset:0, so the button has to stay its containing block or the treatment
       jumps to the page. The row loses the drift and the feather, not the
       reason the thumbnails read as displayed rather than pasted. */
    position: relative; animation: none; filter: none;
    /* all four transform lanes have to be reset, not just the one that used to
       exist — a leftover `scale` or `translate` survives `transform: none` */
    transform: none; translate: none; rotate: none; scale: none;
    opacity: .45; left: auto; top: auto;
  }
  .film__orbit .thumb { scale: none; -webkit-mask-image: none; mask-image: none; }
  .film__orbit button.is-on { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .slide img { animation: none; }
  .slide { transition: none; }
  .film__strip button { transition: none; }
}

/* the one field on this page that leaves it */
.film__watch { color: var(--amber); text-decoration: none; border-bottom: 1px solid rgba(240,168,60,.35); }
.film__watch:hover { border-bottom-color: var(--amber); }

/* ============================================== PLAY ON THE MONITOR ==========
   Juan, Sep 3 2026: "I would want the video to be able to play within that
   website if the user clicks on the main live monitor to play it."

   ⚠️ THE CONTROL IS SHOWN BY THE DATA, NOT BY THE PAGE. Four of the eleven films
   carry a Vimeo URL; film.js hides this button on the other seven rather than
   offering a play that cannot happen. Same rule the synopsis row follows.

   The mark is /sipful's `.vid__tri` re-cut for this page rather than a new
   invention — same 1px amber ring, same dark fill, same hover lift.
   ⚠️ THE WORD "PLAY" IS GONE, Juan's call Sep 3 2026, and this note used to
   argue FOR it ("a bare triangle over a film still reads as part of the frame").
   The ring is what answers that: a 1px amber circle with a dark fill is not part
   of any photograph, and the radial scrim under it separates the control from
   the picture without a caption. The accessible name moved to aria-label on the
   button, which film.js keeps per slide — nothing is lost for a screen reader. */
.film__play {
  position: absolute; inset: 0; z-index: 4;
  display: grid; place-content: center; justify-items: center;
  border: 0; background: none; cursor: pointer;
  border-radius: inherit;
  /* the still is the picture; the button only darkens it enough to hold a
     control, and lifts that away on hover so the frame is never obscured */
  background: radial-gradient(60% 60% at 50% 50%, rgba(5, 7, 6, .42), transparent 70%);
  opacity: .92;
  transition: opacity .3s ease, background .3s ease;
}
.film__play:hover, .film__play:focus-visible { opacity: 1; }
.film__play:focus-visible { outline: 2px solid var(--amber); outline-offset: -4px; }
.film__tri {
  position: relative;
  width: 62px; height: 62px;
  border-radius: 50%; border: 1px solid rgba(240, 168, 60, .55);
  background: rgba(5, 7, 6, .55);
  box-shadow: 0 0 26px rgba(240, 168, 60, .28);
  transition: background .3s ease, transform .3s ease;
}
.film__tri::before {
  content: ""; position: absolute; left: 50%; top: 50%;
  margin: -10px 0 0 -6px;
  border-style: solid; border-width: 10px 0 10px 16px;
  border-color: transparent transparent transparent var(--amber);
}
.film__play:hover .film__tri,
.film__play:focus-visible .film__tri { background: rgba(240, 168, 60, .16); transform: scale(1.06); }
/* the player itself, once it exists. z-index 5 clears the scanline and falloff
   overlays (3 and 2) — a film playing under this page's CRT treatment would be
   the one place the effect stops being atmosphere and starts being dirt. */
.film__frame {
  position: absolute; inset: 0; z-index: 5;
  width: 100%; height: 100%;
  border: 0; border-radius: inherit; background: #000;
}
@media (prefers-reduced-motion: reduce) {
  .film__play, .film__tri { transition: none; }
}
