feat: sign interlude, window plate and a two-button control bay

The sign closes in over the road on a gantry truss, arcs into a wider
window and hands off to a flat 2D plate that mirrors its face; content
steps aside for the interlude and rises after. The bay keeps sound and
settings; volume and Pause motion move into the settings popover.
This commit is contained in:
2026-10-10 23:30:58 +03:00
parent 5757dd968f
commit bacc035c43
12 changed files with 823 additions and 287 deletions

View File

@@ -110,3 +110,36 @@ content had to stay out of their way.
it, so no light ever passes the car.
- A unit test pins both rules.
- The car's own lamps and the wake stay parented to the car, as before.
## Evidence
All captures are 1440x900 unless named otherwise, from the production
preview build on a headed GPU Chromium. They are in `docs/redesign/round5/`.
| Item | Before | After |
| --- | --- | --- |
| 4, 5, 6, 8 Gantry | `gantry-before-1440.webp` | `gantry-after-1440-incoming.webp`, `-docked.webp`, `-idle.webp`, `gantry-after-1024.webp`, `gantry-after-390.webp` |
| 1 Outro | `outro-before-1440.webp` | `outro-after-1440.webp` |
| 7 Car | left half of `car-before-after-desktop.webp` | right half; `car-lite-390.webp` |
| 9, 10 Cards and motion | `cards-before-1440.webp` | `cards-after-1440.webp` |
- **Gantry docking (headed run, clicking Profile).** idle at 300 ms,
incoming at 700 ms, docked from 1000 to 2600 ms, passing at 3300 ms, idle
at 3700 ms. The face lands within 2 px of the window at 1440x900,
1920x1080, 1024x768, 390x844 and 844x390 (unit tests). Clicking the
window, or Enter on it, drives to the next exit (Start to Profile, then
Experience) at 1440 and 390.
- **A mask bug found on the way.** The window cut-out used `--window-x`
as a `mask-position`. Percentages there resolve against the box minus the
layer, so the hole sat 128 px left of the window and the beam hid half of
the sign. The cut-out now has its own `--window-mask-x`.
- **Car.** Phones and the low tier load the lite model: 31,288 to 11,027
faces, 1.19 MB to 0.50 MB raw. The transfer saving is small (149 KB to
143 KB), because the geometry already compresses well. The real wins are
parse time and GPU memory.
- **fps (same machine, same script, Intel Arc iGPU, headed).**
| | start | infrastructure | builds | toolkit | contact |
| --- | --- | --- | --- | --- | --- |
| Before | 70.1 | 82.6 | 83.8 | 81.8 | 87.2 |
| After | 68.7 | 83.5 | 79.7 | 83.7 | 95.1 |

View File

@@ -15,6 +15,7 @@ import ToolkitGrid from './ToolkitGrid.vue';
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useAudioEngine } from '@/composables/useAudioEngine';
import { useAudioIntent } from '@/composables/useAudioIntent';
import { useCardEntrances } from '@/composables/useCardEntrances';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
@@ -80,6 +81,7 @@ const journeyBinding = useJourneySections(
computed(() => motion.policy.value.allowSceneRequest),
);
const journey = journeyBinding.snapshot;
useCardEntrances();
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
const resume = computed(() => {
const id = route.resumeFrom.value;
@@ -262,6 +264,7 @@ onUnmounted(() => removePageListeners());
:items="canonicalNavigation"
:journey="journey"
:visited="route.visited.value"
:resume-id="resume?.id"
:allow-motion="motion.policy.value.allowSceneRequest"
:on-section-link-click="navigation.onSectionLinkClick"
/>
@@ -270,7 +273,7 @@ onUnmounted(() => removePageListeners());
<div class="reading-stage">
<div class="reading-viewport">
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
<HeroJourney :identity="content.identity" :resume="resume" @resume="navigation.onSectionLinkClick" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
<HeroJourney :identity="content.identity" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
<PortfolioSection id="profile" :exit="exitFor('profile')" :next-label="nextLabelFor('profile')" :next-exit="nextExitFor('profile')" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<p>{{ content.profile.introduction }}</p>

View File

@@ -40,9 +40,8 @@ const emit = defineEmits<{
const controlRoot = ref<HTMLElement | null>(null);
const settingsButton = ref<HTMLButtonElement | null>(null);
const volumeButton = ref<HTMLButtonElement | null>(null);
const settingsPanel = ref<HTMLElement | null>(null);
const settingsOpen = ref(false);
const volumeOpen = ref(false);
const action = computed(() => {
if (props.policy.reason === "visitor-pause") {
@@ -123,21 +122,28 @@ const changeVolume = (event: Event): void => {
const toggleSettings = (): void => {
settingsOpen.value = !settingsOpen.value;
volumeOpen.value = false;
};
const toggleVolume = (): void => {
volumeOpen.value = !volumeOpen.value;
settingsOpen.value = false;
};
const closePanels = (restoreFocus = false): void => {
const focusTarget = settingsOpen.value
? settingsButton.value
: volumeButton.value;
const wasOpen = settingsOpen.value;
settingsOpen.value = false;
volumeOpen.value = false;
if (restoreFocus) void nextTick(() => focusTarget?.focus());
if (restoreFocus && wasOpen) void nextTick(() => settingsButton.value?.focus());
};
/** Tab and Shift+Tab cycle inside the open settings panel and its trigger. */
const trapFocus = (event: KeyboardEvent): void => {
if (event.key !== "Tab" || !settingsOpen.value) return;
const panel = settingsPanel.value;
if (!panel || !settingsButton.value) return;
const stops = [
settingsButton.value,
...panel.querySelectorAll<HTMLElement>("button, input, select"),
];
const index = stops.indexOf(document.activeElement as HTMLElement);
if (index === -1) return;
const next = (index + (event.shiftKey ? -1 : 1) + stops.length) % stops.length;
event.preventDefault();
stops[next]?.focus();
};
const onDocumentPointerDown = (event: Event): void => {
@@ -146,12 +152,22 @@ const onDocumentPointerDown = (event: Event): void => {
};
const onDocumentKeyDown = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || (!settingsOpen.value && !volumeOpen.value))
return;
trapFocus(event);
if (event.key !== "Escape" || !settingsOpen.value) return;
event.preventDefault();
closePanels(true);
};
// Opening the panel moves focus into it; the trap keeps it there.
watch(settingsOpen, (open) => {
if (open)
void nextTick(() =>
settingsPanel.value
?.querySelector<HTMLElement>("input, button, select")
?.focus(),
);
});
watch(
() => props.audioState.kind,
() => closePanels(),
@@ -173,28 +189,33 @@ onBeforeUnmount(() => {
ref="controlRoot"
class="drive-controls"
:data-drive-state="audioState.kind"
:data-muted="audioMuted"
>
<!-- Two monochrome bay buttons: the soundtrack (an equaliser that moves
while it plays) and the settings. Each is a 44 px target drawn as a
36 px tile. -->
<button
v-if="audioState.kind === 'idle'"
class="drive-start"
class="drive-start drive-control-button"
type="button"
aria-label="Play soundtrack"
data-tooltip="Play soundtrack"
@click="emit('audioStart')"
>
<IconGlyph name="play" />
<span>PLAY SOUNDTRACK</span>
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="visually-hidden">PLAY SOUNDTRACK</span>
</button>
<button
v-else-if="audioState.kind === 'starting'"
class="drive-start"
class="drive-start drive-control-button"
type="button"
disabled
aria-label="Starting drive"
aria-live="polite"
>
<IconGlyph name="play" />
<span>STARTING…</span>
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="visually-hidden">STARTING…</span>
</button>
<p
@@ -225,45 +246,20 @@ onBeforeUnmount(() => {
<IconGlyph name="play" />
</button>
<button
class="drive-control-button"
v-else
class="drive-control-button drive-sound"
type="button"
:aria-label="muteLabel"
:title="muteLabel"
:data-tooltip="muteLabel"
@click="emit('audioMuteToggle')"
>
<IconGlyph :name="muteIcon" />
</button>
<button
ref="volumeButton"
class="drive-control-button"
type="button"
aria-label="Sound volume"
title="Sound volume"
data-tooltip="Sound volume"
aria-controls="drive-volume-panel"
:aria-expanded="volumeOpen"
@click="toggleVolume"
>
<IconGlyph name="volume" />
</button>
<button
v-if="action"
class="drive-control-button"
type="button"
:aria-label="action.label"
:title="action.label"
:data-tooltip="action.label"
@click="runAction"
>
<IconGlyph :name="action.icon" />
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
</div>
<button
v-if="action && !isAudioActive && action.event !== 'pause'"
v-if="action && action.event !== 'pause'"
class="drive-control-button"
type="button"
:aria-label="action.label"
@@ -297,35 +293,41 @@ onBeforeUnmount(() => {
{{ statusText }}
</p>
<div
v-if="volumeOpen && isAudioActive"
id="drive-volume-panel"
class="drive-popover drive-popover--volume"
role="dialog"
aria-label="Sound volume"
>
<label for="drive-volume">SOUND</label>
<input
id="drive-volume"
aria-label="Sound volume level"
type="range"
min="0"
max="1"
step="0.05"
:value="audioVolume"
@input="changeVolume"
/>
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
</div>
<div
v-if="settingsOpen"
id="drive-settings-panel"
ref="settingsPanel"
class="drive-popover drive-popover--settings"
role="dialog"
aria-label="Drive settings"
>
<strong>DRIVE SETTINGS</strong>
<template v-if="isAudioActive">
<label for="drive-volume">SOUND</label>
<div class="drive-popover__volume">
<input
id="drive-volume"
aria-label="Sound volume level"
type="range"
min="0"
max="1"
step="0.05"
:value="audioVolume"
@input="changeVolume"
/>
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
</div>
</template>
<button
v-if="action && action.event === 'pause'"
class="drive-popover__action"
type="button"
:aria-label="action.label"
@click="runAction"
>
<IconGlyph :name="action.icon" />
<span aria-hidden="true">PAUSE MOTION</span>
</button>
<label for="drive-quality">QUALITY</label>
<select
id="drive-quality"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
import { computed } from 'vue';
import type { JourneySnapshot } from '@/journey/types';
import { bucharestClock } from '@/lib/operations';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
const props = defineProps<{
journey: JourneySnapshot;
@@ -13,24 +13,27 @@ const props = defineProps<{
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
const next = computed(() =>
props.nextLabel === undefined ? 'END OF ROUTE' : `NEXT ${props.nextExit ?? ''} \u2192 ${props.nextLabel}`.toUpperCase(),
props.nextLabel === undefined ? 'END OF ROUTE' : `NEXT ${props.nextExit ?? ''} → ${props.nextLabel}`.toUpperCase(),
);
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`);
// The dash clock is decorative and CSS-generated, so SSR and hydrated text stay identical.
const clock = shallowRef<string | undefined>(undefined);
let timer = 0;
onMounted(() => {
const tick = (): void => { clock.value = bucharestClock(new Date()); };
tick();
timer = window.setInterval(tick, 30_000);
});
onUnmounted(() => window.clearInterval(timer));
/* The window's flat exit plate: exit tab, chapter, a next-exit chevron, and
a route rule that fills along its lower edge. */
const route = computed(() => Math.min(1, Math.max(0, props.journey.routeProgress)));
const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
</script>
<template>
<aside class="journey-hud" aria-hidden="true" :data-exit="exit" :data-clock="clock">
<aside
class="journey-hud"
aria-hidden="true"
:data-exit="exit"
:data-arrived="journey.arrived"
:style="{
'--route': route,
'--sign-accent': SIGN_ACCENTS[journey.activeSectionId],
'--label-chars': sectionLabel.length,
}"
>
<span class="journey-hud__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__section">{{ sectionLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>

View File

@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { SectionId } from "@/content/types";
import type { JourneySnapshot } from "@/journey/types";
import { SIGN_ACCENTS } from "@/journey/sign-routes";
const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>;
@@ -9,6 +10,8 @@ const props = defineProps<{
/** Exits passed in this browser (client-only memory). */
visited?: ReadonlySet<SectionId>;
allowMotion?: boolean;
/** A returning visitor's last exit: the window offers it from the start. */
resumeId?: SectionId | undefined;
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
}>();
@@ -93,18 +96,34 @@ onBeforeUnmount(() => {
compactQuery = null;
});
/** The gantry's sign window drives on to the next exit (or back to the
* start from the destination), exactly like following that exit's link. */
const nextIndex = computed(() =>
activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1,
/** The gantry's sign window drives on to the next exit, resumes a returning
* visitor's drive from the start, or goes back to the start from the
* destination, exactly like following that exit's link. */
const resumeIndex = computed(() =>
props.resumeId === undefined || activeIndex.value !== 0
? -1
: props.items.findIndex((item) => item.id === props.resumeId),
);
const nextIndex = computed(() => {
if (resumeIndex.value > 0) return resumeIndex.value;
return activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1;
});
const windowLabel = computed(() => {
const next = props.items[nextIndex.value];
if (next === undefined) return "Drive on";
if (resumeIndex.value > 0)
return `Resume the drive at ${next.label}, exit ${exitNumber(nextIndex.value)}`;
return nextIndex.value === 0
? `Back to the start: ${next.label}`
? `Drive again from the start: ${next.label}`
: `Drive to the next exit: ${next.label}`;
});
/** Decorative hover line under the window (CSS generated content). */
const windowHint = computed(() => {
if (resumeIndex.value > 0) return `RESUME EXIT ${exitNumber(nextIndex.value)} \u2192`;
return nextIndex.value === 0
? "DRIVE AGAIN \u21ba"
: `DRIVE TO EXIT ${exitNumber(nextIndex.value)} \u2192`;
});
const driveOn = (): void => {
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
};
@@ -117,7 +136,7 @@ const navigate = (event: MouseEvent): void => {
</script>
<template>
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length }">
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
<details ref="disclosure" class="site-navigation__disclosure" open>
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
<ul ref="list">
@@ -138,8 +157,7 @@ const navigate = (event: MouseEvent): void => {
type="button"
class="gantry-window"
:aria-label="windowLabel"
:title="windowLabel"
@click="driveOn"
></button>
><span class="gantry-window__cone" aria-hidden="true"></span><span class="gantry-window__hint" aria-hidden="true" :data-hint="windowHint"></span></button>
</nav>
</template>

View File

@@ -16,6 +16,18 @@ export interface SignRouteSpec {
dwellOffsetX?: number;
}
/** Each exit's sign colour, shared by the 3D sign and the 2D plates. */
export const SIGN_ACCENTS: Readonly<Record<SectionId, string>> = {
start: '#ff83c2',
profile: '#ff83c2',
experience: '#c4a2ff',
infrastructure: '#8be9ed',
'selected-builds': '#ffa68d',
'additional-work': '#ff83c2',
toolkit: '#c4a2ff',
contact: '#ffa68d',
};
export const SIGN_LIFECYCLE = Object.freeze({
anticipateMs: 300,
approachMs: 900,

View File

@@ -154,6 +154,7 @@ export async function createProceduralSceneGeneration(
car.group,
speedWake.group,
sign.group,
sign.truss,
);
const postProcessor = quality.postEffects
@@ -192,6 +193,8 @@ export async function createProceduralSceneGeneration(
// The outro mirrors the intro: on arrival the camera cranes back up toward
// the sun on a long lens. Leaving the destination reverses it, faster.
let outroProgress = 0;
let interludeOn = false;
let riseUntil = 0;
let gantryState = '';
const resize = (): void => {
@@ -373,7 +376,8 @@ export async function createProceduralSceneGeneration(
car.group.position,
width < 1100 || height < 620 ? 2.32 : 2.78,
);
sign.update(snapshot.sign, now);
sign.setResting(snapshot.activeSectionId);
sign.update(snapshot.sign, now, snapshot.driveSpeed);
atmosphere.update(
driveDistance,
visualSpeed,
@@ -568,6 +572,18 @@ export async function createProceduralSceneGeneration(
gantryState = gantry;
document.documentElement.dataset.gantry = gantry;
}
// The interlude: the reading plates step aside while the sign has the
// road to itself (CSS keys off this attribute).
// 'on' while the sign approaches, then 'rise' while the plates return.
const interlude = sign.interlude();
if (interlude !== interludeOn) {
interludeOn = interlude;
riseUntil = interlude ? 0 : now + INTERLUDE_RISE_MS;
document.documentElement.dataset.interlude = interlude ? 'on' : 'rise';
} else if (riseUntil > 0 && now > riseUntil) {
riseUntil = 0;
delete document.documentElement.dataset.interlude;
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {
@@ -603,6 +619,7 @@ export async function createProceduralSceneGeneration(
if (disposed) return;
disposed = true;
delete document.documentElement.dataset.gantry;
delete document.documentElement.dataset.interlude;
// Quality changes reuse the canvas context. Leave texture upload flags at
// defaults before the next renderer creates its empty array textures.
const gl = renderer.getContext();
@@ -617,6 +634,8 @@ const INTRO_SECONDS = 4.2;
const OUTRO_SECONDS = 5.5;
const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62;
/** Plates rise back over 720 ms with up to three 90 ms staggers. */
const INTERLUDE_RISE_MS = 1100;
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

View File

@@ -100,10 +100,12 @@ export function createSignStage(resources: SceneResourceBag) {
const rect = windowElement.getBoundingClientRect();
tilt.x = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width) * 2 - 1));
tilt.y = Math.max(-1, Math.min(1, ((event.clientY - rect.top) / rect.height) * 2 - 1));
if (event.pointerType === 'mouse') tilt.hover = true;
}, { passive: true });
resources.listen(windowElement, 'pointerleave', () => {
tilt.x = 0;
tilt.y = 0;
tilt.hover = false;
}, { passive: true });
}
const rect = windowElement?.getBoundingClientRect();

View File

@@ -2,6 +2,7 @@ import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { SignSnapshot } from '@/journey/types';
import {
SIGN_ACCENTS,
signRouteFor,
signViewportProfileFor,
type SignCorridor,
@@ -25,10 +26,13 @@ export interface SignDock {
height: number;
}
/** Pointer offset over the docked sign, -1..1 on each axis; 0 at rest. */
/** Pointer offset over the docked sign, -1..1 on each axis; 0 at rest.
* `hover` is true while the pointer is over the window: the 3D twin of the
* current exit plate then shows, tilted toward it. */
export interface SignTilt {
x: number;
y: number;
hover?: boolean;
}
/** What the gantry shows: its own 2D read-out, an incoming sign, the
@@ -37,12 +41,22 @@ export type GantryState = 'idle' | 'incoming' | 'docked' | 'passing';
const REST_TILT: SignTilt = { x: 0, y: 0 };
const FACE_Z = 0.54;
/** Hover tilt, about 4 degrees at the window's edge. */
const TILT_RADIANS = 0.07;
/** The docked 3D face crossfades into the window's flat 2D plate. */
const HANDOFF_MS = 200;
/** Above this scroll drive speed a passive crossing skips the interlude. */
const BRISK_DRIVE_SPEED = 0.5;
/** Share of the approach spent on the truss before the arc into the window. */
const ON_TRUSS = 0.7;
const SIGN_MARKER_FONT =
'600 60px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
export interface SignWorld {
group: SignGroup;
/** The overhead truss the sign hangs from on its approach. */
truss: unknown;
inspect(
camera: ProjectingCamera,
width: number,
@@ -57,9 +71,13 @@ export interface SignWorld {
tilt?: SignTilt,
): void;
dockState(): GantryState;
/** True while a full interlude runs: the reading plates make way for it. */
interlude(): boolean;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null, now?: number): void;
/** The exit the window rests on between signs (its hover twin). */
setResting(id: SectionId): void;
update(sign: SignSnapshot | null, now?: number, driveSpeed?: number): void;
}
export interface SignPresentation {
@@ -385,6 +403,23 @@ export function createSign(
let tiltX = 0;
let tiltY = 0;
let state: GantryState = 'idle';
let brisk = false;
let dwellStartedAt: number | null = null;
let hoverLevel = 0;
let trussHeld = 0;
let resting: SectionId = 'start';
const truss = createTruss(resources);
const drawTarget = (id: SectionId): void => {
if (target === id) return;
target = id;
face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn =
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
edgeMaterial.emissive.set(SIGN_ACCENTS[id]);
if (context)
drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
texture.needsUpdate = true;
};
const point = new THREE.Vector3();
const ray = new THREE.Vector3();
const forward = new THREE.Vector3();
@@ -417,6 +452,7 @@ export function createSign(
}
return {
group,
truss: truss.group,
inspect(camera, width, height) {
if (target === null) return null;
group.updateMatrixWorld(true);
@@ -463,41 +499,40 @@ export function createSign(
viewportHeight = Math.max(1, height);
viewport = signViewportProfileFor(width, height);
},
update(sign) {
update(sign, now = 0, driveSpeed = 0) {
// A new latch: decide once whether this sign gets the full interlude.
if (sign && (snapshot === null || snapshot.targetId !== sign.targetId)) {
brisk = sign.navigationArrival !== true && driveSpeed > BRISK_DRIVE_SPEED;
dwellStartedAt = null;
}
snapshot = sign;
group.userData.phase = sign?.phase ?? 'inactive';
group.userData.target = sign?.targetId ?? null;
if (sign?.phase === 'dwell' && dwellStartedAt === null) dwellStartedAt = now;
if (!sign) {
group.visible = false;
initialized = false;
state = 'idle';
truss.hide();
drawTarget(resting);
return;
}
if (target !== sign.targetId) {
target = sign.targetId;
face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn =
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
edgeMaterial.emissive.set(SIGN_ACCENTS[sign.targetId]);
if (context)
drawLabel(
context,
signRouteFor(sign.targetId),
canvas.width,
canvas.height,
);
texture.needsUpdate = true;
initialized = false;
}
drawTarget(sign.targetId);
},
setResting(id) {
resting = id;
},
dockState() {
return state;
},
interlude() {
return !brisk && state === 'incoming';
},
compose(camera, width, height, dock, now, tilt = REST_TILT) {
if (!snapshot) return;
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
previousNow = now;
// No window on the narrowest screens: the gantry shows no sign there.
if (dock.width < 40 || dock.height < 10) {
group.visible = false;
truss.hide();
state = 'idle';
return;
}
@@ -509,82 +544,120 @@ export function createSign(
.transformDirection(
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
);
const route = signRouteFor(snapshot.targetId);
activeCorridor = route.corridor ?? 'right';
const side = activeCorridor.includes('left') ? -1 : 1;
// The face fills the gantry window; its housing tucks behind the beam.
const targetX = dock.left + dock.width / 2;
const targetY = dock.top + dock.height / 2;
const targetWidth = Math.max(40, Math.min(dock.width, dock.height * 4));
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
previousNow = now;
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
screenX += (targetX - screenX) * mix;
screenY += (targetY - screenY) * mix;
screenWidth += (targetWidth - screenWidth) * mix;
initialized = true;
const p = smoothstep(clamp01(snapshot.progress));
const hovering = tilt.hover === true;
const hoverMix = 1 - Math.exp(-dt / 0.08);
hoverLevel += ((hovering ? 1 : 0) - hoverLevel) * hoverMix;
const phase = snapshot?.phase ?? 'rest';
const raw = clamp01(snapshot?.progress ?? 1);
const arrival =
snapshot.phase === 'anticipate'
? 0
: snapshot.phase === 'approach'
? p
: 1;
// Lift-off: the sign rises off the horizon on its side of the road and
// curves up into the window, growing as it closes in.
const lateral = 1 - (1 - arrival) * (1 - arrival);
const closeDepth = 18;
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
let cx = THREE.MathUtils.lerp(width * (0.5 + side * 0.07), screenX, lateral);
let cy = THREE.MathUtils.lerp(height * 0.44, screenY, arrival);
let projectedWidth =
screenWidth * THREE.MathUtils.lerp(0.14, 1, arrival * arrival);
let pitch = 0;
if (snapshot.phase === 'depart') {
// Passing under the gantry: up and out of the top of the frame,
// seen more and more from below.
depth = THREE.MathUtils.lerp(closeDepth, 9, p);
cy = screenY - p * p * (screenY + dock.height * 1.6);
projectedWidth *= 1 + p * 0.45;
pitch = -p * 0.85;
phase === 'anticipate' ? 0 : phase === 'approach' && !brisk ? raw : 1;
// Full scale over the road, in the upper third: about 600 px wide.
const bigWidth = Math.min(620, width * (width < 768 ? 0.82 : 0.42));
const bigY = height * 0.3;
let cx = screenX;
let cy = screenY;
let projectedWidth = screenWidth;
let depth = 18;
let trussOpacity = 0;
let trussDepth = 0;
let trussY = 0;
if (snapshot && (phase === 'anticipate' || phase === 'approach') && !brisk) {
if (arrival < ON_TRUSS) {
// Hanging from the truss, closing in down the road.
const t = arrival / ON_TRUSS;
const near = 1 - (1 - t) * (1 - t);
depth = THREE.MathUtils.lerp(150, 46, near);
cx = width / 2;
cy = bigY;
projectedWidth = bigWidth * THREE.MathUtils.lerp(0.32, 1, near);
trussOpacity = smoothstep(Math.min(1, t * 4));
} else {
// The handoff: the sign lifts off the truss and arcs up into the
// window, shrinking with scale continuity to the window's size.
const q = (arrival - ON_TRUSS) / (1 - ON_TRUSS);
const e = 1 - Math.pow(1 - q, 3);
depth = THREE.MathUtils.lerp(46, 18, e);
cx = THREE.MathUtils.lerp(width / 2, screenX, e);
// Up first, then across: an arc, not a straight cut.
cy = THREE.MathUtils.lerp(bigY, screenY, Math.sqrt(e));
projectedWidth = THREE.MathUtils.lerp(bigWidth, screenWidth, e);
trussOpacity = 1;
}
// The truss carries on toward the camera and passes overhead exactly
// as the sign docks.
trussDepth = arrival < ON_TRUSS
? depth
: THREE.MathUtils.lerp(46, -6, (arrival - ON_TRUSS) / (1 - ON_TRUSS));
}
const docked = phase === 'dwell' || phase === 'rest' || (brisk && phase === 'approach');
const tiltMix = 1 - Math.exp(-dt / 0.16);
const docked = snapshot.phase === 'dwell' ? 1 : 0;
tiltX += (tilt.x * docked - tiltX) * (initialized ? tiltMix : 1);
tiltY += (tilt.y * docked - tiltY) * (initialized ? tiltMix : 1);
const tiltOn = docked ? hoverLevel : 0;
tiltX += (tilt.x * tiltOn - tiltX) * tiltMix;
tiltY += (tilt.y * tiltOn - tiltY) * tiltMix;
worldPoint(point, cx, cy, depth, camera);
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
const scale = worldRight.distanceTo(worldLeft) / 8;
// The face sits FACE_Z in front of the group origin: set the origin
// back by that much so the face itself lands on the target plane.
if (trussOpacity > 0) {
// The bottom chord sits just above the sign's top edge.
if (arrival < ON_TRUSS) trussY = point.y + scale * 1.25;
else trussY = trussHeld;
trussHeld = trussY;
const camera3 = camera as ProjectingCamera;
truss.place(trussY, camera3.position.z - Math.max(-8, trussDepth) * Math.max(0.2, -forward.z), trussOpacity);
} else {
truss.hide();
}
point.addScaledVector(forward, FACE_Z * scale);
group.position.set(point.x, point.y, point.z);
group.scale.setScalar(scale);
// Parallel to the image plane, so the docked face fits the window
// exactly; hover turns it toward the pointer.
group.quaternion.copy(camera.quaternion);
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * 0.3));
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, pitch - tiltY * 0.24));
const opacity = clamp01(snapshot.opacity);
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * TILT_RADIANS));
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, -tiltY * TILT_RADIANS));
// Visibility of the 3D face: the approach fades it in; once docked it
// hands off to the window's 2D plate unless the pointer asks for it.
let opacity: number;
if (!snapshot) {
opacity = hoverLevel;
} else if (phase === 'anticipate') {
opacity = 0;
} else if (phase === 'approach') {
opacity = brisk ? smoothstep(raw) : smoothstep(Math.min(1, raw * 3));
} else if (phase === 'dwell') {
const since = dwellStartedAt === null ? 0 : now - dwellStartedAt;
const handoff = 1 - clamp01(since / HANDOFF_MS);
opacity = Math.max(handoff, hoverLevel);
} else {
opacity = hoverLevel * clamp01(snapshot.opacity);
}
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
// The frame catches light first; one moving highlight resolves into a quiet face.
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
faceMaterial.opacity = opacity;
edgeMaterial.emissiveIntensity =
0.8 + Math.sin(arrival * Math.PI) * 1.4 + Math.hypot(tiltX, tiltY) * 0.6;
0.8 + Math.sin(arrival * Math.PI) * 1.4 + hoverLevel * 0.6;
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
sweepMaterial.uniforms.uOpacity.value =
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0;
phase === 'approach' && !brisk ? opacity * 0.14 : 0;
group.visible = opacity > 0.002;
group.userData.anchoring = 'gantry';
state =
opacity <= 0
? 'idle'
: snapshot.phase === 'depart'
? 'passing'
: arrival >= 0.82
? 'docked'
: 'incoming';
state = !snapshot
? 'idle'
: phase === 'depart'
? 'passing'
: phase === 'dwell' || (phase === 'approach' && (brisk || arrival >= 0.97))
? 'docked'
: 'incoming';
},
};
}
@@ -628,16 +701,6 @@ function createClippedShape(width: number, height: number, cut: number) {
}
/** Chapter accents, matching the CSS --chapter-accent of each panel. */
const SIGN_ACCENTS: Readonly<Record<SectionId, string>> = {
start: '#ff83c2',
profile: '#ff83c2',
experience: '#c4a2ff',
infrastructure: '#8be9ed',
'selected-builds': '#ffa68d',
'additional-work': '#ff83c2',
toolkit: '#c4a2ff',
contact: '#ffa68d',
};
export function signAccentFor(id: SectionId): string {
return SIGN_ACCENTS[id];
@@ -754,3 +817,79 @@ function clamp01(value: number): number {
function smoothstep(value: number): number {
return value * value * (3 - 2 * value);
}
const TRUSS_HALF_SPAN = 17;
const TRUSS_DEPTH = 1.5;
const TRUSS_PANELS = 12;
/** One instanced, untextured truss: top and bottom chords, verticals,
* diagonals and two legs. One draw call. The legs are resized every frame
* so they always reach the road. */
function createTruss(resources: SceneResourceBag) {
const material = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x1c1530,
emissive: 0x24102c,
emissiveIntensity: 0.5,
metalness: 0.72,
roughness: 0.42,
transparent: true,
opacity: 0,
}),
);
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const panel = (TRUSS_HALF_SPAN * 2) / TRUSS_PANELS;
const members: Array<[number, number, number, number, number, number]> = [];
// [x, y, length, thickness, rotationZ, isLeg]
members.push([0, TRUSS_DEPTH, TRUSS_HALF_SPAN * 2 + 0.4, 0.34, 0, 0]);
members.push([0, 0, TRUSS_HALF_SPAN * 2 + 0.4, 0.34, 0, 0]);
for (let i = 0; i <= TRUSS_PANELS; i++) {
const x = -TRUSS_HALF_SPAN + i * panel;
members.push([x, TRUSS_DEPTH / 2, TRUSS_DEPTH, 0.16, Math.PI / 2, 0]);
if (i < TRUSS_PANELS) {
const angle = Math.atan2(TRUSS_DEPTH, panel) * (i % 2 === 0 ? 1 : -1);
members.push([x + panel / 2, TRUSS_DEPTH / 2, Math.hypot(panel, TRUSS_DEPTH), 0.12, angle, 0]);
}
}
members.push([-TRUSS_HALF_SPAN, 0, 1, 0.5, Math.PI / 2, 1]);
members.push([TRUSS_HALF_SPAN, 0, 1, 0.5, Math.PI / 2, 1]);
const mesh = new THREE.InstancedMesh(box, material, members.length);
mesh.name = 'gantry-truss';
mesh.frustumCulled = false;
const group = new THREE.Group();
group.name = 'gantry-truss-group';
group.visible = false;
group.add(mesh);
const dummy = new THREE.Object3D();
const write = (legHeight: number) => {
members.forEach(([x, y, length, thickness, rotation, leg], index) => {
const height = leg ? Math.max(1, legHeight) : length;
dummy.position.set(x, leg ? -height / 2 : y, 0);
dummy.rotation.set(0, 0, rotation);
dummy.scale.set(leg ? height : length, thickness, leg ? thickness : 0.34);
dummy.updateMatrix();
mesh.setMatrixAt(index, dummy.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
};
write(10);
let lastLeg = 10;
return {
group,
material,
/** Places the truss: world y of its bottom chord and world z. */
place(y: number, z: number, opacity: number) {
group.position.set(0, y, z);
if (Math.abs(y - lastLeg) > 0.05) {
lastLeg = y;
write(y);
}
material.opacity = opacity;
group.visible = opacity > 0.001;
},
hide() {
group.visible = false;
},
};
}

View File

@@ -17,12 +17,22 @@
/* mask-position percentages resolve against (box - layer), so the cut-out
needs its own x: 50% centres it, 100% - n puts its right edge n in. */
--window-mask-x: 50%;
--window-y: calc((var(--gantry-h) - var(--window-h)) / 2);
/* The beam's centre bulges below its lower edge around the window. */
--bulge: 0px;
--window-y: calc((var(--gantry-h) + var(--bulge) - var(--window-h)) / 2);
--gantry-steel-1: #1d1230;
--gantry-steel-2: #120c22;
--gantry-rail: rgb(214 140 222 / 30%);
--gantry-rail: rgb(214 140 222 / 40%);
--site-navigation-block-size: var(--gantry-h);
}
/* Wide: a 320 x 72 window in an 8 px bulge at the centre of the beam. */
@media (min-width: 1280px) {
:root {
--window-w: 320px;
--window-h: 72px;
--bulge: 8px;
}
}
/* Mid widths: the window sits beside the controls bay. */
@media (min-width: 768px) and (max-width: 1279px) {
:root {
@@ -61,27 +71,31 @@ html .site-navigation[data-enhanced='true'][data-docked='true'] {
html .site-navigation::before,
html .site-navigation[data-enhanced='true']::before,
html .site-navigation[data-enhanced='true'][data-docked='true']::before {
inset: 0;
inset: 0 0 auto;
block-size: calc(var(--gantry-h) + var(--bulge));
transform: none;
border: 0;
border-radius: 0;
/* Rivet rails top and bottom, brushed steel between, a cut-out window. */
background:
repeating-linear-gradient(90deg, rgb(255 190 240 / 26%) 0 2px, transparent 2px 26px) 0 5px / 100% 2px no-repeat,
repeating-linear-gradient(90deg, rgb(255 190 240 / 18%) 0 2px, transparent 2px 26px) 13px calc(100% - 6px) / 100% 2px no-repeat,
linear-gradient(180deg, rgb(255 255 255 / 5%), transparent 30% 70%, rgb(0 0 0 / 30%)),
repeating-linear-gradient(90deg, rgb(255 190 240 / 40%) 0 2px, transparent 2px 26px) 0 5px / 100% 2px no-repeat,
repeating-linear-gradient(90deg, rgb(255 190 240 / 32%) 0 2px, transparent 2px 26px) 13px calc(var(--gantry-h) - 6px) / 100% 2px no-repeat,
linear-gradient(180deg, rgb(255 255 255 / 5%), transparent 30% 70%, rgb(0 0 0 / 30%)) 0 0 / 100% var(--gantry-h) no-repeat,
linear-gradient(100deg, var(--gantry-steel-1), var(--gantry-steel-2) 55%, #170f2a);
box-shadow:
0 1px 0 var(--gantry-rail),
0 14px 34px -8px rgb(6 1 16 / 70%);
/* Window hole XOR (bulge + beam). The bulge is 0 px tall below 1280. */
-webkit-mask:
linear-gradient(#000 0 0),
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat;
-webkit-mask-composite: xor;
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat,
linear-gradient(#000 0 0) 50% 0 / calc(var(--window-w) + 56px) 100% no-repeat,
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
-webkit-mask-composite: xor, source-over;
mask:
linear-gradient(#000 0 0),
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat;
mask-composite: exclude;
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat,
linear-gradient(#000 0 0) 50% 0 / calc(var(--window-w) + 56px) calc(var(--gantry-h) + var(--bulge)) no-repeat,
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
mask-composite: exclude, add;
}
/* Brand plate in the left bay (decorative; the h1 carries the name). */
@@ -258,8 +272,16 @@ html .site-navigation::after {
outline: 2px solid var(--focus);
outline-offset: 6px;
}
html[data-gantry='docked'] .gantry-window,
/* Incoming: the lip takes the arriving chapter's sign colour. Docked: amber. */
html[data-gantry='incoming'] .gantry-window {
box-shadow:
0 0 0 1px var(--sign-accent, #ff83c2),
0 0 0 4px rgb(10 5 20 / 92%),
0 0 0 5px color-mix(in srgb, var(--sign-accent, #ff83c2) 45%, transparent),
0 0 26px color-mix(in srgb, var(--sign-accent, #ff83c2) 30%, transparent),
inset 0 0 18px rgb(6 2 16 / 40%);
}
html[data-gantry='docked'] .gantry-window {
box-shadow:
0 0 0 1px rgb(255 214 147 / 70%),
0 0 0 4px rgb(10 5 20 / 92%),
@@ -267,12 +289,49 @@ html[data-gantry='incoming'] .gantry-window {
0 0 26px rgb(255 160 220 / 22%),
inset 0 0 18px rgb(6 2 16 / 40%);
}
/* A faint amber cone of light falls from the window onto the road. */
.gantry-window__cone {
position: absolute;
inset: 100% -8% auto;
block-size: clamp(90px, 14vh, 120px);
background: linear-gradient(180deg, rgb(255 214 147 / 6%), rgb(255 214 147 / 2.5%) 55%, transparent);
clip-path: polygon(6% 0, 94% 0, 100% 100%, 0 100%);
pointer-events: none;
}
/* Hover or focus names where the window drives to. */
.gantry-window__hint {
position: absolute;
inset: calc(100% + 12px) 50% auto auto;
translate: 50% -4px;
padding: 0.38rem 0.6rem;
border: 1px solid rgb(255 214 147 / 45%);
border-radius: 3px;
background: #170e29;
color: #ffe3b8;
font: 650 0.64rem/1 var(--mono);
letter-spacing: 0.14em;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease, translate 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.gantry-window__hint::before { content: attr(data-hint) / ''; }
.gantry-window:is(:hover, :focus-visible) .gantry-window__hint {
opacity: 1;
translate: 50% 0;
}
@media (max-width: 359px) {
.gantry-window { display: none; }
}
/* ---------- The dash read-out, shown through the window ---------- */
/* ---------- The flat exit plate, shown through the window ---------- */
/* Between signs, and once a sign has docked, the window shows a flat 2D
twin of the sign: the exit tab in the sign colour, the chapter, a
next-exit chevron breathing at 0.5 Hz and a route rule along its lower
edge. It steps aside only while a 3D sign is arriving, or while the
pointer calls up the 3D twin. */
html .journey-hud {
--sign-accent: #ff83c2;
position: fixed;
z-index: var(--z-hud);
inset: var(--window-y) auto auto 0;
@@ -282,25 +341,44 @@ html .journey-hud {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: 1fr auto;
align-items: center;
column-gap: 9px;
column-gap: 10px;
row-gap: 3px;
inline-size: var(--window-w);
max-inline-size: none;
block-size: var(--window-h);
min-block-size: 0;
padding: 7px 11px 6px;
padding: 8px 12px 9px;
border: 0;
border-radius: 0;
background: linear-gradient(180deg, rgb(20 10 34 / 94%), rgb(12 7 24 / 94%));
background:
linear-gradient(var(--sign-accent), var(--sign-accent)) 0 100% / calc(var(--route, 0) * 100%) 2px no-repeat,
linear-gradient(rgb(217 200 244 / 14%), rgb(217 200 244 / 14%)) 0 100% / 100% 2px no-repeat,
linear-gradient(180deg, #170b28, #0e0820);
background-color: #120a22;
box-shadow: none;
color: #cbb9de;
letter-spacing: 0.06em;
pointer-events: none;
transition: opacity 260ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
transition:
opacity 320ms cubic-bezier(0.22, 1, 0.36, 1),
transform 320ms cubic-bezier(0.22, 1, 0.36, 1),
background-size 720ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* The window's left edge, without reading the nav's own custom properties. */
@media (min-width: 1280px) {
html .journey-hud { inset-inline-start: calc(50% - var(--window-w) / 2); }
/* Wide: the plate mirrors the docked 3D face (a 4:1 face, 288 x 72 in the
window): a 42 x 52 tab, the label from 77 px, the lane rule under it,
so the 3D face hands off to it in place. */
html .journey-hud {
grid-template-rows: 1fr;
column-gap: 9px;
padding: 10px 30px 10px 26px;
background:
linear-gradient(var(--sign-accent), var(--sign-accent)) 77px calc(100% - 13px) / calc(var(--route, 0) * (100% - 107px)) 2px no-repeat,
linear-gradient(rgb(217 200 244 / 16%), rgb(217 200 244 / 16%)) 77px calc(100% - 13px) / calc(100% - 107px) 2px no-repeat,
linear-gradient(180deg, #170b28, #0e0820);
}
}
@media (min-width: 768px) and (max-width: 1279px) {
html .journey-hud { inset-inline-start: calc(100% - var(--bay-r) - var(--window-w) - 6px); }
@@ -309,124 +387,198 @@ html .journey-hud {
html .journey-hud { inset-inline-start: var(--bay-l); }
}
html .journey-hud[data-exit]::before {
content: 'EXIT\A' attr(data-exit) / '';
grid-column: 1;
grid-row: 1;
align-self: center;
font-size: 0.6rem;
grid-row: 1 / -1;
align-self: stretch;
display: grid;
place-content: center;
padding: 0 0.5rem;
clip-path: var(--exit-chamfer);
background: var(--sign-accent);
color: #160b22;
font: 760 0.56rem/1.15 var(--mono);
letter-spacing: 0.14em;
text-align: center;
white-space: pre;
}
html .journey-hud__section {
grid-column: 2;
grid-row: 1;
overflow: hidden;
max-inline-size: none;
color: #fff4fb;
font: 620 0.98rem/1 var(--sans);
color: #fff2df;
font: 650 1.02rem/1 var(--sans);
letter-spacing: -0.01em;
text-overflow: ellipsis;
white-space: nowrap;
}
html .journey-hud__section::before { display: none; }
html .journey-hud[data-clock]::after {
/* Next-exit chevron; at the destination it offers the drive again. */
html .journey-hud::after {
content: '\203A' / '';
grid-column: 3;
grid-row: 1;
padding-inline-start: 0;
padding: 0;
border: 0;
color: #a998bd;
font: 560 0.58rem/1 var(--mono);
color: var(--sign-accent);
font: 500 1.5rem/1 var(--sans);
}
html .journey-hud__route {
grid-column: 1 / -1;
grid-row: 2;
gap: 0.35rem;
html .journey-hud[data-arrived='true']::after {
content: 'AGAIN \21BA' / '';
color: var(--focus);
font: 700 0.56rem/1 var(--mono);
letter-spacing: 0.14em;
}
@media (prefers-reduced-motion: no-preference) {
html .journey-hud:not([data-arrived='true'])::after {
animation: hud-chevron 2s ease-in-out infinite;
}
}
@keyframes hud-chevron {
0%, 100% { opacity: 0.45; transform: translateX(0); }
50% { opacity: 1; transform: translateX(3px); }
}
@media (min-width: 1280px) {
html .journey-hud[data-exit]::before { inline-size: 42px; padding: 0; font-size: 0.6rem; }
/* The face fits its label to 1250 of 2048 px, from 236 down to 124 px; in
the window that is about 314 px / characters, clamped to 17-33 px. */
html .journey-hud__section {
font-size: clamp(17px, calc(314px / var(--label-chars, 10)), 33px);
padding-block-end: 6px;
}
}
/* The route figures stay in the text (tests and the SSR read them) but the
plate shows progress as the rule along its lower edge, like the sign. */
html .journey-hud__route,
html .journey-hud__arrival {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
color: #a998bd;
font: 560 0.56rem/1 var(--mono);
letter-spacing: 0.12em;
clip-path: inset(50%);
white-space: nowrap;
}
html .journey-hud__route span { font-size: inherit; color: inherit; display: inline; }
html .journey-hud__route strong { color: var(--focus); font-size: inherit; }
html .journey-hud__route[data-next]::after {
content: '\00b7 ' attr(data-next) / '';
overflow: hidden;
color: #d6c3ea;
text-overflow: ellipsis;
white-space: pre;
}
html .journey-hud__arrival {
grid-column: 3;
grid-row: 2;
display: block;
color: var(--focus);
font: 720 0.56rem/1 var(--mono);
letter-spacing: 0.16em;
}
/* The read-out yields to the docked sign and returns once it has passed. */
html[data-gantry='incoming'] .journey-hud,
html[data-gantry='docked'] .journey-hud,
html[data-gantry='passing'] .journey-hud {
/* The plate yields while a 3D sign arrives, and to the pointer's 3D twin. */
html[data-gantry='incoming'] .journey-hud {
opacity: 0;
transform: translateY(-6px);
}
@media (hover: hover) {
html[data-gantry]:has(.gantry-window:hover) .journey-hud { opacity: 0; }
}
@media (max-width: 767px) {
html .journey-hud {
grid-template-rows: 1fr;
padding: 0 8px;
padding: 4px 8px 5px 4px;
column-gap: 7px;
}
html .journey-hud__route,
html .journey-hud__arrival,
html .journey-hud[data-clock]::after { display: none; }
html .journey-hud[data-exit]::before { display: block; font-size: 0.56rem; }
html .journey-hud[data-exit]::before { content: attr(data-exit) / ''; display: grid; font-size: 0.6rem; padding: 0 0.4rem; }
html .journey-hud__section { font-size: 0.82rem; }
html .journey-hud::after { font-size: 1.15rem; }
html .journey-hud[data-arrived='true']::after { content: '\21BA' / ''; font-size: 0.9rem; }
}
@media (max-width: 359px) {
html .journey-hud { display: none; }
}
/* ---------- Drive controls in the right bay ---------- */
/* Two monochrome lilac tiles: the soundtrack (a three-bar equaliser that
moves, at most 1 Hz, while it plays) and the settings. Each button stays
a 44 px target; the tile inside it is drawn 36 px. Active is amber. */
html .drive-controls {
--tile-ink: #d9c8f4;
--tile-face: #1b1130;
--tile-edge: rgb(217 200 244 / 30%);
position: fixed;
z-index: var(--z-popover);
inset: calc((var(--gantry-h) - 44px) / 2) max(12px, env(safe-area-inset-right)) auto auto;
display: flex;
align-items: center;
margin: 0;
inline-size: auto;
max-inline-size: none;
gap: 4px;
gap: 0;
}
html .drive-controls__dock {
padding: 0;
gap: 2px;
gap: 0;
border: 0;
border-radius: 0;
background: none;
background-color: var(--gantry-steel-2);
box-shadow: none;
}
html .drive-start,
html .drive-control-button,
html .drive-controls__settings-trigger {
border: 1px solid rgb(214 140 222 / 26%);
border-radius: 4px;
background: rgb(24 14 40 / 92%);
display: inline-grid;
place-items: center;
inline-size: 44px;
min-inline-size: 44px;
block-size: 44px;
min-block-size: 44px;
padding: 0;
border: 0;
border-radius: 0;
background:
linear-gradient(var(--tile-face), var(--tile-face)) center / 34px 34px no-repeat,
linear-gradient(var(--tile-edge), var(--tile-edge)) center / 36px 36px no-repeat;
box-shadow: none;
color: #d9c9e6;
color: var(--tile-ink);
letter-spacing: 0;
transition: color 160ms ease, background-size 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
html .drive-control-button:hover {
border-color: rgb(255 160 220 / 55%);
background: #2f1c45;
color: #ffd1ec;
html .drive-start:hover:not(:disabled),
html .drive-control-button:hover,
html .drive-controls__settings-trigger:hover {
--tile-edge: rgb(217 200 244 / 62%);
--tile-face: #261843;
transform: none;
color: #f1e8ff;
}
html .drive-start {
min-inline-size: 0;
gap: 10px;
padding-inline: 12px 14px;
border-radius: 4px;
font-size: 0.6rem;
letter-spacing: 0.12em;
html .drive-control-button:focus-visible,
html .drive-start:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
@media (max-width: 1279px) {
html .drive-start { inline-size: 44px; padding: 0; justify-content: center; }
html .drive-start > span { display: none; }
html .drive-controls__settings-trigger[aria-expanded='true'],
html .drive-controls[data-drive-state='running'][data-muted='false'] .drive-sound {
--tile-edge: rgb(255 214 147 / 70%);
color: var(--focus);
}
/* Tooltips and popovers open downward from the beam. */
html .drive-control-button svg { inline-size: 18px; block-size: 18px; }
/* The equaliser glyph. */
.drive-eq {
display: inline-flex;
align-items: flex-end;
gap: 3px;
block-size: 14px;
}
.drive-eq i {
inline-size: 3px;
block-size: 100%;
border-radius: 1px;
background: currentColor;
transform-origin: bottom;
}
.drive-eq i:nth-child(1) { transform: scaleY(0.55); }
.drive-eq i:nth-child(3) { transform: scaleY(0.75); }
.drive-controls[data-muted='true'] .drive-eq i { transform: scaleY(0.22); }
@media (prefers-reduced-motion: no-preference) {
.drive-controls[data-drive-state='running'][data-muted='false'] .drive-eq i {
animation: drive-eq 1s ease-in-out infinite alternate;
}
.drive-controls[data-drive-state='running'][data-muted='false'] .drive-eq i:nth-child(2) { animation-delay: -0.35s; }
.drive-controls[data-drive-state='running'][data-muted='false'] .drive-eq i:nth-child(3) { animation-delay: -0.7s; }
}
@keyframes drive-eq {
from { transform: scaleY(0.3); }
to { transform: scaleY(1); }
}
/* Tooltips and the settings popover open downward from the beam. */
html .drive-control-button::after {
inset: calc(100% + 0.5rem) auto auto 50%;
transform: translate(-50%, -4px);
@@ -437,9 +589,49 @@ html .drive-control-button:focus-visible::after {
}
html .drive-popover {
position: absolute;
inset: calc(100% + 0.7rem) 0 auto auto;
inset: calc(100% + 10px) 6px auto auto;
min-inline-size: 16rem;
max-inline-size: calc(100vw - 1rem);
border: 1px solid rgb(217 200 244 / 26%);
border-radius: 4px;
background: #170e29;
box-shadow: 0 22px 48px -18px rgb(4 1 12 / 85%);
transform-origin: right top;
animation: drive-popover-drop 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
html .drive-popover strong { color: var(--focus); border-color: rgb(217 200 244 / 16%); }
html .drive-popover input[type='range'] { accent-color: var(--focus); }
html .drive-popover output { color: var(--focus); }
html .drive-popover select { border-color: rgb(217 200 244 / 26%); background: #10091e; }
.drive-popover__volume {
display: grid;
grid-template-columns: minmax(7rem, 1fr) auto;
align-items: center;
gap: 0.6rem;
}
.drive-popover__action {
grid-column: 1 / -1;
display: inline-flex;
align-items: center;
gap: 0.6rem;
min-block-size: 44px;
padding-inline: 0.8rem;
border: 1px solid rgb(217 200 244 / 26%);
border-radius: 3px;
background: #1f1436;
color: #e6d8fa;
font: 620 0.64rem/1 var(--mono);
letter-spacing: 0.12em;
cursor: pointer;
}
.drive-popover__action:hover { border-color: rgb(217 200 244 / 60%); }
.drive-popover__action:focus-visible,
html .drive-popover select:focus-visible,
html .drive-popover input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.drive-popover__action svg { inline-size: 16px; block-size: 16px; }
@keyframes drive-popover-drop {
from { opacity: 0; transform: translateY(-8px) scale(0.98); }
to { opacity: 1; transform: none; }
}
html .drive-controls__audio-status {
position: absolute;
@@ -451,10 +643,30 @@ html .drive-controls__audio-status {
border: 1px solid rgb(214 140 222 / 32%);
border-radius: 4px;
}
/* Phones: the settings rise as a bottom sheet. */
@media (max-width: 767px) {
html .drive-popover {
position: fixed;
inset: auto 0 0;
min-inline-size: 0;
max-inline-size: none;
padding: 1rem max(1rem, env(safe-area-inset-right)) max(1.2rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
border-width: 1px 0 0;
border-radius: 12px 12px 0 0;
transform-origin: bottom;
animation-name: drive-sheet-rise;
}
}
@keyframes drive-sheet-rise {
from { transform: translateY(100%); }
to { transform: none; }
}
@media (max-height: 420px) {
html .drive-controls { position: fixed; inset: calc((var(--gantry-h) - 44px) / 2) 12px auto auto; margin: 0; }
html .journey-hud { display: grid; }
html .drive-popover { inset: calc(100% + 0.5rem) 0 auto auto; }
}
@media (prefers-reduced-motion: reduce) {
html .drive-popover { animation: none; }
}
/* ---------- Compact screens ---------- */
@@ -486,6 +698,82 @@ html .drive-controls__audio-status {
}
}
/* ---------- The interlude under the gantry ---------- */
/* While a sign approaches (environment.ts sets data-interlude='on'), the
reading plates step up and out in 260 ms, so the road and the sign have
the frame to themselves; then ('rise') they come back up from below in
sequence. Headings only fade: journey and audio measure them. */
@media (prefers-reduced-motion: no-preference) {
html[data-interlude='on'] .reading-stage .portfolio-main > * > * {
opacity: 0;
transition: opacity 260ms cubic-bezier(0.5, 0, 0.75, 0);
}
html[data-interlude='on'] .reading-stage .portfolio-main > * > :not(h1, h2, :has(h1, h2)) {
transform: translateY(-24px);
transition:
opacity 260ms cubic-bezier(0.5, 0, 0.75, 0),
transform 260ms cubic-bezier(0.5, 0, 0.75, 0);
}
/* The exit tag's strike-on animation restarts as the plates return. */
html[data-interlude='on'] .reading-stage .portfolio-section::before,
html[data-interlude='on'] .reading-stage .portfolio-section[data-exit='08']::after {
opacity: 0;
animation: none;
transition: opacity 260ms cubic-bezier(0.5, 0, 0.75, 0);
}
html[data-interlude='rise'] .reading-stage .portfolio-section[data-exit='08']::after,
html[data-interlude='rise'] .reading-stage .portfolio-main > .portfolio-section > *,
html[data-interlude='rise'] .reading-stage .portfolio-main > .hero-journey > * {
animation: plate-fade 720ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
html[data-interlude='rise'] .reading-stage .portfolio-main > .portfolio-section > :not(h1, h2, :has(h1, h2)),
html[data-interlude='rise'] .reading-stage .portfolio-main > .hero-journey > :not(h1, h2, :has(h1, h2)) {
animation: plate-rise 720ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* The chapter body keeps its light-edge sweep alongside the rise. */
html[data-interlude='rise'] .reading-stage .portfolio-main > .portfolio-section[data-current] > .chapter-body {
animation:
plate-rise 720ms cubic-bezier(0.22, 1, 0.36, 1) both,
exit-panel-light 1.6s cubic-bezier(0.2, 0.75, 0.2, 1) both;
}
html[data-interlude='rise'] .reading-stage .portfolio-main > * > :nth-child(2) { animation-delay: 90ms; }
html[data-interlude='rise'] .reading-stage .portfolio-main > * > :nth-child(3) { animation-delay: 180ms; }
html[data-interlude='rise'] .reading-stage .portfolio-main > * > :nth-child(n + 4) { animation-delay: 270ms; }
}
@keyframes plate-rise {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: none; }
}
@keyframes plate-fade {
from { opacity: 0; }
to { opacity: 1; }
}
/* ---------- The current exit carries a ghost of its sign colour ---------- */
@media (min-width: 768px) {
html .site-navigation li[data-route='here'] a::after {
content: '' / '';
position: absolute;
inset: auto calc(10% - 22px) 9px auto;
inline-size: 18px;
block-size: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--sign-accent, #ff83c2) 55%, transparent);
pointer-events: none;
}
}
@media (forced-colors: active) {
html .site-navigation li[data-route='here'] a::after { display: none; }
}
/* ---------- Retired: the end-of-chapter plaque and the resume chip ---------- */
/* The window names the next exit and offers the resume, so the advance
plaque under each chapter goes; the destination keeps END OF ROUTE. */
html .reading-stage .portfolio-section[data-exit]:not([data-exit='08'])::after,
html .portfolio-section[data-exit]:not([data-exit='08'])::after {
content: none;
}
/* ---------- Content lands right under the beam ---------- */
html[data-expressway='live'] .portfolio-section,
html[data-expressway='live'] #contact,

View File

@@ -289,12 +289,13 @@ describe("DriveControls", () => {
});
expect(wrapper.get('[aria-label="Mute sound"]')).toBeTruthy();
expect(wrapper.get('[aria-label="Sound volume"]')).toBeTruthy();
expect(wrapper.get('[aria-label="Pause motion"]')).toBeTruthy();
const settings = wrapper.get('[aria-label="Drive settings"]');
await settings.trigger("click");
// Volume and Pause motion live in the settings panel (two-button bay).
const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]');
expect(panel.get('[aria-label="Sound volume level"]')).toBeTruthy();
expect(panel.get('[aria-label="Pause motion"]')).toBeTruthy();
expect(panel.text()).not.toContain("Motion: active");
expect(panel.find('[role="status"]').exists()).toBe(false);
expect(panel.get("select").attributes("aria-label")).toBe(

View File

@@ -98,21 +98,26 @@ describe('composed route signs', () => {
const allocation = h.resources.snapshot();
h.render(null);
expect(h.sign.group.visible).toBe(false);
// Between signs the face holds the resting chapter's label.
const resting = face.userData.textureUpdateCount;
h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 });
expect(h.sign.group.visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.userData.textureUpdateCount).toBe(resting + 1);
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 0.7 });
expect(h.sign.group.visible).toBe(true);
expect(face.material.opacity).toBeGreaterThan(0);
expect(face.material.opacity).toBeLessThanOrEqual(0.7);
expect(face.material.opacity).toBeLessThanOrEqual(1);
// Docked, the 3D face hands off to the window's flat plate in 200 ms.
h.render(profile, 120);
expect(face.material.opacity).toBe(1);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.material.opacity).toBe(0);
expect(h.sign.group.visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(resting + 1);
expect(h.resources.snapshot()).toEqual(allocation);
h.render(null);
expect(h.sign.group.visible).toBe(false);
const between = face.userData.textureUpdateCount;
h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 });
expect(face.userData.textureUpdateCount).toBe(2);
expect(face.userData.textureUpdateCount).toBe(between + 1);
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
expect(h.resources.snapshot()).toEqual(allocation);
h.resources.dispose();
@@ -155,46 +160,57 @@ describe('composed route signs', () => {
expectDocked(settled, h.dock);
});
it('lifts off the horizon and grows into the window while approaching', () => {
it('closes in over the road in the upper third, then arcs up into the window', () => {
const h = setup();
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds;
const late = h.render({ ...profile, phase: 'approach', progress: 0.7, opacity: 1 })!.bounds;
const big = h.render({ ...profile, phase: 'approach', progress: 0.69, opacity: 1 })!.bounds;
const arcing = h.render({ ...profile, phase: 'approach', progress: 0.85, opacity: 1 })!.bounds;
const docked = h.render(profile)!.bounds;
expect(h.sign.dockState()).toBe('docked');
expect(early.width).toBeLessThan(late.width);
expect(late.width).toBeLessThan(docked.width + 1);
expect(early.top).toBeGreaterThan(late.top);
expect(late.top).toBeGreaterThan(docked.top - 1);
expect(early.top).toBeGreaterThan(900 * 0.3);
// Grows down the road to about 600 px, centred in the upper third.
expect(early.width).toBeLessThan(big.width);
expect(big.width).toBeGreaterThan(560);
expect(big.width).toBeLessThan(640);
expect((big.top + big.bottom) / 2).toBeGreaterThan(900 * 0.2);
expect((big.top + big.bottom) / 2).toBeLessThan(900 * 0.4);
// Then shrinks and rises into the window, landing within 2 px.
expect(arcing.width).toBeLessThan(big.width);
expect(arcing.top).toBeLessThan(big.top);
expectDocked(docked, h.dock);
});
it('passes overhead and out of the top of the frame as it departs', () => {
it('leaves the window to the flat plate as it departs', () => {
const h = setup();
const docked = h.render(profile, 120)!.bounds;
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 })!.bounds;
h.render(profile, 120);
// The flat plate holds the window; the 3D face stays hidden as it departs.
h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 });
expect(h.sign.dockState()).toBe('passing');
expect(passing.top).toBeLessThan(docked.top);
expect(passing.width).toBeGreaterThan(docked.width);
const gone = h.render({ ...profile, phase: 'depart', progress: 1, opacity: 0.05 })!.bounds;
expect(gone.bottom).toBeLessThan(docked.bottom);
expect(h.sign.group.visible).toBe(false);
h.render(null);
expect(h.sign.group.visible).toBe(false);
expect(h.sign.dockState()).toBe('idle');
});
it('turns toward the pointer only while docked, and settles back', () => {
it('turns toward the pointer only while docked and hovered, and settles back', () => {
const h = setup();
h.render(profile, 120);
const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } };
const rest = group.quaternion.clone();
// Without hover the plate stays flat: no tilt.
h.setTilt({ x: 1, y: -1 });
h.render(profile, 60);
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.2);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
// Hovered, the 3D twin appears and leans at most about 4 degrees.
h.setTilt({ x: 1, y: -1, hover: true });
h.render(profile, 60);
expect(h.sign.group.visible).toBe(true);
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.05);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.11);
h.setTilt({ x: 0, y: 0 });
h.render(profile, 120);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
h.setTilt({ x: 1, y: 1 });
h.setTilt({ x: 1, y: 1, hover: true });
h.render(null);
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
const approaching = group.quaternion.clone();