From bacc035c43785694c5eda31e47b689a278602f53 Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 23:30:58 +0300 Subject: [PATCH] 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. --- docs/redesign/round5.md | 33 +++ src/components/AppShell.vue | 5 +- src/components/DriveControls.vue | 144 +++++----- src/components/JourneyHud.vue | 33 ++- src/components/SiteNavigation.vue | 34 ++- src/journey/sign-routes.ts | 12 + src/scene/world/environment.ts | 21 +- src/scene/world/sign-stage.ts | 2 + src/scene/world/sign.ts | 307 ++++++++++++++------ src/styles/gantry.css | 454 ++++++++++++++++++++++++------ tests/unit/SceneCanvas.test.ts | 5 +- tests/unit/sign-rendering.test.ts | 60 ++-- 12 files changed, 823 insertions(+), 287 deletions(-) diff --git a/docs/redesign/round5.md b/docs/redesign/round5.md index ee1af14..29b067a 100644 --- a/docs/redesign/round5.md +++ b/docs/redesign/round5.md @@ -110,3 +110,36 @@ content had to stay out of their way. it, so no light ever passes the car. - A unit test pins both rules. - The car's own lamps and the wake stay parented to the car, as before. + +## Evidence + +All captures are 1440x900 unless named otherwise, from the production +preview build on a headed GPU Chromium. They are in `docs/redesign/round5/`. + +| Item | Before | After | +| --- | --- | --- | +| 4, 5, 6, 8 Gantry | `gantry-before-1440.webp` | `gantry-after-1440-incoming.webp`, `-docked.webp`, `-idle.webp`, `gantry-after-1024.webp`, `gantry-after-390.webp` | +| 1 Outro | `outro-before-1440.webp` | `outro-after-1440.webp` | +| 7 Car | left half of `car-before-after-desktop.webp` | right half; `car-lite-390.webp` | +| 9, 10 Cards and motion | `cards-before-1440.webp` | `cards-after-1440.webp` | + +- **Gantry docking (headed run, clicking Profile).** idle at 300 ms, + incoming at 700 ms, docked from 1000 to 2600 ms, passing at 3300 ms, idle + at 3700 ms. The face lands within 2 px of the window at 1440x900, + 1920x1080, 1024x768, 390x844 and 844x390 (unit tests). Clicking the + window, or Enter on it, drives to the next exit (Start to Profile, then + Experience) at 1440 and 390. +- **A mask bug found on the way.** The window cut-out used `--window-x` + as a `mask-position`. Percentages there resolve against the box minus the + layer, so the hole sat 128 px left of the window and the beam hid half of + the sign. The cut-out now has its own `--window-mask-x`. +- **Car.** Phones and the low tier load the lite model: 31,288 to 11,027 + faces, 1.19 MB to 0.50 MB raw. The transfer saving is small (149 KB to + 143 KB), because the geometry already compresses well. The real wins are + parse time and GPU memory. +- **fps (same machine, same script, Intel Arc iGPU, headed).** + + | | start | infrastructure | builds | toolkit | contact | + | --- | --- | --- | --- | --- | --- | + | Before | 70.1 | 82.6 | 83.8 | 81.8 | 87.2 | + | After | 68.7 | 83.5 | 79.7 | 83.7 | 95.1 | diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index ad26b60..35c02d4 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -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());
- +

{{ content.profile.introduction }}

diff --git a/src/components/DriveControls.vue b/src/components/DriveControls.vue index 31e23b6..5e93724 100644 --- a/src/components/DriveControls.vue +++ b/src/components/DriveControls.vue @@ -40,9 +40,8 @@ const emit = defineEmits<{ const controlRoot = ref(null); const settingsButton = ref(null); -const volumeButton = ref(null); +const settingsPanel = ref(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("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("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" > +

{ - - - -