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:
@@ -15,6 +15,7 @@ import ToolkitGrid from './ToolkitGrid.vue';
|
||||
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
||||
import { useAudioEngine } from '@/composables/useAudioEngine';
|
||||
import { useAudioIntent } from '@/composables/useAudioIntent';
|
||||
import { useCardEntrances } from '@/composables/useCardEntrances';
|
||||
import { useJourneySections } from '@/composables/useJourneySections';
|
||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||
@@ -80,6 +81,7 @@ const journeyBinding = useJourneySections(
|
||||
computed(() => motion.policy.value.allowSceneRequest),
|
||||
);
|
||||
const journey = journeyBinding.snapshot;
|
||||
useCardEntrances();
|
||||
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
||||
const resume = computed(() => {
|
||||
const id = route.resumeFrom.value;
|
||||
@@ -262,6 +264,7 @@ onUnmounted(() => removePageListeners());
|
||||
:items="canonicalNavigation"
|
||||
:journey="journey"
|
||||
:visited="route.visited.value"
|
||||
:resume-id="resume?.id"
|
||||
:allow-motion="motion.policy.value.allowSceneRequest"
|
||||
:on-section-link-click="navigation.onSectionLinkClick"
|
||||
/>
|
||||
@@ -270,7 +273,7 @@ onUnmounted(() => removePageListeners());
|
||||
<div class="reading-stage">
|
||||
<div class="reading-viewport">
|
||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||
<HeroJourney :identity="content.identity" :resume="resume" @resume="navigation.onSectionLinkClick" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
|
||||
<HeroJourney :identity="content.identity" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
|
||||
|
||||
<PortfolioSection id="profile" :exit="exitFor('profile')" :next-label="nextLabelFor('profile')" :next-exit="nextExitFor('profile')" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||
<p>{{ content.profile.introduction }}</p>
|
||||
|
||||
@@ -40,9 +40,8 @@ const emit = defineEmits<{
|
||||
|
||||
const controlRoot = ref<HTMLElement | null>(null);
|
||||
const settingsButton = ref<HTMLButtonElement | null>(null);
|
||||
const volumeButton = ref<HTMLButtonElement | null>(null);
|
||||
const settingsPanel = ref<HTMLElement | null>(null);
|
||||
const settingsOpen = ref(false);
|
||||
const volumeOpen = ref(false);
|
||||
|
||||
const action = computed(() => {
|
||||
if (props.policy.reason === "visitor-pause") {
|
||||
@@ -123,21 +122,28 @@ const changeVolume = (event: Event): void => {
|
||||
|
||||
const toggleSettings = (): void => {
|
||||
settingsOpen.value = !settingsOpen.value;
|
||||
volumeOpen.value = false;
|
||||
};
|
||||
|
||||
const toggleVolume = (): void => {
|
||||
volumeOpen.value = !volumeOpen.value;
|
||||
settingsOpen.value = false;
|
||||
};
|
||||
|
||||
const closePanels = (restoreFocus = false): void => {
|
||||
const focusTarget = settingsOpen.value
|
||||
? settingsButton.value
|
||||
: volumeButton.value;
|
||||
const wasOpen = settingsOpen.value;
|
||||
settingsOpen.value = false;
|
||||
volumeOpen.value = false;
|
||||
if (restoreFocus) void nextTick(() => focusTarget?.focus());
|
||||
if (restoreFocus && wasOpen) void nextTick(() => settingsButton.value?.focus());
|
||||
};
|
||||
|
||||
/** Tab and Shift+Tab cycle inside the open settings panel and its trigger. */
|
||||
const trapFocus = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Tab" || !settingsOpen.value) return;
|
||||
const panel = settingsPanel.value;
|
||||
if (!panel || !settingsButton.value) return;
|
||||
const stops = [
|
||||
settingsButton.value,
|
||||
...panel.querySelectorAll<HTMLElement>("button, input, select"),
|
||||
];
|
||||
const index = stops.indexOf(document.activeElement as HTMLElement);
|
||||
if (index === -1) return;
|
||||
const next = (index + (event.shiftKey ? -1 : 1) + stops.length) % stops.length;
|
||||
event.preventDefault();
|
||||
stops[next]?.focus();
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event: Event): void => {
|
||||
@@ -146,12 +152,22 @@ const onDocumentPointerDown = (event: Event): void => {
|
||||
};
|
||||
|
||||
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape" || (!settingsOpen.value && !volumeOpen.value))
|
||||
return;
|
||||
trapFocus(event);
|
||||
if (event.key !== "Escape" || !settingsOpen.value) return;
|
||||
event.preventDefault();
|
||||
closePanels(true);
|
||||
};
|
||||
|
||||
// Opening the panel moves focus into it; the trap keeps it there.
|
||||
watch(settingsOpen, (open) => {
|
||||
if (open)
|
||||
void nextTick(() =>
|
||||
settingsPanel.value
|
||||
?.querySelector<HTMLElement>("input, button, select")
|
||||
?.focus(),
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.audioState.kind,
|
||||
() => closePanels(),
|
||||
@@ -173,28 +189,33 @@ onBeforeUnmount(() => {
|
||||
ref="controlRoot"
|
||||
class="drive-controls"
|
||||
:data-drive-state="audioState.kind"
|
||||
:data-muted="audioMuted"
|
||||
>
|
||||
<!-- Two monochrome bay buttons: the soundtrack (an equaliser that moves
|
||||
while it plays) and the settings. Each is a 44 px target drawn as a
|
||||
36 px tile. -->
|
||||
<button
|
||||
v-if="audioState.kind === 'idle'"
|
||||
class="drive-start"
|
||||
class="drive-start drive-control-button"
|
||||
type="button"
|
||||
aria-label="Play soundtrack"
|
||||
data-tooltip="Play soundtrack"
|
||||
@click="emit('audioStart')"
|
||||
>
|
||||
<IconGlyph name="play" />
|
||||
<span>PLAY SOUNDTRACK</span>
|
||||
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="visually-hidden">PLAY SOUNDTRACK</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="audioState.kind === 'starting'"
|
||||
class="drive-start"
|
||||
class="drive-start drive-control-button"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Starting drive"
|
||||
aria-live="polite"
|
||||
>
|
||||
<IconGlyph name="play" />
|
||||
<span>STARTING…</span>
|
||||
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="visually-hidden">STARTING…</span>
|
||||
</button>
|
||||
|
||||
<p
|
||||
@@ -225,45 +246,20 @@ onBeforeUnmount(() => {
|
||||
<IconGlyph name="play" />
|
||||
</button>
|
||||
<button
|
||||
class="drive-control-button"
|
||||
v-else
|
||||
class="drive-control-button drive-sound"
|
||||
type="button"
|
||||
:aria-label="muteLabel"
|
||||
:title="muteLabel"
|
||||
:data-tooltip="muteLabel"
|
||||
@click="emit('audioMuteToggle')"
|
||||
>
|
||||
<IconGlyph :name="muteIcon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref="volumeButton"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
aria-label="Sound volume"
|
||||
title="Sound volume"
|
||||
data-tooltip="Sound volume"
|
||||
aria-controls="drive-volume-panel"
|
||||
:aria-expanded="volumeOpen"
|
||||
@click="toggleVolume"
|
||||
>
|
||||
<IconGlyph name="volume" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="action"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
:title="action.label"
|
||||
:data-tooltip="action.label"
|
||||
@click="runAction"
|
||||
>
|
||||
<IconGlyph :name="action.icon" />
|
||||
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="action && !isAudioActive && action.event !== 'pause'"
|
||||
v-if="action && action.event !== 'pause'"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
@@ -297,35 +293,41 @@ onBeforeUnmount(() => {
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="volumeOpen && isAudioActive"
|
||||
id="drive-volume-panel"
|
||||
class="drive-popover drive-popover--volume"
|
||||
role="dialog"
|
||||
aria-label="Sound volume"
|
||||
>
|
||||
<label for="drive-volume">SOUND</label>
|
||||
<input
|
||||
id="drive-volume"
|
||||
aria-label="Sound volume level"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:value="audioVolume"
|
||||
@input="changeVolume"
|
||||
/>
|
||||
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsOpen"
|
||||
id="drive-settings-panel"
|
||||
ref="settingsPanel"
|
||||
class="drive-popover drive-popover--settings"
|
||||
role="dialog"
|
||||
aria-label="Drive settings"
|
||||
>
|
||||
<strong>DRIVE SETTINGS</strong>
|
||||
<template v-if="isAudioActive">
|
||||
<label for="drive-volume">SOUND</label>
|
||||
<div class="drive-popover__volume">
|
||||
<input
|
||||
id="drive-volume"
|
||||
aria-label="Sound volume level"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:value="audioVolume"
|
||||
@input="changeVolume"
|
||||
/>
|
||||
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-if="action && action.event === 'pause'"
|
||||
class="drive-popover__action"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
@click="runAction"
|
||||
>
|
||||
<IconGlyph :name="action.icon" />
|
||||
<span aria-hidden="true">PAUSE MOTION</span>
|
||||
</button>
|
||||
<label for="drive-quality">QUALITY</label>
|
||||
<select
|
||||
id="drive-quality"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import { bucharestClock } from '@/lib/operations';
|
||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||
|
||||
const props = defineProps<{
|
||||
journey: JourneySnapshot;
|
||||
@@ -13,24 +13,27 @@ const props = defineProps<{
|
||||
|
||||
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
|
||||
const next = computed(() =>
|
||||
props.nextLabel === undefined ? 'END OF ROUTE' : `NEXT ${props.nextExit ?? ''} \u2192 ${props.nextLabel}`.toUpperCase(),
|
||||
props.nextLabel === undefined ? 'END OF ROUTE' : `NEXT ${props.nextExit ?? ''} → ${props.nextLabel}`.toUpperCase(),
|
||||
);
|
||||
|
||||
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`);
|
||||
|
||||
// The dash clock is decorative and CSS-generated, so SSR and hydrated text stay identical.
|
||||
const clock = shallowRef<string | undefined>(undefined);
|
||||
let timer = 0;
|
||||
onMounted(() => {
|
||||
const tick = (): void => { clock.value = bucharestClock(new Date()); };
|
||||
tick();
|
||||
timer = window.setInterval(tick, 30_000);
|
||||
});
|
||||
onUnmounted(() => window.clearInterval(timer));
|
||||
/* The window's flat exit plate: exit tab, chapter, a next-exit chevron, and
|
||||
a route rule that fills along its lower edge. */
|
||||
const route = computed(() => Math.min(1, Math.max(0, props.journey.routeProgress)));
|
||||
const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="journey-hud" aria-hidden="true" :data-exit="exit" :data-clock="clock">
|
||||
<aside
|
||||
class="journey-hud"
|
||||
aria-hidden="true"
|
||||
:data-exit="exit"
|
||||
:data-arrived="journey.arrived"
|
||||
:style="{
|
||||
'--route': route,
|
||||
'--sign-accent': SIGN_ACCENTS[journey.activeSectionId],
|
||||
'--label-chars': sectionLabel.length,
|
||||
}"
|
||||
>
|
||||
<span class="journey-hud__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
|
||||
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
||||
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { JourneySnapshot } from "@/journey/types";
|
||||
import { SIGN_ACCENTS } from "@/journey/sign-routes";
|
||||
|
||||
const props = defineProps<{
|
||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||
@@ -9,6 +10,8 @@ const props = defineProps<{
|
||||
/** Exits passed in this browser (client-only memory). */
|
||||
visited?: ReadonlySet<SectionId>;
|
||||
allowMotion?: boolean;
|
||||
/** A returning visitor's last exit: the window offers it from the start. */
|
||||
resumeId?: SectionId | undefined;
|
||||
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
||||
}>();
|
||||
|
||||
@@ -93,18 +96,34 @@ onBeforeUnmount(() => {
|
||||
compactQuery = null;
|
||||
});
|
||||
|
||||
/** The gantry's sign window drives on to the next exit (or back to the
|
||||
* start from the destination), exactly like following that exit's link. */
|
||||
const nextIndex = computed(() =>
|
||||
activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1,
|
||||
/** The gantry's sign window drives on to the next exit, resumes a returning
|
||||
* visitor's drive from the start, or goes back to the start from the
|
||||
* destination, exactly like following that exit's link. */
|
||||
const resumeIndex = computed(() =>
|
||||
props.resumeId === undefined || activeIndex.value !== 0
|
||||
? -1
|
||||
: props.items.findIndex((item) => item.id === props.resumeId),
|
||||
);
|
||||
const nextIndex = computed(() => {
|
||||
if (resumeIndex.value > 0) return resumeIndex.value;
|
||||
return activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1;
|
||||
});
|
||||
const windowLabel = computed(() => {
|
||||
const next = props.items[nextIndex.value];
|
||||
if (next === undefined) return "Drive on";
|
||||
if (resumeIndex.value > 0)
|
||||
return `Resume the drive at ${next.label}, exit ${exitNumber(nextIndex.value)}`;
|
||||
return nextIndex.value === 0
|
||||
? `Back to the start: ${next.label}`
|
||||
? `Drive again from the start: ${next.label}`
|
||||
: `Drive to the next exit: ${next.label}`;
|
||||
});
|
||||
/** Decorative hover line under the window (CSS generated content). */
|
||||
const windowHint = computed(() => {
|
||||
if (resumeIndex.value > 0) return `RESUME EXIT ${exitNumber(nextIndex.value)} \u2192`;
|
||||
return nextIndex.value === 0
|
||||
? "DRIVE AGAIN \u21ba"
|
||||
: `DRIVE TO EXIT ${exitNumber(nextIndex.value)} \u2192`;
|
||||
});
|
||||
const driveOn = (): void => {
|
||||
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
|
||||
};
|
||||
@@ -117,7 +136,7 @@ const navigate = (event: MouseEvent): void => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length }">
|
||||
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
|
||||
<details ref="disclosure" class="site-navigation__disclosure" open>
|
||||
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
|
||||
<ul ref="list">
|
||||
@@ -138,8 +157,7 @@ const navigate = (event: MouseEvent): void => {
|
||||
type="button"
|
||||
class="gantry-window"
|
||||
:aria-label="windowLabel"
|
||||
:title="windowLabel"
|
||||
@click="driveOn"
|
||||
></button>
|
||||
><span class="gantry-window__cone" aria-hidden="true"></span><span class="gantry-window__hint" aria-hidden="true" :data-hint="windowHint"></span></button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user