fix: align CV rules and refresh final documentation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-08-02 22:51:41 +02:00
parent d701339930
commit f2ce611330
10 changed files with 462 additions and 191 deletions

View File

@@ -48,6 +48,7 @@ OWL PRIME — инженерный контур предварительной
2. Если габариты OK и **K > 0.8** (круг) → **D**. 2. Если габариты OK и **K > 0.8** (круг) → **D**.
3. Иначе → **B**. 3. Иначе → **B**.
4. **C-priority:** негабарит + круг → только **C**. 4. **C-priority:** негабарит + круг → только **C**.
5. Граница: **K = 0.8 не круг** (→ B, не D). Одинаково в web и CV.
## Архитектура ## Архитектура

View File

@@ -36,8 +36,8 @@ Primary modules: `main.py` (live), `demo.py` (HUD), `classify.py` (rules), `meas
## Classification rules (Track 3) ## Classification rules (Track 3)
1. Dimensions must be strictly **> 10×10×10 mm** and **< 450×320×320 mm** → else **C** 1. Dimensions must be strictly **> 10×10×10 mm** and **< 450×320×320 mm** → else **C**
2. Else if circularity exceeds the official threshold **0.8** → **D** 2. Else if `circle_ratio > 0.8` → **D**
(web twin uses strict `K > 0.8`; this prototype uses `circle_ratio >= 0.8`) (`K == 0.8` is **not** circular — same strict rule as web `classifier.ts`)
3. Else → **B** 3. Else → **B**
Stabilization: median window + vote → **LOCK**. Uncertain cases fall back to zone **C** after N frames. Stabilization: median window + vote → **LOCK**. Uncertain cases fall back to zone **C** after N frames.

View File

@@ -79,13 +79,14 @@ def classify(
""" """
Порядок ТЗ: Порядок ТЗ:
1) габариты → иначе C (приоритет над кругом) 1) габариты → иначе C (приоритет над кругом)
2) если r_in/r_out >= 0.8 в любом сечении → D 2) если r_in/r_out > 0.8 в любом сечении → D
(K == 0.8 НЕ круг — как в web classifier.ts)
3) иначе → B 3) иначе → B
""" """
dims = _sorted_dims(measurement.length_mm, measurement.width_mm, measurement.height_mm) dims = _sorted_dims(measurement.length_mm, measurement.width_mm, measurement.height_mm)
passes = check_size(dims, min_mm, max_mm) passes = check_size(dims, min_mm, max_mm)
ratio = float(measurement.circle_ratio) ratio = float(measurement.circle_ratio)
circular = ratio >= float(circle_ratio_threshold) circular = ratio > float(circle_ratio_threshold)
clipped = bool(getattr(measurement, "clipped_by_frame", False)) clipped = bool(getattr(measurement, "clipped_by_frame", False))
if not passes or clipped: if not passes or clipped:
@@ -112,7 +113,7 @@ def classify(
circle_ratio=ratio, circle_ratio=ratio,
passes_size=True, passes_size=True,
is_circular=True, is_circular=True,
reason=f"круг в сечении: r_in/r_out={ratio:.3f} >= {circle_ratio_threshold}", reason=f"круг в сечении: r_in/r_out={ratio:.3f} > {circle_ratio_threshold}",
) )
return ClassificationResult( return ClassificationResult(
@@ -121,7 +122,7 @@ def classify(
circle_ratio=ratio, circle_ratio=ratio,
passes_size=True, passes_size=True,
is_circular=False, is_circular=False,
reason=f"габариты OK, круга нет: r_in/r_out={ratio:.3f} < {circle_ratio_threshold}", reason=f"габариты OK, круга нет: r_in/r_out={ratio:.3f} <= {circle_ratio_threshold}",
) )

View File

@@ -113,12 +113,13 @@ class DecisionStabilizer:
top_m = _median(self._tops) top_m = _median(self._tops)
sec_m = _median(self._secs) sec_m = _median(self._secs)
ratio_p75 = _percentile(self._ratios, 75) ratio_p75 = _percentile(self._ratios, 75)
# устойчивый круг: медиана >= 0.8 ИЛИ (медиана сечений >= 0.8 и ≥ половины окна сильные) # устойчивый круг: медиана > 0.8 ИЛИ (медиана сечений > 0.8 и ≥ половины окна сильные)
# K == 0.8 официально НЕ круг (строгое > threshold)
sec_strong = ( sec_strong = (
sum(1 for x in self._secs if x >= self.enter_circle) / max(1, len(self._secs)) sum(1 for x in self._secs if x > self.enter_circle) / max(1, len(self._secs))
) )
circular = ratio >= self.enter_circle or ( circular = ratio > self.enter_circle or (
sec_m >= self.enter_circle and sec_strong >= 0.55 sec_m > self.enter_circle and sec_strong >= 0.55
) )
ratio_show = max(ratio, sec_m) if circular else ratio ratio_show = max(ratio, sec_m) if circular else ratio
@@ -153,7 +154,7 @@ class DecisionStabilizer:
reason=( reason=(
f"круг: med={ratio:.3f} p75={ratio_p75:.3f} " f"круг: med={ratio:.3f} p75={ratio_p75:.3f} "
f"top={top_m:.3f} sec={sec_m:.3f} strong={sec_strong:.0%} " f"top={top_m:.3f} sec={sec_m:.3f} strong={sec_strong:.0%} "
f">= {self.enter_circle}" f"> {self.enter_circle}"
), ),
) )
else: else:
@@ -165,7 +166,7 @@ class DecisionStabilizer:
is_circular=False, is_circular=False,
reason=( reason=(
f"не круг: med={ratio:.3f} sec={sec_m:.3f} " f"не круг: med={ratio:.3f} sec={sec_m:.3f} "
f"strong={sec_strong:.0%} < {self.enter_circle}" f"strong={sec_strong:.0%} <= {self.enter_circle}"
), ),
) )

View File

@@ -43,11 +43,40 @@ def test_lying_bottle_must_be_D_not_B():
assert r.category.zone == "D" assert r.category.zone == "D"
def test_border_circle(): def test_strict_circle_boundary():
r = classify_from_dims(100, 50, 50, circle_ratio=0.8) """Официально: круг только при K > 0.8. K == 0.8 → B (не D)."""
below = classify_from_dims(100, 50, 50, circle_ratio=0.799999)
assert below.category == Category.SUITABLE
assert below.category.zone == "B"
assert below.is_circular is False
exact = classify_from_dims(100, 50, 50, circle_ratio=0.8)
assert exact.category == Category.SUITABLE
assert exact.category.zone == "B"
assert exact.is_circular is False
above = classify_from_dims(100, 50, 50, circle_ratio=0.800001)
assert above.category == Category.NEED_PACK
assert above.category.zone == "D"
assert above.is_circular is True
def test_normal_non_circular_B():
r = classify_from_dims(120, 80, 40, circle_ratio=0.5)
assert r.category == Category.SUITABLE
assert r.category.zone == "B"
def test_normal_circular_above_threshold_D():
r = classify_from_dims(100, 50, 50, circle_ratio=0.81)
assert r.category == Category.NEED_PACK assert r.category == Category.NEED_PACK
r2 = classify_from_dims(100, 50, 50, circle_ratio=0.799) assert r.category.zone == "D"
assert r2.category == Category.SUITABLE
def test_oversized_circular_still_C():
r = classify_from_dims(500, 100, 100, circle_ratio=0.95)
assert r.category == Category.OVERSIZE
assert r.category.zone == "C"
def test_border_dims_strict(): def test_border_dims_strict():
@@ -80,7 +109,10 @@ if __name__ == "__main__":
test_too_small() test_too_small()
test_need_pack_cylinder() test_need_pack_cylinder()
test_lying_bottle_must_be_D_not_B() test_lying_bottle_must_be_D_not_B()
test_border_circle() test_strict_circle_boundary()
test_normal_non_circular_B()
test_normal_circular_above_threshold_D()
test_oversized_circular_still_C()
test_border_dims_strict() test_border_dims_strict()
test_dims_order_independent() test_dims_order_independent()
print("OK: all classification tests passed") print("OK: all classification tests passed")

View File

@@ -20,7 +20,7 @@ Companion to `README.md` and `/documentation`. Not a stage changelog.
| `OPENING_SAFETY_MARGIN_SEC` | 0.15 | same | | `OPENING_SAFETY_MARGIN_SEC` | 0.15 | same |
| Contact / clear planes | ≈1.0538 / 1.6000 | `buildDiverterPlanes` + mount hinge | | Contact / clear planes | ≈1.0538 / 1.6000 | `buildDiverterPlanes` + mount hinge |
| Classifier min/max | exclusive 10³ / 450×320×320 | `src/domain/classifier.ts` | | Classifier min/max | exclusive 10³ / 450×320×320 | `src/domain/classifier.ts` |
| Roundness | K > 0.8 (web); CV code uses ≥ 0.8 | `classifier.ts` / `cv/classify.py` | | Roundness | K > 0.8 (K = 0.8 not circular); web + CV aligned | `classifier.ts` / `cv/classify.py` |
| Belt speed (sim) | 1.0 m/s | runtime / physics helpers | | Belt speed (sim) | 1.0 m/s | runtime / physics helpers |
| Physics timestep | 1/60 s | Rapier world step | | Physics timestep | 1/60 s | Rapier world step |
| `CONVEYOR_CAD_URL` | `/models/sorter/conveyor-clean.glb` | `ConveyorCadModel.tsx` | | `CONVEYOR_CAD_URL` | `/models/sorter/conveyor-clean.glb` | `ConveyorCadModel.tsx` |
@@ -73,7 +73,7 @@ Grounded only in present sources + current `main` code/tests (not a full officia
| B/C/D bounds & roundness | `official_sources/doc-1783095831.pdf` | web `classifier.ts`, CV `cv/classify.py` | unit tests | PARTIAL (PDF not re-parsed each pass) | | B/C/D bounds & roundness | `official_sources/doc-1783095831.pdf` | web `classifier.ts`, CV `cv/classify.py` | unit tests | PARTIAL (PDF not re-parsed each pass) |
| Digital twin demo | workspace PDFs in `input_info/` | `/` + `/documentation` | e2e smoke/routes, production | PASS (demo present) | | Digital twin demo | workspace PDFs in `input_info/` | `/` + `/documentation` | e2e smoke/routes, production | PASS (demo present) |
| CAD conveyor | author pack / STL references | `3d_models/`, `conveyor-clean.glb` | checksums in `/documentation` | PASS (assets present) | | CAD conveyor | author pack / STL references | `3d_models/`, `conveyor-clean.glb` | checksums in `/documentation` | PASS (assets present) |
| Real measurement CV | Track 3 camera intent in briefs | `cv/` RealSense+OpenCV | `cv/README.md`, `test_classify.py` | PARTIAL (prototype, not live web) | | Real measurement CV | Track 3 camera intent in briefs | `cv/` RealSense+OpenCV on `main` | `cv/README.md`, `test_classify.py` | PARTIAL (WORKING_PROTOTYPE, not live web) |
| Physical industrial line | scoring/workspace PDFs | web physics + optional MQTT CV | code; contact not fully validated | PARTIAL | | Physical industrial line | scoring/workspace PDFs | web physics + optional MQTT CV | code; contact not fully validated | PARTIAL |
| Presentation | platform rules | `presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf` | 10-page PDF in repo | PASS (file present) | | Presentation | platform rules | `presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf` | 10-page PDF in repo | PASS (file present) |

View File

@@ -10,7 +10,10 @@ test.describe('routes', () => {
await page.getByTestId('nav-documentation').first().click(); await page.getByTestId('nav-documentation').first().click();
await expect(page).toHaveURL(/\/documentation\/?$/); await expect(page).toHaveURL(/\/documentation\/?$/);
await expect(page.getByTestId('documentation-page')).toBeVisible(); await expect(page.getByTestId('documentation-page')).toBeVisible();
await expect(page.getByTestId('docs-production-status')).toContainText('DATA_ACQUISITION_PACK_READY'); await expect(page.getByTestId('docs-production-status')).toContainText(
'FINAL_ENGINEERING_PROTOTYPE_READY',
);
await expect(page.getByTestId('docs-production-status')).toContainText('WORKING_PROTOTYPE');
await expect(page.getByTestId('nav-documentation').first()).toHaveClass(/active/); await expect(page.getByTestId('nav-documentation').first()).toHaveClass(/active/);
await page.reload(); await page.reload();

View File

@@ -4,16 +4,29 @@
* Unverified / planned items are labeled explicitly — never implied complete. * Unverified / planned items are labeled explicitly — never implied complete.
*/ */
/** Kept for e2e (`docs-production-status`) and acquisition-pack provenance. */
export const PRODUCTION_STATUS = { export const PRODUCTION_STATUS = {
acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY', projectStatus: 'FINAL_ENGINEERING_PROTOTYPE_READY',
webTwinStatus: 'BASELINE_PRESERVED', webTwinStatus: 'PRODUCTION_LIVE',
cvStatus: 'WORKING_PROTOTYPE',
cvLiveIntegrated: false,
unitTests: '196/196', unitTests: '196/196',
cvTests: 'PASS (no camera)',
productionBuild: 'PASS', productionBuild: 'PASS',
productionUrl: 'https://arhipovdan.ru',
contactPhysics: 'NOT_FULLY_VALIDATED', contactPhysics: 'NOT_FULLY_VALIDATED',
officialCompliance: 'PARTIAL_SOURCES_PRESENT', officialCompliance: 'PARTIAL_SOURCES_PRESENT',
canonicalBranch: 'main',
} as const; } as const;
export const SOLUTION_COMPONENTS = [
'Web digital twin (https://arhipovdan.ru)',
'Real CV working prototype (cv/, RealSense D415 + OpenCV)',
'Physical experimental conveyor stand',
'Author CAD (3d_models/conveer.FCStd)',
'Official B/C/D classifier (web + CV, K > 0.8)',
'Engineering documentation (/documentation)',
] as const;
export const CONFIRMED_LAYOUT = { export const CONFIRMED_LAYOUT = {
workspaceMm: { length: 10000, width: 6000 }, workspaceMm: { length: 10000, width: 6000 },
conveyorWidthMm: 500, conveyorWidthMm: 500,
@@ -23,7 +36,7 @@ export const CONFIRMED_LAYOUT = {
} as const; } as const;
export const CLASSIFIER_BOUNDS = { export const CLASSIFIER_BOUNDS = {
status: 'CURRENT_IMPLEMENTATION_VERIFIED_IN_CODE' as const, status: 'ALIGNED_WEB_AND_CV' as const,
officialSourceReference: 'official_sources/doc-1783095831.pdf', officialSourceReference: 'official_sources/doc-1783095831.pdf',
officialSourceParsedThisPass: false, officialSourceParsedThisPass: false,
minExclusiveMm: { width: 10, depth: 10, height: 10 }, minExclusiveMm: { width: 10, depth: 10, height: 10 },
@@ -33,8 +46,63 @@ export const CLASSIFIER_BOUNDS = {
min: '> 10×10×10 мм', min: '> 10×10×10 мм',
max: '< 450×320×320 мм', max: '< 450×320×320 мм',
roundness: 'K > 0.8', roundness: 'K > 0.8',
exactBoundary: 'K = 0.8 → B (not circular), not D',
}, },
checkOrder: 'dimensions→C, else circular→D, else B' as const, checkOrder: 'dimensions→C, else circular (K>0.8)→D, else B' as const,
} as const;
export const CV_PROTOTYPE = {
path: 'cv/',
status: 'WORKING_PROTOTYPE',
hardware: 'Intel RealSense D415',
software: 'Python + OpenCV (+ optional MQTT)',
liveIntegrated: false,
pipeline: [
'depth frame',
'segmentation',
'object contour',
'L×W×H',
'roundness K',
'B/C/D',
'optional MQTT',
] as const,
notes: [
'CV works as a separate prototype under cv/ on main.',
'CV is not connected directly to the live public website.',
'Hardware live validation requires RealSense D415.',
'No-camera unit tests and compileall run in CI/local verification.',
],
} as const;
export const PHYSICAL_STAND = {
status: 'EXPERIMENTAL_PROTOTYPE',
elements: [
'Physical belt conveyor',
'Camera mounting structure above the belt',
'Intel RealSense D415',
'Electronics / control nodes',
'Experimental actuators and printed components',
] as const,
purpose: 'Measurements, calibration, and hardware validation — not claimed as industrial end-to-end certified sorting.',
} as const;
export const ARCHITECTURE = {
realPath: [
'physical product',
'RealSense D415',
'OpenCV measurement',
'classifier (K > 0.8)',
'B/C/D result',
'optional MQTT/controller',
] as const,
digitalPath: [
'digital product',
'simulated measurement',
'same classifier rules',
'route command',
'digital twin',
'B/C/D receiver',
] as const,
} as const; } as const;
export const ROUTE_MAPPING = [ export const ROUTE_MAPPING = [
@@ -72,6 +140,7 @@ export const PHYSICS_STATUS = {
'Synchronized CAD diverter visual / kinematic targets', 'Synchronized CAD diverter visual / kinematic targets',
'CCD enabled for light/thin SKUs in runtime and headless sim', 'CCD enabled for light/thin SKUs in runtime and headless sim',
'Visual/physics spawn gating via product asset preload', 'Visual/physics spawn gating via product asset preload',
'Belt speed target 1.0 m/s; physics timestep 1/60 s',
], ],
notFullyValidated: [ notFullyValidated: [
'Complete contact-only routing through CAD diverters', 'Complete contact-only routing through CAD diverters',
@@ -83,18 +152,26 @@ export const PHYSICS_STATUS = {
planned: [ planned: [
'Visual full belt loop with surface-velocity coupling', 'Visual full belt loop with surface-velocity coupling',
'Controlled tangential friction at 1 m/s', 'Controlled tangential friction at 1 m/s',
'Dynamic rigid bodies for divert segment with fixed timestep',
'Per-SKU collider, damping, and friction profiles', 'Per-SKU collider, damping, and friction profiles',
], ],
} as const; } as const;
export const MOBILE_BEHAVIOR = {
desktop: 'Interactive WebGL 3D digital twin',
mobile:
'Capability-based tier: WebGL when viable; SVG/2D lite fallback on low FPS / missing WebGL (not claimed as full 3D)',
} as const;
export const VALIDATION_BOARD = [ export const VALIDATION_BOARD = [
{ item: 'Unit tests', status: '196/196 PASS' }, { item: 'Web unit tests', status: '196/196 PASS' },
{ item: 'Production build', status: 'PASS' }, { item: 'Production build', status: 'PASS' },
{ item: 'Active routes / + /documentation', status: 'PASS' }, { item: 'Focused E2E (/ + /documentation)', status: 'PASS' },
{ item: 'CV compileall', status: 'PASS' },
{ item: 'CV classify/geometry tests', status: 'PASS (no camera)' },
{ item: 'Production / and /documentation', status: 'PASS' },
{ item: 'conveyor-clean.glb checksum', status: 'PASS' }, { item: 'conveyor-clean.glb checksum', status: 'PASS' },
{ item: 'Author FCStd checksum', status: 'PASS' }, { item: 'Author FCStd checksum', status: 'PASS' },
{ item: 'Frozen diverter angles / 0.50 s', status: 'PASS' }, { item: 'Classifier K > 0.8 (web + CV)', status: 'ALIGNED' },
{ item: 'Full contact physics', status: 'NOT_FULLY_VALIDATED' }, { item: 'Full contact physics', status: 'NOT_FULLY_VALIDATED' },
] as const; ] as const;
@@ -146,7 +223,14 @@ export const OFFICIAL_SOURCE_MATRIX = [
purpose: 'Classifier bounds authority cited by code', purpose: 'Classifier bounds authority cited by code',
present: true, present: true,
canonical: true, canonical: true,
usage: 'Referenced by classifier.ts; not re-parsed this pass', usage: 'Referenced by classifier.ts and cv/classify.py',
},
{
source: 'presentation/Owl_Prime_Ozon_Tech_Track_3_FINAL.pdf',
purpose: 'Final presentation (10 slides)',
present: true,
canonical: true,
usage: 'Single presentation PDF in repository',
}, },
{ {
source: 'input_info/extracted/Постановка_Задача_3_сжато_2.pdf', source: 'input_info/extracted/Постановка_Задача_3_сжато_2.pdf',
@@ -169,15 +253,24 @@ export const RUNTIME_FLOW = [
'RECEIVER', 'RECEIVER',
] as const; ] as const;
export const CURRENT_LIMITATIONS = [ export const REPOSITORY_LAYOUT = [
'Full physical contact sorting through CAD diverters is not fully validated.', 'src/ — web digital twin',
'Belt surface-velocity drive at exactly 1 m/s is planned, not complete.', 'cv/ — RealSense + OpenCV prototype',
'Per-SKU physical parameters still require profiling/calibration.', '3d_models/ — author CAD',
'Author CAD horn / transmission incomplete in active GLB.', 'public/ — runtime GLB/STL/draco',
'Official compliance is partial: missing extracted task PDF; scoring PDF not re-parsed this pass.', 'docs/ — engineering notes',
'Real CV prototype lives in cv/ (WORKING_PROTOTYPE) and is not connected to the live website.', 'presentation/ — final PDF',
'Live CV validation requires Intel RealSense D415 hardware.', 'e2e/ + Vitest — tests',
'Docker / nginx — deployment',
] as const; ] as const;
/** @deprecated alias — historical Gate wording retained for acquisition-pack note */ export const CURRENT_LIMITATIONS = [
export const DATA_ACQUISITION_SEQUENCE = ['MEASURE', 'VERIFY', 'FREEZE', 'DESIGN'] as const; 'Engineering prototype, not an industrial-certified PAK.',
'Real CV prototype is not live-integrated into the public website.',
'Live camera mode requires Intel RealSense D415 hardware.',
'Physical stand parameters still require calibration against the digital twin.',
'Full physical contact sorting through CAD diverters is not fully validated.',
'Belt surface-velocity drive at exactly 1 m/s is not fully validated.',
'Author CAD horn / transmission incomplete in active GLB.',
'Cloud presentation/video links for the platform form are provided separately by the team.',
] as const;

View File

@@ -1,30 +1,37 @@
import AppNav from '../components/AppNav'; import AppNav from '../components/AppNav';
import { import {
ARCHITECTURE,
CAD_PROVENANCE, CAD_PROVENANCE,
CLASSIFIER_BOUNDS, CLASSIFIER_BOUNDS,
CONFIRMED_LAYOUT, CONFIRMED_LAYOUT,
CURRENT_LIMITATIONS, CURRENT_LIMITATIONS,
CV_PROTOTYPE,
DIVERTER_KINEMATICS, DIVERTER_KINEMATICS,
MOBILE_BEHAVIOR,
OFFICIAL_SOURCE_MATRIX, OFFICIAL_SOURCE_MATRIX,
PHYSICAL_STAND,
PHYSICS_STATUS, PHYSICS_STATUS,
PRODUCTION_STATUS, PRODUCTION_STATUS,
REPOSITORY_LAYOUT,
ROUTE_MAPPING, ROUTE_MAPPING,
RUNTIME_FLOW, RUNTIME_FLOW,
SOLUTION_COMPONENTS,
VALIDATION_BOARD, VALIDATION_BOARD,
} from '../data/productionStatusSummary'; } from '../data/productionStatusSummary';
const SECTIONS = [ const SECTIONS = [
{ id: 'about', title: '1. Цель проекта' }, { id: 'about', title: '1. Обзор проекта' },
{ id: 'runtime-flow', title: '2. Runtime flow' }, { id: 'production-web', title: '2. Production web' },
{ id: 'layout', title: '3. Активная линия' }, { id: 'cv', title: '3. Real CV prototype' },
{ id: 'classifier', title: '4. Классификация' }, { id: 'classifier', title: '4. Официальная классификация' },
{ id: 'routes', title: '5. Маршруты B/C/D' }, { id: 'stand', title: '5. Физический стенд' },
{ id: 'diverter', title: '6. Diverter state machine' }, { id: 'twin', title: '6. Digital twin' },
{ id: 'cad', title: '7. CAD provenance' }, { id: 'architecture', title: '7. Архитектура' },
{ id: 'physics', title: '8. Физика' }, { id: 'repository', title: '8. Репозиторий' },
{ id: 'validation', title: '9. Validation status' }, { id: 'validation', title: '9. Validation' },
{ id: 'official', title: '10. Official-source matrix' }, { id: 'limits', title: '10. Ограничения' },
{ id: 'limits', title: '11. Known limitations' }, { id: 'routes', title: '11. Маршруты B/C/D' },
{ id: 'official', title: '12. Official sources' },
] as const; ] as const;
function TocList({ onNavigate }: { onNavigate?: () => void }) { function TocList({ onNavigate }: { onNavigate?: () => void }) {
@@ -55,15 +62,15 @@ export default function DocumentationPage() {
O O
</span> </span>
<div className="product-brand-text"> <div className="product-brand-text">
<div className="product-brand-title">Ozon Tech Sorter</div> <div className="product-brand-title">OWL PRIME</div>
<div className="product-brand-sub">Цифровой двойник линии сортировки</div> <div className="product-brand-sub">Ozon Tech Track 3 — документация</div>
</div> </div>
</div> </div>
<AppNav variant="solid" /> <AppNav variant="solid" />
<div className="product-telemetry docs-topbar-meta"> <div className="product-telemetry docs-topbar-meta">
<div className="product-tele-item"> <div className="product-tele-item">
<span className="product-tele-label">Раздел</span> <span className="product-tele-label">Ветка</span>
<span className="product-tele-value">Документация</span> <span className="product-tele-value">{PRODUCTION_STATUS.canonicalBranch}</span>
</div> </div>
</div> </div>
</header> </header>
@@ -75,7 +82,7 @@ export default function DocumentationPage() {
</span> </span>
<div> <div>
<h1 className="docs-title">Документация</h1> <h1 className="docs-title">Документация</h1>
<p className="docs-subtitle">Текущее состояние веб-симуляции и инженерный статус</p> <p className="docs-subtitle">Финальный инженерный статус решения OWL PRIME</p>
</div> </div>
</div> </div>
<AppNav variant="solid" /> <AppNav variant="solid" />
@@ -99,144 +106,158 @@ export default function DocumentationPage() {
<main className="docs-main"> <main className="docs-main">
<section className="docs-status-banner" data-testid="docs-production-status"> <section className="docs-status-banner" data-testid="docs-production-status">
<div className="docs-status-label">Текущий статус</div> <div className="docs-status-label">Текущий статус</div>
<div className="docs-status-value">{PRODUCTION_STATUS.acquisitionPackStatus}</div> <div className="docs-status-value">{PRODUCTION_STATUS.projectStatus}</div>
<p className="docs-status-note"> <p className="docs-status-note">
Web twin: {PRODUCTION_STATUS.webTwinStatus}. Unit tests {PRODUCTION_STATUS.unitTests}, Web: {PRODUCTION_STATUS.webTwinStatus}. CV: {PRODUCTION_STATUS.cvStatus} (live
build {PRODUCTION_STATUS.productionBuild}. Contact physics:{' '} integrated: {PRODUCTION_STATUS.cvLiveIntegrated ? 'YES' : 'NO'}). Unit tests{' '}
{PRODUCTION_STATUS.contactPhysics}. Official compliance:{' '} {PRODUCTION_STATUS.unitTests}, CV tests {PRODUCTION_STATUS.cvTests}, build{' '}
{PRODUCTION_STATUS.officialCompliance}. {PRODUCTION_STATUS.productionBuild}. Domain: {PRODUCTION_STATUS.productionUrl}. Contact
physics: {PRODUCTION_STATUS.contactPhysics}.
</p> </p>
</section> </section>
<section id="about" className="docs-section"> <section id="about" className="docs-section">
<h2>{SECTIONS[0].title}</h2> <h2>{SECTIONS[0].title}</h2>
<p> <p>
Цифровой двойник линии сортировки Ozon Tech: конвейер, камера/классификация, физическая OWL PRIME — инженерный прототип предварительной сортировки товаров для Ozon Tech Track
маршрутизация и три приёмные категории (B / C / D). Страница <code>/</code> — рабочая 3: веб-цифровой двойник, реальный CV-прототип, физический экспериментальный стенд,
веб-симуляция; эта страница — каноническая сводка текущего состояния. Реальный авторский CAD и единые правила классификации B/C/D.
CV-прототип (RealSense D415) лежит в <code>cv/</code> и не подключён к live-сайту.
</p> </p>
<ul>
{SOLUTION_COMPONENTS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section> </section>
<section id="runtime-flow" className="docs-section"> <section id="production-web" className="docs-section">
<h2>{SECTIONS[1].title}</h2> <h2>{SECTIONS[1].title}</h2>
<p>{RUNTIME_FLOW.join(' → ')}</p> <p>
</section> Production: <a href={PRODUCTION_STATUS.productionUrl}>{PRODUCTION_STATUS.productionUrl}</a>
</p>
<section id="layout" className="docs-section">
<h2>{SECTIONS[2].title}</h2>
<ul> <ul>
<li> <li>
Рабочая зона (чертёж): {CONFIRMED_LAYOUT.workspaceMm.length} ×{' '} <code>/</code> — непрерывная симуляция конвейера
{CONFIRMED_LAYOUT.workspaceMm.width} мм; лента {CONFIRMED_LAYOUT.conveyorWidthMm} мм /
высота верха {CONFIRMED_LAYOUT.conveyorHeightMm} мм.
</li> </li>
<li>Модули CAD: {CONFIRMED_LAYOUT.modules.join(', ')}.</li> <li>
<code>/documentation</code> — эта инженерная документация
</li>
</ul>
<p>
На desktop — интерактивный WebGL 3D twin: CAD-конвейер, цифровое измерение,
классификация B/C/D, CAD-дивертеры и физика Rapier. Mobile: {MOBILE_BEHAVIOR.mobile}.
</p>
<ul>
<li>
Рабочая зона: {CONFIRMED_LAYOUT.workspaceMm.length} × {CONFIRMED_LAYOUT.workspaceMm.width}{' '}
мм; лента {CONFIRMED_LAYOUT.conveyorWidthMm} мм / высота{' '}
{CONFIRMED_LAYOUT.conveyorHeightMm} мм.
</li>
<li>Модули: {CONFIRMED_LAYOUT.modules.join(', ')}.</li>
<li>Приёмники: {CONFIRMED_LAYOUT.receivers.join(', ')}.</li> <li>Приёмники: {CONFIRMED_LAYOUT.receivers.join(', ')}.</li>
<li>Runtime flow: {RUNTIME_FLOW.join(' → ')}</li>
</ul>
</section>
<section id="cv" className="docs-section">
<h2>{SECTIONS[2].title}</h2>
<p>
Path: <code>{CV_PROTOTYPE.path}</code>. Status:{' '}
<code>{CV_PROTOTYPE.status}</code>. Hardware: {CV_PROTOTYPE.hardware}. Software:{' '}
{CV_PROTOTYPE.software}.
</p>
<p>Pipeline: {CV_PROTOTYPE.pipeline.join(' → ')}</p>
<ul>
{CV_PROTOTYPE.notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul> </ul>
</section> </section>
<section id="classifier" className="docs-section"> <section id="classifier" className="docs-section">
<h2>{SECTIONS[3].title}</h2> <h2>{SECTIONS[3].title}</h2>
<p> <p>
Status: <code>{CLASSIFIER_BOUNDS.status}</code> Status: <code>{CLASSIFIER_BOUNDS.status}</code>. Source:{' '}
<code>{CLASSIFIER_BOUNDS.officialSourceReference}</code>.
</p> </p>
<ul> <ul>
<li>Min: {CLASSIFIER_BOUNDS.display.min}</li> <li>Min: {CLASSIFIER_BOUNDS.display.min}</li>
<li>Max: {CLASSIFIER_BOUNDS.display.max}</li> <li>Max: {CLASSIFIER_BOUNDS.display.max}</li>
<li>Roundness: {CLASSIFIER_BOUNDS.display.roundness}</li> <li>Roundness: {CLASSIFIER_BOUNDS.display.roundness}</li>
<li>
Boundary: <strong>{CLASSIFIER_BOUNDS.display.exactBoundary}</strong>
</li>
<li>Order: {CLASSIFIER_BOUNDS.checkOrder}</li> <li>Order: {CLASSIFIER_BOUNDS.checkOrder}</li>
<li>Oversized circular product still goes to C (C priority).</li>
</ul> </ul>
<p> <p>
OFFICIAL_SOURCE_REFERENCE: <code>{CLASSIFIER_BOUNDS.officialSourceReference}</code> Web (<code>src/domain/classifier.ts</code>) and CV (<code>cv/classify.py</code>) both use
{CLASSIFIER_BOUNDS.officialSourceParsedThisPass strict <code>K &gt; 0.8</code>.
? ''
: ' — PDF not re-parsed in this documentation pass; bounds match current code/tests.'}
</p> </p>
</section> </section>
<section id="routes" className="docs-section"> <section id="stand" className="docs-section">
<h2>{SECTIONS[4].title}</h2> <h2>{SECTIONS[4].title}</h2>
<div className="docs-table-scroll"> <p>
<table className="docs-table"> Status: <code>{PHYSICAL_STAND.status}</code>. {PHYSICAL_STAND.purpose}
<thead> </p>
<tr> <ul>
<th>Category</th> {PHYSICAL_STAND.elements.map((el) => (
<th>Physical route</th> <li key={el}>{el}</li>
<th>CAD diverter</th> ))}
<th>Angle</th> </ul>
</tr>
</thead>
<tbody>
{ROUTE_MAPPING.map((row) => (
<tr key={row.category}>
<td>{row.category}</td>
<td>
<code>{row.physicalRoute}</code>
</td>
<td>{row.activeDiverter}</td>
<td>{row.signedAngleDeg}°</td>
</tr>
))}
</tbody>
</table>
</div>
</section> </section>
<section id="diverter" className="docs-section"> <section id="twin" className="docs-section">
<h2>{SECTIONS[5].title}</h2> <h2>{SECTIONS[5].title}</h2>
<p>{DIVERTER_KINEMATICS.phases.join(' → ')}</p>
<ul>
<li>Rotation duration: {DIVERTER_KINEMATICS.rotationDurationSec.toFixed(2)} s</li>
<li>Safety margin: {DIVERTER_KINEMATICS.openingSafetyMarginSec.toFixed(2)} s</li>
<li>Contact plane S: {DIVERTER_KINEMATICS.contactPlaneS}</li>
<li>Clear plane S: {DIVERTER_KINEMATICS.clearPlaneS.toFixed(4)}</li>
<li>Route command bound to productId; one active product at a time</li>
<li>Close after rear-clear of clear plane</li>
<li>
Invented sorter drive mesh active:{' '}
{DIVERTER_KINEMATICS.generatedMechanismActive ? 'YES' : 'NO'}
</li>
</ul>
</section>
<section id="cad" className="docs-section">
<h2>{SECTIONS[6].title}</h2>
<ul> <ul>
<li> <li>
Author: <code>{CAD_PROVENANCE.authorFcstd}</code> Author CAD: <code>{CAD_PROVENANCE.authorFcstd}</code>
<br />
SHA-256: <code>{CAD_PROVENANCE.authorSha256}</code>
</li> </li>
<li> <li>
Runtime: <code>{CAD_PROVENANCE.runtimeGlb}</code> Runtime conveyor: <code>{CAD_PROVENANCE.runtimeGlb}</code>
<br />
SHA-256: <code>{CAD_PROVENANCE.runtimeSha256}</code>
</li> </li>
<li>Author servo/holder nodes used in sorter module only.</li> <li>Product STL models in <code>public/models/</code></li>
<li>Invented sorter drive mesh inactive; author CAD diverters only.</li> <li>Same B/C/D classifier rules as CV</li>
<li>Horn / transmission in GLB: {CAD_PROVENANCE.hornTransmissionInGlb}.</li> <li>Rapier physics + CAD diverters (−45° / +45°)</li>
<li>Synchronized digital routing into B/C/D receivers</li>
</ul> </ul>
</section> <h3>Physics</h3>
<h4>IMPLEMENTED</h4>
<section id="physics" className="docs-section">
<h2>{SECTIONS[7].title}</h2>
<h3>IMPLEMENTED</h3>
<ul> <ul>
{PHYSICS_STATUS.implemented.map((x) => ( {PHYSICS_STATUS.implemented.map((x) => (
<li key={x}>{x}</li> <li key={x}>{x}</li>
))} ))}
</ul> </ul>
<h3>NOT FULLY VALIDATED</h3> <h4>NOT FULLY VALIDATED</h4>
<ul> <ul>
{PHYSICS_STATUS.notFullyValidated.map((x) => ( {PHYSICS_STATUS.notFullyValidated.map((x) => (
<li key={x}>{x}</li> <li key={x}>{x}</li>
))} ))}
</ul> </ul>
<h3>PLANNED</h3> <p>
Diverter phases: {DIVERTER_KINEMATICS.phases.join(' → ')} (
{DIVERTER_KINEMATICS.rotationDurationSec.toFixed(2)} s rotation).
</p>
</section>
<section id="architecture" className="docs-section">
<h2>{SECTIONS[6].title}</h2>
<h3>REAL PATH</h3>
<p>{ARCHITECTURE.realPath.join(' → ')}</p>
<h3>DIGITAL PATH</h3>
<p>{ARCHITECTURE.digitalPath.join(' → ')}</p>
</section>
<section id="repository" className="docs-section">
<h2>{SECTIONS[7].title}</h2>
<p>
Canonical branch: <code>{PRODUCTION_STATUS.canonicalBranch}</code>. Other branches are
historical and not required to run the solution.
</p>
<ul> <ul>
{PHYSICS_STATUS.planned.map((x) => ( {REPOSITORY_LAYOUT.map((line) => (
<li key={x}>{x}</li> <li key={line}>{line}</li>
))} ))}
</ul> </ul>
</section> </section>
@@ -265,8 +286,45 @@ export default function DocumentationPage() {
</div> </div>
</section> </section>
<section id="official" className="docs-section"> <section id="limits" className="docs-section" data-testid="docs-blockers">
<h2>{SECTIONS[9].title}</h2> <h2>{SECTIONS[9].title}</h2>
<ul className="docs-blocker-list">
{CURRENT_LIMITATIONS.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</section>
<section id="routes" className="docs-section">
<h2>{SECTIONS[10].title}</h2>
<div className="docs-table-scroll">
<table className="docs-table">
<thead>
<tr>
<th>Category</th>
<th>Physical route</th>
<th>CAD diverter</th>
<th>Angle</th>
</tr>
</thead>
<tbody>
{ROUTE_MAPPING.map((row) => (
<tr key={row.category}>
<td>{row.category}</td>
<td>
<code>{row.physicalRoute}</code>
</td>
<td>{row.activeDiverter}</td>
<td>{row.signedAngleDeg}°</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="official" className="docs-section">
<h2>{SECTIONS[11].title}</h2>
<div className="docs-table-scroll"> <div className="docs-table-scroll">
<table className="docs-table"> <table className="docs-table">
<thead> <thead>
@@ -294,15 +352,6 @@ export default function DocumentationPage() {
</table> </table>
</div> </div>
</section> </section>
<section id="limits" className="docs-section" data-testid="docs-blockers">
<h2>{SECTIONS[10].title}</h2>
<ul className="docs-blocker-list">
{CURRENT_LIMITATIONS.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</section>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -3312,17 +3312,24 @@ button:disabled {
} }
/* ========================================================= /* =========================================================
Documentation page Documentation page — light readable surface (Ozon blue)
========================================================= */ ========================================================= */
.docs-page { .docs-page {
--docs-bg: #f5f8fc;
--docs-panel: #ffffff;
--docs-text: #101828;
--docs-muted: #344054;
--docs-heading: #0b1f3a;
--docs-blue: #005bff;
--docs-magenta: #f91155;
--docs-line: #d0d5dd;
--docs-status-bg: #e8f1ff;
min-height: 100dvh; min-height: 100dvh;
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
background: background: var(--docs-bg);
radial-gradient(circle at 12% 0%, rgba(56, 189, 248, 0.12), transparent 32%), color: var(--docs-text);
linear-gradient(180deg, #07111d 0%, #050910 100%);
color: var(--text);
} }
.docs-header { .docs-header {
@@ -3336,9 +3343,8 @@ button:disabled {
gap: 12px 20px; gap: 12px 20px;
min-height: 56px; min-height: 56px;
padding: 14px 20px; padding: 14px 20px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--docs-line);
background: rgba(5, 9, 16, 0.92); background: #ffffff;
backdrop-filter: blur(12px);
} }
.docs-brand { .docs-brand {
@@ -3354,9 +3360,9 @@ button:disabled {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 10px; border-radius: 10px;
background: rgba(56, 189, 248, 0.15); background: rgba(0, 91, 255, 0.1);
border: 1px solid rgba(56, 189, 248, 0.35); border: 1px solid rgba(0, 91, 255, 0.35);
color: var(--cyan); color: var(--docs-blue);
font-weight: 800; font-weight: 800;
flex: 0 0 auto; flex: 0 0 auto;
} }
@@ -3364,11 +3370,13 @@ button:disabled {
.docs-title { .docs-title {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2; line-height: 1.2;
color: var(--docs-heading);
} }
.docs-subtitle { .docs-subtitle {
color: var(--muted); color: var(--docs-muted);
font-size: 0.85rem; font-size: 0.85rem;
opacity: 1;
} }
.docs-layout { .docs-layout {
@@ -3385,9 +3393,9 @@ button:disabled {
top: 84px; top: 84px;
align-self: start; align-self: start;
padding: 12px; padding: 12px;
border: 1px solid var(--line); border: 1px solid var(--docs-line);
border-radius: 12px; border-radius: 12px;
background: rgba(10, 19, 31, 0.7); background: var(--docs-panel);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -3405,16 +3413,17 @@ button:disabled {
.docs-toc-desktop a, .docs-toc-desktop a,
.docs-toc-mobile a { .docs-toc-mobile a {
color: var(--muted); color: var(--docs-muted);
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
min-height: 36px; min-height: 36px;
align-items: center; align-items: center;
opacity: 1;
} }
.docs-toc-desktop a:hover, .docs-toc-desktop a:hover,
.docs-toc-mobile a:hover { .docs-toc-mobile a:hover {
color: var(--cyan); color: var(--docs-blue);
} }
.docs-main { .docs-main {
@@ -3432,31 +3441,39 @@ button:disabled {
} }
.docs-status-banner { .docs-status-banner {
border: 1px solid rgba(74, 222, 128, 0.35); border: 1px solid rgba(0, 91, 255, 0.28);
background: rgba(20, 48, 32, 0.45); background: var(--docs-status-bg);
} }
.docs-status-label { .docs-status-label {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--muted); color: var(--docs-blue);
opacity: 1;
} }
.docs-status-value { .docs-status-value {
margin-top: 6px; margin-top: 6px;
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
color: var(--green); color: var(--docs-heading);
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
.docs-status-note { .docs-status-note {
margin-top: 10px; margin-top: 10px;
color: var(--muted); color: var(--docs-muted);
font-size: 0.92rem; font-size: 0.92rem;
line-height: 1.5; line-height: 1.5;
opacity: 1;
}
.docs-section h2,
.docs-section h3,
.docs-section h4 {
color: var(--docs-heading);
} }
.docs-section h2 { .docs-section h2 {
@@ -3464,9 +3481,19 @@ button:disabled {
margin-bottom: 10px; margin-bottom: 10px;
} }
.docs-section a {
color: var(--docs-blue);
}
.docs-section strong {
color: var(--docs-magenta);
font-weight: 700;
}
.docs-section p, .docs-section p,
.docs-section li { .docs-section li {
color: #c9d8e8; color: var(--docs-text);
opacity: 1;
font-size: 0.98rem; font-size: 0.98rem;
line-height: 1.5; line-height: 1.5;
} }
@@ -3480,19 +3507,22 @@ button:disabled {
.docs-section code { .docs-section code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.88em; font-size: 0.88em;
color: var(--cyan-hover); color: var(--docs-blue);
background: rgba(0, 91, 255, 0.08);
padding: 0.1em 0.35em;
border-radius: 4px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
.docs-conflict { .docs-conflict {
margin-top: 12px; margin-top: 12px;
border: 1px solid rgba(245, 158, 11, 0.4); border: 1px solid rgba(249, 17, 85, 0.35);
background: rgba(48, 32, 10, 0.4); background: #fff5f8;
} }
.docs-blocker-list li { .docs-blocker-list li {
color: #ffd0d5; color: var(--docs-text);
} }
.docs-table-scroll { .docs-table-scroll {
@@ -3507,20 +3537,24 @@ button:disabled {
min-width: 420px; min-width: 420px;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.92rem; font-size: 0.92rem;
background: var(--docs-panel);
} }
.docs-table th, .docs-table th,
.docs-table td { .docs-table td {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: 1px solid var(--docs-line);
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
white-space: nowrap; white-space: normal;
color: var(--docs-text);
overflow-wrap: anywhere;
word-break: break-word;
} }
.docs-table th { .docs-table th {
background: rgba(12, 23, 37, 0.9); background: #eef4ff;
color: var(--muted); color: var(--docs-heading);
font-weight: 600; font-weight: 600;
} }
@@ -3689,8 +3723,7 @@ button:disabled {
background: #070d16; background: #070d16;
} }
.main-page--product .product-topbar, .main-page--product .product-topbar {
.docs-page .product-topbar {
position: relative; position: relative;
z-index: 30; z-index: 30;
flex: 0 0 auto; flex: 0 0 auto;
@@ -3706,6 +3739,41 @@ button:disabled {
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
} }
.docs-page .product-topbar {
position: relative;
z-index: 30;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
height: 68px;
max-height: 72px;
padding: 0 24px;
border-bottom: 1px solid var(--docs-line);
background: #ffffff;
backdrop-filter: none;
}
.docs-page .product-brand-mark {
border-color: rgba(0, 91, 255, 0.35);
background: rgba(0, 91, 255, 0.1);
color: var(--docs-blue);
}
.docs-page .product-brand-title {
color: var(--docs-heading);
}
.docs-page .product-brand-sub,
.docs-page .product-tele-label {
color: var(--docs-muted);
}
.docs-page .product-tele-value {
color: var(--docs-text);
}
.product-brand { .product-brand {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3742,8 +3810,7 @@ button:disabled {
color: #8aa0b8; color: #8aa0b8;
} }
.main-page--product .app-nav-solid, .main-page--product .app-nav-solid {
.docs-page .product-topbar .app-nav-solid {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
@@ -3753,14 +3820,38 @@ button:disabled {
background: rgba(8, 16, 26, 0.7); background: rgba(8, 16, 26, 0.7);
} }
.main-page--product .app-nav-link, .docs-page .product-topbar .app-nav-solid,
.docs-page .product-topbar .app-nav-link { .docs-page .docs-header .app-nav-solid {
flex: 0 0 auto;
display: inline-flex;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--docs-line);
background: #f2f6ff;
}
.main-page--product .app-nav-link {
min-height: 34px; min-height: 34px;
padding: 0 16px; padding: 0 16px;
font-size: 13px; font-size: 13px;
border-radius: 8px; border-radius: 8px;
} }
.docs-page .product-topbar .app-nav-link,
.docs-page .docs-header .app-nav-link {
min-height: 34px;
padding: 0 16px;
font-size: 13px;
border-radius: 8px;
color: var(--docs-heading);
}
.docs-page .app-nav-link.active {
background: var(--docs-blue);
color: #ffffff;
}
.product-telemetry { .product-telemetry {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
@@ -3940,11 +4031,9 @@ button:disabled {
white-space: normal; white-space: normal;
} }
/* Documentation desktop grid aligned to product chrome */ /* Documentation desktop grid — keep light readable surface */
.docs-page { .docs-page {
background: background: var(--docs-bg);
radial-gradient(circle at 10% 0%, rgba(56, 189, 248, 0.1), transparent 34%),
linear-gradient(180deg, #07111d 0%, #050910 100%);
} }
.docs-layout { .docs-layout {
@@ -3960,6 +4049,8 @@ button:disabled {
top: 88px; top: 88px;
padding: 14px 12px; padding: 14px 12px;
font-size: 0.84rem; font-size: 0.84rem;
background: var(--docs-panel);
border-color: var(--docs-line);
} }
.docs-main { .docs-main {