diff --git a/LOGISDATA/public/warehouse-interior.jpg b/LOGISDATA/public/warehouse-interior.jpg
new file mode 100644
index 0000000..f34e5b2
Binary files /dev/null and b/LOGISDATA/public/warehouse-interior.jpg differ
diff --git a/LOGISDATA/src/app/globals.css b/LOGISDATA/src/app/globals.css
index 9534532..95478c6 100644
--- a/LOGISDATA/src/app/globals.css
+++ b/LOGISDATA/src/app/globals.css
@@ -325,11 +325,14 @@ button:focus-visible {
* entering section no longer invalidates the other four full-viewport
* subtrees stacked in the native scroll host.
*
- * Scroll snapping is intentionally omitted: the briefing follows the
- * browser's continuous native scroll position, while nav and keyboard
- * jumps choose exact section offsets explicitly.
+ * Each section is a native snap point. Wheel, trackpad and touch gestures
+ * advance through the five chapter positions; the browser settles on the
+ * nearest chapter when input stops. Reduced-motion users get proximity
+ * snapping rather than a forced long-distance settle.
*/
contain: layout paint style;
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
}
.section-inner {
diff --git a/LOGISDATA/src/components/Presentation.tsx b/LOGISDATA/src/components/Presentation.tsx
index 2fb8548..ebf487c 100644
--- a/LOGISDATA/src/components/Presentation.tsx
+++ b/LOGISDATA/src/components/Presentation.tsx
@@ -22,7 +22,7 @@ import { presentationCopy } from "@/lib/data";
import { downloadExecutiveReportJson } from "@/lib/export";
import { t, text } from "@/lib/i18n";
import { shouldIgnorePresentationShortcut } from "@/lib/keyboard";
-import { DURATION, transition } from "@/lib/motion";
+import { DURATION, scrollBehaviorForMotion, transition } from "@/lib/motion";
import { sound } from "@/lib/sound";
import {
SECTIONS,
@@ -269,8 +269,8 @@ export function Presentation() {
};
scrollMetricsRef.current = metrics;
const targetTop = scrollTopForSection(index, metrics.scrollHeight, metrics.clientHeight);
- element.scrollTo({ top: targetTop, behavior: "smooth" });
- }, []);
+ element.scrollTo({ top: targetTop, behavior: scrollBehaviorForMotion(reduced) });
+ }, [reduced]);
useEffect(() => {
toggleLanguageRef.current = toggleLanguage;
@@ -568,6 +568,7 @@ export function Presentation() {
style={{
scrollbarWidth: "none",
overscrollBehaviorY: "contain",
+ scrollSnapType: reduced ? "y proximity" : "y mandatory",
}}
>
diff --git a/LOGISDATA/src/components/three/IndustrialScene.tsx b/LOGISDATA/src/components/three/IndustrialScene.tsx
index c10877c..59b4642 100644
--- a/LOGISDATA/src/components/three/IndustrialScene.tsx
+++ b/LOGISDATA/src/components/three/IndustrialScene.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useFrame, useThree } from "@react-three/fiber";
+import { useFrame, useLoader, useThree } from "@react-three/fiber";
import { useScroll } from "@react-three/drei";
-import { memo, useEffect, useMemo, useRef } from "react";
+import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { SECTION_COUNT, pagePositionFromOffset } from "@/lib/sections";
import type { DeviceProfile } from "@/lib/device";
@@ -53,6 +53,36 @@ const DEPTH_STEP = 2.2;
const clamp01 = (value: number) => THREE.MathUtils.clamp(value, 0, 1);
const smoothStep = (value: number) => value * value * (3 - 2 * value);
+/** Photo-real warehouse plate behind the scroll-synchronized 3D audit models. */
+function WarehouseBackdrop() {
+ const texture = useLoader(THREE.TextureLoader, "/warehouse-interior.jpg");
+
+ // Configure the cached GPU texture once; this is an owned renderer resource,
+ // not React state. The loader cache must retain the same texture reference.
+ /* eslint-disable react-hooks/immutability */
+ useEffect(() => {
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 4;
+ texture.needsUpdate = true;
+ }, [texture]);
+ /* eslint-enable react-hooks/immutability */
+
+ return (
+
+
+
+
+ );
+}
+
function SceneRig({ language, theme, device }: IndustrialSceneProps) {
const scroll = useScroll();
const invalidate = useThree((state) => state.invalidate);
@@ -153,6 +183,11 @@ function SceneRig({ language, theme, device }: IndustrialSceneProps) {
+ {device.tier !== "low" && (
+
+
+
+ )}
{device.effects && }
diff --git a/LOGISDATA/src/components/three/WarehouseGrid.tsx b/LOGISDATA/src/components/three/WarehouseGrid.tsx
index cb9d0f2..17f8097 100644
--- a/LOGISDATA/src/components/three/WarehouseGrid.tsx
+++ b/LOGISDATA/src/components/three/WarehouseGrid.tsx
@@ -11,6 +11,10 @@ import { easeApproach, getSectionApproach } from "@/lib/sceneFocus";
import { FocusFadeText } from "./FocusFadeText";
const SECTION_INDEX = 4;
+const UPRIGHT_X = [-4.2, -2.1, 0, 2.1, 4.2] as const;
+const RACK_DEPTHS = [-1.85, 0.85] as const;
+const BEAM_X = [-3.15, -1.05, 1.05, 3.15] as const;
+const BEAM_LEVELS = [-1.15, 0.35, 1.85] as const;
interface WarehouseGridProps {
language: Language;
@@ -19,106 +23,266 @@ interface WarehouseGridProps {
export function WarehouseGrid({ language, theme }: WarehouseGridProps) {
const boxesRef = useRef(null);
+ const palletsRef = useRef(null);
+ const uprightsRef = useRef(null);
+ const beamsRef = useRef(null);
const scanRef = useRef(null);
+ const forkliftRef = useRef(null);
+ const liftRef = useRef(null);
const scroll = useScroll();
- const frameColor = theme === "dark" ? "#2e5265" : "#73989c";
+ const frameColor = theme === "dark" ? "#3a5b6d" : "#66838b";
+ const floorColor = theme === "dark" ? "#172b38" : "#c7d5d5";
+ const mismatchColor = useMemo(() => new THREE.Color("#fb5b5b"), []);
+ const auditedColor = useMemo(() => new THREE.Color("#4de1c1"), []);
const problemBins = useMemo(
() => warehouseBins.filter((bin) => bin.status === "mismatch").slice(0, 3),
[],
);
- const boxGeometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
- const boxMaterial = useMemo(
- () =>
- new THREE.MeshStandardMaterial({
- vertexColors: true,
- transparent: true,
- opacity: 0.92,
- roughness: 0.48,
- metalness: 0.2,
- }),
+
+ // Shared unit geometry keeps all repeated inventory and rack members
+ // instanced. The expanded interior adds detail without multiplying draw calls.
+ const unitBox = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
+ const inventoryMaterial = useMemo(
+ () => new THREE.MeshStandardMaterial({
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.94,
+ roughness: 0.48,
+ metalness: 0.2,
+ }),
[],
);
-
- useEffect(
- () => () => {
- boxGeometry.dispose();
- boxMaterial.dispose();
- },
- [boxGeometry, boxMaterial],
+ const palletMaterial = useMemo(
+ () => new THREE.MeshStandardMaterial({ color: "#8b765a", roughness: 0.88 }),
+ [],
+ );
+ const uprightMaterial = useMemo(
+ () => new THREE.MeshStandardMaterial({ color: frameColor, metalness: 0.64, roughness: 0.36 }),
+ [frameColor],
);
+ const beamMaterial = useMemo(
+ () => new THREE.MeshStandardMaterial({ color: "#d5a548", metalness: 0.48, roughness: 0.42 }),
+ [],
+ );
+
+ useEffect(() => () => {
+ unitBox.dispose();
+ inventoryMaterial.dispose();
+ palletMaterial.dispose();
+ uprightMaterial.dispose();
+ beamMaterial.dispose();
+ }, [beamMaterial, inventoryMaterial, palletMaterial, unitBox, uprightMaterial]);
useLayoutEffect(() => {
- const mesh = boxesRef.current;
- if (!mesh) return;
const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion();
- const scale = new THREE.Vector3(0.82, 0.62, 0.72);
const position = new THREE.Vector3();
- const mismatchColor = new THREE.Color("#fb5b5b");
- const auditedColor = new THREE.Color("#4de1c1");
- warehouseBins.forEach((bin, index) => {
- matrix.compose(position.set(bin.x, bin.y, bin.z), rotation, scale);
- mesh.setMatrixAt(index, matrix);
- mesh.setColorAt(index, bin.status === "mismatch" ? mismatchColor : auditedColor);
- });
- mesh.instanceMatrix.needsUpdate = true;
- if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
- }, []);
+ const scale = new THREE.Vector3();
+ const cargo = boxesRef.current;
+ const pallets = palletsRef.current;
+ const uprights = uprightsRef.current;
+ const beams = beamsRef.current;
+
+ if (cargo) {
+ warehouseBins.forEach((bin, index) => {
+ matrix.compose(
+ position.set(bin.x, bin.y, bin.z),
+ rotation,
+ scale.set(0.82, 0.62, 0.72),
+ );
+ cargo.setMatrixAt(index, matrix);
+ cargo.setColorAt(index, bin.status === "mismatch" ? mismatchColor : auditedColor);
+ });
+ cargo.instanceMatrix.needsUpdate = true;
+ if (cargo.instanceColor) cargo.instanceColor.needsUpdate = true;
+ }
+
+ if (pallets) {
+ warehouseBins.forEach((bin, index) => {
+ matrix.compose(
+ position.set(bin.x, bin.y - 0.37, bin.z),
+ rotation,
+ scale.set(0.94, 0.12, 0.84),
+ );
+ pallets.setMatrixAt(index, matrix);
+ });
+ pallets.instanceMatrix.needsUpdate = true;
+ }
+
+ if (uprights) {
+ let index = 0;
+ for (const z of RACK_DEPTHS) {
+ for (const x of UPRIGHT_X) {
+ matrix.compose(
+ position.set(x, -0.05, z),
+ rotation,
+ scale.set(0.1, 4.55, 0.1),
+ );
+ uprights.setMatrixAt(index, matrix);
+ index += 1;
+ }
+ }
+ uprights.instanceMatrix.needsUpdate = true;
+ }
+
+ if (beams) {
+ let index = 0;
+ for (const z of RACK_DEPTHS) {
+ for (const y of BEAM_LEVELS) {
+ for (const x of BEAM_X) {
+ matrix.compose(
+ position.set(x, y, z),
+ rotation,
+ scale.set(2.1, 0.09, 0.14),
+ );
+ beams.setMatrixAt(index, matrix);
+ index += 1;
+ }
+ }
+ }
+ beams.instanceMatrix.needsUpdate = true;
+ }
+ }, [auditedColor, mismatchColor]);
useFrame((_, delta) => {
- if (!scanRef.current) return;
+ const progress = easeApproach(getSectionApproach(scroll.offset, SECTION_INDEX));
const step = Math.min(delta, 1 / 20);
- const sectionProgress = getSectionApproach(scroll.offset, SECTION_INDEX);
- const easedProgress = easeApproach(sectionProgress);
- const targetY = THREE.MathUtils.lerp(-2.25, 2.25, easedProgress);
- scanRef.current.position.y = THREE.MathUtils.damp(scanRef.current.position.y, targetY, 5.5, step);
- const material = scanRef.current.material as THREE.MeshBasicMaterial;
- material.opacity = THREE.MathUtils.lerp(0.08, 0.22, Math.sin(sectionProgress * Math.PI));
+
+ // The forklift and scan pass are driven only by chapter scroll progress.
+ // They settle at the selected point and do not run an idle animation.
+ if (forkliftRef.current) {
+ forkliftRef.current.position.x = THREE.MathUtils.lerp(-3.5, 2.45, progress);
+ }
+ if (liftRef.current) {
+ liftRef.current.position.y = 0.24 + progress * 0.78;
+ }
+ if (scanRef.current) {
+ const targetY = THREE.MathUtils.lerp(-2.25, 2.25, progress);
+ scanRef.current.position.y = THREE.MathUtils.damp(scanRef.current.position.y, targetY, 5.5, step);
+ const material = scanRef.current.material as THREE.MeshBasicMaterial;
+ material.opacity = THREE.MathUtils.lerp(0.06, 0.18, progress);
+ }
});
return (
-
- {/* Rack structural framework */}
-
- {[-4.2, -2.1, 0, 2.1, 4.2].map((x) => (
-
-
-
+ {/* Concrete slab and painted aisle/safety markings. */}
+
+
+
+
+ {[-2.06, -0.43, 1.16].map((z) => (
+
+
+
+
+ ))}
+
+ {/* Rear warehouse wall, loading bays, and overhead service lights. */}
+
+
+
+
+ {[-3.1, 0, 3.1].map((x) => (
+
+
+
+
- ))}
- {[-2.25, -0.75, 0.75, 2.25].map((y) => (
-
-
-
+
+
+
+
+ {[-0.67, 0.67].map((side) => (
+
+
+
+
+ ))}
+
+ ))}
+ {[-1.55, -0.15, 1.25].map((z) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Full double-sided rack frames, cross-beams and individually tracked pallets. */}
+
+
+
+
+
+ {/* Scroll-driven electric reach truck: it travels the aisle and raises
+ its forks toward the audited rack level, then stops with the scroll. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[-0.1, 0.1].map((x) => (
+
+
+
+
+ ))}
+
+
+ {[-0.24, 0.28].map((x) => (
+
+
+
))}
- {[-1.4, 0.6].map((z) => (
-
-
-
+ {[-0.24, 0.28].map((x) => (
+
+
+
))}
+
+
+
+
- {/* Sweeping optical scanning sheet */}
+ {/* Scanning sheet remains a single lightweight pass over the audited rack. */}
-
+
-
+
{problemBins.map((bin) => (
diff --git a/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx b/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx
index 18a78f7..69533ec 100644
--- a/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx
+++ b/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx
@@ -29,11 +29,18 @@ export function LiveTelemetryFeed({ open, onClose, language }: LiveTelemetryFeed
const [filterSeverity, setFilterSeverity] = useState("all");
useEffect(() => {
- // The drawer remains mounted so filter state survives closing, but a
- // hidden simulation must not allocate timers or play notification audio.
+ // The drawer remains mounted so filter state survives closing. Do not
+ // allocate timers or play notification audio while it is closed, paused,
+ // or the browser tab is in the background.
if (!open || isPaused) return;
- const interval = setInterval(() => {
+ let interval: ReturnType | null = null;
+ const stop = () => {
+ if (interval === null) return;
+ clearInterval(interval);
+ interval = null;
+ };
+ const tick = () => {
const randomIdx = Math.floor(Math.random() * SAMPLE_TELEMETRY_EVENTS.length);
const base = SAMPLE_TELEMETRY_EVENTS[randomIdx];
const now = new Date();
@@ -52,9 +59,23 @@ export function LiveTelemetryFeed({ open, onClose, language }: LiveTelemetryFeed
} else if (newEvent.severity === "optimized") {
sound.playSuccess();
}
- }, 4500);
-
- return () => clearInterval(interval);
+ };
+ const start = () => {
+ if (interval === null && document.visibilityState === "visible") {
+ interval = setInterval(tick, 4500);
+ }
+ };
+ const onVisibilityChange = () => {
+ if (document.visibilityState === "visible") start();
+ else stop();
+ };
+
+ start();
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ return () => {
+ stop();
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ };
}, [open, isPaused]);
if (!open) return null;
diff --git a/LOGISDATA/src/lib/data.ts b/LOGISDATA/src/lib/data.ts
index b9de95f..05eb9e2 100644
--- a/LOGISDATA/src/lib/data.ts
+++ b/LOGISDATA/src/lib/data.ts
@@ -134,7 +134,7 @@ export const presentationCopy: PresentationCopy = {
},
mismatch: { en: "Mismatch", ar: "عدم تطابق" },
audited: { en: "Audited", ar: "مدقق" },
- scanLabel: { en: "REAL-TIME SCAN", ar: "مسح لحظي" },
+ scanLabel: { en: "SIMULATED SCAN", ar: "مسح محاكى" },
callout: { en: "shelf variance detected", ar: "تم اكتشاف فرق على الرف" },
},
footer: {
diff --git a/LOGISDATA/src/lib/device.ts b/LOGISDATA/src/lib/device.ts
index 175d550..bb053ab 100644
--- a/LOGISDATA/src/lib/device.ts
+++ b/LOGISDATA/src/lib/device.ts
@@ -103,6 +103,24 @@ export function classifyDevice(input: {
};
}
+/**
+ * True when two profiles would drive identical rendering behaviour.
+ * Resize/orientation storms re-classify constantly; callers use this to
+ * keep the previous object (and its identity) when nothing observable
+ * changed, so `useSyncExternalStore` consumers do not re-render.
+ */
+export function deviceProfilesEqual(a: DeviceProfile, b: DeviceProfile): boolean {
+ return (
+ a.tier === b.tier &&
+ a.dpr[0] === b.dpr[0] &&
+ a.dpr[1] === b.dpr[1] &&
+ a.effects === b.effects &&
+ a.particleBudget === b.particleBudget &&
+ a.antialias === b.antialias &&
+ a.reducedMotion === b.reducedMotion
+ );
+}
+
/** Browser-side convenience wrapper around {@link classifyDevice}. */
export function probeDevice(webglOverride?: boolean): DeviceProfile {
if (typeof window === "undefined") return STATIC_PROFILE;
diff --git a/LOGISDATA/src/lib/motion.ts b/LOGISDATA/src/lib/motion.ts
index 81b4a5b..2c9a03e 100644
--- a/LOGISDATA/src/lib/motion.ts
+++ b/LOGISDATA/src/lib/motion.ts
@@ -39,6 +39,11 @@ export interface MotionOptions {
delay?: number;
}
+/** Native scroll behaviour aligned with the user's motion preference. */
+export function scrollBehaviorForMotion(reduced: boolean): ScrollBehavior {
+ return reduced ? "auto" : "smooth";
+}
+
/** Builds a framer-motion transition that automatically honours reduced motion. */
export function transition(
duration: keyof typeof DURATION | number = "base",
diff --git a/LOGISDATA/src/lib/preferenceStore.ts b/LOGISDATA/src/lib/preferenceStore.ts
index 9ca0f0a..aadf706 100644
--- a/LOGISDATA/src/lib/preferenceStore.ts
+++ b/LOGISDATA/src/lib/preferenceStore.ts
@@ -1,4 +1,4 @@
-import { probeDevice, STATIC_PROFILE, type DeviceProfile } from "./device";
+import { deviceProfilesEqual, probeDevice, STATIC_PROFILE, type DeviceProfile } from "./device";
import type { Language, ThemeMode } from "./types";
/**
@@ -66,7 +66,10 @@ function initialize(): void {
snapshot = {
language: storedLanguage === "ar" || storedLanguage === "en" ? storedLanguage : "en",
theme: storedTheme === "light" || storedTheme === "dark" ? storedTheme : readSystemTheme(),
- device: probeDevice(),
+ // Capability is stable for the page lifetime: re-initialisation (for
+ // example a cross-tab storage sync) must not spend another WebGL probe
+ // context when the first result is already known.
+ device: probeDevice(webglAvailable ?? undefined),
hydrated: true,
};
webglAvailable = snapshot.device.tier !== "none";
@@ -99,7 +102,14 @@ export function subscribe(listener: () => void): () => void {
// again. Reusing the cached capability avoids a WebGL context probe for
// every intermediate resize event during browser-chrome/orientation
// changes.
- update({ device: probeDevice(webglAvailable ?? undefined) });
+ const device = probeDevice(webglAvailable ?? undefined);
+ // A fresh profile object would otherwise replace the snapshot on every
+ // resize frame — re-rendering the whole provider tree and invalidating
+ // the memoized 3D scene even when the classification is unchanged.
+ // Keep the existing object (and its identity) until something actually
+ // observable changes.
+ if (deviceProfilesEqual(snapshot.device, device)) return;
+ update({ device });
};
const onEnvironmentChange = () => {
if (resizeFrame !== null) return;
diff --git a/LOGISDATA/src/lib/sound.ts b/LOGISDATA/src/lib/sound.ts
index 31058a7..64dcc7f 100644
--- a/LOGISDATA/src/lib/sound.ts
+++ b/LOGISDATA/src/lib/sound.ts
@@ -8,15 +8,33 @@
* - Smooth exponential gain envelopes to guarantee zero clicking or audio clipping.
*/
+const SOUND_PREFERENCE_KEY = "logisdata_sound_enabled";
+
+function readSoundPreference(): boolean {
+ if (typeof window === "undefined") return false;
+ try {
+ return window.localStorage.getItem(SOUND_PREFERENCE_KEY) === "true";
+ } catch {
+ // Storage can be unavailable in private browsing or hardened webviews.
+ return false;
+ }
+}
+
+function persistSoundPreference(enabled: boolean): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(SOUND_PREFERENCE_KEY, String(enabled));
+ } catch {
+ // Audio is an optional enhancement; persistence must never block toggling.
+ }
+}
+
class SoundSynthesizer {
private ctx: AudioContext | null = null;
private enabled = false;
constructor() {
- if (typeof window !== "undefined") {
- const stored = localStorage.getItem("logisdata_sound_enabled");
- this.enabled = stored === "true";
- }
+ this.enabled = readSoundPreference();
}
public isEnabled(): boolean {
@@ -25,9 +43,7 @@ class SoundSynthesizer {
public toggle(): boolean {
this.enabled = !this.enabled;
- if (typeof window !== "undefined") {
- localStorage.setItem("logisdata_sound_enabled", String(this.enabled));
- }
+ persistSoundPreference(this.enabled);
if (this.enabled) {
this.initContext();
this.playTone(880, 0.08, "sine", 0.05);
@@ -37,9 +53,7 @@ class SoundSynthesizer {
public setEnabled(val: boolean): void {
this.enabled = val;
- if (typeof window !== "undefined") {
- localStorage.setItem("logisdata_sound_enabled", String(this.enabled));
- }
+ persistSoundPreference(this.enabled);
}
private initContext(): AudioContext | null {
diff --git a/LOGISDATA/tests/e2e/navigation.spec.ts b/LOGISDATA/tests/e2e/navigation.spec.ts
index f49c249..97c9ebe 100644
--- a/LOGISDATA/tests/e2e/navigation.spec.ts
+++ b/LOGISDATA/tests/e2e/navigation.spec.ts
@@ -46,6 +46,23 @@ test.describe("section navigation", () => {
});
});
+ test("wheel scrolling settles on chapter snap points and reverses to the prior step", async ({ page }) => {
+ await enterControlRoom(page);
+ const progressbar = page.getByRole("progressbar");
+ const scrollTop = () => page.locator(".html-deck-host").evaluate((host) => host.parentElement?.scrollTop ?? -1);
+ const viewportHeight = await page.locator(".html-deck-host").evaluate(
+ (host) => host.parentElement?.clientHeight ?? 0,
+ );
+
+ await page.mouse.wheel(0, 700);
+ await expect(progressbar).toHaveAttribute("aria-valuenow", "2", { timeout: 15_000 });
+ await expect.poll(async () => Math.abs((await scrollTop()) - viewportHeight)).toBeLessThan(4);
+
+ await page.mouse.wheel(0, -700);
+ await expect(progressbar).toHaveAttribute("aria-valuenow", "1", { timeout: 15_000 });
+ await expect.poll(scrollTop).toBeLessThan(4);
+ });
+
test("the progress indicator tracks the active section", async ({ page }) => {
await enterControlRoom(page);
const progressbar = page.getByRole("progressbar");
diff --git a/LOGISDATA/tests/e2e/smoke.spec.ts b/LOGISDATA/tests/e2e/smoke.spec.ts
index 24f76ed..58a5bd8 100644
--- a/LOGISDATA/tests/e2e/smoke.spec.ts
+++ b/LOGISDATA/tests/e2e/smoke.spec.ts
@@ -7,6 +7,13 @@ test.describe("control room entry", () => {
await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ timeout: 30_000 });
});
+ test("the warehouse environment plate is served as a compact static image", async ({ request }) => {
+ const response = await request.get("/warehouse-interior.jpg");
+ expect(response.ok()).toBe(true);
+ expect(response.headers()["content-type"]).toContain("image/jpeg");
+ expect((await response.body()).byteLength).toBeLessThan(500_000);
+ });
+
test("the 3D runtime stays a deferred chunk, not part of the initial document", async ({ request }) => {
const html = await (await request.get("/")).text();
// The document is the static shell. If Three.js ever gets pulled into the
diff --git a/LOGISDATA/tests/unit/device.test.ts b/LOGISDATA/tests/unit/device.test.ts
index a731ad3..cc650a1 100644
--- a/LOGISDATA/tests/unit/device.test.ts
+++ b/LOGISDATA/tests/unit/device.test.ts
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { classifyDevice, detectWebGL, prefersReducedMotion, probeDevice, STATIC_PROFILE } from "@/lib/device";
+import {
+ classifyDevice,
+ detectWebGL,
+ deviceProfilesEqual,
+ prefersReducedMotion,
+ probeDevice,
+ STATIC_PROFILE,
+} from "@/lib/device";
const base = {
webgl: true,
@@ -83,6 +90,29 @@ describe("detectWebGL", () => {
});
});
+describe("deviceProfilesEqual", () => {
+ it("matches identical profiles and rejects any observable difference", () => {
+ const profile = classifyDevice(base);
+ expect(deviceProfilesEqual(profile, { ...profile })).toBe(true);
+ expect(deviceProfilesEqual(profile, { ...profile, tier: "medium" })).toBe(false);
+ expect(deviceProfilesEqual(profile, { ...profile, dpr: [1, 1] })).toBe(false);
+ expect(deviceProfilesEqual(profile, { ...profile, effects: !profile.effects })).toBe(false);
+ expect(deviceProfilesEqual(profile, { ...profile, particleBudget: 1 })).toBe(false);
+ expect(deviceProfilesEqual(profile, { ...profile, antialias: !profile.antialias })).toBe(false);
+ expect(deviceProfilesEqual(profile, { ...profile, reducedMotion: true })).toBe(false);
+ });
+
+ it("treats separately produced probes of the same environment as equal", () => {
+ expect(deviceProfilesEqual(classifyDevice(base), classifyDevice(base))).toBe(true);
+ expect(
+ deviceProfilesEqual(
+ classifyDevice(base),
+ classifyDevice({ ...base, viewportWidth: 420 }),
+ ),
+ ).toBe(false);
+ });
+});
+
describe("prefersReducedMotion / probeDevice", () => {
afterEach(() => vi.unstubAllGlobals());
diff --git a/LOGISDATA/tests/unit/motion.test.ts b/LOGISDATA/tests/unit/motion.test.ts
new file mode 100644
index 0000000..5c0a064
--- /dev/null
+++ b/LOGISDATA/tests/unit/motion.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+import { scrollBehaviorForMotion } from "@/lib/motion";
+
+describe("scrollBehaviorForMotion", () => {
+ it("uses immediate navigation when reduced motion is requested", () => {
+ expect(scrollBehaviorForMotion(true)).toBe("auto");
+ });
+
+ it("keeps smooth section transitions for users who allow motion", () => {
+ expect(scrollBehaviorForMotion(false)).toBe("smooth");
+ });
+});
diff --git a/LOGISDATA/tests/unit/newComponents.test.tsx b/LOGISDATA/tests/unit/newComponents.test.tsx
index faf3e1e..898bdf8 100644
--- a/LOGISDATA/tests/unit/newComponents.test.tsx
+++ b/LOGISDATA/tests/unit/newComponents.test.tsx
@@ -203,6 +203,30 @@ describe("LiveTelemetryFeed", () => {
expect(timer).not.toHaveBeenCalled();
});
+ it("suspends fixture updates in a background tab and resumes when visible", () => {
+ vi.useFakeTimers();
+ let visibility: DocumentVisibilityState = "visible";
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => visibility,
+ });
+ render( {}} language="en" />);
+ const log = screen.getByRole("log");
+
+ act(() => vi.advanceTimersByTime(4_500));
+ expect(log.children).toHaveLength(7);
+
+ visibility = "hidden";
+ document.dispatchEvent(new Event("visibilitychange"));
+ act(() => vi.advanceTimersByTime(9_000));
+ expect(log.children).toHaveLength(7);
+
+ visibility = "visible";
+ document.dispatchEvent(new Event("visibilitychange"));
+ act(() => vi.advanceTimersByTime(4_500));
+ expect(log.children).toHaveLength(8);
+ });
+
it.each([
[0, "playAlert"],
[0.7, "playSuccess"],
diff --git a/LOGISDATA/tests/unit/preferenceStore.test.ts b/LOGISDATA/tests/unit/preferenceStore.test.ts
index 584d5e6..3b6061e 100644
--- a/LOGISDATA/tests/unit/preferenceStore.test.ts
+++ b/LOGISDATA/tests/unit/preferenceStore.test.ts
@@ -86,3 +86,63 @@ describe("preference store", () => {
setItem.mockRestore();
});
});
+
+describe("device refresh path", () => {
+ it("keeps snapshot identity on an unchanged resize and updates on a tier change", async () => {
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
+ getExtension: () => ({ loseContext: vi.fn() }),
+ } as unknown as RenderingContext);
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1920 });
+ Object.defineProperty(window.navigator, "hardwareConcurrency", { configurable: true, value: 8 });
+ Object.defineProperty(window.navigator, "deviceMemory", { configurable: true, value: 8 });
+ vi.spyOn(window, "matchMedia").mockImplementation(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }) as unknown as MediaQueryList);
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
+ queueMicrotask(() => cb(0));
+ return 1;
+ });
+
+ const listener = vi.fn();
+ const unsubscribe = subscribe(listener);
+ try {
+ const before = getSnapshot();
+ expect(before.device.tier).toBe("high");
+
+ // A resize that leaves the classification unchanged must not notify
+ // subscribers or replace the snapshot object.
+ window.dispatchEvent(new Event("resize"));
+ await Promise.resolve();
+ expect(listener).not.toHaveBeenCalled();
+ expect(getSnapshot()).toBe(before);
+
+ // A genuine tier change is propagated exactly once.
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 420 });
+ window.dispatchEvent(new Event("resize"));
+ await Promise.resolve();
+ expect(listener).toHaveBeenCalledOnce();
+ expect(getSnapshot()).not.toBe(before);
+ expect(getSnapshot().device.tier).toBe("medium");
+ } finally {
+ unsubscribe();
+ }
+ });
+
+ it("does not spend another WebGL probe when cross-tab sync re-initialises", () => {
+ const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext");
+ const listener = vi.fn();
+ const unsubscribe = subscribe(listener);
+ try {
+ // First initialisation probed the capability (three getContext calls).
+ getContext.mockClear();
+
+ window.dispatchEvent(new StorageEvent("storage", { key: LANGUAGE_KEY, newValue: "ar" }));
+ expect(getContext).not.toHaveBeenCalled();
+ expect(listener).toHaveBeenCalledOnce();
+ } finally {
+ unsubscribe();
+ }
+ });
+});
diff --git a/LOGISDATA/tests/unit/sound.test.ts b/LOGISDATA/tests/unit/sound.test.ts
index e7b078b..0180b99 100644
--- a/LOGISDATA/tests/unit/sound.test.ts
+++ b/LOGISDATA/tests/unit/sound.test.ts
@@ -25,6 +25,25 @@ describe("sound synthesizer engine", () => {
expect(sound.isEnabled()).toBe(false);
});
+ it("keeps audio controls usable when browser storage is denied", () => {
+ const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("storage denied");
+ });
+ const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("storage denied");
+ });
+ try {
+ expect(() => sound.setEnabled(true)).not.toThrow();
+ expect(sound.isEnabled()).toBe(true);
+ expect(() => sound.toggle()).not.toThrow();
+ expect(sound.isEnabled()).toBe(false);
+ } finally {
+ getItem.mockRestore();
+ setItem.mockRestore();
+ sound.setEnabled(false);
+ }
+ });
+
it("executes audio trigger methods without throwing exceptions", () => {
sound.setEnabled(false); // Muted
expect(() => sound.playClick()).not.toThrow();