feat: dashcam photo mode with telemetry frame, lens drift and postcard capture

This commit is contained in:
2026-10-11 07:49:56 +03:00
parent d71b0ecfd4
commit 568b0802b4
29 changed files with 1472 additions and 7 deletions

View File

@@ -47,3 +47,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Known weaknesses:** the 92%-wide panel hides the board while reading (visible at chapter entry, in gaps between case studies and at exit); phone screenshots read dim; ~140 px on phones; test-only telemetry.
- Screenshots: docs/overnight/round5/.
## Round 6: Dashcam (photo mode + postcard)
- **What:** a camera button in the drive controls (or key `c`) folds the reading plates away, closes letterbox bars, and shows a dashcam frame with live telemetry (REC dot on the bass, chapter + exit, Bucharest clock, route %, coordinates + name). The lens drifts with the pointer (+-1.6 units, re-centres after 3 s idle). "Save postcard" downloads a 1600x900 PNG of the live scene with credit line, a white shutter flash and a synthesized shutter click when sound is on. Tab title becomes "DASHCAM · 47% · INFRASTRUCTURE · 03:51" while open. Esc/c/Close exits and returns focus. Absent for reduced motion/static/visitor-pause and below 768 px.
- **Why:** the recurring weakness of rounds 1-5: the reading panels hide the world. This gives a juror one deliberate full-screen moment, and a shareable artefact.
- **Build:** journey/dashcam-feed.ts, scene/world/dashcam-lens.ts, composables/useDashcam.ts, lazy components/DashcamFrame.vue + lib/dashcam-postcard.ts + audio/switchboard-click.ts, styles/dashcam.css. 820 tests, initial JS 117.73 KB gz (2.3 KB left), lazy chunk 3.7 KB gz.
- **Verdict: KEEP with fixes** (Fable, hardware GL): Contact bars were striped (aubergine outro bars under black), now frame draws its own bars; canvas resized on open due to overflow lock (removed); focus dropped after save; `c` worked on phones where the button is hidden; round 5's HUD exit tab had been dropped, restored; REC dot got a no-music blink.
- **Known weaknesses:** postcard is the 1440x900 buffer scaled to 1600 (slightly soft) and keeps the lens bars; no phone version; shutter click only while soundtrack runs; two chapter-label vocabularies in the frame.
- Screenshots: docs/overnight/round6/ (postcard.webp is the real downloaded file).

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -145,7 +145,7 @@ const totals = {
firstLiveScene: sum('first-live-scene'),
deferredProjectVisuals: sum('project-still') + sum('chart'),
highTierExtras: sum('high-tier-extras'),
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart'),
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart') + sum('deferred-non-video'),
deferredAudio: sum('soundtrack-audio'),
};

View File

@@ -38,6 +38,8 @@ export const classifyArtifact = (path, source = '') => {
if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js';
if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras';
if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene';
// DASHCAM: the photo-mode frame and postcard, loaded on first open.
if (/^assets\/DashcamFrame-[^/]+\.(?:js|css)$/u.test(path)) return 'deferred-non-video';
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';

View File

@@ -19,6 +19,7 @@ import './styles/print.css';
import './styles/headlight.css';
import './styles/route-log.css';
import './styles/billboard.css';
import './styles/dashcam.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();
</script>

View File

@@ -9,6 +9,7 @@ let audible = false;
export function attachMusicPulse(node: AnalyserNode | null): void {
analyser = node;
if (node === null) bus = null;
bins = node === null ? null : new Uint8Array(new ArrayBuffer(node.frequencyBinCount));
}
@@ -16,6 +17,13 @@ export function setMusicPulseAudible(value: boolean): void {
audible = value;
}
/**
* DASHCAM: the mix bus (the tap after the mixer's sum, before the output
* gain), so a shutter click obeys mute and volume. Null unless audible.
*/
let bus: AudioNode | null = null;
export const readSwitchboard = (): AudioNode | null => (audible ? bus : null);
/** Bass energy in [0, 1]: the mean of roughly 90-380 Hz. */
export function readMusicPulse(): number {
if (!audible || analyser === null || bins === null) return 0;
@@ -37,5 +45,6 @@ export function createPulseTap(context: BaseAudioContext, destination: AudioNode
} catch {
attachMusicPulse(null);
}
bus = tap;
return tap;
}

View File

@@ -0,0 +1,48 @@
import { readSwitchboard } from '@/audio/music-pulse';
/** Shutter click length, seconds. */
export const SHUTTER_SECONDS = 0.03;
/** The switchboard input's level on the mix bus. */
export const SWITCHBOARD_GAIN = 0.18;
/**
* DASHCAM's shutter: a ~30 ms burst of decaying noise with a fast gain
* envelope, through a switchboard input (gain 0.18) onto the mix bus, so
* mute and volume apply through the output gain. Lives in the lazy dashcam chunk. Plays only while
* the soundtrack is running and audible; never creates an AudioContext and
* never throws.
*/
export function playShutterClick(): boolean {
try {
const input = readSwitchboard();
if (input === null || input.context.state !== 'running') return false;
const context = input.context;
const length = Math.max(1, Math.round(context.sampleRate * SHUTTER_SECONDS));
const buffer = context.createBuffer(1, length, context.sampleRate);
const data = buffer.getChannelData(0);
// Two clicks a few ms apart (curtain open, curtain close) over decaying noise.
for (let i = 0; i < length; i += 1) {
const t = i / length;
const curtain = t > 0.55 && t < 0.62 ? 1.6 : 1;
data[i] = (Math.random() * 2 - 1) * (1 - t) ** 3 * curtain;
}
const source = context.createBufferSource();
source.buffer = buffer;
const envelope = context.createGain();
const start = context.currentTime + 0.005;
envelope.gain.setValueAtTime(0, start);
envelope.gain.linearRampToValueAtTime(SWITCHBOARD_GAIN, start + 0.002);
envelope.gain.exponentialRampToValueAtTime(0.0002, start + SHUTTER_SECONDS);
source.connect(envelope);
envelope.connect(input);
source.onended = () => {
source.disconnect();
envelope.disconnect();
};
source.start(start);
source.stop(start + SHUTTER_SECONDS + 0.01);
return true;
} catch {
return false;
}
}

View File

@@ -24,6 +24,7 @@ import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { useRouteLog } from '@/composables/useRouteLog';
import { useBillboard } from '@/composables/useBillboard';
import { useDashcam } from '@/composables/useDashcam';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
@@ -160,6 +161,16 @@ const headlight = useHeadlightBeam({
quality: actualQuality,
activeSectionId: () => journey.value.activeSectionId,
});
/* DASHCAM: a full-screen photo mode over the live scene (lazy frame). */
const dashcam = useDashcam({
allowed: computed(() => controlsReady.value
&& motion.policy.value.reason === 'full'
&& motion.policy.value.allowSceneRequest
&& actualQuality.value !== 'static'
&& pageLifecycle.value === 'active'),
sceneRunning: computed(() => sceneState.value === 'running'),
drift: () => actualQuality.value !== 'low' && matchMedia('(pointer: fine)').matches,
});
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
const serverDefaultPolicy: MotionPolicy = {
allowSceneRequest: false,
@@ -376,6 +387,7 @@ onUnmounted(() => removePageListeners());
:audio-state="audio.state.value"
:audio-muted="audio.muted.value"
:audio-volume="controlsReady ? motion.volume.value : 0.4"
:dashcam="dashcam.available.value"
@pause="pauseMotion"
@resume="resumeMotion"
@enable="enableMotion"
@@ -384,5 +396,17 @@ onUnmounted(() => removePageListeners());
@audio-resume="audio.resumeFromGesture"
@audio-mute-toggle="toggleAudioMuted"
@audio-volume-change="updateAudioVolume"
@dashcam="dashcam.show"
@dashcam-prefetch="dashcam.prefetch"
/>
<component
:is="dashcam.frame.value"
v-if="dashcam.open.value && dashcam.frame.value"
:journey="journey"
:section-label="activeSectionLabel"
:name="content.identity.name"
:city="content.identity.location.city"
:scene-ready="sceneState === 'running'"
@close="dashcam.close"
/>
</template>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import type { JourneySnapshot } from '@/journey/types';
import { readMusicPulse } from '@/audio/music-pulse';
import { playShutterClick } from '@/audio/switchboard-click';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { signRouteFor } from '@/journey/sign-routes';
import { isScrollIntentKey } from '@/composables/useKeyboardNavigation';
import {
cancelDashcamCapture,
readDashcam,
requestDashcamCapture,
setDashcamPointer,
} from '@/journey/dashcam-feed';
import {
DASHCAM_COORDINATES,
dashcamClock,
dashcamTitle,
frameBottomLeft,
frameTopLeft,
routePercent,
savePostcard,
type DashcamTelemetry,
} from '@/lib/dashcam-postcard';
/**
* DASHCAM's frame (lazy chunk): corner brackets, the REC dot on the bass,
* live telemetry and the two actions, over the live scene. Owns the title,
* the focus trap, scroll suppression, the pointer drift feed and the capture.
*/
const props = defineProps<{
journey: JourneySnapshot;
sectionLabel: string;
name: string;
city: string;
sceneReady: boolean;
}>();
const emit = defineEmits<{ close: [] }>();
/** REC dot sampling rate while open. */
const PULSE_HZ = 12;
/** A capture the scene has not answered in this long is dropped. */
const CAPTURE_TIMEOUT_MS = 1_200;
const SHUTTER_MS = 120;
const surface = ref<HTMLElement | null>(null);
const saveButton = ref<HTMLButtonElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
const dot = ref<HTMLElement | null>(null);
const now = shallowRef(new Date());
const busy = shallowRef(false);
const lost = shallowRef(false);
const status = shallowRef('');
const unavailable = computed(() => lost.value || !props.sceneReady);
const telemetry = computed<DashcamTelemetry>(() => ({
sectionId: props.journey.activeSectionId,
sectionLabel: props.sectionLabel,
chapter: props.journey.chapter,
exit: signRouteFor(props.journey.activeSectionId).marker,
routePercent: routePercent(props.journey.routeProgress),
clock: dashcamClock(props.journey.activeSectionId, maintenanceProgress(props.journey), now.value),
name: props.name,
city: props.city,
}));
const title = computed(() => dashcamTitle(telemetry.value));
let previousTitle = '';
let ticker = 0;
let captureTimer = 0;
let shutterTimer = 0;
/** REC dot without music: a slow 0.8 Hz breath instead of a dead speck. */
const REC_BLINK_HZ = 0.8;
const sample = (): void => {
const pulse = readMusicPulse();
const rec = pulse > 0 ? pulse : 0.5 + 0.5 * Math.sin(performance.now() / 1000 * Math.PI * 2 * REC_BLINK_HZ);
dot.value?.style.setProperty('--rec', rec.toFixed(2));
const next = new Date();
if (next.getMinutes() !== now.value.getMinutes()) now.value = next;
};
const finish = (): void => {
window.clearTimeout(captureTimer);
busy.value = false;
// The button was disabled while busy, which drops focus to the body.
const lost = document.activeElement === null || document.activeElement === document.body;
if (lost) void nextTick(() => saveButton.value?.focus());
};
/* The page stays where it was: a scrollbar drag (the only input not
swallowed) snaps back to the locked offset. */
let lockedY = 0;
const onScroll = (): void => {
if (Math.round(window.scrollY) !== lockedY) window.scrollTo({ top: lockedY, behavior: 'instant' });
};
const shoot = (): void => {
if (busy.value || unavailable.value) return;
busy.value = true;
status.value = '';
const root = document.documentElement;
root.dataset.dashcamShutter = '';
window.clearTimeout(shutterTimer);
shutterTimer = window.setTimeout(() => { delete root.dataset.dashcamShutter; }, SHUTTER_MS);
playShutterClick();
const shot = { ...telemetry.value };
requestDashcamCapture((canvas) => {
window.clearTimeout(captureTimer);
void savePostcard(canvas, shot).then((outcome) => {
status.value = outcome === 'failed' ? 'Postcard could not be saved' : 'Postcard saved';
finish();
});
});
// Context loss mid-capture: the scene never answers; drop the request.
captureTimer = window.setTimeout(() => {
cancelDashcamCapture();
lost.value = true;
finish();
}, CAPTURE_TIMEOUT_MS);
};
const onSurfaceClick = (event: MouseEvent): void => {
if (event.target === surface.value) shoot();
};
const onPointerMove = (event: PointerEvent): void => {
if (event.pointerType !== 'mouse' || !readDashcam().drift) return;
setDashcamPointer(
(event.clientX / Math.max(1, window.innerWidth)) * 2 - 1,
(event.clientY / Math.max(1, window.innerHeight)) * 2 - 1,
performance.now(),
);
};
const suppress = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
};
const onKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Tab') {
const stops = [saveButton.value, closeButton.value].filter((el): el is HTMLButtonElement => el !== null && !el.disabled);
if (stops.length === 0) return;
const index = stops.indexOf(document.activeElement as HTMLButtonElement);
const next = index === -1 ? 0 : (index + (event.shiftKey ? -1 : 1) + stops.length) % stops.length;
event.preventDefault();
stops[next]?.focus();
return;
}
// Space and Enter still press a focused button; nothing scrolls the page.
if (isScrollIntentKey(event.key) && !(event.key === ' ' && event.target instanceof HTMLButtonElement)) suppress(event);
};
const CAPTURE = { capture: true } as const;
const ACTIVE = { capture: true, passive: false } as const;
watch(title, (value) => { document.title = value; });
watch(() => props.sceneReady, (ready) => { if (ready) lost.value = false; });
onMounted(() => {
previousTitle = document.title;
document.title = title.value;
sample();
ticker = window.setInterval(sample, 1_000 / PULSE_HZ);
lockedY = Math.round(window.scrollY);
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('wheel', suppress, ACTIVE);
window.addEventListener('touchmove', suppress, ACTIVE);
window.addEventListener('keydown', onKeydown, CAPTURE);
saveButton.value?.focus();
});
onBeforeUnmount(() => {
window.clearInterval(ticker);
window.clearTimeout(captureTimer);
window.clearTimeout(shutterTimer);
delete document.documentElement.dataset.dashcamShutter;
cancelDashcamCapture();
window.removeEventListener('scroll', onScroll);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('wheel', suppress, ACTIVE);
window.removeEventListener('touchmove', suppress, ACTIVE);
window.removeEventListener('keydown', onKeydown, CAPTURE);
document.title = previousTitle;
});
</script>
<template>
<div
ref="surface"
class="dashcam-frame"
role="dialog"
aria-modal="true"
aria-label="Dashcam"
:data-unavailable="unavailable ? '' : undefined"
@click="onSurfaceClick"
>
<span class="dashcam-frame__bar" data-edge="top" aria-hidden="true"></span>
<span class="dashcam-frame__bar" data-edge="bottom" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="tl" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="tr" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="bl" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="br" aria-hidden="true"></span>
<p class="dashcam-frame__plate" data-at="tl">
<span ref="dot" class="dashcam-frame__rec" aria-hidden="true"></span>REC {{ frameTopLeft(telemetry) }}
</p>
<p class="dashcam-frame__plate" data-at="tr"><time>{{ telemetry.clock }}</time></p>
<p class="dashcam-frame__plate" data-at="bl">{{ frameBottomLeft(telemetry) }}</p>
<p class="dashcam-frame__plate" data-at="br">{{ DASHCAM_COORDINATES }}<br>{{ name.toUpperCase() }}</p>
<p v-if="unavailable" class="dashcam-frame__lost" role="status">Scene unavailable</p>
<div class="dashcam-frame__actions">
<button ref="saveButton" type="button" :disabled="busy || unavailable" @click="shoot">Save postcard</button>
<button ref="closeButton" type="button" @click="emit('close')">Close (Esc)</button>
</div>
<p class="visually-hidden" role="status">{{ status }}</p>
</div>
</template>

View File

@@ -19,11 +19,14 @@ const props = withDefaults(
audioState?: AudioUiState;
audioMuted?: boolean;
audioVolume?: number;
/** DASHCAM: offer the shutter (live scene, motion allowed). */
dashcam?: boolean;
}>(),
{
audioState: () => ({ kind: "idle" }),
audioMuted: false,
audioVolume: 0.4,
dashcam: false,
},
);
@@ -36,6 +39,8 @@ const emit = defineEmits<{
audioResume: [];
audioMuteToggle: [];
audioVolumeChange: [volume: number];
dashcam: [];
dashcamPrefetch: [];
}>();
const controlRoot = ref<HTMLElement | null>(null);
@@ -282,6 +287,21 @@ onBeforeUnmount(() => {
<IconGlyph :name="action.icon" />
</button>
<button
v-if="dashcam"
class="drive-control-button drive-controls__dashcam"
type="button"
aria-label="Dashcam"
aria-keyshortcuts="c"
title="Dashcam (C)"
data-tooltip="Dashcam (C)"
@pointerenter="emit('dashcamPrefetch')"
@focus="emit('dashcamPrefetch')"
@click="emit('dashcam')"
>
<IconGlyph name="camera" />
</button>
<button
v-if="audioState.kind !== 'starting'"
ref="settingsButton"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, h, type FunctionalComponent } from 'vue';
import {
PhPause,
PhPlay,
@@ -9,7 +9,7 @@ import {
PhWaveform,
} from '@phosphor-icons/vue';
export type IconGlyphName = 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume';
export type IconGlyphName = 'camera' | 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume';
const props = withDefaults(defineProps<{
name: IconGlyphName;
@@ -18,7 +18,15 @@ const props = withDefaults(defineProps<{
size: 20,
});
// Phosphor's camera (regular weight) redrawn as one stroked path: the package
// component ships all six weights' paths into the initial chunk.
const CameraGlyph: FunctionalComponent<{ size?: number }> = ({ size }) => h('svg', {
viewBox: '0 0 256 256', width: size, height: size, fill: 'none', stroke: 'currentColor', 'stroke-width': 16, 'stroke-linejoin': 'round',
}, [h('path', { d: 'M48 64h32l16-24h64l16 24h32a16 16 0 0 1 16 16v112a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V80a16 16 0 0 1 16-16ZM128 96a36 36 0 1 0 0 72a36 36 0 1 0 0-72Z' })]);
CameraGlyph.props = ['size'];
const icons = {
camera: CameraGlyph,
pause: PhPause,
play: PhPlay,
settings: PhSlidersHorizontal,

View File

@@ -0,0 +1,77 @@
import { computed, markRaw, nextTick, onMounted, onUnmounted, shallowRef, watch, type Component, type Ref } from 'vue';
import { setDashcamOpen } from '@/journey/dashcam-feed';
import { isEditableTarget } from '@/composables/useKeyboardNavigation';
const LIVE_STAGE = ".reading-stage[data-reading-stage='live']";
const RISE_MS = 400;
/** Phones: no shutter in the gantry bay (dashcam.css), and no `c` key either. */
const NARROW = '(max-width: 767px)';
/**
* DASHCAM, DOM side (initial chunk, kept minimal): availability, the `c` key,
* html[data-dashcam] and the lazy frame. The frame itself (telemetry, focus
* trap, capture, the shutter) loads on first open or button hover. Starts
* after mount, so the server HTML carries none of it.
*/
export function useDashcam(sources: {
/** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */
allowed: Readonly<Ref<boolean>>;
sceneRunning: Readonly<Ref<boolean>>;
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
drift: () => boolean;
}) {
const open = shallowRef(false);
const frame = shallowRef<Component | null>(null);
const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
let loading: Promise<void> | null = null;
let riseTimer = 0;
const root = (): HTMLElement => document.documentElement;
const prefetch = (): Promise<void> => (loading ??= import('@/components/DashcamFrame.vue').then(
(module) => { frame.value = markRaw(module.default); },
() => { loading = null; },
));
const show = (): void => {
if (open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
if (window.matchMedia?.(NARROW).matches) return;
window.clearTimeout(riseTimer);
open.value = true;
setDashcamOpen(true, sources.drift());
root().dataset.dashcam = 'on';
void prefetch();
};
const close = (): void => {
if (!open.value) return;
open.value = false;
setDashcamOpen(false, false);
root().dataset.dashcam = 'rise';
riseTimer = window.setTimeout(() => { delete root().dataset.dashcam; }, RISE_MS);
void nextTick(() => document.querySelector<HTMLElement>('.drive-controls__dashcam')?.focus());
};
const onKeydown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return;
if (open.value && event.key === 'Escape') {
event.preventDefault();
close();
} else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') {
const wasOpen = open.value;
if (wasOpen) close();
else show();
if (wasOpen || open.value) event.preventDefault();
}
};
watch(sources.allowed, (allowed) => { if (!allowed) close(); });
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
window.clearTimeout(riseTimer);
if (open.value) setDashcamOpen(false, false);
delete root().dataset.dashcam;
});
return { open, frame, available, show, close, prefetch };
}

View File

@@ -24,7 +24,7 @@ const SCROLL_KEYS = new Set([
export const isScrollIntentKey = (key: string): boolean => SCROLL_KEYS.has(key);
const isEditableTarget = (target: EventTarget | null): boolean =>
export const isEditableTarget = (target: EventTarget | null): boolean =>
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||

View File

@@ -0,0 +1,57 @@
/**
* DASHCAM, the slot the DOM and the scene share (initial chunk, kept tiny).
* The DOM writes whether the dashcam is open, whether the lens may drift and
* the latest normalised pointer; the scene reads it inside its own frame and
* eases the lens itself (src/scene/world/dashcam-lens.ts). Nothing here
* schedules work.
*/
export interface DashcamFeed {
open: boolean;
/** Fine pointer on the high or medium tier: the lens follows the pointer. */
drift: boolean;
/** Pointer in [-1, 1], right and down positive. */
px: number;
py: number;
/** performance.now() of the last pointer move (the scene returns to centre after 3 s). */
movedAt: number;
}
const feed: DashcamFeed = { open: false, drift: false, px: 0, py: 0, movedAt: 0 };
export const readDashcam = (): Readonly<DashcamFeed> => feed;
export function setDashcamOpen(open: boolean, drift: boolean): void {
feed.open = open;
feed.drift = open && drift;
feed.px = 0;
feed.py = 0;
if (!open) capture = null;
}
export function setDashcamPointer(px: number, py: number, now: number): void {
feed.px = Math.max(-1, Math.min(1, px));
feed.py = Math.max(-1, Math.min(1, py));
feed.movedAt = now;
}
/**
* One queued capture. The scene calls it synchronously right after its final
* render pass with the WebGL canvas, the only moment the drawing buffer is
* valid (preserveDrawingBuffer is false).
*/
let capture: ((canvas: HTMLCanvasElement) => void) | null = null;
export function requestDashcamCapture(resolve: (canvas: HTMLCanvasElement) => void): void {
capture = resolve;
}
export function cancelDashcamCapture(): void {
capture = null;
}
/** The scene's side: take (and clear) the queued capture, if any. */
export function takeDashcamCapture(): ((canvas: HTMLCanvasElement) => void) | null {
const next = capture;
capture = null;
return next;
}

224
src/lib/dashcam-postcard.ts Normal file
View File

@@ -0,0 +1,224 @@
import { bucharestClock } from '@/lib/operations';
import { maintenanceClock } from '@/journey/maintenance-progress';
/**
* DASHCAM telemetry and the postcard (lazy dashcam chunk only). Every value
* is real: the route progress, the chapter, the exit, the Bucharest clock
* (the 03:12 window's own clock inside Infrastructure) and the identity.
*/
export interface DashcamTelemetry {
sectionId: string;
/** Navigation label, e.g. "Infrastructure". */
sectionLabel: string;
/** Scene chapter, e.g. "pre-dawn". */
chapter: string;
/** Exit marker, e.g. "04". */
exit: string;
/** Whole percent of the route travelled. */
routePercent: number;
/** HH:MM. */
clock: string;
name: string;
city: string;
}
/** Bucharest, as the dash plate shows it. */
export const DASHCAM_COORDINATES = "N44°26' E26°06'";
export const POSTCARD_WIDTH = 1600;
export const POSTCARD_HEIGHT = 900;
export const routePercent = (progress: number): number =>
Math.round(Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)) * 100);
export const chapterLabel = (chapter: string): string => chapter.replaceAll('-', ' ').toUpperCase();
/** Live Bucharest time; inside Infrastructure the maintenance window's clock. */
export function dashcamClock(sectionId: string, maintenance: number | null, now: Date): string {
const live = (): string => bucharestClock(now);
return sectionId === 'infrastructure' && maintenance !== null ? maintenanceClock(maintenance, live) : live();
}
/** document.title while open, e.g. "DASHCAM · 47% · INFRASTRUCTURE · 03:51". */
export const dashcamTitle = (t: Pick<DashcamTelemetry, 'routePercent' | 'sectionLabel' | 'clock'>): string =>
`DASHCAM · ${t.routePercent}% · ${t.sectionLabel.toUpperCase()} · ${t.clock}`;
/** e.g. "night-shift-infrastructure-0351.png". */
export function postcardFilename(sectionId: string, clock: string): string {
const section = sectionId.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || 'route';
const digits = clock.replace(/\D/gu, '').slice(0, 4).padStart(4, '0');
return `night-shift-${section}-${digits}.png`;
}
export const postcardCredit = (name: string, city: string): string =>
`NIGHT SHIFT · ${name.toUpperCase()} · ${city.toUpperCase()}`;
export const frameTopLeft = (t: DashcamTelemetry): string =>
`DASHCAM · ${chapterLabel(t.chapter)} · EXIT ${t.exit}`;
export const frameBottomLeft = (t: DashcamTelemetry): string =>
`ROUTE ${t.routePercent}% · ${t.sectionLabel.toUpperCase()}`;
/** The source rectangle that covers a dw x dh frame from a sw x sh image, centred. */
export function coverCrop(sw: number, sh: number, dw: number, dh: number): { sx: number; sy: number; sw: number; sh: number } {
if (!(sw > 0 && sh > 0 && dw > 0 && dh > 0)) return { sx: 0, sy: 0, sw: Math.max(0, sw), sh: Math.max(0, sh) };
const scale = Math.max(dw / sw, dh / sh);
const cropW = dw / scale;
const cropH = dh / scale;
return { sx: (sw - cropW) / 2, sy: (sh - cropH) / 2, sw: cropW, sh: cropH };
}
/** Frame chrome geometry for a w x h postcard (proportional to 1600 x 900). */
export function postcardLayout(w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT) {
const unit = Math.min(w / POSTCARD_WIDTH, h / POSTCARD_HEIGHT);
const inset = Math.round(44 * unit);
const pad = Math.round(22 * unit);
const font = Math.round(21 * unit);
return {
inset,
bracket: Math.round(70 * unit),
stroke: Math.max(1, Math.round(3 * unit)),
font,
small: Math.round(15 * unit),
left: inset + pad,
right: w - inset - pad,
top: inset + pad + font,
bottom: h - inset - pad,
lineGap: Math.round(font * 1.35),
dot: Math.round(7 * unit),
credit: h - Math.round(16 * unit),
};
}
/** The 2D context surface the postcard uses (a stub in tests). */
export type PostcardContext = Pick<
CanvasRenderingContext2D,
'drawImage' | 'fillRect' | 'fillText' | 'beginPath' | 'moveTo' | 'lineTo' | 'stroke' | 'arc' | 'fill'
| 'createLinearGradient' | 'save' | 'restore'
> & {
fillStyle: unknown;
strokeStyle: unknown;
lineWidth: number;
font: string;
textAlign: CanvasTextAlign;
textBaseline: CanvasTextBaseline;
shadowColor?: string;
shadowBlur?: number;
};
type Source = CanvasImageSource & { width: number; height: number };
const MONO = "'Geist Mono Variable', ui-monospace, monospace";
export function drawPostcard(ctx: PostcardContext, source: Source, t: DashcamTelemetry, w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT): void {
const crop = coverCrop(source.width, source.height, w, h);
ctx.fillStyle = '#05030a';
ctx.fillRect(0, 0, w, h);
if (crop.sw > 0 && crop.sh > 0) ctx.drawImage(source, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, w, h);
const l = postcardLayout(w, h);
// A soft fall-off at the top and bottom keeps the telemetry legible.
for (const [y0, y1] of [[0, h * 0.2], [h, h * 0.8]] as const) {
const shade = ctx.createLinearGradient(0, y0, 0, y1);
shade.addColorStop(0, 'rgba(4, 2, 10, 0.55)');
shade.addColorStop(1, 'rgba(4, 2, 10, 0)');
ctx.fillStyle = shade;
ctx.fillRect(0, Math.min(y0, y1), w, Math.abs(y1 - y0));
}
// Corner brackets.
ctx.strokeStyle = 'rgba(244, 238, 255, 0.92)';
ctx.lineWidth = l.stroke;
const a = l.inset;
const b = l.bracket;
for (const [x, y, dx, dy] of [[a, a, 1, 1], [w - a, a, -1, 1], [a, h - a, 1, -1], [w - a, h - a, -1, -1]] as const) {
ctx.beginPath();
ctx.moveTo(x, y + dy * b);
ctx.lineTo(x, y);
ctx.lineTo(x + dx * b, y);
ctx.stroke();
}
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.6)';
ctx.shadowBlur = Math.round(l.font * 0.4);
ctx.font = `500 ${l.font}px ${MONO}`;
ctx.textBaseline = 'alphabetic';
// REC dot, then the top-left plate.
ctx.fillStyle = '#ff3b4e';
ctx.beginPath();
ctx.arc(l.left + l.dot, l.top - l.font * 0.35, l.dot, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f4eeff';
ctx.textAlign = 'left';
ctx.fillText(`REC ${frameTopLeft(t)}`, l.left + l.dot * 3, l.top);
ctx.fillText(frameBottomLeft(t), l.left, l.bottom);
ctx.textAlign = 'right';
ctx.fillText(t.clock, l.right, l.top);
ctx.fillText(t.name.toUpperCase(), l.right, l.bottom);
ctx.fillText(DASHCAM_COORDINATES, l.right, l.bottom - l.lineGap);
ctx.font = `500 ${l.small}px ${MONO}`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255, 214, 147, 0.95)';
ctx.fillText(postcardCredit(t.name, t.city), w / 2, l.credit);
ctx.restore();
}
function openInTab(url: string): boolean {
// Browsers refuse top-level navigation to data: URLs; show it in a blank tab.
const tab = window.open('', '_blank');
if (tab === null) return false;
const image = tab.document.createElement('img');
image.src = url;
image.alt = 'Night Shift dashcam postcard';
tab.document.body.append(image);
return true;
}
export type PostcardOutcome = 'download' | 'tab' | 'failed';
/**
* Draws the postcard synchronously (the WebGL buffer is only valid in the
* capture task), then encodes and downloads it. Never throws.
*/
export function savePostcard(source: Source, t: DashcamTelemetry): Promise<PostcardOutcome> {
let canvas: HTMLCanvasElement;
try {
canvas = document.createElement('canvas');
canvas.width = POSTCARD_WIDTH;
canvas.height = POSTCARD_HEIGHT;
const ctx = canvas.getContext('2d');
if (ctx === null) return Promise.resolve('failed');
drawPostcard(ctx as unknown as PostcardContext, source, t);
} catch {
return Promise.resolve('failed');
}
const fallback = (): PostcardOutcome => {
try {
return openInTab(canvas.toDataURL('image/png')) ? 'tab' : 'failed';
} catch {
return 'failed';
}
};
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => {
if (blob === null) {
resolve(fallback());
return;
}
try {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = postcardFilename(t.sectionId, t.clock);
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
resolve('download');
} catch {
resolve(fallback());
}
}, 'image/png');
} catch {
resolve(fallback());
}
});
}

View File

@@ -0,0 +1,69 @@
import type { DashcamFeed } from '@/journey/dashcam-feed';
/** DASHCAM tunables (scene side). */
export const DASHCAM_LENS = Object.freeze({
/** Camera x travel at the viewport edge, world units. */
driftX: 1.6,
/** Look-at height travel at the viewport edge, world units. */
driftTargetY: 0.5,
/** Pointer smoothing time constant, seconds. */
driftSeconds: 0.35,
/** No pointer movement for this long returns the lens to centre. */
idleMs: 3_000,
/** Lens-pass letterbox bar height (each bar, fraction of the frame). */
letterbox: 0.16,
/** Bars close over this long. */
letterboxSeconds: 0.4,
});
/** Where the lens wants to be: the pointer, or centre when closed, idle or without drift. */
export function driftTarget(feed: Readonly<DashcamFeed>, now: number): { x: number; y: number } {
if (!feed.open || !feed.drift || now - feed.movedAt > DASHCAM_LENS.idleMs) return { x: 0, y: 0 };
return {
x: Math.max(-1, Math.min(1, feed.px)),
y: Math.max(-1, Math.min(1, feed.py)),
};
}
export interface DashcamLensState {
/** Added to the camera x. */
x: number;
/** Added to the look-at height. */
targetY: number;
/** Lens-pass letterbox term. */
letterbox: number;
}
/**
* Eases the pointer drift (~0.35 s) and the letterbox (400 ms, smoothstep)
* once per frame. Without drift (touch, low tier) the bars snap and the lens
* stays centred.
*/
export function createDashcamLens() {
let x = 0;
let y = 0;
let bars = 0;
let snap = false;
const state: DashcamLensState = { x: 0, targetY: 0, letterbox: 0 };
return {
update(feed: Readonly<DashcamFeed>, deltaSeconds: number, now: number): DashcamLensState {
const target = driftTarget(feed, now);
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) / DASHCAM_LENS.driftSeconds);
x += (target.x - x) * mix;
y += (target.y - y) * mix;
const goal = feed.open ? 1 : 0;
if (feed.open) snap = !feed.drift;
bars = snap
? goal
: goal > bars
? Math.min(goal, bars + deltaSeconds / DASHCAM_LENS.letterboxSeconds)
: Math.max(goal, bars - deltaSeconds / DASHCAM_LENS.letterboxSeconds);
// Settle exactly at rest so the closed dashcam costs nothing downstream.
if (!feed.open && Math.abs(x) < 1e-4 && Math.abs(y) < 1e-4) x = y = 0;
state.x = x * DASHCAM_LENS.driftX;
state.targetY = -y * DASHCAM_LENS.driftTargetY;
state.letterbox = DASHCAM_LENS.letterbox * bars * bars * (3 - 2 * bars);
return state;
},
};
}

View File

@@ -43,6 +43,8 @@ import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
import { createDashcamLens } from '@/scene/world/dashcam-lens';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
@@ -244,6 +246,8 @@ export async function createProceduralSceneGeneration(
let riseUntil = 0;
let gantryState = '';
let dawnState: DawnStep | '' = '';
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
const dashcamLens = createDashcamLens();
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -651,6 +655,7 @@ export async function createProceduralSceneGeneration(
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
) * (1 - outro);
const craneShift = cameraState.shift * crane;
const dashcam = dashcamLens.update(readDashcam(), deltaSeconds, now);
camera.setViewOffset(
width,
height,
@@ -660,13 +665,13 @@ export async function createProceduralSceneGeneration(
height,
);
camera.position.set(
THREE.MathUtils.lerp(0, cameraState.x, crane),
THREE.MathUtils.lerp(0, cameraState.x, crane) + dashcam.x,
THREE.MathUtils.lerp(46, cameraState.y, crane),
THREE.MathUtils.lerp(92, cameraState.z, crane),
);
camera.lookAt(
THREE.MathUtils.lerp(0, cameraState.targetX, crane),
THREE.MathUtils.lerp(64, cameraState.targetY, crane),
THREE.MathUtils.lerp(64, cameraState.targetY, crane) + dashcam.targetY,
THREE.MathUtils.lerp(-720, cameraState.targetZ, crane),
);
camera.rotation.z += cameraState.roll * crane;
@@ -737,7 +742,10 @@ export async function createProceduralSceneGeneration(
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
postProcessor.setLens({
time: elapsedSeconds,
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
letterbox: Math.max(
0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
dashcam.letterbox,
),
energy: overdrive,
rain: service.rain,
rainTime: elapsedSeconds,
@@ -747,6 +755,15 @@ export async function createProceduralSceneGeneration(
} else {
renderer.render(scene, camera);
}
// DASHCAM: the drawing buffer is only valid now, inside this task.
const capture = takeDashcamCapture();
if (capture !== null) {
try {
capture(canvas);
} catch {
// A failed postcard never takes the scene down.
}
}
renderedFrames += 1;
},
dispose() {

185
src/styles/dashcam.css Normal file
View File

@@ -0,0 +1,185 @@
/* ---------- DASHCAM ---------- */
/* The shutter needs the live scene behind the reading window: the button
only shows while the reading stage is live, and not on phones (no room
in the gantry bay). AppShell withholds it entirely without motion. */
.reading-stage:not([data-reading-stage='live']) ~ .drive-controls .drive-controls__dashcam { display: none; }
@media (max-width: 767px) {
html .drive-controls__dashcam { display: none; }
}
/* Open (html[data-dashcam='on']): the scroll is frozen by the frame itself
(wheel, touch and keys are swallowed, a stray scroll snaps back), never by
overflow: hidden, which would drop the scrollbar and resize the canvas
mid-fade. The scene keeps rendering underneath. */
/* The plates fold away like the interlude's (gantry.css), headings last;
the gantry, its HUD plate and the bay fade with them. */
html[data-dashcam='on'] .reading-stage .portfolio-main > * > * {
opacity: 0;
transition: opacity 130ms cubic-bezier(0.5, 0, 0.75, 0) 60ms;
}
html[data-dashcam='on'] .reading-stage .portfolio-main > * > :not(h1, h2, :has(h1, h2)) {
transform: translateY(-24px);
transition:
opacity 160ms cubic-bezier(0.2, 0.7, 0.3, 1),
transform 160ms cubic-bezier(0.5, 0, 0.75, 0);
}
html[data-dashcam='on'] .reading-stage .portfolio-section::before,
html[data-dashcam='on'] .reading-stage .portfolio-section::after,
html[data-dashcam='on'] .site-header,
html[data-dashcam='on'] .journey-hud,
html[data-dashcam='on'] .drive-controls,
html[data-dashcam='on'] .content-focus-cue {
opacity: 0;
visibility: hidden;
animation: none;
transition: opacity 200ms ease, visibility 0s linear 200ms;
}
/* Closing (data-dashcam='rise', 400 ms): the plates come back up. */
@media (prefers-reduced-motion: no-preference) {
html[data-dashcam='rise'] .reading-stage .portfolio-main > * > * {
animation: plate-fade 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
html[data-dashcam='rise'] .reading-stage .portfolio-main > * > :not(h1, h2, :has(h1, h2)) {
animation: plate-rise 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
html[data-dashcam='rise'] .site-header,
html[data-dashcam='rise'] .journey-hud,
html[data-dashcam='rise'] .drive-controls {
animation: plate-fade 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
/* ---------- The frame (DashcamFrame.vue, lazy) ----------
Kept in the initial stylesheet: the trusted artifact contract links every
reachable stylesheet in the prerendered HTML, so the lazy chunk carries
no CSS of its own. */
.dashcam-frame {
--dash-ink: #f4eeff;
--dash-inset: clamp(16px, 3vw, 44px);
/* Bar height: the lens pass's 0.16, or the outro's upper bar when that is
taller (world.css), so the arrival's aubergine never peeks above black. */
--dash-bar: max(16%, calc(var(--gantry-h, 72px) + min(5vh, 44px)));
position: fixed;
z-index: calc(var(--z-popover, 50) + 10);
inset: 0;
cursor: crosshair;
color: var(--dash-ink);
font: 500 clamp(0.68rem, 0.9vw, 0.82rem) / 1.35 var(--mono);
letter-spacing: 0.08em;
text-shadow: 0 1px 3px rgb(0 0 0 / 65%);
animation: dashcam-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* The bars are drawn here on every tier: they close with the lens pass's
(same 400 ms, same height) and also cover the outro's own letterbox and
its typed credit at the destination. Transform only, so nothing lays out. */
.dashcam-frame__bar {
position: absolute;
inset: 0 0 auto;
block-size: var(--dash-bar);
background: #000;
pointer-events: none;
transform-origin: top;
animation: dashcam-bar 400ms cubic-bezier(0.4, 0, 0.6, 1) both;
}
.dashcam-frame__bar[data-edge='bottom'] {
inset: auto 0 0;
transform-origin: bottom;
}
.dashcam-frame__corner {
position: absolute;
inline-size: clamp(28px, 4.4vw, 70px);
block-size: clamp(28px, 4.4vw, 70px);
border: 0 solid rgb(244 238 255 / 90%);
pointer-events: none;
}
.dashcam-frame__corner[data-corner='tl'] { inset: var(--dash-inset) auto auto var(--dash-inset); border-width: 3px 0 0 3px; }
.dashcam-frame__corner[data-corner='tr'] { inset: var(--dash-inset) var(--dash-inset) auto auto; border-width: 3px 3px 0 0; }
.dashcam-frame__corner[data-corner='bl'] { inset: auto auto var(--dash-inset) var(--dash-inset); border-width: 0 0 3px 3px; }
.dashcam-frame__corner[data-corner='br'] { inset: auto var(--dash-inset) var(--dash-inset) auto; border-width: 0 3px 3px 0; }
.dashcam-frame__plate {
position: absolute;
z-index: 1;
margin: 0;
pointer-events: none;
white-space: nowrap;
}
.dashcam-frame__plate[data-at='tl'] { inset: calc(var(--dash-inset) + 18px) auto auto calc(var(--dash-inset) + 20px); }
.dashcam-frame__plate[data-at='tr'] { inset: calc(var(--dash-inset) + 18px) calc(var(--dash-inset) + 20px) auto auto; text-align: right; font-size: 1.15em; }
.dashcam-frame__plate[data-at='bl'] { inset: auto auto calc(var(--dash-inset) + 18px) calc(var(--dash-inset) + 20px); }
.dashcam-frame__plate[data-at='br'] { inset: auto calc(var(--dash-inset) + 20px) calc(var(--dash-inset) + 18px) auto; text-align: right; }
.dashcam-frame__rec {
--rec: 0;
display: inline-block;
inline-size: 0.7em;
block-size: 0.7em;
margin-inline-end: 0.6em;
border-radius: 50%;
background: #ff3b4e;
vertical-align: -0.02em;
opacity: calc(0.55 + var(--rec) * 0.45);
transform: scale(calc(0.85 + var(--rec) * 0.45));
box-shadow: 0 0 calc(4px + var(--rec) * 14px) rgb(255 59 78 / 80%);
transition: transform 80ms linear, opacity 80ms linear;
}
.dashcam-frame__lost {
position: absolute;
inset: 50% auto auto 50%;
margin: 0;
padding: 0.6em 1.2em;
translate: -50% -50%;
border: 1px solid rgb(244 238 255 / 50%);
background: rgb(8 4 18 / 75%);
}
.dashcam-frame__actions {
position: absolute;
z-index: 2;
inset: auto 50% calc(var(--dash-inset) + 10px) auto;
display: flex;
gap: 8px;
translate: 50% 0;
}
.dashcam-frame__actions button {
min-block-size: 44px;
padding: 0 16px;
border: 1px solid rgb(244 238 255 / 45%);
border-radius: 0;
background: rgb(14 8 28 / 72%);
color: var(--dash-ink);
font: inherit;
letter-spacing: inherit;
text-transform: uppercase;
cursor: pointer;
}
.dashcam-frame__actions button:hover:not(:disabled) { border-color: var(--focus, #ffd693); }
.dashcam-frame__actions button:focus-visible { outline: 2px solid var(--focus, #ffd693); outline-offset: 2px; }
.dashcam-frame__actions button:disabled { cursor: default; opacity: 0.55; }
/* The shutter: a 120 ms white veil. */
html[data-dashcam-shutter] .dashcam-frame,
html[data-dashcam-shutter] .dashcam-frame__bar { animation: none; }
html[data-dashcam-shutter] .dashcam-frame::after {
content: '';
position: absolute;
inset: 0;
block-size: auto;
background: #fff;
pointer-events: none;
animation: dashcam-shutter 120ms ease-out both;
}
@keyframes dashcam-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dashcam-bar {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@keyframes dashcam-shutter {
from { opacity: 0.85; }
to { opacity: 0; }
}
@media (forced-colors: active) {
.dashcam-frame__corner { border-color: CanvasText; }
.dashcam-frame__actions button { border-color: ButtonText; }
}

View File

@@ -70,6 +70,8 @@ describe('release budgets', () => {
expect(classifyArtifact('assets/nightdrive-drive-lift.opus', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/nightdrive-drive-lift.mp3', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
expect(classifyArtifact('assets/DashcamFrame-c.js', 'postcard')).toBe('deferred-non-video');
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
});

View File

@@ -0,0 +1,169 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import DriveControls from '@/components/DriveControls.vue';
import { useDashcam } from '@/composables/useDashcam';
import { readDashcam } from '@/journey/dashcam-feed';
import { validateProductionInputs } from '@/content/schemas';
import { render } from '@/entry-server';
import type { MotionPolicy } from '@/journey/motion-policy';
const fixture = (name: string): unknown =>
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
const policy: MotionPolicy = {
allowSceneRequest: true,
allowAutomaticVideo: true,
maximumTier: 'high',
reason: 'full',
};
describe('dashcam server HTML', () => {
it('renders nothing of the dashcam on the server', async () => {
const html = await render('/', validateProductionInputs(
fixture('portfolio-content.valid.json'),
fixture('asset-manifest.valid.json'),
fixture('site-config.preview.json'),
new URL('file:///prepared-assets/'),
));
expect(html).not.toMatch(/dashcam/iu);
});
});
describe('dashcam button', () => {
const mountControls = (dashcam?: boolean) => mount(DriveControls, {
props: {
policy,
maximumQuality: 'high',
sceneStatus: 'running',
audioState: { kind: 'idle' },
...(dashcam === undefined ? {} : { dashcam }),
},
});
it('is absent unless the shell offers it', () => {
expect(mountControls().find('.drive-controls__dashcam').exists()).toBe(false);
expect(mountControls(false).find('.drive-controls__dashcam').exists()).toBe(false);
});
it('opens on click and prefetches the frame on hover and focus', async () => {
const wrapper = mountControls(true);
const button = wrapper.get('.drive-controls__dashcam');
expect(button.attributes('aria-label')).toBe('Dashcam');
expect(button.attributes('aria-keyshortcuts')).toBe('c');
expect(button.find('svg').exists()).toBe(true);
await button.trigger('pointerenter');
await button.trigger('focus');
await button.trigger('click');
expect(wrapper.emitted('dashcamPrefetch')).toHaveLength(2);
expect(wrapper.emitted('dashcam')).toHaveLength(1);
});
});
describe('useDashcam', () => {
let stage: HTMLElement;
const allowed = ref(true);
const running = ref(true);
const host = () => mount(defineComponent({
setup() {
const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true });
return () => h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show });
},
}), { attachTo: document.body });
const press = (key: string, init: KeyboardEventInit = {}): KeyboardEvent => {
const event = new KeyboardEvent('keydown', { key, cancelable: true, bubbles: true, ...init });
window.dispatchEvent(event);
return event;
};
beforeEach(() => {
vi.useFakeTimers();
allowed.value = true;
running.value = true;
stage = document.createElement('div');
stage.className = 'reading-stage';
stage.dataset.readingStage = 'live';
document.body.append(stage);
});
afterEach(() => {
stage.remove();
vi.useRealTimers();
delete document.documentElement.dataset.dashcam;
});
it('opens and closes on c, then lifts the rise state after 400 ms', () => {
const wrapper = host();
expect(press('c').defaultPrevented).toBe(true);
expect(document.documentElement.dataset.dashcam).toBe('on');
expect(readDashcam()).toMatchObject({ open: true, drift: true });
expect(press('C', { shiftKey: true }).defaultPrevented).toBe(false);
press('c');
expect(document.documentElement.dataset.dashcam).toBe('rise');
expect(readDashcam().open).toBe(false);
vi.advanceTimersByTime(400);
expect(document.documentElement.dataset.dashcam).toBeUndefined();
wrapper.unmount();
});
it('closes on Escape', () => {
const wrapper = host();
press('c');
expect(press('Escape').defaultPrevented).toBe(true);
expect(readDashcam().open).toBe(false);
wrapper.unmount();
});
it('ignores c with modifiers or inside an editable field', () => {
const wrapper = host();
expect(press('c', { ctrlKey: true }).defaultPrevented).toBe(false);
const input = document.createElement('input');
document.body.append(input);
const event = new KeyboardEvent('keydown', { key: 'c', cancelable: true, bubbles: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(document.documentElement.dataset.dashcam).toBeUndefined();
input.remove();
wrapper.unmount();
});
it.each([
['motion is withheld', () => { allowed.value = false; }],
['the scene is not running', () => { running.value = false; }],
['the reading stage is not live', () => { stage.dataset.readingStage = 'still'; }],
])('does nothing when %s', (_label, arrange) => {
const wrapper = host();
arrange();
expect(press('c').defaultPrevented).toBe(false);
expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false);
wrapper.unmount();
});
it('does nothing on a phone-width viewport, where the button is hidden too', () => {
const matchMedia = vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList);
vi.stubGlobal('matchMedia', matchMedia);
try {
const wrapper = host();
expect(press('c').defaultPrevented).toBe(false);
expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false);
wrapper.unmount();
} finally {
vi.unstubAllGlobals();
}
});
it('closes when motion is withdrawn while open', async () => {
const wrapper = host();
press('c');
allowed.value = false;
await vi.runOnlyPendingTimersAsync();
expect(readDashcam().open).toBe(false);
wrapper.unmount();
});
});

View File

@@ -0,0 +1,197 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
cancelDashcamCapture,
readDashcam,
requestDashcamCapture,
setDashcamOpen,
setDashcamPointer,
takeDashcamCapture,
} from '@/journey/dashcam-feed';
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
import {
chapterLabel,
dashcamClock,
dashcamTitle,
frameBottomLeft,
frameTopLeft,
postcardCredit,
postcardFilename,
routePercent,
type DashcamTelemetry,
} from '@/lib/dashcam-postcard';
import { attachMusicPulse, createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse';
import { playShutterClick } from '@/audio/switchboard-click';
afterEach(() => {
setDashcamOpen(false, false);
setMusicPulseAudible(false);
attachMusicPulse(null);
});
describe('dashcam feed', () => {
it('opens with drift only when asked, and resets the pointer on every change', () => {
setDashcamOpen(true, true);
setDashcamPointer(0.5, -0.25, 100);
expect(readDashcam()).toMatchObject({ open: true, drift: true, px: 0.5, py: -0.25, movedAt: 100 });
setDashcamOpen(false, true);
expect(readDashcam()).toMatchObject({ open: false, drift: false, px: 0, py: 0 });
setDashcamOpen(true, false);
expect(readDashcam().drift).toBe(false);
});
it('clamps the pointer to [-1, 1]', () => {
setDashcamOpen(true, true);
setDashcamPointer(4, -9, 0);
expect(readDashcam()).toMatchObject({ px: 1, py: -1 });
});
it('hands a capture to the scene exactly once', () => {
const resolve = vi.fn();
requestDashcamCapture(resolve);
expect(takeDashcamCapture()).toBe(resolve);
expect(takeDashcamCapture()).toBeNull();
});
it('drops a queued capture on cancel and on close', () => {
requestDashcamCapture(vi.fn());
cancelDashcamCapture();
expect(takeDashcamCapture()).toBeNull();
setDashcamOpen(true, false);
requestDashcamCapture(vi.fn());
setDashcamOpen(false, false);
expect(takeDashcamCapture()).toBeNull();
});
});
describe('dashcam lens', () => {
const feed = (overrides: Partial<ReturnType<typeof readDashcam>> = {}) => ({
open: true, drift: true, px: 1, py: 1, movedAt: 0, ...overrides,
});
it('targets the pointer while open with drift, centre otherwise', () => {
expect(driftTarget(feed({ px: 0.4, py: -0.6 }), 10)).toEqual({ x: 0.4, y: -0.6 });
expect(driftTarget(feed({ px: 7, py: -7 }), 10)).toEqual({ x: 1, y: -1 });
expect(driftTarget(feed({ open: false }), 10)).toEqual({ x: 0, y: 0 });
expect(driftTarget(feed({ drift: false }), 10)).toEqual({ x: 0, y: 0 });
});
it('returns to centre after 3 s without pointer movement', () => {
expect(driftTarget(feed(), DASHCAM_LENS.idleMs)).toEqual({ x: 1, y: 1 });
expect(driftTarget(feed(), DASHCAM_LENS.idleMs + 1)).toEqual({ x: 0, y: 0 });
});
it('eases the drift and never exceeds +-1.6 x and +-0.5 target height', () => {
const lens = createDashcamLens();
const first = { ...lens.update(feed(), 1 / 60, 0) };
expect(first.x).toBeGreaterThan(0);
expect(first.x).toBeLessThan(DASHCAM_LENS.driftX * 0.1);
let state = first;
for (let i = 0; i < 600; i += 1) state = lens.update(feed(), 1 / 60, 0);
expect(state.x).toBeCloseTo(1.6, 3);
expect(state.targetY).toBeCloseTo(-0.5, 3);
for (let i = 0; i < 600; i += 1) state = lens.update(feed({ px: -1, py: -1 }), 1 / 60, 0);
expect(state.x).toBeGreaterThanOrEqual(-1.6);
expect(state.targetY).toBeLessThanOrEqual(0.5);
});
it('closes the letterbox to 0.16 over 400 ms and opens it again', () => {
const lens = createDashcamLens();
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.16, 5);
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBe(0);
});
it('snaps the bars and holds centre without drift (touch, low tier)', () => {
const lens = createDashcamLens();
const open = lens.update(feed({ drift: false }), 1 / 60, 0);
expect(Math.abs(open.x) + Math.abs(open.targetY)).toBe(0);
expect(open.letterbox).toBeCloseTo(0.16, 10);
expect(lens.update(feed({ open: false, drift: false }), 1 / 60, 0).letterbox).toBe(0);
});
});
describe('dashcam telemetry', () => {
const telemetry: DashcamTelemetry = {
sectionId: 'infrastructure',
sectionLabel: 'Infrastructure',
chapter: 'pre-dawn',
exit: '04',
routePercent: 47,
clock: '03:51',
name: 'Luca Pîslaru',
city: 'Bucharest',
};
it('formats the document title', () => {
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 47% · INFRASTRUCTURE · 03:51');
});
it('formats the frame plates and the credit', () => {
expect(frameTopLeft(telemetry)).toBe('DASHCAM · PRE DAWN · EXIT 04');
expect(frameBottomLeft(telemetry)).toBe('ROUTE 47% · INFRASTRUCTURE');
expect(postcardCredit(telemetry.name, telemetry.city)).toBe('NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST');
expect(chapterLabel('selected-builds')).toBe('SELECTED BUILDS');
});
it('rounds and clamps route progress', () => {
expect(routePercent(0.474)).toBe(47);
expect(routePercent(1.4)).toBe(100);
expect(routePercent(-1)).toBe(0);
expect(routePercent(Number.NaN)).toBe(0);
});
it('names the postcard after the section and the clock', () => {
expect(postcardFilename('infrastructure', '03:51')).toBe('night-shift-infrastructure-0351.png');
expect(postcardFilename('selected-builds', '23:05')).toBe('night-shift-selected-builds-2305.png');
expect(postcardFilename('', '7:5')).toBe('night-shift-route-0075.png');
});
it('uses the live Bucharest clock outside the maintenance window', () => {
const now = new Date('2026-01-15T01:51:00Z');
expect(dashcamClock('profile', null, now)).toBe('03:51');
expect(dashcamClock('infrastructure', null, now)).toBe('03:51');
});
});
describe('switchboard click', () => {
it('stays silent and creates nothing without an audible soundtrack', () => {
expect(readSwitchboard()).toBeNull();
expect(playShutterClick()).toBe(false);
});
it('plays into the mix bus at the switchboard gain when audible', () => {
const gains: { gain: { setValueAtTime: ReturnType<typeof vi.fn>; linearRampToValueAtTime: ReturnType<typeof vi.fn>; exponentialRampToValueAtTime: ReturnType<typeof vi.fn> }; connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
const context = {
state: 'running',
sampleRate: 1000,
currentTime: 2,
createGain: () => {
const node = {
gain: { value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
context: undefined as unknown,
};
node.context = context;
gains.push(node);
return node;
},
createAnalyser: () => { throw new Error('no analyser'); },
createBuffer: (_c: number, length: number) => ({ getChannelData: () => new Float32Array(length) }),
createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null }),
};
const destination = {} as AudioNode;
createPulseTap(context as unknown as BaseAudioContext, destination);
expect(readSwitchboard()).toBeNull();
setMusicPulseAudible(true);
const bus = readSwitchboard();
expect(bus).toBe(gains[0]);
expect(playShutterClick()).toBe(true);
const envelope = gains[1]!;
expect(envelope.connect).toHaveBeenCalledWith(bus);
expect(envelope.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.18, expect.any(Number));
attachMusicPulse(null);
expect(readSwitchboard()).toBeNull();
});
});

View File

@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest';
import {
DASHCAM_COORDINATES,
POSTCARD_HEIGHT,
POSTCARD_WIDTH,
coverCrop,
drawPostcard,
postcardLayout,
type DashcamTelemetry,
type PostcardContext,
} from '@/lib/dashcam-postcard';
const telemetry: DashcamTelemetry = {
sectionId: 'infrastructure',
sectionLabel: 'Infrastructure',
chapter: 'pre-dawn',
exit: '04',
routePercent: 47,
clock: '03:51',
name: 'Luca Pîslaru',
city: 'Bucharest',
};
interface TextCall { text: string; x: number; y: number; align: CanvasTextAlign; font: string }
function stubContext() {
const texts: TextCall[] = [];
const images: number[][] = [];
const ctx = {
fillStyle: '' as unknown,
strokeStyle: '' as unknown,
lineWidth: 1,
font: '',
textAlign: 'start' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
drawImage: vi.fn((_source: unknown, ...rest: number[]) => { images.push(rest); }),
fillRect: vi.fn(),
fillText: vi.fn((text: string, x: number, y: number) => {
texts.push({ text, x, y, align: ctx.textAlign, font: ctx.font });
}),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
save: vi.fn(),
restore: vi.fn(),
};
return { ctx: ctx as unknown as PostcardContext & typeof ctx, texts, images };
}
const source = (width: number, height: number) => ({ width, height }) as unknown as HTMLCanvasElement;
describe('coverCrop', () => {
it('crops a wider source to the 16:9 centre', () => {
expect(coverCrop(2400, 900, 1600, 900)).toEqual({ sx: 400, sy: 0, sw: 1600, sh: 900 });
});
it('crops a taller source to the 16:9 centre', () => {
const crop = coverCrop(1440, 1080, 1600, 900);
expect(crop.sx).toBe(0);
expect(crop.sw).toBe(1440);
expect(crop.sh).toBeCloseTo(810, 6);
expect(crop.sy).toBeCloseTo(135, 6);
});
it('keeps an exact-ratio source whole and tolerates an empty one', () => {
expect(coverCrop(3200, 1800, 1600, 900)).toEqual({ sx: 0, sy: 0, sw: 3200, sh: 1800 });
expect(coverCrop(0, 0, 1600, 900)).toEqual({ sx: 0, sy: 0, sw: 0, sh: 0 });
});
});
describe('postcardLayout', () => {
it('keeps every plate inside the frame at 1600 x 900', () => {
const l = postcardLayout();
expect(l.inset).toBe(44);
expect(l.left).toBeGreaterThan(l.inset);
expect(l.right).toBeLessThan(POSTCARD_WIDTH - l.inset);
expect(l.top - l.font).toBeGreaterThan(l.inset);
expect(l.bottom - l.lineGap - l.font).toBeGreaterThan(POSTCARD_HEIGHT / 2);
expect(l.bottom).toBeLessThan(POSTCARD_HEIGHT - l.inset);
expect(l.credit).toBeGreaterThan(l.bottom);
expect(l.credit).toBeLessThan(POSTCARD_HEIGHT);
});
it('scales with the smaller axis', () => {
expect(postcardLayout(800, 450).inset).toBe(22);
expect(postcardLayout(800, 900).inset).toBe(22);
});
});
describe('drawPostcard', () => {
it('covers the frame with the scene and writes the telemetry and credit', () => {
const { ctx, texts, images } = stubContext();
drawPostcard(ctx, source(2880, 1800), telemetry);
const l = postcardLayout();
expect(images).toEqual([[0, 90, 2880, 1620, 0, 0, POSTCARD_WIDTH, POSTCARD_HEIGHT]]);
expect(texts.map((call) => call.text)).toEqual([
'REC DASHCAM · PRE DAWN · EXIT 04',
'ROUTE 47% · INFRASTRUCTURE',
'03:51',
'LUCA PÎSLARU',
DASHCAM_COORDINATES,
'NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST',
]);
const byText = Object.fromEntries(texts.map((call) => [call.text, call]));
expect(byText['ROUTE 47% · INFRASTRUCTURE']).toMatchObject({ x: l.left, y: l.bottom, align: 'left' });
expect(byText['03:51']).toMatchObject({ x: l.right, y: l.top, align: 'right' });
expect(byText[DASHCAM_COORDINATES]).toMatchObject({ x: l.right, y: l.bottom - l.lineGap, align: 'right' });
expect(byText['NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST']).toMatchObject({ x: 800, y: l.credit, align: 'center' });
expect(texts.every((call) => call.font.includes('Geist Mono'))).toBe(true);
// Four corner brackets.
expect(ctx.stroke).toHaveBeenCalledTimes(4);
expect(ctx.save).toHaveBeenCalledTimes(1);
expect(ctx.restore).toHaveBeenCalledTimes(1);
});
it('still draws the chrome over an empty source', () => {
const { ctx, texts, images } = stubContext();
drawPostcard(ctx, source(0, 0), telemetry);
expect(images).toEqual([]);
expect(texts).toHaveLength(6);
});
});