fix: synchronize product visuals and apply Ozon light theme

Stop React RigidBody props from fighting Rapier mesh sync on playback ticks, and convert the shell/scene to the centralized Ozon light palette.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-08-02 07:09:17 +00:00
parent dce7faee24
commit 30a9f7efb3
6 changed files with 399 additions and 117 deletions

View File

@@ -8,12 +8,13 @@ import { getRenderedItemDimensions } from '../../domain/physicalLayout';
import * as THREE from 'three';
import RealItemModel, { isProductAssetReady } from './RealItemModel';
import { ItemVerificationOverlay } from './RealModelVerification';
import { CATEGORY_COLORS, INDUSTRIAL_PALETTE } from '../../domain/industrialTheme';
const COLORS = {
B: '#16a34a',
C: '#ea580c',
D: '#7c3aed',
sensorAccent: '#3b82f6',
B: CATEGORY_COLORS.B,
C: CATEGORY_COLORS.C,
D: CATEGORY_COLORS.D,
sensorAccent: INDUSTRIAL_PALETTE.sensorAccent,
};
/** Base real-model materials per SKU (Stage 1 §20 — basic, form-revealing). */

View File

@@ -5,7 +5,7 @@
* C/D redirection is contact-only against kinematic CAD diverter colliders.
* No junction setTranslation / route-specific lateral impulses.
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import {
@@ -62,7 +62,7 @@ function colliderDensity(profile: ReturnType<typeof getProductPhysicsProfile>):
return profile.massKg / Math.max(volume, 1e-6);
}
export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhysics({
function PhysicalPlaybackItemPhysicsInner({
caseData,
elapsedMs,
slotIndex = 0,
@@ -134,7 +134,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseData.id, profile.productId]);
useEffect(() => {
useLayoutEffect(() => {
bodyIdentity.current = `${caseData.id}:${itemId}`;
authority.current = isFault ? 'fault_kinematic' : 'preparing';
phaseRef.current = 'preparing';
@@ -148,6 +148,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
setSpawned(ready);
const body = bodyRef.current;
if (body) {
// Imperative spawn pose — never reapplied via React RigidBody props.
body.setBodyType(RigidBodyType.KinematicPositionBased, false);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
@@ -350,16 +351,17 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
if (elapsedMs < 0) return null;
const density = colliderDensity(profile);
const enabledRotations: [boolean, boolean, boolean] = [
!profile.lockRotationX,
!profile.lockRotationY,
!profile.lockRotationZ,
];
const enabledRotations = useMemo<[boolean, boolean, boolean]>(
() => [!profile.lockRotationX, !profile.lockRotationY, !profile.lockRotationZ],
[profile.lockRotationX, profile.lockRotationY, profile.lockRotationZ],
);
// Initial pose props are mount-only (component is memoized against elapsedMs
// for non-fault cases). Never pass `type` — imperative setBodyType owns it.
// World transform authority after spawn: Rapier body → R3F object3D sync.
return (
<RigidBody
ref={bodyRef}
type="kinematicPosition"
colliders={false}
friction={profile.beltFriction}
restitution={profile.restitution}
@@ -371,6 +373,8 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
enabledRotations={enabledRotations}
position={spawnPose.position}
rotation={spawnPose.rotation}
name={`product-body-${itemId}`}
userData={{ productId: itemId, bodyIdentity: bodyIdentity.current }}
>
{spawned && profile.collider.type === 'cuboid' && (
<CuboidCollider
@@ -398,7 +402,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
/>
)}
<group visible={spawned}>
<group name="ProductVisualGroup" visible={spawned}>
<ItemVisualContent
caseData={caseData}
phase={pose.phase}
@@ -411,4 +415,25 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
</group>
</RigidBody>
);
});
}
/**
* Block playback-tick re-renders for normal (non-fault) products so React
* RigidBody props cannot fight Rapier's mesh sync every frame.
*/
export const PhysicalPlaybackItemPhysics = memo(
PhysicalPlaybackItemPhysicsInner,
(prev, next) => {
if (prev.caseData.id !== next.caseData.id) return false;
if (prev.slotIndex !== next.slotIndex) return false;
if (prev.castShadow !== next.castShadow) return false;
if (prev.verifySku !== next.verifySku) return false;
if (prev.caseData.faultType !== next.caseData.faultType) return false;
if (prev.jitter !== next.jitter) return false;
// Fault kinematics still need elapsedMs; physical products do not.
if (next.caseData.faultType) {
return prev.elapsedMs === next.elapsedMs;
}
return true;
},
);

View File

@@ -114,12 +114,12 @@ const COLORS = {
supports: INDUSTRIAL_PALETTE.plastic,
motor: INDUSTRIAL_PALETTE.metalDark,
sensorAccent: INDUSTRIAL_PALETTE.sensorAccent,
sensorActive: '#60a5fa',
sensorActive: INDUSTRIAL_PALETTE.sensorActive,
gateFrame: INDUSTRIAL_PALETTE.metal,
routeB: INDUSTRIAL_PALETTE.routeB,
routeC: INDUSTRIAL_PALETTE.routeC,
routeD: INDUSTRIAL_PALETTE.routeD,
itemShadow: '#3a4a5a',
itemShadow: INDUSTRIAL_PALETTE.metalDark,
};
const ITEM_MATERIALS: Record<string, { color: string; roughness: number; metalness?: number }> = {
@@ -505,10 +505,13 @@ function ZoneMarker({ position, label, color, active }: {
</mesh>
<Html position={[0, 0.15, 0]} center>
<div style={{
color: active ? color : '#64748b',
color: active ? color : INDUSTRIAL_PALETTE.text,
fontSize: '20px',
fontWeight: 800,
textShadow: active ? `0 0 8px ${color}` : 'none',
padding: '2px 8px',
borderRadius: 8,
background: 'rgba(255,255,255,0.88)',
border: `1px solid ${active ? color : INDUSTRIAL_PALETTE.gridCell}`,
userSelect: 'none',
}}>
{label}
@@ -553,7 +556,16 @@ function BReceiverBin({ active }: { active: boolean }) {
<meshStandardMaterial color={metal} roughness={0.65} metalness={0.35} />
</mesh>
<Html position={[centerX, floorY + wallHeight + 0.18, centerZ]} center>
<div style={{ color: active ? COLORS.routeB : '#64748b', fontSize: '20px', fontWeight: 800, userSelect: 'none' }}>B</div>
<div style={{
color: active ? COLORS.routeB : INDUSTRIAL_PALETTE.text,
fontSize: '20px',
fontWeight: 800,
padding: '2px 8px',
borderRadius: 8,
background: 'rgba(255,255,255,0.88)',
border: `1px solid ${active ? COLORS.routeB : INDUSTRIAL_PALETTE.gridCell}`,
userSelect: 'none',
}}>B</div>
</Html>
</group>
);
@@ -582,10 +594,13 @@ function RollCage({ position, label, color, active, shadows = false }: {
{/* Label */}
<Html position={[0, height + 0.15, 0]} center>
<div style={{
color: active ? color : '#64748b',
color: active ? color : INDUSTRIAL_PALETTE.text,
fontSize: '18px',
fontWeight: 800,
textShadow: active ? `0 0 8px ${color}` : 'none',
padding: '2px 8px',
borderRadius: 8,
background: 'rgba(255,255,255,0.88)',
border: `1px solid ${active ? color : INDUSTRIAL_PALETTE.gridCell}`,
userSelect: 'none',
}}>
{label}
@@ -1025,8 +1040,9 @@ function ContinuousScene({
const protoShadows = proto ? (proto && stage0!.shadows) : shadowsEnabled;
const protoCamera = proto && stage0!.camera;
const shotOverride = protoCamera && stage0!.shot ? shotToPhaseCategory(stage0!.shot) : null;
// Stage 2: premium industrial dark environment is the default look.
const darkBg = proto ? stage0!.darkBackground : true;
// Ozon light presentation environment is the default product look.
// Prototype stage0 may still request a dark background explicitly.
const darkBg = proto ? stage0!.darkBackground : false;
const cameraHighlight = shouldHighlightCamera(phase);
const showScan = shouldShowScanEffect(phase);
@@ -1174,13 +1190,18 @@ function ContinuousScene({
return (
<>
{/* Background: industrial dark product canvas (equipment must dominate) */}
<color attach="background" args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : '#070d16']} />
<fog attach="fog" args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : '#070d16', 13, 26]} />
{/* Background: Ozon light presentation canvas (equipment stays readable) */}
<color
attach="background"
args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : INDUSTRIAL_PALETTE.background]}
/>
<fog
attach="fog"
args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : INDUSTRIAL_PALETTE.background, 16, 32]}
/>
{proto ? (
<>
{/* Cinematic rig: very low ambient, strong key, soft fill, cool rim */}
<ambientLight intensity={stage0!.ambient} />
<hemisphereLight args={['#223148', '#0b1220', 0.3]} />
<directionalLight
@@ -1197,21 +1218,18 @@ function ContinuousScene({
shadow-camera-far={25}
shadow-bias={-0.0004}
/>
{/* Fill — keeps shadowed side readable */}
{stage0!.fill && <directionalLight position={[-5, 6, -3]} intensity={0.35} />}
{/* Rim — cheap back light for edge separation */}
{stage0!.rim && <directionalLight position={[2, 5, -8]} intensity={0.7} color="#bcd7ff" />}
</>
) : (
<>
{/* Stage 2 default: premium industrial rig — readable ambient, key with
PCF shadows, soft fill, cool rim (Stage 0 proven values, brightened
in 2B so brackets/rollers read as volumes from every angle) */}
<ambientLight intensity={0.46} />
<hemisphereLight args={['#314860', '#0a1018', 0.9]} />
{/* Light Ozon studio: cool ambient, neutral key, soft shadows */}
<ambientLight intensity={0.72} />
<hemisphereLight args={[INDUSTRIAL_PALETTE.lightFill, INDUSTRIAL_PALETTE.gridCell, 0.85]} />
<directionalLight
position={[6, 9, 4]}
intensity={2.7}
intensity={1.55}
color={INDUSTRIAL_PALETTE.lightKey}
castShadow={shadowsEnabled}
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
@@ -1221,46 +1239,44 @@ function ContinuousScene({
shadow-camera-bottom={-7}
shadow-camera-near={1}
shadow-camera-far={25}
shadow-bias={-0.00035}
shadow-normalBias={0.025}
shadow-bias={-0.0003}
shadow-normalBias={0.03}
/>
<directionalLight position={[-5, 6, -3]} intensity={0.7} />
<directionalLight position={[2, 5, -8]} intensity={0.85} color="#bcd7ff" />
{/* low front fill so the +Z face (camera side) never goes black */}
<directionalLight position={[1, 3, 8]} intensity={0.55} color="#cfdcf2" />
{/* Procedural studio environment (no external HDRI — offline-safe) */}
<directionalLight position={[-5, 6, -3]} intensity={0.55} color={INDUSTRIAL_PALETTE.lightFill} />
<directionalLight position={[2, 5, -8]} intensity={0.45} color={INDUSTRIAL_PALETTE.sensorActive} />
<directionalLight position={[1, 3, 8]} intensity={0.4} color={INDUSTRIAL_PALETTE.lightKey} />
{shadowsEnabled && (
<Environment resolution={64} frames={1}>
<Lightformer intensity={1.6} position={[0, 5, 0]} rotation-x={Math.PI / 2} scale={[8, 8, 1]} color="#dfe9ff" />
<Lightformer intensity={0.7} position={[-5, 2, -4]} rotation-y={Math.PI / 3} scale={[4, 2, 1]} color="#b8c8e8" />
<Lightformer intensity={0.5} position={[5, 1.5, 3]} rotation-y={-Math.PI / 4} scale={[3, 1.5, 1]} color="#ffe9c8" />
<Lightformer intensity={1.8} position={[0, 5, 0]} rotation-x={Math.PI / 2} scale={[8, 8, 1]} color={INDUSTRIAL_PALETTE.lightKey} />
<Lightformer intensity={0.6} position={[-5, 2, -4]} rotation-y={Math.PI / 3} scale={[4, 2, 1]} color={INDUSTRIAL_PALETTE.lightFill} />
<Lightformer intensity={0.35} position={[5, 1.5, 3]} rotation-y={-Math.PI / 4} scale={[3, 1.5, 1]} color={INDUSTRIAL_PALETTE.lightKey} />
</Environment>
)}
</>
)}
{/* Grid — subdued so equipment remains the visual subject */}
{/* Grid — subtle Ozon blue-gray */}
<Grid
args={[16, 12]}
cellSize={0.5}
cellThickness={0.22}
cellColor={darkBg ? '#1a2533' : '#1e2a38'}
cellThickness={0.28}
cellColor={darkBg ? INDUSTRIAL_PALETTE.metalDark : INDUSTRIAL_PALETTE.gridCell}
sectionSize={2}
sectionThickness={0.45}
sectionColor={darkBg ? '#243344' : '#2a3a4c'}
fadeDistance={10}
sectionThickness={0.4}
sectionColor={darkBg ? INDUSTRIAL_PALETTE.frame : INDUSTRIAL_PALETTE.sensorActive}
fadeDistance={12}
infiniteGrid={false}
position={[0, 0.001, 0]}
/>
{/* Floor — dark polished concrete with soft reflections */}
{/* Floor — light cool gray */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow={shadowsEnabled}>
<planeGeometry args={[16, 12]} />
<meshStandardMaterial
color={darkBg ? '#121a26' : '#151d2a'}
roughness={darkBg ? 0.64 : 0.8}
metalness={darkBg ? 0.2 : 0.1}
envMapIntensity={0.5}
color={darkBg ? '#121a26' : INDUSTRIAL_PALETTE.floor}
roughness={darkBg ? 0.64 : 0.88}
metalness={darkBg ? 0.2 : 0.04}
envMapIntensity={0.35}
/>
</mesh>

View File

@@ -1,30 +1,55 @@
/**
* Shared industrial visual tokens for the continuous digital twin.
* Keeps both modes looking like projections of one system.
* Shared visual tokens for the continuous digital twin.
* Ozon light presentation environment (runtime materials / lights only).
* CSS mirrors these values in `src/styles.css` (:root).
*/
/** Canonical Ozon brand + semantic tokens (source of truth for tests / 3D). */
export const OZON_TOKENS = {
blue: '#005BFF',
magenta: '#F1117E',
darkSpace: '#001A34',
morning: '#00A2FF',
green: '#00BE6C',
orange: '#FFA800',
white: '#FFFFFF',
page: '#F5F7FA',
surface: '#FFFFFF',
surfaceSoft: '#EEF4FF',
border: '#DCE6F5',
muted: '#5F6F82',
} as const;
export const INDUSTRIAL_PALETTE = {
background: '#e8eef4',
backgroundDark: '#0b1220',
floor: '#d5dde8',
gridCell: '#c5d0de',
gridSection: '#9aabbf',
/** Scene clear / fog — soft Ozon morning surface */
background: OZON_TOKENS.surfaceSoft,
backgroundDark: OZON_TOKENS.darkSpace,
floor: '#E2EAF5',
gridCell: '#C5D4EA',
gridSection: OZON_TOKENS.blue,
belt: '#2f3a48',
beltStripe: '#f1c40f',
beltStripe: OZON_TOKENS.orange,
metal: '#7b8796',
metalDark: '#4a5563',
frame: '#5b6b7c',
plastic: '#94a3b8',
rubber: '#1f2937',
cardboard: '#b68b58',
sensorAccent: '#3b82f6',
routeB: '#16a34a',
routeC: '#ea580c',
routeD: '#7c3aed',
fault: '#ef4444',
warning: '#f59e0b',
lightKey: '#f8fafc',
lightFill: '#d0dae8',
/** Ozon blue — primary technical markers */
sensorAccent: OZON_TOKENS.blue,
sensorActive: OZON_TOKENS.morning,
/** Success / B */
routeB: OZON_TOKENS.green,
/** Attention / C */
routeC: OZON_TOKENS.orange,
/** Active selection / D accent (magenta used selectively) */
routeD: OZON_TOKENS.magenta,
fault: OZON_TOKENS.magenta,
warning: OZON_TOKENS.orange,
success: OZON_TOKENS.green,
lightKey: OZON_TOKENS.white,
lightFill: OZON_TOKENS.surfaceSoft,
text: OZON_TOKENS.darkSpace,
} as const;
export const INDUSTRIAL_MATERIALS = {

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
SETTLE_DURATION_SEC,
SETTLE_LINEAR_SPEED_MPS,
SETTLE_ANGULAR_SPEED_RAD_S,
DOCUMENTED_CONTACT_PLANE_S,
DOCUMENTED_CLEAR_PLANE_S,
} from './junctionContactPhysics';
import { BELT_SPEED_MPS } from './productPhysicsProfiles';
import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep';
import {
DIVERTER_LEFT_SIGNED_DEG,
DIVERTER_RIGHT_SIGNED_DEG,
categoryToPhysicalRoute,
OPENING_SAFETY_MARGIN_SEC,
rotationDurationSec,
} from './pusherMotion';
import { INDUSTRIAL_PALETTE, OZON_TOKENS } from './industrialTheme';
import physicsSrc from '../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw';
import visualSrc from '../components/ThreeD/PhysicalPlaybackItem.tsx?raw';
import sceneSrc from '../components/ThreeD/SorterDigitalTwinContinuous.tsx?raw';
import mainSrc from '../main.tsx?raw';
describe('product visual / rigid-body sync contracts', () => {
it('keeps a single RigidBody visual hierarchy with mount-only pose + memo', () => {
expect(physicsSrc).toMatch(/<RigidBody[\s\S]*ProductVisualGroup[\s\S]*ItemVisualContent[\s\S]*<\/RigidBody>/);
expect(physicsSrc).toMatch(/name="ProductVisualGroup"/);
// Initial pose is allowed; continuous type control is not (fights Dynamic).
expect(physicsSrc).toMatch(/position=\{spawnPose\.position\}/);
expect(physicsSrc).not.toMatch(/type="kinematicPosition"/);
expect(physicsSrc).toMatch(/setBodyType\(RigidBodyType\.Dynamic/);
expect(physicsSrc).toMatch(/Block playback-tick re-renders/);
expect(physicsSrc).toMatch(/castShadow=\{castShadow && spawned\}/);
});
it('visual model uses only local pivot offset (no world position writer)', () => {
expect(visualSrc).toMatch(/pivotOffsetY/);
expect(visualSrc).toMatch(/<group position=\{\[0, pivotOffsetY, 0\]\}>/);
expect(visualSrc).not.toMatch(/position=\{pose\.position\}/);
expect(visualSrc).not.toMatch(/position=\{currentWorld/);
expect(visualSrc).not.toMatch(/mesh\.position\.copy/);
});
it('one physics instance path remains (no duplicate PhysicalPlaybackItem mount)', () => {
const physicsMounts = sceneSrc.match(/PhysicalPlaybackItemPhysics/g) ?? [];
const kinematicMounts = sceneSrc.match(/<PhysicalPlaybackItem[\s>]/g) ?? [];
expect(physicsMounts.length).toBeGreaterThanOrEqual(1);
expect(kinematicMounts.length).toBe(0);
});
});
describe('falling / physics freeze regression', () => {
it('receiver settle thresholds unchanged', () => {
expect(SETTLE_LINEAR_SPEED_MPS).toBe(0.20);
expect(SETTLE_ANGULAR_SPEED_RAD_S).toBe(1.0);
expect(SETTLE_DURATION_SEC).toBe(0.30);
});
it('B/C/D routing, belt, timestep, diverter timing unchanged', () => {
expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT');
expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT');
expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT');
expect(DIVERTER_LEFT_SIGNED_DEG).toBe(-45);
expect(DIVERTER_RIGHT_SIGNED_DEG).toBe(45);
expect(rotationDurationSec()).toBeCloseTo(0.5, 6);
expect(OPENING_SAFETY_MARGIN_SEC).toBeCloseTo(0.15, 6);
expect(DOCUMENTED_CONTACT_PLANE_S).toBe(1.0538);
expect(DOCUMENTED_CLEAR_PLANE_S).toBe(1.6);
expect(BELT_SPEED_MPS).toBe(1.0);
expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12);
});
});
describe('Ozon light theme tokens', () => {
it('defines centralized Ozon tokens; CSS stylesheet is wired', () => {
expect(OZON_TOKENS.blue).toBe('#005BFF');
expect(OZON_TOKENS.magenta).toBe('#F1117E');
expect(OZON_TOKENS.darkSpace).toBe('#001A34');
expect(OZON_TOKENS.morning).toBe('#00A2FF');
expect(OZON_TOKENS.green).toBe('#00BE6C');
expect(OZON_TOKENS.orange).toBe('#FFA800');
expect(OZON_TOKENS.page).toBe('#F5F7FA');
expect(OZON_TOKENS.surfaceSoft).toBe('#EEF4FF');
// Magenta is selective accent — never the page background token.
expect(OZON_TOKENS.page).not.toBe(OZON_TOKENS.magenta);
expect(mainSrc).toMatch(/styles\.css/);
});
it('scene default is light Ozon environment', () => {
expect(INDUSTRIAL_PALETTE.background.toUpperCase()).toBe('#EEF4FF');
expect(INDUSTRIAL_PALETTE.sensorAccent.toUpperCase()).toBe('#005BFF');
expect(INDUSTRIAL_PALETTE.routeB.toUpperCase()).toBe('#00BE6C');
expect(sceneSrc).toMatch(/const darkBg = proto \? stage0!\.darkBackground : false/);
expect(sceneSrc).toMatch(/INDUSTRIAL_PALETTE\.background/);
});
});

View File

@@ -1,25 +1,54 @@
/* =========================================================
OZON Tech Sorter — Product Demo UI
OZON Tech Sorter — Product Demo UI (light Ozon palette)
========================================================= */
:root {
color-scheme: dark;
--bg: #050910;
--panel: rgba(10, 19, 31, 0.92);
--panel-strong: #0c1725;
--line: #1e3a54;
--muted: #91a4b8;
--text: #e5f2ff;
--cyan: #38bdf8;
--cyan-hover: #7dd3fc;
--green: #4ade80;
--orange: #f59e0b;
--purple: #c084fc;
--red: #fb3d4e;
color-scheme: light;
/* Brand tokens */
--ozon-blue: #005BFF;
--ozon-magenta: #F1117E;
--ozon-dark-space: #001A34;
--ozon-morning: #00A2FF;
--ozon-green: #00BE6C;
--ozon-orange: #FFA800;
--ozon-white: #FFFFFF;
--ozon-page: #F5F7FA;
--ozon-surface: #FFFFFF;
--ozon-surface-soft: #EEF4FF;
--ozon-border: #DCE6F5;
--ozon-text: #001A34;
--ozon-muted: #5F6F82;
/* Semantic */
--brand-primary: var(--ozon-blue);
--brand-secondary: var(--ozon-magenta);
--brand-light: var(--ozon-morning);
--status-success: var(--ozon-green);
--status-warning: var(--ozon-orange);
--text-primary: var(--ozon-dark-space);
--page-background: var(--ozon-page);
--panel-background: var(--ozon-surface);
--panel-border: var(--ozon-border);
--focus-ring: var(--ozon-blue);
/* Compat aliases used across the stylesheet */
--bg: var(--page-background);
--panel: var(--panel-background);
--panel-strong: var(--ozon-surface-soft);
--line: var(--panel-border);
--muted: var(--ozon-muted);
--text: var(--text-primary);
--cyan: var(--brand-primary);
--cyan-hover: var(--brand-light);
--green: var(--status-success);
--orange: var(--status-warning);
--purple: var(--brand-secondary);
--red: var(--brand-secondary);
--section-max: 1200px;
--radius: 16px;
--radius: 14px;
--tap: 44px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: "Manrope", "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
*,
@@ -45,9 +74,9 @@ body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 18% 8%, rgba(56, 189, 248, 0.14), transparent 28%),
radial-gradient(circle at 88% 18%, rgba(192, 132, 252, 0.1), transparent 24%),
linear-gradient(160deg, #03060b 0%, #07111d 52%, #050910 100%);
radial-gradient(circle at 12% 0%, rgba(0, 162, 255, 0.10), transparent 32%),
radial-gradient(circle at 88% 8%, rgba(0, 91, 255, 0.06), transparent 28%),
linear-gradient(180deg, var(--ozon-surface-soft) 0%, var(--page-background) 42%, var(--page-background) 100%);
color: var(--text);
font-size: 16px;
line-height: 1.5;
@@ -77,25 +106,26 @@ a {
button {
min-height: var(--tap);
border: 1px solid #2a4a66;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: #0e1b2b;
color: var(--text);
background: var(--panel-background);
color: var(--text-primary);
cursor: pointer;
font-weight: 700;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
box-shadow: 0 1px 2px rgba(0, 26, 52, 0.06);
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
button:hover {
border-color: var(--cyan);
background: #11273b;
border-color: var(--brand-primary);
background: var(--ozon-surface-soft);
transform: translateY(-1px);
}
button:focus-visible,
.nav-link:focus-visible,
.criteria-link:focus-visible {
outline: 2px solid var(--cyan);
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
@@ -121,19 +151,20 @@ button:disabled {
.btn-primary {
border: none;
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
color: #041018;
background: var(--brand-primary);
color: var(--ozon-white);
box-shadow: 0 2px 8px rgba(0, 91, 255, 0.28);
}
.btn-primary:hover {
background: linear-gradient(135deg, #7dd3fc, #38bdf8);
color: #041018;
background: var(--brand-light);
color: var(--ozon-white);
}
.btn-secondary {
background: #0e1b2b;
color: var(--text);
border: 1px solid #2a4a66;
background: var(--panel-background);
color: var(--text-primary);
border: 1px solid var(--panel-border);
}
.category-B { color: var(--green); }
@@ -3686,7 +3717,7 @@ button:disabled {
height: 100vh;
max-width: 100vw;
overflow: hidden;
background: #070d16;
background: var(--page-background);
}
.main-page--product .product-topbar,
@@ -3701,8 +3732,8 @@ button:disabled {
height: 68px;
max-height: 72px;
padding: 0 24px;
border-bottom: 1px solid rgba(56, 189, 248, 0.18);
background: rgba(6, 12, 20, 0.88);
border-bottom: 1px solid var(--panel-border);
background: var(--ozon-white);
backdrop-filter: blur(14px);
}
@@ -3720,9 +3751,9 @@ button:disabled {
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid rgba(56, 189, 248, 0.4);
background: rgba(56, 189, 248, 0.12);
color: #7dd3fc;
border: 1px solid var(--panel-border);
background: var(--ozon-surface-soft);
color: var(--brand-primary);
font-weight: 800;
font-size: 15px;
flex: 0 0 auto;
@@ -3732,7 +3763,7 @@ button:disabled {
font-size: 15px;
font-weight: 700;
line-height: 1.15;
color: #e8f2ff;
color: var(--text-primary);
}
.product-brand-sub {
@@ -3829,7 +3860,7 @@ button:disabled {
display: grid;
place-items: center;
color: var(--muted);
background: #070d16;
background: var(--page-background);
z-index: 2;
}
@@ -3840,8 +3871,8 @@ button:disabled {
height: 78px;
max-height: 84px;
padding: 0 24px 10px;
border-top: 1px solid rgba(56, 189, 248, 0.18);
background: rgba(6, 12, 20, 0.9);
border-top: 1px solid var(--panel-border);
background: var(--ozon-white);
backdrop-filter: blur(14px);
}
@@ -3895,7 +3926,9 @@ button:disabled {
position: static;
min-height: 40px;
border-radius: 10px;
background: rgba(12, 22, 34, 0.9);
background: var(--ozon-surface-soft);
border: 1px solid var(--panel-border);
color: var(--text-primary);
}
.product-sim-pill {
@@ -3990,3 +4023,89 @@ button:disabled {
font-size: 1.05rem;
}
}
/* =========================================================
Ozon light chrome overrides (shell / HUD / nav / controls)
========================================================= */
.main-hud,
.app-nav-overlay,
.app-nav-bar,
.main-progress {
background: rgba(255, 255, 255, 0.92) !important;
border-color: var(--panel-border) !important;
color: var(--text-primary);
box-shadow: 0 4px 18px rgba(0, 26, 52, 0.08);
}
.play-button,
.main-page--product .play-button {
background: var(--brand-primary) !important;
color: var(--ozon-white) !important;
box-shadow: 0 4px 16px rgba(0, 91, 255, 0.28);
}
.play-button:hover {
background: var(--brand-light) !important;
}
.app-nav-link.active {
color: var(--ozon-white) !important;
background: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
}
.app-nav-link:hover {
color: var(--brand-primary);
border-color: var(--panel-border);
background: var(--ozon-surface-soft);
}
.product-topbar,
.docs-product-topbar,
.main-page--product .product-topbar,
.docs-page .product-topbar {
background: var(--ozon-white) !important;
border-bottom: 1px solid var(--panel-border) !important;
color: var(--text-primary) !important;
box-shadow: 0 1px 0 rgba(0, 26, 52, 0.04);
}
.product-brand-title,
.product-tele-value {
color: var(--text-primary) !important;
}
.product-brand-sub,
.product-tele-label {
color: var(--ozon-muted) !important;
}
.main-page--product .app-nav-solid,
.docs-page .product-topbar .app-nav-solid {
background: var(--ozon-surface-soft) !important;
border-color: var(--panel-border) !important;
}
.product-webgl-note,
.docs-page {
background: var(--page-background) !important;
color: var(--text-primary);
}
.product-tele-value.category-B { color: var(--status-success) !important; }
.product-tele-value.category-C { color: var(--status-warning) !important; }
.product-tele-value.category-D { color: var(--brand-secondary) !important; }
.progress-dot {
background: var(--ozon-border);
}
.main-page--product .product-dock,
.main-page--product .main-controls .auto-camera-toggle,
.main-page--product .presentation-toggle,
.stop-button {
background: var(--ozon-white) !important;
border-color: var(--panel-border) !important;
color: var(--text-primary) !important;
}