design: redesign product demo UI
This commit is contained in:
178
src/App.tsx
178
src/App.tsx
@@ -1,28 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Header from './components/Header';
|
||||
import HeroSection from './components/HeroSection';
|
||||
import ProductDemoSection from './components/ProductDemoSection';
|
||||
import ScenarioCards from './components/ScenarioCards';
|
||||
import CriteriaCards from './components/CriteriaCards';
|
||||
import EngineeringDetails from './components/EngineeringDetails';
|
||||
import { DEMO_STEPS } from './data/demoSteps';
|
||||
import { SCENARIOS } from './data/scenarios';
|
||||
import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation';
|
||||
import type { DemoStep, PresentationMode, ScenarioId, SimulationState } from './domain/types';
|
||||
|
||||
import GuidedDemoView from './components/GuidedDemoView';
|
||||
|
||||
const TICK_MS = 250;
|
||||
|
||||
function isFaultDemoStep(step: DemoStep): boolean {
|
||||
return step.scenarioId === 'jam' || step.scenarioId === 'emergency_stop';
|
||||
}
|
||||
import { createSimulation, setRunning, stepSimulationToNextState } from './domain/simulation';
|
||||
import type { ScenarioId, SimulationState } from './domain/types';
|
||||
|
||||
export default function App() {
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
|
||||
const [presentationMode, setPresentationMode] = useState<PresentationMode>('guided');
|
||||
const [safeDemoEnabled, setSafeDemoEnabled] = useState(true);
|
||||
const [demoStepIndex, setDemoStepIndex] = useState(0);
|
||||
const [pendingFaultStepId, setPendingFaultStepId] = useState<string | undefined>();
|
||||
|
||||
const activeScenario = useMemo(
|
||||
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
|
||||
() => SCENARIOS.find((s) => s.id === activeScenarioId) ?? SCENARIOS[0],
|
||||
[activeScenarioId],
|
||||
);
|
||||
|
||||
const currentDemoStep = DEMO_STEPS[demoStepIndex];
|
||||
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
|
||||
|
||||
@@ -30,110 +26,70 @@ export default function App() {
|
||||
setSimulation(createSimulation(activeScenario));
|
||||
}, [activeScenario]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!simulation.running) {
|
||||
return undefined;
|
||||
// We are not using automatic interval in the new product demo by default to keep it step-by-step
|
||||
// but if the user wants it to run automatically, we can preserve it if needed.
|
||||
// Let's use handleNext to just step through.
|
||||
|
||||
const handleStartDemo = () => {
|
||||
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (!simulation.currentItem && demoStepIndex === 0) {
|
||||
setSimulation(stepSimulationToNextState(simulation));
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setSimulation((current) => stepSimulation(current, TICK_MS));
|
||||
}, TICK_MS);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [simulation.running]);
|
||||
|
||||
const handleStart = () => setSimulation((current) => setRunning(current, true));
|
||||
const handlePause = () => setSimulation((current) => setRunning(current, false));
|
||||
const handleReset = () => setSimulation(createSimulation(activeScenario));
|
||||
const handleStep = () => setSimulation((current) => stepSimulationToNextState(current));
|
||||
const handleScenarioChange = (scenarioId: ScenarioId) => {
|
||||
setPendingFaultStepId(undefined);
|
||||
setActiveScenarioId(scenarioId);
|
||||
};
|
||||
|
||||
const handleDemoStepChange = (nextIndex: number) => {
|
||||
setPendingFaultStepId(undefined);
|
||||
setDemoStepIndex(Math.max(0, Math.min(nextIndex, DEMO_STEPS.length - 1)));
|
||||
};
|
||||
|
||||
const handleApplyDemoStepScenario = (step: DemoStep) => {
|
||||
if (presentationMode === 'presentation' && safeDemoEnabled && isFaultDemoStep(step) && pendingFaultStepId !== step.id) {
|
||||
setPendingFaultStepId(step.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingFaultStepId(undefined);
|
||||
setActiveScenarioId(step.scenarioId);
|
||||
};
|
||||
|
||||
const handleRunPreferredAction = (step: DemoStep) => {
|
||||
if (activeScenarioId !== step.scenarioId) {
|
||||
handleApplyDemoStepScenario(step);
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.preferredAction === 'start') {
|
||||
handleStart();
|
||||
} else if (step.preferredAction === 'pause') {
|
||||
handlePause();
|
||||
} else if (step.preferredAction === 'reset') {
|
||||
handleReset();
|
||||
const handleNext = () => {
|
||||
// If simulation is finished or errored, we might need to reset or move to next scenario
|
||||
const isFinished = simulation.machineState === 'RETURN_HOME' || simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
if (isFinished) {
|
||||
const nextIndex = (demoStepIndex + 1) % DEMO_STEPS.length;
|
||||
setDemoStepIndex(nextIndex);
|
||||
setActiveScenarioId(DEMO_STEPS[nextIndex].scenarioId);
|
||||
} else {
|
||||
handleStep();
|
||||
setSimulation((current) => stepSimulationToNextState(current));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartDemo = () => {
|
||||
setPendingFaultStepId(undefined);
|
||||
setDemoStepIndex(0);
|
||||
setActiveScenarioId(DEMO_STEPS[0].scenarioId);
|
||||
const handleReset = () => {
|
||||
setSimulation(createSimulation(activeScenario));
|
||||
};
|
||||
|
||||
return presentationMode === 'guided' ? (
|
||||
<GuidedDemoView
|
||||
simulation={simulation}
|
||||
scenarios={SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
presentationMode={presentationMode}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
demoSteps={DEMO_STEPS}
|
||||
demoStepIndex={demoStepIndex}
|
||||
currentDemoStep={currentDemoStep}
|
||||
pendingFaultStepId={pendingFaultStepId}
|
||||
onStart={handleStart}
|
||||
onPause={handlePause}
|
||||
onReset={handleReset}
|
||||
onStep={handleStep}
|
||||
onScenarioChange={handleScenarioChange}
|
||||
onPresentationModeChange={setPresentationMode}
|
||||
onSafeDemoChange={setSafeDemoEnabled}
|
||||
onDemoStepChange={handleDemoStepChange}
|
||||
onApplyDemoStepScenario={handleApplyDemoStepScenario}
|
||||
onRunPreferredAction={handleRunPreferredAction}
|
||||
onRestartDemo={handleRestartDemo}
|
||||
/>
|
||||
) : (
|
||||
<Dashboard
|
||||
simulation={simulation}
|
||||
scenarios={SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
presentationMode={presentationMode}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
demoSteps={DEMO_STEPS}
|
||||
demoStepIndex={demoStepIndex}
|
||||
currentDemoStep={currentDemoStep}
|
||||
pendingFaultStepId={pendingFaultStepId}
|
||||
onStart={handleStart}
|
||||
onPause={handlePause}
|
||||
onReset={handleReset}
|
||||
onStep={handleStep}
|
||||
onScenarioChange={handleScenarioChange}
|
||||
onPresentationModeChange={setPresentationMode}
|
||||
onSafeDemoChange={setSafeDemoEnabled}
|
||||
onDemoStepChange={handleDemoStepChange}
|
||||
onApplyDemoStepScenario={handleApplyDemoStepScenario}
|
||||
onRunPreferredAction={handleRunPreferredAction}
|
||||
onRestartDemo={handleRestartDemo}
|
||||
/>
|
||||
const handleScenarioChange = (scenarioId: ScenarioId) => {
|
||||
setActiveScenarioId(scenarioId);
|
||||
// Find first step that matches this scenario
|
||||
const stepIdx = DEMO_STEPS.findIndex(s => s.scenarioId === scenarioId);
|
||||
if (stepIdx >= 0) setDemoStepIndex(stepIdx);
|
||||
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-page">
|
||||
<Header simulation={simulation} />
|
||||
|
||||
<main className="product-main">
|
||||
<HeroSection onStartDemo={handleStartDemo} />
|
||||
|
||||
<ProductDemoSection
|
||||
simulation={simulation}
|
||||
onNext={handleNext}
|
||||
onReset={handleReset}
|
||||
demoStepTitle={currentDemoStep.title}
|
||||
/>
|
||||
|
||||
<ScenarioCards
|
||||
scenarios={SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onScenarioChange={handleScenarioChange}
|
||||
/>
|
||||
|
||||
<CriteriaCards />
|
||||
|
||||
<EngineeringDetails simulation={simulation} />
|
||||
</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<p>OZON Tech Hackathon 2026. Sorter Simulation.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/components/CriteriaCards.tsx
Normal file
29
src/components/CriteriaCards.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { OZON_CRITERIA } from '../data/criteria';
|
||||
|
||||
export default function CriteriaCards() {
|
||||
return (
|
||||
<section className="criteria-cards-section" id="criteria">
|
||||
<div className="section-header">
|
||||
<h2>Покрытие критериев OZON</h2>
|
||||
<p>Каждый инженерный критерий хакатона покрывается симуляцией и тестами.</p>
|
||||
</div>
|
||||
|
||||
<div className="criteria-grid">
|
||||
{OZON_CRITERIA.map((criterion) => (
|
||||
<div key={criterion.id} className="criteria-card">
|
||||
<div className="criteria-card-header">
|
||||
<h3>{criterion.title}</h3>
|
||||
<span className={`status-badge ${criterion.status.replace(/ /g, '-')}`}>
|
||||
{criterion.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="criteria-evidence">{criterion.evidence}</p>
|
||||
<div className="criteria-footer">
|
||||
<span className="related-docs">Docs: {criterion.relatedDoc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,88 +11,60 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
||||
|
||||
if (!current) {
|
||||
return (
|
||||
<section className="proof-card proof-card-empty">
|
||||
<div className="proof-card proof-card-empty">
|
||||
<div className="proof-card-inner">
|
||||
<p className="eyebrow">Awaiting cycle</p>
|
||||
<h2>Ready to demonstrate sorting cycle</h2>
|
||||
<p>The demo will show detection, classification, control command and routing to B/C/D.</p>
|
||||
<div className="evidence-chain empty-chain">
|
||||
<span>Detection</span>
|
||||
<span className="arrow">→</span>
|
||||
<span>Classification</span>
|
||||
<span className="arrow">→</span>
|
||||
<span>Command</span>
|
||||
<span className="arrow">→</span>
|
||||
<span>Actuator</span>
|
||||
<span className="arrow">→</span>
|
||||
<span>Route</span>
|
||||
<span className="arrow">→</span>
|
||||
<span>Log</span>
|
||||
</div>
|
||||
<p className="start-prompt">Click <strong>Start Guided Demo</strong> or Next to begin.</p>
|
||||
<h3>Ready to demonstrate sorting cycle</h3>
|
||||
<p>Нажмите «Запустить демо» для начала или переключите сценарий ниже.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`proof-card ${stopped ? 'proof-card-stopped' : ''}`}>
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Current Item Proof</p>
|
||||
<h2>{item?.name} (ID: {item?.id})</h2>
|
||||
<div className={`proof-card ${stopped ? 'proof-card-stopped' : ''}`}>
|
||||
<div className="proof-card-header">
|
||||
<p className="eyebrow">Результат классификации</p>
|
||||
<h3>{item?.name} (ID: {item?.id})</h3>
|
||||
</div>
|
||||
|
||||
<div className="proof-grid">
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Dimensions</span>
|
||||
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
|
||||
{dimensions?.width} x {dimensions?.depth} x {dimensions?.height} mm
|
||||
</strong>
|
||||
<span className="proof-label">Категория</span>
|
||||
<strong className={`category-${result?.category || 'default'}`}>{result?.category || '-'}</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Roundness</span>
|
||||
<span className="proof-label">Маршрут</span>
|
||||
<strong>{result?.category ? `Зона ${result.category}` : '-'}</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Команда</span>
|
||||
<strong>{simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proof-details">
|
||||
<div className="detail-row">
|
||||
<span>Габариты:</span>
|
||||
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
|
||||
{dimensions?.width} x {dimensions?.depth} x {dimensions?.height} мм
|
||||
</strong>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span>Roundness:</span>
|
||||
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : ''}>
|
||||
{item?.roundness.toFixed(2)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Confidence</span>
|
||||
<strong className={lowConfidence ? 'warn-text' : ''}>
|
||||
{(item?.confidence ?? 0 * 100).toFixed(0)}%
|
||||
</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Category</span>
|
||||
<strong className={`category-${result?.category}`}>{result?.category}</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Command</span>
|
||||
<strong>{simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'}</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Target zone</span>
|
||||
<strong>{result?.category ? `Zone ${result.category}` : '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proof-reason">
|
||||
<strong>Decision reason:</strong> {result?.reason}
|
||||
{lowConfidence && <p className="warning-note">Low CV confidence — rule-based fallback used</p>}
|
||||
{simulation.machineState === 'FAULT' && <p className="error-note">FAULT: Conveyor stopped, reset required</p>}
|
||||
{simulation.machineState === 'EMERGENCY_STOP' && <p className="error-note">EMERGENCY_STOP: All actuators stopped</p>}
|
||||
{simulation.metrics.queueLength > 1 && <p className="warning-note">Spacing warning: sequential processing enforced</p>}
|
||||
<strong>Почему такое решение:</strong> {result?.reason}
|
||||
{lowConfidence && <p className="warning-note">Низкая уверенность CV — использован rule-based fallback</p>}
|
||||
{simulation.machineState === 'FAULT' && <p className="error-note">FAULT: Конвейер остановлен</p>}
|
||||
{simulation.machineState === 'EMERGENCY_STOP' && <p className="error-note">EMERGENCY_STOP: Аварийная остановка</p>}
|
||||
{simulation.metrics.queueLength > 1 && <p className="warning-note">Внимание: очередь товаров</p>}
|
||||
</div>
|
||||
<div className="evidence-chain active-chain">
|
||||
<span className={simulation.machineState === 'DETECTING' ? 'active-step' : 'done-step'}>Detection</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className={simulation.machineState === 'CLASSIFYING' ? 'active-step' : (result ? 'done-step' : '')}>Classification</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className={simulation.machineState.startsWith('ROUTE_TO_') ? 'active-step' : (result ? 'done-step' : '')}>Command</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className={simulation.actuators.pusherC !== 'idle' || simulation.actuators.pusherD !== 'idle' ? 'active-step' : (result ? 'done-step' : '')}>Actuator</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className={simulation.machineState.startsWith('ROUTE_TO_') ? 'active-step' : ''}>Route</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className={simulation.machineState === 'RETURN_HOME' ? 'active-step' : ''}>Log</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import Header from './Header';
|
||||
import SorterScene from './SorterScene';
|
||||
import CurrentItemPanel from './CurrentItemPanel';
|
||||
import ClassificationPanel from './ClassificationPanel';
|
||||
import StateMachinePanel from './StateMachinePanel';
|
||||
import MetricsPanel from './MetricsPanel';
|
||||
import EventLog from './EventLog';
|
||||
import ScenarioPanel from './ScenarioPanel';
|
||||
import SensorPanel from './SensorPanel';
|
||||
import PidPanel from './PidPanel';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import PresentationModePanel from './PresentationModePanel';
|
||||
import OzonCriteriaPanel from './OzonCriteriaPanel';
|
||||
import type { DemoFocusArea, DemoStep, PresentationMode, Scenario, ScenarioId, SimulationState } from '../domain/types';
|
||||
|
||||
interface DashboardProps {
|
||||
simulation: SimulationState;
|
||||
scenarios: Scenario[];
|
||||
activeScenarioId: ScenarioId;
|
||||
presentationMode: PresentationMode;
|
||||
safeDemoEnabled: boolean;
|
||||
demoSteps: DemoStep[];
|
||||
demoStepIndex: number;
|
||||
currentDemoStep: DemoStep;
|
||||
pendingFaultStepId?: string;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onStep: () => void;
|
||||
onScenarioChange: (scenarioId: ScenarioId) => void;
|
||||
onPresentationModeChange: (mode: PresentationMode) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
onDemoStepChange: (index: number) => void;
|
||||
onApplyDemoStepScenario: (step: DemoStep) => void;
|
||||
onRunPreferredAction: (step: DemoStep) => void;
|
||||
onRestartDemo: () => void;
|
||||
}
|
||||
|
||||
function focusClass(mode: PresentationMode, activeFocus: DemoFocusArea, panelFocus: DemoFocusArea | DemoFocusArea[]): string {
|
||||
if (mode !== 'presentation') {
|
||||
return '';
|
||||
}
|
||||
const focusList = Array.isArray(panelFocus) ? panelFocus : [panelFocus];
|
||||
return focusList.includes(activeFocus) || (activeFocus === 'safety' && focusList.includes('scene')) ? 'focus-highlight' : 'presentation-muted';
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
simulation,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
presentationMode,
|
||||
safeDemoEnabled,
|
||||
demoSteps,
|
||||
demoStepIndex,
|
||||
currentDemoStep,
|
||||
pendingFaultStepId,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onScenarioChange,
|
||||
onPresentationModeChange,
|
||||
onSafeDemoChange,
|
||||
onDemoStepChange,
|
||||
onApplyDemoStepScenario,
|
||||
onRunPreferredAction,
|
||||
onRestartDemo,
|
||||
}: DashboardProps) {
|
||||
const focusArea = currentDemoStep.focusArea;
|
||||
|
||||
return (
|
||||
<div className={`app-shell mode-${presentationMode}`}>
|
||||
<Header
|
||||
simulation={simulation}
|
||||
presentationMode={presentationMode}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
onStart={onStart}
|
||||
onPause={onPause}
|
||||
onReset={onReset}
|
||||
onStep={onStep}
|
||||
onPresentationModeChange={onPresentationModeChange}
|
||||
onSafeDemoChange={onSafeDemoChange}
|
||||
/>
|
||||
{presentationMode === 'presentation' ? (
|
||||
<PresentationModePanel
|
||||
steps={demoSteps}
|
||||
currentStep={currentDemoStep}
|
||||
currentIndex={demoStepIndex}
|
||||
activeScenarioId={activeScenarioId}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
pendingFaultStepId={pendingFaultStepId}
|
||||
onPrevious={() => onDemoStepChange(demoStepIndex - 1)}
|
||||
onNext={() => onDemoStepChange(demoStepIndex + 1)}
|
||||
onRestart={onRestartDemo}
|
||||
onApplyScenario={() => onApplyDemoStepScenario(currentDemoStep)}
|
||||
onRunPreferredAction={() => onRunPreferredAction(currentDemoStep)}
|
||||
/>
|
||||
) : null}
|
||||
<main className="dashboard-grid">
|
||||
<section className={`scene-column panel panel-scene ${focusClass(presentationMode, focusArea, ['scene', 'safety'])}`}>
|
||||
<SorterScene simulation={simulation} />
|
||||
</section>
|
||||
<aside className="right-rail">
|
||||
<div className={focusClass(presentationMode, focusArea, 'classification')}><CurrentItemPanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'classification')}><ClassificationPanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><StateMachinePanel currentState={simulation.machineState} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'scene')}><SensorPanel simulation={simulation} /></div>
|
||||
</aside>
|
||||
<section className="bottom-row">
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><MetricsPanel metrics={simulation.metrics} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><TimelinePanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, ['pid', 'safety'])}><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'eventLog')}><EventLog events={simulation.events} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'criteria')}>
|
||||
<OzonCriteriaPanel currentStep={currentDemoStep} />
|
||||
</div>
|
||||
<div className="scenario-panel-wrap">
|
||||
<ScenarioPanel
|
||||
scenarios={scenarios}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onScenarioChange={onScenarioChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/EngineeringDetails.tsx
Normal file
36
src/components/EngineeringDetails.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import StateMachinePanel from './StateMachinePanel';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import PidPanel from './PidPanel';
|
||||
import EventLog from './EventLog';
|
||||
import MetricsPanel from './MetricsPanel';
|
||||
import SensorPanel from './SensorPanel';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function EngineeringDetails({ simulation }: { simulation: SimulationState }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="engineering-details-section" id="engineering">
|
||||
<div className="engineering-toggle" onClick={() => setIsOpen(!isOpen)}>
|
||||
<h2>Инженерные детали (Engineering Mode)</h2>
|
||||
<button className="btn-secondary">
|
||||
{isOpen ? 'Свернуть' : 'Развернуть'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="engineering-content">
|
||||
<div className="engineering-grid">
|
||||
<div className="eng-panel"><StateMachinePanel currentState={simulation.machineState} /></div>
|
||||
<div className="eng-panel"><MetricsPanel metrics={simulation.metrics} /></div>
|
||||
<div className="eng-panel"><SensorPanel simulation={simulation} /></div>
|
||||
<div className="eng-panel"><TimelinePanel simulation={simulation} /></div>
|
||||
<div className="eng-panel"><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
|
||||
<div className="eng-panel"><EventLog events={simulation.events} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import SorterScene from './SorterScene';
|
||||
import CurrentProofCard from './CurrentProofCard';
|
||||
import { OZON_CRITERIA } from '../data/criteria';
|
||||
import type { DemoStep, Scenario, ScenarioId, SimulationState, PresentationMode } from '../domain/types';
|
||||
|
||||
interface GuidedDemoViewProps {
|
||||
simulation: SimulationState;
|
||||
scenarios: Scenario[];
|
||||
activeScenarioId: ScenarioId;
|
||||
presentationMode: PresentationMode;
|
||||
safeDemoEnabled: boolean;
|
||||
demoSteps: DemoStep[];
|
||||
demoStepIndex: number;
|
||||
currentDemoStep: DemoStep;
|
||||
pendingFaultStepId?: string;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onStep: () => void;
|
||||
onScenarioChange: (scenarioId: ScenarioId) => void;
|
||||
onPresentationModeChange: (mode: any) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
onDemoStepChange: (index: number) => void;
|
||||
onApplyDemoStepScenario: (step: DemoStep) => void;
|
||||
onRunPreferredAction: (step: DemoStep) => void;
|
||||
onRestartDemo: () => void;
|
||||
}
|
||||
|
||||
export default function GuidedDemoView({
|
||||
simulation,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
demoSteps,
|
||||
demoStepIndex,
|
||||
currentDemoStep,
|
||||
pendingFaultStepId,
|
||||
safeDemoEnabled,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onPresentationModeChange,
|
||||
onDemoStepChange,
|
||||
onApplyDemoStepScenario,
|
||||
onRunPreferredAction,
|
||||
onRestartDemo,
|
||||
}: GuidedDemoViewProps) {
|
||||
const isFaultStep = currentDemoStep.scenarioId === 'jam' || currentDemoStep.scenarioId === 'emergency_stop';
|
||||
const pendingFaultConfirmation = pendingFaultStepId === currentDemoStep.id;
|
||||
|
||||
const handleStartDemo = () => {
|
||||
if (!simulation.currentItem && demoStepIndex === 0 && !simulation.running) {
|
||||
onRunPreferredAction(currentDemoStep);
|
||||
} else if (demoStepIndex === 0 && simulation.running) {
|
||||
onRestartDemo();
|
||||
} else {
|
||||
onRunPreferredAction(currentDemoStep);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
onDemoStepChange(demoStepIndex + 1);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
onDemoStepChange(demoStepIndex - 1);
|
||||
};
|
||||
|
||||
const relatedCriteriaList = OZON_CRITERIA.filter(c => currentDemoStep.relatedCriteria.includes(c.id));
|
||||
|
||||
return (
|
||||
<div className="guided-demo-layout">
|
||||
{/* 1. Top Demo Header */}
|
||||
<header className="guided-header">
|
||||
<div className="guided-header-left">
|
||||
<p className="eyebrow">Guided Sorting Demo</p>
|
||||
<h1>Step {demoStepIndex + 1} / {demoSteps.length}: {currentDemoStep.title}</h1>
|
||||
</div>
|
||||
<div className="guided-header-center">
|
||||
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
|
||||
<span />
|
||||
{simulation.systemStatus}
|
||||
</div>
|
||||
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
|
||||
</div>
|
||||
<div className="guided-header-right">
|
||||
<button className="primary-action" onClick={handleStartDemo}>
|
||||
{simulation.currentItem ? 'Restart Guided Demo' : 'Start Guided Demo'}
|
||||
</button>
|
||||
<div className="nav-actions">
|
||||
<button onClick={handlePrevious} disabled={demoStepIndex === 0}>Previous</button>
|
||||
<button onClick={handleNext} disabled={demoStepIndex === demoSteps.length - 1}>Next</button>
|
||||
</div>
|
||||
<button onClick={onReset}>Reset</button>
|
||||
<button onClick={() => onPresentationModeChange('engineering')}>Back to Engineering</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Grid */}
|
||||
<main className="guided-grid">
|
||||
{/* 2. Large Scene Area */}
|
||||
<section className="guided-left">
|
||||
<div className="panel panel-scene guided-scene-panel">
|
||||
<SorterScene simulation={simulation} />
|
||||
<div className="guided-metrics-strip">
|
||||
<div className="guided-metric">
|
||||
<span>Cycle Time</span>
|
||||
<strong>{simulation.metrics.avgCycleTimeMs} ms</strong>
|
||||
</div>
|
||||
<div className="guided-metric">
|
||||
<span>CV Latency</span>
|
||||
<strong>{simulation.metrics.cvLatencyMs} ms</strong>
|
||||
</div>
|
||||
<div className="guided-metric">
|
||||
<span>Actuator Latency</span>
|
||||
<strong>{simulation.metrics.actuatorLatencyMs} ms</strong>
|
||||
</div>
|
||||
<div className="guided-metric">
|
||||
<span>Throughput</span>
|
||||
<strong>{simulation.metrics.throughputItemsPerMin} items/min</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Column */}
|
||||
<aside className="guided-right">
|
||||
{/* 3. Current Proof Card */}
|
||||
<CurrentProofCard simulation={simulation} />
|
||||
|
||||
{/* 4. Demo Narration Card */}
|
||||
<section className="panel guided-narration">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Demo Narration</p>
|
||||
<h2>{currentDemoStep.title}</h2>
|
||||
</div>
|
||||
<p className="explanation">{currentDemoStep.explanation}</p>
|
||||
<div className="narration-details">
|
||||
<div>
|
||||
<strong>Watch:</strong> {currentDemoStep.whatToWatch}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Jury Value:</strong> {currentDemoStep.juryValue}
|
||||
</div>
|
||||
</div>
|
||||
<blockquote className="presenter-phrase">
|
||||
"{currentDemoStep.presenterPhrase}"
|
||||
</blockquote>
|
||||
{isFaultStep && safeDemoEnabled && (
|
||||
<button
|
||||
className={`fault-confirm-btn ${pendingFaultConfirmation ? 'pending' : ''}`}
|
||||
onClick={() => onApplyDemoStepScenario(currentDemoStep)}
|
||||
>
|
||||
{pendingFaultConfirmation ? 'Click again to confirm fault scenario' : 'Apply fault scenario'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 5. Criteria Snapshot */}
|
||||
<section className="panel guided-criteria">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Criteria Snapshot</p>
|
||||
<h2>This step covers</h2>
|
||||
</div>
|
||||
<div className="criteria-list-mini">
|
||||
{relatedCriteriaList.map(c => (
|
||||
<div key={c.id} className="criterion-mini">
|
||||
<div className="criterion-mini-header">
|
||||
<strong>{c.title}</strong>
|
||||
<span className={`criterion-status status-${c.status.replace(/ /g, '-')}`}>{c.status}</span>
|
||||
</div>
|
||||
<p>{c.evidence}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +1,31 @@
|
||||
import type { PresentationMode, SimulationState } from '../domain/types';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
interface HeaderProps {
|
||||
simulation: SimulationState;
|
||||
presentationMode: PresentationMode;
|
||||
safeDemoEnabled: boolean;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onStep: () => void;
|
||||
onPresentationModeChange: (mode: PresentationMode) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
simulation,
|
||||
presentationMode,
|
||||
safeDemoEnabled,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onPresentationModeChange,
|
||||
onSafeDemoChange,
|
||||
}: HeaderProps) {
|
||||
const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
export default function Header({ simulation }: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<div>
|
||||
<p className="eyebrow">Intelligent robotic sorting line</p>
|
||||
<h1>OZON Tech Sorter Simulation</h1>
|
||||
<header className="site-header">
|
||||
<div className="header-logo">
|
||||
<span className="logo-icon">O</span>
|
||||
<div className="logo-text">
|
||||
<span className="logo-title">OZON Tech</span>
|
||||
<span className="logo-subtitle">Sorter Simulation</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-status">
|
||||
<div className="mode-toggle" role="group" aria-label="Dashboard mode">
|
||||
<button className={presentationMode === 'engineering' ? 'active' : ''} onClick={() => onPresentationModeChange('engineering')}>Engineering Mode</button>
|
||||
<button className={presentationMode === 'presentation' ? 'active' : ''} onClick={() => onPresentationModeChange('presentation')}>Presentation Dashboard</button>
|
||||
<button className={presentationMode === 'guided' ? 'active' : ''} onClick={() => onPresentationModeChange('guided')}>Guided Demo</button>
|
||||
</div>
|
||||
{presentationMode === 'presentation' ? (
|
||||
<button className={safeDemoEnabled ? 'safe-demo-toggle active' : 'safe-demo-toggle'} onClick={() => onSafeDemoChange(!safeDemoEnabled)}>
|
||||
Safe Demo: {safeDemoEnabled ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
) : null}
|
||||
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
|
||||
<span />
|
||||
{simulation.systemStatus}
|
||||
</div>
|
||||
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
|
||||
<div className="scenario-label">Sim t+{(simulation.simTimeMs / 1000).toFixed(1)}s</div>
|
||||
<div className="header-actions">
|
||||
<button onClick={onStart} disabled={locked}>Start</button>
|
||||
<button onClick={onPause}>Pause</button>
|
||||
<button onClick={onReset}>Reset</button>
|
||||
<button onClick={onStep} disabled={locked}>Step state</button>
|
||||
</div>
|
||||
|
||||
<div className="header-nav">
|
||||
<a href="#demo" className="nav-link">Демо</a>
|
||||
<a href="#scenarios" className="nav-link">Сценарии</a>
|
||||
<a href="#criteria" className="nav-link">Критерии</a>
|
||||
<a href="#engineering" className="nav-link">Инженерный режим</a>
|
||||
</div>
|
||||
|
||||
<div className="header-status-compact">
|
||||
<div className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></div>
|
||||
<span>{simulation.systemStatus}</span>
|
||||
<span className="sim-time">t+{(simulation.simTimeMs / 1000).toFixed(1)}s</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
51
src/components/HeroSection.tsx
Normal file
51
src/components/HeroSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }) {
|
||||
return (
|
||||
<section className="hero-section">
|
||||
<div className="hero-content">
|
||||
<h1 className="hero-title">Интеллектуальная система сортировки товаров</h1>
|
||||
<p className="hero-subtitle">
|
||||
Симуляция показывает полный цикл: обнаружение товара, классификация по правилам,
|
||||
управляющая команда и маршрутизация в зоны B, C, D.
|
||||
</p>
|
||||
|
||||
<div className="hero-badges">
|
||||
<span className="hero-badge">Computer Vision</span>
|
||||
<span className="hero-badge">Rule-based classification</span>
|
||||
<span className="hero-badge">Actuator routing</span>
|
||||
<span className="hero-badge">Cycle time</span>
|
||||
<span className="hero-badge">Fault handling</span>
|
||||
</div>
|
||||
|
||||
<div className="hero-actions">
|
||||
<button className="btn-primary" onClick={onStartDemo}>Запустить демо</button>
|
||||
<a href="#scenarios" className="btn-secondary">Показать сценарии</a>
|
||||
<a href="#engineering" className="btn-secondary">Инженерный режим</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-visual">
|
||||
<div className="hero-chain">
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">📷</div>
|
||||
<span>Detection</span>
|
||||
</div>
|
||||
<div className="chain-arrow">→</div>
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">🧠</div>
|
||||
<span>Classification</span>
|
||||
</div>
|
||||
<div className="chain-arrow">→</div>
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">⚡</div>
|
||||
<span>Command</span>
|
||||
</div>
|
||||
<div className="chain-arrow">→</div>
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">📦</div>
|
||||
<span>Routing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { DemoStep, ScenarioId } from '../domain/types';
|
||||
|
||||
interface PresentationModePanelProps {
|
||||
steps: DemoStep[];
|
||||
currentStep: DemoStep;
|
||||
currentIndex: number;
|
||||
activeScenarioId: ScenarioId;
|
||||
safeDemoEnabled: boolean;
|
||||
pendingFaultStepId?: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onRestart: () => void;
|
||||
onApplyScenario: () => void;
|
||||
onRunPreferredAction: () => void;
|
||||
}
|
||||
|
||||
export default function PresentationModePanel({
|
||||
steps,
|
||||
currentStep,
|
||||
currentIndex,
|
||||
activeScenarioId,
|
||||
safeDemoEnabled,
|
||||
pendingFaultStepId,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onRestart,
|
||||
onApplyScenario,
|
||||
onRunPreferredAction,
|
||||
}: PresentationModePanelProps) {
|
||||
const scenarioApplied = activeScenarioId === currentStep.scenarioId;
|
||||
const pendingFaultConfirmation = pendingFaultStepId === currentStep.id;
|
||||
const isFaultStep = currentStep.scenarioId === 'jam' || currentStep.scenarioId === 'emergency_stop';
|
||||
|
||||
return (
|
||||
<section className="presentation-panel panel">
|
||||
<div className="presentation-step-index">Step {currentIndex + 1} / {steps.length}</div>
|
||||
<div className="presentation-copy">
|
||||
<p className="eyebrow">Presentation Mode</p>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p>{currentStep.explanation}</p>
|
||||
</div>
|
||||
<div className="presentation-proof">
|
||||
<div>
|
||||
<strong>What to watch</strong>
|
||||
<span>{currentStep.whatToWatch}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>What it proves for jury</strong>
|
||||
<span>{currentStep.juryValue}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Current proof</strong>
|
||||
<span>classification decision {'->'} control command {'->'} physical route {'->'} logged result</span>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{currentStep.presenterPhrase}</blockquote>
|
||||
<div className="presentation-meta">
|
||||
<span>Recommended scenario: <strong>{currentStep.scenarioId}</strong></span>
|
||||
<span>Action: <strong>{currentStep.preferredAction}</strong></span>
|
||||
<span>Focus: <strong>{currentStep.focusArea}</strong></span>
|
||||
<span>Safe Demo: <strong>{safeDemoEnabled ? 'ON' : 'OFF'}</strong></span>
|
||||
</div>
|
||||
<div className="criteria-tags">
|
||||
{currentStep.relatedCriteria.map((criterion) => <span key={criterion}>{criterion}</span>)}
|
||||
</div>
|
||||
{safeDemoEnabled && isFaultStep && pendingFaultConfirmation ? (
|
||||
<p className="confirm-note">Click “Confirm fault scenario” again to intentionally switch to {currentStep.scenarioId}.</p>
|
||||
) : null}
|
||||
<div className="presentation-actions">
|
||||
<button onClick={onPrevious} disabled={currentIndex === 0}>Previous step</button>
|
||||
<button onClick={onNext} disabled={currentIndex === steps.length - 1}>Next step</button>
|
||||
<button onClick={onRestart}>Restart demo</button>
|
||||
<button className={scenarioApplied ? 'active' : ''} onClick={onApplyScenario}>
|
||||
{pendingFaultConfirmation ? 'Confirm fault scenario' : scenarioApplied ? 'Scenario applied' : 'Apply scenario'}
|
||||
</button>
|
||||
<button onClick={onRunPreferredAction}>{scenarioApplied ? 'Run suggested action' : 'Apply then run'}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
41
src/components/ProductDemoSection.tsx
Normal file
41
src/components/ProductDemoSection.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import SorterScene from './SorterScene';
|
||||
import CurrentProofCard from './CurrentProofCard';
|
||||
import StorylineStepper from './StorylineStepper';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
interface Props {
|
||||
simulation: SimulationState;
|
||||
onNext: () => void;
|
||||
onReset: () => void;
|
||||
demoStepTitle: string;
|
||||
}
|
||||
|
||||
export default function ProductDemoSection({ simulation, onNext, onReset, demoStepTitle }: Props) {
|
||||
return (
|
||||
<section className="product-demo-section" id="demo">
|
||||
<div className="demo-header">
|
||||
<h2>Live Demo</h2>
|
||||
<div className="demo-status">
|
||||
Текущий шаг: <strong>{demoStepTitle}</strong>
|
||||
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StorylineStepper simulation={simulation} />
|
||||
|
||||
<div className="demo-layout">
|
||||
<div className="demo-scene-container">
|
||||
<SorterScene simulation={simulation} />
|
||||
</div>
|
||||
|
||||
<div className="demo-proof-container">
|
||||
<CurrentProofCard simulation={simulation} />
|
||||
<div className="demo-controls">
|
||||
<button className="btn-primary" onClick={onNext}>Следующий шаг (Next)</button>
|
||||
<button className="btn-secondary" onClick={onReset}>Сброс (Reset)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
45
src/components/ScenarioCards.tsx
Normal file
45
src/components/ScenarioCards.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Scenario, ScenarioId } from '../domain/types';
|
||||
|
||||
interface Props {
|
||||
scenarios: Scenario[];
|
||||
activeScenarioId: ScenarioId;
|
||||
onScenarioChange: (id: ScenarioId) => void;
|
||||
}
|
||||
|
||||
export default function ScenarioCards({ scenarios, activeScenarioId, onScenarioChange }: Props) {
|
||||
return (
|
||||
<section className="scenario-cards-section" id="scenarios">
|
||||
<div className="section-header">
|
||||
<h2>Сценарии тестирования (Jury Test Cases)</h2>
|
||||
<p>Выберите сценарий для проверки поведения системы в разных условиях.</p>
|
||||
</div>
|
||||
|
||||
<div className="scenario-grid">
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={`scenario-card ${scenario.id === activeScenarioId ? 'active' : ''}`}
|
||||
onClick={() => onScenarioChange(scenario.id)}
|
||||
>
|
||||
<div className="scenario-card-header">
|
||||
<h3>{scenario.name}</h3>
|
||||
{scenario.id === activeScenarioId && <span className="active-badge">Выбран</span>}
|
||||
</div>
|
||||
<p className="scenario-desc">{scenario.description}</p>
|
||||
<div className="scenario-meta">
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Цель:</span>
|
||||
<span className="meta-value">{scenario.goal}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Результат:</span>
|
||||
<span className="meta-value">{scenario.expectedCategorySummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-secondary btn-small mt-auto">Применить</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
65
src/components/StorylineStepper.tsx
Normal file
65
src/components/StorylineStepper.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function StorylineStepper({ simulation }: { simulation: SimulationState }) {
|
||||
const state = simulation.machineState;
|
||||
|
||||
const isDetection = state === 'MOVING_TO_CAMERA' || state === 'DETECTING';
|
||||
const isClassification = state === 'MOVING_TO_GATE' || state === 'WAITING_AT_GATE' || state === 'CLASSIFYING';
|
||||
const isCommand = state.startsWith('ROUTE_TO_');
|
||||
const isRouting = state === 'RETURN_HOME';
|
||||
|
||||
const item = simulation.currentItem;
|
||||
|
||||
return (
|
||||
<div className="storyline-stepper">
|
||||
<Step
|
||||
label="1. Detection"
|
||||
desc="Распознавание габаритов"
|
||||
active={isDetection}
|
||||
done={Boolean(item) && !isDetection}
|
||||
/>
|
||||
<Step
|
||||
label="2. Classification"
|
||||
desc="Дерево решений"
|
||||
active={isClassification}
|
||||
done={Boolean(item?.classification) && !isClassification && !isDetection}
|
||||
/>
|
||||
<Step
|
||||
label="3. Decision"
|
||||
desc="Выбор категории"
|
||||
active={isClassification && state === 'CLASSIFYING'}
|
||||
done={Boolean(item?.classification)}
|
||||
/>
|
||||
<Step
|
||||
label="4. Command"
|
||||
desc="Сигнал механизмам"
|
||||
active={isCommand}
|
||||
done={isRouting}
|
||||
/>
|
||||
<Step
|
||||
label="5. Routing"
|
||||
desc="Движение в зону"
|
||||
active={isCommand || isRouting}
|
||||
done={isRouting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ label, desc, active, done }: { label: string, desc: string, active: boolean, done: boolean }) {
|
||||
let statusClass = 'step-pending';
|
||||
if (active) statusClass = 'step-active';
|
||||
else if (done) statusClass = 'step-done';
|
||||
|
||||
return (
|
||||
<div className={`story-step ${statusClass}`}>
|
||||
<div className="step-indicator">
|
||||
<div className="step-dot"></div>
|
||||
</div>
|
||||
<div className="step-content">
|
||||
<strong>{label}</strong>
|
||||
<span>{desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
739
src/styles.css
739
src/styles.css
@@ -1423,3 +1423,742 @@ button:disabled {
|
||||
.criterion-mini-header strong { font-size: 13px; }
|
||||
.criterion-mini p { font-size: 12px; color: var(--muted); margin: 0; line-height: 1.4; }
|
||||
|
||||
|
||||
/* ====== NEW PRODUCT DEMO STYLES ====== */
|
||||
|
||||
.product-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: rgba(7, 14, 23, 0.9);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--cyan);
|
||||
color: #000;
|
||||
font-weight: 900;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.header-status-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 10px var(--green);
|
||||
}
|
||||
.status-dot.paused { background: var(--orange); box-shadow: 0 0 10px var(--orange); }
|
||||
.status-dot.fault, .status-dot.emergency_stop { background: var(--red); box-shadow: 0 0 10px var(--red); }
|
||||
|
||||
/* Hero */
|
||||
.hero-section {
|
||||
padding: 60px 0 80px;
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(36px, 4vw, 56px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 32px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
padding: 6px 12px;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
color: var(--cyan);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--cyan);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 14px 28px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--cyan-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
border: 1px solid #2a4a66;
|
||||
padding: 14px 28px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--cyan);
|
||||
background: #11273b;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--panel);
|
||||
padding: 32px 40px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.chain-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chain-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #0d1b2a;
|
||||
border: 1px solid #1a2f44;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.chain-step span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chain-arrow {
|
||||
color: #334155;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Demo Section */
|
||||
.product-demo-section {
|
||||
padding: 40px 0 80px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.demo-header h2 {
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.demo-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.demo-status strong {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.storyline-stepper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
background: var(--panel);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.story-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #2a4a66;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #050910;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.step-active .step-indicator { border-color: var(--cyan); }
|
||||
.step-active .step-dot { background: var(--cyan); box-shadow: 0 0 8px var(--cyan); }
|
||||
.step-done .step-indicator { border-color: var(--green); background: var(--green); }
|
||||
.step-done .step-dot { background: transparent; }
|
||||
.step-pending { opacity: 0.5; }
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-content strong { font-size: 14px; margin-bottom: 4px; }
|
||||
.step-content span { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.demo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.demo-scene-container {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Sorter scene sizing fix */
|
||||
.demo-scene-container .scene-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
.demo-scene-container .sorter-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
.demo-scene-container .scene-title-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.demo-proof-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Redesigned Proof Card */
|
||||
.proof-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.proof-card-stopped {
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 0 20px rgba(251, 61, 78, 0.1);
|
||||
}
|
||||
|
||||
.proof-card-empty {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.proof-card-header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.proof-card-header h3 { margin: 4px 0 0; font-size: 20px; }
|
||||
|
||||
.proof-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.proof-item {
|
||||
background: #0d1b2a;
|
||||
border: 1px solid #1a2f44;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.proof-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.proof-item strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.proof-details {
|
||||
background: #0d1b2a;
|
||||
border: 1px solid #1a2f44;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.detail-row span { color: var(--muted); }
|
||||
|
||||
.proof-reason {
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
border-left: 4px solid var(--cyan);
|
||||
padding: 12px 16px;
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Scenarios */
|
||||
.scenario-cards-section {
|
||||
padding: 60px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 32px;
|
||||
margin: 0 0 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.scenario-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-card:hover {
|
||||
border-color: var(--cyan);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.scenario-card.active {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
box-shadow: 0 0 0 1px var(--cyan);
|
||||
}
|
||||
|
||||
.scenario-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scenario-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
background: var(--cyan);
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.scenario-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
background: #0d1b2a;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-label { color: #64748b; }
|
||||
.meta-value { font-weight: 600; color: #cbd5e1; }
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
.mt-auto { margin-top: auto; }
|
||||
|
||||
/* Criteria */
|
||||
.criteria-cards-section {
|
||||
padding: 60px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.criteria-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.criteria-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.criteria-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.criteria-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.criteria-evidence {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.criteria-footer {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Engineering Details */
|
||||
.engineering-details-section {
|
||||
padding: 40px 0 80px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.engineering-toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.engineering-toggle h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.engineering-content {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.engineering-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.eng-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eng-panel .panel {
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.eng-panel .event-table {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Responsive Overrides */
|
||||
body {
|
||||
min-width: 0 !important; /* Remove old 1180px limit */
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.hero-section {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
.hero-subtitle {
|
||||
margin: 0 auto 32px;
|
||||
}
|
||||
.hero-badges {
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-chain {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.demo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.story-step {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.step-content {
|
||||
align-items: center;
|
||||
}
|
||||
.engineering-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.header-nav {
|
||||
display: none;
|
||||
}
|
||||
.demo-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.storyline-stepper {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
.hero-chain {
|
||||
flex-wrap: wrap;
|
||||
padding: 20px;
|
||||
}
|
||||
.chain-arrow {
|
||||
display: none;
|
||||
}
|
||||
.proof-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.demo-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.engineering-toggle h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.site-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user