/* ABOUTME: The cinematic chapter treatment: one grid, one type scale, one stage, one set of chrome. */
/* ABOUTME: Opt-in per page via body.film, so deleting one link tag returns the page to the site design. */

/*
  How this file is organised, and the rules it holds itself to.

  1. TOKENS. Everything measurable is a token. Nothing below invents a size, a colour or a
     spacing value inline. A new theme stays a block of theme values and changes nothing here.
  2. GRID. One twelve column grid, one container inset, one gutter. Frames place content on
     that grid by column number. No frame narrows a box by moving its margin, because doing
     that throws away the container inset and puts text against the viewport edge.
  3. STAGE. One fixed element behind the document. It never touches body::before or
     body::after: portfolio-v2.css owns both, and ::after is the cursor trail.
  4. FRAMES. Four act layouts, chosen per act by data-frame. Adding an act means copying an
     act block and picking a frame; it is never a design decision.
  5. CHROME, then MOTION last. Everything that moves is inside prefers-reduced-motion, so the
     static rendering is the real design.

  Stacking contract, because this page shares a stylesheet with the site chrome. Owned by
  portfolio-v2.css: header 30, style panel 40, skip link 45, cursor canvas 999. Owned here:
  stage 0, act numeral 0 within its pin, document content 2, chapter chrome 25. Nothing here
  goes above 25, so the header, the style panel and the cursor trail all stay on top.
*/

@font-face {
  font-family: "Instrument Serif";
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/instrument-serif-italic-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Instrument Serif";
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/instrument-serif-italic-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ============================================================================
   1. TOKENS
   ========================================================================= */

/*
  Values no theme touches. The faces, the grid, the spacing scale and the type scale are
  constants of this treatment, so they belong at :root where they cost one resolution.
*/
:root {
  --face-display: "Archivo", system-ui, sans-serif;
  --face-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
  --face-italic: "Instrument Serif", Georgia, "Times New Roman", serif;

  /* Grid. The inset is the page left and right margin and is the same on every section. */
  --film-inset: clamp(20px, 5vw, 76px);
  --film-gap: clamp(14px, 1.9vw, 26px);
  --film-rule: 1px;

  /*
    Spacing. Eight steps, each roughly one and a half times the last. Every margin and every
    pad in this file is one of these, which is what stops a section being spaced by eye.
  */
  --s1: 0.25rem;
  --s2: 0.5rem;
  --s3: 0.85rem;
  --s4: 1.35rem;
  --s5: 2.1rem;
  --s6: 3.4rem;
  --s7: 5.5rem;
  --s8: 8.5rem;

  /* Type. One scale, with wide contrast between the display sizes and the metadata size. */
  --t-meta: 0.68rem;
  --t-small: 0.82rem;
  --t-body: 1.03rem;
  --t-lede: clamp(1.08rem, 1.6vw, 1.4rem);
  --t-h3: clamp(1.15rem, 1.7vw, 1.45rem);
  --t-h2: clamp(1.7rem, 3.4vw, 2.7rem);
  /*
    The two display sizes are bounded by the height of the frame as well as its width.

    A chapter act is a pinned screen, so its budget is vertical, and these were sized on vw
    alone. On a wide, short window that is backwards: the screen gets wider, the type gets
    bigger, and the room it has to fit in gets smaller. At 1440x900 the wide heading came out
    at 70px and wrapped to seven lines, 513px of the 747px the pin can hold, which pushed the
    pin past the viewport. A sticky element taller than the viewport does not hold with its
    top at zero: it scrolls until its bottom is flush with the bottom of the screen, so the
    top of the heading ends up above the fold and behind the header.

    The vh terms are what the frame can actually afford, measured against the tallest frame
    in this chapter. They only bind on a short window; on a tall one the vw term still wins.
  */
  --t-h2-wide: clamp(2rem, min(5.2vw, 6.2vh), 4.4rem);
  --t-h1: clamp(2.5rem, min(8.2vw, 11.4vh), 7rem);
  --t-numeral: clamp(16rem, 42vw, 40rem);

  /* Fixed chrome heights, so acts can treat them as gutters instead of guessing. */
  --film-header-h: 65px;
  /* The letterbox bars. Named here because the progress hairline shares their top edge and has
     to be drawn on them rather than under them, and two rules holding the same number by
     coincidence is how it ended up underneath in the first place. */
  --film-bar-h: 14px;
  --film-bar-z: 26;

  --film-grain: 0.05;
  --film-vignette: 0.5;
}

/*
  Anything derived from a theme token MUST be declared here and not at :root. A custom
  property alias resolves where it is declared, so var(--cyan) written at :root freezes to the
  :root default and never sees a theme, because themes override --cyan on body[data-theme].
  That exact mistake previously froze this page's scenery on one colour in every
  theme. tools/check-site.mjs fails the build if a :root rule in this file contains var().
*/
body.film {
  /* Film pages keep their own type under every theme (owner ruling 2026-10-23). A theme may set
     these fonts on body[data-theme]; this rule has the same weight and loads later, so it wins. */
  --font-body: Inter, "Open Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-display: Inter, "Open Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  /* The film is the page here, so a theme that draws a page of its own (Paper's lined sheet,
     its margin, its handwriting without bold) does not draw it on a film page. initial sends
     each back to the fallback every other theme uses. */
  --page-bg: initial;
  --page-gutter: initial;
  --font-synthesis: initial;
  /* Nor does it write a theme's marks around the film's words (Cyber's prompts and brackets),
     set its labels or cut its buttons (Ember's ribbons), or size and light its headline. */
  --label-prefix: initial;
  --heading-prefix: initial;
  --button-open: initial;
  --button-close: initial;
  --eyebrow-case: initial;
  --eyebrow-tracking: initial;
  --eyebrow-font: initial;
  --button-shape: initial;
  --button-primary-fill: initial;
  --button-primary-ink: initial;
  --headline-glow: initial;
  --headline-scale: initial;

  /*
    The stage, per scene. These four values are what make an act a different place rather
    than the same place in a different colour: how hard the room converges, where its
    horizon sits, and how dark its corners are. Two reviewers in a row described four
    declared scenes as one static backdrop, because until now the only thing that actually
    changed between them was the accent hue and a line spacing nobody could see.
  */
  --stage-perspective: 620px;
  --stage-lift: 0px;

  /* The empty band outside the container, once --max has capped the container width. It is
     the only part of the frame guaranteed to hold no text at any scroll position. It is
     declared here rather than at :root for the reason above: nothing in this file resolves a
     var() at :root, so that the rule has no exceptions to remember. */
  --film-gutter: max(var(--film-inset), calc((100vw - var(--max)) / 2));

  --scene-line: var(--line);
  --scene-line-strong: color-mix(in srgb, var(--cyan) 72%, transparent);
  --scene-accent: var(--cyan);
  --scene-glow: color-mix(in srgb, var(--cyan) 13%, transparent);
  /* Which way the key light falls. It mirrors between rooms, which is a change nobody can
     miss, and it costs one number per scene. */
  --stage-shaft: 1;
  --stage-brightness: 1;
}

/* The two light themes need more grain and far less vignette to read as film rather than mud. */
body.film[data-theme="light"],
body.film[data-theme="paper"] {
  --film-grain: 0.075;
  --film-vignette: 0.2;
}

/*
  Each act names a scene, and a scene is only ever a different accent that the theme already
  defines. Four scenes therefore cost four rules, however many themes there are.
*/
/* Streams: a tight corridor. Hard convergence, low horizon, runs along the travel. */
body.film[data-scene="streams"] {
  --scene-line-strong: color-mix(in srgb, var(--green) 74%, transparent);
  --scene-glow: color-mix(in srgb, var(--green) 15%, transparent);
  --stage-perspective: 360px;
  --stage-lift: 44px;
  --stage-shaft: -1;
  --film-vignette: 0.66;
  --stage-brightness: 0.8;
}

/* Lattice: a hall. Shallow convergence, high horizon, and the ceiling comes into play. */
body.film[data-scene="lattice"] {
  --scene-line-strong: color-mix(in srgb, var(--violet) 66%, transparent);
  --scene-glow: color-mix(in srgb, var(--violet) 16%, transparent);
  --stage-perspective: 620px;
  --stage-lift: -44px;
  --film-vignette: 0.34;
  --stage-brightness: 1.25;
}

/* Strata: a low seam. The floor takes the frame and the ceiling all but closes. */
body.film[data-scene="strata"] {
  --scene-line-strong: color-mix(in srgb, var(--amber) 62%, transparent);
  --scene-glow: color-mix(in srgb, var(--amber) 14%, transparent);
  --stage-perspective: 440px;
  --stage-lift: 96px;
  --stage-shaft: -1;
  --film-vignette: 0.74;
  --stage-brightness: 0.62;
}


/* ============================================================================
   2. GRID
   ========================================================================= */

/*
  One container, on every section, including the ones that are not acts. A reader should be
  able to draw a straight line down the left edge of the page from the wordmark to the footer.

  Anchored to the left inset, not centred, and that is the whole point of the rule. The stage
  behind this container is the full viewport: a canvas has no max width and no margins. Centring
  the copy inside a capped container while the scene fills the screen means the page grows a
  dead band on the left that the scene never grows on the right, and it grows without limit.
  Measured: the copy's left edge sat 130px in at 1440, 370px at 1920 and 1130px at 3440, while
  the wordmark above it stayed at 56px and the set behind it stayed where it was. The whole
  composition slid right as the window widened, which is what "everything is pointing towards
  the right side of the screen" describes.

  margin-inline: <inset> auto, not a plain inset, so nothing changes below the cap: at those
  widths the container is already 100% minus two insets and the auto end resolves to the same
  inset, which is the centred layout this replaces.
*/
body.film .section-inner,
body.film .site-footer-inner {
  width: min(var(--max), calc(100% - var(--film-inset) * 2));
  margin-inline: var(--film-inset) auto;
}

/*
  A section opts into the grid by carrying data-frame, and its children are then placed by
  column number in section 4. Sections without a frame stay in normal flow, which is what the
  comment block and the footer want. Below the frame breakpoint every frame is one column and
  every placement is switched off, so a narrow screen is a plain vertical read.
*/
body.film [data-frame] {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  row-gap: var(--s5);
}

@media (min-width: 901px) {
  body.film [data-frame] {
    grid-template-columns: repeat(12, minmax(0, 1fr));
    column-gap: var(--film-gap);
    row-gap: var(--s4);
    align-content: center;
  }
}


/* ============================================================================
   3. STAGE
   ========================================================================= */

/*
  One dedicated fixed element carries the whole grade.

  It cannot be body::before or body::after: portfolio-v2.css already uses both and ::after is
  the cursor trail, a protected feature. It also cannot be a background-image on body, because
  the themed backdrop is a three layer background shorthand and any background-image
  declaration replaces all three layers, leaving the page blank.
*/
.film-grade {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  perspective: var(--stage-perspective);
  perspective-origin: 50% 63%;
  background: radial-gradient(ellipse at 50% 63%, transparent 34%, rgba(0, 0, 0, var(--film-vignette)) 100%);
}

/*
  The floor is one plane laid back in 3D and pushed toward the camera as the reader scrolls,
  which is what turns vertical scrolling into travelling forward. It moves with translateY
  along the rotated plane, not translateZ, which would travel along the plane normal and rise
  instead. --film-depth is written by film.js; with no JS the plane sits at its resting depth
  and the page still reads correctly.
*/
.film-room {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: translateY(var(--stage-lift));
  /* The chapter's rooms end where the chapter does. Past the last act the page is a comment
     thread and a footer, and a receding perspective grid behind a form is not atmosphere,
     it is a set of lines through someone's placeholder text. */
  opacity: var(--stage-presence, 1);
}

.film-stage-floor,
.film-stage-ceiling {
  position: absolute;
  left: -60%;
  right: -60%;
  height: 220vh;
  transform-style: preserve-3d;
  background-image:
    repeating-linear-gradient(to right, var(--scene-line-strong) 0 1px, transparent 1px 110px),
    repeating-linear-gradient(to bottom, var(--scene-line-strong) 0 1px, transparent 1px 110px);
}

.film-stage-floor {
  top: 63%;
  transform: rotateX(74deg) translateY(calc(var(--film-depth, 0) * 1px));
  /*
    Fade only the first sliver, which is the far distance. Everything after it is the floor
    the reader is actually looking at. Fading half the plane instead left the grid invisible
    at every scroll position and made the page's whole premise decorative.
  */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 1) 5%);
  mask-image: linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 1) 5%);
}

/* The ceiling, mirrored, so the reader is inside the space rather than looking down on it. */
.film-stage-ceiling {
  bottom: 37%;
  transform: rotateX(-74deg) translateY(calc(var(--film-depth, 0) * -1px));
  opacity: 0.28;
  -webkit-mask-image: linear-gradient(to top, transparent 0, rgba(0, 0, 0, 1) 6%);
  mask-image: linear-gradient(to top, transparent 0, rgba(0, 0, 0, 1) 6%);
}

.film-stage-wall {
  position: absolute;
  top: -70%;
  bottom: -70%;
  width: 240vh;
  transform-style: preserve-3d;
  background-image:
    repeating-linear-gradient(to right, var(--scene-line-strong) 0 1px, transparent 1px 110px),
    repeating-linear-gradient(to bottom, var(--scene-line-strong) 0 1px, transparent 1px 110px);
  opacity: 0.55;
}

/* Each wall hinges on the screen edge nearest the reader and recedes from there, so its
   strongest ruling is at the edge of the frame and it fades into the distance. */
.film-stage-wall[data-side="left"] {
  left: 0;
  transform-origin: left center;
  transform: rotateY(76deg) translateX(calc(var(--film-depth, 0) * -1px));
  -webkit-mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.9), transparent 42%);
  mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.9), transparent 42%);
}

.film-stage-wall[data-side="right"] {
  right: 0;
  transform-origin: right center;
  transform: rotateY(-76deg) translateX(calc(var(--film-depth, 0) * 1px));
  -webkit-mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.9), transparent 42%);
  mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.9), transparent 42%);
}

.film-stage-glow {
  position: absolute;
  left: 50%;
  top: 63%;
  opacity: var(--stage-brightness, 1);
  width: min(62rem, 92vw);
  height: 24rem;
  transform: translate(-50%, -50%);
  background: radial-gradient(ellipse at center, var(--scene-glow), transparent 70%);
}

.film-grade::before {
  content: "";
  position: absolute;
  inset: 0;
  opacity: var(--film-grain);
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/*
  The site's second backdrop layer is moved by the chapter, and its own drawing is dropped.

  It carries a white 115 degree diagonal and a repeating 80px vertical ruling. Behind an act the
  scene covers both; below the last act the canvas has faded to nothing and they are the only
  thing in the right half of the frame, which is a white band across the comment column and a
  ruling down the page. Same reason the site's 54px grid is suppressed above: two grids at two
  scales read as neither, and this one is answering nothing on a chapter.

  The glow layers either side of it keep their backgrounds, because a wash of the theme's own
  colour is what stops the tail of the page going flat.
*/
body.film .theme-layer.layer-b {
  background: none;
  transform:
    translate3d(calc((var(--act-now, 0) - 0.5) * 30vw), 0, 0)
    scaleX(var(--stage-shaft, 1));
}

/*
  The site draws its own fixed 54px grid behind every page. On a chapter it is a second grid
  competing with the stage's, at a different scale and in a different perspective, and two
  grids read as neither. The stage provides this page's grid; suppressing the content here
  restores it the moment this stylesheet is removed.
*/
body.film::before {
  content: none;
}

/*
  Lift the document above the stage. The header is deliberately absent from this rule: it is
  already fixed at z-index 30 site wide, and naming it here overrode that and made the nav
  scroll away on this page and nowhere else.
*/
body.film main,
body.film .site-footer {
  position: relative;
  z-index: 2;
}

/*
  The truck strip's own bottom rule, which is the one piece of pure black on a page that has
  none anywhere else.

  The strip itself is not touched and is not ours to touch: it stays exactly as it is on every
  page including this one, by owner ruling of 2026-09-14, and the mountains, the hills, the
  trees and the truck are all left alone. Only the 3px #000 it inherits from animations.css is
  restated here, scoped to this page, because animations.css is shared with five others.

  Worth recording that this is subtler than it was reported: a reviewer found it by reading the
  stylesheet and said plainly that no frame it was given reached that far down the page. Looked
  at rendered, the strip reads correctly and only this rule is wrong.
*/
body.film .loop-wrapper {
  border-bottom-color: color-mix(in srgb, var(--text) 12%, transparent);
}


/* ============================================================================
   4. ACTS AND FRAMES
   ========================================================================= */

/*
  An act is a pinned screen inside a taller wrapper. The reader scrolls the wrapper height
  while the screen holds still, so scrolling reads as time passing rather than as the page
  moving. Sticky needs no JavaScript and is supported everywhere; where the viewport is too
  short to hold a screen the wrapper collapses and the content returns to normal flow, which
  is also what a reader who asked for reduced motion gets.
*/
.act {
  position: relative;
  /*
    A pin one viewport tall always takes a full viewport of scrolling to travel off screen, so
    only the height above 100vh is a hold: this holds the frame for 140vh and spends 100vh
    letting it go.

    It was 175vh, which gave each act 113vh of scroll to own. The camera's whole move for an
    act happens inside that, so at 113vh a flick of the wheel carried the reader most of the
    way through a set: the owner's words were "it takes me TOO FAR inside TOO QUICKLY".
    Doubling the scroll an act owns halves the distance covered per turn of the wheel without
    changing a single camera move. Four acts at this height run to within a few screens of
    what the shorter acts plus their overlap used to cost, so removing the overlap did not
    lengthen the page.
  */
  height: 240vh;
}

/*
  Acts do not overlap, and the handoff is structural rather than arithmetic.

  They used to, by 62vh, so that one frame dissolved into the next. Three numbers had to agree
  for that to work: the overlap, an exit fade driven by how far the outgoing frame had
  travelled, and a head start on the incoming copy's arrival. tools/check-site.mjs enforced
  the relationship because nothing else could.

  It came apart when the copy stopped being driven by scroll position. A reveal that latches
  is up as soon as its act is on screen, and inside a 62vh overlap that put the title card and
  the next act's copy in the same pixels at full opacity. The arithmetic still passed, because
  it models an arrival that fades in over a scroll span and there is no longer one.

  Without the overlap each act owns its own scroll outright: its pin holds at the top, then
  unsticks and travels away while the next act's pin climbs into the frame from below. Two
  acts are on screen during that handoff, which is what makes it read as a move rather than a
  cut, but they are in different bands of the screen instead of on top of each other. Nothing
  has to be tuned against anything else, so there is nothing left to get wrong.
*/

/*
  Everything after the last act is ordinary flow, and it has to be, because the arrival gate
  that lets acts overlap safely only exists inside an act. Pinning the closing address as a
  short act put half a screen of its own empty frame between the outro and the thread, and
  giving it automatic height let the comment block ride up over the last act's pin.
*/

body.film .act-pin {
  position: sticky;
  top: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  box-sizing: border-box;
  /* The fixed header is a gutter, not an overlay. Reserving it here is why nothing arrives
     at the top of a frame cut through the middle of its letters. Masking the top of the pin
     was the previous answer and it faded live text instead. */
  padding-top: calc(var(--film-header-h) + var(--s5));
  padding-bottom: var(--s6);
}

/*
  A jump to an act lands on the act's own top, where its pin locks.

  The site's stylesheet stops every in-page jump 82px short, to keep a heading out from under
  the fixed header. A pinned act already reserves the header inside itself, so here the gap was
  counted twice: the act rail and every deep link stopped the act 82px low, and the first small
  scroll then moved the copy and the camera into place. Only where the pins actually pin; on a
  narrow or short screen, or with reduced motion, the acts are ordinary flow and the header
  offset is still what keeps a heading visible.
*/
@media (min-width: 901px) and (min-height: 641px) and (prefers-reduced-motion: no-preference) {
  html:has(> body.film) {
    scroll-padding-top: 0;
  }
}

/*
  The first frame reserves the band the scroll cue stands in, the same way every pin reserves
  the header.

  The cue is fixed to the bottom centre of the viewport, which is where a scroll cue belongs,
  and the title card's four stat cards run the full width of the reading lane straight down to
  meet it. Measured at 1440: the cue's box started 2px below the last card at 900 high and
  overlapped it by 17px at 768, and a reviewer read it as a caption belonging to the card row
  rather than as chrome. It is the only frame this can happen on, because the cue is suppressed
  once the reader is a quarter of a screen down.
*/
body.film .case-hero.act-pin {
  padding-bottom: calc(var(--s6) + var(--s6));
}

/* Content sits above the stage without needing a scrim behind every paragraph. */
body.film .act-pin > .section-inner {
  position: relative;
  z-index: 1;
}

/*
  The frames. An act picks one and nothing else about an act is a layout decision. The numbers
  below are column positions on the twelve column grid and nothing else. They only apply above
  the frame breakpoint; narrower than that every frame is a single stacked column.
*/
@media (min-width: 901px) {
  /* Title card: the name at full width, then two columns of equal weight beneath it. */
  body.film [data-frame="title"] > .chapter-meta,
  body.film [data-frame="title"] > h1 {
    grid-column: 1 / -1;
  }

  body.film [data-frame="title"] > .chapter-line,
  body.film [data-frame="title"] > .hero-actions {
    grid-column: 1 / span 5;
  }

  /* The lede runs to the last column so it sets in three lines rather than five. */
  body.film [data-frame="title"] > .page-lede {
    grid-column: 7 / span 6;
  }

  body.film [data-frame="title"] > .case-video {
    grid-column: 7 / span 5;
  }

  body.film [data-frame="title"] > .hero-actions,
  body.film [data-frame="title"] > .case-video {
    align-self: start;
  }

  /*
    The component strip closes the title card. It was a section of its own between two acts,
    which meant 170px of content in a full height band and no way for the acts either side
    to overlap across it.
  */
  body.film [data-frame="title"] > .case-metrics {
    grid-column: 1 / -1;
    margin-top: var(--s5);
  }

  /* Pair: two equal columns, the second dropped a step so the frame is not a symmetric box. */
  body.film [data-frame="pair"] > :nth-child(1) {
    grid-column: 1 / span 9;
  }

  body.film [data-frame="pair"] > :nth-child(2) {
    grid-column: 6 / span 7;
    margin-top: var(--s6);
  }

  /* Sequence: a heading over a run of numbered steps that share one rule. */
  body.film [data-frame="sequence"] > .section-heading {
    grid-column: 1 / span 8;
  }

  body.film [data-frame="sequence"] > .case-flow {
    grid-column: 1 / -1;
  }

  /*
    Split: one wide column of argument against one narrow column of result, both flush at the
    top. The proportion is what distinguishes this frame from the pair above; adding a drop
    as well left an empty column down the middle and squeezed the result into 380px.
  */
  body.film [data-frame="split"] > :nth-child(1) {
    grid-column: 1 / span 7;
  }

  body.film [data-frame="split"] > :nth-child(2) {
    grid-column: 8 / span 5;
  }

  /* Coda: the closing address, with its exits held to the right. */
  body.film [data-frame="coda"] > .chapter-meta {
    grid-column: 1 / -1;
  }

  body.film [data-frame="coda"] > h2 {
    grid-column: 1 / span 7;
    /* The closing line is the last thing the chapter says and it stands opposite the two exits
       across a full-width row once the reader is past the chapter. At the ordinary h2 size it
       was 496px of ink with 450px of nothing between it and the buttons, which reads as a row
       that was not finished rather than as a closing address. This is the scale the file
       already keeps for a heading that owns its whole row. */
    font-size: var(--t-h2-wide);
  }

  /* The exits are right aligned here and only here, because this is the one layout that gives
     them a column of their own beside the heading. Held to the right they finish the row the
     heading opens. In any layout that stacks them under it they belong at the left margin like
     every other line, and the rule below that stacks them says so again. */
  body.film [data-frame="coda"] > .chapter-close-links {
    grid-column: 9 / span 4;
    align-self: end;
    justify-content: flex-end;
  }

  /* Single: one column, at the same measure as every frame above it. */
  body.film [data-frame="single"] > * {
    grid-column: 1 / -1;
  }
}



/* ============================================================================
   5. TYPE
   ========================================================================= */

body.film {
  font-family: var(--face-display);
  font-weight: 400;
  letter-spacing: -0.005em;
}

/*
  The metadata voice: mono, small, widely tracked. It is what separates a chapter from a
  marketing section, and it is the only voice on the page allowed to be uppercase.
*/
body.film .eyebrow,
body.film .chapter-meta,
body.film .case-step > span,
body.film .tag {
  font-family: var(--face-mono);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  font-size: var(--t-meta);
  /* Was 62%, which measured 2.9:1 against the page at this size: under the 4.5:1 floor for
     body text, and it has been the worst-contrast line on the chapter through six reviews.
     Small letter-spaced mono needs more ink than a paragraph does, not less. */
  color: color-mix(in srgb, var(--text) 86%, transparent);
}

body.film .chapter-meta {
  display: flex;
  align-items: center;
  gap: var(--s3);
  margin: 0;
  color: var(--scene-accent);
}

/* The rule finishes the line to the edge of the grid, which is what makes it read as a slate. */
body.film .chapter-meta::after {
  content: "";
  flex: 1;
  height: var(--film-rule);
  background: var(--scene-line);
}

body.film .eyebrow {
  margin: 0 0 var(--s3);
}

body.film h1,
body.film h2,
body.film h3 {
  font-family: var(--face-display);
  font-weight: 700;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

body.film .case-hero h1 {
  font-size: var(--t-h1);
  line-height: 0.92;
  margin: var(--s2) 0 var(--s3);
}

body.film h2 {
  font-size: var(--t-h2);
  line-height: 1.04;
  margin: 0 0 var(--s4);
}

/*
  A heading has to be sized against the column it sits in, not against the window. Sized off
  the viewport, one scale cannot serve both a heading that runs the full grid and one in a
  four column outcome panel: at 66px the narrow panel set one word per line, which is what a
  wall of orphans looks like. Container units give every panel the same optical size.
*/
body.film .case-panel {
  container-type: inline-size;
}

body.film .case-panel h2 {
  font-size: clamp(1.45rem, 8cqi, 3.2rem);
}

/* A heading that owns the whole grid can be the largest thing in the frame after the title. */
body.film .section-heading h2 {
  font-size: var(--t-h2-wide);
}

body.film h3 {
  font-size: var(--t-h3);
  line-height: 1.2;
  margin: 0 0 var(--s3);
}

/* One italic line per chapter, and only one. It is the chapter premise, not a subtitle. */
body.film .chapter-line {
  font-family: var(--face-italic);
  font-style: italic;
  font-weight: 400;
  font-size: clamp(1.35rem, 2.4vw, 2.05rem);
  line-height: 1.2;
  letter-spacing: 0;
  color: var(--text);
  margin: 0;
  text-wrap: balance;
}

body.film .page-lede,
body.film .case-panel p:not(.eyebrow),
body.film .case-step p:not(.eyebrow),
body.film .decision-list li,
body.film .footer-note {
  font-family: var(--face-display);
  font-size: var(--t-body);
  line-height: 1.65;
  color: var(--muted);
}

body.film .page-lede {
  font-size: var(--t-lede);
  line-height: 1.55;
  margin: 0;
}

body.film p {
  margin: 0;
}


/* ============================================================================
   6. COMPONENTS
   ========================================================================= */

/*
  Every boxed component on this page loses its box. Hairlines and space carry the structure
  instead, which is the largest single difference between a chapter and a card grid.
*/
/*
  The rule above each panel carries the act's colour.

  Every act writes --scene-accent, and until now only two things on the page resolved it: the
  step numerals, which exist in act three alone, and the primary button, which exists in act one
  alone. So two of the four acts changed the colour of their room and put none of it on the page
  beside it, which is the one place a reader can compare the two. This rule is in every act, in
  every panel, and it is a hairline, so it carries the hue without competing with the words.
*/
body.film .case-panel,
body.film .case-step,
body.film .case-metrics .stat-card {
  background: none;
  border: 0;
  border-top: var(--film-rule) solid color-mix(in srgb, var(--scene-accent) 52%, var(--scene-line));
  border-radius: 0;
  box-shadow: none;
  min-height: 0;
  padding: var(--s4) 0 0;
}

body.film .case-metrics .stat-card strong {
  display: block;
  font-family: var(--face-display);
  font-weight: 700;
  font-size: clamp(1.2rem, 1.9vw, 1.6rem);
  letter-spacing: -0.02em;
  color: var(--text);
  margin-bottom: var(--s2);
}

body.film .case-metrics .stat-card span {
  display: block;
  font-family: var(--face-display);
  font-size: var(--t-small);
  line-height: 1.45;
  text-transform: none;
  letter-spacing: 0;
  color: var(--muted);
}

/* The heading's own bottom margin would stack on top of the grid's row gap. */
body.film .section-heading {
  margin-bottom: 0;
}

/*
  The step run is the one genuinely spatial thing in this chapter, so it is drawn as a flow
  rather than set as four more columns of prose: one unbroken rule across the run, a node on
  the rule under each number, and the nodes arriving left to right as the act plays. Four
  separately ruled columns was the previous answer and it was indistinguishable from the
  definition row further up the page.
*/
body.film .case-flow {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  column-gap: var(--film-gap);
  align-items: start;
  padding-top: var(--s4);
}

body.film .case-flow::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: var(--film-rule);
  background: linear-gradient(to right, var(--scene-line), var(--scene-line) 86%, transparent);
}

body.film .case-flow .case-step {
  position: relative;
  border-top: 0;
  padding-top: var(--s4);
}

body.film .case-flow .case-step::before {
  content: "";
  position: absolute;
  left: 0;
  top: calc(var(--s4) * -1 - 3px);
  width: 7px;
  height: 7px;
  background: var(--scene-accent);
}

body.film .case-step > span {
  display: block;
  min-height: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  font-weight: 400;
  font-size: 1.15rem;
  letter-spacing: 0.08em;
  color: var(--scene-accent);
  margin-bottom: var(--s3);
}

body.film .decision-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

body.film .decision-list li {
  padding: var(--s3) 0 var(--s3) var(--s4);
  border-bottom: var(--film-rule) solid var(--scene-line);
  position: relative;
}

body.film .decision-list li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 1.55em;
  width: 0.6rem;
  height: var(--film-rule);
  background: var(--scene-accent);
}

body.film .tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--s4);
  margin-top: var(--s4);
  border-top: var(--film-rule) solid var(--scene-line);
  padding-top: var(--s3);
}

body.film .tag {
  position: relative;
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0 0 0 var(--s4);
}

body.film .tag::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 0.55rem;
  height: var(--film-rule);
  background: var(--scene-accent);
}

/*
  The video facade is a caption until it is a video. Held as an empty 16 by 9 frame it was
  the largest object in the title card and carried nothing: this video has no usable poster
  frame, so the frame was a rectangle of background with a button parked in it. It becomes a
  rule and a control, which is honest about being a link, and it takes its real proportions
  the moment it has something to show.
*/
body.film .case-video {
  position: relative;
  aspect-ratio: auto;
  min-height: 0;
  display: block;
  border: 0;
  border-top: var(--film-rule) solid var(--scene-line);
  border-radius: 0;
  padding: var(--s3) 0 0;
  background: none;
  box-shadow: none;
  isolation: isolate;
}

/* The facade's own decoration is a hardcoded blue wash that fights every theme, and there
   is no frame left for it to sit in. */
body.film .lite-video::before,
body.film .lite-video::after {
  content: none;
}

body.film .video-play {
  min-height: 0;
  border: 0;
  border-radius: 0;
  background: none;
  box-shadow: none;
  padding: 0;
  color: var(--scene-accent);
  font-family: var(--face-mono);
  font-size: var(--t-meta);
  text-transform: uppercase;
  letter-spacing: 0.18em;
}

/*
  A screen with no triangle in it.

  This control and the film's own play control sit on the same opening screen, in the same
  accent, in the same uppercase mono, and they do different things: this one opens a video and
  that one runs the chapter. Drawn as two bare triangles they measured 96% alike as pixels. A
  triangle inside a frame was the next answer, and a reviewer still read two play triangles. So
  the only triangle on the screen is the film's, and this one is a screen on its stand, with
  words that say what it opens.
*/
body.film .video-play-icon {
  width: 20px;
  height: 13px;
  border: 1px solid var(--scene-accent);
  border-radius: 0;
}

body.film .video-play-icon::after {
  bottom: -6px;
  width: 8px;
  height: 1px;
  background: var(--scene-accent);
}

body.film .hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
}

/*
  Site chrome. The shared header is a pale gradient bar with rounded pills, which fights a film
  grade, so film pages flatten it. Scoped to body.film, so removing this stylesheet restores
  the original chrome exactly and no other page changes.
*/
body.film .site-header {
  --nav-top-bg: transparent;
  --nav-top-text: var(--text);
  /*
    Opaque, and the blur is gone with it. At 95% the chapter's own title read straight through
    this bar as the reader left the first act: a ghost of "Cyber Sentinel" across the wordmark
    and the nav, measured as a luma spread of 5.2 in a band of the bar carrying none of its own
    ink, against 1 to 2 for the room's dither behind it. A backdrop blur softens what shows
    through and does not stop it, and once the bar is opaque there is nothing for it to blur.
  */
  background: var(--bg-deep);
  border-bottom: var(--film-rule) solid var(--scene-line);
}

body.film .site-nav .brand {
  font-family: var(--face-display);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text);
}

body.film .nav-links a,
body.film .mobile-nav a {
  font-family: var(--face-mono);
  font-size: var(--t-meta);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--muted);
}

body.film .nav-links a:hover,
body.film .nav-links a[aria-current="page"] {
  color: var(--scene-accent);
}

body.film .button,
body.film .nav-toggle,
body.film .style-toggle {
  border-radius: 0;
  font-family: var(--face-mono);
  font-size: var(--t-meta);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  border: var(--film-rule) solid var(--scene-line);
  background: none;
  color: var(--text);
}

body.film .button.primary {
  border-color: var(--scene-accent);
  color: var(--scene-accent);
}

/*
  The reset control is a button, so the blanket rule above stripped its fill and made a working
  control look disabled. It keeps a surface of its own.
*/
body.film [data-style-reset] {
  background: color-mix(in srgb, var(--scene-accent) 14%, transparent);
  border-color: var(--scene-accent);
  color: var(--text);
}

body.film .style-panel {
  border-radius: 0;
  border: var(--film-rule) solid var(--scene-line);
}

body.film .style-panel label,
body.film .style-panel .style-label {
  font-family: var(--face-mono);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-size: 0.66rem;
}

body.film .style-panel select {
  border-radius: 0;
  font-family: var(--face-display);
}

/*
  The tail. Without these the grade stops at the last act and the site design reappears
  underneath it: rounded comment boxes and a pale gradient footer.
*/
body.film [data-frame="single"] > .eyebrow {
  border-top: var(--film-rule) solid var(--scene-line);
  padding-top: var(--s3);
  margin-bottom: var(--s4);
}

body.film [data-comments] h2,
body.film [data-comments] h3 {
  font-family: var(--face-display);
  font-weight: 700;
  font-size: clamp(1.45rem, 2.9vw, 2.4rem);
  line-height: 1.06;
  letter-spacing: -0.03em;
  margin: 0 0 var(--s3);
}

body.film .comment-shell {
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0;
  box-shadow: none;
}

body.film .comment-note,
body.film .comment-status {
  font-family: var(--face-mono);
  font-size: var(--t-meta);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--muted);
}

body.film [data-comments] input,
body.film [data-comments] textarea {
  border-radius: 0;
  border: 0;
  border-bottom: 2px solid color-mix(in srgb, var(--text) 30%, transparent);
  background: none;
  padding-left: 0;
  padding-right: 0;
  color: var(--text);
  font-family: var(--face-display);
}

/* Placeholders at half strength on near black are a guess, not a label. */
body.film [data-comments] input::placeholder,
body.film [data-comments] textarea::placeholder {
  color: var(--muted);
  opacity: 0.9;
  font-family: var(--face-mono);
  font-size: var(--t-meta);
  letter-spacing: 0.06em;
}

body.film [data-comments] input:focus-visible,
body.film [data-comments] textarea:focus-visible {
  outline: none;
  border-bottom-color: var(--scene-accent);
}

body.film .comment-item,
body.film .comment-empty,
body.film [data-comments] > * {
  border-radius: 0;
  border-color: var(--scene-line);
  background: none;
  box-shadow: none;
}

body.film [data-comments] textarea {
  resize: vertical;
  min-height: 7rem;
}

body.film [data-comments] .comment-form {
  display: grid;
  gap: var(--s4);
  max-width: 46rem;
}

body.film [data-comments] .comment-form label {
  display: grid;
  gap: var(--s2);
}

body.film [data-comments] button {
  justify-self: start;
  padding: var(--s3) var(--s5);
}

body.film [data-comments] .comment-list {
  margin-top: var(--s6);
  display: grid;
  gap: var(--s4);
}

body.film [data-comments] .comment-item {
  border: 0;
  border-top: var(--film-rule) solid var(--scene-line);
  padding: var(--s4) 0 0;
}

body.film .site-footer {
  background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
  border-top: var(--film-rule) solid var(--scene-line);
}

body.film .site-footer h2 {
  font-size: clamp(1.4rem, 2.6vw, 2.1rem);
}

body.film .site-footer h3 {
  font-family: var(--face-mono);
  font-weight: 400;
  font-size: var(--t-meta);
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--muted);
}

body.film .site-footer a {
  font-family: var(--face-display);
}

body.film .chapter-close {
  padding-block: var(--s7) var(--s6);
}

body.film .chapter-close + .section {
  padding-top: 0;
}

/* The component strip carries the same four column rhythm as the step run below it. */
body.film .case-metrics {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  column-gap: var(--film-gap);
  row-gap: var(--s4);
  align-items: start;
}

/* The exits finish the grid rather than stopping short of it. */
body.film .chapter-close-links {
  display: flex;
  gap: var(--s2);
  flex-wrap: wrap;
  margin: 0;
}



/* ============================================================================
   7. CHROME
   ========================================================================= */

/*
  The running head. Something has to say which chapter this is at every scroll position,
  including the handoffs, where by design no section is legible. It sits in the gutter,
  which is the one band of the frame that provably never holds text at any width, and it
  reads vertically because a horizontal label at the foot of the screen is the reference
  page's device and this should not be.

  It sits AT the inner edge of the gutter, not centred in it. The gutter is
  max(--film-inset, (100vw - --max)/2), so it grows without limit as the window does: a
  container pinned to the edge and as wide as the gutter with its content centred put this
  57px from the edge at 1440 and 557px from it at 3440, which is a running head floating in
  the middle of the page. The same offset as the play control keeps the three pieces of edge
  chrome on one line, and at the 1181px width where this first appears the gutter is 59px
  wide against a 24px mark, so the promise that it never touches the copy still holds.
*/
.film-spine {
  position: fixed;
  left: var(--s4);
  top: 50%;
  transform: translateY(-50%);
  z-index: 24;
  display: grid;
  place-items: center;
  pointer-events: none;
}

.film-spine span {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  white-space: nowrap;
  font-family: var(--face-mono);
  /* 12px, the floor every label in the scene is held to. At 0.6rem it was 9.6px, the faintest
     type on the page, and a review read it as below the readable floor. */
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.32em;
  color: color-mix(in srgb, var(--text) 58%, transparent);
}

/* Below this the gutter is too narrow to hold it without touching the copy. */
@media (max-width: 1180px) {
  .film-spine {
    display: none;
  }
}

/*
  A soft matte under the header. Type scrolling behind fixed chrome has to go somewhere, and
  terminating on a hard horizontal edge reads as a rendering fault rather than as content
  passing underneath: on a phone, six frames in eight opened on a line of display type sliced
  through its x-height. Content dissolves into this instead of being cut by it.
*/
.film-matte {
  position: fixed;
  left: 0;
  right: 0;
  top: var(--film-header-h);
  height: 1.5rem;
  z-index: 24;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    var(--bg-deep) 0%,
    color-mix(in srgb, var(--bg-deep) 74%, transparent) 40%,
    color-mix(in srgb, var(--bg-deep) 34%, transparent) 72%,
    transparent 100%
  );
}

/*
  Reading progress, drawn as a hairline along the bottom edge of the header.

  What was here before and is not coming back: a caption bar pinned along the bottom of the
  screen carrying the act name and an N of N counter. It permanently occupied part of every
  viewport, it cut phone headings in half as they passed behind it, and together with a tick
  rail and a cue it accounted for most of what a reviewer recognised as another site's kit.

  A way to jump between acts, a cue and a play control have since returned, because a reader
  who wants the architecture should not have to scroll past everything before it, and a film
  with no way to play it is a page that merely moves. They are built to avoid what got the
  first versions removed: the rail lives in the gutter, which is the one band of the frame that
  provably never holds text, and shows a name only on hover or focus; the cue is gone a quarter
  of a screen in; and neither carries a number, per the owner's ruling of 2026-09-11.
*/
.film-progress {
  position: fixed;
  left: 0;
  top: var(--film-header-h);
  /* Above the top letterbox bar, not under it. Both are anchored to the bottom of the header:
     the bar is 14px of the page's own ground and was painted straight over this, so the film
     had a progress indicator that was invisible at every scroll position on every screen. It
     reads as a line running along the frame's top edge now, which is where a film would put
     it. */
  z-index: calc(var(--film-bar-z) + 1);
  height: 2px;
  width: var(--film-progress, 0%);
  background: var(--scene-accent);
  pointer-events: none;
}

/*
  ?still. A debug mode, and the only place on this site that reaches for !important: the point
  of it is that nothing is allowed to still be arriving, so it has to beat every transition on
  the page including ones written after it.
*/
body.film-still *,
body.film-still *::before,
body.film-still *::after {
  transition: none !important;
  animation: none !important;
}

/*
  Short screens get a tighter vertical rhythm.

  Every act is pinned to the height of the screen, and a pin taller than the screen does not
  hold at the top: it scrolls until its bottom is flush with the bottom of the viewport, which
  puts its heading above the fold and behind the header. At 1366x768 the opening act ran 35px
  over and the problem act 71px; at 1280x720 it was 67px and 115px. Both are ordinary laptop
  screens.

  The type already answers the screen's height, because every display size is clamped against
  vh as well as vw. The spacing was not, and it is what tips a frame over: the header gutter,
  the pad under the last line, and the gap between the two panels together come to more than a
  fifth of a 720px screen.

  Every margin and pad in this file is one of these eight steps, so the frame tightens as a
  whole rather than one value at a time being nudged until a number goes green. The three
  smallest steps are left alone: they set the space inside a line of type, not between blocks.
*/
@media (max-height: 880px) {
  body.film {
    --s4: 1.1rem;
    --s5: 1.6rem;
    --s6: 2.4rem;
    --s7: 3.8rem;
  }
}

@media (max-height: 790px) {
  body.film {
    --s3: 0.7rem;
    --s4: 0.9rem;
    --s5: 1.15rem;
    --s6: 1.5rem;
    --s7: 2.6rem;
  }

  /* The pin's own gutter above the copy, on top of the header it already reserves. On a short
     screen the header is a larger share of the frame, so the extra breathing room goes first. */
  body.film .act-pin {
    padding-top: calc(var(--film-header-h) + var(--s3));
  }
}

/*
  One rung further down, for a 720px screen. With the scene running the reading column is
  narrower than the plain page's, so copy wraps to more lines, and at 1280x720 the problem act
  ran 7px taller than the screen on the 790px rung alone. The same eight steps tighten again
  instead of one margin being nudged, so the frame still tightens as a whole.
*/
@media (max-height: 740px) {
  body.film {
    --s6: 1.3rem;
    --s7: 2.2rem;
  }
}

/*
  The frame's own edge. Two bars, and they are the reason the page reads as something being
  projected rather than as a document that moves. Deliberately thin: this is an edge, not
  cinema bars, and every pixel of height here is a pixel the acts have to fit inside.
*/
.film-bar {
  position: fixed;
  left: 0;
  right: 0;
  height: var(--film-bar-h);
  z-index: var(--film-bar-z);
  pointer-events: none;
  background: var(--bg-deep);
}

/*
  The top edge sits under the header, not behind it.

  At top: 0 it was a 14px bar at z-index 26 underneath a 65px fixed header at z-index 30, so it
  was never visible at any scroll position and the upper boundary of the film was the site's
  navigation bar. Putting it below the header gives the frame a real top edge without covering
  the nav, and it carries the same act-coloured hairline as the bottom one so the two read as a
  pair rather than as one bar and one accident.
*/
.film-bar[data-film-bar="top"] {
  top: var(--film-header-h);
  box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--scene-accent) 34%, transparent);
}

.film-bar[data-film-bar="bottom"] {
  bottom: 0;
  /* The lower edge gets a hairline of the act's own colour. It is the only place on the page
     where the room's colour meets the frame, and it is what stops the bar reading as the page
     simply ending. */
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--scene-accent) 34%, transparent);
}

/*
  The act rail. It lives in the right gutter, opposite the running head, and it is dots until
  a reader reaches for it. The name is absolutely positioned so a long one cannot widen the
  gutter and push the reading column.
*/
/* At the inner edge of the gutter rather than centred in it, for the reason on .film-spine:
   centring walked the dots 340px in from the right edge of a 2560px screen. */
.film-act-nav {
  position: fixed;
  right: var(--s4);
  top: 50%;
  transform: translateY(-50%);
  z-index: 26;
  display: grid;
  justify-items: center;
  gap: var(--s3);
}

.film-act-nav-item {
  position: relative;
  display: grid;
  place-items: center;
  padding: 0.35rem 0.5rem;
  text-decoration: none;
  border: 0;
}

.film-act-nav-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--text) 42%, transparent);
  background: transparent;
  transition: background 220ms ease, border-color 220ms ease, transform 220ms ease;
}

.film-act-nav-item[aria-current="true"] .film-act-nav-dot {
  background: var(--scene-accent);
  border-color: var(--scene-accent);
  transform: scale(1.5);
}

.film-act-nav-item:hover .film-act-nav-dot,
.film-act-nav-item:focus-visible .film-act-nav-dot {
  border-color: var(--scene-accent);
}

.film-act-nav-label {
  position: absolute;
  right: calc(100% + var(--s2));
  white-space: nowrap;
  font-family: var(--face-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.24em;
  color: color-mix(in srgb, var(--text) 78%, transparent);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms ease;
}

.film-act-nav-item:hover .film-act-nav-label,
.film-act-nav-item:focus-visible .film-act-nav-label {
  opacity: 1;
}

/* Same rule as the running head: below this the gutter cannot hold it clear of the copy. */
@media (max-width: 1180px) {
  .film-act-nav {
    display: none;
  }
}

/*
  The cue on the first screen. A title card looks finished, and this says it is not. It is
  written to be gone rather than dismissed: film.js drops --cue-shown to 0 a quarter of a
  screen in and it never comes back.
*/
.film-scroll-cue {
  position: fixed;
  left: 50%;
  bottom: calc(14px + var(--s4));
  transform: translateX(-50%);
  z-index: 26;
  display: grid;
  justify-items: center;
  gap: var(--s2);
  pointer-events: none;
  font-family: var(--face-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.3em;
  color: color-mix(in srgb, var(--text) 62%, transparent);
  opacity: var(--cue-shown, 1);
  transition: opacity 420ms ease;
}

.film-scroll-cue-line {
  width: 1px;
  height: 34px;
  background: linear-gradient(to bottom, var(--scene-accent), transparent);
}

/*
  A shorter tick on a short screen, because the band the title frame reserves for the cue is
  reserved out of a frame that has to fit as well. At 1440x768 the full length still left the
  cue 5px inside the last stat card, and taking the frame's own padding any further is what
  makes act one taller than the screen: checkFilmFrameFit fails at 1440x900 seven pixels in.
  The cue says the page scrolls either way.
*/
@media (max-height: 820px) {
  .film-scroll-cue-line {
    height: 16px;
  }
}

/*
  Below this there is no band left to stand in. At 1440x720 even the short tick sits 9px inside
  the last stat card, and the frame cannot give up more height: act one is already within seven
  pixels of being taller than a 1440x900 screen. A cue printed on the copy is worse than no cue,
  and a 720px tall window is one a reader is already scrolling.
*/
@media (max-height: 745px) {
  .film-scroll-cue {
    display: none;
  }
}

/*
  The play control. Bottom right, out of the reading column at every width, and it says what
  it will do in a word as well as in a shape.
*/
.film-play {
  position: fixed;
  right: var(--s4);
  bottom: calc(14px + var(--s3));
  z-index: 27;
  display: grid;
  grid-auto-flow: column;
  align-items: center;
  gap: var(--s2);
  padding: 0.5rem 0.85rem;
  border: 1px solid color-mix(in srgb, var(--scene-accent) 52%, transparent);
  border-radius: 0;
  background: color-mix(in srgb, var(--bg-deep) 82%, transparent);
  color: var(--scene-accent);
  font-family: var(--face-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  cursor: pointer;
  transition: border-color 200ms ease, background 200ms ease;
}

/*
  Gone once the reader is past the last act, because there is nothing left for it to play. It
  used to sit there in full view at the foot of the page and scroll whoever pressed it 932px
  back up to the bottom of the chapter, which is the one thing a play control must never do.
*/
body.film.past-chapter .film-play {
  opacity: 0;
  pointer-events: none;
  visibility: hidden;
}

/*
  The act rail goes for the same reason, and it was left behind when the play control was dealt
  with. Past the last act it sat in the right gutter beside the comment form as four hollow dots
  with none of them current, which is a control describing a thing that is no longer on screen.
  film.js already drops aria-current there; this is the other half of it.
*/
body.film.past-chapter .film-act-nav {
  opacity: 0;
  pointer-events: none;
  visibility: hidden;
}

.film-play:hover,
.film-play:focus-visible {
  border-color: var(--scene-accent);
  background: color-mix(in srgb, var(--scene-accent) 12%, var(--bg-deep));
}

.film-play-icon {
  width: 0;
  height: 0;
  border-left: 7px solid currentColor;
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
}

/* While it is playing the shape is a stop, because that is what pressing it now does. */
body.is-playing .film-play-icon {
  width: 8px;
  height: 8px;
  border: 0;
  background: currentColor;
}

/* ============================================================================
   8. MOTION
   ========================================================================= */

/*
  Everything that moves lives below this line, inside a reduced-motion query, so the static
  rendering above is the real design. Firefox still ships scroll-driven animation behind a
  flag, so the choreography is driven by film.js writing --act rather than by CSS timelines.
  All of it also depends on .is-choreographed, which only JavaScript adds.
*/
@media (min-width: 901px) and (min-height: 641px) and (prefers-reduced-motion: no-preference) {
  /*
    An act holds at full presence for the whole time it is on screen. It does not fade at
    all: it leaves by scrolling away, the way a pinned block does once its wrapper runs out.
  */
  /*
    Arrival, and it latches. Each part of a frame comes in on its own beat when film.js adds
    .is-arrived to the pin, and nothing takes that class off again.

    It used to be a clamp over --phase, a value recomputed from the act's position on every
    scroll frame. A scroll-derived property runs backwards as readily as forwards, so
    scrolling back a little took every paragraph part of the way out again and the reader
    watched the text twitch against the scrollbar. A class added once cannot do that. The old
    arrival also spanned 0.08 of a 108px phase, so it happened inside nine pixels of
    scrolling: a flash, not choreography, and twenty-nine pixels separated a full frame from
    an empty one.

    All of this is inside is-choreographed and inside a reduced motion query, so with no
    JavaScript, or with motion turned down, every word is on screen from the start.
  */
  body.film.is-choreographed .act-pin .case-flow::before {
    transform: scaleX(0);
    transform-origin: left;
    transition: transform 900ms cubic-bezier(0.2, 0.65, 0.25, 1) 140ms;
  }

  body.film.is-choreographed .act-pin.is-arrived .case-flow::before {
    transform: scaleX(1);
  }

  body.film.is-choreographed .act-pin [data-frame]:not([data-frame="title"]) > *:not(.case-flow),
  body.film.is-choreographed .act-pin .case-flow > * {
    opacity: 0;
    transform: translateY(1.6rem);
    /* The beat is a delay now rather than an offset into a scroll span, so the stagger is
       read in time and does not depend on how fast the reader is scrolling. */
    transition:
      opacity 640ms ease calc(var(--beat, 0) * 90ms),
      transform 640ms cubic-bezier(0.2, 0.65, 0.25, 1) calc(var(--beat, 0) * 90ms);
    will-change: opacity, transform;
  }

  body.film.is-choreographed .act-pin.is-arrived [data-frame]:not([data-frame="title"]) > *:not(.case-flow),
  body.film.is-choreographed .act-pin.is-arrived .case-flow > * {
    opacity: 1;
    transform: none;
  }

  /*
    The title card is exempt. It is the first thing on the page and there is no scroll behind
    it, so timing its arrival to scroll means landing on a half drawn hero.

    Beats. Six is more parts than any frame here has, and an unlisted part simply leads.
  */
  body.film.is-choreographed .act-pin [data-frame] > *:nth-child(2),
  body.film.is-choreographed .act-pin .case-flow > *:nth-child(1) { --beat: 1; }
  body.film.is-choreographed .act-pin [data-frame] > *:nth-child(3),
  body.film.is-choreographed .act-pin .case-flow > *:nth-child(2) { --beat: 2; }
  body.film.is-choreographed .act-pin [data-frame] > *:nth-child(4),
  body.film.is-choreographed .act-pin .case-flow > *:nth-child(3) { --beat: 3; }
  body.film.is-choreographed .act-pin [data-frame] > *:nth-child(5),
  body.film.is-choreographed .act-pin .case-flow > *:nth-child(4) { --beat: 4; }
  body.film.is-choreographed .act-pin [data-frame] > *:nth-child(n + 6) { --beat: 5; }

  /*
    Nothing here moves the copy. The pin is sticky, so the browser already holds it in place
    while its act owns the scroll and lets it travel away afterwards.

    Three things used to move it and all three are gone. An exit fade on --exit existed only
    to keep two overlapping acts off each other's pixels, and the acts no longer overlap. A
    dolly on --act pushed the copy slowly through the whole hold, which is the same reported
    fault as the reveal: a held frame is held. And --enter translated an arriving frame up by
    however far its pin still had to travel, so that it was drawn where it would eventually
    hold; without the overlap and the fade to cover it, that lifted the incoming act's copy
    straight on top of the outgoing act's, 720px of it at the worst depth measured.
  */


  /*
    The numeral is what the pin transforms. It drifts across the frame and grows as the act
    runs, so being held in place still shows the reader something moving, which was the whole
    point of pinning. A pin that transforms nothing is a stuck page.
  */
  /*
    What a pin transforms is the space, not an object in it. The floor and ceiling keep
    rushing past while the copy is held still, and the key light swells as an act runs, so
    being held in place still reads as travelling forward. Rejected alternative: a graphic
    object inside the frame, twice. One collided with the copy and one was read by two
    separate reviewers as a broken form control; a continuous field can be neither.
  */
  body.film.is-choreographed .film-stage-glow {
    transform:
      translate(-50%, -50%)
      translateX(calc((var(--act-now, 0) - 0.5) * 26vw))
      scale(calc(0.78 + var(--act-now, 0) * 0.5));
    opacity: calc(var(--stage-brightness, 1) * (0.6 + var(--act-now, 0) * 0.4));
    will-change: transform, opacity;
  }

  body.film.is-choreographed .nav-links a {
    opacity: 0.62;
    transition: opacity 220ms ease;
  }

  body.film .site-header:hover .nav-links a,
  body.film .site-header:focus-within .nav-links a,
  body.film .nav-links a:hover,
  body.film .nav-links a[aria-current="page"] {
    opacity: 1;
  }

  .film-progress {
    transition: width 180ms linear;
  }

  /* The cut between two rooms. Long enough to read as a camera move, short enough that it
     has finished before the incoming frame's copy has arrived. */
  .film-room {
    transition: transform 700ms cubic-bezier(0.2, 0.7, 0.2, 1);
  }

  .film-grade {
    transition: perspective 700ms cubic-bezier(0.2, 0.7, 0.2, 1);
  }

  .film-stage-ceiling {
    transition: opacity 700ms ease;
  }

  .film-stage-glow {
    transition: width 700ms ease, height 700ms ease;
  }

}


/* ============================================================================
   9. NARROW AND SHORT VIEWPORTS
   ========================================================================= */

/*
  Below this width, or on a screen too short to hold a frame, acts stop pinning and the
  chapter becomes an ordinary vertical read. The same happens for a reader who asked for
  reduced motion, which is why the query lists all three together.
*/
@media (min-width: 901px) and (min-height: 641px) {
  /*
    Two systems can animate an element in, and only one may. Above this breakpoint the act
    choreography owns arrival, so the site-wide reveal observer is switched off for anything
    inside an act; below it, the reveal observer is the only one running.
  */
  body.film .act .reveal {
    opacity: 1;
    transform: none;
    /* Its transition has to go as well as its values. Left in place it applies to the act
       choreography's own opacity, which is written on every scroll frame, so arrival lags
       the scroll by half a second and settles at whatever the reader stopped at. */
    transition: none;
  }
}

@media (max-width: 900px), (max-height: 640px), (prefers-reduced-motion: reduce) {
  .act {
    height: auto;
  }

  /* A display headline half dissolved through a translucent bar reads as a rendering fault
     rather than as content scrolling behind chrome. On a phone the bar is solid, and the
     matte below it is long enough to dissolve a line of display type rather than clip it. */
  body.film .site-header {
    background: var(--bg-deep);
  }

  body.film .act .act-pin {
    min-height: 100svh;
  }

  body.film .act-pin {
    position: static;
    min-height: 0;
    display: block;
    overflow: visible;
    padding-top: var(--s6);
    padding-bottom: var(--s6);
  }

  /* With no pin reserving it, the fixed header sits on top of the first line of the page. It
     is 65px at every width, so main clears it once rather than every act clearing it. */
  body.film main {
    padding-top: var(--film-header-h);
  }

  /*
    One composed section at a time, on the phone too. Sticky pinning is the wrong mechanism
    here, because a frame whose content is taller than a phone screen would have its foot
    clipped and unreachable, so each act simply claims a screen and centres what it holds.
    A section with more in it than that grows instead of being cut.

    Rejected alternative: scroll snapping. The scroll container on this page is the document,
    and snap on the document scroller behaves differently across the engines this site is
    read in, which is not a thing to discover from a recruiter's phone.
  */
  body.film .act .act-pin {
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: calc(100svh - var(--film-header-h));
  }

  /* The rooms are the same rooms, seen from closer: at 390px the grid cells and the
     convergence of a 620px perspective are both too large for anything to read as a space. */
  /* Scaling the room down shrinks its ruling too, and at 0.52 the lattice ran at roughly
     the line height of the body copy and interfered with it. */
  .film-room {
    transform: translateY(calc(var(--stage-lift) * 0.6)) scale(0.78);
  }

  .film-stage-wall {
    opacity: 0.3;
  }

  /*
    The title card is still a title card on a phone. Unwound into the same even stack as
    every other section it had no opening at all, which is most of what separates a chapter
    from a dark blog post on the device most of these links are opened on.
  */
  body.film .case-hero.act-pin {
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: calc(100svh - var(--film-header-h));
    padding-top: var(--s5);
  }

  body.film .case-hero h1 {
    font-size: clamp(3rem, 13vw, 4.5rem);
  }

  body.film [data-frame="title"] > .case-metrics {
    margin-top: var(--s6);
  }

  body.film .act-pin::before {
    font-size: clamp(9rem, 30vw, 16rem);
    opacity: 0.7;
  }

}

/* The step run and the component strip are inner grids, not frames, so they collapse here. */
@media (max-width: 900px) {
  body.film .case-flow,
  body.film .case-metrics {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    row-gap: var(--s5);
  }

  /* One rule per step instead of one rule across the run: the connector spans only the
     first row, so at two columns its nodes would hang under later rows on nothing. */
  body.film .case-flow {
    padding-top: 0;
  }

  body.film .case-flow::before,
  body.film .case-flow .case-step::before {
    content: none;
  }

  body.film .case-flow .case-step {
    border-top: var(--film-rule) solid var(--scene-line);
  }
}

/* Three lines of tracked mono is a paragraph, not a slate. */
@media (max-width: 700px) {
  body.film .chapter-meta {
    letter-spacing: 0.12em;
    font-size: 0.6rem;
  }
}

@media (max-width: 420px) {
  body.film .case-flow,
  body.film .case-metrics {
    grid-template-columns: minmax(0, 1fr);
  }
}

/*
  Four places, not four hues. The floor and ceiling are the same two planes throughout; what
  changes per act is how they are ruled, and each ruling behaves differently once the plane
  is moving: lines along the direction of travel rush, lines across it sweep past. This is a
  field rather than an object, so it cannot collide with the copy at any scroll position and
  cannot be mistaken for a broken element, which two earlier attempts at scene objects were.
*/

/* Streams: runs along the direction of travel, converging on the vanishing point. */
body.film[data-scene="streams"] .film-stage-wall {
  background-image: repeating-linear-gradient(to right, var(--scene-line-strong) 0 1px, transparent 1px 40px);
}

body.film[data-scene="streams"] .film-stage-floor,
body.film[data-scene="streams"] .film-stage-ceiling {
  background-image: repeating-linear-gradient(to right, var(--scene-line-strong) 0 1px, transparent 1px 40px);
}

/* Lattice: rules meeting rules. */
body.film[data-scene="lattice"] .film-stage-wall {
  background-image:
    repeating-linear-gradient(58deg, var(--scene-line-strong) 0 1px, transparent 1px 72px),
    repeating-linear-gradient(-58deg, var(--scene-line-strong) 0 1px, transparent 1px 72px);
}

body.film[data-scene="lattice"] .film-stage-floor,
body.film[data-scene="lattice"] .film-stage-ceiling {
  background-image:
    repeating-linear-gradient(58deg, var(--scene-line-strong) 0 1px, transparent 1px 72px),
    repeating-linear-gradient(-58deg, var(--scene-line-strong) 0 1px, transparent 1px 72px);
}

/* Strata: bands across the direction of travel, so they sweep toward the reader. */
body.film[data-scene="strata"] .film-stage-wall {
  background-image: repeating-linear-gradient(to bottom, var(--scene-line-strong) 0 2px, transparent 2px 58px);
}

body.film[data-scene="strata"] .film-stage-floor,
body.film[data-scene="strata"] .film-stage-ceiling {
  background-image: repeating-linear-gradient(to bottom, var(--scene-line-strong) 0 2px, transparent 2px 58px);
}

/* The ceiling is not a constant either: it closes in over the last act and lifts over the
   third, which is the cheapest way to make two acts feel like two different rooms. */
body.film[data-scene="lattice"] .film-stage-ceiling {
  opacity: 0.4;
}

body.film[data-scene="strata"] .film-stage-ceiling {
  opacity: 0.12;
}

/* The key light is in a different place in each act. */
body.film[data-scene="streams"] .film-stage-glow {
  width: min(38rem, 62vw);
  height: 30rem;
}

body.film[data-scene="lattice"] .film-stage-glow {
  width: min(74rem, 96vw);
  height: 20rem;
}

body.film[data-scene="strata"] .film-stage-glow {
  width: min(44rem, 70vw);
  height: 34rem;
}

/*
  On a phone the header stops floating. There is no scroll position at which a fixed bar
  does not sit on top of something, and on a 390px screen that something is a display
  headline four times out of eight; a matte turns the cut into a fade but cannot remove it.
  A chapter is read top to bottom and the navigation is one footer away.
*/
@media (max-width: 900px) {
  body.film .site-header {
    position: static;
  }

  /* Nothing is fixed over the page, so there is nothing to dissolve into, and the progress
     hairline belongs at the top of the viewport rather than under a bar that has gone. */
  .film-matte {
    display: none;
  }

  .film-progress {
    top: 0;
  }

  body.film main {
    padding-top: 0;
  }
}

/* ============================================================================
   THE 3D LAYER
   ============================================================================
   The chapter's sets are drawn by assets/js/scene/, on one canvas behind the
   document. Everything in this block is about what happens to the CSS stage
   above once that canvas is actually painting.

   .scene-live is added by the engine after its first successful frame, never
   before. So a browser with no WebGL, a module that fails to parse, or a
   device the engine turns itself down on all keep the CSS stage and the
   chapter reads exactly as it did. That is the fallback, and it is the page
   that currently ships rather than something built for the occasion.
*/
.chapter-scene {
  position: fixed;
  inset: 0;
  z-index: 0;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0;
}

/*
  No transition here. The engine writes this element's opacity every frame to fade the scene
  out as the chapter ends, and a 900ms transition on the same property turns each of those
  writes into a chase: the scene was still at 0.41 three screens after the last act, painting
  behind the comment form. The fade in at the start is the engine's own, in the same place.
*/

/*
  The receding floor, the ceiling, the side walls and the stage glow were a CSS
  attempt at the depth the canvas now draws properly. Two grids stacked on each
  other is why the owner's verdict on the last version was "filled with GRIDs
  everywhere". They stay in the markup because they are the fallback; they are
  simply not drawn on top of a scene that has real geometry in it.

  The vignette and the grain are not among them. They belong to .film-grade
  itself, which follows the canvas in the document at the same z-index, so they
  are painted over the scene and stay on while it runs.
*/
/*
  The header's backdrop blur costs nothing over a static page and a great deal over a canvas
  that repaints every frame: the blur is recomputed across the full width of the header on
  each of them. Its own background is already 95% opaque, so it was buying five per cent of
  translucency for a per-frame full-width filter.
*/
body.film.scene-live .site-header {
  backdrop-filter: none;
}

body.scene-live .film-room,
body.scene-live .film-stage-glow {
  /* Removed from the page, not faded out. At opacity 0 the floor, the ceiling, both walls
     and the glow are still laid out, still transformed in 3D and still painted every frame,
     behind a canvas that covers them completely. Taking them out of the tree was worth
     twenty frames a second under a 4x CPU throttle. */
  display: none;
}

/*
  Type needs a ground to sit on once there is a moving scene behind it, or the
  copy reads as floating over a video.

  It is a background on the pinned section, not an overlay of any kind. Two
  earlier attempts put it on a pseudo-element of the copy block, first at
  z-index -1 and then at 0 with the children lifted to 1, and both painted over
  the words. A test that filled the scrim with flat red is what settled it. A
  background-image cannot have that argument: backgrounds paint before content,
  always, with no stacking context to reason about.

  The ellipse sits over the copy column and fades out before the middle of the
  frame, so the scene keeps the rest of the screen and the camera can fly into a
  set instead of having to stay out of the way of the text.

  Sized to where the copy actually is, measured rather than guessed. At 1440 the
  four acts put their words inside x 5% to 52%, 5% to 31%, 5% to 59% and 5% to
  52% of the frame, and down to y 91% in act two. At 38% wide this was fully
  transparent by x 57%, which is short of act three's four step columns: their
  last two ran out past the edge of it and printed over the Elasticsearch
  cylinder's ribs. A reviewer found that, and the same fault in act one, where a
  board edge crosses the lede, and in act two behind the headline.

  Contrast was never the problem and is not what this fixes: those frames measure
  4.85:1 against a floor of 4.5 and 0.05 of the line's own edge against a ceiling
  of 0.10. The words are legible. They were sitting on a busy pattern, which is a
  composition fault, and the standard answer for text over busy imagery is a scrim
  that actually covers the text.

  Light, at 40% and 20%, with a halo on the words doing the rest. At 92% and 72%
  the ellipse also covered the space beside the copy where act three's labels
  sit, and darkened their words over their own dark plates: Elasticsearch read
  1.82:1, Logstash 2.37:1 and alerts.json 2.52:1 against 4.5, and every label
  passed with the scrim switched off. A label carries its own ground, the way a
  map label carries a mask, so the copy's protection must not reach it. The halo
  follows the letters rather than a box: a soft ground under each block was
  tried and drew a visible dark rectangle behind every heading and caption. What
  the halo leaves of the scene inside a line, the fade in engine.js takes away.
  Decided by the assistant after research, at the owner's delegation,
  2026-10-05; measured by checkFilmLabelContrast and checkFilmTextClear.
*/
body.scene-live .act > section {
  background-image: radial-gradient(
    ellipse 46% 64% at 28% 49%,
    color-mix(in srgb, var(--bg-deep, #060b12) 40%, transparent) 0%,
    color-mix(in srgb, var(--bg-deep, #060b12) 20%, transparent) 56%,
    transparent 82%
  );
}
body.scene-live main .act {
  text-shadow:
    0 0 4px var(--bg-deep, #060b12),
    0 0 12px color-mix(in srgb, var(--bg-deep, #060b12) 95%, transparent),
    0 0 32px color-mix(in srgb, var(--bg-deep, #060b12) 85%, transparent);
}

/*
  The reading column.

  This used to be an art lane: the copy took columns one to eight and the set
  was pushed into nine to twelve. That is why the owner's verdict was that the
  model "moves towards the right side when scrolling in" instead of being
  something you go into. A set parked in a quarter of the frame can only ever
  slide past the edge of it.

  So the copy narrows to about 640px, which is half the grid, and the rest of
  the frame belongs to the scene. The camera aims most of the way at each set
  rather than a fifth of the way, and the sets sit near the middle of the track,
  so scrolling carries the reader into them. The scrim above keeps the words
  legible while geometry passes behind and around them.

  With no WebGL none of this applies and the frames keep their full measure,
  which is the layout that ships today.
*/
@media (min-width: 901px) {
  body.film.scene-live [data-frame="title"] > .chapter-meta,
  body.film.scene-live [data-frame="title"] > h1,
  body.film.scene-live [data-frame="title"] > .chapter-line,
  body.film.scene-live [data-frame="title"] > .page-lede,
  body.film.scene-live [data-frame="title"] > .hero-actions,
  body.film.scene-live [data-frame="title"] > .case-video {
    grid-column: 1 / span 7;
  }

  /*
    The two horizontal strips keep the full measure. Folded into six columns they stack two by
    two, which added about 190px to a frame that has 900px to fit in, and the act stopped
    being fully composed at a fifth of its scroll depths. They sit low in the frame where the
    set is not, so their width costs the scene nothing.
  */
  body.film.scene-live [data-frame="title"] > .case-metrics,
  body.film.scene-live [data-frame="sequence"] > .case-flow {
    /* Wider than the reading column because four cards in six columns stack two by two and
       add two hundred pixels to a frame that has nine hundred to fit in. Not the full twelve,
       though: at full width the right-hand cards sit in the set's own third of the frame, and
       a set passing behind them measured three times the edge budget inside the word
       "Detect".

       Seven columns was tried, to get the fourth card away from the next act's set as it
       approaches. It made that worse rather than better: the cards wrap to shorter lines, so
       the same overlap covers a larger share of one. The approaching set dims instead. */
    grid-column: 1 / span 8;
  }

  /* The argument and the result stay side by side. Stacked in one narrow column they were
     nearly two hundred pixels taller than the frame they live in. */
  body.film.scene-live [data-frame="split"] > :nth-child(1) {
    grid-column: 1 / span 4;
  }

  /*
    Act two's two panels, stacked, in half the grid.

    In four columns, the same as act four's, they were the narrowest copy on the page with no
    second column beside them: a 376px strip with 30px headings and 42 characters a line, against
    act one's 778px and 59. The owner's word for it was empty. Six columns sets the paragraphs at
    about 60 characters, inside the 50 to 75 that reads best, and the headings grow with the
    panel because they are sized to it.
  */
  body.film.scene-live [data-frame="pair"] > :nth-child(1),
  body.film.scene-live [data-frame="pair"] > :nth-child(2) {
    grid-column: 1 / span 6;
  }

  /*
    The architecture heading gets six columns, not four.
    At four it was 376px wide holding 55.8px type, which set "A practical monitoring flow."
    as six lines with the word "flow." alone on the last one, and the top of it was clipped by
    the header during the handoff. Six columns sets the same words in three lines and still
    stops well short of the lane the set uses.
  */
  body.film.scene-live [data-frame="sequence"] > .section-heading {
    grid-column: 1 / span 6;
  }

  /* The result column. Three, not two.

     Two was tried, to hand the set the 86px it needed to keep the Kibana dashboard off the
     copy at the handoff. It bought that at a price nobody would take: the paragraph in this
     column came out at 19 characters a line. A column narrowed until the set clears it is a
     column nobody wants to read, and the overlap it was avoiding is handled where it belongs,
     by when the screens light up.

     Running it to column eight, which is where it started, put the dashboard behind the last
     two lines of the same paragraph. */
  body.film.scene-live [data-frame="split"] > :nth-child(2) {
    grid-column: 5 / span 3;
  }

  /*
    The reading lane exists so a set has somewhere to be. Below the last act there is no set:
    the canvas has faded out entirely by then, and holding the copy in six of twelve columns
    left the closing address and the whole comment thread in the left half of the frame with
    the right 55% empty. Past the chapter the page is an ordinary page again.
  */
  body.film.scene-live.past-chapter [data-frame="coda"] > .chapter-meta,
  body.film.scene-live.past-chapter [data-frame="coda"] > h2,
  body.film.scene-live.past-chapter [data-frame="coda"] > .chapter-close-links,
  body.film.scene-live.past-chapter [data-frame="single"] > * {
    grid-column: 1 / span 12;
  }

  body.film.scene-live [data-frame="coda"] > .chapter-meta,
  body.film.scene-live [data-frame="coda"] > h2,
  body.film.scene-live [data-frame="coda"] > .chapter-close-links,
  body.film.scene-live [data-frame="single"] > * {
    grid-column: 1 / span 6;
  }

  /* Both rules above put the exits on a row of their own under the heading rather than in a
     column beside it, so the right hand alignment the other layout wants would read here as an
     accidental indent: a full width heading, then two small buttons a long way from it with
     nothing in between. Alignment travels with the column each layout grants. */
  body.film.scene-live [data-frame="coda"] > .chapter-close-links {
    justify-content: flex-start;
  }

}

/*
  Phones.

  There is no art lane on a 390px screen: the copy is the whole width, so a set
  beside it is a set behind it. Rather than pretend otherwise, the scene becomes
  a backdrop there, held well back and washed down, and the words keep the
  foreground. The engine already centres the sets and stands the camera further
  off below 820px; this is the other half of that decision.
*/
@media (max-width: 900px) {
  /*
    No reserved band, in the end.

    Reserving the top of the title card for the set works at the top of the page and nowhere
    else: below the frame breakpoint the acts are ordinary flow, so the copy scrolls up
    through whatever band is reserved for it and lands on the set anyway. Three attempts at
    widening the band made the measurement worse, not better.

    What works on a phone is what the measurements support: the camera holds one height so
    every set stays in the upper part of the window, the copy carries its own ground, and the
    track's log rows and packet ticks are dropped because they are the only things with hard
    edges that were never inside a set and so never moved out of the way.
  */

  /* The copy's ground is a full ellipse here rather than one weighted to the left, and it
     sits low, under the copy, so the band above it stays clear. */
  body.scene-live .act > section {
    background-image: radial-gradient(
      ellipse 108% 62% at 50% 58%,
      color-mix(in srgb, var(--bg-deep, #060b12) 94%, transparent) 0%,
      color-mix(in srgb, var(--bg-deep, #060b12) 78%, transparent) 58%,
      transparent 88%
    );
  }
}

/*
  The fade a film playing itself goes through when it loops from the end of its last act back to its first (film.js). It covers the
  scene and the copy while the page jumps, so the reader sees the page dim and come back rather than every act racing past. Above the
  scene and the copy, under the header, the bars and the controls.
*/
.film-cut {
  position: fixed;
  inset: 0;
  z-index: 20;
  background: var(--bg-deep, #060b12);
  opacity: 0;
  pointer-events: none;
  transition: opacity 450ms ease;
}

.film-cut.is-shown {
  opacity: 1;
}
