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

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

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 });

View File

@@ -1,4 +1,5 @@
import type { JourneySnapshot } from './types';
import { CARD_GAP } from './card-gaps';
import { createRewindLatch, sectionLocalProgress } from './route-log';
/**
@@ -12,8 +13,8 @@ import { createRewindLatch, sectionLocalProgress } from './route-log';
*/
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
/** Upper bounds of each step, in section-local progress. */
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: 0.9 } as const;
/** Upper bounds of each step, in section-local progress. Cutover lands where the card clears the road (CARD_GAP). */
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: CARD_GAP.from } as const;
/**
* The clock reads live Bucharest time again from here. The window keeps its

View File

@@ -6,6 +6,8 @@
* pure. Scroll-driven, so scrolling back takes the masts offline again.
*/
import { CARD_GAP } from './card-gaps';
/** Chapter-local progress at which each mast comes online. */
export const RELAY_STOPS = [0.04, 0.22, 0.4, 0.58] as const;
/** A mast's rungs light bottom-up over this much chapter progress after its stop. */
@@ -13,6 +15,17 @@ export const RELAY_RAMP = 0.12;
/** Beacon blink rate (photosensitivity: well under 3 Hz). */
export const BEACON_HZ = 0.5;
/**
* 0..1: the station goes live, every online mast's beam sweeping the sky
* together, in the open road after the Toolkit card (CARD_GAP), where the
* masts are in frame. Scrolling back takes the beams down again.
*/
export function stationLive(p: number | null): number {
if (p === null) return 0;
const t = Math.min(1, Math.max(0, (p - CARD_GAP.from) / (CARD_GAP.peak - CARD_GAP.from)));
return t * t * (3 - 2 * t);
}
/** How many masts are online at chapter progress p (0 outside the chapter). */
export const relayOnline = (p: number | null): number =>
p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length;

View File

@@ -9,8 +9,10 @@
* dims the plate and runs the crane back.
*/
/** Section-local progress of each stop (one per additional project). */
export const YARD_STOPS = [0.1, 0.33, 0.56, 0.79] as const;
import { CARD_GAP } from './card-gaps';
/** Section-local progress of each stop (one per additional project); the last lift lands in the open road after the card. */
export const YARD_STOPS = [0.12, 0.38, 0.64, CARD_GAP.peak] as const;
/** The crane stands still this far either side of a stop while the spreader works. */
export const YARD_HOLD = 0.06;
/**
@@ -40,7 +42,9 @@ const smooth = (from: number, to: number, value: number): number => {
/**
* Crane z for progress p: parked over stack i within YARD_HOLD of stop i,
* easing between stacks, from CRANE_FROM_Z at 0 to CRANE_TO_Z at 1.
* easing between stacks from CRANE_FROM_Z at 0; it would run on to
* CRANE_TO_Z after the last hold, but the last stop sits in the card gap
* (CARD_GAP.peak) so the crane stays parked to the chapter boundary.
* Non-decreasing in p.
*/
export function craneZ(p: number): number {

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { CARD_GAP } from '@/journey/card-gaps';
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
/**
@@ -7,7 +8,8 @@ import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-pro
* window opens over the city: one district powers down lamp by lamp, amber
* backup packets stream to an offsite silhouette on the far right, rain
* starts on the windshield, then the service re-lights in the infrastructure
* accent. Everything is a pure function of section-local scroll progress, so
* accent at cutover, in the open road after the card (CARD_GAP), where the
* skyline is in frame. Everything is a pure function of section-local scroll progress, so
* scrolling back up replays it in reverse. Only the distant lightning keeps
* its own (bounded) clock.
*
@@ -48,9 +50,9 @@ export function maintenanceTimeline(p: number): MaintenanceFrame {
return {
step: maintenanceStep(x),
dim: smooth(0.1, 0.35, x),
relight: smooth(0.35, 0.65, x),
relight: smooth(CARD_GAP.from, CARD_GAP.peak, x),
rain: MAINTENANCE_RAIN_PEAK * smooth(0.1, 0.3, x) * (1 - smooth(0.7, 0.9, x)),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - 0.67) / 0.21),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - (CARD_GAP.from - 0.21)) / 0.21),
offsite: smooth(0.14, 0.36, x),
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, x)),
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),

View File

@@ -1,7 +1,8 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit } from '@/journey/relay-progress';
import { CARD_GAP } from '@/journey/card-gaps';
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit, stationLive } from '@/journey/relay-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { CHAPTER_COMPOSITIONS, RELAY_NUDGE } from './composition';
@@ -18,8 +19,9 @@ type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
* RELAY STATION, scene side. Four lattice transmitter masts, one per toolkit
* group, stand on the far shoulders, alternating sides (first on the right).
* As the chapter scrolls each comes online in turn: its lamp rungs ignite
* bottom-up (one per technology), a red beacon blinks at 0.5 Hz and a thin
* additive beam sweeps the sky every 6 s. Every beat is a pure function of
* bottom-up (one per technology) and a red beacon blinks at 0.5 Hz. The
* station goes live in the open road after the card (CARD_GAP): every
* online mast's thin additive beam sweeps the sky every 6 s. Every beat is a pure function of
* section-local progress, so scrolling back takes the masts offline again.
* One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a
* technology flashes its rung. Lite (low tier, touch, data saver): all rungs
@@ -133,7 +135,7 @@ const smooth = (from: number, to: number, value: number): number => {
const halfWidthAt = (y: number): number => THREE.MathUtils.lerp(BASE_HALF, TOP_HALF, y / MAST_HEIGHT);
/** The chapter fades the station in and out at its edges. */
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(CARD_GAP.to, 1, p));
export interface RelayRung {
/** World height of the rung's centre. */
@@ -453,6 +455,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
const dt = Math.max(0, deltaSeconds);
const live = section === 'toolkit' ? p : null;
const masts = relayLayout(live, readRelay().counts, full, aspect);
const beams = stationLive(live);
const changed = section !== lastSection;
lastSection = section;
@@ -498,9 +501,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
if (beamMesh !== null && beamState !== null) {
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
rotation.setFromAxisAngle(forward, sweep);
const shown = drawn && mast.level > 0 ? mast.scale : 0;
const shown = drawn && mast.level > 0 && beams > 0 ? mast.scale : 0;
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
beamState.setXYZ(m, mast.level, 0, mast.presence);
beamState.setXYZ(m, mast.level * beams, 0, mast.presence);
rotation.identity();
}

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { CARD_GAP } from '@/journey/card-gaps';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import {
CRANE_FROM_Z,
@@ -434,7 +435,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
return t * t * (3 - 2 * t);
};
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live));
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(CARD_GAP.to, 1, live));
const z = craneZ(live ?? 0);
const worldZ = group.position.z + z * Math.cos(yaw);
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
@@ -461,7 +462,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
});
stackState.needsUpdate = true;
warm = floodWarmup(warm, full && live !== null && live < 0.93, deltaSeconds);
warm = floodWarmup(warm, full && live !== null && live < CARD_GAP.to, deltaSeconds);
const light = floodLight(warm);
flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0);
pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0;

View File

@@ -9,7 +9,8 @@
/* ---------- Chapter openers over the road ---------- */
@media screen {
.reading-stage .portfolio-section {
margin-block: clamp(220px, 44vh, 480px) 0;
/* CARD GAPS: with the padding below, the open road between chapters (journey/card-gaps.ts). */
margin-block: clamp(240px, 48vh, 520px) 0;
padding-block-end: clamp(150px, 20vh, 230px);
}
.reading-stage .portfolio-section:not([data-layout='project-stage']) {

View File

@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { CARD_CLEAR_BAND, CARD_GAP, CARD_GAP_OPEN_ROAD_VH, CARD_GAP_SPANS_VH, CARD_TITLE_VH, gapWindow } from '@/journey/card-gaps';
import { MAINTENANCE_BOUNDS, maintenanceStep } from '@/journey/maintenance-progress';
import { RELAY_STOPS, stationLive } from '@/journey/relay-progress';
import { plateLit, spreaderDrop, YARD_HOLD, YARD_STOPS } from '@/journey/yard-progress';
import { maintenanceTimeline } from '@/scene/world/maintenance-window';
import { relayPresence } from '@/scene/world/relay-masts';
const sweep = (from: number, to: number, steps = 200): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
const spans = sweep(CARD_GAP_SPANS_VH.min, CARD_GAP_SPANS_VH.max, 40);
describe('card gaps', () => {
it('matches the open road the stylesheet leaves between chapters', () => {
const css = readFileSync(resolve(process.cwd(), 'src/styles/route.css'), 'utf8');
const rule = /\.reading-stage \.portfolio-section \{[^}]*margin-block: clamp\(\d+px, (\d+)vh, \d+px\) 0;\s*padding-block-end: clamp\(\d+px, (\d+)vh, \d+px\);/u.exec(css);
expect(rule).not.toBeNull();
expect(Number(rule![1]) + Number(rule![2])).toBe(CARD_GAP_OPEN_ROAD_VH);
expect(CARD_TITLE_VH).toBeGreaterThan(0);
expect(CARD_CLEAR_BAND.top).toBeLessThan(CARD_CLEAR_BAND.bottom);
});
it('keeps the static window inside the measured gap for every chapter span', () => {
for (const span of spans) {
const window = gapWindow(span);
expect(window.from).toBeLessThanOrEqual(CARD_GAP.from);
expect(window.to).toBeGreaterThanOrEqual(CARD_GAP.to);
// The static peak sits where the gap is fully in frame, whatever the span.
expect(CARD_GAP.peak).toBeGreaterThan(window.from);
expect(CARD_GAP.peak).toBeLessThan(window.to);
}
expect(CARD_GAP.from).toBeLessThan(CARD_GAP.peak);
expect(CARD_GAP.peak).toBeLessThan(CARD_GAP.to);
expect(CARD_GAP.to).toBeLessThan(1);
});
it('relights the district and cuts over in the gap', () => {
expect(maintenanceTimeline(CARD_GAP.from).relight).toBe(0);
expect(maintenanceTimeline(CARD_GAP.from - 0.01).relight).toBe(0);
expect(maintenanceTimeline(CARD_GAP.peak).relight).toBe(1);
expect(maintenanceTimeline(CARD_GAP.from).packetPhase).toBe(2);
expect(MAINTENANCE_BOUNDS.verify).toBe(CARD_GAP.from);
expect(maintenanceStep(CARD_GAP.peak)).toBe('done');
// The peak of the relight (its steepest frame) lies inside the window.
const ps = sweep(0, 1, 1000);
const slopes = ps.map((p, i) => (i === 0 ? 0 : maintenanceTimeline(p).relight - maintenanceTimeline(ps[i - 1]!).relight));
const steepest = ps[slopes.indexOf(Math.max(...slopes))]!;
expect(steepest).toBeGreaterThan(CARD_GAP.from);
expect(steepest).toBeLessThan(CARD_GAP.to);
});
it('lands the last yard lift in the gap, with the spreader down and the plate lit before the yard fades', () => {
const last = YARD_STOPS[YARD_STOPS.length - 1]!;
expect(last).toBe(CARD_GAP.peak);
expect(spreaderDrop(last)).toBe(1);
expect(plateLit(last, YARD_STOPS.length - 1)).toBe(1);
expect(last + YARD_HOLD).toBeGreaterThan(CARD_GAP.to);
// The spreader is still up (no work-lamp strike yet) as the gap comes fully into frame.
expect(spreaderDrop(CARD_GAP.from)).toBeLessThan(0.6);
expect(plateLit(CARD_GAP.from, YARD_STOPS.length - 1)).toBe(0);
for (let i = 1; i < YARD_STOPS.length; i += 1) expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
});
it('brings the relay station live in the gap, after every mast is online and before they fade', () => {
expect(stationLive(null)).toBe(0);
expect(stationLive(CARD_GAP.from)).toBe(0);
expect(stationLive(CARD_GAP.peak)).toBe(1);
expect(Math.max(...RELAY_STOPS)).toBeLessThan(CARD_GAP.from);
for (const p of sweep(CARD_GAP.from, CARD_GAP.to)) expect(relayPresence(p)).toBe(1);
const forward = sweep(0, 1).map(stationLive);
forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); });
});
});

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, shallowRef } from 'vue';
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
import { CARD_GAP } from '@/journey/card-gaps';
import type { JourneySnapshot } from '@/journey/types';
import {
createMaintenanceDirection,
@@ -48,8 +49,8 @@ describe('maintenance progress', () => {
expect(maintenanceStep(0.35)).toBe('transfer');
expect(maintenanceStep(0.649)).toBe('transfer');
expect(maintenanceStep(0.65)).toBe('verify');
expect(maintenanceStep(0.899)).toBe('verify');
expect(maintenanceStep(0.9)).toBe('done');
expect(maintenanceStep(CARD_GAP.from - 0.001)).toBe('verify');
expect(maintenanceStep(CARD_GAP.from)).toBe('done');
expect(maintenanceStep(1)).toBe('done');
});
@@ -57,7 +58,7 @@ describe('maintenance progress', () => {
expect(maintenanceClock(0, live)).toBe('03:12');
expect(maintenanceClock(0.1, live)).toBe('03:12');
expect(maintenanceClock(0.5, live)).toBe('03:32');
expect(maintenanceClock(0.9, live)).toBe('03:51');
expect(maintenanceClock(CARD_GAP.from, live)).toBe('03:51');
expect(maintenanceClock(0.999, live)).toBe('03:51');
expect(maintenanceClock(1, live)).toBe('14:07');
let previous = -1;

View File

@@ -57,8 +57,8 @@ describe('the 03:12 window timeline', () => {
expect(maintenanceTimeline(0.1).packetPhase).toBe(0);
expect(maintenanceTimeline(0.36).packetPhase).toBe(1);
expect(maintenanceTimeline(0.6).packetPhase).toBe(1);
expect(maintenanceTimeline(0.66).packetPhase).toBe(1);
expect(maintenanceTimeline(0.89).packetPhase).toBe(2);
expect(maintenanceTimeline(0.65).packetPhase).toBe(1);
expect(maintenanceTimeline(0.9).packetPhase).toBe(2);
});
});
@@ -111,8 +111,8 @@ describe('maintenance window meshes', () => {
it('drops rain, packets and lightning on the lite tier but keeps the power-down', () => {
const resources = new SceneResourceBag();
const window = createMaintenanceWindow(resources);
let state = window.update(0.5, 1, 0.016, 'lite');
for (let i = 0; i < 200; i += 1) state = window.update(0.5, 1, 0.05, 'lite');
let state = window.update(0.95, 1, 0.016, 'lite');
for (let i = 0; i < 200; i += 1) state = window.update(0.95, 1, 0.05, 'lite');
expect(state.rain).toBe(0);
expect(state.flash).toBe(0);
expect(state.lightning).toBe(0);

View File

@@ -17,6 +17,7 @@ import {
YARD_STOPS,
yardStopsPassed,
} from '@/journey/yard-progress';
import { CARD_GAP } from '@/journey/card-gaps';
import { DECK_PASS_Z } from '@/journey/underpass';
import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass';
@@ -24,7 +25,7 @@ const sweep = (steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i
describe('night freight progress', () => {
it('has four ordered stops and stacks far to near', () => {
expect(YARD_STOPS).toEqual([0.1, 0.33, 0.56, 0.79]);
expect(YARD_STOPS).toEqual([0.12, 0.38, 0.64, CARD_GAP.peak]);
for (let i = 1; i < YARD_STOPS.length; i += 1) {
expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
expect(STACK_ZS[i]!).toBeGreaterThan(STACK_ZS[i - 1]!);
@@ -39,7 +40,9 @@ describe('night freight progress', () => {
previous = z;
}
expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z);
expect(craneZ(1)).toBeCloseTo(CRANE_TO_Z);
// The last lift is the chapter's final beat (CARD_GAP): the crane stays parked over the near stack to the boundary.
expect(craneZ(1)).toBeCloseTo(STACK_ZS[STACK_ZS.length - 1]!);
expect(CRANE_TO_Z).toBeGreaterThan(STACK_ZS[STACK_ZS.length - 1]!);
YARD_STOPS.forEach((stop, i) => {
for (const d of [-YARD_HOLD, -0.02, 0, 0.02, YARD_HOLD]) expect(craneZ(stop + d)).toBeCloseTo(STACK_ZS[i]!);
});
@@ -63,10 +66,10 @@ describe('night freight progress', () => {
expect(plateLit(stop - 0.05, i)).toBe(0);
});
expect(spreaderDrop(0.215)).toBe(0);
expect(spreaderDrop(0.95)).toBe(0);
expect(spreaderDrop(0.51)).toBe(0);
expect(plateLit(null, 0)).toBe(0);
expect(plateLit(0.5, 9)).toBe(0);
expect([0, 0.1, 0.2, 0.33, 0.6, 0.8, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
expect([0, 0.12, 0.2, 0.38, 0.7, 0.95, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
});
it('is symmetric in reverse: the same progress gives the same frame either way', () => {