design system

This commit is contained in:
2026-05-17 00:25:53 +03:00
parent df60a7b74c
commit cc219cdb26
19 changed files with 1527 additions and 62 deletions
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+18
View File
@@ -0,0 +1,18 @@
<svg width="1200" height="720" viewBox="0 0 1200 720" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="720" fill="#151A1C"/>
<path d="M0 515C206 448 412 418 618 424C824 430 1018 473 1200 552V720H0V515Z" fill="#202629"/>
<path d="M170 574C356 516 552 492 758 504C918 513 1065 548 1200 608V720H0V646C55 618 112 594 170 574Z" fill="#2B3235"/>
<path d="M592 515L548 720H653L615 515H592Z" fill="#D7D1C4" fill-opacity=".72"/>
<path d="M423 292C423 246.713 459.713 210 505 210H740C785.287 210 822 246.713 822 292V396C822 441.287 785.287 478 740 478H505C459.713 478 423 441.287 423 396V292Z" fill="#0F1314"/>
<path d="M423 292C423 246.713 459.713 210 505 210H740C785.287 210 822 246.713 822 292V396C822 441.287 785.287 478 740 478H505C459.713 478 423 441.287 423 396V292Z" stroke="#6F7779" stroke-width="3"/>
<path d="M486 210L518 164H716L748 210H486Z" fill="#1E2527" stroke="#6F7779" stroke-width="3"/>
<circle cx="623" cy="344" r="93" fill="#1B2224" stroke="#A7B8BD" stroke-width="5"/>
<circle cx="623" cy="344" r="62" fill="#0B0D0E" stroke="#56798A" stroke-width="4"/>
<circle cx="623" cy="344" r="31" fill="#223037"/>
<circle cx="642" cy="324" r="13" fill="#D9E7EA" fill-opacity=".55"/>
<rect x="483" y="278" width="72" height="19" rx="4" fill="#56798A" fill-opacity=".55"/>
<rect x="692" y="278" width="72" height="19" rx="4" fill="#8F7660" fill-opacity=".46"/>
<rect x="488" y="410" width="270" height="14" rx="7" fill="#D7D1C4" fill-opacity=".25"/>
<path d="M289 185C410 109 567 75 760 92C895 104 1005 146 1090 218" stroke="#D9E7EA" stroke-opacity=".16" stroke-width="2"/>
<path d="M183 446C304 405 440 385 591 385C797 385 968 423 1104 499" stroke="#D9E7EA" stroke-opacity=".14" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Design System Showcase | videoreg.ru',
description:
'Hidden design-system showcase for the Best Dashcams 2026 comparison experience.',
robots: {
follow: false,
index: false,
},
};
type ShowcaseLayoutProps = {
children: React.ReactNode;
};
export default function ShowcaseLayout({ children }: ShowcaseLayoutProps) {
return children;
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound } from 'next/navigation';
import { dashcamProducts } from '@/entities/dashcam/model/data';
import { BestDashcamsPage } from '@/shared/ui/templates/BestDashcamsPage';
export const dynamicParams = false;
export const revalidate = 3600;
export function generateStaticParams() {
return [{ devRoot: '_dev' }];
}
type ShowcasePageProps = {
params: Promise<{
devRoot: string;
}>;
};
export default async function ShowcasePage({ params }: ShowcasePageProps) {
const { devRoot } = await params;
if (devRoot !== '_dev') {
notFound();
}
return <BestDashcamsPage products={dashcamProducts} />;
}
+165 -61
View File
@@ -1,34 +1,113 @@
@import "tailwindcss";
:root {
color-scheme: light;
--bg: #f6f6f3;
--panel: #ffffff;
--panel-muted: #efede7;
--text: #1f2528;
--muted: #667075;
--line: #d8d5cc;
--accent: #156f5b;
--accent-strong: #0c4f41;
--warning: #9a5c12;
--danger: #a33a3a;
--info: #345f9f;
--shadow: 0 10px 24px rgb(31 37 40 / 8%);
--font-ui: Geist, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--font-tech: "IBM Plex Mono", Iosevka, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
--surface-0: #f2eee6;
--surface-1: #fbfaf6;
--surface-2: #e8e1d5;
--surface-3: #d8cfc0;
--surface-inverse: #151a1c;
--text-primary: #202528;
--text-muted: #667076;
--text-subtle: #858076;
--text-inverse: #f7f3ea;
--text-on-accent: #f8fbfb;
--border-subtle: #d7cec0;
--border-strong: #bdb3a4;
--accent: #56798a;
--accent-strong: #385f70;
--accent-soft: #dbe7e9;
--accent-soft-border: #b7cbd1;
--warning: #8f7044;
--warning-soft: #efe5d6;
--warning-soft-border: #d7bea0;
--danger: #884b46;
--danger-soft: #eadbd8;
--danger-soft-border: #c9aaa5;
--success: #58705c;
--success-soft: #dde8dd;
--success-soft-border: #b8ccb9;
--focus: #56798a;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--shadow-soft: 0 14px 34px rgb(32 37 40 / 8%);
--shadow-raised: 0 22px 48px rgb(32 37 40 / 12%);
}
@theme inline {
--font-sans: var(--font-ui);
--font-mono: var(--font-tech);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--surface-0: #101415;
--surface-1: #171d1f;
--surface-2: #202729;
--surface-3: #2d3538;
--surface-inverse: #f4efe6;
--text-primary: #f3efe8;
--text-muted: #b7b2aa;
--text-subtle: #948f87;
--text-inverse: #161a1c;
--text-on-accent: #f8fbfb;
--border-subtle: #30383b;
--border-strong: #4b5559;
--accent: #7f9ead;
--accent-strong: #b5ced7;
--accent-soft: #1d3139;
--accent-soft-border: #31505b;
--warning: #d0b184;
--warning-soft: #352a1e;
--warning-soft-border: #5a462b;
--danger: #d7aaa5;
--danger-soft: #351f1f;
--danger-soft-border: #583433;
--success: #a8c2aa;
--success-soft: #1f3022;
--success-soft-border: #38523b;
--shadow-soft: 0 14px 34px rgb(0 0 0 / 24%);
--shadow-raised: 0 22px 48px rgb(0 0 0 / 32%);
}
}
* {
box-sizing: border-box;
}
html,
body {
html {
min-height: 100%;
scroll-behavior: smooth;
}
body {
min-height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
text-rendering: optimizeLegibility;
}
button,
@@ -37,6 +116,29 @@ select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
}
h1,
h2,
h3,
p,
td,
th {
overflow-wrap: anywhere;
}
::selection {
background: var(--accent-soft);
color: var(--text-primary);
}
.page-shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
@@ -48,38 +150,31 @@ select {
align-items: flex-start;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
overflow-wrap: anywhere;
}
h1 {
.page-shell h1 {
margin: 0;
font-size: 2rem;
line-height: 1.1;
}
h2 {
.page-shell h2 {
margin: 0;
font-size: 1.1rem;
line-height: 1.25;
}
h3 {
.page-shell h3 {
margin: 0;
font-size: 0.95rem;
line-height: 1.25;
@@ -94,10 +189,10 @@ h3 {
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
background: var(--surface-1);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.panel-header {
@@ -105,7 +200,7 @@ h3 {
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--border-subtle);
padding: 16px;
}
@@ -124,7 +219,7 @@ h3 {
}
.field span {
color: var(--muted);
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 650;
}
@@ -133,25 +228,25 @@ h3 {
.field select {
min-height: 42px;
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
color: var(--text-primary);
padding: 9px 10px;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
outline: 3px solid rgb(21 111 91 / 16%);
outline: 3px solid color-mix(in srgb, var(--accent) 22%, transparent);
}
.submit-button {
min-height: 42px;
border: 0;
border-radius: 6px;
border-radius: var(--radius-md);
background: var(--accent);
color: #fff;
color: var(--text-on-accent);
cursor: pointer;
font-weight: 750;
}
@@ -168,15 +263,15 @@ h3 {
.metric {
min-height: 76px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-muted);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-2);
padding: 12px;
}
.metric span {
display: block;
color: var(--muted);
color: var(--text-muted);
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
@@ -198,9 +293,9 @@ h3 {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-1);
padding: 14px;
}
@@ -214,8 +309,8 @@ h3 {
.pill {
min-height: 24px;
border-radius: 999px;
background: var(--panel-muted);
color: var(--muted);
background: var(--surface-2);
color: var(--text-muted);
padding: 4px 9px;
font-size: 0.76rem;
font-weight: 700;
@@ -226,7 +321,7 @@ h3 {
}
.pill.status-published {
color: var(--accent);
color: var(--success);
}
.pill.status-archived {
@@ -242,15 +337,15 @@ h3 {
.cache-item {
min-height: 42px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-1);
padding: 7px 10px;
}
.cache-item span {
display: block;
color: var(--muted);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
@@ -263,9 +358,9 @@ h3 {
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-lg);
color: var(--text-muted);
padding: 22px;
text-align: center;
}
@@ -296,7 +391,7 @@ h3 {
padding-top: 16px;
}
h1 {
.page-shell h1 {
font-size: 1.55rem;
}
@@ -306,3 +401,12 @@ h3 {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en">
<html id="top" lang="en">
<body>{children}</body>
</html>
);
+401
View File
@@ -0,0 +1,401 @@
import type { DashcamProduct, ScoreDimension, SpecKey } from './types';
export const scoreDimensions: Array<{ key: ScoreDimension; label: string }> = [
{ key: 'video', label: 'Video' },
{ key: 'night', label: 'Night' },
{ key: 'parking', label: 'Parking' },
{ key: 'reliability', label: 'Reliability' },
{ key: 'usability', label: 'Usability' },
{ key: 'value', label: 'Value' },
];
export const comparisonSpecRows: Array<{ key: SpecKey; label: string }> = [
{ key: 'sensor', label: 'Sensor' },
{ key: 'resolution', label: 'Resolution / FPS' },
{ key: 'bitrate', label: 'Bitrate' },
{ key: 'hdr', label: 'HDR' },
{ key: 'lens', label: 'Lens / FOV' },
{ key: 'storage', label: 'Storage' },
{ key: 'power', label: 'Capacitor / battery' },
{ key: 'channels', label: 'Channels' },
];
export const dashcamProducts: DashcamProduct[] = [
{
id: 'kaito-vx9-pro',
brand: 'Kaito',
model: 'VX9 Pro',
award: 'Overall pick',
tagline: 'The most balanced front and rear system for daily drivers.',
score: 9.2,
price: '$329',
priceNote: 'typical kit price',
seller: 'Verified retailers',
ctaLabel: 'Check sellers',
mediaTone: 'petrol',
evidence: '46 logged drives, 12 night routes, 3 heat cycles',
editorialNote: 'Wins because the night image holds plate detail without making the app or install feel fragile.',
strengths: ['stable HDR', 'clear night plates', 'clean app pairing'],
knownIssues: ['rear cable is stiff in cold weather'],
scores: {
video: 9.4,
night: 9.1,
parking: 8.8,
reliability: 9.3,
usability: 9.0,
value: 8.8,
},
specs: {
sensor: {
label: 'Sensor',
value: '1/1.8 in stacked CMOS',
trust: 'verified',
method: 'teardown',
source: 'bench teardown',
},
resolution: {
label: 'Resolution / FPS',
value: '4K 60 front + 2K rear',
trust: 'verified',
method: 'measured',
source: 'sample footage',
},
bitrate: {
label: 'Bitrate',
value: '82 Mbps front',
trust: 'verified',
method: 'measured',
source: 'file analysis',
},
hdr: {
label: 'HDR',
value: 'dual-exposure HDR',
trust: 'verified',
method: 'firmware-docs',
source: 'firmware notes',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.7, 146 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '512 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '2-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'renga-night-s2',
brand: 'Renga',
model: 'Night S2',
award: 'Best night detail',
tagline: 'A low-light specialist for unlit roads and wet city glass.',
score: 9.1,
price: '$289',
priceNote: 'front unit',
seller: 'Camera specialists',
ctaLabel: 'View pricing',
mediaTone: 'graphite',
evidence: '31 logged night routes, rain test, tunnel exposure test',
editorialNote: 'Plate recovery is excellent, but the parking mode setup is less polished than the overall pick.',
strengths: ['low noise image', 'fast exposure recovery', 'strong windshield glare control'],
knownIssues: ['parking menus need clearer wording'],
scores: {
video: 9.0,
night: 9.7,
parking: 7.9,
reliability: 8.7,
usability: 8.2,
value: 8.9,
},
specs: {
sensor: {
label: 'Sensor',
value: 'large-format night CMOS',
trust: 'claimed',
method: 'manufacturer',
},
resolution: {
label: 'Resolution / FPS',
value: '4K 30 front',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '76 Mbps front',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'night HDR',
trust: 'claimed',
method: 'manufacturer',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.6, 142 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '256 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '1-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'aster-park-duo',
brand: 'Aster',
model: 'Park Duo',
award: 'Best parking mode',
tagline: 'The safest choice for parked cars and apartment garages.',
score: 8.9,
price: '$379',
priceNote: 'hardwire kit included',
seller: 'Install partners',
ctaLabel: 'Find kit',
mediaTone: 'brass',
evidence: '18 overnight sessions, 8 voltage cut-off cycles, heat soak test',
editorialNote: 'The image is not the sharpest, but the buffered parking behavior is dependable and easy to audit.',
strengths: ['buffered impact clips', 'stable voltage cut-off', 'excellent event labeling'],
knownIssues: ['front 4K mode increases cabin heat'],
scores: {
video: 8.4,
night: 8.5,
parking: 9.6,
reliability: 9.2,
usability: 8.7,
value: 8.1,
},
specs: {
sensor: {
label: 'Sensor',
value: 'dual 5 MP CMOS',
trust: 'unknown',
},
resolution: {
label: 'Resolution / FPS',
value: '2K 60 front + 2K rear',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '64 Mbps combined',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'parking HDR',
trust: 'claimed',
method: 'manufacturer',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.8, 140 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '512 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '2-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'mori-value-4k',
brand: 'Mori',
model: 'Value 4K',
award: 'Best value',
tagline: 'Strong front-camera quality without paying for cloud extras.',
score: 8.6,
price: '$179',
priceNote: 'front unit',
seller: 'Marketplace verified',
ctaLabel: 'Compare price',
mediaTone: 'titanium',
evidence: '22 daytime routes, 9 night routes, 2 firmware versions',
editorialNote: 'A credible budget pick because it avoids the usual cheap-camera failure: unreadable evening plates.',
strengths: ['sharp daytime video', 'simple menus', 'good included mount'],
knownIssues: ['voice alerts sound compressed'],
scores: {
video: 8.8,
night: 8.1,
parking: 7.5,
reliability: 8.4,
usability: 8.8,
value: 9.5,
},
specs: {
sensor: {
label: 'Sensor',
value: '8 MP CMOS',
trust: 'unknown',
},
resolution: {
label: 'Resolution / FPS',
value: '4K 30 front',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '58 Mbps front',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'basic HDR',
trust: 'claimed',
method: 'manufacturer',
},
lens: {
label: 'Lens / FOV',
value: 'f/1.8, 150 deg',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '256 GB microSD',
trust: 'verified',
method: 'measured',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '1-channel',
trust: 'verified',
method: 'multi-source',
},
},
},
{
id: 'haven-fleet-lte',
brand: 'Haven',
model: 'Fleet LTE',
award: 'Best connected',
tagline: 'For rideshare drivers who need location, upload, and incident logs.',
score: 8.7,
price: '$449',
priceNote: 'LTE kit',
seller: 'Fleet resellers',
ctaLabel: 'See plans',
mediaTone: 'oxblood',
evidence: '14 LTE upload tests, 20 app sessions, 6 parking alerts',
editorialNote: 'The subscription is not cheap, but remote event review is genuinely useful for shared vehicles.',
strengths: ['reliable LTE upload', 'good incident timeline', 'strong cabin audio controls'],
knownIssues: ['cloud plan pricing needs care'],
scores: {
video: 8.5,
night: 8.2,
parking: 8.9,
reliability: 8.8,
usability: 9.0,
value: 7.4,
},
specs: {
sensor: {
label: 'Sensor',
value: 'dual 5 MP CMOS',
trust: 'claimed',
method: 'manufacturer',
},
resolution: {
label: 'Resolution / FPS',
value: '2K 30 front + 1080p cabin',
trust: 'verified',
method: 'measured',
},
bitrate: {
label: 'Bitrate',
value: '48 Mbps combined',
trust: 'verified',
method: 'measured',
},
hdr: {
label: 'HDR',
value: 'front HDR',
trust: 'verified',
method: 'firmware-docs',
},
lens: {
label: 'Lens / FOV',
value: '145 deg front, 165 deg cabin',
trust: 'claimed',
method: 'manufacturer',
},
storage: {
label: 'Storage',
value: '256 GB microSD + cloud',
trust: 'verified',
method: 'multi-source',
},
power: {
label: 'Capacitor / battery',
value: 'supercapacitor',
trust: 'verified',
method: 'teardown',
},
channels: {
label: 'Channels',
value: '2-channel cabin',
trust: 'verified',
method: 'multi-source',
},
},
},
];
+48
View File
@@ -0,0 +1,48 @@
export type TrustState = 'verified' | 'claimed' | 'unknown';
export type VerificationMethod =
| 'measured'
| 'firmware-docs'
| 'teardown'
| 'multi-source'
| 'manufacturer';
export type ScoreDimension = 'video' | 'night' | 'parking' | 'reliability' | 'usability' | 'value';
export type SpecKey =
| 'sensor'
| 'resolution'
| 'bitrate'
| 'hdr'
| 'lens'
| 'storage'
| 'power'
| 'channels';
export type SpecFact = {
label: string;
value: string;
trust: TrustState;
method?: VerificationMethod;
source?: string;
};
export type DashcamProduct = {
id: string;
brand: string;
model: string;
award: string;
tagline: string;
score: number;
price: string;
priceNote: string;
seller: string;
ctaLabel: string;
mediaTone: 'petrol' | 'graphite' | 'brass' | 'oxblood' | 'titanium';
evidence: string;
editorialNote: string;
strengths: string[];
knownIssues: string[];
scores: Record<ScoreDimension, number>;
specs: Record<SpecKey, SpecFact>;
};
+5
View File
@@ -0,0 +1,5 @@
type ClassValue = string | false | null | undefined;
export function cn(...classes: ClassValue[]) {
return classes.filter(Boolean).join(' ');
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
type BadgeTone = 'accent' | 'neutral' | 'warning' | 'danger' | 'success';
type BadgeProps = {
children: ReactNode;
className?: string;
tone?: BadgeTone;
};
const toneClasses: Record<BadgeTone, string> = {
accent: 'border-[var(--accent-soft-border)] bg-[var(--accent-soft)] text-[var(--accent-strong)]',
neutral: 'border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-muted)]',
warning: 'border-[var(--warning-soft-border)] bg-[var(--warning-soft)] text-[var(--warning)]',
danger: 'border-[var(--danger-soft-border)] bg-[var(--danger-soft)] text-[var(--danger)]',
success: 'border-[var(--success-soft-border)] bg-[var(--success-soft)] text-[var(--success)]',
};
export function Badge({ children, className, tone = 'neutral' }: BadgeProps) {
return (
<span
className={cn(
'inline-flex min-h-6 items-center rounded-[var(--radius-sm)] border px-2 py-1 text-[0.72rem] font-semibold',
toneClasses[tone],
className,
)}
>
{children}
</span>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md';
type BaseProps = {
children: ReactNode;
className?: string;
size?: ButtonSize;
variant?: ButtonVariant;
};
type LinkProps = BaseProps &
AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
};
type NativeButtonProps = BaseProps &
ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
type ButtonProps = LinkProps | NativeButtonProps;
const variantClasses: Record<ButtonVariant, string> = {
primary:
'border-[var(--accent)] bg-[var(--accent)] text-[var(--text-on-accent)] hover:bg-[var(--accent-strong)] hover:border-[var(--accent-strong)]',
secondary:
'border-[var(--border-strong)] bg-[var(--surface-1)] text-[var(--text-primary)] hover:bg-[var(--surface-2)]',
ghost:
'border-transparent bg-transparent text-[var(--text-muted)] hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)]',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'min-h-9 px-3 text-xs',
md: 'min-h-11 px-4 text-sm',
};
export function Button(props: ButtonProps) {
const { children, className, size = 'md', variant = 'primary' } = props;
const classes = cn(
'inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] border font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]',
sizeClasses[size],
variantClasses[variant],
className,
);
if (props.href) {
const { href, ...linkProps } = props;
return (
<a {...linkProps} className={classes} href={href}>
{children}
</a>
);
}
const buttonProps = props as NativeButtonProps;
const { type = 'button', ...restButtonProps } = buttonProps;
return (
<button {...restButtonProps} className={classes} type={type}>
{children}
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { TrustState, VerificationMethod } from '@/entities/dashcam/model/types';
import { cn } from '@/shared/lib/cn';
import { Icon } from './Icon';
type EvidenceChipProps = {
className?: string;
compact?: boolean;
method?: VerificationMethod;
source?: string;
state: TrustState;
};
const labels: Record<TrustState, string> = {
verified: 'Verified',
claimed: 'Claimed',
unknown: 'Unknown',
};
const stateClasses: Record<TrustState, string> = {
verified: 'border-[var(--success-soft-border)] bg-[var(--success-soft)] text-[var(--success)]',
claimed: 'border-[var(--warning-soft-border)] bg-[var(--warning-soft)] text-[var(--warning)]',
unknown: 'border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-muted)]',
};
const iconByState: Record<TrustState, 'check' | 'info' | 'circle'> = {
verified: 'check',
claimed: 'info',
unknown: 'circle',
};
const methodLabels: Record<VerificationMethod, string> = {
measured: 'measured in review',
'firmware-docs': 'confirmed from firmware docs',
teardown: 'confirmed by teardown',
'multi-source': 'confirmed by multiple sources',
manufacturer: 'manufacturer claimed',
};
export function EvidenceChip({ className, compact, method, source, state }: EvidenceChipProps) {
const detail = [method ? methodLabels[method] : undefined, source].filter(Boolean).join(', ');
const title = detail ? `${labels[state]}: ${detail}` : labels[state];
return (
<span
className={cn(
'inline-flex min-h-6 items-center gap-1.5 rounded-[var(--radius-sm)] border px-2 py-1 text-[0.68rem] font-semibold',
stateClasses[state],
className,
)}
title={title}
>
<Icon className="h-3.5 w-3.5" name={iconByState[state]} />
{labels[state]}
</span>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { cn } from '@/shared/lib/cn';
type IconName =
| 'camera'
| 'check'
| 'circle'
| 'info'
| 'shield'
| 'signal'
| 'spark'
| 'warning';
type IconProps = {
name: IconName;
className?: string;
};
export function Icon({ name, className }: IconProps) {
return (
<svg
aria-hidden="true"
className={cn('h-4 w-4 shrink-0', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
{paths[name]}
</svg>
);
}
const paths: Record<IconName, React.ReactNode> = {
camera: (
<>
<path d="M4 8.8A2.8 2.8 0 0 1 6.8 6h1.7l1.2-1.8h4.6L15.5 6h1.7A2.8 2.8 0 0 1 20 8.8v7.4a2.8 2.8 0 0 1-2.8 2.8H6.8A2.8 2.8 0 0 1 4 16.2V8.8Z" />
<path d="M9 12.5a3 3 0 1 0 6 0a3 3 0 0 0-6 0Z" />
</>
),
check: <path d="m5 12.5 4.2 4.2L19 6.8" />,
circle: <path d="M12 19.2a7.2 7.2 0 1 0 0-14.4 7.2 7.2 0 0 0 0 14.4Z" />,
info: (
<>
<path d="M12 17v-6" />
<path d="M12 7.3h.01" />
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
</>
),
shield: <path d="M12 21s7-3.4 7-10.2V5.4L12 3 5 5.4v5.4C5 17.6 12 21 12 21Z" />,
signal: (
<>
<path d="M4.8 13.6a10.2 10.2 0 0 1 14.4 0" />
<path d="M8 16.8a5.7 5.7 0 0 1 8 0" />
<path d="M12 20h.01" />
</>
),
spark: (
<>
<path d="M12 3.5 13.8 9l5.7 1.8-5.7 1.9L12 18.5l-1.8-5.8-5.7-1.9L10.2 9 12 3.5Z" />
<path d="m18 4 1 2.6 2.5.9-2.5.8L18 11l-.9-2.7-2.6-.8 2.6-.9L18 4Z" />
</>
),
warning: (
<>
<path d="M12 8v5" />
<path d="M12 17h.01" />
<path d="m10.2 4.4-7 12.2A2.2 2.2 0 0 0 5.1 20h13.8a2.2 2.2 0 0 0 1.9-3.4l-7-12.2a2.1 2.1 0 0 0-3.6 0Z" />
</>
),
};
+33
View File
@@ -0,0 +1,33 @@
import { cn } from '@/shared/lib/cn';
type ScoreMeterProps = {
className?: string;
label: string;
value: number;
};
export function ScoreMeter({ className, label, value }: ScoreMeterProps) {
const normalizedValue = Math.max(0, Math.min(value, 10));
return (
<div className={cn('grid gap-2', className)}>
<div className="flex items-baseline justify-between gap-3 text-xs">
<span className="font-semibold text-[var(--text-muted)]">{label}</span>
<span className="font-mono text-[var(--text-primary)]">{normalizedValue.toFixed(1)}</span>
</div>
<div
aria-label={`${label} score ${normalizedValue.toFixed(1)} out of 10`}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={normalizedValue}
className="h-1.5 overflow-hidden rounded-full bg-[var(--surface-3)]"
role="meter"
>
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${normalizedValue * 10}%` }}
/>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import type { ReactNode } from 'react';
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { scoreDimensions } from '@/entities/dashcam/model/data';
import { cn } from '@/shared/lib/cn';
import { Badge } from '@/shared/ui/atoms/Badge';
import { Button } from '@/shared/ui/atoms/Button';
import { EvidenceChip } from '@/shared/ui/atoms/EvidenceChip';
import { Icon } from '@/shared/ui/atoms/Icon';
import { ScoreMeter } from '@/shared/ui/atoms/ScoreMeter';
type ProductCardRootProps = {
children: ReactNode;
className?: string;
featured?: boolean;
};
function Root({ children, className, featured }: ProductCardRootProps) {
return (
<article
className={cn(
'overflow-hidden rounded-[var(--radius-lg)] border bg-[var(--surface-1)]',
featured
? 'border-[var(--accent-soft-border)] shadow-[var(--shadow-raised)]'
: 'border-[var(--border-subtle)] shadow-[var(--shadow-soft)]',
className,
)}
>
{children}
</article>
);
}
function Media({ product }: { product: DashcamProduct }) {
return (
<div
className={cn(
'relative isolate min-h-56 overflow-hidden bg-[var(--surface-inverse)] p-4 text-[var(--text-inverse)]',
mediaToneClasses[product.mediaTone],
)}
>
<div className="absolute inset-0 opacity-70 [background-image:linear-gradient(130deg,rgba(255,255,255,.12),transparent_45%),radial-gradient(circle_at_24%_20%,rgba(255,255,255,.12),transparent_24%)]" />
<img
alt=""
className="absolute inset-x-0 bottom-0 z-0 h-full w-full object-cover opacity-55 mix-blend-screen"
src="/dashcam-product.svg"
/>
<div className="relative z-10 flex h-full min-h-48 flex-col justify-between gap-8">
<div className="flex items-center justify-between gap-3">
<Badge className="border-white/15 bg-white/10 text-white" tone="neutral">
{product.award}
</Badge>
<span className="font-mono text-xs text-white/70">score {product.score.toFixed(1)}</span>
</div>
<div className="w-fit rounded-[var(--radius-md)] border border-white/15 bg-black/30 px-3 py-2 backdrop-blur-sm">
<p className="m-0 font-mono text-[0.72rem] text-white/72">evidence log</p>
<p className="m-0 mt-1 max-w-64 text-sm font-semibold text-white">{product.evidence}</p>
</div>
</div>
</div>
);
}
function Header({ product }: { product: DashcamProduct }) {
return (
<div className="grid gap-3 p-5">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">
{product.brand}
</p>
<h2 className="m-0 mt-1 text-2xl font-semibold leading-tight text-[var(--text-primary)]">
{product.model}
</h2>
</div>
<p className="m-0 text-sm leading-6 text-[var(--text-muted)]">{product.tagline}</p>
</div>
);
}
function Snapshot({ product }: { product: DashcamProduct }) {
const specKeys = ['sensor', 'resolution', 'bitrate'] as const;
return (
<dl className="grid gap-2 border-y border-[var(--border-subtle)] bg-[var(--surface-0)] px-5 py-4">
{specKeys.map((key) => {
const spec = product.specs[key];
return (
<div className="grid grid-cols-[6rem_minmax(0,1fr)] items-start gap-3" key={key}>
<dt className="text-xs font-semibold text-[var(--text-muted)]">{spec.label}</dt>
<dd className="m-0 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-[var(--text-primary)]">
<span>{spec.value}</span>
<EvidenceChip compact method={spec.method} source={spec.source} state={spec.trust} />
</dd>
</div>
);
})}
</dl>
);
}
function ScoreStrip({ product }: { product: DashcamProduct }) {
return (
<div className="grid gap-3 p-5">
{scoreDimensions.map((dimension) => (
<ScoreMeter
key={dimension.key}
label={dimension.label}
value={product.scores[dimension.key]}
/>
))}
</div>
);
}
function Verdict({ product }: { product: DashcamProduct }) {
return (
<div className="grid gap-3 px-5 pb-5">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-2)] p-3">
<p className="m-0 flex items-center gap-2 text-xs font-semibold uppercase text-[var(--text-muted)]">
<Icon className="text-[var(--accent)]" name="shield" />
Editorial override note
</p>
<p className="m-0 mt-2 text-sm leading-6 text-[var(--text-primary)]">{product.editorialNote}</p>
</div>
<div className="flex flex-wrap gap-2">
{product.strengths.map((strength) => (
<Badge key={strength} tone="accent">
{strength}
</Badge>
))}
</div>
</div>
);
}
function Actions({ product }: { product: DashcamProduct }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-subtle)] p-5">
<div>
<p className="m-0 font-mono text-xl font-semibold text-[var(--text-primary)]">{product.price}</p>
<p className="m-0 mt-1 text-xs text-[var(--text-muted)]">
{product.priceNote} / {product.seller}
</p>
</div>
<Button href="#sellers" variant="secondary">
<Icon name="signal" />
{product.ctaLabel}
</Button>
</div>
);
}
export const ProductCard = {
Actions,
Header,
Media,
Root,
ScoreStrip,
Snapshot,
Verdict,
};
const mediaToneClasses: Record<DashcamProduct['mediaTone'], string> = {
brass: '[--media-accent:#8F7660]',
graphite: '[--media-accent:#687175]',
oxblood: '[--media-accent:#7B4B49]',
petrol: '[--media-accent:#56798A]',
titanium: '[--media-accent:#A8A095]',
};
+33
View File
@@ -0,0 +1,33 @@
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { Badge } from '@/shared/ui/atoms/Badge';
type WinnerRailProps = {
products: DashcamProduct[];
};
export function WinnerRail({ products }: WinnerRailProps) {
return (
<section aria-label="Winner shortcuts" className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{products.map((product) => (
<a
className="group grid min-h-36 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] p-4 shadow-[var(--shadow-soft)] transition-colors hover:border-[var(--accent-soft-border)] hover:bg-[var(--surface-2)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]"
href={`#${product.id}`}
key={product.id}
>
<div>
<Badge tone={product.award === 'Overall pick' ? 'accent' : 'neutral'}>{product.award}</Badge>
<h3 className="m-0 mt-3 text-base font-semibold text-[var(--text-primary)]">
{product.brand} {product.model}
</h3>
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<p className="m-0 text-xs leading-5 text-[var(--text-muted)]">{product.price}</p>
<span className="font-mono text-lg font-semibold text-[var(--accent-strong)]">
{product.score.toFixed(1)}
</span>
</div>
</a>
))}
</section>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { Badge } from '@/shared/ui/atoms/Badge';
import { Button } from '@/shared/ui/atoms/Button';
import { Icon } from '@/shared/ui/atoms/Icon';
export function PageHero() {
return (
<section className="grid gap-8 py-8 md:grid-cols-[minmax(0,1fr)_minmax(19rem,29rem)] md:items-end md:py-12">
<div className="grid gap-6">
<div className="flex flex-wrap gap-2">
<Badge tone="accent">2026 guide</Badge>
<Badge tone="neutral">verified / claimed / unknown</Badge>
</div>
<div className="grid gap-4">
<h1 className="m-0 max-w-4xl text-5xl font-semibold leading-[1.02] text-[var(--text-primary)] sm:text-6xl lg:text-7xl">
Best Dashcams 2026
</h1>
<p className="m-0 max-w-2xl text-base leading-7 text-[var(--text-muted)] sm:text-lg">
Evidence-led rankings for drivers who care about plate detail, parking mode, heat
stability, and readable specs before checkout.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button href="#winners">
<Icon name="camera" />
View winners
</Button>
<Button href="#methodology" variant="secondary">
<Icon name="shield" />
Methodology
</Button>
</div>
</div>
<aside
aria-label="Method summary"
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] p-5 shadow-[var(--shadow-soft)]"
>
<div className="flex items-center justify-between gap-4 border-b border-[var(--border-subtle)] pb-4">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--text-muted)]">ranking model</p>
<p className="m-0 mt-1 text-lg font-semibold text-[var(--text-primary)]">Six scored dimensions</p>
</div>
<Icon className="h-5 w-5 text-[var(--accent)]" name="spark" />
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
{['video', 'night', 'parking', 'reliability', 'usability', 'value'].map((item) => (
<div
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-0)] p-3"
key={item}
>
<dt className="font-mono text-xs text-[var(--text-muted)]">{item}</dt>
<dd className="m-0 mt-1 font-semibold text-[var(--text-primary)]">weighted by use case</dd>
</div>
))}
</dl>
</aside>
</section>
);
}
@@ -0,0 +1,134 @@
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { comparisonSpecRows, scoreDimensions } from '@/entities/dashcam/model/data';
import { EvidenceChip } from '@/shared/ui/atoms/EvidenceChip';
type ProductComparisonTableProps = {
products: DashcamProduct[];
};
export function ProductComparisonTable({ products }: ProductComparisonTableProps) {
return (
<section
aria-labelledby="comparison-title"
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] shadow-[var(--shadow-soft)]"
id="comparison"
>
<div className="grid gap-2 border-b border-[var(--border-subtle)] p-5">
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">hardware first</p>
<h2 className="m-0 text-2xl font-semibold text-[var(--text-primary)]" id="comparison-title">
Compact comparison matrix
</h2>
<p className="m-0 max-w-2xl text-sm leading-6 text-[var(--text-muted)]">
The first rows show decisive hardware specs. Outcome rows and editorial issues follow.
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-[64rem] border-collapse text-left text-sm">
<caption className="sr-only">Dashcam hardware and outcome comparison</caption>
<thead>
<tr className="border-b border-[var(--border-subtle)]">
<th
className="sticky left-0 z-20 w-44 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold uppercase text-[var(--text-muted)]"
scope="col"
>
Spec
</th>
{products.map((product) => (
<th
className="min-w-48 bg-[var(--surface-1)] px-4 py-4 align-bottom"
key={product.id}
scope="col"
>
<span className="block text-xs font-semibold uppercase text-[var(--accent-strong)]">
{product.award}
</span>
<span className="mt-1 block text-base font-semibold text-[var(--text-primary)]">
{product.brand} {product.model}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{comparisonSpecRows.map((row) => (
<tr className="border-b border-[var(--border-subtle)]" key={row.key}>
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
{row.label}
</th>
{products.map((product) => {
const spec = product.specs[row.key];
return (
<td className="bg-[var(--surface-0)] px-4 py-4 align-top" key={product.id}>
<div className="grid gap-2">
<span className="font-medium text-[var(--text-primary)]">{spec.value}</span>
<EvidenceChip
compact
method={spec.method}
source={spec.source}
state={spec.trust}
/>
</div>
</td>
);
})}
</tr>
))}
<tr className="border-b border-[var(--border-subtle)]">
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
Score
</th>
{products.map((product) => (
<td className="px-4 py-4 align-top" key={product.id}>
<span className="font-mono text-2xl font-semibold text-[var(--accent-strong)]">
{product.score.toFixed(1)}
</span>
</td>
))}
</tr>
{scoreDimensions.map((dimension) => (
<tr className="border-b border-[var(--border-subtle)]" key={dimension.key}>
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
{dimension.label}
</th>
{products.map((product) => (
<td className="px-4 py-4 align-top" key={product.id}>
<span className="font-mono font-semibold text-[var(--text-primary)]">
{product.scores[dimension.key].toFixed(1)}
</span>
</td>
))}
</tr>
))}
<tr>
<th
className="sticky left-0 z-10 bg-[var(--surface-1)] px-5 py-4 text-xs font-semibold text-[var(--text-muted)]"
scope="row"
>
Known issue
</th>
{products.map((product) => (
<td className="px-4 py-4 align-top text-[var(--text-primary)]" key={product.id}>
{product.knownIssues[0]}
</td>
))}
</tr>
</tbody>
</table>
</div>
</section>
);
}
@@ -0,0 +1,182 @@
import type { DashcamProduct } from '@/entities/dashcam/model/types';
import { Badge } from '@/shared/ui/atoms/Badge';
import { Button } from '@/shared/ui/atoms/Button';
import { Icon } from '@/shared/ui/atoms/Icon';
import { ProductCard } from '@/shared/ui/molecules/ProductCard';
import { WinnerRail } from '@/shared/ui/molecules/WinnerRail';
import { PageHero } from '@/shared/ui/organisms/PageHero';
import { ProductComparisonTable } from '@/shared/ui/organisms/ProductComparisonTable';
type BestDashcamsPageProps = {
products: DashcamProduct[];
};
export function BestDashcamsPage({ products }: BestDashcamsPageProps) {
const [overallPick, ...useCasePicks] = products;
return (
<main className="min-h-screen bg-[var(--surface-0)] text-[var(--text-primary)]">
<SiteHeader />
<div className="mx-auto grid w-[calc(100%_-_1.25rem)] max-w-[1180px] gap-10 pb-14 sm:w-[calc(100%_-_2rem)]">
<PageHero />
<WinnerRail products={products} />
<section className="grid gap-5" id="winners">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">
tested winners
</p>
<h2 className="m-0 mt-2 text-3xl font-semibold text-[var(--text-primary)]">
Fast decisions, visible evidence
</h2>
</div>
<Button href="#comparison" variant="secondary">
<Icon name="signal" />
Compare specs
</Button>
</div>
<div className="grid gap-5 lg:grid-cols-[1.1fr_.9fr]">
<div id={overallPick.id}>
<ProductCard.Root featured>
<ProductCard.Media product={overallPick} />
<ProductCard.Header product={overallPick} />
<ProductCard.Snapshot product={overallPick} />
<ProductCard.ScoreStrip product={overallPick} />
<ProductCard.Verdict product={overallPick} />
<ProductCard.Actions product={overallPick} />
</ProductCard.Root>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
{useCasePicks.slice(0, 2).map((product) => (
<div id={product.id} key={product.id}>
<ProductCard.Root>
<ProductCard.Media product={product} />
<ProductCard.Header product={product} />
<ProductCard.Snapshot product={product} />
<ProductCard.Actions product={product} />
</ProductCard.Root>
</div>
))}
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
{useCasePicks.slice(2).map((product) => (
<div id={product.id} key={product.id}>
<ProductCard.Root>
<ProductCard.Media product={product} />
<ProductCard.Header product={product} />
<ProductCard.Snapshot product={product} />
<ProductCard.Actions product={product} />
</ProductCard.Root>
</div>
))}
</div>
</section>
<ProductComparisonTable products={products} />
<section
aria-labelledby="sellers-title"
className="grid gap-5 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-1)] p-5 shadow-[var(--shadow-soft)]"
id="sellers"
>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--accent-strong)]">
seller comparison
</p>
<h2 className="m-0 mt-2 text-2xl font-semibold text-[var(--text-primary)]" id="sellers-title">
Prices stay secondary to evidence
</h2>
</div>
<Badge tone="neutral">affiliate-safe layout</Badge>
</div>
<div className="grid gap-3 lg:grid-cols-5">
{products.map((product) => (
<article
className="grid gap-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-0)] p-4"
key={product.id}
>
<div>
<p className="m-0 text-xs font-semibold uppercase text-[var(--text-muted)]">
{product.seller}
</p>
<h3 className="m-0 mt-2 text-base font-semibold text-[var(--text-primary)]">
{product.brand} {product.model}
</h3>
</div>
<div>
<p className="m-0 font-mono text-xl font-semibold text-[var(--text-primary)]">
{product.price}
</p>
<p className="m-0 mt-1 text-xs text-[var(--text-muted)]">{product.priceNote}</p>
</div>
<Button href="#methodology" size="sm" variant="secondary">
Review terms
</Button>
</article>
))}
</div>
</section>
<section
className="grid gap-5 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-inverse)] p-5 text-[var(--text-inverse)] shadow-[var(--shadow-soft)] md:grid-cols-[1fr_1.1fr]"
id="methodology"
>
<div>
<Badge className="border-white/15 bg-white/10 text-white" tone="neutral">
Methodology
</Badge>
<h2 className="m-0 mt-4 text-3xl font-semibold">How winners are selected</h2>
</div>
<div className="grid gap-4 text-sm leading-6 text-white/72">
<p className="m-0">
Scores are grouped into video, night, parking, reliability, usability, and value.
Category winners use different weights over the same dimensions.
</p>
<p className="m-0">
Public evidence states stay simple: verified, claimed, and unknown. Internal notes
preserve whether a fact came from measurement, firmware docs, teardown, multiple
sources, or manufacturer claims.
</p>
</div>
</section>
</div>
</main>
);
}
function SiteHeader() {
return (
<header className="border-b border-[var(--border-subtle)] bg-[color-mix(in_srgb,var(--surface-0)_88%,transparent)] backdrop-blur">
<div className="mx-auto flex min-h-16 w-[calc(100%_-_1.25rem)] max-w-[1180px] items-center justify-between gap-4 sm:w-[calc(100%_-_2rem)]">
<a
className="flex items-center gap-3 font-semibold text-[var(--text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]"
href="#top"
>
<span className="grid h-9 w-9 place-items-center rounded-[var(--radius-md)] border border-[var(--border-strong)] bg-[var(--surface-1)]">
<Icon className="text-[var(--accent)]" name="camera" />
</span>
videoreg.ru
</a>
<nav aria-label="Primary navigation" className="hidden items-center gap-1 text-sm md:flex">
{['Winners', 'Comparison', 'Methodology', 'Sellers'].map((item) => (
<a
className="rounded-[var(--radius-sm)] px-3 py-2 text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus)]"
href={`#${item.toLowerCase()}`}
key={item}
>
{item}
</a>
))}
</nav>
</div>
</header>
);
}