/* ============================================================================
   /the-gap/ — Sophicly funnel  (WD-305 / WD-308 / WD-309)
   ----------------------------------------------------------------------------
   FOURTH BUILD. Rejected before as: wrong argument · not our style · "too
   plain, not creative, far too many words".

   THE CALL (Neil: "wwad"). Not the Evidence Board — a corkboard case-file skin
   is a COSTUME; it decorates the argument instead of being it, and it fights a
   scholarly, calm brand. Apple gives the real object the screen. Our real
   object is AQA's own mark scheme, where the distance between a mid grade and
   a top grade is THREE WORDS. So: type is the hero, one idea per screen, and
   the swap ANIMATES — Level 3's word is wiped away and Level 4's rises behind
   it. That is the argument, performed rather than described.

   Taken from the awwwards tier (Lusion / Immersive Garden / Resn) only what
   transfers to a text page with no WebGL: display type carrying the page, one
   beat per viewport, and reveals by MASK rather than fade.

   BRAND (BRAND.md, read not guessed):
   · ⛔ zero borders — surfaces separate by contrast + purple-tinted shadow
   · teal is the default accent for new work; purple = attainment, so it appears
     only on the Level 4 band, which has earned it
   · Proxima Soft + Playfair italic, read from the THEME's own tokens
   · DISPLAY tier (39–76px, fluid) because this is a marketing page
   · 4px scale, radius 8/12, one arrival curve cubic-bezier(.23,1,.32,1),
     two duration tiers 6× apart
   ⛔ Tokens live on the component root, never :root (landmine #3).
   ============================================================================ */

.swf {
  /* ⭐⭐ THE BRAND DARK LADDER (Neil, 2026-08-19: "the main dark theme colour should be
     1c1d1f… the other surfaces in dark theme should just be shades of that. Why are we not
     using that?"). BRAND.md §Dark Base backs him: `#1c1d1f` is the canonical dark-mode base,
     neutral, NOT purple-tinted, and raised-on-base steps up to `#242628`. The page now sits
     ON the brand base and every other surface is a shade of it. */
  --swf-page:      #1c1d1f;   /* Dark Base — the ground */
  --swf-base:      #242628;   /* one step up: the 4-6 box */
  --swf-raised:    #2b2c2f;   /* two steps up: the 7-9 box */
  --swf-ink:       #e0e0ee;
  --swf-ink-soft:  #9da5b4;

  --swf-teal:      #51dacf;
  --swf-mint:      #7DF9E9;
  --swf-purple-lt: #a78bfa;
  --swf-absent:    #9ca3af;
  --swf-btn-ink:   #1C1D1F;
  /* the footer highlighter's own pair, carried verbatim from that port — a pale lilac block
     with near-black ink on it. It reads on both grounds, so it is ONE value, not a theme pair. */
  --swf-marker:     #ded6fb;
  --swf-marker-ink: #1C1D1F;

  --swf-font:    var(--font-family--body, 'Proxima Soft Complete', -apple-system, sans-serif);
  /* ⛔ NAMED DIRECTLY, NOT DEFERRED. This was
     `var(--font-family--display, 'Playfair Display Complete', Georgia, serif)`,
     and MEASURED on the live page the theme defines `--font-family--display` as
     **'Proxima Soft Complete'** — so the variable resolved, the fallback never
     fired, and every serif on this page had been rendering in the SANS since the
     first build. A defined-but-wrong variable beats your fallback silently; only
     a computed-style read catches it. (`skillcloud.css` names the face directly
     and was correct all along, which is why the cloud's labels were the only
     Playfair on the page.) The face IS loaded site-wide — we just never asked
     for it. */
  --swf-display: 'Playfair Display Complete', Georgia, 'Times New Roman', serif;

  --swf-sm: 8px; --swf-md: 12px; --swf-lg: 16px; --swf-xl: 24px;
  --swf-2xl: 32px; --swf-3xl: 48px;

  /* ⭐⭐ THE THREE SPACING TOKENS (WD-343, Neil 2026-08-20). Everything vertical on
     this page is one of these three and nothing else. Source of the ladder:
     `sophicly-plugins/BRAND-RISEN-SPACING-AND-TIMING.md` §2 + §8 rule 4 + §14.6 —
     Sevilla QUADRUPLES section spacing from mobile to desktop (50px → 200px), and
     "most of the beautiful layout impression is this and nothing else."

     ⛔ THE FAILURE THIS REPLACES, recorded so it is not re-made: this file had SIX
     different ladders doing one job (`.swf-band` 96→200px = 2.08x, `.swf-statement`
     72→144 = 2.0x, `.swf-label` 32→72, `.swf-beat__h` var(--swf-2xl), `.swf-rungs`
     and `.swf-tally` 40→64, `.swf-loop` var(--swf-lg)) — none of them quadrupling,
     while the brand doc had said to since 2026-08-09. Neil could see it: "our
     spacing as well is also very inconsistent."

     ⛔ A clamp() whose max is under ~4x its min is not this system.

     ⛔⛔ WD-357 — THE SECOND FAILURE, AND IT IS THE ONE NEIL COULD SEE: the ladder was
     right and the OWNERSHIP was wrong. Every section carried BOTH its own padding AND a
     bottom margin, so a gap's size depended on which two SHAPES happened to meet.
     MEASURED at 1440 on 1.25.3, content edge to content edge:
         beat -> beat            0 + 86 +   0 =  86px
         band <-> beat         200 + 86 +   0 = 286px
         statement -> statement 200 + 86 + 200 = 486px
     — a 5.6x spread on one page. `/about/`, which Neil names as the standard, has NO
     section margins at all (measured `margin: -1px 0` to hide the seams): every gap is
     the two neighbours' PADDING, so the rhythm cannot fork.
     ⭐ THE RULE: vertical space between sections is owned by PADDING ONLY. A `.swf`
     section may not carry a vertical margin — the gate `bin/spacing-gate.mjs` fails on it.

     TWO TIERS, both read off `/about/` at 390 and 1440 (root §14b: the reference first):
         BAND  (its own ground — photo, statement, the criteria panel)   50 -> 150px
         BEAT  (a reading region on the page's ground)                   25 ->  75px
     `/about/`'s photo band measures exactly 50 -> 150 and its reading regions 20 -> 40.
     Ours pays the beat tier at HALF the band so that beat->beat (150) still reads as air;
     his page never puts two reading regions next to each other, ours does.
     ⚠️ STATED, because it contradicts the 4x rule three paragraphs up: 50 -> 150 is 3.0x.
     That is what the gold standard ships (the 4x came from Risen's own 50 -> 200), and
     Neil's instruction was to base this page on `/about/`. */
  --swf-band: 50px;                         /* his ladder, stepped below ·  50 -> 150px */
  --swf-sect: 25px;                         /* stepped below              ·  25 ->  75px */
  --swf-step: clamp(1.5rem, 3.5vw, 3rem);   /*  24 ->  48px · label -> headline -> content     */

  --swf-beat: var(--swf-sect);                /* the beat gap IS the section gap (WD-343) */

  --swf-r-md: 8px; --swf-r-lg: 12px;
  --swf-arrive: cubic-bezier(.23, 1, .32, 1);

  --swf-shadow-md: 0 4px 12px rgba(83,51,237,.10);
  --swf-shadow-lg: 0 12px 40px rgba(44,0,62,.20);

  /* ⭐ WD-346, Neil: "why do we have this horrible, narrow style? It looks very ugly."
     It was 54rem = 864px inside a 1440px viewport — 60%, with 288px dead either side.
     Neither reference is that tight: Risen's `#dSubContent` is FULL WIDTH with a 40px
     gutter and his content runs to 960px; Soleil Noir's container is
     `min(100% - 200px, 1240px)`. 75rem = 1200px matches Soleil and leaves his gutter.
     Full-bleed shapes (band, chapter bar, the scroll headline) ignore this entirely —
     they take the `full` track. */
  --swf-col: 77.5rem;                          /* 1240px — Soleil Noir's measured container */

  /* ⭐⭐ THE GUTTER — HOW FAR ANYTHING SITS FROM THE EDGE OF THE SCREEN (WD-408, Neil 2026-08-27).
     Verbatim: *"is our spacing towards the edge of the screen on mobile — is our spacing even?…
     they're very, very consistent spacing from start to finish. WE DON'T HAVE THAT. I want you to
     base our spacing… also to the edge of the screen on all screen sizes… off of the Risen Church
     website."*
     MEASURED off the 2015 Risen site, saved at `sophicly-plugins/_references/risen-2015-archive/`
     with the full table in its `MEASURED-gutters-2026-08-27.md`: the phone gutter is **26px and
     barely moves from 360 to 414**, and above that the gutter is simply whatever is left once the
     reading column has capped. ⭐ THAT CONSTANCY IS THE WHOLE EFFECT — his spacing reads as even
     because the phone inset never changes, not because anything scales smoothly.
     ⛔ OURS WAS `--swf-lg` = 16px, and the rail's cards bypassed it entirely at 0. That is exactly
     what he was looking at. */
  --swf-gutter: 26px;                          /* Risen, measured — constant across the phone range */

  /* the READING measure, also Risen's: their body column caps at 680px and never grows again.
     ⚠️ This governs PROSE only. `--swf-col` above stays 1240 because Neil ruled at WD-376 that the
     PLOTS take the column and only the prose is held to a measure — *"why is it aligned to the left
     leaving a huge gap on the right?"*. The two numbers come from two different references and that
     is deliberate; see the note in the handoff before changing either. */
  --swf-read: 42.5rem;                         /* 680px — Risen's capped reading column */

  /* ⛔ THE PAGE PAINTS ITS OWN GROUND. MEASURED on staging: `<html data-theme="light">`
     while `body` computes `rgb(0,0,0)` — the site loader paints the body black — so this
     article was rendering LIGHT surfaces (#ffffff boxes, near-black ink) over a BLACK page.
     That is what Neil saw as "not our brand colours". An article that never states its own
     background inherits whatever the host is doing, which is not something a marketing page
     can leave to chance. */
  background: var(--swf-page);
  margin: 0 auto;
  /* ⛔⛔ THE CONTAINER PAYS NOTHING. Neil, 2026-08-21, with the DevTools overlay showing a
     green band above the chapter bar: "you have a massive space at the top here… looks like
     extra padding or something."
     It was `padding: clamp(3rem, 9vw, 7rem) 0 clamp(4rem, 10vw, 8rem)` — MEASURED **107.7px**
     of top padding at his 1197px window, and 128px at the foot. A FOURTH ladder, outside the
     token system, added on top of whatever the first section already pays.
     ⭐ THE BLIND SPOT WORTH NAMING: `bin/spacing-gate.mjs` walked the junctions BETWEEN
     sections and had no opinion about the box they sit in, so it passed this three times.
     A gate that measures the gaps cannot see the frame. It now asserts this too.
     Risen does the same thing: `#aboutHeader` is followed straight by `.serveNav`, whose own
     50px is the entire gap. Sections own their space; the article owns none. */
  padding: 0;
  font-family: var(--swf-font); font-size: 16px; line-height: 1.55;
  color: var(--swf-ink); text-wrap: pretty;
}

/* ⭐ THE ARTICLE IS FULL WIDTH; THE COLUMN LIVES ON THE BEATS. It was
   `max-width:54rem` on .swf, which silently squeezed the Skill Cloud — a
   1400px component — into 864px, and made `.swf-beat--wide` (a class that
   was in the markup with NO rule behind it) do nothing at all. Same shape
   as /science-of-learning/: `.sol-page` is full width and each section
   constrains its own content. No `100vw` anywhere, so no scrollbar overflow. */
/* ⛔ THE COLUMN IS A GRID TRACK, NOT `margin-inline:auto`. The first cut used
   max-width + auto margins, and it silently lost: `.swf-beat { margin: 0 0 X }`
   is the SHORTHAND, same specificity, later in the file — so it reset
   margin-left/right to 0 and every beat sat hard against the left edge while
   the Skill Cloud band stayed centred. Measured at five widths: the heading was
   at [16,848] in a 1728px viewport. That is what Neil saw as "a lot of the text
   is sitting on the right edge... I don't really know why".
   A track cannot be clobbered by a margin shorthand, so this cannot come back. */
.swf, .swf-beat--wide {
  display: grid;
  grid-template-columns:
    [full-start] minmax(var(--swf-gutter), 1fr)
    [col-start]  minmax(0, var(--swf-col)) [col-end]
                 minmax(var(--swf-gutter), 1fr) [full-end];
}
.swf > *, .swf-beat--wide > * { grid-column: col; min-width: 0; }
/* the wide beat spans the whole grid, and so does the Skill Cloud's ground */
.swf-beat--wide, .swf-beat--wide > .ssc-band, .swf-beat--wide > .swf-rb { grid-column: full; }
/* ⭐ WD-383 — the closing panel is full-bleed like the ribbon band, but its heading and small
   print are NOT: they stay on the column track, so only the panel spans. */
.swf-beat--wide > .swf-cc { grid-column: full; }
/* ⭐ WD-314 — LIGHT IS THE DEFAULT, DARK IS THE EXPLICIT STATE. The house form is
   `html:not([data-theme='dark'])` (50 uses on /science-of-learning/). It was
   `html.light, html[data-theme="light"]`, and a first-time visitor is served
   `<html lang="en-GB">` with NO data-theme at all — so neither matched and this
   page rendered dark inside a light site. */
/* ⭐⭐ BOTH THEMES, PROPERLY (WD-338). At 1.9.0 I deleted the light palette to fix the
   black-body bug — that traded one defect for another and Neil had to report it. The real
   fault was never that light EXISTED; it was that the page did not paint its own ground, so
   light surfaces landed on the loader's black body.
   Now: the DARK ladder above is the default, this block states the LIGHT one, and `.swf`
   paints `--swf-page` either way — so neither theme can inherit a background it did not
   choose. All three dark conventions are matched per BRAND.md §Dark theme
   (`html.dark`, `html[data-theme='dark']`, `html.dark-mode`), with light as the default. */
html[data-theme='light'] .swf,
html.light .swf,
html:not([data-theme='dark']):not(.dark):not(.dark-mode) .swf {
  /* ⭐⭐ WD-386 — THE LIGHT PALETTE IS RISEN CHURCH'S, MEASURED OFF THEIR RENDERED PAGE
     (`web.archive.org/web/20150419033223/http://risenchurch.com/whoweare`), not their stylesheet
     and not from memory. Neil, 2026-08-26: *"I didn't realise that our main lighting colour is
     not white… some of our text in light theme is really dark… base our light theme colours off
     Risen Church."* Both halves of that were right, and the numbers say so:
         WAS  --swf-page #f5f5f7  — an off-white. Theirs is pure #ffffff.
         WAS  --swf-ink  #1a1a1e  — near-black, 15.93:1 on our ground.
                                    ⭐ RISEN NEVER PUT BLACK ON WHITE. Their DARKEST ink on white
                                    is #555 (7.46:1) and their body copy is #777.
     WHAT WAS TAKEN, and it is their own values:
         #ffffff  the ground (4 large painted boxes on their page)
         #555555  their darkest ink on white — "Why We Exist", their 40px display line at #565656
         #636363  their secondary text — 16 uses as a text colour in their stylesheet
         #efefef  their light panel
         #a8a8a8  their meta numerals ("01") — DECORATIVE ONLY, see below
     ⛔ WHAT WAS NOT TAKEN, AND WHY — the one place this deviates from the reference on purpose.
     Their BODY COPY is #777, which is **4.48:1 on white and therefore under the 4.5 AA line**;
     their links are #999 (2.85:1) and their numerals #a8a8a8 (2.38:1). Risen is a 2015 church
     site read on desktop; ours is read by parents on phones in daylight, and this stylesheet
     already carries the scar — the old `--swf-ink-soft` was #9ca3af at 2.5:1 and had to be fixed.
     So secondary text takes **their #636363 (6.01:1)** rather than their #777, and #a8a8a8 is
     kept for `--swf-absent`, which paints decoration, never prose. Every ink below passes AA.
     Full table + the arithmetic: `webdesign-FIXLIST.md` WD-386. */
  --swf-page: #ffffff; --swf-base: #f7f7f8; --swf-raised: #efefef;
  --swf-ink: #555555;         /* 7.46:1 on white — AAA */
  --swf-ink-soft: #636363;    /* 6.01:1 on white — AA  */
  /* ⛔ #a8a8a8 IS RISEN'S, AND IT DID NOT SURVIVE THE AUDIT. Their "01" numerals are decoration;
     ours paints `.swf-rung__grade` — "Grades 4–6", a real label at 24px — where it measured
     **2.22:1 against a 3:1 requirement**. Darkened to the nearest tone that passes (3.25:1 on
     white) and still reads as the muted "not yet" grey. Taking a reference value is not the same
     as taking the JOB it does there. */
  --swf-absent: #8f8f8f;      /* 3.25:1 on white — passes at large text, where it is used */
  --swf-teal: #4D76FD;                            /* brand blue is the light-theme accent */
  --swf-mint: #2c003e;                            /* Brand Deep Purple — the dark mid stop lifts the sweep's contrast */
  --swf-purple-lt: #5333ed;                       /* on light, purple stays the brand fill */
  --swf-btn-ink: #ffffff;
  --swf-shadow-md: 0 4px 12px rgba(28,29,31,.08);
  --swf-shadow-lg: 0 12px 40px rgba(28,29,31,.12);
}
/* ⛔⛔ AND DARK MUST WIN EXPLICITLY. MEASURED: with `data-theme="dark"` set, the page STILL
   served the light palette — because the host also carries a stale `class="light"` on <html>,
   so `html.light .swf` above kept matching. A theme system that keys on two channels needs
   the explicit state to override the default one, not to tie with it. This block restates
   the brand dark ladder and is last, so it wins on order as well as intent. */
html[data-theme='dark'] .swf,
html.dark .swf,
html.dark-mode .swf {
  --swf-page: #1c1d1f; --swf-base: #242628; --swf-raised: #2b2c2f;
  --swf-ink: #e0e0ee; --swf-ink-soft: #9da5b4; --swf-absent: #9ca3af;
  --swf-teal: #51dacf; --swf-mint: #7DF9E9; --swf-purple-lt: #a78bfa;
  --swf-btn-ink: #1C1D1F;
  --swf-shadow-md: 0 4px 12px rgba(83,51,237,.10);
  --swf-shadow-lg: 0 12px 40px rgba(44,0,62,.20);
}

/* the veil over a band photograph is a READABILITY ground, so it stays dark in both themes —
   the type on it is always light. Only the page chrome flips. */

/* ── the opening ──────────────────────────────────────────────────────────── */
.swf-open { margin-bottom: 0; }   /* the next section's padding owns the gap */
.swf-kicker, .swf-beat__n {
  margin: 0 0 var(--swf-lg); font-size: 11px; font-weight: 700;
  letter-spacing: .3em; text-transform: uppercase; color: var(--swf-teal);
}
.swf-h1 {
  margin: 0 0 var(--swf-xl);
  font-family: var(--swf-font); font-weight: 700;
  font-size: clamp(2.4rem, 1.4rem + 5vw, 5rem);
  line-height: 1.0; letter-spacing: -.035em; color: var(--swf-ink);
}
.swf-h1 em, .swf-beat__h em {
  font-family: var(--swf-display); font-style: italic; font-weight: 700;
  letter-spacing: -.01em; color: var(--swf-teal);
}
.swf-open__sub { margin: 0; max-width: 30rem; font-size: 1.15rem; color: var(--swf-ink-soft); }

/* ══════════════════════════════════════════════════════════════════════════
   THE LENS (WD-330) — ported from the Framer `TextLensRevealer`, whose full
   source we read out of its site bundle. HIS numbers: magnifier radius 80px,
   glass blur 2px, glass opacity .1, border 1px rgba(255,255,255,.3), and the
   two inset shadows below. HIS mechanism: the base line is punched through with
   a radial-gradient mask, a second line sits under it clipped to the same
   circle, and a glass disc rides the pointer.

   ⭐ CENTRED, and the alignment is a decision not a default (Neil: "why is it
   aligned to the left?"). A single statement is centred — his own reference
   centres every hero and every band; left-alignment is for multi-line reading
   prose in columns, which this is not.

   ⭐ NO-JS BASE STATE IS BOTH LINES, STACKED. The lens is layered on top by
   `.is-lens`, which only JS adds. Nothing is hidden until the mechanism exists.
   ══════════════════════════════════════════════════════════════════════════ */
/* ⛔ NOT `max-width: 24ch`. `ch` resolves against THIS element's font, and the
   display size lives on the CHILD paragraphs — so it measured at the inherited
   16px and capped the block at 242px, wrapping a 49px headline into five lines.
   Measured, then fixed with an absolute measure. */
.swf-lens { position: relative; text-align: center; margin: 0 auto; max-width: 38rem; }
/* ⭐ WD-331 — the opener pair is Playfair Display italic (Neil, 2026-08-19). It now
   matches the section labels and the band pull-quotes, so the serif italic reads as
   the page's voice rather than a one-off.
   ⚠️ ONE forced deviation, stated: the tracking goes -.03em -> -.015em. The tight
   value was dialled for the SANS; a serif italic at display size closes up on its
   own and -.03em collides the letterforms. Everything else is unchanged. */
.swf-lens__base, .swf-lens__under {
  margin: 0;
  /* ⛔ WD-505 (Neil, 2026-08-30: "why does the text on this band feel different?"): this rule
     used to set weight 700 · clamp() size · line-height 1.06 · letter-spacing -.015em — the
     Framer lens port's type, from before WD-345 ruled every band identical to Risen. Measured:
     700/54px/1.06 against 400/76px/normal on every other band. The four declarations are gone;
     the lines are `.swf-band__h` and take the ladder. Only the clip/mask below is the lens. */
}
.swf-lens__base  { color: var(--swf-ink); }
.swf-lens__under { color: var(--swf-teal); margin-top: var(--swf-md); }
.swf-lens__glass { display: none; }

/* ── enhanced: the two lines occupy the SAME box and the lens moves between them ── */
.swf-lens.is-lens { touch-action: none; cursor: none; }
.swf-lens.is-lens .swf-lens__under {
  position: absolute; inset: 0; margin: 0;
  /* his `clipPath: circle(${c}px at ${x}px ${y}px)` */
  clip-path: circle(var(--lens-r, 80px) at var(--lens-x, -999px) var(--lens-y, -999px));
}
.swf-lens.is-lens .swf-lens__base {
  /* his `radial-gradient(circle ${c}px at ${x}px ${y}px, transparent 0%, transparent ${c}px, black ${c}px)` */
  -webkit-mask-image: radial-gradient(circle var(--lens-r, 80px) at var(--lens-x, -999px) var(--lens-y, -999px), transparent 0%, transparent var(--lens-r, 80px), black var(--lens-r, 80px));
          mask-image: radial-gradient(circle var(--lens-r, 80px) at var(--lens-x, -999px) var(--lens-y, -999px), transparent 0%, transparent var(--lens-r, 80px), black var(--lens-r, 80px));
}
.swf-lens.is-lens .swf-lens__glass {
  display: block; position: absolute; pointer-events: none;
  width: calc(var(--lens-r, 80px) * 2); height: calc(var(--lens-r, 80px) * 2);
  left: 0; top: 0;
  transform: translate(calc(var(--lens-x, -999px) - var(--lens-r, 80px)), calc(var(--lens-y, -999px) - var(--lens-r, 80px)));
  border-radius: 50%;
  border: 1px solid rgba(255,255,255,.3);                    /* his glassBorder */
  background: radial-gradient(circle at center, rgba(255,255,255,.15) 0%, rgba(255,255,255,.05) 100%);
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);   /* his glassBlur */
  box-shadow: 0 8px 32px rgba(0,0,0,.12), inset 0 2px 4px rgba(255,255,255,.6), inset 0 -2px 4px rgba(0,0,0,.1);
  opacity: 0; transition: opacity 260ms var(--swf-arrive);
}
.swf-lens.is-lens.is-live .swf-lens__glass { opacity: 1; }
/* light theme: the white glass needs less lift on a pale ground */
html:not([data-theme='dark']) .swf-lens.is-lens .swf-lens__glass {
  border-color: rgba(255,255,255,.55);
  background: radial-gradient(circle at center, rgba(255,255,255,.35) 0%, rgba(255,255,255,.12) 100%);
}
@media (prefers-reduced-motion: reduce) { .swf-lens.is-lens .swf-lens__glass { transition: none; } }

/* ── beats — one idea, lots of air ────────────────────────────────────────── */
.swf-beat { margin: 0; padding-block: var(--swf-beat); }
.swf-beat__h {
  margin: 0 0 var(--swf-step);
  font-family: var(--swf-font); font-weight: 700;
  font-size: clamp(2rem, 1.1rem + 4vw, 3.9rem);
  line-height: 1.02; letter-spacing: -.03em; color: var(--swf-ink);
}
.swf-cap {
  margin: var(--swf-xl) 0 0; max-width: 30rem;
  font-size: 15px; line-height: 1.5; color: var(--swf-ink-soft);
}


/* ══════════════════════════════════════════════════════════════════════════
   THE ARRIVAL — /the-gap/ JOINS THE HOUSE SEQUENCE (WD-339, Neil 2026-08-19:
   "we have an established sequence for any page that has a general header hero
   and a body. We have literally spent weeks doing this.")

   ⛔ HE IS RIGHT AND THIS PAGE WAS NEVER IN IT. `sophicly-hero-motion.css` carries
   the BODY GATE — an explicit list of page-body wrappers held at
   `opacity:0; visibility:hidden` until the hero stamps `swh-body-in`, then faded as
   ONE BLOCK on Risen's own values (`swhFadeIn 1s ease-in 0.5s both`):
       .course-content · .sb-archive · .sb-post · .sb-related · .resource-content ·
       .text-content · .text-resources · .resources-section · .library-carousel ·
       section.library · .sol-page · .sh-page · .ab-page
   `.swf` is not among them, so this page fell outside the sequence entirely — and I
   then invented a SECOND reveal with my own trigger and my own timings on top of it.
   That is what Neil saw as "the body is visible before the head has animated in",
   and it is the exact defect `_arrivalgate.mjs` check B2 exists to catch
   ("nothing readable before the engine stamps swh-body-in").

   ⭐ HIS VALUES, NOT MINE. Same class, same 1s, same ease-in, same 0.5s delay, so
   /the-gap/ arrives on the identical beat as /science-of-learning/ and the blog.
   The keyframe is declared locally under our own name so this file never depends on
   another lane's stylesheet loading first — the VALUES are copied, not the object.
   ⚠️ A HANDOFF IS FILED asking the hero lane to add `.swf` to the canonical list, so
   the next funnel page inherits this instead of re-implementing it.

   ⛔ FAIL-SAFE: the hide is scoped to `html.swf-js`, set by our own head script, and
   the hero's gate opens on the hero finishing, on there being no hero, AND on a hard
   timeout — so a page with no JS, no hero or a dead engine still shows its body
   (root §4d; the same landmine as §OVERLAY 4). */
@keyframes swfBodyIn { from { opacity: 0 } to { opacity: 1 } }

html.swf-js:not(.swh-body-in) .swf { opacity: 0; visibility: hidden; }
html.swf-js.swh-body-in .swf { visibility: visible; animation: swfBodyIn 1s ease-in 0.5s both; }

@media (prefers-reduced-motion: reduce) {
  html.swf-js:not(.swh-body-in) .swf { opacity: 1; visibility: visible; }
  html.swf-js.swh-body-in .swf { animation: none; }
}

/* ── ARRIVAL: every beat rises once, on the brand curve ───────────────────── */
/* hidden ONLY when the head script confirmed JS — otherwise nothing is ever hidden */
html.swf-js .swf-beat { opacity: 0; transform: translateY(28px); }
/* ⛔ THE OPENER GETS AN OPACITY-ONLY ENTRANCE, DELIBERATELY. It holds the glass
   lens, and a parent `transform` (a) kills `backdrop-filter` outright and (b)
   rasterises round children as squares until an interaction repaints them —
   root CLAUDE.md, takeover-modal landmines 1 and 2. A `translateY(0)` still
   creates a containing block, so "it finishes at zero" does not save it. */

html.swf-js .swf-beat.is-in, html.swf-js .swf-open.is-in {
  opacity: 1; transform: none;
  transition: opacity 1600ms var(--swf-arrive), transform 1600ms var(--swf-arrive);
}

/* ── proof · risk ─────────────────────────────────────────────────────────── */
.swf-proof__quote {
  margin: 0; padding: 0;
  font-family: var(--swf-display); font-style: italic;
  font-size: clamp(1.5rem, 1rem + 2.4vw, 2.6rem); line-height: 1.18; color: var(--swf-ink);
}
.swf-proof__quote b { font-style: normal; font-family: var(--swf-font); color: var(--swf-purple-lt); }
.swf-inline-link { color: var(--swf-teal); font-weight: 600; text-decoration: none; }
.swf-inline-link:hover { text-decoration: underline; text-underline-offset: .2em; }
.swf-risk { text-align: center; }
.swf-risk__line { max-width: 32rem; margin-left: auto; margin-right: auto; }

/* ── THE ONE BUTTON — the Halo button, teal (BRAND §0 "confirmed loved") ──── */
.swf-cta { margin: var(--swf-beat) 0; text-align: center; }
.swf-btn {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  min-height: 60px; padding: 0 var(--swf-3xl); max-width: 100%;
  border-radius: 14px; background: var(--swf-teal); color: var(--swf-btn-ink) !important;
  font-family: var(--swf-font); font-weight: 700; font-size: 15px;
  letter-spacing: .06em; text-transform: uppercase; text-decoration: none !important;
  isolation: isolate;
  transition: background-color .5s cubic-bezier(.22,1,.36,1);
}
.swf-btn::after {
  content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
  background: var(--swf-teal); opacity: 0; transform: scale(1);
}
/* WD-493h.3 — Neil: the halo is a FLICK, not a held state ("it's supposed to just flick out
   temporarily, but it's just staying out"). A one-shot keyframe: grows and fades to nothing,
   then stays gone for the rest of the hover; re-entering the button replays it. */
.swf-btn:hover::after { animation: swf-halo-flick .55s cubic-bezier(.22,1,.36,1) forwards; }
@keyframes swf-halo-flick {
  0%   { opacity: .32; transform: scale(1); }
  100% { opacity: 0;   transform: scaleX(1.35) scaleY(1.55); }
}
.swf-btn:hover { background: var(--swf-mint); }
.swf-btn:focus-visible { outline: 3px solid var(--swf-teal); outline-offset: 4px; }
.swf-btn__roll { position: relative; display: block; overflow: hidden; height: 1em; }
/* WD-493f.3 — line-height was `normal` (~1.4), so the 15px line box was taller than the 1em
   window and the glyphs sat low enough to clip ("the text is positioned too low so it's been
   cut off"). line-height:1 makes the line box exactly the window. Global: every roll button
   carried the same latent clip. */
.swf-btn__roll span { display: block; line-height: 1; transition: transform .5s cubic-bezier(.22,1,.36,1); }
.swf-btn__roll span + span { position: absolute; inset: 0; transform: translateY(100%); }
.swf-btn:hover .swf-btn__roll span { transform: translateY(-100%); }
.swf-btn:hover .swf-btn__roll span + span { transform: translateY(0); }

.swf-cta__note { margin: var(--swf-lg) 0 0; font-size: 14px; color: var(--swf-ink-soft); }
.swf-receipt {
  margin: var(--swf-xl) auto 0; max-width: 30rem; padding-top: var(--swf-lg);
  font-size: 13px; line-height: 1.5; color: var(--swf-ink-soft);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
}
.swf-receipt__link { color: var(--swf-ink-soft); text-underline-offset: .2em; }
.swf-receipt__link:hover { color: var(--swf-teal); }
.swf-scarcity__line {
  margin: 0; padding: var(--swf-lg) var(--swf-xl); text-align: center;
  background: var(--swf-base); border-radius: var(--swf-r-lg); box-shadow: var(--swf-shadow-md);
  font-size: 15px; color: var(--swf-ink);
}

/* ⭐ A REFUSAL IS HALF A CHANGE — reduced motion SETS the end state, it does not
   merely skip the tween. Every reveal above starts hidden, so skipping alone
   would leave a blank page (root CLAUDE.md §4d). */
@media (prefers-reduced-motion: reduce) {
  .swf-beat, .swf-open { opacity: 1 !important; transform: none !important; transition: none !important; }
  .swf-swap__from::after { transform: scaleX(1); animation: none !important; }
  .swf-swap__to { clip-path: none !important; animation: none !important; }
  .swf-fifth__fill { transform: scaleX(1); animation: none !important; }
  .swf-ring__list li { opacity: 1 !important; animation: none !important; }
  .is-in .swf-header-strip .is-lit { animation: none !important; }
  .swf-btn, .swf-btn::after, .swf-btn__roll span { transition: none !important; animation: none !important; }
  /* deviation F — reduced motion is a LAYOUT: the ribbons stop and every word stays legible
     exactly where it is. The script never starts its loop, and nothing is hidden waiting on JS. */
}

/* ⭐ WD-312 — THE HERO IS NOT OURS. It is the site's own `General Header Hero`
   (Etch component 55860), composed onto the page the way /science-of-learning/
   and /about/ compose it. The bespoke `.swf-hero` that used to live here was
   the copy-the-reference law failing in the "make it consistent" costume. */


/* ── 1 · the grade ladder — two rows, the whole argument ──────────────────── */
/* ══════════════════════════════════════════════════════════════════════════
   THE RISEN LAYOUT SYSTEM (WD-327). Neil: "the styling is just not interesting…
   study these pages for layout structures and techniques" — /whoweare, /contact
   and /serve, 2015. Numbers taken from HIS stylesheet, which we hold
   byte-unmodified at `Menus for Sophicly/references/risen-church-2015/`, not
   from taste (WWMSD is answered from the source).

   THE DIAGNOSIS, in one sentence: he reserves the big type for PHOTOGRAPHS and
   demotes every section heading to a small, hugely-tracked italic label. We were
   doing the opposite — one giant left-aligned sans headline per beat, seven
   times, no imagery, one column, one alignment. One gesture repeated is what
   reads as boring.

   HIS DEVICES, measured at the largest breakpoint:
     .aboutH1 / .contactH1   26px Times ITALIC, letter-spacing 10px, margin 70px 0 100px
     .serveQuoteContent      padding 200px 0, centred, .subPxImg cover + parallax
     .serveQuote h1          42px, letter-spacing 2px, white
     .serveQ                 24px/36px Times italic, margin-top 60px
     .risen2C                column-count 2, column-gap 40px
     .cDash1                 80 x 1px, margin 50px auto
     .fx                     opacity 0 -> all 1800ms cubic-bezier(0.23,1,.32,1)

   ⛔ BRAND DEVIATIONS, stated not assumed: his 1px #ccc column-top and nav
   dividers are DROPPED (BRAND.md bans hairline borders on panels); his short
   .cDash1 rule is typographic, not a panel border, so it stays. Times italic
   maps to Playfair Display italic, our house serif. His white ground maps to our
   theme-aware pair. The 1800ms curve is already ours as --swh-arrival.
   ══════════════════════════════════════════════════════════════════════════ */

/* ⛔ THE IN-BODY CHAPTER MARK IS RETIRED (WD-352, Neil: "we don't need these in the
   body"). The number + 30x3px dash device (WD-345) said the same thing the chapter bar
   at the top already says, and with the bar now carrying titles it was pure repetition.
   Its rules are removed rather than left dangling; each beat carries an `aria-label`
   so the sections are still named for a screen reader. */
/* ── his 80 x 1px rule. The ONLY divider on the page. ────────────────────── */
.swf-rule { width: 80px; height: 1px; border: 0; margin: clamp(2rem, 5vw, 50px) auto;
  background: color-mix(in srgb, var(--swf-ink) 22%, transparent); }

/* ── his .risen2C ────────────────────────────────────────────────────────── */
@media (min-width: 56rem) {
  .swf-2col { column-count: 2; column-gap: 40px; }
  .swf-2col > p { margin-top: 0; break-inside: avoid; }
}

/* ── the full-bleed image band — his .serveQuoteContent + .subPxImg ──────── */
.swf-band {
  position: relative; overflow: hidden;
  display: grid; place-items: center;
  padding: var(--swf-band) var(--swf-lg);            /* his 50 -> 150px */
  text-align: center;
  /* ⛔ NO VERTICAL MARGIN — see the ownership rule in the token block. The earlier
     "bottom margin only" fix (WD-345) made the margins UNIFORM but left them stacked on
     top of each section's own padding, which is what forked the rhythm. */
  margin: 0;
}
/* ══════════════════════════════════════════════════════════════════════════
   THE PARALLAX BAND — ported from the GOLD STANDARD (WD-353, Neil 2026-08-21:
   "we already have these on the about page that work perfectly").
   Source: sophicly.com/about — `.ab-sub-px` / `.ab-sub-px-img`, read off the live
   page. Full spec: `BRAND-RISEN-SPACING-AND-TIMING.md` §15.

   ⛔⛔ WHY OURS BROKE ON MOBILE, MEASURED at 390px:
       about  : image layer 528px inside a 352px section → 176px headroom, travel 101.7px
       ours   : image layer 186px inside a 166px section →  20px headroom, travel ±60px
   `inset: 0` + `scale(1.12)` yields only 6% overhang per side. Against a FIXED 60px
   travel that is fine on a 630px desktop band and catastrophic on a 166px mobile one:
   the layer slides clean past its own edge and uncovers the ground behind it.
   ⭐ THE GOLD STANDARD MAKES BOTH NUMBERS PROPORTIONAL — a 25% overhang per side
   (`inset:-25% 0`, so the layer is 150% of the section) and a travel expressed as a
   PERCENTAGE OF THE LAYER, never pixels. It therefore cannot break at any viewport.
   ══════════════════════════════════════════════════════════════════════════ */
.swf-band__img {
  position: absolute;
  /* ⭐ 30%, NOT THEIR 25% — a STATED, MEASURED deviation (root §13).
     Their travel is 30% of the section height against 25% of headroom, so at the very
     end of the range the layer overshoots its own edge by 5%. On /about/ that is
     invisible because their ScrollTrigger ends at 'bottom top', i.e. after the section
     has left the screen. Our gate caught the same 5% still on screen on two of our
     bands (8.3px and 12.6px at 390px) — the margin is thinner here because our bands
     are shorter. Raising the overhang to match the travel makes an uncovered edge
     IMPOSSIBLE AT ANY SCROLL POSITION rather than merely unlikely (root §0 (d):
     engineer the failure out, do not rely on it staying off-screen).
     The visible motion is unchanged — still 30% of the section height. */
  /* ⭐⭐ WD-487 — RISEN'S LAYER GEOMETRY, VERBATIM. Read off their `theme.js` in our mirror:
         function setSubPx(){ subPxImg.height( subPx.outerHeight() + subPx.outerHeight()*.3 ) }
     and their `style.css`: `.subPxImg{position:absolute;top:0;left:0;right:0;bottom:0;…}`.
     So the layer is ANCHORED AT THE TOP and 130% tall — the whole overhang hangs BELOW the
     section. Ours had been `inset:-30% 0`, a 160% layer centred on the section: same MOTION, but a
     different SLICE of the photograph (the middle of the picture instead of its top 77%). Neil:
     *"I want it exactly the same as the Risen Church website."* Now it is.
     ⭐ `max(130%, 100% + 15vh)` is their 30% as a FLOOR: the bottom edge stays covered only while
     0.15·vh ≤ overhang, and their bands are all taller than half a viewport; ours are not, so the
     overhang grows where it must. The travel never changes — motion is what is seen. */
  top: 0; left: 0; right: 0;
  height: max(130%, calc(100% + 15vh));
  background-position: center center; background-size: cover; background-repeat: no-repeat;
  transform: translate3d(0, var(--swf-px, 0px), 0);   /* JS fallback path only — see @supports below */
  will-change: transform;
  pointer-events: none;             /* theirs */
}
/* ⭐ THE SUBJECT IS NOT ALWAYS IN THE MIDDLE. `background-position:center` plus
   the 1.12 parallax scale was cropping the lit doorway (low in its frame) and the
   students (bottom-right) out of view, leaving the band looking like an empty
   dark rectangle. Framed per image — checked against the actual artwork. */
.swf-band[data-band="band-1-stop"]    .swf-band__img { background-position: center 62%; }
.swf-band[data-band="band-3-fixable"] .swf-band__img { background-position: center 72%; }
.swf-band[data-band="band-4-proof"]   .swf-band__img { background-position: center 30%; }

/* a readability ground, not a border. Every band carries type over a photograph.
   ⚠️ Kept deliberately light at the top: three of the four photographs are
   already dark, and stacking a heavy scrim on them flattens the image to a
   rectangle. The gradient earns its weight where the type sits. */
.swf-band__veil { position: absolute; inset: 0;
  background: linear-gradient(to bottom, rgba(12,13,14,.18) 0%, rgba(12,13,14,.52) 50%, rgba(12,13,14,.70) 100%); }
/* the one bright photograph needs more, and only it */
.swf-band[data-band="band-4-proof"] .swf-band__veil {
  background: linear-gradient(to bottom, rgba(12,13,14,.42) 0%, rgba(12,13,14,.60) 50%, rgba(12,13,14,.72) 100%); }
.swf-band__in { position: relative; max-width: 46rem; }
/* ⭐⭐ EVERY BAND HEADLINE IS THE SERIF ITALIC (Neil, 2026-08-21: "in the Risen chat site, in
   the parallax sections… he just uses serif. I think we should do the same… all the SWF band
   sections should just use Playfair Display Complete italics").
   ⚠️ ONE FINDING, STATED, because his two band shapes differ and the instruction reads across
   both (root §0b — 29 rules of the `.serveQuote` / `.whoWeAreQuote` families extracted from the
   mirror, not recalled):
     · his FLAT band `.whoWeAreQuote` IS `font-family: Times, serif; font-style: italic;
       font-weight: 400` — 42 -> 76px, `#444`, no tracking. Neil is exactly right about this one.
     · his PHOTO band `.serveQuote h1` is 28 -> 65px with `letter-spacing: 2px` and inherits
       `body`'s Proxima semibold — SANS. The serif italic there is the line UNDERNEATH it
       (`.serveQ`, Times italic 17 -> 24px).
   We are going serif italic on BOTH by his ruling, and it is the right call for us: most of our
   bands have no `.serveQ` sub-line, so the headline IS the lyrical line.
   ⛔ WEIGHT IS 700, NOT HIS 400 — stated. His 400 sits on a flat warm ground; ours sits on a
   veiled photograph, and the opener pair `.swf-lens__base/__under` already sits on that same
   photo band at Playfair italic 700 and was approved there (WD-331). One weight, not two.
   ⛔ TRACKING GOES -.02em -> -.015em for the same reason WD-331 recorded: the tight value was
   dialled for the sans, and a serif italic at display size closes up on its own. */
/* ══════════════════════════════════════════════════════════════════════════════════════════
   WD-476 · THE BAND HEADLINE IS RISEN'S `.whoWeAreQuote`, PORTED EXACTLY (Neil, 2026-08-30)

   *"I wanted to be exactly the same as the risen church website for the parallax bands. I can
   already see by my eye that this has got a different line height. It's got a different weight,
   probably a different size, and maybe even different kerning… I want the same weight, the same
   size for all the different screen sizes, same kerning… and same line height as well. Everything
   identical."*
   ⭐ HIS EYE WAS RIGHT ON ALL FOUR. Read off their real stylesheet
   (`risenchurch.com/wp-content/themes/risen/style.css`, Wayback 20150401063346 — 58 matching
   rules extracted, nothing truncated), theirs against what we had:

     |              | Risen `.whoWeAreQuote`  | ours (before)              |
     | weight       | 400                     | 700          ← too heavy   |
     | line-height  | NOT DECLARED (= normal) | 1.06         ← too tight   |
     | letter-spacing| NOT DECLARED (= normal)| -.015em      ← invented    |
     | size @1024+  | 76px                    | 54.4px       ← far too small|

   ⭐⭐ THE FINDING THAT MATTERS MOST: **they never set `line-height` or `letter-spacing` at all.**
   The look he was pointing at is the BROWSER DEFAULT on a serif italic — so the correct port is to
   DELETE both declarations, not to tune them. A value we invented was the whole difference.

   THEIR LADDER, VERBATIM — a stepped set of fixed px, not a clamp (and note the deliberate DROP
   from 42px to 38px at 400px, which is theirs and is kept):
     base   42px / 270px   ·  ≥400  38px / 350px  ·  ≥600  46px / 500px
     ≥768   58px / 600px   ·  ≥860  63px / 840px  ·  ≥1024 76px / 1000px
   The `width` is part of the type spec, not decoration: it sets where every line breaks.

   ⛔ TWO STATED DEVIATIONS, both forced:
   1. **Playfair Display italic, not Times** — Neil's own instruction. ⚠️ At an identical px size
      Playfair reads slightly larger than Times (bigger x-height); the SIZE is what he asked to
      match, so the size is matched and this note exists so the difference is not mistaken for an
      error later.
   2. **Colour stays `#f4f4f8`, not their `#444`.** Theirs sits on a cream panel; ours sits on a
      dark veil over a photograph, where #444 is invisible. He asked about size, weight, kerning
      and line-height — not colour.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.swf-band__h {
  margin: 0 auto; color: #f4f4f8;
  font-family: var(--swf-display); font-style: italic; font-weight: 400;
  font-size: 42px; width: 270px;
  display: block; position: relative; text-align: center;
  /* ⛔ NO `line-height` AND NO `letter-spacing` — see the note above. Theirs declares neither, so
     declaring either here is the deviation, not the omission. */
}
@media screen and (min-width: 400px)  { .swf-band__h { font-size: 38px; width: 350px; } }
@media screen and (min-width: 600px)  { .swf-band__h { font-size: 46px; width: 500px; } }
@media screen and (min-width: 768px)  { .swf-band__h { font-size: 58px; width: 600px; } }
@media screen and (min-width: 860px)  { .swf-band__h { font-size: 63px; width: 840px; } }
@media screen and (min-width: 1024px) { .swf-band__h { font-size: 76px; width: 1000px; } }
/* ⛔ a fixed `width` must never overflow a narrow phone — theirs never had to survive 320px */
.swf-band__h { max-width: 100%; }
/* ⛔ WD-504 (measured 2026-08-30): `.swf-band__in{max-width:46rem}` = 736px was CAPPING the
   ladder's 1000px step, so a 96-character band line wrapped to EIGHT lines at 76px. The width is
   part of the type spec (see above) — the wrapper must not be narrower than the widest step. */
@media screen and (min-width: 1024px) { .swf-band .swf-band__in { max-width: 1000px; } }
/* ⛔ WD-504: on the opener, `.swf-band--open .swf-band__h{width:1280px}` also hit the two lens lines,
   which live INSIDE the 750px `.swf-lens` — so they overflowed it to the right and read as
   right-aligned beside the glass. Inside the lens the lines fill the lens, never a fixed px. */
.swf-band--open .swf-lens .swf-band__h { width: 100%; max-width: 100%; }
/* the whole line is the serif italic now, so COLOUR is the only emphasis device left —
   which is what `.swf-lens__base em` already does on the opener. */
.swf-band__h em { color: var(--swf-mint); }
/* ⛔⛔ THE TWO GROUNDS OF ONE CLASS (WD-345, Neil 2026-08-20: "I'm amazed that you
   actually think that we are anywhere close to the clean styling of the risen
   church website"). `.swf-band__h/__p` are hardcoded LIGHT ink because the photo
   band's veil is always dark — but `swf_statement()` reuses the same classes on a
   `--swf-base` ground, which in LIGHT theme is #ffffff. MEASURED on staging
   1.14.1: "Two years of this cycle" = #f4f4f8 on #ffffff — invisible, and the
   whole statement + CTA region read as a giant empty white block. The statement
   was only ever verified in dark theme (the WD-338 lesson, re-made).
   So the channels are split at the SHAPE, not the class:
   — the PHOTO band's veil is dark in both themes → its ink is hardcoded light,
     INCLUDING the em (the mint VAR resolves to Deep Purple #2c003e on light,
     which was killing the Hafsa "8 and 7" against the dark photograph);
   — the STATEMENT's ground is the theme surface → its ink is the theme's. */
.swf-band .swf-band__h em { color: #7DF9E9; }              /* dark veil, both themes */
.swf-statement .swf-band__h { color: var(--swf-ink); }
.swf-statement .swf-band__h em { color: var(--swf-mint); } /* light: #2c003e · dark: #7DF9E9 */
.swf-statement .swf-band__p { color: var(--swf-ink-soft); }
.swf-band__p {
  margin: clamp(1.75rem, 4vw, 60px) auto 0; max-width: 34rem;   /* his margin-top 60px */
  font-family: var(--swf-display); font-style: italic; font-weight: 400;
  font-size: clamp(1.05rem, .85rem + .7vw, 1.5rem); line-height: 1.5;
  color: rgba(244,244,248,.82);
}
.swf-band .swf-btn { margin-top: var(--swf-step); }
/* the band is full-bleed inside the article grid */
.swf > .swf-band { grid-column: full; }
/* ⭐⭐ WD-458 — A PARALLAX BAND IS FULL-BLEED AT ANY DEPTH. STANDARD, not per-page (Neil,
   2026-08-29: *"why are they not full width? They should be full width as standard… The about
   page shows exactly how it should be. It should just be a standard way that this section is
   designed. Every section where we have these parallax bands, it should be exactly the same."*)

   ⛔ THE CAUSE, MEASURED at 1440 rather than reasoned about: the rule above only reaches a band
   that is a DIRECT child of `.swf`, because `grid-column` needs a grid parent. The two bands that
   were wrong are nested — one inside `.swf-dx`, two inside `.swf-dc` — and **`.swf-dx` carries
   `padding-inline: var(--swf-gutter)`**, so every band under it started at x=26 and measured
   1388px inside a 1440px viewport. `grid-column` computed `auto/auto` on all three: the rule was
   not overridden, it never applied.

   ⭐ THE REFERENCE IS `/about/`, read for this fix: `.ab-quote-content { width: 100% }` with NO
   max-width and NO inline padding — it is full-bleed by construction because nothing above it
   insets it. Ours cannot copy that literally (our bands live inside a padded page grid), so the
   band cancels exactly the one gutter it is nested inside. Token-based, so it tracks
   `--swf-gutter` at every breakpoint.
   ⛔ NOT `width: 100vw` / `margin-inline: calc(50% - 50vw)`: `vw` INCLUDES the scrollbar, so that
   idiom overflows by the scrollbar width on any desktop with a classic (non-overlay) scrollbar —
   trading an inset band for a horizontal scroll on the whole page.
   ⚙️ Enforced by `bin/parallax-gate.mjs`, which now fails any band narrower than the viewport. */
/* ⛔⛔ WD-473 — `.swf-ao` MUST BE IN THIS LIST, and it was missed for a day. WD-468 renamed the
   deck section `.swf-dc` → `.swf-ao` and left this selector naming the old class, so the rule
   simply stopped matching and both deck bands fell back inside `.swf-dx`'s gutter — the exact
   defect WD-458 above had already fixed, which is why Neil's report was *"we've been through this
   already"*. A selector that names a class nothing emits any more fails SILENTLY: no error, no
   warning, just a band 52px narrower than the page.
   ⭐ AND `bin/parallax-gate.mjs` ALREADY FAILS ANY BAND NARROWER THAN THE VIEWPORT — the gate
   existed, it was simply not run after a rename that had no obvious connection to parallax. When
   you rename a component class, grep the whole stylesheet for the OLD name before shipping; a
   rename is a key change, and this file's own key-mismatch rule applies to CSS selectors too. */
:where(.swf-dx, .swf-dc, .swf-ao, .swf-statement) > .swf-band {
  margin-inline: calc(var(--swf-gutter) * -1);
}

/* ⭐⭐ HIS BAND LADDER, AT HIS OWN BREAKPOINTS — 50 · 80 · 100 · 200px.
   ⛔ THIS IS A STEP FUNCTION AND A `clamp()` CANNOT REPRODUCE IT. The first cut of
   WD-343 used `clamp(3.5rem, 9vw, 12.5rem)` and MEASURED 129.6px at 1440 — the
   `vw` term throttles it, so the 200px max is unreachable until ~2220px and the
   band was still only 2.6x its mobile value on a normal desktop. That is exactly
   the "scales far more timidly" failure this token set exists to end
   (`BRAND-RISEN-SPACING-AND-TIMING.md` §14.6), reintroduced while fixing it.
   Caught by reading the COMPUTED value on staging, not by reading the source.
   ⇒ When porting a stepped ladder, port the STEPS. */
/* ⭐ STEPS, NOT A CLAMP (§1a — a clamp with a vw middle term never reaches its max at a
   normal width; `--swf-sect`'s old `clamp(2.5rem, 6vw, 7.5rem)` MEASURED 86.4px at 1440,
   not its 120px maximum, which is the third time this ladder has shipped short). */
@media (min-width: 600px) { .swf { --swf-band: 80px;  --swf-sect: 40px; } }
@media (min-width: 768px) { .swf { --swf-band: 100px; --swf-sect: 50px; } }
@media (min-width: 801px) { .swf { --swf-band: 150px; --swf-sect: 75px; } }

/* ══════════════════════════════════════════════════════════════════════════
   THE CHAPTER BAR — shape B, his `.serveNav`, FULL WIDTH (WD-343 → WD-346).
   Spec: `sophicly-plugins/BRAND-RISEN-SPACING-AND-TIMING.md` §14.2 B.

   ⛔⛔ NO RESTING ACTIVE STATE (Neil, 2026-08-20: "why is one the basics in an
   active state? that's not how it is on the risen church website"). He is right —
   his three items are IDENTICAL GREY at rest and the teal bar is a HOVER reward.
   The orientation he ruled for is still there, but it is carried by INK ALONE:
   the current chapter sits at full ink, the rest stay grey. The bar and the lift
   are reserved for hover, exactly as his are. An always-lit bar was me inventing
   a state the reference does not have.

   ⛔ NO `1px #ccc` DIVIDERS — BRAND.md bans hairline borders on panels (§14.4).
   ══════════════════════════════════════════════════════════════════════════ */
/* ⛔⛔ THIS SECTION IS A PORT, AND ITS SPACING IS NOT OURS TO SET. Neil, 2026-08-21:
   "that section was an original section from the Risen church website. So the spacing on
   that should not change. Make a gate for that, please."
   HIS VALUES, read from the mirror (`risen-church-2015/…/css/style.css`, all 22 rules of the
   `.serveNav` family extracted — root §14c Gate 1):
       .serveNav { padding: 20px 0 }
       @media (min-width: 600px) { padding: 30px 0 }
       @media (min-width: 768px) { padding: 50px 0 }   (his 801px step repeats 50)
   ⛔ THE PORT NEVER CARRIED THIS AT ALL — the bar's air came entirely from a bottom margin we
   invented, and WD-357 removed it, which is why he saw the section he liked get worse.
   ⛔ It is therefore EXEMPT from the two-tier rhythm: `bin/serve-nav-gate.mjs` owns it, and
   `bin/spacing-gate.mjs` skips its junctions and defers to that gate. */
.swf-chapters { grid-column: full; margin: 0; padding: 20px 0; }
@media (min-width: 37.5rem) { .swf-chapters { padding: 30px 0; } }   /* his 600px */
@media (min-width: 48rem)   { .swf-chapters { padding: 50px 0; } }   /* his 768px */
.swf-chapters ul {
  margin: 0; padding: 0; list-style: none;
  display: grid; grid-template-columns: repeat(4, 1fr);   /* his 3 equal columns, made 4 */
}
/* ⭐ HIS DIVIDERS — RESTORED AT NEIL'S REQUEST (WD-347, 2026-08-20: "he's actually got
   a divider between them. We should put that divider in, that looks nice").
   I had dropped them at WD-343 citing BRAND.md's ban on hairline borders — that ban is
   about PANELS, and this is a nav rule between columns, which is a different job. His
   own device, and he asked for it: overruled, correctly.
   His `.serveNav li:nth-child(2)` carries BOTH borders, which is how a 3-up gets its two
   rules. A 4-up needs three, so it is every item after the first. Colour follows the
   theme instead of his flat #ccc, so it survives the dark ground. */
.swf-chapters li + li {
  border-left: 1px solid color-mix(in srgb, var(--swf-ink) 15%, transparent);
}
.swf-chapters a {
  display: grid; place-items: center; position: relative;
  height: 36px;                              /* his 36px */
  font-size: 14px;                           /* his 14px */
  font-weight: 600; color: var(--swf-ink-soft); text-decoration: none;
}
/* ⭐ HIS `.serveSpan` TRICK — the repeated part of every label is its own span,
   hidden below 520px: "Basics / Words / Writing / Cycle" on a phone,
   "One — the basics" on desktop. Cheapest responsive-copy device on his site. */
.swf-chapters .swf-ch__n { display: none; }
@media (min-width: 32.5rem) { .swf-chapters .swf-ch__n { display: inline; } }

.swf-chapters .snT {
  transform: translateY(0);
  transition: transform 500ms var(--swf-arrive), color 500ms var(--swf-arrive);
}
.swf-chapters .snB {
  position: absolute; top: 50%; left: 50%;
  width: 30px; height: 2px; margin-left: -15px;      /* his mobile bar */
  background: var(--swf-teal); opacity: 0; transform: translateY(15px);
  transition: transform 500ms var(--swf-arrive), opacity 500ms var(--swf-arrive);
}
/* orientation = INK ONLY */
.swf-chapters a.is-current .snT { color: var(--swf-ink); }
/* hover = his full gesture: title lifts, bar rises and fades in 0.05s behind it */
.swf-chapters a:hover .snT, .swf-chapters a:focus-visible .snT {
  color: var(--swf-ink); transform: translateY(-8px);
}
.swf-chapters a:hover .snB, .swf-chapters a:focus-visible .snB {
  opacity: 1; transform: translateY(8px);
  transition: transform 500ms var(--swf-arrive) .05s, opacity 500ms var(--swf-arrive) .05s;
}
@media (min-width: 50.0625rem) {                      /* his 801px step */
  .swf-chapters a { height: 96px; font-size: 24px; }
  .swf-chapters .snB { width: 50px; height: 5px; margin-left: -25px; transform: translateY(35px); }
  .swf-chapters a:hover .snT, .swf-chapters a:focus-visible .snT { transform: translateY(-15px); }
  .swf-chapters a:hover .snB, .swf-chapters a:focus-visible .snB { transform: translateY(15px); }
}
@media (prefers-reduced-motion: reduce) {
  .swf-chapters .snT, .swf-chapters .snB { transition: opacity 500ms var(--swf-arrive); }
  .swf-chapters a:hover .snT, .swf-chapters a:focus-visible .snT { transform: none; }
  .swf-chapters a:hover .snB, .swf-chapters a:focus-visible .snB { transform: translateY(8px); }
}

/* ══════════════════════════════════════════════════════════════════════════
   SOLEIL NOIR `s-scroll-text` — the scroll-lit headline (WD-346).
   Ported from `build/css/components/s-scroll-text.css`, read in full (1255 bytes).
   Their values: 11.07rem @15px base = 166px / weight 700 / line-height .95 /
   tracking -.1em, dropping to 14vw below 1280px; ghost layer opacity .4 with
   `mix-blend-mode: overlay`; lit layer at `--opacity`, written per character;
   the whole title drifts `(1 - progress) * section-spacing * -0.5`.

   ⛔ DEVIATIONS, STATED (root §13): their `--section-spacing` is 12rem/180px — ours
   is `--swf-sect` so the drift stays on this page's own scale; their type is Futura
   Now, ours is the house sans; and the ghost layer uses `overlay`, which needs a
   ground to blend into — on our flat page it is a plain low-opacity copy instead,
   which is what `overlay` resolves to over a near-uniform surface anyway.
   ══════════════════════════════════════════════════════════════════════════ */
.swf-scroll { --progress: 0; }
.swf-scroll__title {
  margin: 0;
  font-family: var(--swf-font); font-weight: 700;
  /* ⭐ THEIR EXACT SIZE (WD-347 — Neil: "that needs to be a lot bigger").
     MEASURED on the live site at 1440: font-size 166.05px · line-height 157.747px
     (= .95) · weight 700 · letter-spacing -16.605px (= -.1em) · container 1240px.
     166px = 10.375rem at our 16px root. Their 14vw ramp is kept below 1280px. */
  font-size: clamp(2.6rem, 14vw, 10.375rem);
  line-height: .95; letter-spacing: -.1em;   /* theirs exactly */
  color: var(--swf-ink);
  white-space: nowrap;      /* a wrapped line breaks the line-by-line reveal outright */
}
@media (max-width: 30rem) { .swf-scroll__title { white-space: normal; } }
.swf-scroll__line { display: block; }
.swf-scroll__word { display: inline-block; }
.swf-scroll__char {
  position: relative; display: inline-block;
  color: transparent;                         /* the glyph itself never shows */
}
/* ⭐⭐ ONE LAYER, NOT NINETY-TWO (WD-349 — Neil: "it's like a trembling sort of
   experience... the page is almost like shaking").
   MEASURED with REAL wheel events at 4x CPU throttle (the `scrollTo` + sleep shape
   FALSE-PASSES — WD-333 recorded that trap and I fell into it again first):
       as shipped                    median 15.0ms · p95 22.6 · max 52.1 · 1 frame >50ms
       char transforms off           median 12.2ms · p95 20.2 · max 24.7 · 0 frames >32ms
       pseudo-elements off entirely  median 10.4ms · p95 18.5 · max 22.1
       band `will-change` removed    median 14.8ms · p95 22.2 · max 51.7  ← no change
   Soleil puts the drift on every `::before`/`::after`. At 46 characters that is 92
   self-promoting `translate3d` layers re-composited every frame, and it is the whole
   of the cost — the bands are innocent, exactly as WD-333 already found.
   ⭐ The drift value is IDENTICAL for every character (one `--progress`, one
   expression), so translating the TITLE once is pixel-for-pixel the same picture at
   1/92 of the cost. The real glyphs are transparent, so moving them changes nothing
   that is visible. Same motion, applied at the right level. */
.swf-scroll__title {
  transform: translate3d(0, calc((1 - var(--progress, 0)) * var(--swf-sect) * -0.5), 0);
  will-change: transform;
}
/* ⛔⛔ NO GHOST LAYER (Neil's ruling, 2026-08-20: "I think it's better if it's actually
   completely invisible at first, because otherwise you can kind of see it. It doesn't
   have the right effect").
   A STATED DEVIATION FROM THE REFERENCE (root §13): Soleil's `::before` sits at
   opacity .4 with `mix-blend-mode: overlay`, which on their blue gradient reads as a
   faint shimmer. On our flat dark ground the same .4 reads as plainly legible grey
   text, so the reveal had nothing to reveal. The ghost is gone; only the lit layer
   remains. */
.swf-scroll__char::after {
  content: attr(data-char);
  position: absolute; top: 0; left: 0; z-index: 1;
  width: 100%; height: 100%;
  color: var(--swf-ink);
  opacity: 1;                    /* ⭐ NO-JS: fully legible if the script never runs */
}
/* only once JS is present does the sweep own the opacity — `swf-js` is set by an
   inline script in the HEAD, so there is no flash of visible-then-blank. */
html.swf-js .swf-scroll__char::after { opacity: var(--opacity, 0); }

.swf-scroll__text {
  margin: -90px 0 0 auto;              /* theirs exactly */
  width: 27vw; max-width: 18.34em;     /* theirs — and now the em resolves the same */
  font-size: 1.5rem;                   /* 24px */
  line-height: 1.4;                    /* 33.6 / 24 */
  color: var(--swf-ink);               /* theirs is full ink, not muted */
  text-wrap: balance;
}
.swf-scroll__text p + p { margin-top: 1.5em; text-indent: 1em; }   /* theirs */
.swf-scroll__text em { font-family: var(--swf-display); font-style: italic; color: var(--swf-ink); }
@media (max-width: 80rem) {                    /* their 1280px stack */
  .swf-scroll__text { margin: 2.5em 0 0; width: 100%; max-width: 27.5em; }
}
@media (prefers-reduced-motion: reduce) {
  /* no sweep, no drift — the sentence simply reads (root §4d: a refusal must still
     leave something on the screen). */
  html.swf-js .swf-scroll__char::after { opacity: 1; }
  .swf-scroll__title { transform: none; will-change: auto; }
}

/* ── the ladder, now two bands of flying type (WD-326) ──────────────────── */
/* ── 1 · THE TWO BOXES (WD-334, Neil 2026-08-19) ──────────────────────────────
   "maybe because we're comparing grade four to six and seven to nine, maybe they
   should actually be in boxes. On the left box, you have grade four to six and
   seven to nine in the right box."

   ⭐ THE SURFACES ARE THE PAGE'S OWN, NOT NEW ONES. The retired `.swf-ladder__row`
   already established floor = `--swf-base` + the small shadow, top = `--swf-raised`
   + the large one. That pairing is kept; only the layout is new.

   ⛔ BOTH BOXES ARE IDENTICAL IN STRUCTURE, and that is the whole point. Neil, on
   the previous cut: "why is some of the text aligned to the left? Why is some of it
   [centred]? I don't understand why it's so inconsistent." Two rungs of one ladder
   are the same kind of thing, so they get the same treatment — same padding, same
   type, same alignment. The ONLY differences carry meaning: the surface, the colour
   and the glow, which say which one is higher. */
.swf-rungs {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--swf-xl); margin: var(--swf-step) 0 0;
}
/* ⭐ THE WORDS REST ON THE FLOOR OF THE BOX (WD-338). Neil: "I want them to fall to the
   bottom of the container and lie at the bottom. At the moment, they're doing something
   else." He is right — they were dropping into a centred stack, which is arriving, not
   falling. The label is pinned to the top, `margin-bottom:auto` pushes the words to the
   floor, and `min-height` gives them the height to fall THROUGH. Both boxes are identical,
   so the two rungs still read as the same kind of thing. */
.swf-rung {
  display: flex; flex-direction: column; gap: clamp(1.75rem, 4vw, 40px);
  /* ⭐ MEASURED, and it is why the first attempt did not read as falling: at 352px the
     three words occupied 156-304px, leaving 70px above and 48px below — visually a
     centred stack. Words resting on a floor need the EMPTINESS ABOVE THEM to be the
     obvious thing. Taller box, words hard against the bottom padding. */
  min-height: clamp(20rem, 34vw, 30rem);
  padding: clamp(2rem, 4vw, 48px) clamp(1.5rem, 3vw, 40px);
  border-radius: var(--swf-r-lg); text-align: center;
}
/* ⭐⭐ BOTH BOXES START IDENTICAL, AND THAT IS THE WHOLE POINT (Neil, 2026-08-19):
   "the grade four to six criteria should do that, but the grade seven to nine ones should
   just have that nice sparkle. They should stay in their nice elevated position. But instead,
   both sets of criteria fall down slightly and it doesn't make any sense."
   He is right, and it is the idea the section was missing: the two columns are level with
   each other, then ONLY the 4-6 words let go and collapse to the floor. The 7-9 words never
   move. The asymmetry IS the argument — you can see which grade stays up.
   So the list is CENTRED in the space below the label (the elevated position, the same in
   both boxes) and takes the rest of the box, which is also the stage the words fall through. */
.swf-rung__list { flex: 1 1 auto; justify-content: center; }
/* ⛔ EACH WORD SHRINK-WRAPS ITS TEXT. MEASURED: as block children of the flex column the
   `li`s stretched to the full 340px stage width, so every physics body was a full-width
   slab — they could not tilt (no room either side) and stacked like pancakes instead of
   heaping. Appearance is unchanged (the text was already centred); what changes is that a
   word is now the size of the WORD. */
.swf-rung__list li { align-self: center; }
/* ⛔ WD-390 — THE SURFACES MOVED TO `.swf-rung__deco`. They cannot stay here: the decorator
   sits behind the content and GROWS from 8.3%, so a background on the rung itself would cover
   it and the entire expand would be invisible. See the Spec Cards block further down. */
.swf-rung__grade {
  margin: 0; font-family: var(--swf-display); font-style: italic; font-weight: 400;
  font-size: clamp(1.05rem, .8rem + .8vw, 1.6rem);
  letter-spacing: .38em; text-indent: .38em;      /* the tracking would otherwise sit it left of centre */
}
.swf-rung.is-floor .swf-rung__grade { color: var(--swf-absent); }
.swf-rung.is-top   .swf-rung__grade { color: var(--swf-purple-lt); }
.swf-rung__list {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--swf-lg);
  font-family: var(--swf-font); font-weight: 700;
  font-size: clamp(1.35rem, .9rem + 1.4vw, 2.1rem);
  line-height: 1.15; letter-spacing: -.02em;
}
.swf-rung.is-floor .swf-rung__list { color: var(--swf-ink-soft); }
.swf-rung.is-top   .swf-rung__list { color: var(--swf-teal); }
.swf-rungs__note { max-width: 40rem; margin-top: var(--swf-step) !important; }
@media (max-width: 720px) { .swf-rungs { grid-template-columns: 1fr; } }

/* ── the two gestures (WD-337, Neil 2026-08-19) ──────────────────────────────
   "the grade four to six criteria, clear, relevant, and accurate… so maybe just
   after it enters the viewport, it falls down. Right? So the words actually fall,
   and then the grade seven to nine have the sparkle effect."

   ⭐ FAIL-SAFE, unchanged: the hidden state applies ONLY under `html.swf-js`. No JS,
   a blocked file or an optimiser eating this rule leaves all six words plainly on
   screen — never a blank box (root §4d). */

/* ── 1 · THE FALL — owned by fall.js + matter.js, NOT by CSS ─────────────────
   The CSS keyframe fall that used to live here is retired: physics writes `transform`
   every frame, and a CSS animation writing the same property is a fight, not a fallback.
   ⭐ THE FAIL-SAFE IS NOW THE LAYOUT ITSELF — with no matter.js, no JS, or reduced motion,
   the words simply stay in their elevated position, exactly level with the 7-9 column.
   That is a correct, readable screen, which is what §4d asks for. */
.swf-js .swf-rung.is-floor .swf-rung__list li { opacity: 1; }

/* ── 2 · THE SPARKLE ─────────────────────────────────────────────────────────
   Ported from `Text Effects for Sophicly/Magical Text Effect.html`. Timings are the
   source's exactly: scale 700ms ease forwards · rotate 1000ms linear infinite ·
   background-pan 3s linear infinite · star size clamp(20px, 1.5vw, 30px) · svg
   opacity .7. Only the three colour stops changed, and only because the source's
   measure 1.85:1 and 2.07:1 on this surface (see the note in the PHP). */
@keyframes swfMagicScale  { from, to { transform: scale(0); } 50% { transform: scale(1); } }
@keyframes swfMagicRotate { from { transform: rotate(0deg); } to { transform: rotate(180deg); } }
@keyframes swfMagicPan    { from { background-position: 0% center; } to { background-position: -200% center; } }

/* ⛔⛔ THE SPARKLE WRAPPER MUST BEHAVE EXACTLY LIKE THE 4-6 STAGE, or the two columns do
   not start level — and starting level is the entire point (Neil: "they should stay in their
   nice elevated position", i.e. where the 4-6 words WERE before they fell).
   MEASURED at 1.12.5: `.swf-magic` was `display:block`, so it neither filled the box nor
   centred its list. The 7-9 words sat jammed under the label at the TOP while the 4-6 words
   fell from the MIDDLE — the columns were never parallel, so the collapse read as two
   half-empty boxes instead of one column letting go. It mirrors the stage now. */
.swf-magic {
  position: relative;
  flex: 1 1 auto;
  display: flex; flex-direction: column; justify-content: center;
}
/* inside the wrapper the list is content, not the stage — it must not stretch as well */
.swf-magic .swf-rung__list { flex: 0 0 auto; }
.swf-magic-star {
  --size: clamp(20px, 1.5vw, 30px);
  display: block; position: absolute; pointer-events: none;
  width: var(--size); height: var(--size);
  left: var(--star-left, 50%); top: var(--star-top, 50%);
  opacity: 0;                                   /* nothing sparkles until the box arrives */
}
.swf-rung.is-top.is-in .swf-magic-star { opacity: 1; animation: swfMagicScale 700ms ease forwards; }
.swf-magic-star > svg { display: block; width: 100%; height: 100%; opacity: .7;
  animation: swfMagicRotate 1000ms linear infinite; }
/* ⛔ NOT `--swf-mint`: in LIGHT theme that token is Brand Deep Purple #2c003e (it is the
   gradient's dark mid stop), so the star rendered as a small dark blob beside the words —
   it read as a smudge, not a sparkle. A sparkle is a highlight, so it takes the accent that
   is BRIGHT against its own surface in each theme. */
.swf-magic-star > svg > path { fill: var(--swf-teal); }

/* the words themselves carry the moving gradient — the source's `.magic-text` */
.swf-rung.is-top.is-in .swf-magic-text {
  background: linear-gradient(to right,
    var(--swf-teal), var(--swf-mint), var(--swf-purple-lt), var(--swf-teal));
  background-size: 200%;
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
  animation: swfMagicPan 3s linear infinite;
}

/* ⛔ REDUCED MOTION: the words are the content, so they are simply there — no fall,
   no sparkle, and the gradient is dropped entirely so the text keeps its solid
   colour rather than depending on a paint it cannot animate (root §4d). */
@media (prefers-reduced-motion: reduce) {
  .swf-js .swf-rung.is-floor .swf-rung__list li,
  .swf-js .swf-rung.is-floor.is-in .swf-rung__list li { opacity: 1; animation: none; }
  .swf-rung.is-top.is-in .swf-magic-star { display: none; }
  .swf-rung.is-top.is-in .swf-magic-text {
    background: none; -webkit-text-fill-color: var(--swf-teal); color: var(--swf-teal); animation: none;
  }
}

/* ── 3 · THE TALLY — because nobody reads paragraphs (WD-338) ────────────────
   Neil: "You just basically written like two paragraphs there. Nobody's gonna read
   paragraphs. It needs to be more interesting than just paragraphs."
   The point of this beat is REPETITION — every component of every paper resolves to
   the same verdict — so the layout says it three times instead of describing it once.
   The right-hand column repeating verbatim IS the argument. */
.swf-tally { display: flex; flex-direction: column; gap: 0; margin: 0 0 var(--swf-step); }
.swf-tally__row {
  display: grid; grid-template-columns: 1fr auto; gap: var(--swf-lg);
  align-items: baseline; padding: clamp(1.1rem, 2.6vw, 1.6rem) 0;
  border-bottom: 1px solid color-mix(in srgb, var(--swf-ink) 12%, transparent);
}
.swf-tally__row:first-child { border-top: 1px solid color-mix(in srgb, var(--swf-ink) 12%, transparent); }

/* ══════════════════════════════════════════════════════════════════════════════════════
   THE HIGHLIGHTER ON THE TALLY ROWS (Neil, 2026-08-21: "the SWF tally section should have a
   little hover effect… have a look at the Sophicly footer highlighter port, the hover effect
   over the list — I think that's really nice").
   SOURCE: `CSS Shine Effect/Sophicly Footer Highlighter - PORT.html`, all 8 rules of the `.hl`
   family read (root §14c Gate 1). That file is itself a measured port — its header records a
   rebuild after Neil called the first cut "a guess", so its numbers are frame-captured, not
   invented, and they are carried here unchanged:
     ENTER  marker opacity 0 -> 1, label colour, and a 24px indent — ALL IN ONE FRAME
     LEAVE  marker 420ms · indent 250ms · colour 230ms, all cubic-bezier(.33,1,.68,1)
   ⭐ "SNAP ON, EASE OFF" IS THE CHARACTER, and it is bought by declaring the transitions on the
   REST state and killing them with `transition:none` on hover. Do not "tidy" that into a normal
   hover transition — it inverts the effect.
   ⛔ THE SECOND CANDIDATE, and why it is not the one (root §14b): Neil also offered the Track
   Record port. Its `.tr-plate` is a scaleX REVEAL fired once on scroll-in by GSAP, not a hover at
   all; the hover in that file is `.trow.lit { background: var(--plate) }` — a solid fill scoped,
   in its own comment, to the TABLE because "applying it to a big stat tile produced a purple slab".
   Our tally row is wide and two-ended, so a solid fill would swamp "marked on what you wrote",
   which is the punchline. The highlighter is the row-list shape; the plate is the tile shape.
   ⛔ TWO ADAPTATIONS, STATED: his marker is `inset: 0` of a 24px row that has no padding, so it
   ends exactly at the text. Ours bleeds 16px past each end so the stroke reads as a highlighter
   over a wide row rather than a filled box, and the RESTING alignment of the column is unchanged.
   And only the LEFT column indents — the right column is the verdict and holding it still is what
   makes the repetition land. */
.swf-tally__row { position: relative; }
.swf-tally__what, .swf-tally__mark { position: relative; z-index: 1; }

@media (hover: hover) {
  .swf-tally__row::before {
    content: ''; position: absolute; inset: 0 calc(var(--swf-lg) * -1); z-index: 0;
    background: var(--swf-marker); opacity: 0;
    transition: opacity 420ms cubic-bezier(.33, 1, .68, 1);   /* his LEAVE */
  }
  .swf-tally__what {
    transition: transform 250ms cubic-bezier(.33, 1, .68, 1),
                color 230ms cubic-bezier(.33, 1, .68, 1);     /* his LEAVE */
  }
  .swf-tally__mark { transition: color 230ms cubic-bezier(.33, 1, .68, 1); }

  .swf-tally__row:hover::before { opacity: 1; transition: none; }                 /* his ENTER */
  .swf-tally__row:hover .swf-tally__what {
    transform: translateX(24px); color: var(--swf-marker-ink); transition: none;  /* his 24px */
  }
  .swf-tally__row:hover .swf-tally__mark { color: var(--swf-marker-ink); transition: none; }
}
@media (prefers-reduced-motion: reduce) {
  .swf-tally__row::before, .swf-tally__what, .swf-tally__mark { transition: none !important; }
  .swf-tally__row:hover .swf-tally__what { transform: none; }
}
.swf-tally__what {
  font-family: var(--swf-font); font-weight: 700; color: var(--swf-ink);
  font-size: clamp(1.15rem, .85rem + 1.1vw, 1.75rem); letter-spacing: -.02em; line-height: 1.15;
}
.swf-tally__mark {
  font-family: var(--swf-display); font-style: italic;
  /* ⭐ INK-SOFT, NOT ACCENT (WD-345). Risen's law — "black and white do the work;
     grey does the hierarchy; the accent appears once per screen" (brand doc §3).
     This verdict in teal ×3, plus the headline em, plus the closing link, put
     FIVE accent hits in one viewport. The repetition is the argument; it does
     not also need the loudest colour. The beat's one accent is the close. */
  color: var(--swf-ink-soft);
  font-size: clamp(1rem, .8rem + .7vw, 1.35rem); white-space: nowrap;
}
/* ⛔ `.swf-tally__close` IS GONE (WD-369). That paragraph became a full-bleed band — Neil,
   2026-08-23: "that should be turned into an SWF parallax section, just that." The rules are
   deleted rather than left behind: an unused selector is the thing a later reader copies. */
@media (max-width: 560px) {
  .swf-tally__row { grid-template-columns: 1fr; gap: 4px; }
  .swf-tally__mark { white-space: normal; }
}

/* ── a STATEMENT band: the full-bleed type of a band with no photograph and no
   parallax (WD-338). Neil, counting them: "you've got two scrollable parallax
   sections… Actually, go three." Four photo-parallax bands is one device wearing
   four hats. Two keep the photograph — the opening statement and the proof — and
   the middle two become this, so the page has a rhythm instead of a tic. */
.swf-statement {
  display: grid; place-items: center; text-align: center;
  padding: var(--swf-band) var(--swf-lg);            /* shape C and D are ONE box */
  background: var(--swf-base);
  margin: 0;                     /* padding owns the gap — token block */
}
.swf-statement__in { max-width: 46rem; }

/* ⭐ A BAND WITH NO PHOTOGRAPH YET (WD-362). The veil is the band's readability ground and it is
   dark in both themes, so with no `__img` beneath it the section reads as the band shape over the
   page's own colour — full-bleed, light serif italic, the same padding — rather than the pale
   `--swf-base` slab the statement shape used. Give it a photograph and this class comes off. */
.swf-band--flat .swf-band__veil { background: var(--swf-page); }

/* ⭐ WHERE TWO BAND-TIER SECTIONS MEET, THE SECOND PAYS THE BEAT TIER. Both tiers are
   right on their own, but stacked they give 150 + 150 = 300px where every other junction
   on the page is 150 or 225 — MEASURED at 1440 between the two statements, and again
   between the second statement and the proof band. `/about/` never answers this question
   because it never puts two bands next to each other; ours does, twice. Dropping the
   SECOND one's top padding to `--swf-sect` lands it on 225 — the same junction value as
   every band-meets-beat on the page, so the whole spine reads 150 (beat/beat) or 225
   (anything touching a band) and nothing else. */
.swf-band + .swf-band,
.swf-band + .swf-statement,
.swf-statement + .swf-band,
.swf-statement + .swf-statement { padding-top: var(--swf-sect); }

/* ── 4 · AN ACTUAL LOOP — a ring that draws itself, with a head that travels it ── */
/* ══ WD-379 · THE RIBBON BAND — replaces the drawn loop (Neil, 2026-08-26) ═══════════════
   Ported from `CSS Shine Effect/Sophicly Ribbon Band - PORT.html`; the mechanism notes live in
   the PHP beside the markup. ⚠️ FULL-BLEED AND FULL-HEIGHT, and that is not a preference: the
   wave svg is `width: calc(100% + 100px)` with `height:auto` on a SQUARE viewBox, so it is as
   tall as it is wide. Boxed into a shorter band the same svg is the same width and therefore the
   same height, and you see only a sliver of the wave — the ratio between svg and box IS the
   design. The prototype's first cut made exactly that mistake. */
.swf-rb { position: relative; width: 100%; height: 100svh; min-height: 520px;
  overflow: clip; background: var(--swf-page); }
/* ⭐ WD-381b — THE BAND BUTTS THE PHOTO BAND BELOW IT. MEASURED, not judged: the beat carries
   `padding-block: var(--swf-beat)` (75px), so between the band's bottom edge and `band-6-cycle`
   sat a 75px strip of `--swf-page` (#1c1d1f) — while the band's own field BELOW the wave is
   `--swf-base` (#242628). Three tones and TWO hard horizontal edges inside 75px, which is what
   Neil saw: *"I am not sure what's going on with the color underneath it. There's too much
   separation."* The band's field split is the prototype's mechanism and stays; the empty strip
   between two full-bleed elements is what goes, leaving ONE section change instead of two.
   ⚠️ The TOP needs no such fix and must not get one — the field ABOVE the wave is already
   `--swf-page`, so the band's top edge is invisible against the heading's ground by design. */
.swf-beat--flush-b { padding-bottom: 0; }
/* ⭐ WD-384.1 — MEASURED: of the three `.swf-beat__h` on this page, this was the only one
   computing `text-align: start`; the criteria beat and the closing beat both centre. WD-376's
   ruling is that a FIGURE IS A CENTRED COMPOSITION, and a full-bleed band with a heading over it
   is exactly that — so the heading follows its figure. */
.swf-beat--flush-b > .swf-beat__h { text-align: center; }
/* the wave: full-bleed, vertically centred, overflowing top and bottom as theirs does */
.swf-rb__band { position: absolute; inset: 0; z-index: 0; overflow: hidden;
  display: flex; align-items: center; justify-content: flex-end; pointer-events: none;
  transform: translate3d(0, var(--swf-px, 0px), 0); will-change: transform; }
/* ⛔ `max-width: none` IS LOAD-BEARING, and it is the one line the prototype does not need.
   The theme ships a global `svg { max-width: 100% }`; inside it this svg computed to 1440px
   instead of the 1540 the prototype measures, so the wave was 100px narrower and — because the
   viewBox is SQUARE and the height follows the width — 100px shorter as well. Caught by diffing
   the live page against the prototype file (1440 vs 1540), not by looking. */
.swf-rb__band svg { display: block; width: calc(100% + 100px); min-width: 100%; max-width: none;
  height: auto; margin-left: -50px; margin-right: -50px; overflow: visible; }
/* the two loops, at their measured boxes and offsets, scaled by ONE token */
.swf-rb__loop { position: absolute; pointer-events: none; }
.swf-rb__loop svg { display: block; width: 100%; height: 100%; overflow: visible; }
/* ⭐ WD-381a — THE PARALLAX RIDES ON TOP OF THE PORTED GEOMETRY, IT DOES NOT REPLACE IT.
   `translate3d(...) rotate(...)` applies RIGHT TO LEFT, so each loop is rotated by its ported
   angle first and THEN moved along the PARENT's vertical axis — the rotation is untouched and
   the travel is screen-vertical, not tilted. ⛔ Never reorder these two functions. */
.swf-rb__loop--a { z-index: 4;
  width: calc(769px * var(--swf-rb-k)); height: calc(750px * var(--swf-rb-k));
  bottom: calc(-2px * var(--swf-rb-k)); left: calc(-157px * var(--swf-rb-k));
  transform: translate3d(0, var(--swf-px, 0px), 0) rotate(210deg); will-change: transform; }
.swf-rb__loop--b { z-index: 1;
  width: calc(612px * var(--swf-rb-k)); height: calc(723px * var(--swf-rb-k));
  top: calc(-322px * var(--swf-rb-k)); right: calc(-177px * var(--swf-rb-k));
  transform: translate3d(0, var(--swf-px, 0px), 0) rotate(225deg); will-change: transform; }
.swf-rb__text { font-family: var(--swf-font); font-weight: 700; text-transform: uppercase;
  line-height: 1em; user-select: none; -webkit-user-select: none; }
.swf-rb__probe { visibility: hidden; opacity: 0; pointer-events: none; }
/* ⛔ THE FOUR PHASES ARE THE ARGUMENT, so they are real text until the ribbons exist — and the
   hide is applied by the same script that draws them, never by CSS waiting on JS. */
.swf-rb__fallback { position: relative; z-index: 6; margin: 0; padding: var(--swf-xl);
  list-style: none; display: grid; gap: var(--swf-md); place-content: center; height: 100%;
  text-align: center; font-size: clamp(18px, 1.4rem + 1vw, 32px); font-weight: 600; color: var(--swf-ink); }
[data-built] .swf-rb__fallback { display: none; }
/* their own two breakpoints, one token (the prototype's deviation E) */
/* the third ribbon colour — a BRAND supporting blue (BRAND.md §1), theme-aware because in LIGHT
   `--swf-teal` already resolves to #4D76FD and the two would collide. */
:root { --swf-rb-k: 1; --swf-rb-alt: #42A1EC; }
html[data-theme="dark"], html:not([data-theme="light"]) { --swf-rb-alt: #4D76FD; }
@media (max-width: 1199.98px) { :root { --swf-rb-k: .68; } }
@media (max-width: 809.98px)  { :root { --swf-rb-k: .46; } }

.swf-cap--lead { max-width: 34rem; margin-top: 0; margin-bottom: var(--swf-2xl); font-size: 1.02rem; }

/* WP's utility class, restated locally so the sr-only chapter names cannot depend
   on a theme stylesheet RUCSS might strip (WD-345). */
.swf .screen-reader-text {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* ── the OPENING band (WD-346): his `.serveQuoteContent` is the tallest thing on
   /serve/ — 200px of padding around a 750px column. This one opens the page, so it
   carries his measure exactly and its line uses `.serveQ`'s italic voice, which
   `.swf-band__p` already is. */
/* ⛔⛔ WD-476 — RAISED TO 1000px, AND THE OLD 750 WAS THE RIGHT NUMBER OFF THE WRONG ELEMENT.
   MEASURED after porting the quote ladder: at 900 and 1440 the headline computed **750px wide**
   instead of their 840 and 1000, because this cap sat above it — so the type size was right and
   every line still broke in the wrong place, which is half the look.
   `.whoWeAreQuote` lives in `.whoWeAreQuoteContent`, which is `width: 100%` with NO cap; the
   measure IS the ladder (270 → 350 → 500 → 600 → 840 → 1000). The 750 was ported from their HERO
   content column, a different component — the same class of mistake as the `letter-spacing: .02em`
   removed just below, which took the hero h1's tracking and put it on a band.
   ⭐ The PARAGRAPH keeps a reading measure of its own; only the headline follows the ladder. */
.swf-band--open .swf-band__in { max-width: 1000px; }
/* ⭐ WD-476b — A WIDER MEASURE FOR OUR LONGER COPY, and the number is measured, not chosen.
   Neil: *"This section is now three lines. I think we need to get it down to two."* The authored
   line carries its own `<br>`, so two lines is the intended shape — but MEASURED at 76px Playfair
   italic, "The examiner's criteria are published." needs **1226px** against Risen's 1000px cap, so
   it wrapped again inside its own half.
   ⛔ THE LADDER IS NOT CHANGED — 1000px is right for THEIR longest quote ("Risen is committed to
   unity, liberty, and love in Jesus Christ."). Ours is simply a longer sentence, so the OPEN band
   gets a wider measure; every other band keeps §16.2 exactly. `max-width: 100%` still guards a
   narrow window, and the value clears the measured 1226 with room for the descender-heavy italic. */
@media screen and (min-width: 1024px) {
  .swf-band--open .swf-band__in,
  .swf-band--open .swf-band__h { max-width: 1280px; width: 1280px; }
}
/* ⛔ WD-476 — REMOVED. This copied the tracking from their HERO h1 (`#aboutTopContent h1`,
   `letter-spacing:3px` on an uppercase sans) onto the BAND headline, which is a different
   component: `.whoWeAreQuote` declares no tracking at all. Borrowing a value from the right site
   but the wrong element is still an invented value. */


/* ══════════════════════════════════════════════════════════════════════════
   THE LENS, ON THE OPENING PHOTO BAND (WD-347, Neil 2026-08-20).
   `.swf-lens` itself is untouched — this only re-dresses it for a dark
   photographic ground and the band's type scale.
   ⚠️ INK FOLLOWS THE SHAPE'S GROUND, not the theme (the WD-345 rule): the veil
   over a band photograph is dark in BOTH themes, so both lines are light here
   and the payoff word takes the mint the other bands already use.
   ══════════════════════════════════════════════════════════════════════════ */
.swf-band--open .swf-lens { max-width: 100%; }         /* WD-505: the band ladder's width (1000px at ≥1024), so "the skill of your writing." holds one line at 76px */
.swf-band--open .swf-lens__base,
.swf-band--open .swf-lens__under {
  /* the BAND's headline scale (his h1: 28 -> 65px), not the opener's old display
     clamp — the lens is furniture on a band now.
     ⛔ THE FACE WAS THE ONE THAT GOT AWAY. This override was added when the lens moved onto
     the band and it set `--swf-font` / `normal` — so the opener pair had been rendering SANS
     ever since, while the rule 800 lines above still carried the WD-331 note saying the pair
     is Playfair italic. MEASURED after the WD-360 change: five of the seven `.swf-band__h`
     computed as Playfair italic and these TWO computed as `"Proxima Soft Complete" normal 700`.
     A later, more specific rule beats a correct comment; only a computed read finds it. */
  /* WD-505: no type here any more — `.swf-band__h`'s ladder is the only size, weight and
     leading these lines have, exactly like every other band. */
}
.swf-band--open .swf-lens__base  { color: #f4f4f8; }
.swf-band--open .swf-lens__under { color: #f4f4f8; margin-top: 0; }
.swf-band--open .swf-lens__base em,
.swf-band--open .swf-lens__under em {
  font-family: var(--swf-display); font-style: italic; color: #7DF9E9;
  letter-spacing: -.01em;
}
/* on a photograph the glass reads best with his own values — the light-theme
   softening below must not apply here, so it is restated after it. */
.swf-band--open .swf-lens.is-lens .swf-lens__glass {
  border: 1px solid rgba(255,255,255,.3);
  background: radial-gradient(circle at center, rgba(255,255,255,.15) 0%, rgba(255,255,255,.05) 100%);
  box-shadow: 0 8px 32px rgba(0,0,0,.28), inset 0 2px 4px rgba(255,255,255,.6), inset 0 -2px 4px rgba(0,0,0,.1);
}

/* ══════════════════════════════════════════════════════════════════════════
   BEAT 2 — HIS `.wwaContent` BLOCK (WD-351, Neil 2026-08-20)
   Source he named: /whoweare `.risenSubContent > .wwaContent > .aboutH1`.
   Values re-read from the mirror rather than from my own earlier summary
   (root §14b habit 4 — a late-session summary is lossy toward the hypothesis).

     .aboutH1     base   20px Times italic 400 · letter-spacing 5px  · #555 · margin 0 0 40px
                  400px  26px                  · letter-spacing 5px  ·        margin 0 0 40px
                  620px  26px                                        ·        margin 20px 0 40px
                  860px  26px                  · letter-spacing 10px ·        margin 15px 0 70px
                  1024px 26px                  · letter-spacing 10px ·        margin 70px 0 100px
     .wwaContent  base   padding 20px 0 30px · margin 0 auto
                  801px  padding 20px 0 40px
                  1024px width 960px
     .aboutBelieve 768px margin 50px 0 0

   ⛔ DEVIATIONS, STATED (root §13): his Times italic is our Playfair italic (the
   house serif — BRAND.md §3); his flat `#555` becomes `--swf-ink-soft` so it holds
   in both themes; and his tracking is given in `em` (10px on 26px = .385em) so it
   scales with the fluid size instead of breaking at one breakpoint.
   ⚠️ `.wwaContent`'s 960px cap is NOT applied — this beat is `--wide` because the
   Skill Cloud is a 1400px component and was squeezed once before (the comment at
   the top of this file). His spacing carries over; his column does not.
   ══════════════════════════════════════════════════════════════════════════ */
.swf-beat__h--lead {
  font-family: var(--swf-display); font-style: italic; font-weight: 400;
  /* ⭐⭐ THE PROPERTY I MISSED WAS ALIGNMENT (WD-352, Neil: "you haven't styled this
     like Risen Church's"). MEASURED on his live /whoweare: `text-align: center`,
     inherited from `body { text-align: center }` — his whole site is centred by
     default and only reading columns (`.risen2C`, `.abLeft`) opt back out. I had
     matched his size, tracking and margins to the pixel and left it ranged left,
     which is why it still did not look like his.
     Also measured and now matched: `line-height: normal` (I had 1.35) and
     `color: rgb(85,85,85)`.
     ⭐ THE LESSON: I diffed the properties I had CHOSEN to set. His value came from a
     property I never wrote down, inherited three levels up. When a port "matches on
     every number" and still looks wrong, diff the COMPUTED style of both, not the
     declarations you wrote (root §14c Gate 2). */
  text-align: center;
  line-height: normal;
  color: var(--swf-ink-soft);
  text-transform: none;
  /* ⛔⛔ HIS STEPS, NOT A CLAMP. First cut used `clamp(1.25rem, 1rem + .55vw, 1.625rem)`
     and MEASURED 23.92px at 1440 — his is a flat 26px from 400px up, and the clamp
     would not have reached it until ~1745px. Same for the margin: `clamp(2.5rem, 6vw,
     6.25rem)` measured 86.4px against his 100px.
     THIS IS THE §14.6 RULE I WROTE AND THEN BROKE AGAIN — "when porting a stepped
     ladder, port the STEPS." A clamp cannot express a step function. */
  font-size: 20px; letter-spacing: 5px;      /* his base */
  margin: 0 0 40px;
}
@media (min-width: 25rem)   { .swf-beat__h--lead { font-size: 26px; } }               /* his 400px  */
@media (min-width: 38.75rem){ .swf-beat__h--lead { margin: 20px 0 40px; } }           /* his 620px  */
@media (min-width: 53.75rem){ .swf-beat__h--lead { letter-spacing: 10px;
                                                   margin: 15px 0 70px; } }           /* his 860px  */
@media (min-width: 64rem)   { .swf-beat__h--lead { margin: 70px 0 100px; } }          /* his 1024px */

/* ⛔ THE GAP UNDER "Examiner's Grade 9 Criteria" — Neil, 2026-08-21: "one of the biggest
   issues is the space beneath it… even on larger screens the gap is quite big."
   MEASURED heading-bottom to first accordion row: 345px @1440 · 316px @1024 · 146px @390.
   FOUR spacers were stacked in a row: his own `.aboutH1` margin-bottom (100px — a PORTED
   value, and correct), then `.ssc-band` margin-top 48, then `.ssc` padding-top 150, then
   `.ssc-stage` margin-top 48. His 100px IS the whole gap on his page; ours added 246px of
   our own on top of it. The three of ours are gone — the panel keeps one `--swf-step` of
   inset so its rows are not flush to its own edge. */
.swf-beat--criteria .ssc-band { margin-top: 0; }


/* ══════════════════════════════════════════════════════════════════════════
   THE GRAPHS (WD-369 / WD-369c) — evidence for the band above it
   --------------------------------------------------------------------------
   ⭐⭐ THEY ARE SCROLL-SCRUBBED, NOT PLAYED ON ENTRY. Neil, 2026-08-24, having
   asked for animated graphs twice: *"it's not animated in any way. The whole
   point is I wanted the animated graphs."*

   ⛔ HE WAS RIGHT, AND THE FIRST VERSION WAS ANIMATED — WHERE NOBODY COULD SEE
   IT. The animations were CSS keyframes fired by `.is-in`, which funnel.js adds
   when a BEAT enters the viewport. All three figures live in ONE beat, so the
   moment the first graph appeared, all three ran — and the second and third were
   still a screen and a half below the fold. By the time you scrolled to them
   they had finished. Every check passed: the markup was right, the keyframes
   were right, the resting state was right. **"It animates" and "you can watch it
   animate" are different claims, and only the second one was ever the ask.**

   ⭐ SO IT NOW WORKS THE WAY THE REFERENCE DOES. tympanus.net/Tutorials/ScrollGraph
   scrubs its timeline against scroll position (`scrub: 1`); the line does not
   play, it DRAWS AS YOU SCROLL. `initGraphScrub()` in funnel.js sets `--p` (0→1)
   on each figure from its own position in the viewport, mirroring the parallax
   driver in the same file, and every value below is a function of `--p`.
   No pinning (that would take the scroll away) and no GSAP plugins (DrawSVG and
   MotionPath are not in the site bundle; `stroke-dashoffset` and `offset-path`
   are their exact native equivalents, already used by `.swf-loop`).

   ⛔ `--p` DEFAULTS TO 1 — FINISHED. If the script never runs, is stripped by
   RUCSS, or reduced-motion is on, the reader gets the completed chart rather
   than an empty box (§4d: a guard must state what appears instead).
   ══════════════════════════════════════════════════════════════════════════ */

.swf-graphs { display: grid; gap: calc(var(--swf-band) * 1.1); }
/* ⭐ WD-407b (Neil, 2026-08-27): *"the next section needs to somehow come in sooner as well,
   because there will be a big gap; maybe reduce the spacing to the next section?"*
   He is right, and the reason is structural rather than a spacing preference: EVERY other section
   on this page ends on its own content, so the standard band rhythm is measured from something
   the reader can still see. The rail ends on a PIN RELEASING — the last card is already sliding
   away — so the usual gap is paid on top of a viewport that is already emptying, and it reads as
   twice the space it is. The rail therefore hands over on a tighter beat than the ladder does. */
.swf-graphs > .swf-sr { margin-bottom: calc(var(--swf-band) * -0.55); }
/* WD-662 — THE CURTAIN. Live layout only (the static phone layout has no pin and no curtain): the rail pulls up
   under the marks figure by the hold, and the marks figure paints the page ground ABOVE it. Both halves are
   required — a transparent curtain unveils nothing. */
html.swf-js .swf-graphs > .swf-sr:not(.is-static) { margin-top: calc(var(--swf-sr-hold) * -1); }
html.swf-js .swf-graphs > .swf-fig[data-fig="marks"] { position: relative; z-index: 1; background: var(--swf-page); }
/* WD-672 (Neil, 2026-09-02, measured): "the space at the bottom is not the same as the spacing at the top". Above the
   eyebrow the figure has the beat's ground (`--swf-sect`, 75px); below the key it had only the panel's own 48px
   (`--swf-3xl`) and then the plate began — 75 vs 48. The curtain now breathes the same at its foot: its bottom
   padding tops the panel's 48 up to one `--swf-sect`, so the plate edge sits 75 under the key. `::before` spans the
   padding box, so the curtain covers it; the hold reads the figure's border box, so the pin still meets this edge.
   Phones: sect (25/40) < 48 → 0, unchanged. */
html.swf-js .swf-graphs > .swf-fig[data-fig="marks"] { padding-bottom: max(0px, calc(var(--swf-sect) - var(--swf-3xl))); }
/* the curtain is FULL-BLEED: the figure is the content column, the plate under it is the viewport, so the curtain
   paints out to the viewport edges the way the contact topo does (`left:calc(50% - 50vw); width:100vw`). */
html.swf-js .swf-graphs > .swf-fig[data-fig="marks"]::before { content: ""; position: absolute; top: 0; bottom: 0; left: calc(50% - 50vw); width: 100vw; background: var(--swf-page); z-index: -1; }
.swf-sr + * { margin-top: 0; }
/* ⛔ WD-669 (Neil, 2026-09-02, measured): the hold is ONE VIEWPORT + the gap, but the curtain is only ~595px tall,
   so the rail's plate poked 300–700px ABOVE the marks figure (402px at 1564×997) and painted over the band before
   it — "And almost none of it is ever redrafted." was sliced in half. The reference never meets this because its
   curtain block IS 100lvh. The rail therefore clips its own excess above the curtain's top edge: paintRail measures
   `curtain.top − rail.top` (0 when the curtain is a full viewport or taller) and writes it as `--swf-sr-excess`.
   Everything below the curtain's top edge — the reveal beneath its bottom edge — is unchanged. Live layout only. */
/* ⛔ WD-671 (Neil, 2026-09-02, measured): the pin releases with ~416px of bare plate under the last card (it has
   lifted away top-left by then), then the WD-407b 68px, then the next heading — ~500px against a 150px beat. The
   rail now ends where its content ends: the plate is clipped `--swf-sr-tail` px above the pin's bottom and the next
   section is pulled up by the same amount (this margin SUPERSEDES the WD-407b guess above for the live layout).
   tail = pin − last card's bottom at p = 1 − sect, measured by measureTail() in funnel.js. So the plate edge sits one
   `--swf-sect` under the last card and the next heading one beat (2 × sect = 150px) under it — the tighter beat
   WD-407b asked for, now exact. Live layout only; the static layout keeps the rule above. */
html.swf-js .swf-graphs > .swf-sr:not(.is-static) {
  clip-path: inset(var(--swf-sr-excess, 0px) 0 var(--swf-sr-tail, 0px) 0);
  margin-bottom: calc(-1 * (var(--swf-sr-tail, 0px) + var(--swf-sect)));
}

/* ⭐ FULL COLUMN WIDTH. Neil, 2026-08-24: *"why is it aligned to the left leaving
   a huge gap on the right?"* The figures were capped at 46rem inside a 1240px
   column, so every graph sat in the left 60% with 500px of dead space beside it.
   The PLOT now takes the column; only the prose is held to a reading measure. */
.swf-fig { margin: 0; display: grid; gap: var(--swf-step); --p: 1; --d: var(--p); }
/* ⭐ WD-376 — THE FIGURES ARE CENTRED COMPOSITIONS. Neil, 2026-08-26: *"why is everything aligned
   to the left? I don't get it… Why am I having to remind you over and over again?"* The house
   band (Risen shape C — BRAND-RISEN §14.2, the shape `/about/` is built on) is a CENTRED column
   of at most 750px: headline, then the line under it. A figure is that shape with a chart in the
   middle. BRAND.md §9 "left-align text" governs running prose, not a centrepiece. */
.swf-fig--centred { justify-items: center; text-align: center; }
.swf-fig--centred .swf-fig__h { max-width: 24ch; }
.swf-fig--centred .swf-fig__cap { max-width: 46rem; }
.swf-mono--centred { width: 100%; }
.swf-mono--centred .swf-mono__head { justify-content: center; }
.swf-mono--centred .swf-mono__foot { max-width: 760px; margin: 0 auto; width: 100%; }

/* ══════════════════════════════════════════════════════════════════════════
   WD-370 · THE PINNED CAMERA STAGE — the line figure only
   ──────────────────────────────────────────────────────────────────────────
   Ported from `Sophicly Scroll Graphs - PORT.html`: a tall block whose child
   sticks for the length of it, so `--p` is how far the block has travelled
   past the top of the viewport rather than a viewport fraction. Native sticky,
   no JS pin — a JS pin costs a layout shift on entry.

   ⭐ `--d` IS THE DRAW FRACTION, `--p` IS THE SCROLL. They are not the same
   number and conflating them is the prototype's own documented trap: the line
   must FINISH before the scrub ends, so the camera has room to land and the
   last dot pops onto a settled frame. `--d = clamp(p / 0.9)`, and 0.9 is not a
   new magic number — it is the SAME 0 → 0.9 threshold span the dots already
   carry (WD-369c), so the last dot lights exactly as the camera arrives.
   It defaults to `var(--p)`, so the two unpinned figures are untouched.
   ══════════════════════════════════════════════════════════════════════════ */
/* ⛔ THE CAMERA'S TWO GROUPS ARE DRIVEN BY THE CSS `transform` PROPERTY, not the SVG
   `transform` attribute — see the long note in funnel.js. These two declarations are what
   make the CSS property mean exactly what the attribute means: `view-box` so the units are
   viewBox user units, and origin `0 0` because CSS otherwise defaults the origin to the
   CENTRE of the box, which silently re-frames every scale. */
.swf-pov-scale, .swf-pov-pan { transform-box: view-box; transform-origin: 0 0; }

.swf-stage { height: 300vh; }
.swf-stage__pin {
  position: sticky; top: 0; height: 100vh; overflow: hidden; box-sizing: border-box;
  /* bottom = the site's fixed nav bar (~70px at 1440) plus air; 6vh alone put the caption UNDER it */
  padding: 7vh 0 max(6vh, 110px);
  display: flex; flex-direction: column; justify-content: center;
}
/* the figure is the column: headline, plot, caption, centred as ONE block.
   `min-height: 0` on the plot is load-bearing — a flex/grid child defaults to
   `min-height: auto` and would grow past the pin instead of fitting inside it
   (root CLAUDE.md, the reachability rule). */
.swf-stage .swf-fig { height: 100%; grid-template-rows: auto 1fr auto; align-content: center; }
.swf-stage .swf-line { min-height: 0; display: flex; }
.swf-stage .swf-line__svg {
  width: 100%; height: 100%; min-height: 0;
  /* ⛔ HIDDEN, NOT VISIBLE — AND THIS IS THE ONE THAT BITES. The camera
     magnifies to 2x; with `overflow: visible` (which is what this selector says
     everywhere else on the page) the zoomed plot paints straight through the
     headline and the caption. A close-up is clipped by its frame, like any
     camera. The prototype carries the identical note twice, for both its
     stages, because it made the mistake first. */
  overflow: hidden;
}
@media (max-width: 700px) {
  /* a phone has no vertical room to spare: shorter travel, tighter safe area */
  .swf-stage { height: 240vh; }
  .swf-stage__pin { padding: 4vh 0 4vh; }
  /* ⭐ AND THE PLOT STOPS CLAIMING THE WHOLE COLUMN. MEASURED at 390x844: the svg box was
     358x540 while the viewBox's own 130:72 makes it 358x198, so `meet` letterboxed 340px of
     EMPTY BOX under a chart that had gone small — correct by every check and obviously wrong
     to look at. On a narrow screen the plot is width-driven, so let it be its natural height
     and centre the three rows in the pin instead of stretching the middle one. */
  .swf-stage .swf-fig { grid-template-rows: auto auto auto; align-content: center; }
  .swf-stage .swf-line__svg { height: auto; }
}
.swf-fig__h { margin: 0; max-width: 34rem;
  font-family: var(--swf-font); font-weight: 700; color: var(--swf-ink);
  font-size: clamp(1.3rem, .95rem + 1.5vw, 2.1rem); line-height: 1.2; letter-spacing: -.025em;
}
.swf-fig__h em { font-style: normal; color: var(--swf-teal); }
.swf-fig__cap { margin: 0; max-width: 38rem; font-size: 15px; line-height: 1.55; color: var(--swf-ink-soft); }
.swf-fig__cap b { color: var(--swf-ink); }
/* the source line is small, but it is never hidden — the page's whole claim is that its
   numbers are checkable, and a citation nobody can read is not a citation */
.swf-fig__src { display: block; margin-top: var(--swf-sm); font-size: 13px; opacity: .8; }

/* ── 1 · the mark bars — grow with the scroll ────────────────────────────── */
.swf-bars { display: grid; gap: var(--swf-md); }
.swf-bars__row { display: grid; grid-template-columns: 5rem 1fr; gap: var(--swf-md); align-items: center; }
.swf-bars__label { font-size: 14px; color: var(--swf-ink-soft); }
.swf-bars__track {
  display: flex; overflow: hidden; border-radius: var(--swf-r-md);
  background: var(--swf-base); height: 52px;
}
.swf-bars__seg {
  display: grid; place-items: center; min-width: 0; width: var(--w);
  transform-origin: left center;
  /* scrubbed: each segment starts at its own `--s` offset and fills over the next 22% */
  transform: scaleX(clamp(0, calc((var(--p) - var(--s, 0)) / .22), 1));
  font-size: 13px; letter-spacing: .02em; white-space: nowrap;
}
.swf-bars__seg b { font-weight: 700; }
/* ⭐ BOTH SEGMENTS ARE THE WRITING COLOUR — see the PHP: they are two KINDS of writing,
   not writing versus not-writing, and colouring one of them "other" is what made the
   figure contradict the tally above it. */
.swf-bars__seg--answers { background: color-mix(in srgb, var(--swf-teal) 45%, var(--swf-base)); color: var(--swf-ink); }
.swf-bars__seg--pieces  { background: var(--swf-teal); color: var(--swf-btn-ink); }

/* ══════════════════════════════════════════════════════════════════════════════════════════
   WD-375 · THE MONO-CHART CARD — geometry ported from amicro.vercel.app/mono-charts
   ------------------------------------------------------------------------------------------
   Neil, 2026-08-25, on the old marks figure: *"it's boring. It's plain. It's not very
   interesting at all. Not imaginative in the slightest."* He supplied the reference above and
   asked for it to be saved (it is, in the FIXLIST as WD-375).

   WHAT IS PORTED, having read it in a real browser: the CARD TEMPLATE — eyebrow label + a
   pill-shaped type tag on one row · a BIG number with a small unit phrase beside it · the plot
   inside its own INSET rounded panel · a two-column monospace footer strip. And the house move
   that makes the whole set feel like one system: ROUNDED CAPS ON EVERYTHING — bar tops, arc
   ends, cell corners.
   ⛔ WHAT IS NOT PORTED, stated rather than silently adapted (§13): the palette. That page is
   deliberately MONOCHROME; this one is the brand's teal/purple, and on this page colour already
   carries meaning. Copying the monochrome would break the page to match a demo.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.swf-mono { display: grid; gap: var(--swf-md); }
.swf-mono__head { display: flex; align-items: center; gap: var(--swf-md); flex-wrap: wrap; }
.swf-mono__eyebrow {
  font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--swf-ink-soft);
}
.swf-mono__tag {
  font-size: 11px; letter-spacing: .06em; padding: 3px 10px; border-radius: 999px;
  color: var(--swf-ink-soft); background: var(--swf-base);
}
.swf-mono__big { display: flex; align-items: baseline; gap: var(--swf-md); flex-wrap: wrap; }
.swf-mono__n {
  font-family: var(--swf-font); font-weight: 700; letter-spacing: -.03em; color: var(--swf-ink);
  font-size: clamp(2.6rem, 1.4rem + 4vw, 5rem); line-height: 1;
  /* tabular figures so a counting number does not jitter its own width as it runs */
  font-variant-numeric: tabular-nums;
}
.swf-mono__unit { font-size: 15px; color: var(--swf-ink-soft); }
.swf-mono__panel {
  background: var(--swf-base); border-radius: 24px;           /* the reference's card radius, measured */
  padding: clamp(24px, 4vw, 48px);
}
/* ⭐ WD-387.7 (Neil): *"the one where it's got the 100% chart for language and literature — does
   that need that container background?"* No. It is the page's opening figure and a lone
   centrepiece, which is the same case as the lead 66% ring — and that one was unboxed at WD-384
   for exactly this reason. A card frame around a single centred chart adds an edge and says
   nothing. The five SMALL charts keep their cards: there the box is what separates one from the
   next. Padding stays, so the ring keeps its air. */
.swf-fig--centred .swf-mono__panel { background: none; border-radius: 0; }
.swf-mono__foot {
  display: flex; justify-content: space-between; gap: var(--swf-md); flex-wrap: wrap;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12px; letter-spacing: .02em; color: var(--swf-ink-soft);
  padding-top: var(--swf-sm); border-top: 1px solid var(--swf-raised);
}

/* ── the two subject pillars — 160 + 160 = 320 ──────────────────────────────
   ⛔ SPLIT BY SUBJECT, NEVER BY PAPER. Per-paper marks differ between boards (AQA Language is
   80+80, Edexcel Language is 64+96) and this page names no board, so a per-paper chart would
   print wrong numbers for a large part of the audience. Each SUBJECT is 160 on all three boards
   we checked — that is the board-neutral fact, so that is the chart. See the PHP. */
/* ── the 100% donut — the reference's "Mono Rounded Donut" (WD-376) ──────────────────────
   Two segments, one per subject, rounded caps, a 5° gap between them, drawn WITH the scroll
   (segment 0 over the first half of `--p`, segment 1 over the second) so the ring closes as the
   reader reaches it. Thick stroke (the dashboard donut's own 18/55 ratio). */
.swf-donut { position: relative; width: min(100%, 360px); margin: 0 auto; }
.swf-donut__svg { width: 100%; height: auto; display: block; overflow: visible; }
.swf-donut__track { fill: none; stroke: var(--swf-raised); stroke-width: 14; }
.swf-donut__seg {
  stroke-width: 14; stroke-linecap: round; stroke-dasharray: 100;
  stroke-dashoffset: calc(100px - 100px * clamp(0, calc((var(--p) - var(--t, 0)) / var(--span, 1)), 1));
}
.swf-donut__seg--0 { stroke: var(--swf-teal); }
.swf-donut__seg--1 { stroke: color-mix(in srgb, var(--swf-teal) 45%, var(--swf-base)); }  /* same hue, one step back — two KINDS of writing */
.swf-donut__centre {
  position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 2px; pointer-events: none;
}
.swf-donut__n {
  font-family: var(--swf-font); font-weight: 700; letter-spacing: -.04em; color: var(--swf-ink);
  font-size: clamp(2.6rem, 1.6rem + 3.4vw, 4.6rem); line-height: 1; font-variant-numeric: tabular-nums;
}
.swf-donut__k { font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: var(--swf-ink-soft); }
.swf-donut__key {
  list-style: none; margin: clamp(20px, 3vh, 34px) auto 0; padding: 0;
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--swf-xl);
  max-width: 760px; text-align: left;
}
.swf-donut__row {
  display: grid; grid-template-columns: 12px 1fr; gap: 4px 10px; align-items: baseline;
  opacity: clamp(0, calc((var(--p) - var(--t, 0)) * 6), 1);
}
.swf-donut__dot { width: 10px; height: 10px; border-radius: 50%; align-self: center; }
.swf-donut__dot--0 { background: var(--swf-teal); }
.swf-donut__dot--1 { background: color-mix(in srgb, var(--swf-teal) 45%, var(--swf-base)); }
.swf-donut__label { font-size: clamp(15px, 14px + .3vw, 18px); font-weight: 600; color: var(--swf-ink); }
.swf-donut__note { grid-column: 2; font-size: 14px; line-height: 1.5; color: var(--swf-ink-soft); }
@media (max-width: 620px) { .swf-donut__key { grid-template-columns: 1fr; gap: var(--swf-lg); } }

/* ══ WD-374 / WD-376 · OUR OWN 117 STUDENTS ═══════════════════════════════════════════════
   The LEAD (66%) is ONE centred column inside its pinned stage — the ring takes the whole stage
   width, so the camera's 2x close-up has room (Neil on 1.35.1: *"that chart is outside the
   viewport. Like, come on"* — it was in a 320px column). The other five, plus one provenance
   tile, are the reference's 3-column grid (24px gap, MEASURED). */
.swf-survey--lead { width: 100%; min-height: 0; }
/* ⛔⛔ WD-384.2 — "Why is this boxed?" (Neil). MEASURED: it computed `background: #242628`,
   `border-radius: 24px`, `padding: 20px` — the mono card — DESPITE the `background: none;
   padding: 0` below. THE CAUSE IS SOURCE ORDER, NOT A MISSING RULE. `.swf-stat` (the base, with
   the box) is at ~line 1520 and this modifier is ABOVE it at ~1494; both are one class, so they
   tie on specificity and the LATER one wins — the base. A modifier written above its own base is
   a rule that silently does nothing.
   ⭐ Fixed by specificity rather than by moving the block, so it cannot come back if either rule
   is ever relocated again: `.swf-survey--lead .swf-stat--lead` is (0,2,0) and can no longer tie.
   The lead ring is deliberately unboxed — it is the ONE figure that lives alone in the pinned
   camera stage, where a card frame would fight the 1.45x close-up. */
.swf-survey--lead .swf-stat--lead {
  /* ⛔⛔ WD-385.5 — `auto minmax(0,1fr) auto`, AND THE MIDDLE ONE IS THE POINT. This was
     `minmax(0, 1fr) auto`, i.e. TWO tracks — but `.swf-stat__body { display: contents }` means
     this grid has THREE children (the head line, the plot, the text block), so the flexible `1fr`
     row was landing on the HEAD — the little "GAP 1 OF 6 · 117 students" line — and the CHART was
     placed in an `auto` row that only ever got its intrinsic height.
     MEASURED, and this is what Neil saw as a clipped chart:
       1440x900   rows 0 / 342 / 130   — nothing spare, the plot got the remainder
       1997x1597  rows **510** / 480 / 130 — 510px of free space handed to a 24px label while the
                  chart sat at its 480px cap and the camera's 1.45x close-up spilled out of it
     Naming the tracks explicitly puts the free space where the picture is. Nothing about the
     old rule looked wrong; only the computed `grid-template-rows` showed it. */
  display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
  justify-items: center; gap: var(--swf-lg);
  height: 100%; min-height: 0; background: none; padding: 0; border: 0; border-radius: 0;
}
.swf-stat--lead .swf-mono__head { justify-content: center; }
.swf-stat--lead .swf-stat__body { display: contents; }
.swf-stat--lead .swf-stat__plot { width: 100%; height: 100%; min-height: 0; background: none; padding: 0; }
.swf-stat--lead .swf-stat__text { text-align: center; }
.swf-stat--lead .swf-stat__pct { font-size: clamp(2.6rem, 1.4rem + 4vw, 5rem); }

/* the LEAD figure inside its pinned stage — `min-height: 0` on every level is load-bearing:
   a grid child defaults to min-height:auto and grows past the pin (root CLAUDE.md reachability) */
.swf-stage .swf-fig--lead { height: 100%; grid-template-rows: auto minmax(0, 1fr) auto; align-content: center;
  /* ⭐ WD-385.5 — SPACE RECLAIMED FOR THE PICTURE, on the LEAD stage only. The generic figure gap
     is `--swf-step` (48px at 1440), so the three rows spent 96px of a 727px frame on air while the
     chart was the thing short of room. Scoped to `--lead` so every other figure keeps the house
     rhythm. MEASURED at 1440x900: the plot goes 319px -> ~380px, and the ring with it. */
  gap: clamp(16px, 2.2vh, 32px); }
/* the same reasoning on the pin's own top padding — the bottom must stay at 110px, it is the
   clearance for the site's fixed nav bar and the caption sits under it without it. */
.swf-stage[data-stage="survey"] .swf-stage__pin { padding-top: 4vh; }
.swf-stage .swf-fig--lead .swf-ring__svg { height: 100%; width: 100%; min-height: 0;
  /* ⭐ WD-385.5 — the cap was `min(50vh, 480px)`, and its own note reasoned about the card's
     1240px WIDTH while the thing that clips is the HEIGHT. With the free space now reaching the
     plot (above), that cap became the new limit: at 1597 the row could give 990px and the svg
     still took 480. Raised so the picture can use the room it has been given. It is still a cap —
     a ring must not grow so tall it pushes the label off a short screen — just one set against
     the dimension that actually binds. */
  max-height: min(78vh, 760px); }
.swf-fig__cap--tight { max-width: 34rem; }

.swf-survey--grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; width: 100%; }
.swf-stat {
  background: var(--swf-base); border-radius: 24px;
  padding: 20px; display: grid; gap: var(--swf-md); align-content: start;
  /* ⛔ NO FADE, NO RISE — MOTION.md §3f, ruled by A/B: body content arrives visible. The CHART
     inside the card animates with the scroll; the card itself does not. */
}
.swf-stat__body { display: grid; gap: var(--swf-md); }
.swf-stat__plot {
  display: flex; align-items: center; justify-content: center; min-width: 0;
  /* the reference's inset plot panel, measured: darker ground, 14px radius */
  background: var(--swf-page); border-radius: 14px; padding: 16px; min-height: 150px;
}
.swf-stat__pct {
  font-family: var(--swf-font); font-weight: 700; letter-spacing: -.03em; color: var(--swf-teal);
  font-size: clamp(2rem, 1.2rem + 2vw, 2.8rem); line-height: 1; font-variant-numeric: tabular-nums;
}
.swf-stat__label { margin-top: 6px; font-size: 16px; color: var(--swf-ink); font-weight: 600; line-height: 1.3; }
.swf-stat__sub { margin-top: 4px; font-size: 13px; line-height: 1.5; color: var(--swf-ink-soft); }
.swf-stat--note .swf-stat__body--note { min-height: 150px; align-content: center; }
.swf-stat--note .swf-stat__pct { color: var(--swf-ink); }
/* ⛔ NO CONTINUOUS ANIMATION ON THESE CARDS — RULED BY NEIL, 2026-08-26, REVERSING HIS OWN
   ASK OF THE SAME DAY: *"maybe it's better if those don't have keyframe animations. You can
   animate them in on scroll, and maybe every time they enter the viewport, but maybe don't have
   continuous animations. Maybe that doesn't work properly."*
   ⭐ He is right and it restores the house rule: MOTION.md Motif 3 reserves ambient motion for
   ACTIVE / ongoing states and says never on a resting element. A finished statistic is resting.
   1.37.0 had five idle loops here (breathe · twinkle · wave · shimmer · sway); all removed.
   What they animate on instead: `--p`, scrubbed per CARD, and each card REPLAYS every time it
   re-enters the viewport (`data-scrub="unit"` in funnel.js). Motion tied to the reader's scroll,
   nothing running on its own. ⚠️ The LOOP keeps its orbiting comet — a cycle that does not stop
   is that section's entire argument, and it is an ongoing state, not a resting one. */

@media (max-width: 980px) { .swf-survey--grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .swf-survey--grid { grid-template-columns: 1fr; } }

/* 1 · the ring. pathLength=100 + dashoffset is the house draw-on idiom, and it is ALSO what
   lets the camera ride the arc's head with getPointAtLength() — the same mechanism as the line,
   which is exactly what the prototype's own second stage did ("the camera could follow the ring
   around" — Neil, on the prototype). */
.swf-ring__svg { width: 100%; height: auto; display: block; overflow: hidden; }
.swf-ring__track { fill: none; stroke: var(--swf-raised); stroke-width: 9; }
.swf-ring__arc {
  stroke: var(--swf-teal); stroke-width: 9; stroke-linecap: round;
  stroke-dasharray: 100;
  stroke-dashoffset: calc(100px - 100px * var(--d));
}
.swf-ring__head { fill: var(--swf-mint, #7DF9E9); opacity: clamp(0, calc(var(--d) * 20), 1); }
.swf-fig[data-done="1"] .swf-ring__head { opacity: 0; transition: opacity .4s linear; }
.swf-stat--lead .swf-ring__pct { display: none; }   /* the figure is printed once, under the ring */
.swf-ring__pct {
  fill: var(--swf-ink); font-family: var(--swf-font); font-weight: 700; font-size: 22px;
  /* .78, not .5 — MEASURED on a frame: at 0.55 the camera is still at ~1.1x and this numeral
     rendered across the whole panel and clipped on the frame edge. It arrives once the pull-back
     has essentially landed. */
  opacity: clamp(0, calc((var(--p) - .78) * 8), 1);
}

/* 2 · the pillar pair · 3 · the ten dots · 4 · the liquid fill · 5 · the bullet · 6 · the gauge
   All six carry rounded caps and all six are driven by the SAME `--p` the card is, so nothing
   plays off-screen (the WD-369c defect: CSS keyframes fired on `.is-in` and three figures shared
   one beat, so two of them animated below the fold). */
.swf-mini-pillars { display: flex; align-items: flex-end; gap: 12px; height: 86px; }
.swf-mini-pillars span {
  width: 26px; height: var(--h); border-radius: 999px; background: var(--swf-teal);
  transform-origin: bottom center;
  transform: scaleY(clamp(.04, calc((var(--p) - var(--t, 0)) / .3), 1));
}
.swf-mini-dots { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; width: 100%; max-width: 130px; }
.swf-mini-dots span { aspect-ratio: 1; border-radius: 50%; background: var(--swf-raised); }
.swf-mini-dots span.is-lit {
  background: color-mix(in srgb, var(--swf-teal)
              calc(clamp(0, calc((var(--p) - var(--t, 0) - var(--d, 0) * .08) * 12), 1) * 100%), var(--swf-raised));
}
.swf-mini-fill {
  width: 86px; height: 86px; border-radius: 50%; background: var(--swf-raised);
  overflow: hidden; display: flex; align-items: flex-end;
}
.swf-mini-fill span {
  width: 100%; height: var(--h); background: var(--swf-teal);
  transform-origin: bottom center;
  transform: scaleY(clamp(0, calc((var(--p) - var(--t, 0)) / .3), 1));
}
.swf-mini-bullet { width: 100%; max-width: 150px; height: 14px; border-radius: 999px; background: var(--swf-raised); overflow: hidden; }
.swf-mini-bullet span {
  display: block; height: 100%; width: var(--w); border-radius: 999px; background: var(--swf-teal);
  transform-origin: left center;
  transform: scaleX(clamp(0, calc((var(--p) - var(--t, 0)) / .3), 1));
}
.swf-mini-gauge { width: 100%; max-width: 140px; height: auto; display: block; }
.swf-mini-gauge__t { stroke: var(--swf-raised); stroke-width: 10; stroke-linecap: round; }
.swf-mini-gauge__f {
  stroke: var(--swf-teal); stroke-width: 10; stroke-linecap: round;
  stroke-dasharray: 100;
  stroke-dashoffset: calc(100px - 46px * clamp(0, calc((var(--p) - var(--t, 0)) / .3), 1));
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   WD-388 · THE SCATTER RAIL — a pinned horizontal rail of cards that drift and counter-rotate
   as they cross the screen, each opening a panel over itself on click.

   PORTED 1:1 from `CSS Shine Effect/Sophicly Scatter Rail - PORT.html` (#B-515c). Its own
   measurement of sondaven.com is the source of every number here; deviations are named in the
   PHP (`swf_graph_survey()`), not re-argued. The two that touch this file:
     C. prefix `sr` → `swf-sr`   D. colours from `--swf-*`, never the prototype's literals.

   ⚠️ THE PIN IS THE 720vh, NOT THE `position:sticky`. Sticky travels only as far as its parent
   exceeds its own height, so shortening `--swf-sr-pin` shortens the TRAVEL, not the section.
   ⚠️ NO ANCESTOR MAY BE `overflow:hidden` — that disables sticky outright. `clip` does not.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
/* ⛔⛔ WD-400 + WD-401 ARE ONE BUG, AND THIS RULE IS THE FIX (Neil, 2026-08-27).
   MEASURED: `[data-fig="marks"]` reported **4627px wide** inside a 1440px viewport, with its donut
   sitting at x=2234 — 1600px off the right-hand edge. It was never blank; it was off-screen.
   THE MECHANISM: `.swf-graphs` is a GRID, and a grid track's `auto` minimum is MAX-CONTENT. The
   rail's flex row is 4627px wide, so it blew the track out to 4627px and every one of the three
   figures in that beat stretched to match. Neil saw two of them as *"just blank"* and the rail's
   cards as *"cut off"* — two reports, one cause.
   ⇒ `min-width: 0` stops the rail's own width from sizing the track (the classic flex/grid
   min-content trap), and the full-bleed pair below lets the rail span the viewport, which is what
   he actually asked for: *"why can't they go right to the edge of the viewport?"*
   ⚠️ AND THE GATE COULD NOT SEE IT. Every `--p`, every dashoffset, every rendered path length was
   correct — nothing asked whether the drawing was ON SCREEN. A new check does now. */
.swf-graphs > * { min-width: 0; }

.swf-sr {
  /* ⭐ FULL-BLEED. `overflow: clip` is what makes this safe — it stops the 100vw box adding a
     horizontal scrollbar, and `clip` (unlike `hidden`) does not disable the sticky pin inside. */
  width: 100vw; max-width: 100vw; min-width: 0;
  margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw);

  /* their measured constants, verbatim */
  --swf-sr-cell:  calc(100vw / 12);              /* their 1-c-cell: 120px at 1440 */
  --swf-sr-card-w: calc(var(--swf-sr-cell) * 4);
  --swf-sr-card-h: 75vh;
  /* ⛔ WD-394 (Neil, 2026-08-27) — THEIRS IS 120vw AND OURS CANNOT BE. Verbatim: *"After that,
     there's a massive gap. I'm not sure what's meant to be in there. It's just black"* — and
     again about the far end, *"it's like a blank screen, basically."* He is describing the
     prototype's own lead-in: at 120vw the cards fly in from a screen and a half off to the right,
     which is 26% of a 720vh section spent on an empty page. That is right for THEIR page, where
     the rail is the whole event; ours sits in the middle of an argument and the reader has
     nowhere to look. 30vw still enters the first card from off-screen — the gesture survives —
     without asking anyone to scroll past two blank viewports to see it.
     ⭐ The gate is unaffected: it asserts the travel is −100% of the ROW'S OWN width, whatever
     that width turns out to be, rather than a pinned pixel figure. */
  /* ⭐⭐ RESTORED TO THE PROTOTYPE'S OWN VALUES (WD-404). WD-394 cut the lead to 30vw to kill a
     blank first screen, and that broke the entrance: a card is at its bottom-right START only
     when `q ≈ 0`, i.e. when `cardLeft ≈ 1.2·vw`, so 120vw IS the run-up. MEASURED at 30vw, card 1
     began at **q = 0.52** — already upright and motionless, which is exactly what Neil then
     reported. The blank screens are now filled by the headline inside the pin instead, so the two
     no longer have to be traded against each other. */
  --swf-sr-lead: 120vw;                          /* theirs, and it is the entrance */
  --swf-sr-trail: 20vw;                          /* theirs */
  /* WD-662 (retest, Neil: "in the prototype it fades in from the centre, it doesn't scroll up") — the pin
     grows by ONE viewport, and that viewport belongs to the catchphrase: the headline is pinned and centred
     while its lines resolve (the reference's own sticky-centre reveal), THEN the rail's 620vh of travel runs
     exactly as before. paintRail() splits the two: the first `innerHeight` of scroll drives the catchphrase,
     the remainder drives the cards — so WD-404/405/407's measured geometry is untouched, just shifted by 100vh. */
  /* WD-662 RETEST 2 (Neil: "revealed from underneath the section before it, like a curtain — we're still missing
     that feeling"). The reference's structure, read from its own CSS: `.s-catchphrase{margin-top:-100lvh; z-index:0}`
     — the section STARTS a viewport under the block before it, its title is already pinned at centre, and the
     previous block (painted above it) scrolls away and unveils it. Same here: the hold is one viewport PLUS the
     grid gap (so the pin's top meets the marks figure's bottom exactly), the section pulls up by that amount, and
     the marks figure becomes the opaque curtain (below). paintRail reads the pull-up back from the computed
     margin, so JS and CSS cannot disagree. */
  --swf-sr-hold:  calc(100vh + var(--swf-band) * 1.1); /* the catchphrase's pinned reveal + the eaten gap (WD-662) */
  --swf-sr-pin:   calc(720vh + var(--swf-sr-hold)); /* their 6480 over a 900 pin = 620vh of travel, plus the hold */
  /* ⛔ OURS. Theirs opens in 1.2s and closes in 0.8s; MOTION.md §3 caps FEEDBACK at 400ms
     ("longer reads as broken"), and a click is feedback. The drift keeps their linear scrub
     untouched, because a scrubbed parallax is not an entrance and the ceiling does not reach it. */
  --swf-sr-open:  300ms;
  --swf-sr-close: 250ms;
  --swf-sr-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --swf-sr-exit:     cubic-bezier(0.4, 0, 1, 1);

  position: relative; height: var(--swf-sr-pin); overflow: clip;
  z-index: 0;                                     /* WD-662: under the curtain */
  /* WD-662: the section is its own plate, one step up the surface ladder — Neil: "the previous section and
     that one are the same colour so you don't get that effect… a different shade of #1c1d1f, or white in light".
     The prototype's catchphrase sits on `--paper` over a `--paper-dim` runway for the same reason. */
  background: var(--swf-base);
}
.swf-sr__pin {
  position: sticky; top: 0; height: 100vh; overflow: clip;
  display: flex; align-items: center;
}
/* ⭐ THE HEADLINE INSIDE THE PIN — it fills the first and last viewports, which a sticky section
   otherwise spends empty (WD-404/405). Behind the cards, centred, and driven by `--head` which the
   scrub sets: 1 before the first card arrives, 0 while they cross, 1 again after the last leaves. */
.swf-sr__head {
  position: absolute; inset: 0; z-index: 0;
  display: flex; align-items: center; justify-content: center;
  /* ⛔ WD-408 — THE GUTTER TOKEN, not a clamp of its own. Moving this headline INSIDE the pin
     (WD-404) took it off the page's grid track, so it stopped inheriting the column's gutter and
     fell back to a private `clamp(20px, 5vw, 80px)`. MEASURED at 768px it sat **4px** from the
     edge while every other block on the page sat at 26 — the exact unevenness Neil reported.
     ⭐ Anything that leaves the grid track must be handed the gutter explicitly; a component that
     sets its own edge inset is how a page stops being consistent. */
  padding: 0 var(--swf-gutter);
  pointer-events: none;
  opacity: var(--head, 1);
  transition: opacity 240ms var(--swf-sr-standard);
}
/* and on Risen's own reading measure — a headline and a caption are prose, and prose caps at 680 */
.swf-sr__head .swf-fig { width: 100%; max-width: var(--swf-read); }
/* ⛔ the head must never eat a card's click, and it must not be read twice by a screen reader
   once it is invisible */
.swf-sr__head[aria-hidden="true"] { visibility: hidden; }
.swf-sr.is-static .swf-sr__head {
  position: static; opacity: 1 !important; visibility: visible !important;
  padding: 0 0 var(--swf-step); pointer-events: auto;
}
/* ── WD-662 · THE RAILHEAD IS THE INCREDIBLES CATCHPHRASE (Neil, 2026-09-02). Ported from
   `CSS Shine Effect/Sophicly Catchphrase - Incredibles PORT.html` — its 5 rules + 1 type class, every one
   accounted for here (§14c count assertion):
     1. `.s-catchphrase`           — margin-top:−100lvh / height:calc(…) / position:relative  ⛔ FORCED OUT: the
                                     rule exists to slide a stand-alone section under a full-viewport block and
                                     give its sticky title travel; here the section IS the pin (`.swf-sr__pin`,
                                     sticky, 100vh) and a negative margin inside an absolute head does nothing.
     2. its phone `@media`         — the two vars of rule 1; goes with it.
     3. `.s__content`              — PORTED verbatim, minus `position:sticky; top:calc(50lvh − h/2)` (the pin
                                     already centres it — same forced-out reason).
     4. `.s__title`                — PORTED verbatim (width 100% · max-width 5em · centred · will-change).
     5. `.s__title .line`          — PORTED verbatim.
     +  `.t-h-3xl`                 — PORTED: weight 400, line-height .86, letter-spacing −.05em, the one fluid
                                     size `min(8.5rem, 3.4rem + 4.25vw, 13vw)`, Playfair italic (their own
                                     disclosed deviations). Face from `--swf-display` — the same Playfair, the
                                     theme's token rather than the prototype's CDN @font-face.
   ADDITIONS, stated: the outer `.swf-cp` stacks title + caption (the caption is the section's copy and was
   asked for); the `<em>` keeps the railhead's accent so "117 students" reads as it did. Nothing else. */
.swf-sr__head .swf-cp { display: flex; flex-direction: column; align-items: center; gap: var(--swf-step); width: 100%; }
.swf-sr__head .s__content {
  display: flex; flex-direction: row; align-items: center; justify-content: center;
  padding-right: 1.5rem; padding-left: 1.5rem; width: 100%;
}
.swf-sr__head .s__title { margin: 0; width: 100%; max-width: 5em; text-align: center; will-change: transform; }
.swf-sr__head .s__title .line { will-change: opacity, transform; }
.swf-sr__head .t-h-3xl {
  font: 400 min(8.5rem, 3.4rem + 4.25vw, 13vw)/.86 var(--swf-display);
  letter-spacing: -.05em; font-style: italic; color: var(--swf-ink);
}
.swf-sr__head .t-h-3xl em { font-style: italic; color: var(--swf-teal); }   /* the railhead's own accent (.swf-fig__h em); italic because Playfair is italic-only in the brand */

.swf-sr__rail {
  position: relative; z-index: 1;
  display: flex; align-items: center; gap: var(--swf-sr-cell);
  padding-left: var(--swf-sr-lead); padding-right: var(--swf-sr-trail);
  will-change: transform;
}
.swf-sr__item { flex: none; width: var(--swf-sr-card-w); will-change: transform; }

.swf-sr__card {
  position: relative; display: block; width: 100%; height: var(--swf-sr-card-h);
  overflow: clip; cursor: pointer; text-align: left; padding: 0;
  /* ⛔ WD-397 (Neil, 2026-08-27): *"Can you get rid of the stroke around the outside of the
     cards? That can improve it."* Gone — and it is not simply deleted, because it was carrying
     a job. It was added at 1.43.0 for a MEASURED reason: in light theme `--swf-raised` is #efefef
     on a #ffffff page, so an unbordered card was very nearly invisible against its own ground.
     ⇒ The separation moves entirely to the SHADOW plus a card fill that is a real step off the
     page in both themes. Check both before touching this: removing a border is easy, and
     silently reintroducing a rail of ghosts on the light theme is the cost of doing it carelessly. */
  border: 0;
  border-radius: var(--swf-r-lg, 14px);
  background-image: linear-gradient(180deg,
    color-mix(in srgb, var(--swf-ink) 6%, transparent),
    color-mix(in srgb, var(--swf-ink) 2%, transparent));
  box-shadow: var(--swf-shadow-lg), 0 2px 10px color-mix(in srgb, var(--swf-ink) 10%, transparent);
  background: var(--swf-base); color: inherit; font: inherit;
}
.swf-sr__card:focus-visible { outline: 3px solid var(--swf-teal); outline-offset: 4px; }

/* ⛔ THEIR CARD CARRIES A PHOTOGRAPH in this space. Ours carries the CHART — deviation B, and
   the reason for it: today every one of these numbers is readable with no interaction at all,
   and a card whose substance is behind a click is a card a scrolling parent never opens.
   ⭐⭐ AND THAT SUBSTITUTION HAS A CONSEQUENCE THE FIRST CUT MISSED. A photograph FILLS a
   75vh box; a chart does not. Floating a 130px plot in the middle of a 675px card left a vast
   empty field with everything crammed into the bottom fifth — screenshotted, and it read as a
   broken card rather than a composed one. A gate cannot catch that: every number in it was
   right. So the card is now laid out as a POSTER — head, then the chart taking the whole middle
   as its own row, then the number and the label — and the gradient field is a BACKGROUND only. */
/* ⛔ WD-402 — `display: block` IS NOT DECORATION HERE. These parts are `<span>`s (deviation F:
   a button's content model is phrasing content). On desktop they are `position:absolute`, and
   ABSOLUTE POSITIONING BLOCKIFIES an inline element — so they behaved as blocks without ever
   being told to. The `.is-static` column then sets `position:relative`, which does NOT blockify,
   so on mobile each span silently reverted to INLINE and collapsed to its content. Neil:
   *"the colour… is sort of just sitting at the top left of the card, which is strange, because
   the card's not actually coloured properly."* That smudge was the gradient field, inline.
   ⇒ State `display` explicitly on anything whose layout you depend on. A box that is only a block
   as a SIDE EFFECT of its positioning changes shape the moment a breakpoint changes the position. */
.swf-sr__art {
  display: block;
  position: absolute; inset: 0; overflow: clip;
  background:
    radial-gradient(120% 90% at 18% 12%, color-mix(in srgb, var(--swf-purple-lt) 20%, transparent), transparent 60%),
    radial-gradient(90% 70% at 88% 78%, color-mix(in srgb, var(--swf-teal) 12%, transparent), transparent 62%),
    linear-gradient(180deg, var(--swf-raised), var(--swf-base));
}

/* the face IS the layout now — three rows, the chart owning the middle one */
.swf-sr__face {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  padding: var(--swf-xl); gap: var(--swf-lg);
}
.swf-sr__plot {
  flex: 1 1 auto; min-height: 0;
  display: flex; align-items: center; justify-content: center;
  padding: var(--swf-lg) 0;
}
/* ⭐ THE PLOTS WERE AUTHORED FOR A 150px GRID CELL and must be told to grow, or they sit at
   their old size in the middle of the space the card gives them.
   ⛔ AND EACH ONE IS RESIZED ON ITS OWN TERMS, NEVER BY ONE BLANKET RULE. The first cut set a
   height and a max-width on all of them: `.swf-mini-fill` is a CIRCLE (86x86, radius 50%), so
   two independent axes turned it into an ellipse — screenshotted, and no gate would have seen
   it. A shape's aspect is part of what it MEANS; scale the shape, never its two axes. */
.swf-sr__plot .swf-ring__svg     { width: min(82%, 250px); height: auto; }
.swf-sr__plot .swf-mini-gauge    { width: min(84%, 250px); max-width: none; height: auto; }
.swf-sr__plot .swf-mini-dots     { width: min(78%, 230px); max-width: none; gap: 12px; }
.swf-sr__plot .swf-mini-bullet   { width: min(84%, 250px); max-width: none; height: 20px; }
/* square, because it is a circle — one length, used twice */
.swf-sr__plot .swf-mini-fill     { width: 180px; height: 180px; }
.swf-sr__plot .swf-mini-pillars  { height: 180px; gap: 18px; }
.swf-sr__plot .swf-mini-pillars span { width: 46px; }
.swf-sr__foot { flex: none; display: block; }
.swf-sr__pct   { display: block; }
.swf-sr__label { display: block; max-width: 22ch; }
/* ⭐⭐ THE TOGGLE STAYS ABOVE THE OPEN PANEL, and this is the whole point of WD-396. The panel
   is `inset: var(--swf-lg)` at z-index 1 and covers the card — so the minus the reader is meant
   to see was rendering UNDERNEATH it, and the control still appeared to vanish the moment it was
   used. A state indicator hidden by the state it indicates is not an indicator.
   ⇒ The row is raised above the panel, and the panel is given room at its foot so its text never
   runs beneath the control. */
.swf-sr__row {
  position: relative; z-index: 3;
  margin-top: var(--swf-lg); display: flex; align-items: center;
  justify-content: space-between; gap: var(--swf-lg);
}
.swf-sr__card[aria-expanded="true"] .swf-sr__panel { padding-bottom: calc(var(--swf-xl) + 44px); }
.swf-sr__hint { font-size: 13px; color: var(--swf-ink-soft); }

/* the toggle is the house `.swf-plus` idiom — see its own block near the foot of this file */

/* ⭐ THEIR CLIP-PATH WIPE, at our duration. Inset from the card edge exactly as theirs is,
   and it scrolls inside itself when the content overflows. */
.swf-sr__panel {
  position: absolute; inset: var(--swf-lg); z-index: 1; display: block;
  background: var(--swf-page); border: 0;
  border-radius: var(--swf-r-lg, 14px); padding: var(--swf-xl); overflow: auto;
  clip-path: inset(0% 0% 100% 0%); transform: scale(1.06); opacity: 0; visibility: hidden;
  transition: clip-path var(--swf-sr-close) var(--swf-sr-exit),
              transform var(--swf-sr-close) var(--swf-sr-exit),
              opacity   var(--swf-sr-close) var(--swf-sr-exit),
              visibility 0s linear var(--swf-sr-close);
}
.swf-sr__card[aria-expanded="true"] .swf-sr__panel {
  clip-path: inset(0% 0% 0% 0%); transform: scale(1); opacity: 1; visibility: visible;
  transition: clip-path var(--swf-sr-open) var(--swf-sr-standard),
              transform var(--swf-sr-open) var(--swf-sr-standard),
              opacity   var(--swf-sr-open) var(--swf-sr-standard),
              visibility 0s;
}
.swf-sr__panel-h {
  display: block; font-size: 15px; font-weight: 700; letter-spacing: .02em; color: var(--swf-ink);
}
.swf-sr__panel-p {
  display: block; margin-top: var(--swf-md); color: var(--swf-ink-soft);
  font-size: 15px; line-height: 1.55;
}
/* their `.benefit-card_info_grad-over`: a fade at the foot of the scrolling panel, so text
   continuing below the fold is SIGNALLED rather than cut */
.swf-sr__scrim {
  position: absolute; left: var(--swf-lg); right: var(--swf-lg); bottom: var(--swf-lg);
  height: 72px; z-index: 2; pointer-events: none; opacity: 0;   /* under the toggle's z-index 3 */
  border-radius: 0 0 var(--swf-r-lg, 14px) var(--swf-r-lg, 14px);
  transition: opacity var(--swf-sr-close) var(--swf-sr-exit);
  background: linear-gradient(180deg, transparent, var(--swf-page) 76%);
}
.swf-sr__card[aria-expanded="true"] .swf-sr__scrim { opacity: 1; transition: opacity var(--swf-sr-open) var(--swf-sr-standard); }
.swf-sr.is-static .swf-sr__scrim { display: none; }

/* ⛔ ONE settle, no cascade — MOTION.md Motif 4: a staggered response to a click reads as jank */
.swf-sr__panel > * {
  transform: translateY(10px); opacity: 0;
  transition: transform var(--swf-sr-close) var(--swf-sr-exit), opacity var(--swf-sr-close) var(--swf-sr-exit);
}
.swf-sr__card[aria-expanded="true"] .swf-sr__panel > * {
  transform: none; opacity: 1;
  transition: transform var(--swf-sr-open) var(--swf-sr-standard), opacity var(--swf-sr-open) var(--swf-sr-standard);
}

/* ── WD-389 · the split-text fragments. Hidden ONLY under `html.swf-js`, so no JS, a blocked
      file or an optimiser eating funnel.js leaves every word plainly on screen (root §4d). ── */
.swf-js .swf-sp-char, .swf-js .swf-sp-word { opacity: 0; }
.swf-sp-char, .swf-sp-word { display: inline-block; will-change: opacity; }
.swf-sp-word { white-space: pre; }

/* ── DEVIATION E of the port: their own sub-992 layout, and what reduced motion gets.
      No pin, no rail, no drift — a plain column, with nothing hidden waiting for a scroll
      that will not come. ─────────────────────────────────────────────────────────────── */
.swf-sr.is-static { height: auto; overflow: visible; }
.swf-sr.is-static .swf-sr__pin {
  position: static; height: auto; display: block; overflow: visible;
}
/* ⭐ WD-409 (Neil, 2026-08-27): *"the cards on mobile, they go all the way to the edge of the
   screen. I'm just wondering, is that the best way?"* — no, and the rule now answers it rather
   than taste. The rail is full-bleed on DESKTOP because the cards must cross the whole viewport
   (WD-401, his own ask). In the column nothing crosses anything, and a card carrying a heading and
   a paragraph is TYPE — and BRAND.md's gutter law governs type, not pictures. So the column pays
   the same 26px inset as every other line on the page. */
.swf-sr.is-static .swf-sr__rail {
  flex-direction: column; gap: var(--swf-lg);
  padding: 0 var(--swf-gutter); transform: none !important;
}
.swf-sr.is-static .swf-sr__item { width: 100%; transform: none !important; }
/* ⭐ THE ART STAYS ABSOLUTE IN THE COLUMN TOO — it is the card's BACKGROUND, not a row in it.
   Pulling it into the flow (the first cut) is what left the colour as a blob above the content
   instead of filling the card. Only the FACE becomes flow, and its height is what sizes the card. */
.swf-sr.is-static .swf-sr__face { position: relative; inset: auto; background: none; }
.swf-sr.is-static .swf-sr__plot { min-height: 190px; }
/* ⛔ IN THE COLUMN A CLOSED PANEL IS *DISPLAY:NONE*, NOT MERELY INVISIBLE. MEASURED on a 390px
   phone: the closed panel sat in flow at 413px wide and 344px tall inside a 390px card — 23px of
   horizontal overflow and 340px of dead space under the content, because `visibility:hidden` still
   reserves a box and the closed state's `transform: scale(1.06)` was still scaling it (390 x 1.06
   = 413.4, and half the difference is the 12px it hung off the left).
   On DESKTOP the panel is absolutely positioned, so neither of those could ever show. */
.swf-sr.is-static .swf-sr__panel {
  display: none; position: relative; inset: auto; margin-top: var(--swf-lg);
  transform: none; clip-path: none; opacity: 1; visibility: visible;
}
.swf-sr.is-static .swf-sr__card[aria-expanded="true"] .swf-sr__panel { display: block; }
.swf-sr.is-static .swf-sr__panel > * { transform: none; opacity: 1; }
/* the card is sized by its content in the column — a fixed min-height left a tall empty box */
.swf-sr.is-static .swf-sr__card { height: auto; min-height: 0; }
.swf-sr.is-static .swf-sr__panel,
.swf-sr.is-static .swf-sr__panel > * { transition: none; }

@media (prefers-reduced-motion: reduce) {
  .swf-js .swf-sp-char, .swf-js .swf-sp-word { opacity: 1; }
}


/* ── 2 · the line — draws as you scroll ─────────────────────────────────── */
.swf-line__svg { display: block; width: 100%; height: auto; overflow: visible; }
/* D3, from the prototype: the axes mean nothing at 2x, so they arrive WITH the
   pull-back rather than before it. Only the pinned stage zooms, so only the
   pinned stage holds them back; elsewhere `--p` reaches 1 and they are simply on.
   Under reduced motion `--p` is forced to 1, so they render outright. */
.swf-line__axes { opacity: 1; }
.swf-stage .swf-line__axes { opacity: clamp(0, calc((var(--p) - .55) / .2), 1); }
.swf-line__axis { stroke: var(--swf-raised); stroke-width: .5; }
.swf-line__grid { stroke: var(--swf-raised); stroke-width: .35; stroke-dasharray: 1.5 1.5; }
.swf-line__tick, .swf-line__age {
  fill: var(--swf-ink-soft); font-family: var(--swf-font); font-size: 3.2px; letter-spacing: .04em;
}
.swf-line__val {
  fill: var(--swf-ink-soft); font-family: var(--swf-font); font-size: 4.6px; font-weight: 700;
}
.swf-line__val.is-focus { fill: var(--swf-teal); }
.swf-line__path {
  stroke: var(--swf-teal); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round;
  /* pathLength="100" normalises the curve, so this is exactly 0%→100% drawn, whatever
     the real geometry is — and it means the PHP never computes a bezier arc length. */
  stroke-dasharray: 100;
  /* `--d`, not `--p` — see the stage block above. On an unpinned figure the two
     are the same value, so nothing changed for the graphs that kept the scrub. */
  stroke-dashoffset: calc(100px - 100px * var(--d));
}
/* the dots pop as the line reaches them: each carries its own `--t` threshold */
.swf-line__dot, .swf-line__val {
  transform-box: fill-box; transform-origin: center;
  opacity: clamp(0, calc((var(--p) - var(--t, 0)) * 12), 1);
  transform: scale(clamp(.2, calc((var(--p) - var(--t, 0)) * 12), 1));
}
.swf-line__dot { fill: var(--swf-ink); }
.swf-line__dot.is-focus { fill: var(--swf-teal); }
.swf-line__age { opacity: clamp(0, calc((var(--p) - var(--t, 0)) * 12), 1); }

/* their `.focal-point` riding the path — `offset-path` IS a motion path, no plugin.
   It sits exactly at the head of the drawn line, because both are driven by `--p`. */
.swf-line__rider {
  fill: var(--swf-mint);
  /* the rider IS the drawing head the camera follows, so it rides `--d` — the
     same fraction the JS feeds to `getPointAtLength()`. If these two ever
     disagree the camera tracks a point that is not on the end of the line. */
  offset-distance: calc(var(--d) * 100%);
  opacity: clamp(0, calc(var(--d) * 20), 1);
}
/* once the line is complete the rider has arrived; fade it so it does not sit on the
   final dot as a second, brighter blob */
.swf-fig[data-done="1"] .swf-line__rider { opacity: 0; transition: opacity .4s linear; }

/* ── 3 · the hundred dots — light up in sequence ────────────────────────── */
.swf-grid {
  display: grid; grid-template-columns: repeat(20, 1fr); gap: clamp(4px, .7vw, 9px);
  max-width: 40rem; margin: 0 auto;              /* centred in its panel (WD-377) */
}
.swf-grid__dot { aspect-ratio: 1; border-radius: 50%; background: var(--swf-raised); }
.swf-grid__dot.is-lit {
  /* `--t` is this dot's own position in the sequence, so they light left-to-right */
  background: color-mix(in srgb, var(--swf-teal)
              calc(clamp(0, calc((var(--p) - var(--t)) * 14), 1) * 100%), var(--swf-raised));
  transform: scale(clamp(.72, calc(.72 + clamp(0, calc((var(--p) - var(--t)) * 14), 1) * .28), 1));
}

@media (max-width: 560px) {
  .swf-bars__row { grid-template-columns: 1fr; gap: 4px; }
  .swf-bars__seg { font-size: 12px; }
  .swf-grid { grid-template-columns: repeat(10, 1fr); }
}

/* ⛔ MOTION IS OPTIONAL, THE CHART IS NOT. funnel.js leaves `--p` at its default of 1
   under reduced-motion, so every value above resolves to its finished state and the
   reader gets the complete graph with no movement at all. */
@media (prefers-reduced-motion: reduce) {
  .swf-fig { --p: 1 !important; --d: 1 !important; }
  .swf-line__rider { display: none; }
  /* ⛔ THE STAGE STOPS BEING A STAGE. 300vh of empty scroll with a static chart
     stuck to the top of it is worse than no effect at all — it is three screens
     of nothing. Under reduced motion the block collapses to its content and the
     finished graph simply sits in the page. (§4d: a refusal must state what the
     reader gets instead.) */
  .swf-stage { height: auto; }
  .swf-stage__pin { position: static; height: auto; padding: 0; overflow: visible; }
  .swf-stage .swf-fig { height: auto; }
  .swf-stage .swf-line__svg { height: auto; overflow: visible; }
}

/* ══════════════════════════════════════════════════════════════════════════════════════════
   WD-383 · THE CLOSING CALL — #B-524 ported onto /the-gap/ (Neil, 2026-08-26)
   ------------------------------------------------------------------------------------------
   Neil, having read the business lane's ruling that the ASK does not change: *"we can use the
   following element or component to create this section. It says book demo, but you can change
   it to take the assessment or something. Maybe the background can just say assessment or take
   assessment."* So the TEXT is the only thing he named, and per §13 it is the only thing changed
   from the component he pointed at: `CSS Shine Effect/Sophicly Closing Call - PORT.html` (#B-524),
   itself a measured port of altitude101.com's book-demo section. Everything below is copied out
   of that file; the deviations are all listed and each has a reason.

   ⭐ WHAT HE ACTUALLY LIKES ABOUT IT is recorded in that file in his own words, and it decides
   what must not drift: *"the main thing that I like is that background with the spotlight where
   you can see the words free demo."* So the load-bearing part is the MASK — 10%-opacity giant
   type revealed only inside a radial hole that follows the pointer.

   ⛔ GATE 0 (root §14c) — REUSE BEFORE PORT. I checked whether the site's own `.btn-main` was
   already available here rather than assuming: it is NOT. MEASURED on the live page — 76 rules
   mention it, and **every one is scoped** (`.nav-component .btn-main…`, `.sn-tab-trigger…`,
   `.sidebar-card__cta.is-btn-main…`). A bare prototype-shaped button injected into /the-gap/
   computed to an unstyled **1440×25 block**: no plate, no radius, no hover layer. So the
   component's own self-contained copy is ported, and it is SCOPED TO `.swf-cc` — an unscoped
   `.btn-main{}` here would collide with `.nav-component .btn-main` at equal specificity and be
   decided by source order, which is not a thing to leave to luck.

   STATED DEVIATIONS (everything not listed is 1:1 with #B-524):
   1. TEXT — the one Neil named. The marquee reads `SWF_CTA_MARQUEE`; the button reads
      `SWF_CTA_LABEL`, i.e. the SAME constant the rest of the funnel uses, so the page cannot
      end up asking for two different things and a business ruling changes both at once.
   2. TOKENS — the prototype declares `--bg/--surface/--ink/--muted/--teal/--blue/--purple-light`
      on `:root`. ⛔ Root CLAUDE.md forbids a site-wide plugin declaring those names on `:root`
      (they are names host pages already use). Every one is mapped onto the `--swf-*` ladder
      instead, so the section themes with the page for free. `--blue` maps to `--swf-rb-alt`, the
      BRAND supporting blue already introduced by the ribbon band — not a new colour.
   3. THE BUTTON'S TWO FIXED COLOURS — the prototype pins `--color-black:#1f2124` and
      `--color-white-dark:#f4f5f6`. On this page that is a WHITE PLATE ON A WHITE GROUND in light
      theme. They are pointed at `--swf-ink` / `--swf-page` instead, which keeps the design (a
      light plate on dark, a dark plate on light) in both themes.
   4. THE BORDER — the prototype has a `--border` token; this stylesheet has none, and the two
      existing hairlines here are `color-mix(in srgb, var(--swf-ink) 12%, transparent)`. Matched
      to those rather than inventing a token.
   5. ⭐ THE ARRIVAL IS GATED ON `html.swf-js`, which the prototype does not need. `.cc__arrive`
      starts at `opacity: 0` and is un-hidden by script; ship that as-is and a JS failure leaves
      the page's FINAL CALL TO ACTION INVISIBLE (root §4d — a refusal must say what the reader
      sees instead). This file already solves it for beats (`html.swf-js .swf-beat{opacity:0}`),
      so the same gate is used: no JS, no hiding.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.swf-cc {
  --swf-cc-r: 200px; --swf-cc-f: 120px;   /* their spotlight: solid to 80px, gone by 320px */
  --swf-cc-mx: 50%;  --swf-cc-my: 50%;    /* written straight from the pointer, no easing   */
  --swf-cc-loop: 20s;                     /* their scrollSpeed                              */
  --swf-cc-ease-out: cubic-bezier(.215,.61,.355,1);   /* stand-in for GSAP power2.out        */
  --swf-cc-entrance: cubic-bezier(.22,1,.36,1);       /* MOTION.md §2 "Sophicly Entrance"    */
  position: relative; display: flex; align-items: center; justify-content: center;
  /* ⭐ WD-384.5 (Neil): *"Make this full width with no stroke and parallax if possible."* The
     prototype boxes the panel (`min(100% - gutters, 1440px)`, a hairline and a 16px radius)
     because a component port is shown on a page that is not its own. Here it IS the page's
     ending, so it runs edge to edge like the bands above it — and a border and a radius on a
     full-bleed element would only draw a frame around the viewport. */
  width: 100%; margin: 0;
  height: min(62svh,560px); min-height: 420px;
  overflow: clip; border: 0; border-radius: 0;
  background: var(--swf-page);
}
/* Their footerBg.png layer becomes ours — a brand wash (their photograph is theirs).
   ⭐ WD-384.5 — AND IT IS NOW THE PARALLAX LAYER, on the house mechanism rather than a new one:
   `initParallax()` already drives every `.swf-band`'s first child by writing `--swf-px`, and this
   wash is `.swf-cc`'s first child, so the section simply joins that loop. The 30% overhang and
   the 30% travel are the same pair the bands use and `bin/parallax-gate.mjs` measures — the
   overhang MUST match the travel or the layer's own edge appears at the end of the range. */
.swf-cc__wash { position: absolute; top: 0; left: 0; right: 0; height: max(130%, calc(100% + 15vh));
  z-index: 1; pointer-events: none;   /* WD-487: Risen's top-anchored 130% layer — see .swf-band__img */
  transform: translate3d(0, var(--swf-px, 0px), 0); will-change: transform;
  background:
    radial-gradient(90% 70% at 50% 118%, color-mix(in srgb, var(--swf-rb-alt) 26%, transparent) 0%, transparent 62%),
    radial-gradient(60% 50% at 12% -10%, color-mix(in srgb, var(--swf-purple-lt) 16%, transparent) 0%, transparent 60%),
    linear-gradient(180deg, var(--swf-base) 0%, var(--swf-page) 70%); }

/* ⭐ THE MASKED TYPE — the mechanism Neil named. `--swf-cc-mx/my` are written straight from the
   pointer with no easing and no lerp, exactly as theirs are. */
.swf-cc__mask { position: relative; z-index: 2; margin: 0 auto; display: flex; flex-direction: column;
  align-items: center; gap: 1vh; text-align: center; width: 100%;
  -webkit-mask-image: radial-gradient(circle at var(--swf-cc-mx) var(--swf-cc-my),
    rgba(0,0,0,1) 0, rgba(0,0,0,1) calc(var(--swf-cc-r) - var(--swf-cc-f)), rgba(0,0,0,0) calc(var(--swf-cc-r) + var(--swf-cc-f)));
  mask-image: radial-gradient(circle at var(--swf-cc-mx) var(--swf-cc-my),
    rgba(0,0,0,1) 0, rgba(0,0,0,1) calc(var(--swf-cc-r) - var(--swf-cc-f)), rgba(0,0,0,0) calc(var(--swf-cc-r) + var(--swf-cc-f)));
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; }
/* ⛔ `--swf-display`, the token this file already defines — NOT an invented `--swf-serif`, and
   not a deferred theme variable. This stylesheet's own line 54 records why: the theme defines
   `--font-family--display` as **Proxima Soft**, so a deferred name resolved to the SANS and the
   fallback never fired. A defined-but-wrong variable beats your fallback silently. */
.swf-cc__type { font-family: var(--swf-display);
  font-style: italic; font-weight: 400; font-size: 12vh; line-height: 1;
  color: var(--swf-ink); opacity: .10; width: 100%; }

/* a marquee row: the loop runs on the CHILDREN, the drift on the row */
.swf-cc__row { overflow: hidden; width: 100%; }
.swf-cc__track { display: flex; flex-direction: row; align-items: center; justify-content: center;
  width: 100%; transform: translate3d(var(--swf-cc-rowx, 0px), 0, 0); }
.swf-cc__copy { display: flex; flex: 0 0 auto; white-space: nowrap;
  animation: swf-cc-loop var(--swf-cc-loop) linear infinite; animation-direction: var(--swf-cc-dir, normal); }
@keyframes swf-cc-loop { from { transform: translate3d(0,0,0) } to { transform: translate3d(-100%,0,0) } }

/* ── the button ─────────────────────────────────────────────────────────────────────────── */
.swf-cc__btnwrap { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center;
  justify-content: center; pointer-events: none; }
.swf-cc__tilt { display: inline-block; pointer-events: auto; transform-style: preserve-3d;
  backface-visibility: hidden;
  transform: perspective(500px) rotateY(var(--swf-cc-ry,0deg)) rotateX(var(--swf-cc-rx,0deg));
  transition: transform .8s var(--swf-cc-ease-out); }
/* deviation 5 — the hide only exists when the script that removes it is running */
html.swf-js .swf-cc__arrive { opacity: 0; transform: translateY(12px);
  transition: opacity .9s var(--swf-cc-entrance), transform .9s var(--swf-cc-entrance); }
html.swf-js .swf-cc__arrive.is-in { opacity: 1; transform: none; }

/* ---------- VERBATIM from #B-524: .btn-main base, scoped (see GATE 0 above) ---------- */
.swf-cc .btn-main { display: flex; flex-direction: row; align-items: center; justify-content: center; position: relative; padding: 0px; appearance: none; background: none; border-width: medium; border-style: none; border-color: currentcolor; border-image: none; cursor: pointer; font-family: var(--swf-font); font-weight: 500; letter-spacing: -0.05em; line-height: 1; text-decoration: none; text-transform: uppercase; white-space: nowrap; }
.swf-cc .btn-main__outer { position: relative; display: block; width: 100%; overflow: hidden; }
.swf-cc .btn-main__outer::after, .swf-cc .btn-main__outer::before { position: absolute; top: 0px; left: 0px; z-index: 1; width: 100%; height: 100%; background: transparent; border-radius: inherit; content: ""; }
.swf-cc .btn-main__outer::before { clip-path: inset(0px); }
.swf-cc .btn-main__outer::after { z-index: 5; border: 1px solid rgba(43, 43, 43, 0.17); }
.swf-cc .btn-main__hover, .swf-cc .btn-main__inner { display: flex; flex-direction: row; align-items: center; justify-content: center; }
.swf-cc .btn-main__inner { position: relative; z-index: 2; }
.swf-cc .btn-main__inner .btn-main__text { transition: transform 0.8s cubic-bezier(0.23, 1, 0.32, 1); will-change: transform; }
.swf-cc .btn-main__hover { position: absolute; top: 0px; left: 0px; z-index: 3; width: 100%; height: 100%; border-radius: inherit; clip-path: ellipse(80% 50% at 50% 152%); transition: clip-path 0.6s cubic-bezier(0.19, 1, 0.22, 1); will-change: clip-path; }
.swf-cc .btn-main__hover .btn-main__text { transform: translate3d(0px, 0.5em, 0px); transition: transform 0.8s cubic-bezier(0.23, 1, 0.32, 1); will-change: transform; }
.swf-cc .btn-main__hover .btn-main__text::after, .swf-cc .btn-main__hover .btn-main__text::before { display: block; transition: clip-path 1s cubic-bezier(0.23, 1, 0.32, 1); content: attr(data-text); }
.swf-cc .btn-main__hover .btn-main__text::before { position: absolute; top: 0px; left: 0px; clip-path: inset(0px); color: var(--swf-teal); }
.swf-cc .btn-main__hover .btn-main__text::after { position: relative; clip-path: inset(100% 0px 0px); }
/* ---------- VERBATIM: size lg ---------- */
.swf-cc .btn-main--lg { font-size: 1rem; font-weight: 400; }
.swf-cc .btn-main--lg .btn-main__outer { padding: 1rem 3rem; height: 3rem; border-radius: 999rem; }
/* ---------- VERBATIM: primary / white, on our two tokens (deviation 3) ---------- */
.swf-cc .btn-main--primary.btn-main--white { color: var(--swf-page); }
.swf-cc .btn-main--primary.btn-main--white .btn-main__outer::before { background: var(--swf-ink); }
.swf-cc .btn-main--primary.btn-main--white .btn-main__outer::after { border-color: var(--swf-ink); }
.swf-cc .btn-main--primary.btn-main--white .btn-main__hover { background: var(--swf-page); border: 1px solid var(--swf-ink); color: var(--swf-ink); }
/* ---------- VERBATIM: the hover choreography ---------- */
@media (hover: hover) {
  .swf-cc .btn-main:hover .btn-main__outer::before, .swf-cc .btn-main.is-forced .btn-main__outer::before { clip-path: inset(0px 0px 110%); transition: clip-path 0.8s cubic-bezier(0.86, 0, 0.07, 1); }
  .swf-cc .btn-main:hover .btn-main__inner .btn-main__text, .swf-cc .btn-main.is-forced .btn-main__inner .btn-main__text { transform: translate3d(0px, -0.75em, 0px); transition: transform 0.4s cubic-bezier(0.55, -0.61, 0.73, 0.04); }
  .swf-cc .btn-main:hover .btn-main__hover, .swf-cc .btn-main.is-forced .btn-main__hover { clip-path: ellipse(100% 70% at 50% 50%); transition: clip-path 0.8s cubic-bezier(0.86, 0, 0.07, 1); }
  .swf-cc .btn-main:hover .btn-main__hover .btn-main__text, .swf-cc .btn-main.is-forced .btn-main__hover .btn-main__text { transform: translateZ(0px); transition: transform 0.8s cubic-bezier(0.19, 1, 0.22, 1) 0.35s; }
  .swf-cc .btn-main:hover .btn-main__hover .btn-main__text::before, .swf-cc .btn-main.is-forced .btn-main__hover .btn-main__text::before { clip-path: inset(0px 0px 100%); transition: clip-path 0.6s cubic-bezier(0.19, 1, 0.22, 1) 0.35s; }
  .swf-cc .btn-main:hover .btn-main__hover .btn-main__text::after, .swf-cc .btn-main.is-forced .btn-main__hover .btn-main__text::after { clip-path: inset(0px); transition: clip-path 0.6s cubic-bezier(0.19, 1, 0.22, 1) 0.35s; }
}
.swf-cc .u-height-fix { display: block; }
.swf-cc .btn-main:focus-visible { outline: 3px solid var(--swf-teal); outline-offset: 4px; }

@media (max-width: 720px) {
  .swf-cc { height: min(70svh, 520px); }
  .swf-cc__type { font-size: 9vh; }
  /* ⛔ NOT IN THE PROTOTYPE, AND FORCED BY OUR OWN LABEL. Theirs reads "book the free demo."
     (19 characters); ours is `SWF_CTA_LABEL`, 30. The rule above is `white-space: nowrap` with
     3rem of side padding, so at 390px the longer label overflows its own pill. The type scale
     and the padding come down; the shape, the plate and the whole hover choreography do not. */
  .swf-cc .btn-main--lg { font-size: .82rem; }
  .swf-cc .btn-main--lg .btn-main__outer { padding: 1rem 1.4rem; }
}

/* deviation F, carried from #B-524 — reduced motion is a LAYOUT: no marquee, no tilt, and the
   mask is removed entirely so the words are simply legible. */
@media (prefers-reduced-motion: reduce) {
  .swf-cc__mask { -webkit-mask-image: none; mask-image: none; }
  .swf-cc__type { opacity: .34; }
  .swf-cc__copy { animation: none; }
  .swf-cc__tilt { transform: none; transition: none; }
  html.swf-js .swf-cc__arrive, html.swf-js .swf-cc__arrive.is-in { opacity: 1; transform: none; transition: none; }
}

/* the closing section's own type — the panel above is #B-524, these four rules are ours */
/* ⭐ WD-385.1 (Neil) — no padding above this section. Its first child is now the full-bleed
   panel, and a beat's `padding-block` above a full-bleed element only prints a strip of page
   colour between it and the band above — the same seam WD-381b removed under the ribbon band.
   The bottom padding stays: it is the last thing on the page and needs a floor. */
.swf-close {
  text-align: center; padding-top: 0;
  /* ⭐ NO BOTTOM PADDING (Neil, 2026-08-30). This is the LAST section on the page and the panel
     inside it is full-bleed and self-contained — its own 62svh box already carries the breathing
     room. The beat tier's 25→75px underneath it was simply a strip of page ground below the CTA,
     which reads as the page trailing off rather than ending on the button. */
  padding-bottom: 0;
}
/* the panel is full-bleed; the small print stays on the column track */
/* WD-384.3/.4 — the heading moved up onto `band-6-cycle` and the sub and proof lines were cut,
   so `.swf-close__h`, `.swf-close__sub` and `.swf-close__proof` went with them (root §3: remove
   what your own change made unused). The closing beat is now the panel and its small print. */
/* WD-385.2 — the small-print stack was removed, so its three rules went with it (root §3). */

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   WD-390 · THE RUNGS, DRESSED AS SPEC CARDS. Neil, 2026-08-27: *"currently, the SWF rungs section
   is really boring… I like the text and how it falls down and stuff like that, but the way it
   ENTERS, the styling is very, very plain. Why don't we style it like this?"*

   PORTED from `CSS Shine Effect/Sophicly Spec Cards - PORT.html` (#A-469, measured off
   atlasmotion.com). ⭐ READ THE ASK PRECISELY: the FALLING TEXT IS UNTOUCHED — he says he likes
   it. What changes is the BOX around it and the way it ARRIVES.

   ⛔ AND THE GEOMETRY IS UNTOUCHED TOO, on purpose. `min-height`, the padding and the flex stage
   are what WD-338 tuned so the words have a runway to fall THROUGH — change any of them and the
   fall stops reading as a fall. So the icon is absolutely positioned in a corner and costs no
   vertical space, and the decorator is a layer rather than a container.

   THEIR MEASURED NUMBERS, not to be re-derived:
     decorator opens at 8.3% of full size, growing on an EXPONENTIAL DAMP (λ ≈ 10.4 — successive
     gaps to the target shrink by a constant 0.8407 per frame). ⛔ NOT a fixed-duration tween.
     six 6px register squares pulled HALF OUTSIDE the box by translate(±50%, ±50%).
     the icon is a GENERATIVE 9×9 pixel grid: 4.56px module on a 5.0667 pitch, first at 7.453.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* ⭐ THE SURFACE MOVES OFF THE RUNG AND ONTO THE DECORATOR. It has to: the decorator sits behind
   the content and GROWS, so a background left on the rung itself would simply cover it and the
   whole expand would be invisible. The two rungs still differ only in surface and colour, which
   is the rule this section has carried since WD-337. */
.swf-rung { position: relative; background: none; box-shadow: none; }
.swf-rung > * { position: relative; z-index: 1; }

/* ⛔⛔ THE RESTING STATE IS FULL SIZE, AND THE COLLAPSE IS INLINE-ONLY. This is not a style
   preference, it is the bug the gate caught: the first cut declared the 8.3% start HERE, so when
   the expand finished and handed the box back to the stylesheet (`style.width = ''`), the
   collapsed rule reasserted itself and the decorator snapped shut — measured, 8.3% after a
   1400ms settle. The prototype's own `.specs__deco` is `inset:0` for exactly this reason.
   ⭐ It also makes the no-JS fallback correct for free: with funnel.js blocked the boxes are
   simply drawn at full size, which is the readable screen root §4d asks for. */
.swf-rung__deco {
  position: absolute; left: 0; top: 0; z-index: 0;
  width: 100%; height: 100%;
  border: 1px solid color-mix(in srgb, var(--swf-ink) 20%, transparent);
  /* ⭐ SQUARE, like the reference. A radius pulls the border away from the corner the register
     squares are pinned to, so the overhang stops reading as a machined mark and starts reading
     as a stray dot beside a rounded tile. */
  border-radius: 2px;
  /* the two rungs' own surfaces, carried over from the rules this replaces */
  background: var(--swf-base);
  box-shadow: var(--swf-shadow-md);
}
.swf-rung.is-top .swf-rung__deco { background: var(--swf-raised); box-shadow: var(--swf-shadow-lg); }

/* ⭐ HALF OUTSIDE THE BOX. That overhang IS the look — it reads as a machined register mark
   rather than a border decoration. Get the translate wrong and this is a generic bordered tile. */
.swf-rung__deco span {
  position: absolute; width: 6px; height: 6px; background: var(--swf-ink); border-radius: 1px;
}
.swf-rung__deco span:nth-child(1) { top: 0;    left: 0;  transform: translate(-50%, -50%); }
.swf-rung__deco span:nth-child(2) { top: 0;    right: 0; transform: translate(50%, -50%); }
.swf-rung__deco span:nth-child(3) { bottom: 0; left: 0;  transform: translate(-50%, 50%); }
.swf-rung__deco span:nth-child(4) { bottom: 0; right: 0; transform: translate(50%, 50%); }
.swf-rung__deco span:nth-child(5) { top: 25%;  right: 0; transform: translate(50%, 25%); }
.swf-rung__deco span:nth-child(6) { top: 25%;  left: 0;  transform: translate(-50%, 25%); }
.swf-rung.is-top .swf-rung__deco span { background: var(--swf-teal); }

/* the generative pixel mark — a corner register, so it costs the fall no runway */
.swf-rung__mark {
  position: absolute; z-index: 1; right: clamp(1rem, 2vw, 22px); top: clamp(1rem, 2vw, 22px);
  width: 40px; height: 40px; opacity: .38; pointer-events: none;
}
.swf-rung__mark svg { display: block; width: 100%; height: 100%; }
.swf-rung.is-floor .swf-rung__mark { color: var(--swf-absent); }
.swf-rung.is-top   .swf-rung__mark { color: var(--swf-teal); opacity: .5; }

@media (prefers-reduced-motion: reduce) {
  /* no expand and no type-in — the boxes are simply drawn, which is the readable screen */
  .swf-js .swf-rung__deco { width: 100% !important; height: 100% !important; transform: none !important; }
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   ⭐⭐ `.swf-plus` — THE HOUSE DISCLOSURE TOGGLE. A PLUS THAT BECOMES A MINUS.

   Ruled a SITE-WIDE STANDARD by Neil, 2026-08-27: *"we should just make that the standard way…
   you can put that in our brand guidelines and maybe make a gate for it."* Recorded in
   `sophicly-plugins/BRAND.md` and gated in `bin/graph-gate.mjs`.

   PORTED, NOT INVENTED, from the mobile nav on `/landing/` — `.plus` > `.plusB.plus1` +
   `.plusB.plus2`, which is the component he named. Measured off the live page in BOTH states
   (root §14b — the reference is opened before any code is written):

     | element        | rest                 | open                        | dur   | ease     |
     |----------------|----------------------|-----------------------------|-------|----------|
     | box `.plus`    | 25 x 50, relative    | unchanged                   | —     | —        |
     | bar 1, across  | 16 x 2, identity     | **identity — it NEVER moves** | 0.2s | ease-out |
     | bar 2, down    | 2 x 16, identity     | rotate(90deg), own centre   | 0.2s  | ease-out |
     | colour         | #7DF9E9  (= our `--swf-mint`)                                        |

   ⭐ THE IDIOM IS THAT THE UPRIGHT LIES DOWN ONTO THE CROSSBAR — that is what leaves a MINUS on
   screen, and a minus is the only thing that tells a reader the panel closes again.
   ⛔ ROTATING THE OTHER BAR IS NOT THE SAME EFFECT, IT IS THE BUG. The rail's first icon rotated
   the CROSSBAR upright instead, so both bars finished vertical and the control appeared to
   vanish. Neil: *"there's nothing to indicate that if I click again it will close it… it's just
   disappearing and not doing anything."* Same two elements, same 90 degrees, opposite bar.
   ⇒ The gate asserts the RELATION (crossed at rest · parallel when open · one bar never moves),
   never a transcribed matrix — a check that copies its subject's values only tests its own memory.

   USAGE: `swf_plus()` in the PHP renders it. The open state is driven by the CONTROL's
   `aria-expanded`, so the toggle needs no class of its own and cannot fall out of sync with the
   thing it discloses.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.swf-plus {
  position: relative; display: block; flex: none;
  width: 25px; height: 25px;              /* theirs is 25x50 — that box is their nav row height */
  color: var(--swf-mint);
}
.swf-plus__b {
  position: absolute; display: block; background: currentColor;
  transition: transform .2s ease-out;      /* theirs, measured */
}
.swf-plus__b--across { width: 16px; height: 2px; left: 4.5px; top: 11.5px; transform-origin: 8px 1px; }
.swf-plus__b--down   { width: 2px; height: 16px; left: 11.5px; top: 4.5px; transform-origin: 1px 8px; }

/* ⭐ THE UPRIGHT LIES DOWN. The crossbar is deliberately given no open-state rule at all, so it
   cannot acquire one by accident — its stillness is half the effect.
   ⚠️ SCOPED TO THE CONTROL, never a bare `[aria-expanded="true"] …`. `aria-expanded` is common on
   this site (nav items, disclosures), and an unscoped descendant selector fires on ANY expanded
   ancestor anywhere above the icon — which would rotate a plus that nobody had clicked. */
button[aria-expanded="true"] .swf-plus__b--down,
a[aria-expanded="true"] .swf-plus__b--down,
[role="button"][aria-expanded="true"] .swf-plus__b--down { transform: rotate(90deg); }

@media (prefers-reduced-motion: reduce) {
  /* the state change still HAPPENS — only the travel is dropped. A toggle that cannot show its
     state is broken, not calm (root §4d). */
  .swf-plus__b { transition: none; }
}

/* ── WD-395 · the opened panel, which now leads with the figure ───────────────────────────── */
.swf-sr__panel-fig {
  display: block; font-family: var(--swf-font); font-weight: 700; letter-spacing: -.03em;
  font-size: clamp(2.2rem, 1.4rem + 2vw, 3rem); line-height: 1; color: var(--swf-teal);
  font-variant-numeric: tabular-nums;
}
.swf-sr__panel-h {
  display: block; margin-top: var(--swf-sm); font-size: 16px; font-weight: 600;
  line-height: 1.3; color: var(--swf-ink); letter-spacing: 0;
}
.swf-sr__panel-split {
  display: block; margin-top: var(--swf-md); padding-top: var(--swf-md);
  border-top: 1px solid color-mix(in srgb, var(--swf-ink) 12%, transparent);
  font-size: 14px; line-height: 1.5; color: var(--swf-teal);
}
.swf-sr__panel-note {
  display: block; margin-top: var(--swf-lg); padding-top: var(--swf-md);
  border-top: 1px solid color-mix(in srgb, var(--swf-ink) 12%, transparent);
  font-size: 13px; line-height: 1.5; color: var(--swf-ink-soft);
}

/* ── WD-398 · the provenance card carries the SAMPLE, one mark per student ────────────────── */
.swf-sr__tally {
  display: flex; flex-wrap: wrap; align-content: center; justify-content: center;
  gap: 5px; width: min(88%, 250px); max-height: 100%;
}
.swf-sr__tally i {
  display: block; width: 8px; height: 8px; border-radius: 50%;
  background: var(--swf-teal);
  /* each mark arrives with the card's own crossing, in the order it was drawn — so the sample
     COUNTS ITSELF UP rather than appearing all at once. `--m` is the mark's position in 0..1. */
  opacity: clamp(0, calc((var(--p, 1) - var(--m)) * 12), 1);
}


/* ══════════════════════════════════════════════════════════════════════════
   /the-rules/ — THE PANE STACK
   Ported from `CSS Shine Effect/Sophicly Paragraph Panes - PORT.html` (all 66
   of its `.pp*` rules read before writing any of this). Values are ITS values;
   only the tokens are swapped for ours, and every deviation is named in the PHP.
   ⭐ Its own comment names the mechanism: "the whole mechanism is these five
   declarations" — overlap by the bite, radius the top corners, clip, translateY.
   ══════════════════════════════════════════════════════════════════════════ */
.swf-rules-page {
  --swf-bite: 40px;      /* how far each pane rides over the one before */
  --swf-lag: 300px;      /* their LAG — the outgoing pane's travel */
  --swf-shade: 0.6;      /* their SHADE — how dark it goes as it passes behind */
  --swf-marquee: 80s;    /* their marquee, linear infinite */
  --swf-frame: 320 / 300;
}

/* ── the stack ────────────────────────────────────────────────────────────── */
.swf-pp { background: transparent; overflow: visible; position: relative; grid-column: full; }
.swf-pp__pane {
  position: relative; width: 100%;
  margin-top: calc(-1 * var(--swf-bite));
  border-top-left-radius: var(--swf-bite); border-top-right-radius: var(--swf-bite);
  overflow: hidden; will-change: transform;
  display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start;
  padding: 0 var(--swf-gutter);
  transform: translateY(var(--pane-y, 0px));
}
/* their seven backgrounds, ours: the house ladder, one light surface, one accent */
.swf-pp__pane[data-colour="1"] { background: var(--swf-base); }
.swf-pp__pane[data-colour="2"] { background: #f4f3f7; --swf-ink: #1c1d1f; --swf-ink-soft: #55555f; }
.swf-pp__pane[data-colour="3"] { background: var(--swf-raised); }
.swf-pp__pane[data-colour="4"] { background: var(--swf-teal); --swf-ink: #10202a; --swf-ink-soft: #24404d; }
.swf-pp__pane[data-colour="5"] { background: var(--swf-page); }
.swf-pp__pane[data-colour="6"] { background: #ece9f4; --swf-ink: #1c1d1f; --swf-ink-soft: #55555f; }
.swf-pp__pane[data-colour="7"] { background: var(--swf-base); }
.swf-pp__pane { color: var(--swf-ink); }

/* the head: their 1fr 3fr grid, 80px tall (120 from 1025 up) */
.swf-pp__head {
  position: relative; z-index: 1; width: 100%;
  display: grid; grid-template-columns: 1fr 3fr; align-items: center;
  flex: 0 0 80px; min-height: 80px;
}
.swf-pp__index { padding-right: var(--swf-lg); font-size: 12px; font-weight: 600; letter-spacing: .2em; opacity: .6; }
.swf-pp__label {
  margin: 0; font-weight: 400; line-height: 1em;
  font-size: calc(22px + 38 * ((100vw - 420px) / 1200));
  letter-spacing: -.02em;
}
.swf-pp__label em { font-family: var(--swf-display); font-style: italic; font-weight: 400; }

/* the marquee: two identical sets, 80s linear infinite, behind everything */
.swf-pp__marquee {
  position: absolute; top: 65px; left: 0; z-index: 0; width: 100%;
  overflow: hidden; display: flex; pointer-events: none; user-select: none;
}
.swf-pp__set {
  display: flex; flex: 0 0 auto; justify-content: space-around; min-width: 100%;
  animation: swf-pp-marquee var(--swf-marquee) linear infinite; will-change: transform;
}
@keyframes swf-pp-marquee { to { transform: translateX(-100%); } }
.swf-pp__item { display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; }
.swf-pp__item span {
  display: inline-block; font-family: var(--swf-display); font-style: italic; font-weight: 400;
  line-height: 1.25em; font-size: calc(90px + 190 * ((100vw - 480px) / 1440));
  /* ⚠️ DEVIATION G, STATED. The prototype runs the marquee at FULL text strength and relies on
     an opaque photo frame covering the middle of the pane to stop it swamping the content —
     its own comment says exactly that. Ours has a frame but a much lighter palette, and at full
     strength the type read as noise rather than texture. Held at 7%.
     ⛔ This is the ONE number on this page that is mine rather than theirs. If the frame ever
     grows to their proportions, put it back to full and delete this line. */
  opacity: .07;
}

.swf-pp__main { position: relative; z-index: 1; width: 100%; display: inline-flex; flex: 1 1 auto; align-items: stretch; padding: 14vw 0 80px; }
.swf-pp__pane:last-child .swf-pp__main { padding-bottom: 100px; }
.swf-pp__inner { display: block; position: relative; width: 100%; }
.swf-pp__col { display: block; width: 100%; }

/* ⭐ THE FRAME IS OPAQUE, AND THAT IS LOAD-BEARING — the prototype's own note: the marquee runs
   at full strength behind the pane and what stops it swamping the content is the media sitting
   on top of it. A translucent frame turns the pane to mush. Ours is --swf-page (BRAND.md
   forbids pure black). */
.swf-pp__media {
  position: relative; width: 100%; aspect-ratio: var(--swf-frame);
  border-radius: 8px; overflow: hidden; background: var(--swf-page); color: var(--swf-ink);
}
.swf-pp__pane[data-colour="5"] .swf-pp__media { background: var(--swf-base); }
.swf-pp__diagramWrap { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.swf-pp__diagram { display: block; width: min(72%, 380px); height: auto; }

.swf-pp__content { position: relative; display: inline-flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; margin-top: 40px; }
.swf-pp__contentInner { display: block; width: 100%; padding: 20px 0 0; border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
.swf-pp__lead { display: block; margin: 0 0 1.1em; line-height: 1.225em; font-size: calc(22px + 10 * ((100vw - 420px) / 1200)); }
.swf-pp__list { margin: 0; padding: 0; list-style: none; }
.swf-pp__list li { position: relative; display: block; width: 100%; padding-left: 22px; line-height: 1.425em; font-size: calc(15px + 3 * ((100vw - 420px) / 860)); color: var(--swf-ink-soft); }
.swf-pp__list li:not(:last-child) { margin-bottom: var(--swf-sm); }
.swf-pp__list li::before { content: "-"; position: absolute; top: 0; left: 0; width: 22px; line-height: 1.6em; color: var(--swf-teal); }
.swf-pp__pane[data-colour="4"] .swf-pp__list li::before { color: #10202a; }
.swf-pp__codes { margin: 22px 0 0; font-size: 12px; letter-spacing: .04em; opacity: .55; }

/* the shade, as the pane passes behind the next one */
.swf-pp__overlay { position: absolute; inset: 0; z-index: 2; background: var(--swf-page); opacity: var(--pane-shade, 0); pointer-events: none; margin: 0; }

/* ── the two-rule spine + the framing beats ──────────────────────────────── */
.swf-pp-eyebrow { margin: 0 0 var(--swf-md); font-size: 12px; letter-spacing: .2em; text-transform: uppercase; color: var(--swf-teal); }
.swf-pp-standfirst { width: 100%; max-width: var(--swf-read); margin: var(--swf-lg) auto 0; font-size: 1.05rem; line-height: 1.55; color: var(--swf-ink-soft); }
.swf-pp-tworules { width: 100%; max-width: var(--swf-read); margin: 0 auto; padding: 0; list-style: none; display: grid; gap: var(--swf-lg); }
.swf-pp-tworules li { display: flex; gap: var(--swf-lg); align-items: baseline; }
.swf-pp-tworules li + li { border-top: 1px solid color-mix(in srgb, var(--swf-ink) 14%, transparent); padding-top: var(--swf-lg); }
.swf-pp-tworules span { font-family: var(--swf-display); font-size: 2.2rem; line-height: 1; color: var(--swf-teal); min-width: 1.3rem; }
.swf-pp-tworules p { margin: 0; font-size: 1.2rem; line-height: 1.4; }

/* ── their two armed breakpoints, verbatim ───────────────────────────────── */
@media (min-width: 768px) {
  .swf-pp__content { margin-top: 0; }
  .swf-pp__marquee { display: none; }
  .swf-pp__main { padding: 40px 0 20px; }
  .swf-pp__inner { display: grid; grid-template-columns: repeat(2, 1fr); grid-gap: 0 40px; padding-bottom: 20px; }
  .swf-pp__contentInner { border: 0 none; padding-top: 0; }
}
@media (min-width: 1025px) {
  .swf-pp__marquee { display: flex; }
  .swf-pp__label { font-size: 60px; }
  .swf-pp__lead  { font-size: 32px; }
  .swf-pp__list li { font-size: 18px; }
  .swf-pp__item span { font-size: 280px; }
  .swf-pp__head { flex-basis: 120px; min-height: 120px; }
  .swf-pp__main { padding-top: 160px; }
  .swf-pp__inner { display: grid; align-items: stretch; grid-gap: 0 60px; grid-template-columns: 3fr 2fr; }
  .swf-pp__col { display: inline-flex; align-items: stretch; width: auto; }
  .swf-pp__contentInner { margin: auto 0; max-width: 460px; }
}
@media (min-width: 1230px) and (min-height: 820px) { .swf-pp__pane { min-height: calc(100vh + var(--swf-bite)); } }
@media (min-width: 768px) and (max-width: 1024px) and (min-height: 580px) { .swf-pp__pane { min-height: calc(100vh + var(--swf-bite)); } }

/* ⭐ NO-JS AND REDUCED-MOTION LAND ON A CORRECT PAGE, NOT A DEAD ONE (root §4d): the panes are
   real markup, so with no script they simply stack — no transform, no shade, no marquee. */
@media (prefers-reduced-motion: reduce) {
  .swf-pp__pane { transform: none; }
  .swf-pp__overlay { opacity: 0; }
  .swf-pp__set { animation: none; }
}

/* ── /top-grade-rules/ · THE RAIN, BEHIND THE SITE'S OWN HERO (WD-421 + WD-422) ──
   ⛔ NOT A SECOND HEADER. `.header-hero` is the shared Etch component and is NOT edited —
   `rules.js` adds `.swf-gr-host` to it on this page only and appends the two canvases, so the
   component's entrance animation, type scale and <h1> all still run exactly as on /about/.
   ⭐ The dark ground is set HERE rather than in the component, because it is this page's
   choice: the rain only exists against dark, and a light-theme version would be a different
   design rather than a variant. */
/* ⛔ THE HERO'S OVERLAY AND VIGNETTE ARE OFF ON THIS PAGE ONLY (Neil, 2026-08-28: *"the overlay
   just hides the glyph rain too much"*). They exist to hold type off a PHOTOGRAPH; there is no
   photograph here, and over the rain they read as a grey wash. Scoped to `.swf-gr-host`, so
   every other page using this shared component is untouched. */
/* ⛔⛔ THE DOUBLED CLASS IS DELIBERATE — `.swf-gr-host.swf-gr-host` is 0,3,0 and beats the
   component's own 0,2,0 rules. MEASURED at 1.53.2: `funnel.css` is enqueued with no dependency
   and the THEME'S STYLESHEET LOADS AFTER IT, so every equal-specificity rule of ours silently
   lost the cascade — the hidden photograph came back, the overlay came back, and the teal badge
   stayed white. All three were "in the stylesheet" and none of them applied.
   ⭐ Specificity, not `!important`: `!important` wins today and makes the next override
   unwinnable. Doubling the scoping class raises weight while staying overridable in order. */
/* ⭐⭐ WD-585 — AND THE IMAGE ITSELF, which this rule forgot. Neil, 2026-09-01: *"there's, like, a
   broken image link or something saying the rules of a top grade... we're not using an image on
   that page anyway."*
   ⛔ THE CAUSE IS AN EMPTY `src`, NOT A MISSING FILE. The photograph was "removed" from this hero by
   blanking the attribute in the Etch block, so the markup still carries
   `<img class="header-hero__bg" alt="The Rules of a Top Grade" src="">`. A browser treats `src=""`
   as a broken image: it paints the broken-image glyph AND the alt text, which is exactly the
   caption he can see in the top-left. Nothing 404s, so no failed-request check would ever have
   found it — the page requests nothing at all.
   ⇒ The image joins the overlay and the vignette it was always meant to be hidden alongside; the
   note above already says the photograph is off on a glyph-rain host. Same doubled-class weight,
   same reasoning: specificity rather than `!important`, so it stays overridable in order. */
.swf-gr-host.swf-gr-host .header-hero__bg,
.swf-gr-host.swf-gr-host .header-hero__overlay,
.swf-gr-host.swf-gr-host .header-hero__vignette { display: none; }
/* ⭐ THE GROUND FOLLOWS THE THEME — the rain supports both (see PALETTE in rules.js), so the
   header must too, or the light theme is dark glyphs on a dark ground. */
.swf-gr-host { position: relative; background: #101114; isolation: isolate; }
/* ⚠️ `.header-hero` sets its own background, so the bare attribute selector lost on specificity
   — measured: the host computed rgb(0,0,0) while the theme said light. Both grounds are declared
   at the same weight as the component's own rule. */
.header-hero.swf-gr-host[data-gr-theme="dark"]  { background: #101114; }
.header-hero.swf-gr-host[data-gr-theme="light"] { background: #f4f3f7; }
.swf-gr-host[data-gr-theme="light"],
.swf-gr-host[data-gr-theme="light"] .header-hero__title,
.swf-gr-host[data-gr-theme="light"] .header-hero__caption { color: #1c1d1f; }
/* ⛔ THE BADGE COLOUR IS NOT SET HERE EITHER — same cause as `.header-hero__bg` below: Etch's
   page styles declare `.header-hero__badge { color: #fff !important; }`, so both rules that used
   to sit on this line lost silently no matter how they were specified. Neil's ask (WD-425 #4:
   *"'the mark scheme' in teal, which I thought was actually quite nice"*) is applied inline with
   priority in `rules.js::paintOverrides()`, which also re-applies it on a theme flip. */
.swf-gr-host[data-gr-theme="light"] .header-hero__badge--unused,
.swf-gr-host[data-gr-theme="light"] .header-hero__subtitle { color: #55555f; }
/* ⛔⛔ NOTHING IN THIS STYLESHEET CAN TOUCH `.header-hero__bg`, AND THAT IS WHY FOUR FIXES FAILED.
   MEASURED, twice, in the live page:
     1. the hero's entrance ANIMATION assigns `visibility` and `opacity` inline on this image every
        frame of its ramp, so a `visibility:hidden` rule here lost to a SCRIPT, not to the cascade;
     2. `INLINE#etch-page-styles` — the Etch component's own page styles — declares
        `.header-hero__bg { background: rgb(51,51,51) !important; z-index: 0 !important; }`
        and `.header-hero__badge { color: #fff !important; }`.
   ⭐⭐ SO LOAD ORDER IS IRRELEVANT HERE, and the long-planned "give `funnel.css` a dependency so it
   loads after the theme" is NOT the fix for this: `funnel.css` could load dead last and still lose
   every one of these, because `!important` outranks order and specificity alike. A plain INLINE
   style loses to it too. Only an inline `!important` declaration outranks an important author
   rule — so the overrides live in `rules.js::paintOverrides()`, page-scoped, and nothing weaker
   will hold. The image keeps its box, visibility and opacity and so remains the metronome the rain
   mirrors (BRAND.md §7z); only its PAINT is removed.
   ⛔ Do not reinstate a rule here in any form. It is not a specificity problem, and a rule that
   silently loses is worse than no rule — it reads as a fix. */

/* ⭐ THE GROUND IS PAINTED ON THE LAYER, NOT ON THE HOST. The component sets its own background
   and wins on specificity — measured: the host computed rgb(0,0,0) while the theme said light,
   which in light mode means dark ink glyphs on a black ground, i.e. nothing visible. The layer
   is ours, sits above the component's background and below its content, so painting it here
   settles the question instead of re-entering a specificity fight (root: watch the cascade —
   two of my own rules already cancelled each other out on this component once today). */
.swf-gr-host .swf-gr__layer {
  position: absolute; inset: 0; z-index: 0; pointer-events: none; background: #101114;
}
.swf-gr-host.swf-gr-host[data-gr-theme="light"] .swf-gr__layer { background: #f4f3f7; }
.swf-gr-host .swf-gr__source,
.swf-gr-host .swf-gr__output {
  position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;
}
.swf-gr-host .swf-gr__source { opacity: 0; }   /* the component's capture surface, never seen */
.swf-gr-host .swf-gr__output { z-index: 0; }
/* the hero's own content must sit ABOVE the rain — the component does not know the rain exists.
   ⛔⛔ THE LAYER MUST BE EXCLUDED HERE, and forgetting it is what broke the header at 1.53.1.
   This selector is `0,3,0` and beats the `0,2,0` rule that makes `.swf-gr__layer` absolute, so
   the layer was forced back to `position:relative` — an `inset:0` relative box has NO HEIGHT,
   the canvas inside it sized to **1440 x 1**, and a one-pixel canvas draws nothing.
   ⭐ Nothing errored. Opacity was 1, the WebGL context was alive, the mirror tracked perfectly.
   The header simply went grey — which is what Neil reported and what no error-based check could
   have found. Two rules of mine, written twenty minutes apart, cancelling each other out. */
.swf-gr-host > *:not(.swf-gr__layer) { position: relative; z-index: 1; }
/* the hero's type is authored against a photograph; on our dark ground it needs the dark palette */
.swf-gr-host, .swf-gr-host .header-hero__title, .swf-gr-host .header-hero__caption { color: #f4f6fa; }
.swf-gr-host .header-hero__badge, .swf-gr-host .header-hero__subtitle { color: #9aa3b2; }
@media (prefers-reduced-motion: reduce) { .swf-gr-host .swf-gr__output { display: none; } }

/* ── /top-grade-rules/ · THE TWO RULES, ON "BUILT TO SPEC" ────────────────────
   Ported from `Sophicly Spec Cards - PORT.html` §B (all 21 `.bts*` rules read first).
   Their values, kept. Deviation: no CTA (Neil), and the rules occupy its slot. */
/* ⚠️ NO `opacity` DECLARED HERE, DELIBERATELY. The section is faded in on SCROLL from
   `rules.js` (Neil: *"I don't want to be able to see the section under the header hero until I
   start scrolling"*), and that driver sets `opacity:0` itself only once it is certain it will
   run. Declaring 0 here instead would make a JS failure, or reduced-motion, render the section
   permanently invisible — the §4d failure. The CSS default must be the VISIBLE state. */
.swf-bts {
  --su: calc(100vw / 1440);        /* their scale unit — everything below rides on it */
  position: relative; overflow: hidden; grid-column: full;
  color: #e0e0ee; background: #1c1d1f;
}
/* the bg is the zoom layer: starts at 1.75x and relaxes to 1 as the section rises */
.swf-bts__bg { position: absolute; inset: 0; will-change: transform; transform: scale(1.75); display: flex; }
/* ⭐ darkened AT THE IMAGE, not veiled by an overlay — theirs has no scrim over the plates.
   `--k` is solved PER PLATE at runtime: engravings and letterpress title pages differ wildly
   in mean lightness, and one shared brightness leaves the pale ones glaring as bright columns. */
.swf-bts__bg img {
  flex: 1 1 0; min-width: 0; height: 100%; display: block;
  object-fit: cover; object-position: center 32%;
  filter: grayscale(1) contrast(1.18) brightness(var(--k, .30));
}
/* a light, text-only gradient weighted to the band where the type sits */
/* ⚠️ STRONGER THAN THEIRS, and stated. Their plates are darker Doré engravings; ours include
   pale letterpress title pages that sit directly behind the type, and at their weights the
   headline fought the background (measured at 1.52.0). Same shape, deeper stops. */
.swf-bts__scrim {
  position: absolute; inset: 0;
  background: radial-gradient(ellipse 82% 60% at 50% 50%, rgba(15,16,17,.92), rgba(15,16,17,.62) 68%, rgba(15,16,17,.42));
}
/* ⛔ `.swf-bts__eyebrow` REMOVED with its element — Neil, 2026-08-28: *"get rid of where it says
   the mark scheme, in the section under the header hero."* The teal "The mark scheme" he liked is
   the HEADER's badge (WD-425 #4) and is untouched; this was a second copy one section down. */
.swf-bts__credit {
  position: absolute; right: 1.25rem; bottom: 1rem; z-index: 2; margin: 0;
  font-size: .58rem; letter-spacing: .16em; text-transform: uppercase; color: rgba(224,224,238,.42);
}
/* ⭐⭐ WD-452 — THEIR PADDING, RESTORED VERBATIM. Neil, 2026-08-29: *"ours is a lot smaller than
   the original. Just make it exactly like the original. I don't really get why we've changed it…
   I actually quite like that it's big and bold, because it forces the readers to read it."*
   ⚠️ THE REASONING THAT SHRANK IT WAS SOUND AND THE OUTCOME WAS STILL WRONG, which is the thing
   worth remembering: the top padding was cut to close a "gap at the top" left by the removed
   eyebrow and CTA. Correct as far as it went — and it quietly cost the one section whose entire
   job is SCALE. The title is optically centred on its own ink by `rules.js` whatever the padding
   is, so the asymmetry costs nothing in balance and buys back the prototype's presence. */
.swf-bts__inner {
  position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center;
  /* ⭐⭐ WD-462 — THE CTA'S SLOT IS KEPT AS SPACE. Neil, 2026-08-29: *"look how small it is. If you
     look at the original one, it's literally taking up like ninety percent of the viewport,
     whereas ours are taking up like thirty percent… because it's taking up such a little bit of
     the viewport, we can see the text underneath, and that pulls attention away from the statement
     inside that big section, because that's the whole point."*

     ⭐ HE IS RIGHT, AND THE ARITHMETIC SAYS SO. The prototype's section at 1440x900 is
     250 (pad-top) + ~285 (two 150px lines at line-height 1.1, less the -.1em/-.2em row margins)
     + 120 (`.bts__btnWrap` margin-top, `clamp(2.25rem, 8.33vw, 7.5rem)`)
     + 76 (the button: 31 + 31 padding + 14 line-height)
     + 150 (pad-bottom) = **~881px, i.e. 98% of the viewport.**
     Ours had the two paddings and the type — and had DELETED the last two terms with the CTA, so
     it measured 485px. Removing a button removed 196px of deliberate silence around the statement.

     ⛔ SO THE BUTTON'S SPACE STAYS, AND THAT IS HIS INSTRUCTION VERBATIM: *"I know in the original
     there's also a call to action button, which increases the spacing, but maybe we should just
     keep it the same size as the original. Just keep the same size."* No button, same box.
     ⚠️ Built from the prototype's OWN tokens rather than a measured pixel, so it stays right at
     every width; the button term is a clamp because theirs is 76px on desktop and 51px on mobile
     (its 860px rule: 17/18 padding + 16 line-height). */
  padding: clamp(5rem, 17.36vw, 15.625rem) 1.25rem
           calc(clamp(3.5rem, 10.4vw, 9.375rem)      /* theirs, the real padding */
              + clamp(2.25rem, 8.33vw, 7.5rem)       /* + the CTA's margin-top */
              + clamp(3.1875rem, 5.3vw, 4.75rem));   /* + the CTA's own height */
}
.swf-bts__title {
  display: flex; flex-direction: column; width: 100%; text-align: center; margin: 0;
  /* ⭐⭐ WD-459 — PLAYFAIR DISPLAY ITALIC, and it is the SAME RULE the bands follow, not an
     exception to WD-453. Neil, 2026-08-29: *"reduce it to 1 line that hits the key points,
     playfair display."*
     ⭐ THE PRINCIPLE, now consistent across the page: **a full-bleed photographic plate with type
     over it takes the display face.** That is what a parallax band is (WD-457) and it is exactly
     what this section is — six author plates behind one line. The sans belongs to STRUCTURE
     (section and card headlines, questions, body); this is not structure, it is the same
     furniture-on-a-picture as a band. WD-452 briefly set this in Proxima Soft under the
     typeface-hierarchy ruling; that was the right rule applied to the wrong kind of section.
     ⚠️ Italic is not decoration here — BRAND.md carries Playfair Display Complete as an
     ITALIC-ONLY face, so upright would fall back to a different cut. */
  line-height: 1.1; font-family: var(--swf-display); font-style: italic; font-weight: 700;
  /* theirs, MEASURED off the live page: 150px at 1440 and 266.667 at 2560 — exactly 100vw/1440,
     with the tracking scaling too. The 2.5rem floor is ours; a phone needs one. */
  font-size: max(2.5rem, calc(150 * var(--su)));
  letter-spacing: calc(-4.5 * var(--su));
  color: #f2f3f7;
}
/* ⛔⛔ WD-452 — THE PER-LINE `--fit` SHRINK IS GONE, AND SO IS `white-space: nowrap`.
   That pair was the whole defect. `--fit` scaled each line down until it fitted on ONE line, so
   a longer sentence rendered SMALLER — at 1440 the title was landing near 75px against the
   prototype's 150px, i.e. half the size, and the cap had separately been cut 150 → 132 when the
   lines still carried "1. " / "2. " numbering that no longer exists.
   ⭐ THEIRS IS ONE FIXED SIZE AND THE TEXT WRAPS. A sentence longer than the measure takes two
   visual lines at full size instead of one line at half size — which is exactly the "big and bold"
   he asked for, and it is what the prototype would do with our copy. Safe because the splitter
   already wraps each WORD in an inline-block, so wrapping never breaks mid-word. */
/* ⛔ WD-462 — NO `--fit`, NO JS. The lines are short enough to sit at the prototype's own
   `150 · --su` at every width ("Hit the criteria." is 17 characters ≈ 1140px against a 1400px
   measure at 1440, and ≈ 306px against 350px at 390), so there is nothing to scale and the
   section's height is now a pure function of CSS. Fitting was only ever needed because the copy
   was too long — the right fix was the copy, which is Neil's call and he has made it. */
.swf-bts__line { display: block; text-align: center; }
.swf-bts__title > .swf-bts__line:nth-child(1) { margin-top: -0.1em; }   /* theirs */
.swf-bts__title > .swf-bts__line:nth-child(2) { margin-top: -0.2em; }   /* theirs */

/* ══════════════════════════════════════════════════════════════════════════════
   WD-453 · THE TYPEFACE HIERARCHY, PUT BACK THE RIGHT WAY ROUND.

   Neil, 2026-08-29: *"Playfair Display italics is starting to dominate, whereas it's actually
   meant to be the other way around. Our main font is actually Proxima Soft, and the Playfair
   Display italic is meant to be sort of like an accent, or is meant to provide a contrast that
   draws attention. But we're doing it the other way around."*

   MEASURED, not assumed: twelve rules on this page set `--swf-display`, and they covered every
   band headline, every band paragraph, both deck card headlines and the card quotations. When
   the display face is on EVERYTHING, it is no longer an accent — there is nothing left for it to
   contrast against, and the page reads as a serif page with sans captions.

   ⭐ THE RULE APPLIED, per element: Playfair italic survives ONLY where it marks ONE thing —
   the `<em>` inside a band headline, `.swf-rulesec__small` ("genuinely", "every one of"), the
   deck's big ghost numeral, and the examiner's quotation on a card (italic serif is the
   conventional signal for someone else's words). Everything structural is Proxima Soft.

   ⛔⛔ THE BANDS ARE THE EXCEPTION, AND IT IS A STANDING RULE — NOT A ONE-OFF (Neil, 2026-08-29,
   ruling on the band markup itself: *"the following sections should use Playfair Display italics.
   That can be a standard thing."*).

   ⭐ **A PARALLAX BAND HEADLINE IS ALWAYS PLAYFAIR DISPLAY ITALIC.** Every `.swf-band__h` on every
   funnel page, on every board, for ever — it is the one place the display face carries a whole
   line rather than one word, because the band IS the accent: a full-bleed photographic plate that
   interrupts the page between two blocks of reading. The type is furniture on a picture, not
   structure, so the rule that flipped card and section headlines to Proxima Soft does NOT reach
   it. WD-453 briefly scoped an override here that made them sans; it is deleted, and the site-wide
   default (`.swf-band__h`, ~800 lines above) is once again the only rule that governs them —
   which also keeps `/the-gap/` and `/watch/` identical to this page rather than quietly divergent.

   ⚠️ SO THE HIERARCHY HAS THREE TIERS, and the next model should not "fix" the middle one:
     structure (section + card headlines, the exam question) ....... Proxima Soft
     bands, and one-word accents inside a sans headline ............ Playfair Display italic
     quotations and the ghost numeral .............................. Playfair Display italic
   ══════════════════════════════════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════════════════════════════════
   WD-448 · EACH RULE GETS ITS OWN SECTION — the SPLIT HEADLINE.
   Ported from `CSS Shine Effect/Sophicly Split Headline - PORT.html` (#B-515b, mechanism from
   sondaven.com — all rights reserved, so MECHANISM ONLY: no markup, copy, colour or face of
   theirs). Their constants, read from that file:
       xPercent  wrap([5, -1, -5]) → wrap([-5, 1, 5])   ease none
       range     top-bottom → bottom-top (the element's whole pass)
       scrub     1s smoothing, reproduced as an exponential follow (TAU = scrub/3)
   ⭐ AND THE HALF THAT IS NOT IN THE JAVASCRIPT: the three rows are aligned `flex-end` / `center`
   / `flex-start` and the middle row carries a 20.5% right offset. Port the motion without the
   alignment and it reads as three lines sliding, not a block breathing.
   ⭐ THE SIDE MARGIN IS DERIVED FROM THE DRIFT: theirs is more than double the travel by
   construction (9.44vw margin against 4.05vw of drift), which is why the rows can never reach the
   edge. Keep that relationship if the type size changes.
   ══════════════════════════════════════════════════════════════════════════════ */
/* ⛔⛔ NAMED `swf-rulesec`, NOT `swf-rule` — THAT CLASS WAS ALREADY TAKEN, by a horizontal-rule
   divider (`width: 80px; height: 1px`) further up this same file. My section inherited the 80px
   and collapsed to padding-only width (272px), with its heading measuring ZERO and the display
   type spilling out of it as loose letters.
   ⭐ THE LESSON, and the ports already state it — the Split Headline port renamed sondaven's
   `.services-*` for exactly this reason: a component class taken from the CONTENT's vocabulary
   ("rule" as in the two rules) collides with the same word used for STRUCTURE ("rule" as in
   <hr>). Grep the stylesheet for a class before you claim it; a collision does not error, it
   silently gives your section someone else's geometry. */
.swf-rulesec {
  grid-column: full; position: relative;
  /* ⭐ WD-481 — THE BAND TIER, not a fourth ladder. It was `clamp(80px, 18vh, 200px)`, which
     measured **162px** at 1440×900 and, being vh-based, changed with the WINDOW'S HEIGHT while
     every other section on this page changes with its WIDTH — two sections could not agree on a
     gap. Vertical space is `--swf-band` / `--swf-sect` and nothing else (the ladder at the top of
     this file). The INLINE padding is untouched: 9.44vw is the Risen row geometry the comment
     above depends on. */
  padding-block: var(--swf-band);
  padding-inline: max(20px, 9.44vw);
  /* ⚠️ clip, NEVER hidden — the rows translate sideways, and `hidden` is a scroll container that
     will scroll itself the moment anything inside takes focus (their #B-509 lesson). */
  overflow-x: clip;
  /* ⭐ WD-481 — ON THE BRAND LADDER, IN BOTH THEMES. Was `#f4f3f7` / `#1c1d1f`, with a dark
     override painting `#0f1011`. Neither hex is ours: `--swf-base` is the one step off the page
     ground in BOTH themes (#f7f7f8 light · #242628 dark), which is what this alternating ground
     is for. And `#1c1d1f` ink was near-black on white — the exact thing WD-386 ruled out
     ("Risen never put black on white"); `--swf-ink` is their #555. */
  background: var(--swf-base); color: var(--swf-ink);
}
/* ⭐ TWO RULE SECTIONS MEET ON ONE GROUND, so the junction between them is a BEAT, not two
   bands — band + band = 300px of empty ground between two headlines with no edge to justify it.
   The model this page is held to allows exactly two junction values (2×sect, or sect+band); this
   is the first. */
.swf-rulesec + .swf-rulesec { padding-top: var(--swf-sect); }
.swf-rulesec__inner { max-width: 1440px; margin: 0 auto; }
.swf-rulesec__eyebrow {
  margin: 0; text-align: center; font-family: var(--swf-font); font-weight: 700;
  font-size: .75rem; letter-spacing: .4em; text-transform: uppercase; color: #5333ed;
}
.swf-rulesec__head {
  margin: clamp(24px, 5vh, 56px) 0 0; font-family: var(--swf-font); font-weight: 700;
  font-size: clamp(38px, 10vw, 144px); line-height: .83; letter-spacing: -.064em;
  text-transform: uppercase; will-change: transform;
}
.swf-rulesec__row {
  display: flex; align-items: center;
  transform: translateX(var(--x, 0)); will-change: transform;
}
.swf-rulesec__row--1 { justify-content: flex-end; }
.swf-rulesec__row--2 { justify-content: center; gap: 24px; padding-right: 20.5%; }
.swf-rulesec__row--3 { justify-content: flex-start; }
.swf-rulesec__small {
  font-size: clamp(13px, 2.2vw, 32px); font-weight: 600; letter-spacing: .06em;
  align-self: center; text-transform: none;
  font-family: var(--swf-display); font-style: italic; color: #5333ed;
}
/* ⛔ `.swf-rulesec__note` IS GONE (2026-08-29, Neil): the two paragraphs under RULE ONE and RULE
   TWO are deleted, so the rule that styled them is dead weight — it would only invite the next
   model to put a paragraph back. The headline IS the rule. */
/* ⛔ THE DARK GROUND OVERRIDE IS GONE (WD-481) — `--swf-base` already flips, and a hardcoded
   pair here is a second source of truth that drifts. The ACCENTS below stay: purple fails on
   dark and mint is its replacement, which is a swap the tokens cannot make. */
html[data-theme='dark'] .swf-rulesec__eyebrow, html.dark .swf-rulesec__eyebrow { color: #7DF9E9; }
html[data-theme='dark'] .swf-rulesec__small, html.dark .swf-rulesec__small { color: #7DF9E9; }

@media (prefers-reduced-motion: reduce) {
  /* theirs: reduced motion is a LAYOUT — the rows sit at their rest offsets, readable, still */
  .swf-rulesec__row { transform: none !important; }
}
/* the CTA's slot, now the rules themselves (deviation) */
.swf-bts__rules {
  position: relative; margin: clamp(2.25rem, 8.33vw, 7.5rem) 0 0; padding: 0;
  list-style: none; display: grid; gap: var(--swf-lg);
  width: 100%; max-width: 40rem; text-align: left;
}
.swf-bts__rules li { display: flex; gap: var(--swf-lg); align-items: baseline; }
.swf-bts__rules li + li { border-top: 1px solid rgba(224,224,238,.16); padding-top: var(--swf-lg); }
.swf-bts__rules span {
  font-family: var(--swf-display); font-size: 1.9rem; line-height: 1; color: #51dacf;
  min-width: 1.2rem; font-variant-numeric: tabular-nums;
}
.swf-bts__rules p { margin: 0; font-size: 1.08rem; line-height: 1.45; color: #cfd3de; }
@media (prefers-reduced-motion: reduce) { .swf-bts__bg { transform: none; } }

/* ── /top-grade-rules/ · THE CRITERIA, DECRYPTED (WD-430) ──────────────────────────────────
   Replaces the ten-pane stack. The slider is ported from `Roll for Premium/Roll for Premium.html`:
   a native `input[type=range]` whose value is mirrored onto `--slider-complete` (0-100), with the
   track filled by `clip-path: inset(0 var(--inset) 0 0 round …)`. Their values, kept.
   ⛔ NO `opacity:0` ON THE PLAIN-ENGLISH BLOCK IN CSS. It is hidden by the driver only once the
   driver is certain it can show it again — with no JS this section must read as a plain list
   (root §4d). Same construction as the header layer and the two-rules section above. */
/* ⚠️ LIGHT IS THE BASE AND DARK IS STATED EXPLICITLY AT THE END OF THIS BLOCK — following the
   convention established higher up this file, and for the reason MEASURED there: the host can
   carry a stale `class="light"` on <html> WHILE `data-theme="dark"` is set, so a dark-base /
   `html.light`-override structure (which is how this section was first written) lights up in dark
   mode. The explicit state must beat the default one, and must key on BOTH channels. */
/* ══════════════════════════════════════════════════════════════════════════════
   /top-grade-rules/ · THE DECRYPTED CRITERIA (WD-444 rebuild — full brand pass)

   Neil, 2026-08-29: *"you're using fonts that have nothing to do with our brand…
   the cards are really boring… the spacing and sizing has got nothing to do with
   our brand either."* All three faults were real and MEASURED:
     · `.swf-dx__h` asked for `var(--swf-serif …)` — a token DEFINED NOWHERE — so the
       biggest type in the section computed as **Georgia 700 upright** (the exact
       silent-fallback failure BRAND.md §Playfair warns about).
     · the cards were the hildenkaira prototype's pastel services-cards with our
       words poured in: their em scale, their layout, `ui-monospace` for the one
       thing the reader came to read.
     · spacing was ad-hoc clamps, not the page's own --swf-band/--swf-sect/--swf-step.

   THE REBUILD, and each choice traces to a source:
     · intro heading = the HOUSE heading (`.swf-beat__h` pattern verbatim: Proxima
       700, clamp(2rem → 3.9rem), -.03em, an <em> accent word in Playfair italic).
     · cards go DARK ON THE BRAND LADDER (#242628 base — BRAND.md Dark Base ladder;
       the always-dark sidebar is the precedent for a dark surface in light theme).
       They are identical in both themes; only the ground behind them swaps.
     · type on the 1.25/16 ladder (BRAND.md §type): eyebrow/chips 13px CHROME ·
       reading 20px · quote 20→25 fluid · title 39→49 DISPLAY fluid · ghost numeral
       61→120 (DISPLAY carve-out: single-line display numerals may exceed).
     · per-card accent, one each: mint #7DF9E9 · purple #a78bfa (dark-theme purple
       accent per BRAND.md — #5333ed is a fill, never text on dark) · marker #ded6fb.
     · zero borders — separation by contrast + purple-tinted shadow (this file's
       own convention, line 20).
   ⛔ THE DEAL GEOMETRY IS UNTOUCHED — pin/container/list/item and the narrow
   fallback are the verified WD-441 port and keep the prototype's em base.
   ══════════════════════════════════════════════════════════════════════════════ */
.swf-dx {
  grid-column: full; position: relative;
  /* ⭐⭐ WD-481 — `calc(var(--swf-band) * 2)` measured **300px**, and its first child is itself
     a full band (150px), so the page paid **450px** above one headline and 462px at the seam
     with `.swf-rulesec`. That is the dead space Neil photographed. A section whose children
     carry their own band pays the BEAT tier, so every junction here lands on the model's
     sect+band = 225px. */
  padding-block: var(--swf-sect);
  padding-inline: var(--swf-gutter);
  /* WD-481: the brand ladder, both themes — see the note on `.swf-rulesec`. */
  background: var(--swf-base); color: var(--swf-ink);
}
.swf-dx__intro { max-width: 46rem; margin: 0 auto; text-align: center; }
/* the HOUSE heading, centred — byte-matched to .swf-beat__h, not a cousin of it */
.swf-dx__h {
  margin: 0 0 var(--swf-step);
  font-family: var(--swf-font); font-weight: 700;
  font-size: clamp(2rem, 1.1rem + 4vw, 3.9rem);
  line-height: 1.02; letter-spacing: -.03em; color: #1c1d1f;
}
.swf-dx__h em {
  font-family: var(--swf-display); font-style: italic; font-weight: 700;
  letter-spacing: -.01em;
  color: #5333ed;   /* purple is "reserved for special moments" — this section is the page's one */
}
.swf-dx__stand {
  margin: 0 auto; max-width: 34rem;
  font-size: 1.25rem; line-height: 1.6; color: var(--swf-ink-soft);   /* 20px — one ladder step up */
}

/* ══════════════════════════════════════════════════════════════════════════════
   WD-468 · THE ASSESSMENT-OBJECTIVE PANELS — Image to Content, our parallax

   Neil, 2026-08-29, replacing the card deck: *"something like the image to content style from
   tympanus or codrops. But… they've got a weird sort of parallax. Why don't we replace their
   parallax effect with ours?… there's, like, a navigation on the left. We don't need that…
   the user can click on them to find out more about each assessment objective."*
   Reference read before building: https://tympanus.net/Development/ImageToContent/
     theirs, from `index.d73f7ad5.css`     ours
     .preview        min-height 90vh       .swf-ao__panel  min-height min(68vh, 640px)
     .preview__img   overflow hidden       .swf-ao__frame  overflow clip
     .preview__img-inner background 150%   .swf-ao__img    inset -30% 0, the band overhang
     scrubbed yPercent                     `--swf-px`, the page's own parallax loop
     .frame  vertical-rl sticky index      ⛔ not built — he said so
     full-viewport .content-wrap           an in-place split (see the deviation in the template)

   ⛔ THE DEAD `.swf-dc` DECK RULES ARE GONE, and only because they now have no consumer: the
   markup that used them was replaced in the same change (root §3 — remove what YOUR change made
   unused, never pre-existing dead code). Its full story, including the deletion that caused
   WD-467, is in `~/.claude/handoffs/open/webdesign-FIXLIST.md`.
   ⚠️ `.swf-dc__lens` and `.swf-dc__hint` STAY — they are NOT the deck's. `rules.js` creates both
   under those names for the mark-scheme paper's decrypt, and the paper restyles them at
   `.swf-ms__paper .swf-dc__hint`. Deleting them because the prefix matched would have taken the
   reticle and the phone's "Tap to reveal" button with them.
   ══════════════════════════════════════════════════════════════════════════════ */
.swf-ao { position: relative; }

.swf-ao__list {
  width: min(80rem, 100%); margin: 0 auto;
  padding-block: var(--swf-sect);
  /* ⛔ WD-477 — THE GAP IS BACK, AND THE REASON IS THE RADIUS (Neil, 2026-08-30, after seeing it
     flush): *"I can't tell whether it's better with or without the spacing... I think it's because
     we've got the rounded corners without the spacing. It looks a bit weird. So just bring the
     spacing back."* He is right about the cause: a rounded corner needs ground around it to read
     as a corner at all. Ten rounded panels stacked flush make a column of pinched notches where
     two radii meet, not one continuous surface. Removing the gap would only work with square
     corners, which is a bigger change than the one he asked for. */
  display: grid; gap: clamp(20px, 2.4vw, 34px);
}

/* ⭐ ONE PROPERTY CARRIES THE WHOLE OPEN — the grid template. Closed, the detail column is `0fr`
   and the picture owns the panel; open, it is `1fr` and the picture squeezes to half while the
   criterion grows in beside it. That IS "image to content", and it costs one transitionable
   declaration instead of a full-viewport takeover and its four landmines.
   ⛔ `overflow: clip`, never `hidden` — this lane's standing rule: `hidden` makes a scroll
   container, and this page is full of scroll machinery that would then have something new to
   fight. `clip` clips identically and creates none. */
/* ⭐⭐ OPEN IS THE DEFAULT — IN THE CSS, NOT ONLY IN THE MARKUP (root §4d), and the first cut of
   this file got it wrong. It shipped `grid-template-columns: 1fr 0fr` as the base, so with no JS
   every criterion was collapsed to a zero-width column and the button that opens it did nothing:
   the exact failure the template's own comment claimed to be avoiding. The closed state now hangs
   off `.is-js`, which `rules.js` adds to the list only once it has bound the handler — so the
   collapse cannot exist unless something is there to undo it. */
.swf-ao__panel {
  /* ⭐⭐ THE CONTAINER IS WHAT STOPS THE TEXT RE-WRAPPING WHILE THE COLUMN OPENS — see the note on
     `.swf-ao__detail-in`. `cqw` resolves against THIS box, which never changes width. */
  container-type: inline-size;
  position: relative; overflow: clip;
  border-radius: var(--swf-r-lg);
  min-height: min(68vh, 640px);
  display: grid;
  grid-template-areas: "media detail";
  grid-template-columns: 1fr 1fr;
  /* ⭐⭐ WD-470 — 300ms ON THE STANDARD CURVE, and both numbers come from `MOTION.md`, which Neil
     told me to check. §3 Duration Scale: the open is UI FEEDBACK — he clicked and is waiting — so
     it sits under the **400ms feedback ceiling** ("longer reads as broken"), on Standard
     `cubic-bezier(0.4,0,0.2,1)`; `page`/section transitions are the 300 row. The old 720ms on the
     arrival curve broke that ceiling by nearly double, and MOTION.md says in terms: "Never use
     arrival timing on anything triggered by a click."
     ⭐ THE WHOLE GESTURE IS A `narrative` BEAT (1000-1800ms, "staged with overlap, never one slow
     tween"): 300ms of space opening, THEN ~850ms of text decrypting = ~1.15s, staged. That is
     exactly the order Neil described — *"it should open with no text, and then as soon as it's
     finished, it just starts decrypting"* — and it is also what the brand doc already prescribed. */
  transition: grid-template-columns .3s cubic-bezier(0.4, 0, 0.2, 1);
  color: #f4f6fa;
  box-shadow: 0 40px 90px -40px rgba(44,0,62,.5), 0 2px 6px rgba(20,10,40,.3);
}
.is-js .swf-ao__panel:not(.is-open) { grid-template-columns: 1fr 0fr; }

/* the picture, and the veil that makes type survivable on top of any photograph */
.swf-ao__frame { grid-area: media; position: relative; overflow: clip; min-width: 0; }
.swf-ao__img {
  position: absolute;
  /* ⛔ -30% PER SIDE, AND IT MUST MATCH `TRAVEL_RATIO` IN `funnel.js`. The travel is 30% of the
     section height; anything less here and the layer's own edge slides into view at the end of
     the range. The bands carry the same pair and the same comment. */
  /* ⭐ WD-478 — THE OVERHANG IS NOW PUBLISHED PER BAND by `initParallax`, because Risen's travel
     is a fraction of SCROLL DISTANCE (viewport-dependent) rather than of the section, so a short
     section needs more headroom than a tall one. `--swf-oh` defaults to their 30% and only ever
     grows. See the long note in `funnel.js`. */
  /* ⭐⭐ WD-487 — RISEN'S LAYER GEOMETRY, VERBATIM. Read off their `theme.js` in our mirror:
         function setSubPx(){ subPxImg.height( subPx.outerHeight() + subPx.outerHeight()*.3 ) }
     and their `style.css`: `.subPxImg{position:absolute;top:0;left:0;right:0;bottom:0;…}`.
     So the layer is ANCHORED AT THE TOP and 130% tall — the whole overhang hangs BELOW the
     section. Ours had been `inset:-30% 0`, a 160% layer centred on the section: same MOTION, but a
     different SLICE of the photograph (the middle of the picture instead of its top 77%). Neil:
     *"I want it exactly the same as the Risen Church website."* Now it is.
     ⭐ `max(130%, 100% + 15vh)` is their 30% as a FLOOR: the bottom edge stays covered only while
     0.15·vh ≤ overhang, and their bands are all taller than half a viewport; ours are not, so the
     overhang grows where it must. The travel never changes — motion is what is seen. */
  top: 0; left: 0; right: 0;
  height: max(130%, calc(100% + 15vh));
  background-size: cover; background-position: center;
  transform: translate3d(0, var(--swf-px, 0px), 0);
  will-change: transform;
}
/* ⭐ FAIL-SAFE, inherited from the bands: with `--swf-px` never set the fallback is 0px, so no-JS,
   a script error or reduced motion gives a STILL PHOTOGRAPH, never a broken one. */
.swf-ao__veil {
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(20,10,40,.30) 0%, rgba(20,10,40,.58) 46%, rgba(20,10,40,.86) 100%);
}

/* the face sits in the SAME grid area as the picture, so it shrinks with it on open */
/* ⛔ `align-content: end` IS LOAD-BEARING, and the symptom was not where it looked. MEASURED at
   390: the Close button rendered as an 87px CIRCLE while its padding read an identical
   `9.3px 17.25px` at both widths — so nothing was padding it. The face is a GRID with a 460px
   floor and `align-content: normal`, i.e. STRETCH, so its four rows were inflated to fill
   (85 / 72.6 / 119 / 95) and the button grew with the row it sat in. Packing the rows to the
   bottom gives every child its natural height and fixes the spread-out face in the same
   declaration. ⭐ Worth remembering as a class: a control that is the wrong SHAPE inside a grid is
   usually its TRACK's fault, not its own — check `grid-template-rows` before touching padding. */
.swf-ao__face {
  grid-area: media; position: relative; z-index: 2; min-width: 0;
  align-self: end;
  padding: clamp(26px, 3vw, 44px);
  display: grid; gap: var(--swf-md); justify-items: start; align-content: end;
}
.swf-ao__code {
  margin: 0; font-family: var(--swf-display); font-weight: 700; font-style: italic;
  font-size: clamp(2.75rem, 1.6rem + 3.4vw, 5rem); line-height: .9;
  letter-spacing: -.03em; color: #7DF9E9;
}
.swf-ao__skill {
  margin: 0; max-width: 18ch;
  font-family: var(--swf-font); font-weight: 700;
  font-size: clamp(1.6rem, 1.1rem + 1.6vw, 2.5rem);
  line-height: 1.06; letter-spacing: -.03em; color: #fff;
}
.swf-ao__plain {
  margin: 0; max-width: 42ch;
  font-size: clamp(1rem, .95rem + .3vw, 1.25rem); line-height: 1.5; color: #e0e0ee;
}

/* ⭐ A REAL BUTTON — `.swf-ao__open` is not a link, because nothing navigates. Its two labels are
   both in the markup and swapped by `aria-expanded`, so the control never depends on JS having
   written text into it. */
.swf-ao__open {
  margin-top: var(--swf-sm);
  padding: .62em 1.15em; border: 0; border-radius: 999px;
  background: #7DF9E9; color: #1C1D1F;
  font: inherit; font-weight: 700; font-size: .9375rem; letter-spacing: .01em;
  cursor: pointer; transition: transform .18s var(--swf-arrive), background .18s linear;
}
.swf-ao__open:hover { background: #a9fbf0; }
.swf-ao__open:active { transform: scale(.97); }
.swf-ao__open:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.swf-ao__open-off { display: none; }
.swf-ao__open[aria-expanded="true"] .swf-ao__open-on { display: none; }
.swf-ao__open[aria-expanded="true"] .swf-ao__open-off { display: inline; }

/* the content half */
.swf-ao__detail {
  grid-area: detail; position: relative; z-index: 2;
  overflow: hidden; min-width: 0;
  background: #242628;
}
/* ⛔⛔ ABSOLUTE, AND IT IS A CORRECTNESS FIX RATHER THAN A LAYOUT PREFERENCE. MEASURED on the
   first build: a closed panel rendered **3497px tall** instead of its 640px floor. A `0fr` COLUMN
   is zero-WIDE, but its item still lays out at that width — so the criterion wrapped to roughly
   one character per line and the grid ROW grew to fit the resulting tower. Taking the content out
   of flow means the closed column contributes no height at all, and the panel is sized by the
   photograph as intended.
   ⚠️ THE PHONE DOES NOT HAVE THIS BUG AND MUST NOT GET THIS FIX: there the `0fr` is the ROW
   itself, so it is simply zero-high and the content sizes it correctly when open. The override is
   in the 900px block, and inverting the two is how this returns.
   ⭐ `inset: 0` + `overflow-y: auto` also means a long criterion scrolls its own box instead of
   pushing the Close button past the fold — root §CONTENT MUST SCROLL. */
/* ⭐⭐ WD-469 — `width: 50cqw` IS THE WHOLE FIX FOR "the way that the text animates… looks weird"
   (Neil, 2026-08-30), and the cause was measured rather than guessed. Sampling the criterion's
   own box across one open, its height stepped:
       2701 → 1415 → 515 → 322 → 289 → 225 → 161 → 129 → 96 → 64px
   Ten distinct heights in 0.72s. The text was laid out at the COLUMN's animating width, so it
   started as a one-character-per-line tower and re-wrapped on **every frame** — words visibly
   cascading and collapsing into place. It is the same root as the 3497px panel: content sized by
   a track that is still moving.
   ⇒ `cqw` resolves against `.swf-ao__panel`, whose width is CONSTANT, so the criterion is wrapped
   ONCE at its final measure and the growing column simply UNCOVERS it. A wipe, not a reflow.
   ⛔ Do not "simplify" this to `width: 100%` or `inset: 0`: both resolve against `.swf-ao__detail`,
   which is the animating box, and the cascade comes straight back. */
/* ⛔ PINNED TO THE **RIGHT**, and that is what makes this a WIPE rather than a SLIDE. The detail
   column grows leftward — its right edge is fixed at the panel edge and its left edge travels from
   1280 to 640. Pinned `left: 0` the text rides that moving edge and slides in from off-panel;
   pinned `right: 0` it is STATIONARY at 640-1280 the whole time and the widening column simply
   uncovers it. Same single property, and the difference between the two is the whole feel of the
   open. ⭐ The sweep follows the same direction (right to left) — see `SWEEP_DIR` in `rules.js`,
   which is their `directional: true`. */
.swf-ao__detail-in {
  position: absolute; top: 0; bottom: 0; right: 0; left: auto;
  width: 50cqw;
  padding: clamp(26px, 3vw, 44px);
  display: flex; flex-direction: column; justify-content: center; gap: var(--swf-lg);
  min-height: 0; overflow-y: auto;
}
.swf-ao__label {
  margin: 0; font-size: .8125rem; font-weight: 600;
  letter-spacing: .12em; text-transform: uppercase; color: #9da5b4;
}
.swf-ao__quote { margin: 0; }
.swf-ao__quote p {
  margin: 0; max-width: 44ch;
  font-family: var(--swf-display); font-style: italic; font-weight: 400;
  font-size: clamp(1.1875rem, 1rem + .7vw, 1.5rem); line-height: 1.34; color: #f4f6fa;
}
.swf-ao__where {
  margin-top: var(--swf-sm);
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--swf-md) var(--swf-xl);
}
.swf-ao__nat, .swf-ao__map li {
  margin: 0; font-size: .8125rem; letter-spacing: .06em; text-transform: uppercase; color: #9da5b4;
}
.swf-ao__nat b, .swf-ao__map b { margin-left: .5em; letter-spacing: .1em; color: #7DF9E9; }
.swf-ao__map {
  margin: 0; padding: 0; list-style: none;
  display: flex; flex-wrap: wrap; gap: var(--swf-md) var(--swf-xl);
}

/* ── WD-469 · the ASCII sweep's three character states ─────────────────────────────────────
   Ported from Canvas UI's ASCII Sweep, whose shader paints the same three: ahead of the band the
   OLD panel, inside the band a glyph, behind it the new content with a glowing trail.
   ⭐ THEIR `glow: 2` AND `tint: 0.75` ARE WHAT MAKE IT READ AS A SWEEP rather than as a fade —
   the band burns, the trail cools, and only then does the real character settle. Take the glow
   out and it is a wipe.
   ⛔ `data-on="off"` IS ONLY INVISIBLE UNDER `.is-sweeping`, which `rules.js` adds when a sweep
   actually starts and removes when it settles. Hiding it unconditionally would mean any failure
   between wrapping the text and settling it — a thrown error, an unmeasurable box, an observer
   that never fires — left the criterion permanently blank (root §4d). */
.swf-ao__ch { transition: color .14s linear; }
.is-sweeping .swf-ao__ch[data-on="off"] { opacity: 0; }
.swf-ao__ch[data-on="band"] {
  color: #7DF9E9;                                   /* deviation 2 — theirs is #4ade80 */
  text-shadow: 0 0 8px rgba(125,249,233,.95), 0 0 18px rgba(125,249,233,.55);
  transition: none;
}
.swf-ao__ch[data-on="trail"] {
  color: #d8fffa;
  text-shadow: 0 0 6px rgba(125,249,233,.45);
  transition: none;
}
.swf-ao__ch[data-on="on"] { color: inherit; text-shadow: none; }
/* WD-503 — the resting state of an unread face: noise, dimmed, no glow. Visible by design (it is
   the "coded" promise the panels make); the sweep, not a stylesheet, turns it into words. */
.swf-ao__ch[data-on="cipher"] { opacity: .55; color: inherit; text-shadow: none; }

@media (prefers-reduced-motion: reduce) {
  .swf-ao__ch { transition: none; }
}

/* ⛔ NO SIDE-BY-SIDE SPLIT ON A PHONE. Half of a 390px panel is 195px, which is neither a
   photograph nor a column of text. The same one-property open runs on ROWS instead. */
@media (max-width: 900px) {
  .swf-ao__panel {
    grid-template-areas: "media" "detail";
    grid-template-columns: 1fr;
    grid-template-rows: auto auto;
    transition: grid-template-rows .3s cubic-bezier(0.4, 0, 0.2, 1);
    min-height: 0;
  }
  .is-js .swf-ao__panel:not(.is-open) { grid-template-columns: 1fr; grid-template-rows: auto 0fr; }
  .swf-ao__frame, .swf-ao__face { grid-area: media; }
  .swf-ao__frame, .swf-ao__face { min-height: min(58vh, 460px); }
  /* ⭐ BACK INTO FLOW — see the note on `.swf-ao__detail-in`. Here the collapsed track is the ROW,
     so the content must size it; absolute positioning would give an open panel a 0px detail. */
  /* ⭐ AND `50cqw` GOES WITH IT: the phone animates the ROW, so the width never changes and there
     is nothing to re-wrap. Back into flow at full width, per the note above. */
  .swf-ao__detail-in {
    position: static; width: auto; inset: auto; overflow: visible;
    padding-block: clamp(24px, 6vw, 34px);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* ⭐ A LAYOUT, NOT A SLOWER VERSION — the panel opens instantly and the picture never moves. */
  .swf-ao__panel { transition: none; }
  .swf-ao__img { transform: none; }
  .swf-ao__open { transition: none; }
}

/* ══════════════════════════════════════════════════════════════════════════════
   WD-466 · THE MARK SCHEME PAGE — restored. Neil: *"I wanted a page, like how we had it before,
   and in that page they could decrypt the text."* The canvas wall is withdrawn; the sheet is back,
   carrying the general principles instead of one question. The decrypt is the DOM engine again,
   per character, which is what it is good at.
   ⛔ PERMANENTLY-LIGHT ISLAND — a picture of a piece of paper. Every colour is NAMED, because
   `--swf-mint` is Brand Deep Purple in light theme and would flip the ink out from under it.
   ══════════════════════════════════════════════════════════════════════════════ */
.swf-ms {
  /* WD-481: was `calc(var(--swf-band) * 1.4)` = 210px — a fifth multiplier nothing else uses,
     and it sat under a band's 150px, so 360px separated the headline from the paper it names.
     The paper is a reading region inside `.swf-dx`'s ground: the BEAT tier. */
  grid-column: full; padding-block: var(--swf-sect);
  padding-inline: var(--swf-gutter);
  display: flex; justify-content: center;
}
.swf-ms__stack { position: relative; width: 62rem; max-width: 100%; }

/* the three sheets under the paper — the reference's own transforms, verbatim (WD-450) */
.swf-ms__sheet {
  position: absolute; inset: 0; border-radius: 2px;
  box-shadow: 0 24px 50px -34px rgba(20,10,40,.5); pointer-events: none;
}
.swf-ms__sheet--1 { z-index: 1; background: #F4F1EA; transform: rotate(-2.29061deg) skew(-.572252deg, 0deg) scale(1.0008, 1.0004); }
.swf-ms__sheet--2 { z-index: 2; background: #FFFFFF; transform: rotate(-.57294deg) scale(1.00005, 1.00005); }
.swf-ms__sheet--3 { z-index: 3; background: #F4F1EA; transform: rotate(1.71836deg) scale(1.00045, 1.00045); }

.swf-ms__paper {
  /* the engine's palette. On white stock the mint is invisible, so the cipher and its wavefront
     are Sophicly purple — noise you can actually see. */
  --dc-acc: #5333ed; --dc-acc-rgb: 83,51,237;
  position: relative; z-index: 4; overflow: hidden; cursor: none;
  padding: clamp(26px, 4vw, 60px) clamp(20px, 3.6vw, 56px) clamp(30px, 4vw, 60px);
  border-radius: 2px; background: #FFFFFF; color: #1c1d1f; font-family: var(--swf-font);
  box-shadow: 0 40px 80px -40px rgba(20,10,40,.55), 0 2px 4px rgba(20,10,40,.12);
}
/* the stock's own tooth — a whisper of ruling, so it reads as paper and not a white div */
.swf-ms__paper::before {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background:
    radial-gradient(120% 90% at 88% -8%, rgba(83,51,237,.05), transparent 58%),
    repeating-linear-gradient(180deg, rgba(28,29,31,.032) 0 1px, transparent 1px 28px);
}
.swf-ms__paper > * { position: relative; z-index: 1; }
.swf-ms__paper.is-plain { cursor: auto; }

/* ⭐ WD-467 — THE MASTHEAD IS A STAMP. It was a 11-14px right-aligned caption and Neil could not
   see what the page was: *"maybe we need to have it somewhere a bit more prominent that it's about
   the mark scheme."* The stamp reads first, the qualification reads second, and the 2px rule under
   both is the thing that makes a white div look like a printed document. */
.swf-ms__masthead {
  margin: 0 0 clamp(20px, 3vw, 36px); padding-bottom: var(--swf-md);
  border-bottom: 2px solid #1c1d1f;
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: var(--swf-md) clamp(12px, 2vw, 22px);
  color: #1c1d1f;
}
.swf-ms__stamp {
  padding: .34em .7em .3em; border-radius: 2px;
  background: #5333ed; color: #fff;
  font-weight: 700; font-size: clamp(13px, 1.35vw, 19px);
  letter-spacing: .16em; text-transform: uppercase;
}
.swf-ms__mastline {
  margin-left: auto;                      /* the qualification sits opposite, as on a real cover */
  font-weight: 600; font-size: clamp(11px, 1.05vw, 14px);
  letter-spacing: .1em; text-transform: uppercase;
}

.swf-ms__title { margin-bottom: clamp(22px, 3vw, 36px); }
.swf-ms__qline {
  margin: 0; font-weight: 700; font-size: clamp(11px, 1.05vw, 13px);
  letter-spacing: .16em; text-transform: uppercase; color: #55555f;
}
/* ⭐⭐ WD-467 — "Nine words for a grade nine", on TWO lines, and the size came off the arithmetic
   rather than off the eye (WML CLAUDE.md §ALIGNMENT/GEOMETRY: solve it on paper first).
     content width  = 62rem sheet - 2 x clamp(20,3.6vw,56) padding - the 30px indent  = ~850px
     longest line    = "for a grade nine", 16 characters
     at the old 104px cap, 16 x ~.50em advance                                        = ~832px
   832 into 850 is a 2% margin on an ESTIMATED advance, i.e. a line that overflows on any font
   fallback or any narrower sheet. Capped at 88px it measures ~704px and has real air, and TWO
   lines at 88px still stand taller on the page than ONE line at 104px did. */
.swf-ms__ao {
  position: relative; display: inline-block; margin: .1em 0 0 clamp(12px, 2vw, 30px);
  /* ⛔ WD-480 — TRACKING BACK TO NORMAL. Neil: *"why the words so squashed together? Have we done
     something to the kerning there?"* We had: `letter-spacing: -.04em`, which at 88px is −3.5px in
     every gap — my value, not a ported one. The sheet is a printed document, and printed documents
     do not tighten their display type. Removed rather than reduced. */
  font-weight: 700; font-size: clamp(40px, 6.4vw, 88px); line-height: .9;
  color: #1c1d1f;
}
/* ⭐⭐ THE GHOST HOLDS THE BOX. Cipher glyphs are proportional, so an enciphered heading is a
   different width every frame — at this size that is a headline that shakes and a ring that
   chases it. Ghost and cipher share one grid cell: the ghost is hidden but still lays out, so the
   box is the REAL text's box for ever and the scramble happens inside it. */
.swf-ms__aoline { display: grid; }
.swf-ms__aoline > * { grid-area: 1 / 1; }
.swf-ms__aoghost { visibility: hidden; }
/* ⛔ STRETCHES TO THE BLOCK, and the stroke does not stretch with it — see the template's note.
   Without `preserveAspectRatio="none"` a path drawn at 260x150 scaled by width alone lies as a
   flat band across the middle of a two-line heading. */
/* ⛔⛔ `max-width: none` IS LOAD-BEARING AND WAS MISSING ALL ALONG. The theme ships a global
   `svg { max-width: 100% }` (this file already fights it at `.swf-rb__band svg`, line ~1184), so
   the ring's `width: 124%` had been silently clamped to 100% since it was written — MEASURED
   after the two-line change: ring box 574px against a heading box of 574px, i.e. a circle drawn
   exactly ON the words instead of around them. A percentage that quietly becomes 100% is the
   worst kind of CSS bug: it still looks roughly right on a single short line, which is why it
   survived. */
.swf-ms__ring {
  position: absolute; z-index: -1; left: 50%; top: 52%;
  width: 114%; height: 128%; max-width: none;
  transform: translate(-50%, -50%); color: #5333ed;
}
.swf-ms__q {
  margin: clamp(14px, 2vw, 20px) 0 0; max-width: 40ch;
  font-family: var(--swf-font); font-weight: 700; font-style: normal;
  font-size: clamp(1.0625rem, .95rem + .7vw, 1.5rem); line-height: 1.3; color: #1c1d1f;
}
.swf-ms__squiggle {
  display: block; width: clamp(120px, 14vw, 180px); height: auto;
  margin: clamp(14px, 2vw, 22px) 0 0; color: #1c1d1f;
}

/* the criteria table — hairlines and a tinted head row, the shape a student recognises */
.swf-ms__table { width: 100%; border-collapse: collapse; table-layout: fixed; border: 1px solid rgba(28,29,31,.55); }
.swf-ms__table th, .swf-ms__table td {
  border: 1px solid rgba(28,29,31,.4); padding: clamp(9px, 1.1vw, 14px);
  vertical-align: top; text-align: left;
}
.swf-ms__table thead th {
  background: rgba(28,29,31,.05); font-weight: 700; font-size: clamp(11px, .9vw, 13px);
  letter-spacing: .08em; text-transform: uppercase; color: #1c1d1f;
}
.swf-ms__table thead th:first-child { width: 26%; }
.swf-ms__table tbody th b {
  font-weight: 700; font-size: clamp(14px, 1.15vw, 18px); color: #1c1d1f; letter-spacing: -.01em;
}
.swf-ms__table td p { margin: 0; font-size: clamp(13px, 1.02vw, 15.5px); line-height: 1.45; color: #1c1d1f; }

.swf-ms__src {
  margin: clamp(18px, 2.4vw, 28px) 0 0;
  font-size: clamp(12px, .95vw, 14px); line-height: 1.5; color: #55555f;
}

/* the paper owns the lens + the hint the cards used to */
.swf-ms__paper .swf-dc__lens { z-index: 3; }
.swf-ms__paper .swf-dc__hint {
  opacity: 1; right: clamp(20px, 3.6vw, 56px); top: clamp(66px, 8vw, 104px);
  background: #5333ed; color: #fff;     /* opaque: it sits on printed stock, not on glass */
}
.swf-ms__paper.is-lit .swf-dc__hint, .swf-ms__paper.is-plain .swf-dc__hint { opacity: 0; }

/* ⚠️ DARK THEME TAKES THE GLARE OFF THE STOCK, NOTHING MORE. A sheet of paper does not invert. */
html[data-theme='dark'] .swf-ms__paper, html.dark .swf-ms__paper { background: #EDE9E0; }
html[data-theme='dark'] .swf-ms__sheet--2, html.dark .swf-ms__sheet--2 { background: #EDE9E0; }
html[data-theme='dark'] .swf-ms__sheet--1, html[data-theme='dark'] .swf-ms__sheet--3,
html.dark .swf-ms__sheet--1, html.dark .swf-ms__sheet--3 { background: #E2DDD2; }

@media (max-width: 700px) {
  /* ⛔ NO SIDE-BY-SIDE TABLE ON A PHONE — a 26% term column leaves the meaning at ~4 words a line. */
  .swf-ms__table, .swf-ms__table tbody, .swf-ms__table tr, .swf-ms__table td { display: block; width: 100%; }
  .swf-ms__table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  .swf-ms__table tbody th { display: block; width: 100%; text-align: left; background: rgba(28,29,31,.05); }
  .swf-ms__table tr + tr { margin-top: -1px; }
  /* ⛔ the stamp keeps the left edge and the qualification drops under it — `margin-left:auto`
     on a wrapped flex line shoves it to the far right, alone, which reads as a stray label. */
  .swf-ms__mastline { margin-left: 0; }
  /* ⭐ MEASURED IN THE BROWSER AT 390, and the first arithmetic for this was WRONG in a way worth
     recording: the usable width is not the viewport minus gutters, it is **234px** — the paper
     renders 286px wide inside the section's own gutters, less its 20px padding each side and the
     12px heading indent. Driven at three sizes against that real number:
       34px → "for a grade nine" measures 227px, ONE line each, heading box 61px tall  ✔
       40px → hits the 234px cap and wraps, heading box 108px — a THREE-line heading, and the
              ring (sized for two) then cuts straight through the last line
     So the floor is 34px. ⛔ Do not raise it from a desktop-shaped estimate of the width. */
  .swf-ms__ao { font-size: clamp(34px, 8.4vw, 46px); }
  /* ⛔ `position: absolute` RESTATED: the deck's own 900px block sets `.swf-dc__hint{position:static}`,
     unscoped and later in the file, which dropped this chip to the bottom of the sheet (WD-460). */
  .swf-ms__paper .swf-dc__hint {
    position: absolute; right: 16px; top: 62px; padding: 5px 12px; font-size: .6875rem;
  }
}

/* ── the decrypt, ported from Canvas UI (WD-445) ──────────────────────────── */
/* ⛔ WD-447: the cards no longer encipher, so they no longer hide the cursor. */
/* the pointer reticle — shows where the decrypt field is centred */
.swf-dc__lens {
  position: absolute; z-index: 3; left: 0; top: 0; width: 26px; height: 26px;
  margin: -13px 0 0 -13px; border-radius: 50%;
  border: 1px solid rgba(var(--dc-acc-rgb), .55);
  background: radial-gradient(circle, rgba(var(--dc-acc-rgb), .16), transparent 70%);
  opacity: 0; transition: opacity .18s linear; pointer-events: none;
}
.is-lit .swf-dc__lens { opacity: 1; }
/* the prompt — a card that looks like noise must SAY it can be opened (root §4d) */
.swf-dc__hint {
  /* ⚠️ TOP-RIGHT, not bottom-centre: at the foot it sat on top of the board-mapping row, which
     is itself content. The eyebrow line is the only always-empty corner of the card. */
  position: absolute; z-index: 3; right: clamp(24px, 4vw, 56px); top: clamp(28px, 4vw, 52px);
  margin: 0; padding: 7px 16px; border-radius: 999px;
  background: rgba(var(--dc-acc-rgb), .12); color: var(--dc-acc);
  font-family: var(--swf-font); font-weight: 700; font-size: .75rem;
  letter-spacing: .12em; text-transform: uppercase; white-space: nowrap;
  transition: opacity .3s var(--swf-arrive); pointer-events: none;
}
/* ⭐⭐ WD-460 — ON TOUCH THE CHIP IS A BUTTON, so it must be able to receive the tap. The `<p>`
   above is `pointer-events: none` (correct: on a pointing device it is a label over a hover
   field). A button that cannot be pressed is the same broken promise in a new costume, so the
   element type carries the behaviour rather than a media query guessing at it. */
button.swf-dc__hint {
  pointer-events: auto; cursor: pointer; border: 0; font: inherit;
  font-family: var(--swf-font); font-weight: 700; font-size: .75rem;
  letter-spacing: .12em; text-transform: uppercase;
  /* a real touch target — 44px is the smallest Apple will call one */
  min-height: 44px; padding-inline: 18px;
  -webkit-tap-highlight-color: transparent;
}
button.swf-dc__hint:active { transform: scale(.97); }
button.swf-dc__hint:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
/* ⛔ ONE HINT AT MOST. Six cards in a pile all peek out at the top, so six chips were visible at
   once. The card decrypts itself on arrival now, so the hint is only a nudge for the second way
   in (hover) and only ever belongs on the card actually being read. */
.swf-dc__hint { opacity: 0; }

/* ⭐ THE THREE GLYPH STATES, mirroring the reference's shader: cipher · wavefront · plain.
   `edgeGlow: 2` and `edgeTint: 0.75` are theirs — the ring burns brighter and tints hard toward
   the cipher colour, which is what makes the decrypt read as a wave rather than a fade. */
/* the word wrapper (WD-450) — every glyph is an inline-block, so without this the line-breaker
   may break INSIDE a word. It is the enciphered text's only defence against that. */
.swf-dx__w { display: inline-block; white-space: nowrap; }
.swf-dx__ch { transition: color .12s linear; }
.swf-dx__ch[data-on="0"] { color: rgba(var(--dc-acc-rgb), .55); }
.swf-dx__ch[data-on="e"] {                       /* on the wavefront */
  color: var(--dc-acc);
  text-shadow: 0 0 10px rgba(var(--dc-acc-rgb), .85);
  transition: none;
}
.swf-dx__ch[data-on="1"] { color: inherit; }
/* ⭐⭐ WD-460 — `.is-plain` IS AUTHORITATIVE, and this is a correctness fix, not a tidy-up.
   MEASURED after the touch reveal: `goPlain()` restores every character's REAL TEXT (probed:
   `firstCharText === firstCharReal`) but the `data-on` attribute was still reading `0`, so the
   sheet rendered the true words in the CIPHER COLOUR — lavender at 55% on white paper. The
   student taps "Tap to reveal", the mark scheme does appear, and it appears almost unreadable.
   ⛔ The lesson: TWO SOURCES OF TRUTH FOR ONE STATE. `data-on` is written per-frame by the paint
   loop and `.is-plain` is written once by the reveal, so any frame that lands after the reveal
   can quietly contradict it — a race that is invisible on desktop, where the pointer field keeps
   repainting anyway. The panel-level class is the one that cannot be raced, so it wins outright
   and the per-character attribute is left to the animation it belongs to. */
.is-plain .swf-dx__ch { color: inherit; text-shadow: none; }

/* ⛔⛔ THE DECK'S OWN 900px BLOCK IS DELETED WITH THE DECK — and one line of it was doing HARM,
   which is worth recording because it is the twin of WD-467. It carried
   `.swf-dc__hint { position: static }`, written for the CARD, unscoped. The cards are gone, so
   that rule's only remaining subject was the MARK SCHEME's decrypt chip — and being later in the
   file it beat the chip's own `position: absolute`. WD-460 had already been bitten by this and
   patched it back at `max-width: 700`, which left **701-900px still broken**: the "Hover to
   decrypt" chip dropped out of the corner and into the flow of the sheet, at every tablet width.
   Deleting the deck fixes a bug nobody had reported yet. ⚠️ The `max-width: 700` restatement in
   the paper's own block STAYS — it is correct, it is scoped, and it costs nothing. */

@media (prefers-reduced-motion: reduce) {
  .swf-dx__ch { transition: none; }
}

/* ── dark theme: the GROUND swaps; the cards are already the dark ladder ──── */
/* ⛔ the GROUND override is gone (WD-481) — `--swf-base` flips on its own. */
html[data-theme='dark'] .swf-dx__h,
html.dark .swf-dx__h { color: var(--swf-ink); }
html[data-theme='dark'] .swf-dx__h em,
html.dark .swf-dx__h em { color: #7DF9E9; }   /* purple text fails on dark; mint is the dark accent */
html[data-theme='dark'] .swf-dx__stand,
html.dark .swf-dx__stand { color: var(--swf-ink-soft); }


/* ══════════════════════════════════════════════════════════════════════════════
   WD-448 · THE WORD TUNNEL — the practice reminder, immediately before the CTA.
   Neil, 2026-08-29: *"just before the call to action at the end to take the assessment, we should
   have a section where we remind them that everything that they've just gone through is just a
   theory. Until they actually put it into practice, it doesn't really matter."*
   Ported from `CSS Shine Effect/Sophicly Word Tunnel - PORT.html` (#B-578). Their geometry:
       seven layers, scale 0.85^i (1 · .85 · .7225 · .614125 · .52200625 · .4437053125 · .377149…)
       layer 1 is IN FLOW and never scales — it sets the section's height; 2-7 are absolute inset 0
       range   start = trigger top reaches viewport CENTRE · end = trigger bottom reaches top
       ease    power3.out  →  e = 1 − (1 − p)³
   ⭐ The port already carries the word PRACTICE and the line "nothing here works unless you keep
   coming back to it" — it was built for exactly this argument, so the adaptation is copy only.
   ⛔ ONE token published per frame (`--e`); everything else is CSS. Do not re-derive per layer.
   ══════════════════════════════════════════════════════════════════════════════ */
.swf-tunnel {
  grid-column: full; position: relative; padding-top: 16px;
  /* ⭐⭐ WD-481 — THE TUNNEL NOW ANSWERS THE THEME. It was `#070707` on `#eaeaea` in BOTH
     themes: a near-black that is not on our dark ladder (`--swf-page` is #1c1d1f) and a black
     panel dropped into a white page in light. Neil: *"everything apart from the general header
     hero need to support our light and dark theme colours."*
     ⭐ The depth is pure `scale` on the layers — no gradient, no opacity, nothing that needs a
     dark ground — so the corridor reads identically with the ground inverted: receding words in
     `--swf-ink` on `--swf-page`. */
  background: var(--swf-page); color: var(--swf-ink); overflow: clip;
}
.swf-tunnel__header { padding: clamp(48px, 8vh, 96px) var(--swf-gutter) 0; max-width: 1440px; margin: 0 auto; }
.swf-tunnel__eyebrow {
  margin: 0 0 var(--swf-lg); display: flex; align-items: center; gap: var(--swf-md);
  font-family: var(--swf-font); font-weight: 700; font-size: .75rem;
  /* the accent token, not the literal — `--swf-mint` is mint on dark and Brand Deep Purple
     (#2c003e) on light, which is the pair this page uses everywhere else. */
  letter-spacing: .3em; text-transform: uppercase; color: var(--swf-mint);
}
.swf-tunnel__eyebrow::before {
  content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--swf-mint);
}
.swf-tunnel__h {
  margin: 0; max-width: 20ch;
  font-family: var(--swf-font); font-weight: 500;
  font-size: clamp(2rem, 1.2rem + 3.4vw, 3.9375rem);   /* 31 → 63, DISPLAY */
  line-height: 1.05; letter-spacing: -.02em; color: var(--swf-ink);
}
/* ⭐⭐ WD-449 — THE PIN. Stated deviation from the port (root §13): theirs runs the scrub across
   the section's own pass through the viewport, which works because on their page the section is
   the only thing on screen. Ours sits under a heading, so by the time the words cleared the fold
   `--e` was already 0.67 — measured. The track owns the scroll; the content sticks and plays the
   whole 0 → 1 while pinned, so the reader ARRIVES at one word and watches it become a tunnel.
   ⚠️ Layer height follows the pin: 100svh, not their 125svh, or the layers overflow the pinned
   box and the crosshair no longer sits at the centre of what you can see. */
.swf-tunnel__track { position: relative; height: 320vh; }
.swf-tunnel__content {
  position: sticky; top: 0; height: 100svh; overflow: hidden;
  /* ⭐⭐ IT FADES OUT AS IT LEAVES (Neil, 2026-08-30): *"is there any way that when it scrolls out
     of view, it dims to, like, zero opacity, so it doesn't take away attention from the section
     where it says get my child's free five minute assessment?"*
     ⭐ THE RAMP IS KEYED TO THE CONTENT'S OWN EXIT, NOT TO THE PIN'S PROGRESS, and the difference
     matters. The track is 320vh, so fading over "the last 20% of the pin" would leave ~64vh of
     scrolling with nothing on screen. Keyed to the exit, it is 1 for the whole pinned run and only
     falls as the panel physically rises out of the viewport — which is what he described.
     Published per frame as `--swf-tunnel-fade` by the same driver that owns `--e`; a stylesheet
     alone cannot see the exit. Default 1, so no JS = no fade rather than an invisible section. */
  opacity: var(--swf-tunnel-fade, 1);
}
.swf-tunnel__layer {
  --t: 1;                                            /* this layer's scale target */
  position: absolute; inset: 0;
  min-height: 100svh; padding: 16px;
  display: flex; flex-flow: column; justify-content: space-between;
  gap: clamp(7rem, 11.73rem + 1.887vw, 14rem);
  background: var(--swf-page); color: var(--swf-ink);
  transform: scale(calc(1 - (1 - var(--t)) * var(--e, 0)));
  transform-origin: center center; will-change: transform;
}
.swf-tunnel__layer.is-abs { position: absolute; inset: 0; }   /* every layer is, now the box is fixed */
/* 0.85^1 … 0.85^6 — theirs, in DOM order */
.swf-tunnel__layer.is-abs:nth-of-type(2) { --t: .85 }
.swf-tunnel__layer.is-abs:nth-of-type(3) { --t: .7225 }
.swf-tunnel__layer.is-abs:nth-of-type(4) { --t: .614125 }
.swf-tunnel__layer.is-abs:nth-of-type(5) { --t: .52200625 }
.swf-tunnel__layer.is-abs:nth-of-type(6) { --t: .4437053125 }
.swf-tunnel__layer.is-abs:nth-of-type(7) { --t: .377149515625 }
.swf-tunnel__word { display: block; width: 100%; height: auto; margin: 0; color: var(--swf-ink); }
/* ⭐⭐ WD-471 — `geometricPrecision` IS THE ANTI-WOBBLE. Neil, 2026-08-30: *"as it's working, the
   text is sort of wobbling… it looks a bit weird how it's wobbling."*
   MEASURED FIRST, and the measurement is what rules out the obvious suspects: driving the pin in
   60 fine steps, `--e` is MONOTONIC (0 non-monotonic samples), the track height never changes
   (2880px at every sample), the layer's scale runs 0.89291 → 0.7344 with **0 sign flips** in its
   width delta, and the word's screen x moves smoothly. So the scrub is not jittering and nothing
   is relaying out — **the geometry is clean, and the wobble is therefore in the RASTER**.
   ⇒ By default a browser GRID-FITS glyphs (hints them onto the pixel grid) and re-does it every
   time the scale changes, so each frame snaps letters to slightly different positions — text that
   breathes. `geometricPrecision` turns hinting off and renders from the outlines, which is exactly
   what a continuously scaled word needs.
   ⚠️ HONEST LABEL: the first three facts are measured; that the residue is raster-level is
   inference from having excluded everything else, and only a real screen in motion can confirm it.
   ⭐ AND `lengthAdjust` DROPS FROM `spacingAndGlyphs` TO THE DEFAULT `spacing` (in the template):
   `spacingAndGlyphs` stretches the GLYPHS themselves to hit `textLength`, so every re-raster
   redistributes letter shapes as well as gaps — the same artefact, doubled. `spacing` still spans
   the viewBox exactly; it just moves the gaps. */
.swf-tunnel__word text {
  font-family: var(--swf-font); font-weight: 600; font-size: 325px; fill: currentColor;
  text-rendering: geometricPrecision;
}
.swf-tunnel__focus {
  position: absolute; inset: 50% 0 0; transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center; pointer-events: none;
}
.swf-tunnel__line { width: 3rem; height: 1px; background: var(--swf-ink); }
.swf-tunnel__line.is-dup { position: absolute; width: 3rem; transform: rotate(90deg); }
/* ⭐ THE REFUSAL HAS A STATE (theirs, kept): no motion = one clean panel, still legible. */
@media (prefers-reduced-motion: reduce) {
  /* ⭐ THE REFUSAL HAS A STATE (theirs, kept): one clean panel, one pair of words, no pin. */
  .swf-tunnel__track { height: auto; }
  .swf-tunnel__content { position: relative; height: auto; }
  .swf-tunnel__layer { transform: none; position: relative; inset: auto; }
  .swf-tunnel__layer.is-abs { display: none; }
}


/* ══════════════════════════════════════════════════════════════════════════════
   WD-487 · THE PARALLAX RUNS ON THE COMPOSITOR — a main-thread stall cannot touch it.

   Neil's diag from his own machine (`?swf_pxdiag=1`, 2026-08-30): the maths was RIGHT (worst
   disagreement 13.2px = one frame of lag), but **114 frame stalls over 50ms in one pass**, one of
   them 100ms mid-page. The JS driver writes `--swf-px` on the main thread, so during any long
   task — whatever its cause — the page keeps scrolling while the layer holds still, then it
   catches up in one jump. That IS "the image drops into place", and it cannot be fixed by finding
   the task, because the next task does it again.

   ⭐ THE MOTION IS UNCHANGED TO THE PIXEL. Theirs: y = (st − top) × 0.15, live while
   |st − top| < wh. A `view()` timeline's progress is LINEAR in scroll distance, and `cover`
   starts exactly where their window does (section top at the viewport bottom, d = −wh). So:
       from  d = −wh   →  y = −0.15·wh  = −15vh
       to    d = +wh   →  y = +0.15·wh  = +15vh      (`animation-range: cover 0 cover 200vh`)
   and `fill-mode: both` holds y at ±15vh outside the window — which is precisely their
   `if (st > top − wh && st < top + wh)` freezing the tween at the edges. Byte-for-byte, minus
   the thread it runs on.

   The JS driver stays as the FALLBACK for browsers without scroll-driven animations; it is
   gated off with the same `CSS.supports` test so the two can never both drive one layer.
   ══════════════════════════════════════════════════════════════════════════════ */
@supports (animation-timeline: view()) {
  .swf-band, .swf-cc, .swf-ao__panel { view-timeline: --swf-band block; }
  .swf-band__img, .swf-ao__img, .swf-cc__wash {
    animation: swf-parallax linear both;
    animation-timeline: --swf-band;
    animation-range: cover 0px cover 200vh;
  }
  @keyframes swf-parallax {
    from { transform: translate3d(0, -15vh, 0); }
    to   { transform: translate3d(0,  15vh, 0); }
  }
  @media (prefers-reduced-motion: reduce) {
    .swf-band__img, .swf-ao__img, .swf-cc__wash { animation: none; }
  }
}


/* ══════════════════════════════════════════════════════════════════════════════
   WD-489 · ONE RULE AT A TIME — the two rule sections fade as they leave.

   Neil, 2026-08-30: *"when they're not fully in view, I think they should have, like, a fade to
   zero — like the practice section. Otherwise I can see both lines at the same time. I can see
   PRODUCE GOOD WRITING and MEET THE CRITERIA. So when one is in view, the other should be faded
   out."* He is right, and the reason is structural rather than cosmetic: the page's argument is
   that there are exactly TWO rules, delivered in order. Two 200px display headlines legible at
   once turns a sequence into a list, and the reader has to decide which one they are being told.

   ⭐ IT IS THE CONTENT THAT FADES, NOT THE SECTION — and that distinction is load-bearing. Both
   rule sections sit on the SAME ground (`--swf-base`), and `.swf-dx` below carries it too, so the
   three read as one continuous surface. Fading the SECTION would fade its background out to the
   page colour and put a seam between two panels that are meant to be one. Fading `__inner` moves
   only the words.

   ⭐ SAME MECHANISM AS THE PARALLAX (WD-487), deliberately: a `view()` timeline on the compositor,
   so it cannot be touched by the main-thread stalls Neil measured, and there is no second scroll
   loop to drift. The hold either side of centre is what stops it flickering on a short viewport —
   a plain 0→1→0 ramp would only ever be fully opaque at one exact scroll position.
   ⛔ NOT the tunnel's JS `--swf-tunnel-fade` path: that one predates the compositor work and needs
   the pin's own geometry. This needs nothing but the element's own visibility.
   ══════════════════════════════════════════════════════════════════════════════ */
@supports (animation-timeline: view()) {
  /* ⭐ v3 (Neil, 2026-08-30: *"they fade out a little bit early"* — and, asked where Sevilla would
     fade them, the honest answer from his source is NOWHERE: `scrollFX()` fades an element IN as
     its top reaches the viewport bottom and only fades it OUT if you scroll back below that. He
     never dims on exit. So this is our effect, not his, and the reference for it is the PRACTICE
     tunnel on this page, which fades against its own edge leaving.)
     v2 keyed everything to the section's CENTRE (±14% of cover), which at 1440 had the headline
     gone while 63% of it was still on screen — that is the "early". v3 keys to the EDGES, with
     named ranges: opaque by the time 60% of it has entered; starts to dim only once its top has
     crossed the viewport top and 25% of it has left; gone as it leaves. The crossfade midpoint is
     the geometric midpoint, so both headlines can read at ~half for a moment when the sections
     are shorter than the screen — that is what is on screen, and it is stated here. */
  .swf-rulesec__inner {
    animation: swf-rule-focus linear both;
    animation-timeline: view();
    animation-range: entry 0% exit 100%;
  }
  /* v4 — v3 measured both headlines at 1 / 1 together at 1440 (y=1781), i.e. his original
     complaint back again. Edge-keyed but STEEPER: in over entry 30→90%, out over exit 0→60%, so
     the crossfade sums to ~0.9 and neither is ever full while the other is above ~0.3. On a phone
     both 261px sections sit whole on one 844px screen — dimming a fully visible headline is the
     stranger effect, so there they simply both read. Stated, not hidden. */
  @keyframes swf-rule-focus {
    entry 0%   { opacity: 0; }
    entry 30%  { opacity: 0; }
    entry 90%  { opacity: 1; }
    exit 0%    { opacity: 1; }
    exit 60%   { opacity: 0; }
    exit 100%  { opacity: 0; }
  }
  /* ⛔ §4d — a refusal needs a state. With reduced motion the words are simply always readable. */
  @media (prefers-reduced-motion: reduce) {
    .swf-rulesec__inner { animation: none; opacity: 1; }
  }
}


/* ══════════════════════════════════════════════════════════════════════════════
   WD-493 · THE CONTACT PAGE — atlasmotion.com/contact, measured 2026-08-30 (root §14b/§14c):
     h1 184px · weight 300 · letter-spacing -0.03em · line-height .89 · two lines · left of centre
     lead: right of centre, ~4 lines, muted · form 772px · 2×2 grid · labels ABOVE · fields 48px
     · padding 10px 8px 12px · 1px hairline rgba(255,255,255,.1) · fill rgba(217,217,217,.1)
     · radius 0 · button right-aligned · contact rows under the form.
   Deviations are disclosed at swf_render_contact(): type, tokens, fields, the site's own button.
   The headline scale is Atlas's ratio to the viewport (184/1440 = 12.8vw), floored so a phone
   still reads it as a headline and capped at their number.
   ══════════════════════════════════════════════════════════════════════════════ */
/* WD-493e — pinned dark, whatever the toggle says: the dark token set re-declared under the pin
   (same values as the :root dark block; if those ever change, change these with them). */
body.swf-dark-pin { background: #1c1d1f; }
/* the light theme's token block is `html:not([data-theme='dark']):not(.dark):not(.dark-mode) .swf`
   — (0,4,1). `:not(#swf-never)` lifts this to id-level, so the pin beats BOTH theme states without
   `!important` (the id never exists; :not() with an id argument counts as an id). */
/* ── WD-660 · THE CONTACT ENTRANCE, ON THE CANON'S OWN NUMBERS THIS TIME (Neil, 2026-09-02, failing the
   WD-657 cut: "page background is animating in before the elements; also timing of the elements needs work.
   Study the timing of the landing pages and the general header hero pages"). The WD-657 cut cited MOTION.md
   §3e but did not READ it — every number below was wrong by a little and the body was wrong by a lot
   (1.6s at a 1.6s delay, on a third curve MOTION.md §2 forbids, so the FORM — the page's visual mass — arrived
   after the 1.8s ground lift had already finished; that is exactly what he saw).
   THE SPEC, read from `sophicly-plugins/MOTION.md` §3e (split-char rise) + §3 (arrival ladder):
     h1 chars   1s power3.out, from y:20px, 0.3s lead, 0.03s/char          ("the one thing": 900–1200ms)
     lead       0.8s Entrance, from y:10px, at 0.55s (the canon's second line; primary support: 700–900ms)
     body       0.6s Entrance, from y:8px, after the header's last element (secondary blocks: 500–700ms)
   Travel 5–20px only. Body now lands at ~1.95s, INSIDE the ground's 1.8s lift, so ground and content read as
   one arrival. The article's own ground is still shown at once (WD-657, unchanged); hidden starts stay scoped
   to html.swf-js (no JS = all visible); chars are split by contact.js (plain split, <br> kept).
   ⚠️ Easing: the funnel's `--swf-arrive` (.23,1,.32,1) is Risen's easeOutQuint, MOTION.md's Entrance is
   (.22,1,.36,1) — two house tokens a hundredth apart. This file uses ITS OWN token everywhere; introducing a
   third here would be the drift. Flagged in the FIXLIST for a one-line reconciliation, not decided here. */
html.swf-js.swh-body-in .swf-contact { animation: none; opacity: 1; visibility: visible; }
.swf-contact__h .swf-ch { display: inline-block; backface-visibility: hidden; will-change: transform, opacity; }
html.swf-js:not(.swh-body-in) .swf-contact__h .swf-ch { opacity: 0; transform: translateY(20px); }
html.swf-js.swh-body-in .swf-contact__h .swf-ch { opacity: 1; transform: none;
  transition: opacity 1s cubic-bezier(.215,.61,.355,1), transform 1s cubic-bezier(.215,.61,.355,1);
  transition-delay: calc(.3s + var(--i, 0) * .03s); }
html.swf-js:not(.swh-body-in) .swf-contact__lead { opacity: 0; transform: translateY(10px); }
html.swf-js.swh-body-in .swf-contact__lead { opacity: 1; transform: none; transition: opacity .8s var(--swf-arrive), transform .8s var(--swf-arrive); transition-delay: .55s; }
html.swf-js:not(.swh-body-in) .swf-contact :is(.swf-contact__body, .swf-contact__side, .swf-contact__ticker) { opacity: 0; transform: translateY(8px); }
html.swf-js.swh-body-in .swf-contact :is(.swf-contact__body, .swf-contact__side, .swf-contact__ticker) { opacity: 1; transform: none; transition: opacity .6s var(--swf-arrive), transform .6s var(--swf-arrive); transition-delay: 1.35s; }
@media (prefers-reduced-motion: reduce) { html.swf-js .swf-contact :is(.swf-contact__h .swf-ch, .swf-contact__lead, .swf-contact__body, .swf-contact__side, .swf-contact__ticker) { transform: none; transition: none; opacity: 1; } }
/* ── WD-660, RETEST (Neil: "still animating the background first — background, then the text, then the form").
   MEASURED this time, phone width, dark, first AND repeat visit: at body-in the article and every decor layer
   inside it (topo canvas · lines · nav scrim) went 0→1 in ONE frame, chars 0.3s later, form 1.35s later. The
   article's own ground is the page colour, so "the background" he sees IS those three layers popping in ahead
   of the words. The hero pages hold their body until the title has landed (hero-motion `.rFc` 1s ease-in @.5s
   AFTER the chars start at .3s); the contact page now does the same: the decor rides the BODY beat, so the
   order is words → lead → (form + texture + lines + scrim together). */
html.swf-js:not(.swh-body-in) .swf-contact :is(.swf-contact__topo, .swf-contact__lines, .swf-nav-scrim) { opacity: 0; }
html.swf-js.swh-body-in .swf-contact :is(.swf-contact__topo, .swf-contact__lines, .swf-nav-scrim) { opacity: 1; transition: opacity .6s var(--swf-arrive); transition-delay: 1.35s; }
@media (prefers-reduced-motion: reduce) { html.swf-js .swf-contact :is(.swf-contact__topo, .swf-contact__lines, .swf-nav-scrim) { transition: none; opacity: 1; } }
/* WD-668 — the programme page carries the same scrim (markup in swf_render_science); it must sit above the brain
   stage's canvas and under the nav, and it never takes a tap. */
#science .swf-nav-scrim { position: fixed; top: 0; left: 0; right: 0; z-index: 5; pointer-events: none; }
/* WD-651 · ONE STATED DEVIATION from the archive's scrim (root #13): theirs is `position:absolute; top:0` inside a
   stage that begins at the top of the viewport; this article begins ~130px down (header clearance), so the same
   rule painted a grey band across the headline and, caught by the child-lift rule below, sat IN FLOW. Here it
   is fixed to the viewport, exactly where the nav is. Everything else is theirs, byte for byte. */
.swf-contact .swf-nav-scrim { position: fixed; top: 0; left: 0; right: 0; }
/* ── WD-651 · NAV SCRIM for the contact page in LIGHT theme — BRAND §"page without a hero": the reference is the
   blog archive's .arc-nav-scrim (sophicly-blog-arc.php), ported VERBATIM (every rule, the veil rgb inlined,
   masks byte-equal), renamed .swf-nav-scrim. Light only; dark needs none of it. */
.swf-nav-scrim{
  position:absolute; left:0; right:0; top:0;
  height:190px;
  z-index:2;                 /* over the stage, under both navs (12 and 11) */
  pointer-events:none;
  display:none;              /* dark theme needs none of this */
}
html.light .swf-nav-scrim,
html[data-theme="light"] .swf-nav-scrim{ display:block }

.swf-nav-scrim i{ position:absolute; left:0; right:0; top:0; display:block }
.swf-nav-scrim i:nth-child(1){
  height:190px;
  /* ⭐⭐ B137 (v0.32.0) — NO BACKDROP BLUR. Neil's ruling, 2026-09-01, after a day of A/B on his
     own screen: *"only way that I've noticed that it's clean is if you remove all of the blur from
     the top and the sides"* → then, on the panel's option E (tint only): *"These are the best
     settings; we can use these."* The two masked backdrop-filter layers shimmered on his 120Hz
     Liquid Retina panel while the strip moved; seven macOS screen recordings of the same page,
     same Chrome 152 build, same panel could not capture it, so his eyes are the measurement.
     The masked <i> layers stay (byte-equal to what he approved); without a filter they paint
     nothing, and the legibility now rests on the ::after tint alone. */
  -webkit-mask-image:linear-gradient(180deg,
    rgba(0,0,0,1.0000) 0.0px,
    rgba(0,0,0,0.9987) 0.3px,
    rgba(0,0,0,0.9901) 1.9px,
    rgba(0,0,0,0.9694) 5.8px,
    rgba(0,0,0,0.9337) 12.6px,
    rgba(0,0,0,0.8821) 22.4px,
    rgba(0,0,0,0.8154) 35.1px,
    rgba(0,0,0,0.7356) 50.2px,
    rgba(0,0,0,0.6456) 67.3px,
    rgba(0,0,0,0.5493) 85.6px,
    rgba(0,0,0,0.4507) 104.4px,
    rgba(0,0,0,0.3544) 122.7px,
    rgba(0,0,0,0.2644) 139.8px,
    rgba(0,0,0,0.1846) 154.9px,
    rgba(0,0,0,0.1179) 167.6px,
    rgba(0,0,0,0.0663) 177.4px,
    rgba(0,0,0,0.0306) 184.2px,
    rgba(0,0,0,0.0099) 188.1px,
    rgba(0,0,0,0.0013) 189.7px,
    rgba(0,0,0,0.0000) 190.0px);
  mask-image:linear-gradient(180deg,
    rgba(0,0,0,1.0000) 0.0px,
    rgba(0,0,0,0.9987) 0.3px,
    rgba(0,0,0,0.9901) 1.9px,
    rgba(0,0,0,0.9694) 5.8px,
    rgba(0,0,0,0.9337) 12.6px,
    rgba(0,0,0,0.8821) 22.4px,
    rgba(0,0,0,0.8154) 35.1px,
    rgba(0,0,0,0.7356) 50.2px,
    rgba(0,0,0,0.6456) 67.3px,
    rgba(0,0,0,0.5493) 85.6px,
    rgba(0,0,0,0.4507) 104.4px,
    rgba(0,0,0,0.3544) 122.7px,
    rgba(0,0,0,0.2644) 139.8px,
    rgba(0,0,0,0.1846) 154.9px,
    rgba(0,0,0,0.1179) 167.6px,
    rgba(0,0,0,0.0663) 177.4px,
    rgba(0,0,0,0.0306) 184.2px,
    rgba(0,0,0,0.0099) 188.1px,
    rgba(0,0,0,0.0013) 189.7px,
    rgba(0,0,0,0.0000) 190.0px);
}
.swf-nav-scrim i:nth-child(2){
  height:120px;
  -webkit-mask-image:linear-gradient(180deg,
    rgba(0,0,0,1.0000) 0.0px,
    rgba(0,0,0,0.9987) 0.2px,
    rgba(0,0,0,0.9901) 1.2px,
    rgba(0,0,0,0.9694) 3.7px,
    rgba(0,0,0,0.9337) 8.0px,
    rgba(0,0,0,0.8821) 14.1px,
    rgba(0,0,0,0.8154) 22.2px,
    rgba(0,0,0,0.7356) 31.7px,
    rgba(0,0,0,0.6456) 42.5px,
    rgba(0,0,0,0.5493) 54.1px,
    rgba(0,0,0,0.4507) 65.9px,
    rgba(0,0,0,0.3544) 77.5px,
    rgba(0,0,0,0.2644) 88.3px,
    rgba(0,0,0,0.1846) 97.8px,
    rgba(0,0,0,0.1179) 105.9px,
    rgba(0,0,0,0.0663) 112.0px,
    rgba(0,0,0,0.0306) 116.3px,
    rgba(0,0,0,0.0099) 118.8px,
    rgba(0,0,0,0.0013) 119.8px,
    rgba(0,0,0,0.0000) 120.0px);
  mask-image:linear-gradient(180deg,
    rgba(0,0,0,1.0000) 0.0px,
    rgba(0,0,0,0.9987) 0.2px,
    rgba(0,0,0,0.9901) 1.2px,
    rgba(0,0,0,0.9694) 3.7px,
    rgba(0,0,0,0.9337) 8.0px,
    rgba(0,0,0,0.8821) 14.1px,
    rgba(0,0,0,0.8154) 22.2px,
    rgba(0,0,0,0.7356) 31.7px,
    rgba(0,0,0,0.6456) 42.5px,
    rgba(0,0,0,0.5493) 54.1px,
    rgba(0,0,0,0.4507) 65.9px,
    rgba(0,0,0,0.3544) 77.5px,
    rgba(0,0,0,0.2644) 88.3px,
    rgba(0,0,0,0.1846) 97.8px,
    rgba(0,0,0,0.1179) 105.9px,
    rgba(0,0,0,0.0663) 112.0px,
    rgba(0,0,0,0.0306) 116.3px,
    rgba(0,0,0,0.0099) 118.8px,
    rgba(0,0,0,0.0013) 119.8px,
    rgba(0,0,0,0.0000) 120.0px);
}
.swf-nav-scrim::after{
  content:"";
  position:absolute; inset:0;
  background:linear-gradient(180deg,
      rgba(21,22,23,0.5500) 0.0px,
      rgba(21,22,23,0.5497) 0.1px,
      rgba(21,22,23,0.5475) 0.9px,
      rgba(21,22,23,0.5421) 2.7px,
      rgba(21,22,23,0.5325) 6.0px,
      rgba(21,22,23,0.5181) 11.0px,
      rgba(21,22,23,0.4987) 17.7px,
      rgba(21,22,23,0.4743) 26.2px,
      rgba(21,22,23,0.4452) 36.2px,
      rgba(21,22,23,0.4120) 47.7px,
      rgba(21,22,23,0.3754) 60.3px,
      rgba(21,22,23,0.3363) 73.8px,
      rgba(21,22,23,0.2956) 87.9px,
      rgba(21,22,23,0.2544) 102.1px,
      rgba(21,22,23,0.2137) 116.2px,
      rgba(21,22,23,0.1746) 129.7px,
      rgba(21,22,23,0.1380) 142.3px,
      rgba(21,22,23,0.1048) 153.8px,
      rgba(21,22,23,0.0757) 163.8px,
      rgba(21,22,23,0.0513) 172.3px,
      rgba(21,22,23,0.0319) 179.0px,
      rgba(21,22,23,0.0175) 184.0px,
      rgba(21,22,23,0.0079) 187.3px,
      rgba(21,22,23,0.0025) 189.1px,
      rgba(21,22,23,0.0003) 189.9px,
      rgba(21,22,23,0.0000) 190.0px);
}

html:not(#swf-never) body.swf-dark-pin .swf-contact {
  --swf-page: #1c1d1f; --swf-base: #242628; --swf-raised: #2b2c2f;
  --swf-ink: #e0e0ee; --swf-ink-soft: #9da5b4; --swf-absent: #9ca3af;
  --swf-teal: #51dacf; --swf-mint: #7DF9E9; --swf-purple-lt: #a78bfa;
  --swf-btn-ink: #1C1D1F;
}
.swf-contact { padding-block: var(--swf-band) calc(var(--swf-band) * 2); position: relative; background: var(--swf-page); }
/* ⭐ WD-675 (Neil, 2026-09-02: "I just want the same sequence as all the other pages, which is based on the Risen
   church animation sequence"). MEASURED on /about/ in light: `html.swh-arriving body` is #000; on release hero-motion
   WASHES the body ground #000 → the theme ground over `--swh-arrival` (1800ms, linear) while the content arrives.
   MEASURED on /contact/: the article painted its OWN ground (`--swf-page`, white) — it popped in whole at body-in
   and the body under it stayed #000 for the life of the page, so the wash never showed. The contact page now has
   no ground of its own: the BODY carries the page colour (under the funnel's own theme selectors, and only once
   `.swh-arriving` has lifted, exactly as hero-motion scopes its own pages), so the one wash rule hero-motion
   already owns paints it. The nav scrim is ground, not content: it rides the same 1800ms linear window from
   release instead of the body beat. Scoped to `html.swf-page-contact` (the shortcode id, never the slug). */
/* `!important` (measured on PROD, WD-675): the page there is the old slug, and Etch's legacy orchestrator sheet
   (`etch-sophicly-orchestrator-hide-css-*`) pins `body.page-slug-contact-us { background:#000 !important }` and,
   once revealed, `var(--body-bg, #000) !important` with the variable unset — black for the life of the page.
   Hero-motion beats the same pin on its own pages with (0,3,1) + `!important`; so does this. */
html.swf-page-contact:not(.swh-arriving) body { background-color: #1c1d1f !important; }
html[data-theme='light']:not(.swh-arriving).swf-page-contact body, html.light:not(.swh-arriving).swf-page-contact body, html:not([data-theme='dark']):not(.dark):not(.dark-mode):not(.swh-arriving).swf-page-contact body { background-color: #ffffff !important; }
html.swf-page-contact .swf, html.swf-page-contact .swf-contact { background: transparent; }
html.swf-js.swf-page-contact.swh-body-in .swf-contact .swf-nav-scrim { transition: opacity var(--swh-arrival, 1800ms) linear; transition-delay: 0s; }
/* ── WD-493f.1 · ATLAS'S GRID, read out of their own drawer (BackgroundGrid in hoisted.js,
   fetched 2026-08-30): vertical lines come in DOUBLE PAIRS 10px apart at ±(content/2 + 10)
   and ±(content/2 + 20), a second pair 12vw further out, and horizontals are a mirrored double
   pair framing the content block. 1px, one ink, alpha .2 on their page (under a fade mask) —
   .1 here, no mask. Our content region = the --swf-col column the wrap sits in.
   ⛔ the first cut collapsed all lines into ONE: `.swf-contact > *` (same specificity, later in
   the file) overrode this div's position:absolute, so its height was 0 and every percentage
   line piled up at the top. The child lift below now EXCLUDES the decor layers. */
.swf-contact__lines {
  /* full-bleed out of the 1240px article — Atlas's lines cross the whole VIEWPORT. `50vw`
     includes the scrollbar (the ledger's warning), so the page pins `overflow-x: clip` below:
     no scroll container, no sideways scrollbar, the overhang simply cannot scroll. */
  position: absolute; inset: 0 calc(50% - 50vw); pointer-events: none; z-index: 0; overflow: hidden;
  /* half-width of the content region: the column's half, or the gutter edge when narrower */
  --swf-chalf: min(calc(50% - var(--swf-gutter)), calc(var(--swf-col) / 2));
}
body.swf-dark-pin { overflow-x: clip; }
.swf-contact__lines i { position: absolute; background: currentColor; color: var(--swf-ink); opacity: .1; }
/* inner double pair, left + right content edges */
.swf-contact__lines i:nth-child(1) { top: 0; bottom: 0; width: 1px; left: calc(50% - var(--swf-chalf) - 20px); }
.swf-contact__lines i:nth-child(2) { top: 0; bottom: 0; width: 1px; left: calc(50% - var(--swf-chalf) - 10px); }
.swf-contact__lines i:nth-child(3) { top: 0; bottom: 0; width: 1px; left: calc(50% + var(--swf-chalf) + 10px); }
.swf-contact__lines i:nth-child(4) { top: 0; bottom: 0; width: 1px; left: calc(50% + var(--swf-chalf) + 20px); }
/* outer double pair, 12vw further out (Atlas's t*.12) — off-canvas on narrow screens, clipped */
.swf-contact__lines i:nth-child(5) { top: 0; bottom: 0; width: 1px; left: calc(50% - var(--swf-chalf) - 20px - 12vw); }
.swf-contact__lines i:nth-child(6) { top: 0; bottom: 0; width: 1px; left: calc(50% - var(--swf-chalf) - 10px - 12vw); }
.swf-contact__lines i:nth-child(7) { top: 0; bottom: 0; width: 1px; left: calc(50% + var(--swf-chalf) + 10px + 12vw); }
.swf-contact__lines i:nth-child(8) { top: 0; bottom: 0; width: 1px; left: calc(50% + var(--swf-chalf) + 20px + 12vw); }
/* horizontal double pairs framing the content block, top and bottom */
.swf-contact__lines i:nth-child(9)  { left: 0; right: 0; height: 1px; top: calc(var(--swf-band) - 34px); }
.swf-contact__lines i:nth-child(10) { left: 0; right: 0; height: 1px; top: calc(var(--swf-band) - 24px); }
.swf-contact__lines i:nth-child(11) { left: 0; right: 0; height: 1px; bottom: calc(var(--swf-band) + 24px); }
.swf-contact__lines i:nth-child(12) { left: 0; right: 0; height: 1px; bottom: calc(var(--swf-band) + 34px); }
/* WD-493f.2 · the texture — a still topo-contour field contact.js draws once. Behind everything,
   full-bleed like the lines (sized by its insets — no width:100%, which would re-cap it at the
   article's 1240px). */
/* ⛔ a canvas is a REPLACED element: left+right insets do NOT stretch it (it keeps its intrinsic
   300×150, measured). Size it explicitly. */
.swf-contact__topo { position: absolute; top: 0; left: calc(50% - 50vw); width: 100vw; max-width: none; height: 100%; pointer-events: none; z-index: 0; }
.swf-contact > :not(.swf-contact__lines):not(.swf-contact__topo):not(.swf-nav-scrim) { position: relative; z-index: 1; }   /* WD-651: the scrim is a decor layer too */
/* WD-493d.1 — effortel's arrangement: headline LEFT, form RIGHT, above the fold. On the `.swf`
   grid the two sections share one row at desktop; the headline scale steps down so the pair fits. */
.swf-contact__head { display: block; }
/* WD-493f.5 — the side rail (contact rows + ticker) rides the LEFT column under the headline at
   desktop; on a phone the source order (head → body → side) keeps the form above it. */
@media (min-width: 1024px) {
  .swf-contact__wrap {
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 772px);
    grid-template-areas: "head body" "side body"; gap: 0 var(--swf-3xl, 48px); align-items: start;
  }
  .swf-contact__head { grid-area: head; }
  .swf-contact__body { grid-area: body; }
  .swf-contact__side { grid-area: side; }
}
.swf-contact__h {
  margin: 0; max-width: 100%;
  font-family: var(--swf-display); font-style: italic; font-weight: 400;
  /* capped at the band ladder's own top step (76px): the left column is ~420px beside the 772px
     form, and anything larger broke "Speak with" into four ragged lines (measured at 110px). */
  font-size: clamp(48px, 5.5vw, 76px); line-height: 1.02; letter-spacing: -.02em; color: var(--swf-ink);
}
.swf-contact__lead {
  margin: var(--swf-xl) 0 0; max-width: 30ch;
  font-family: var(--swf-font); font-size: 18px; line-height: 1.45; color: var(--swf-ink-soft);
}
/* ⛔ measured: inside the `.swf` grid an item with `margin:auto` stops stretching and shrinks to its
   content (677px, and 254px on a phone). `justify-self` centres it; `width:100%` fills the track. */
.swf-contact__body { width: 100%; max-width: 772px; margin: var(--swf-xl) 0 0; }
@media (min-width: 1024px) { .swf-contact__body { margin-top: 0; } }
.swf-contact__form { display: flex; flex-direction: column; gap: var(--swf-xl); }
.swf-contact__grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--swf-xl) var(--swf-3xl, 48px); }
.swf-field { display: flex; flex-direction: column; gap: var(--swf-sm); font-family: var(--swf-font); }
.swf-field > span { font-size: 16px; color: var(--swf-ink); }
/* WD-493e brand pass (Neil: "get rid of the strokes around the outside") — Atlas's hairline
   border is theirs; ours is a quiet fill, the brand radius, and a teal ring only on focus. */
.swf-field input, .swf-field select, .swf-field textarea {
  width: 100%; min-height: 48px; padding: 12px 14px; margin: 0;
  font: inherit; font-size: 18px; color: var(--swf-ink);
  /* WD-493h.1 (Neil: the topo pattern showed THROUGH the fields — the old rgba fill was
     translucent, not a z-index fault). OPAQUE lift from the brand dark ladder instead. */
  background: var(--swf-base); border: 0; border-radius: var(--swf-r-md);
  outline: none; transition: box-shadow .2s ease, background-color .2s ease;
}
.swf-field input:focus-visible, .swf-field select:focus-visible, .swf-field textarea:focus-visible {
  box-shadow: 0 0 0 2px var(--swf-teal); background: var(--swf-raised);
}
.swf-field textarea { resize: vertical; min-height: 156px; }
.swf-field select { appearance: none; -webkit-appearance: none; cursor: pointer; }
.swf-hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.swf-contact__actions { display: flex; justify-content: flex-end; }
/* WD-493f.4 — Neil: "Halo style is fine but reduce the padding — it looks a bit big and ugly."
   Contact page only: the site-wide button keeps its own scale. */
.swf-contact .swf-btn { min-height: 50px; padding: 0 var(--swf-2xl); }
.swf-contact__err { margin: 0; color: #ffb4b4; font-family: var(--swf-font); font-size: 15px; }
.swf-contact__done { text-align: left; }
.swf-contact__done-h { margin: 0 0 var(--swf-md); font-family: var(--swf-display); font-style: italic; font-weight: 400; font-size: clamp(32px, 4vw, 48px); color: var(--swf-ink); }
.swf-contact__done p { margin: 0; font-family: var(--swf-font); font-size: 18px; color: var(--swf-ink-soft); }
/* WD-493f.5 — rows stack left-aligned under the lead, in the side rail */
.swf-contact__side { margin: var(--swf-2xl) 0 0; }
.swf-contact__rows { display: flex; flex-direction: column; align-items: flex-start; gap: var(--swf-lg); margin: 0; }
.swf-contact__rows div { display: flex; gap: var(--swf-lg); align-items: baseline; }
.swf-contact__rows dt { min-width: 5.5em; }
.swf-contact__rows dt { margin: 0; font-family: var(--swf-font); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--swf-ink-soft); }
.swf-contact__rows dd { margin: 0; font-family: var(--swf-font); font-size: 14px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.swf-contact__rows a { color: var(--swf-ink); text-decoration: none; }
.swf-contact__rows a:hover { color: var(--swf-teal); }
.swf-contact__ticker {
  position: relative; height: 132px; overflow: hidden; margin-top: var(--swf-2xl); text-align: left;
  -webkit-mask-image: linear-gradient(to bottom, transparent, black 18%, black 82%, transparent);
          mask-image: linear-gradient(to bottom, transparent, black 18%, black 82%, transparent);
}
.swf-contact__ticker-run {
  list-style: none; margin: 0; padding: 0;
  font-family: var(--swf-font); font-size: 14px; font-weight: 700; letter-spacing: .1em;
  /* WD-493h.2 — Neil: the ticker read as just more grey text; teal separates it */
  text-transform: uppercase; color: var(--swf-teal); line-height: 2;
  animation: swf-ticker-up 36s linear infinite;
}
@keyframes swf-ticker-up { from { transform: translateY(0); } to { transform: translateY(-100%); } }
@media (prefers-reduced-motion: reduce) { .swf-contact__ticker-run { animation: none; } .swf-contact__ticker { height: auto; mask-image: none; -webkit-mask-image: none; } .swf-contact__ticker-run[aria-hidden] { display: none; } }
@media (max-width: 760px) {
  .swf-contact__grid { grid-template-columns: 1fr; }
  .swf-contact__actions { justify-content: stretch; }
  .swf-contact__actions .swf-btn { width: 100%; }
}

/* ══════════════════════════════════════════════════════════════════════════════
   WD-494 · THE DEMO PAGE — the contact page's sibling: same article class, same lines, same
   topo, same head/body/side grid. Only what differs is declared here.
   ══════════════════════════════════════════════════════════════════════════════ */
html:not(#swf-never) body.swf-dark-pin .swf-demo {
  --swf-page: #1c1d1f; --swf-base: #242628; --swf-raised: #2b2c2f;
  --swf-ink: #e0e0ee; --swf-ink-soft: #9da5b4; --swf-absent: #9ca3af;
  --swf-teal: #51dacf; --swf-mint: #7DF9E9; --swf-purple-lt: #a78bfa;
  --swf-btn-ink: #1C1D1F;
}
/* the widget paints its own white card; a quiet raised frame keeps it deliberate on the dark
   ground rather than a pasted-in rectangle */
.swf-demo__cal { background: var(--swf-base); border-radius: var(--swf-r-lg); padding: var(--swf-md); }
.swf-demo__cal iframe { display: block; width: 100%; min-height: 700px; border: 0; border-radius: var(--swf-r-md); background: #fff; }
.swf-demo__fallback { margin: var(--swf-md) 0 0; font-family: var(--swf-font); font-size: 13px; color: var(--swf-ink-soft); }
.swf-demo__fallback a { color: var(--swf-ink); }
.swf-demo__subh {
  margin: 0 0 var(--swf-lg); font-family: var(--swf-font); font-size: 12px; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase; color: var(--swf-ink-soft);
}
.swf-demo__learn { display: flex; flex-direction: column; gap: var(--swf-xl); margin: 0; max-width: 34ch; }
.swf-demo__learn dt { margin: 0; font-family: var(--swf-font); font-size: 16px; font-weight: 700; color: var(--swf-ink); }
.swf-demo__learn dd { margin: var(--swf-sm) 0 0; font-family: var(--swf-font); font-size: 15px; line-height: 1.5; color: var(--swf-ink-soft); }
.swf-demo__talk { margin: var(--swf-2xl) 0 0; font-family: var(--swf-font); font-size: 15px; color: var(--swf-ink-soft); max-width: 34ch; }
.swf-demo__talk a { color: var(--swf-teal); text-decoration: none; }
.swf-demo__talk a:hover { text-decoration: underline; }

/* ══════════════════════════════════════════════════════════════════════════════
   WD-491 · PROGRAMME PAGE STRIPS — the problem brief (verdict 1) and the merged method strip
   (verdict 2). They sit between Etch components, follow the theme like every .swf section,
   and own their vertical space by padding only (WD-357).
   ══════════════════════════════════════════════════════════════════════════════ */
.swf-pbrief { padding-block: var(--swf-band); text-align: center; }
.swf-pbrief__line {
  margin: 0 auto; max-width: 42rem;
  font-family: var(--swf-font); font-size: clamp(18px, 2.2vw, 22px); line-height: 1.55;
  color: var(--swf-ink); text-wrap: balance;
}
.swf-pbrief__link { margin: var(--swf-xl) 0 0; font-family: var(--swf-font); font-size: 15px; font-weight: 700; letter-spacing: .04em; }
.swf-pbrief__link a { color: var(--swf-teal); text-decoration: none; }
.swf-pbrief__link a:hover { text-decoration: underline; }

.swf-method { padding-block: var(--swf-band); }
.swf-method__h {
  margin: 0; text-align: center;
  font-family: var(--swf-display); font-style: italic; font-weight: 400;
  font-size: clamp(34px, 5vw, 56px); line-height: 1.05; color: var(--swf-ink);
}
.swf-method__lead { margin: var(--swf-lg) auto 0; max-width: 34rem; text-align: center; font-family: var(--swf-font); font-size: 18px; color: var(--swf-ink-soft); }
.swf-method__rows { display: flex; flex-direction: column; gap: var(--swf-lg); max-width: 46rem; margin: var(--swf-2xl) auto 0; }
.swf-method__rows > div { background: var(--swf-base); border-radius: var(--swf-r-lg); padding: var(--swf-xl); }
.swf-method__rows dt { margin: 0 0 var(--swf-sm); font-family: var(--swf-font); font-size: 13px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--swf-teal); }
.swf-method__rows dd { margin: 0; font-family: var(--swf-font); font-size: 16px; line-height: 1.55; color: var(--swf-ink); }
.swf-method__rows dd span { color: var(--swf-ink-soft); font-weight: 700; }
.swf-method__close { margin: var(--swf-2xl) auto 0; max-width: 34rem; text-align: center; font-family: var(--swf-font); font-size: 17px; font-weight: 700; color: var(--swf-ink); }

/* ⭐⭐ WD-584 — THE GAP'S HERO IS THE AUTHORED PHOTOGRAPH NOW, AND ITS CROP HAS TO BE STEERED.
   Neil, 2026-09-01: *"for the gap page, you're still using the old header hero image. Can we use
   one of the new images, please?"*

   ⛔ WHY THIS NEEDS CSS AT ALL, rather than just swapping the file. MEASURED on staging: the same
   `<img class="header-hero__bg">` is painted into two slots of opposite shape —
       desktop  1440x936  ratio 1.54
       phone     419x754  ratio 0.56   ← PORTRAIT
   The retired photograph was a tall 1290x1712, so it suited the phone and the desktop cropped it.
   The authored replacement is the reverse: 16:9, with the boy at ~82% across and the light pool
   (the copy space the band rules ask for) on the left. Dropped in at the default `50% 50%`, the
   portrait phone slot keeps the middle ~31% of the width — which is empty floor, with the subject
   cropped clean out of frame.

   ⭐ So the FILE is one master and the CROP is per breakpoint. `object-position` costs nothing,
   needs no `srcset`, and — the reason it is the right tool here — the `<img>` belongs to the Etch
   header section, not to this plugin, so its markup is not ours to rewrite. This steers it from the
   outside without touching another lane's component (root §LANE OWNERSHIP).
   ⚠️ Scoped to `body.swf-page-the-gap`, the per-page class added at WD-580 — every other page's
   hero keeps its own framing.

   THE NUMBERS, derived rather than dialled: at 419x754 the image covers to 1345px wide, so 926px
   overflow the slot. The boy's centre lands at 1345 x 0.82 = 1103. Placing him at ~62% across the
   slot (right of centre, light to his left, which is where the headline sits) wants a window origin
   of 1103 - 260 = 843, i.e. 843/926 = 91%. 88% is used, keeping a little more of the light.

   ⛔⛔ AND IT NEEDS `!important`, WHICH IS NOT LAZINESS — IT IS THE ONLY THING THAT WORKS HERE.
   The first cut used a plain rule. It parsed, it matched the element (verified via CDP:
   `matches()` true, the rule present in the sheet's cssRules), and the computed value stayed at the
   initial `50% 50%` anyway — because the hero's own page-style declares
   `object-position: center center !important`, and an important declaration beats a normal one
   whatever its specificity or source order. This is the recorded Etch landmine
   (`reference_etch_page_styles_are_important_so_load_order_cannot_win`), and it cost a full
   deploy-and-measure cycle to rediscover from the inside.
   ⚠️ The id is carried too, so that when BOTH are important, specificity still decides in our
   favour rather than leaving it to which sheet happens to load last. */
body.swf-page-the-gap #header-hero img.header-hero__bg{object-position:60% 50% !important}
@media (max-width:719px), (max-aspect-ratio:1/1){
  body.swf-page-the-gap #header-hero img.header-hero__bg{object-position:88% 50% !important}
}

/* ⭐ WD-596 — THE CONTACT HEADLINE CLEARS THE MOBILE HEADER. Neil, phone: "the contact page on
   mobile, the top part is overlapping with the s underline." WD-582 L6 put the General Mobile
   Header (position:fixed, MEASURED 94px tall at 390 wide) on this page, and the page's own top band
   (50px) was written for no header at all: the h1 sat at y50, under the S-mark. The-gap and
   top-grade-rules compose the General Header Hero, which clears it; this page and the demo page
   (both `.swf-contact`) do not, so the clearance is added here — only when a mobile header is
   actually on the page (`:has`), and only under the header's own breakpoint (it is display:flex
   at 899 and display:none at 900, measured). contact.js writes the measured height into
   `--swf-mobile-header` so the 94px is a fallback, not a claim. */
@media (max-width: 899px) {
  body:has(.mobile-header) .swf-contact { padding-top: calc(var(--swf-band) + var(--swf-mobile-header, 94px)); }
}
