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

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

View File

@@ -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"