polish: time the scene payoffs to the gaps between cards

One shared window (journey/card-gaps.ts, CARD_GAP) describes the open road
after each chapter's panel in section-local progress; the district relight
and cutover, the last yard lift and the relay station's beams now key off
it instead of their own numbers, and the chapter margin widens from 44vh
to 48vh so the gap reads as a stretch of road. Unit tests prove every
payoff's peak sits inside the window for every chapter span.
This commit is contained in:
2026-10-12 19:20:43 +03:00
parent c6ce7c24f5
commit 75108562a4
15 changed files with 193 additions and 28 deletions

61
src/journey/card-gaps.ts Normal file
View File

@@ -0,0 +1,61 @@
import { ACTIVATION_LINE_VH } from './section-controller';
/**
* CARD GAPS: the open road between chapter cards. A chapter opens with its
* title standing over the road, then one reading panel, then (route.css) a
* stretch of open road before the next title: the section's bottom padding
* plus the next section's top margin. Section-local progress runs 0 with a
* chapter's heading on the activation line to 1 with the next heading there,
* so the gap after a chapter's panel always sits at the END of its progress,
* and it is the one stretch where a scene payoff can be watched rather than
* read over. The payoffs (the district relight, the last yard lift, the relay
* beams) key off CARD_GAP instead of their own tuned numbers, so this window
* is the single source of truth. Pure and tiny: shared by the DOM
* (maintenance steps) and the lazy scene chunk.
*/
/** Open road below a panel, in vh: the section's bottom padding plus the next section's top margin (route.css). */
export const CARD_GAP_OPEN_ROAD_VH = 20 + 48;
/** The next chapter's title stack: its heading stands this far above its panel (about 197 px; vh at the 900 px design height). */
export const CARD_TITLE_VH = 22;
/** The viewport band a payoff must have clear of panels, as fractions of the viewport height. */
export const CARD_CLEAR_BAND = { top: 0.3, bottom: 0.85 } as const;
export interface GapWindow {
/** The previous panel has cleared the band (section-local progress). */
from: number;
/** The open road is centred in the viewport. */
peak: number;
/** The next panel reaches the band. */
to: number;
}
/**
* Where the open road after a chapter's panel fills the clear band, for a
* chapter whose heading-to-heading span is `spanVh` viewport heights. Not
* called per frame: payoffs use the static CARD_GAP below, which this
* validates in tests.
*/
export function gapWindow(spanVh: number): GapWindow {
const line = ACTIVATION_LINE_VH * 100;
// Panel edges relative to the next heading, in vh: the previous panel's bottom and the next panel's top.
const previousBottom = -CARD_GAP_OPEN_ROAD_VH;
const nextTop = CARD_TITLE_VH;
return {
from: 1 + (previousBottom + line - CARD_CLEAR_BAND.top * 100) / spanVh,
peak: 1 + ((previousBottom + nextTop) / 2 + line - 50) / spanVh,
to: 1 + (nextTop + line - CARD_CLEAR_BAND.bottom * 100) / spanVh,
};
}
/** Chapter spans (vh) the static window must hold for: the shortest chapter on a tall screen to the tallest on a short one. */
export const CARD_GAP_SPANS_VH = { min: 140, max: 330 } as const;
/**
* The gap window every payoff keys off, in section-local progress. Valid for
* all spans in CARD_GAP_SPANS_VH (tests/unit/card-gaps.test.ts proves it):
* payoffs begin once the previous panel has cleared the band and complete
* with the road centred, before the chapter's set dressing fades for the
* boundary (`to`).
*/
export const CARD_GAP: GapWindow = Object.freeze({ from: 0.9, peak: 0.94, to: 0.96 });