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:
@@ -110,3 +110,36 @@ content had to stay out of their way.
|
|||||||
it, so no light ever passes the car.
|
it, so no light ever passes the car.
|
||||||
- A unit test pins both rules.
|
- A unit test pins both rules.
|
||||||
- The car's own lamps and the wake stay parented to the car, as before.
|
- 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 |
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import ToolkitGrid from './ToolkitGrid.vue';
|
|||||||
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
||||||
import { useAudioEngine } from '@/composables/useAudioEngine';
|
import { useAudioEngine } from '@/composables/useAudioEngine';
|
||||||
import { useAudioIntent } from '@/composables/useAudioIntent';
|
import { useAudioIntent } from '@/composables/useAudioIntent';
|
||||||
|
import { useCardEntrances } from '@/composables/useCardEntrances';
|
||||||
import { useJourneySections } from '@/composables/useJourneySections';
|
import { useJourneySections } from '@/composables/useJourneySections';
|
||||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||||
@@ -80,6 +81,7 @@ const journeyBinding = useJourneySections(
|
|||||||
computed(() => motion.policy.value.allowSceneRequest),
|
computed(() => motion.policy.value.allowSceneRequest),
|
||||||
);
|
);
|
||||||
const journey = journeyBinding.snapshot;
|
const journey = journeyBinding.snapshot;
|
||||||
|
useCardEntrances();
|
||||||
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
||||||
const resume = computed(() => {
|
const resume = computed(() => {
|
||||||
const id = route.resumeFrom.value;
|
const id = route.resumeFrom.value;
|
||||||
@@ -262,6 +264,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
:items="canonicalNavigation"
|
:items="canonicalNavigation"
|
||||||
:journey="journey"
|
:journey="journey"
|
||||||
:visited="route.visited.value"
|
:visited="route.visited.value"
|
||||||
|
:resume-id="resume?.id"
|
||||||
:allow-motion="motion.policy.value.allowSceneRequest"
|
:allow-motion="motion.policy.value.allowSceneRequest"
|
||||||
:on-section-link-click="navigation.onSectionLinkClick"
|
:on-section-link-click="navigation.onSectionLinkClick"
|
||||||
/>
|
/>
|
||||||
@@ -270,7 +273,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
<div class="reading-stage">
|
<div class="reading-stage">
|
||||||
<div class="reading-viewport">
|
<div class="reading-viewport">
|
||||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
<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')">
|
<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>
|
<p>{{ content.profile.introduction }}</p>
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const controlRoot = ref<HTMLElement | null>(null);
|
const controlRoot = ref<HTMLElement | null>(null);
|
||||||
const settingsButton = ref<HTMLButtonElement | 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 settingsOpen = ref(false);
|
||||||
const volumeOpen = ref(false);
|
|
||||||
|
|
||||||
const action = computed(() => {
|
const action = computed(() => {
|
||||||
if (props.policy.reason === "visitor-pause") {
|
if (props.policy.reason === "visitor-pause") {
|
||||||
@@ -123,21 +122,28 @@ const changeVolume = (event: Event): void => {
|
|||||||
|
|
||||||
const toggleSettings = (): void => {
|
const toggleSettings = (): void => {
|
||||||
settingsOpen.value = !settingsOpen.value;
|
settingsOpen.value = !settingsOpen.value;
|
||||||
volumeOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleVolume = (): void => {
|
|
||||||
volumeOpen.value = !volumeOpen.value;
|
|
||||||
settingsOpen.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closePanels = (restoreFocus = false): void => {
|
const closePanels = (restoreFocus = false): void => {
|
||||||
const focusTarget = settingsOpen.value
|
const wasOpen = settingsOpen.value;
|
||||||
? settingsButton.value
|
|
||||||
: volumeButton.value;
|
|
||||||
settingsOpen.value = false;
|
settingsOpen.value = false;
|
||||||
volumeOpen.value = false;
|
if (restoreFocus && wasOpen) void nextTick(() => settingsButton.value?.focus());
|
||||||
if (restoreFocus) void nextTick(() => focusTarget?.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 => {
|
const onDocumentPointerDown = (event: Event): void => {
|
||||||
@@ -146,12 +152,22 @@ const onDocumentPointerDown = (event: Event): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (event.key !== "Escape" || (!settingsOpen.value && !volumeOpen.value))
|
trapFocus(event);
|
||||||
return;
|
if (event.key !== "Escape" || !settingsOpen.value) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
closePanels(true);
|
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(
|
watch(
|
||||||
() => props.audioState.kind,
|
() => props.audioState.kind,
|
||||||
() => closePanels(),
|
() => closePanels(),
|
||||||
@@ -173,28 +189,33 @@ onBeforeUnmount(() => {
|
|||||||
ref="controlRoot"
|
ref="controlRoot"
|
||||||
class="drive-controls"
|
class="drive-controls"
|
||||||
:data-drive-state="audioState.kind"
|
: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
|
<button
|
||||||
v-if="audioState.kind === 'idle'"
|
v-if="audioState.kind === 'idle'"
|
||||||
class="drive-start"
|
class="drive-start drive-control-button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Play soundtrack"
|
aria-label="Play soundtrack"
|
||||||
|
data-tooltip="Play soundtrack"
|
||||||
@click="emit('audioStart')"
|
@click="emit('audioStart')"
|
||||||
>
|
>
|
||||||
<IconGlyph name="play" />
|
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
<span>PLAY SOUNDTRACK</span>
|
<span class="visually-hidden">PLAY SOUNDTRACK</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-else-if="audioState.kind === 'starting'"
|
v-else-if="audioState.kind === 'starting'"
|
||||||
class="drive-start"
|
class="drive-start drive-control-button"
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
aria-label="Starting drive"
|
aria-label="Starting drive"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<IconGlyph name="play" />
|
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
<span>STARTING…</span>
|
<span class="visually-hidden">STARTING…</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
@@ -225,45 +246,20 @@ onBeforeUnmount(() => {
|
|||||||
<IconGlyph name="play" />
|
<IconGlyph name="play" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="drive-control-button"
|
v-else
|
||||||
|
class="drive-control-button drive-sound"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="muteLabel"
|
:aria-label="muteLabel"
|
||||||
:title="muteLabel"
|
:title="muteLabel"
|
||||||
:data-tooltip="muteLabel"
|
:data-tooltip="muteLabel"
|
||||||
@click="emit('audioMuteToggle')"
|
@click="emit('audioMuteToggle')"
|
||||||
>
|
>
|
||||||
<IconGlyph :name="muteIcon" />
|
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
</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" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="action && !isAudioActive && action.event !== 'pause'"
|
v-if="action && action.event !== 'pause'"
|
||||||
class="drive-control-button"
|
class="drive-control-button"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="action.label"
|
:aria-label="action.label"
|
||||||
@@ -297,35 +293,41 @@ onBeforeUnmount(() => {
|
|||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</p>
|
</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
|
<div
|
||||||
v-if="settingsOpen"
|
v-if="settingsOpen"
|
||||||
id="drive-settings-panel"
|
id="drive-settings-panel"
|
||||||
|
ref="settingsPanel"
|
||||||
class="drive-popover drive-popover--settings"
|
class="drive-popover drive-popover--settings"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Drive settings"
|
aria-label="Drive settings"
|
||||||
>
|
>
|
||||||
<strong>DRIVE SETTINGS</strong>
|
<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>
|
<label for="drive-quality">QUALITY</label>
|
||||||
<select
|
<select
|
||||||
id="drive-quality"
|
id="drive-quality"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
import { bucharestClock } from '@/lib/operations';
|
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
journey: JourneySnapshot;
|
journey: JourneySnapshot;
|
||||||
@@ -13,24 +13,27 @@ const props = defineProps<{
|
|||||||
|
|
||||||
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
|
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
|
||||||
const next = computed(() =>
|
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 window's flat exit plate: exit tab, chapter, a next-exit chevron, and
|
||||||
|
a route rule that fills along its lower edge. */
|
||||||
// The dash clock is decorative and CSS-generated, so SSR and hydrated text stay identical.
|
const route = computed(() => Math.min(1, Math.max(0, props.journey.routeProgress)));
|
||||||
const clock = shallowRef<string | undefined>(undefined);
|
const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
|
||||||
let timer = 0;
|
|
||||||
onMounted(() => {
|
|
||||||
const tick = (): void => { clock.value = bucharestClock(new Date()); };
|
|
||||||
tick();
|
|
||||||
timer = window.setInterval(tick, 30_000);
|
|
||||||
});
|
|
||||||
onUnmounted(() => window.clearInterval(timer));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
|
||||||
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
||||||
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import type { SectionId } from "@/content/types";
|
import type { SectionId } from "@/content/types";
|
||||||
import type { JourneySnapshot } from "@/journey/types";
|
import type { JourneySnapshot } from "@/journey/types";
|
||||||
|
import { SIGN_ACCENTS } from "@/journey/sign-routes";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||||
@@ -9,6 +10,8 @@ const props = defineProps<{
|
|||||||
/** Exits passed in this browser (client-only memory). */
|
/** Exits passed in this browser (client-only memory). */
|
||||||
visited?: ReadonlySet<SectionId>;
|
visited?: ReadonlySet<SectionId>;
|
||||||
allowMotion?: boolean;
|
allowMotion?: boolean;
|
||||||
|
/** A returning visitor's last exit: the window offers it from the start. */
|
||||||
|
resumeId?: SectionId | undefined;
|
||||||
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -93,18 +96,34 @@ onBeforeUnmount(() => {
|
|||||||
compactQuery = null;
|
compactQuery = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The gantry's sign window drives on to the next exit (or back to the
|
/** The gantry's sign window drives on to the next exit, resumes a returning
|
||||||
* start from the destination), exactly like following that exit's link. */
|
* visitor's drive from the start, or goes back to the start from the
|
||||||
const nextIndex = computed(() =>
|
* destination, exactly like following that exit's link. */
|
||||||
activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1,
|
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 windowLabel = computed(() => {
|
||||||
const next = props.items[nextIndex.value];
|
const next = props.items[nextIndex.value];
|
||||||
if (next === undefined) return "Drive on";
|
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
|
return nextIndex.value === 0
|
||||||
? `Back to the start: ${next.label}`
|
? `Drive again from the start: ${next.label}`
|
||||||
: `Drive to the next exit: ${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 => {
|
const driveOn = (): void => {
|
||||||
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
|
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
|
||||||
};
|
};
|
||||||
@@ -117,7 +136,7 @@ const navigate = (event: MouseEvent): void => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<details ref="disclosure" class="site-navigation__disclosure" open>
|
||||||
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
|
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
|
||||||
<ul ref="list">
|
<ul ref="list">
|
||||||
@@ -138,8 +157,7 @@ const navigate = (event: MouseEvent): void => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="gantry-window"
|
class="gantry-window"
|
||||||
:aria-label="windowLabel"
|
:aria-label="windowLabel"
|
||||||
:title="windowLabel"
|
|
||||||
@click="driveOn"
|
@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>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ export interface SignRouteSpec {
|
|||||||
dwellOffsetX?: number;
|
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({
|
export const SIGN_LIFECYCLE = Object.freeze({
|
||||||
anticipateMs: 300,
|
anticipateMs: 300,
|
||||||
approachMs: 900,
|
approachMs: 900,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
car.group,
|
car.group,
|
||||||
speedWake.group,
|
speedWake.group,
|
||||||
sign.group,
|
sign.group,
|
||||||
|
sign.truss,
|
||||||
);
|
);
|
||||||
|
|
||||||
const postProcessor = quality.postEffects
|
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 outro mirrors the intro: on arrival the camera cranes back up toward
|
||||||
// the sun on a long lens. Leaving the destination reverses it, faster.
|
// the sun on a long lens. Leaving the destination reverses it, faster.
|
||||||
let outroProgress = 0;
|
let outroProgress = 0;
|
||||||
|
let interludeOn = false;
|
||||||
|
let riseUntil = 0;
|
||||||
let gantryState = '';
|
let gantryState = '';
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
@@ -373,7 +376,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
car.group.position,
|
car.group.position,
|
||||||
width < 1100 || height < 620 ? 2.32 : 2.78,
|
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(
|
atmosphere.update(
|
||||||
driveDistance,
|
driveDistance,
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
@@ -568,6 +572,18 @@ export async function createProceduralSceneGeneration(
|
|||||||
gantryState = gantry;
|
gantryState = gantry;
|
||||||
document.documentElement.dataset.gantry = 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) {
|
if (TEST_BUILD && snapshot.sign !== null) {
|
||||||
const presentation = sign.inspect(camera, width, height);
|
const presentation = sign.inspect(camera, width, height);
|
||||||
if (presentation !== null) {
|
if (presentation !== null) {
|
||||||
@@ -603,6 +619,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
delete document.documentElement.dataset.gantry;
|
delete document.documentElement.dataset.gantry;
|
||||||
|
delete document.documentElement.dataset.interlude;
|
||||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||||
// defaults before the next renderer creates its empty array textures.
|
// defaults before the next renderer creates its empty array textures.
|
||||||
const gl = renderer.getContext();
|
const gl = renderer.getContext();
|
||||||
@@ -617,6 +634,8 @@ const INTRO_SECONDS = 4.2;
|
|||||||
const OUTRO_SECONDS = 5.5;
|
const OUTRO_SECONDS = 5.5;
|
||||||
const OUTRO_RETURN_SECONDS = 1.6;
|
const OUTRO_RETURN_SECONDS = 1.6;
|
||||||
const OUTRO_REACH = 0.62;
|
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 {
|
function easeInOutCubic(t: number): number {
|
||||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
|||||||
@@ -100,10 +100,12 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
const rect = windowElement.getBoundingClientRect();
|
const rect = windowElement.getBoundingClientRect();
|
||||||
tilt.x = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width) * 2 - 1));
|
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));
|
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 });
|
}, { passive: true });
|
||||||
resources.listen(windowElement, 'pointerleave', () => {
|
resources.listen(windowElement, 'pointerleave', () => {
|
||||||
tilt.x = 0;
|
tilt.x = 0;
|
||||||
tilt.y = 0;
|
tilt.y = 0;
|
||||||
|
tilt.hover = false;
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
const rect = windowElement?.getBoundingClientRect();
|
const rect = windowElement?.getBoundingClientRect();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import type { SignSnapshot } from '@/journey/types';
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
import {
|
import {
|
||||||
|
SIGN_ACCENTS,
|
||||||
signRouteFor,
|
signRouteFor,
|
||||||
signViewportProfileFor,
|
signViewportProfileFor,
|
||||||
type SignCorridor,
|
type SignCorridor,
|
||||||
@@ -25,10 +26,13 @@ export interface SignDock {
|
|||||||
height: number;
|
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 {
|
export interface SignTilt {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
hover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the gantry shows: its own 2D read-out, an incoming sign, the
|
/** 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 REST_TILT: SignTilt = { x: 0, y: 0 };
|
||||||
const FACE_Z = 0.54;
|
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 =
|
const SIGN_MARKER_FONT =
|
||||||
'600 60px "Geist Mono Variable", ui-monospace, monospace';
|
'600 60px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
||||||
export interface SignWorld {
|
export interface SignWorld {
|
||||||
group: SignGroup;
|
group: SignGroup;
|
||||||
|
/** The overhead truss the sign hangs from on its approach. */
|
||||||
|
truss: unknown;
|
||||||
inspect(
|
inspect(
|
||||||
camera: ProjectingCamera,
|
camera: ProjectingCamera,
|
||||||
width: number,
|
width: number,
|
||||||
@@ -57,9 +71,13 @@ export interface SignWorld {
|
|||||||
tilt?: SignTilt,
|
tilt?: SignTilt,
|
||||||
): void;
|
): void;
|
||||||
dockState(): GantryState;
|
dockState(): GantryState;
|
||||||
|
/** True while a full interlude runs: the reading plates make way for it. */
|
||||||
|
interlude(): boolean;
|
||||||
setViewportAspect(aspect: number): void;
|
setViewportAspect(aspect: number): void;
|
||||||
setViewportSize(width: number, height: 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 {
|
export interface SignPresentation {
|
||||||
@@ -385,6 +403,23 @@ export function createSign(
|
|||||||
let tiltX = 0;
|
let tiltX = 0;
|
||||||
let tiltY = 0;
|
let tiltY = 0;
|
||||||
let state: GantryState = 'idle';
|
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 point = new THREE.Vector3();
|
||||||
const ray = new THREE.Vector3();
|
const ray = new THREE.Vector3();
|
||||||
const forward = new THREE.Vector3();
|
const forward = new THREE.Vector3();
|
||||||
@@ -417,6 +452,7 @@ export function createSign(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
|
truss: truss.group,
|
||||||
inspect(camera, width, height) {
|
inspect(camera, width, height) {
|
||||||
if (target === null) return null;
|
if (target === null) return null;
|
||||||
group.updateMatrixWorld(true);
|
group.updateMatrixWorld(true);
|
||||||
@@ -463,41 +499,40 @@ export function createSign(
|
|||||||
viewportHeight = Math.max(1, height);
|
viewportHeight = Math.max(1, height);
|
||||||
viewport = signViewportProfileFor(width, 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;
|
snapshot = sign;
|
||||||
group.userData.phase = sign?.phase ?? 'inactive';
|
group.userData.phase = sign?.phase ?? 'inactive';
|
||||||
group.userData.target = sign?.targetId ?? null;
|
group.userData.target = sign?.targetId ?? null;
|
||||||
|
if (sign?.phase === 'dwell' && dwellStartedAt === null) dwellStartedAt = now;
|
||||||
if (!sign) {
|
if (!sign) {
|
||||||
group.visible = false;
|
|
||||||
initialized = false;
|
|
||||||
state = 'idle';
|
state = 'idle';
|
||||||
|
truss.hide();
|
||||||
|
drawTarget(resting);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target !== sign.targetId) {
|
drawTarget(sign.targetId);
|
||||||
target = sign.targetId;
|
},
|
||||||
face.userData.textureUpdateCount += 1;
|
setResting(id) {
|
||||||
face.userData.fontReadyWhenDrawn =
|
resting = id;
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dockState() {
|
dockState() {
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
|
interlude() {
|
||||||
|
return !brisk && state === 'incoming';
|
||||||
|
},
|
||||||
compose(camera, width, height, dock, now, tilt = REST_TILT) {
|
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.
|
// No window on the narrowest screens: the gantry shows no sign there.
|
||||||
if (dock.width < 40 || dock.height < 10) {
|
if (dock.width < 40 || dock.height < 10) {
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
|
truss.hide();
|
||||||
state = 'idle';
|
state = 'idle';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -509,82 +544,120 @@ export function createSign(
|
|||||||
.transformDirection(
|
.transformDirection(
|
||||||
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
|
(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 targetX = dock.left + dock.width / 2;
|
||||||
const targetY = dock.top + dock.height / 2;
|
const targetY = dock.top + dock.height / 2;
|
||||||
const targetWidth = Math.max(40, Math.min(dock.width, dock.height * 4));
|
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;
|
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
|
||||||
screenX += (targetX - screenX) * mix;
|
screenX += (targetX - screenX) * mix;
|
||||||
screenY += (targetY - screenY) * mix;
|
screenY += (targetY - screenY) * mix;
|
||||||
screenWidth += (targetWidth - screenWidth) * mix;
|
screenWidth += (targetWidth - screenWidth) * mix;
|
||||||
initialized = true;
|
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 =
|
const arrival =
|
||||||
snapshot.phase === 'anticipate'
|
phase === 'anticipate' ? 0 : phase === 'approach' && !brisk ? raw : 1;
|
||||||
? 0
|
// Full scale over the road, in the upper third: about 600 px wide.
|
||||||
: snapshot.phase === 'approach'
|
const bigWidth = Math.min(620, width * (width < 768 ? 0.82 : 0.42));
|
||||||
? p
|
const bigY = height * 0.3;
|
||||||
: 1;
|
let cx = screenX;
|
||||||
// Lift-off: the sign rises off the horizon on its side of the road and
|
let cy = screenY;
|
||||||
// curves up into the window, growing as it closes in.
|
let projectedWidth = screenWidth;
|
||||||
const lateral = 1 - (1 - arrival) * (1 - arrival);
|
let depth = 18;
|
||||||
const closeDepth = 18;
|
let trussOpacity = 0;
|
||||||
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
|
let trussDepth = 0;
|
||||||
let cx = THREE.MathUtils.lerp(width * (0.5 + side * 0.07), screenX, lateral);
|
let trussY = 0;
|
||||||
let cy = THREE.MathUtils.lerp(height * 0.44, screenY, arrival);
|
if (snapshot && (phase === 'anticipate' || phase === 'approach') && !brisk) {
|
||||||
let projectedWidth =
|
if (arrival < ON_TRUSS) {
|
||||||
screenWidth * THREE.MathUtils.lerp(0.14, 1, arrival * arrival);
|
// Hanging from the truss, closing in down the road.
|
||||||
let pitch = 0;
|
const t = arrival / ON_TRUSS;
|
||||||
if (snapshot.phase === 'depart') {
|
const near = 1 - (1 - t) * (1 - t);
|
||||||
// Passing under the gantry: up and out of the top of the frame,
|
depth = THREE.MathUtils.lerp(150, 46, near);
|
||||||
// seen more and more from below.
|
cx = width / 2;
|
||||||
depth = THREE.MathUtils.lerp(closeDepth, 9, p);
|
cy = bigY;
|
||||||
cy = screenY - p * p * (screenY + dock.height * 1.6);
|
projectedWidth = bigWidth * THREE.MathUtils.lerp(0.32, 1, near);
|
||||||
projectedWidth *= 1 + p * 0.45;
|
trussOpacity = smoothstep(Math.min(1, t * 4));
|
||||||
pitch = -p * 0.85;
|
} 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 tiltMix = 1 - Math.exp(-dt / 0.16);
|
||||||
const docked = snapshot.phase === 'dwell' ? 1 : 0;
|
const tiltOn = docked ? hoverLevel : 0;
|
||||||
tiltX += (tilt.x * docked - tiltX) * (initialized ? tiltMix : 1);
|
tiltX += (tilt.x * tiltOn - tiltX) * tiltMix;
|
||||||
tiltY += (tilt.y * docked - tiltY) * (initialized ? tiltMix : 1);
|
tiltY += (tilt.y * tiltOn - tiltY) * tiltMix;
|
||||||
|
|
||||||
worldPoint(point, cx, cy, depth, camera);
|
worldPoint(point, cx, cy, depth, camera);
|
||||||
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
||||||
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
||||||
const scale = worldRight.distanceTo(worldLeft) / 8;
|
const scale = worldRight.distanceTo(worldLeft) / 8;
|
||||||
// The face sits FACE_Z in front of the group origin: set the origin
|
if (trussOpacity > 0) {
|
||||||
// back by that much so the face itself lands on the target plane.
|
// 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);
|
point.addScaledVector(forward, FACE_Z * scale);
|
||||||
group.position.set(point.x, point.y, point.z);
|
group.position.set(point.x, point.y, point.z);
|
||||||
group.scale.setScalar(scale);
|
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.copy(camera.quaternion);
|
||||||
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * 0.3));
|
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * TILT_RADIANS));
|
||||||
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, pitch - tiltY * 0.24));
|
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, -tiltY * TILT_RADIANS));
|
||||||
const opacity = clamp01(snapshot.opacity);
|
|
||||||
|
// 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;
|
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;
|
||||||
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
|
|
||||||
edgeMaterial.emissiveIntensity =
|
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.uSweep.value = arrival * 1.5 - 0.2;
|
||||||
sweepMaterial.uniforms.uOpacity.value =
|
sweepMaterial.uniforms.uOpacity.value =
|
||||||
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
|
phase === 'approach' && !brisk ? opacity * 0.14 : 0;
|
||||||
group.visible = opacity > 0;
|
group.visible = opacity > 0.002;
|
||||||
group.userData.anchoring = 'gantry';
|
group.userData.anchoring = 'gantry';
|
||||||
state =
|
state = !snapshot
|
||||||
opacity <= 0
|
? 'idle'
|
||||||
? 'idle'
|
: phase === 'depart'
|
||||||
: snapshot.phase === 'depart'
|
? 'passing'
|
||||||
? 'passing'
|
: phase === 'dwell' || (phase === 'approach' && (brisk || arrival >= 0.97))
|
||||||
: arrival >= 0.82
|
? 'docked'
|
||||||
? 'docked'
|
: 'incoming';
|
||||||
: 'incoming';
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -628,16 +701,6 @@ function createClippedShape(width: number, height: number, cut: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Chapter accents, matching the CSS --chapter-accent of each panel. */
|
/** 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 {
|
export function signAccentFor(id: SectionId): string {
|
||||||
return SIGN_ACCENTS[id];
|
return SIGN_ACCENTS[id];
|
||||||
@@ -754,3 +817,79 @@ function clamp01(value: number): number {
|
|||||||
function smoothstep(value: number): number {
|
function smoothstep(value: number): number {
|
||||||
return value * value * (3 - 2 * value);
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,12 +17,22 @@
|
|||||||
/* mask-position percentages resolve against (box - layer), so the cut-out
|
/* 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. */
|
needs its own x: 50% centres it, 100% - n puts its right edge n in. */
|
||||||
--window-mask-x: 50%;
|
--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-1: #1d1230;
|
||||||
--gantry-steel-2: #120c22;
|
--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);
|
--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. */
|
/* Mid widths: the window sits beside the controls bay. */
|
||||||
@media (min-width: 768px) and (max-width: 1279px) {
|
@media (min-width: 768px) and (max-width: 1279px) {
|
||||||
:root {
|
:root {
|
||||||
@@ -61,27 +71,31 @@ html .site-navigation[data-enhanced='true'][data-docked='true'] {
|
|||||||
html .site-navigation::before,
|
html .site-navigation::before,
|
||||||
html .site-navigation[data-enhanced='true']::before,
|
html .site-navigation[data-enhanced='true']::before,
|
||||||
html .site-navigation[data-enhanced='true'][data-docked='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;
|
transform: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
/* Rivet rails top and bottom, brushed steel between, a cut-out window. */
|
/* Rivet rails top and bottom, brushed steel between, a cut-out window. */
|
||||||
background:
|
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 / 40%) 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,
|
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%)),
|
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);
|
linear-gradient(100deg, var(--gantry-steel-1), var(--gantry-steel-2) 55%, #170f2a);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 0 var(--gantry-rail),
|
0 1px 0 var(--gantry-rail),
|
||||||
0 14px 34px -8px rgb(6 1 16 / 70%);
|
0 14px 34px -8px rgb(6 1 16 / 70%);
|
||||||
|
/* Window hole XOR (bulge + beam). The bulge is 0 px tall below 1280. */
|
||||||
-webkit-mask:
|
-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,
|
||||||
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,
|
||||||
-webkit-mask-composite: xor;
|
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
|
||||||
|
-webkit-mask-composite: xor, source-over;
|
||||||
mask:
|
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,
|
||||||
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,
|
||||||
mask-composite: exclude;
|
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). */
|
/* 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: 2px solid var(--focus);
|
||||||
outline-offset: 6px;
|
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 {
|
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:
|
box-shadow:
|
||||||
0 0 0 1px rgb(255 214 147 / 70%),
|
0 0 0 1px rgb(255 214 147 / 70%),
|
||||||
0 0 0 4px rgb(10 5 20 / 92%),
|
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%),
|
0 0 26px rgb(255 160 220 / 22%),
|
||||||
inset 0 0 18px rgb(6 2 16 / 40%);
|
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) {
|
@media (max-width: 359px) {
|
||||||
.gantry-window { display: none; }
|
.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 {
|
html .journey-hud {
|
||||||
|
--sign-accent: #ff83c2;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: var(--z-hud);
|
z-index: var(--z-hud);
|
||||||
inset: var(--window-y) auto auto 0;
|
inset: var(--window-y) auto auto 0;
|
||||||
@@ -282,25 +341,44 @@ html .journey-hud {
|
|||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 9px;
|
column-gap: 10px;
|
||||||
row-gap: 3px;
|
row-gap: 3px;
|
||||||
inline-size: var(--window-w);
|
inline-size: var(--window-w);
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
block-size: var(--window-h);
|
block-size: var(--window-h);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
padding: 7px 11px 6px;
|
padding: 8px 12px 9px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 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;
|
box-shadow: none;
|
||||||
color: #cbb9de;
|
color: #cbb9de;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
pointer-events: none;
|
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. */
|
/* The window's left edge, without reading the nav's own custom properties. */
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
html .journey-hud { inset-inline-start: calc(50% - var(--window-w) / 2); }
|
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) {
|
@media (min-width: 768px) and (max-width: 1279px) {
|
||||||
html .journey-hud { inset-inline-start: calc(100% - var(--bay-r) - var(--window-w) - 6px); }
|
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 { inset-inline-start: var(--bay-l); }
|
||||||
}
|
}
|
||||||
html .journey-hud[data-exit]::before {
|
html .journey-hud[data-exit]::before {
|
||||||
|
content: 'EXIT\A' attr(data-exit) / '';
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1 / -1;
|
||||||
align-self: center;
|
align-self: stretch;
|
||||||
font-size: 0.6rem;
|
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 {
|
html .journey-hud__section {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
color: #fff4fb;
|
color: #fff2df;
|
||||||
font: 620 0.98rem/1 var(--sans);
|
font: 650 1.02rem/1 var(--sans);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
html .journey-hud__section::before { display: none; }
|
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-column: 3;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
padding-inline-start: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: #a998bd;
|
color: var(--sign-accent);
|
||||||
font: 560 0.58rem/1 var(--mono);
|
font: 500 1.5rem/1 var(--sans);
|
||||||
}
|
}
|
||||||
html .journey-hud__route {
|
html .journey-hud[data-arrived='true']::after {
|
||||||
grid-column: 1 / -1;
|
content: 'AGAIN \21BA' / '';
|
||||||
grid-row: 2;
|
color: var(--focus);
|
||||||
gap: 0.35rem;
|
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;
|
overflow: hidden;
|
||||||
color: #a998bd;
|
clip-path: inset(50%);
|
||||||
font: 560 0.56rem/1 var(--mono);
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
html .journey-hud__route span { font-size: inherit; color: inherit; display: inline; }
|
/* The plate yields while a 3D sign arrives, and to the pointer's 3D twin. */
|
||||||
html .journey-hud__route strong { color: var(--focus); font-size: inherit; }
|
html[data-gantry='incoming'] .journey-hud {
|
||||||
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 {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-6px);
|
transform: translateY(-6px);
|
||||||
}
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
html[data-gantry]:has(.gantry-window:hover) .journey-hud { opacity: 0; }
|
||||||
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
html .journey-hud {
|
html .journey-hud {
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
padding: 0 8px;
|
padding: 4px 8px 5px 4px;
|
||||||
column-gap: 7px;
|
column-gap: 7px;
|
||||||
}
|
}
|
||||||
html .journey-hud__route,
|
html .journey-hud[data-exit]::before { content: attr(data-exit) / ''; display: grid; font-size: 0.6rem; padding: 0 0.4rem; }
|
||||||
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__section { font-size: 0.82rem; }
|
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) {
|
@media (max-width: 359px) {
|
||||||
html .journey-hud { display: none; }
|
html .journey-hud { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Drive controls in the right bay ---------- */
|
/* ---------- 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 {
|
html .drive-controls {
|
||||||
|
--tile-ink: #d9c8f4;
|
||||||
|
--tile-face: #1b1130;
|
||||||
|
--tile-edge: rgb(217 200 244 / 30%);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: var(--z-popover);
|
z-index: var(--z-popover);
|
||||||
inset: calc((var(--gantry-h) - 44px) / 2) max(12px, env(safe-area-inset-right)) auto auto;
|
inset: calc((var(--gantry-h) - 44px) / 2) max(12px, env(safe-area-inset-right)) auto auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
inline-size: auto;
|
inline-size: auto;
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
gap: 4px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
html .drive-controls__dock {
|
html .drive-controls__dock {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 2px;
|
gap: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
background: none;
|
background: none;
|
||||||
|
background-color: var(--gantry-steel-2);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
html .drive-start,
|
||||||
html .drive-control-button,
|
html .drive-control-button,
|
||||||
html .drive-controls__settings-trigger {
|
html .drive-controls__settings-trigger {
|
||||||
border: 1px solid rgb(214 140 222 / 26%);
|
display: inline-grid;
|
||||||
border-radius: 4px;
|
place-items: center;
|
||||||
background: rgb(24 14 40 / 92%);
|
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;
|
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 {
|
html .drive-start:hover:not(:disabled),
|
||||||
border-color: rgb(255 160 220 / 55%);
|
html .drive-control-button:hover,
|
||||||
background: #2f1c45;
|
html .drive-controls__settings-trigger:hover {
|
||||||
color: #ffd1ec;
|
--tile-edge: rgb(217 200 244 / 62%);
|
||||||
|
--tile-face: #261843;
|
||||||
|
transform: none;
|
||||||
|
color: #f1e8ff;
|
||||||
}
|
}
|
||||||
html .drive-start {
|
html .drive-control-button:focus-visible,
|
||||||
min-inline-size: 0;
|
html .drive-start:focus-visible {
|
||||||
gap: 10px;
|
outline: 2px solid var(--focus);
|
||||||
padding-inline: 12px 14px;
|
outline-offset: -2px;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
}
|
||||||
@media (max-width: 1279px) {
|
html .drive-controls__settings-trigger[aria-expanded='true'],
|
||||||
html .drive-start { inline-size: 44px; padding: 0; justify-content: center; }
|
html .drive-controls[data-drive-state='running'][data-muted='false'] .drive-sound {
|
||||||
html .drive-start > span { display: none; }
|
--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 {
|
html .drive-control-button::after {
|
||||||
inset: calc(100% + 0.5rem) auto auto 50%;
|
inset: calc(100% + 0.5rem) auto auto 50%;
|
||||||
transform: translate(-50%, -4px);
|
transform: translate(-50%, -4px);
|
||||||
@@ -437,9 +589,49 @@ html .drive-control-button:focus-visible::after {
|
|||||||
}
|
}
|
||||||
html .drive-popover {
|
html .drive-popover {
|
||||||
position: absolute;
|
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);
|
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;
|
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 {
|
html .drive-controls__audio-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -451,10 +643,30 @@ html .drive-controls__audio-status {
|
|||||||
border: 1px solid rgb(214 140 222 / 32%);
|
border: 1px solid rgb(214 140 222 / 32%);
|
||||||
border-radius: 4px;
|
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) {
|
@media (max-height: 420px) {
|
||||||
html .drive-controls { position: fixed; inset: calc((var(--gantry-h) - 44px) / 2) 12px auto auto; margin: 0; }
|
html .drive-controls { position: fixed; inset: calc((var(--gantry-h) - 44px) / 2) 12px auto auto; margin: 0; }
|
||||||
html .journey-hud { display: grid; }
|
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 ---------- */
|
/* ---------- 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 ---------- */
|
/* ---------- Content lands right under the beam ---------- */
|
||||||
html[data-expressway='live'] .portfolio-section,
|
html[data-expressway='live'] .portfolio-section,
|
||||||
html[data-expressway='live'] #contact,
|
html[data-expressway='live'] #contact,
|
||||||
|
|||||||
@@ -289,12 +289,13 @@ describe("DriveControls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.get('[aria-label="Mute sound"]')).toBeTruthy();
|
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"]');
|
const settings = wrapper.get('[aria-label="Drive settings"]');
|
||||||
await settings.trigger("click");
|
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"]');
|
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.text()).not.toContain("Motion: active");
|
||||||
expect(panel.find('[role="status"]').exists()).toBe(false);
|
expect(panel.find('[role="status"]').exists()).toBe(false);
|
||||||
expect(panel.get("select").attributes("aria-label")).toBe(
|
expect(panel.get("select").attributes("aria-label")).toBe(
|
||||||
|
|||||||
@@ -98,21 +98,26 @@ describe('composed route signs', () => {
|
|||||||
const allocation = h.resources.snapshot();
|
const allocation = h.resources.snapshot();
|
||||||
h.render(null);
|
h.render(null);
|
||||||
expect(h.sign.group.visible).toBe(false);
|
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 });
|
h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 });
|
||||||
expect(h.sign.group.visible).toBe(false);
|
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 });
|
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 0.7 });
|
||||||
expect(h.sign.group.visible).toBe(true);
|
expect(h.sign.group.visible).toBe(true);
|
||||||
expect(face.material.opacity).toBeGreaterThan(0);
|
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);
|
h.render(profile, 120);
|
||||||
expect(face.material.opacity).toBe(1);
|
expect(face.material.opacity).toBe(0);
|
||||||
expect(face.userData.textureUpdateCount).toBe(1);
|
expect(h.sign.group.visible).toBe(false);
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(resting + 1);
|
||||||
expect(h.resources.snapshot()).toEqual(allocation);
|
expect(h.resources.snapshot()).toEqual(allocation);
|
||||||
h.render(null);
|
h.render(null);
|
||||||
expect(h.sign.group.visible).toBe(false);
|
expect(h.sign.group.visible).toBe(false);
|
||||||
|
const between = face.userData.textureUpdateCount;
|
||||||
h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 });
|
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(routeLabelFor('selected-builds')).toBe('Selected builds');
|
||||||
expect(h.resources.snapshot()).toEqual(allocation);
|
expect(h.resources.snapshot()).toEqual(allocation);
|
||||||
h.resources.dispose();
|
h.resources.dispose();
|
||||||
@@ -155,46 +160,57 @@ describe('composed route signs', () => {
|
|||||||
expectDocked(settled, h.dock);
|
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();
|
const h = setup();
|
||||||
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
|
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
|
||||||
const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds;
|
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;
|
const docked = h.render(profile)!.bounds;
|
||||||
expect(h.sign.dockState()).toBe('docked');
|
expect(h.sign.dockState()).toBe('docked');
|
||||||
expect(early.width).toBeLessThan(late.width);
|
// Grows down the road to about 600 px, centred in the upper third.
|
||||||
expect(late.width).toBeLessThan(docked.width + 1);
|
expect(early.width).toBeLessThan(big.width);
|
||||||
expect(early.top).toBeGreaterThan(late.top);
|
expect(big.width).toBeGreaterThan(560);
|
||||||
expect(late.top).toBeGreaterThan(docked.top - 1);
|
expect(big.width).toBeLessThan(640);
|
||||||
expect(early.top).toBeGreaterThan(900 * 0.3);
|
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 h = setup();
|
||||||
const docked = h.render(profile, 120)!.bounds;
|
h.render(profile, 120);
|
||||||
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 })!.bounds;
|
// 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(h.sign.dockState()).toBe('passing');
|
||||||
expect(passing.top).toBeLessThan(docked.top);
|
expect(h.sign.group.visible).toBe(false);
|
||||||
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);
|
|
||||||
h.render(null);
|
h.render(null);
|
||||||
expect(h.sign.group.visible).toBe(false);
|
expect(h.sign.group.visible).toBe(false);
|
||||||
expect(h.sign.dockState()).toBe('idle');
|
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();
|
const h = setup();
|
||||||
h.render(profile, 120);
|
h.render(profile, 120);
|
||||||
const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } };
|
const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } };
|
||||||
const rest = group.quaternion.clone();
|
const rest = group.quaternion.clone();
|
||||||
|
// Without hover the plate stays flat: no tilt.
|
||||||
h.setTilt({ x: 1, y: -1 });
|
h.setTilt({ x: 1, y: -1 });
|
||||||
h.render(profile, 60);
|
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.setTilt({ x: 0, y: 0 });
|
||||||
h.render(profile, 120);
|
h.render(profile, 120);
|
||||||
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
|
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(null);
|
||||||
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
|
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
|
||||||
const approaching = group.quaternion.clone();
|
const approaching = group.quaternion.clone();
|
||||||
|
|||||||
Reference in New Issue
Block a user