diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..297374d
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
+
+export default config;
diff --git a/public/dashcam-product.svg b/public/dashcam-product.svg
new file mode 100644
index 0000000..9e4e447
--- /dev/null
+++ b/public/dashcam-product.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/(dev)/[devRoot]/showcase/layout.tsx b/src/app/(dev)/[devRoot]/showcase/layout.tsx
new file mode 100644
index 0000000..1efb933
--- /dev/null
+++ b/src/app/(dev)/[devRoot]/showcase/layout.tsx
@@ -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;
+}
diff --git a/src/app/(dev)/[devRoot]/showcase/page.tsx b/src/app/(dev)/[devRoot]/showcase/page.tsx
new file mode 100644
index 0000000..f18733d
--- /dev/null
+++ b/src/app/(dev)/[devRoot]/showcase/page.tsx
@@ -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 ;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index cece514..2a3ea5e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 25d2435..3c782c2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -12,7 +12,7 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps) {
return (
-
+
{children}
);
diff --git a/src/entities/dashcam/model/data.ts b/src/entities/dashcam/model/data.ts
new file mode 100644
index 0000000..90ecea0
--- /dev/null
+++ b/src/entities/dashcam/model/data.ts
@@ -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',
+ },
+ },
+ },
+];
diff --git a/src/entities/dashcam/model/types.ts b/src/entities/dashcam/model/types.ts
new file mode 100644
index 0000000..7ebc42f
--- /dev/null
+++ b/src/entities/dashcam/model/types.ts
@@ -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;
+ specs: Record;
+};
diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts
new file mode 100644
index 0000000..e359646
--- /dev/null
+++ b/src/shared/lib/cn.ts
@@ -0,0 +1,5 @@
+type ClassValue = string | false | null | undefined;
+
+export function cn(...classes: ClassValue[]) {
+ return classes.filter(Boolean).join(' ');
+}
diff --git a/src/shared/ui/atoms/Badge.tsx b/src/shared/ui/atoms/Badge.tsx
new file mode 100644
index 0000000..1f9715b
--- /dev/null
+++ b/src/shared/ui/atoms/Badge.tsx
@@ -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 = {
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/atoms/Button.tsx b/src/shared/ui/atoms/Button.tsx
new file mode 100644
index 0000000..f2ebc66
--- /dev/null
+++ b/src/shared/ui/atoms/Button.tsx
@@ -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 & {
+ href: string;
+ };
+
+type NativeButtonProps = BaseProps &
+ ButtonHTMLAttributes & {
+ href?: undefined;
+ };
+
+type ButtonProps = LinkProps | NativeButtonProps;
+
+const variantClasses: Record = {
+ 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 = {
+ 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 (
+
+ {children}
+
+ );
+ }
+
+ const buttonProps = props as NativeButtonProps;
+ const { type = 'button', ...restButtonProps } = buttonProps;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/atoms/EvidenceChip.tsx b/src/shared/ui/atoms/EvidenceChip.tsx
new file mode 100644
index 0000000..fab9432
--- /dev/null
+++ b/src/shared/ui/atoms/EvidenceChip.tsx
@@ -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 = {
+ verified: 'Verified',
+ claimed: 'Claimed',
+ unknown: 'Unknown',
+};
+
+const stateClasses: Record = {
+ 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 = {
+ verified: 'check',
+ claimed: 'info',
+ unknown: 'circle',
+};
+
+const methodLabels: Record = {
+ 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 (
+
+
+ {labels[state]}
+
+ );
+}
diff --git a/src/shared/ui/atoms/Icon.tsx b/src/shared/ui/atoms/Icon.tsx
new file mode 100644
index 0000000..4cd40f5
--- /dev/null
+++ b/src/shared/ui/atoms/Icon.tsx
@@ -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 (
+
+ {paths[name]}
+
+ );
+}
+
+const paths: Record = {
+ camera: (
+ <>
+
+
+ >
+ ),
+ check: ,
+ circle: ,
+ info: (
+ <>
+
+
+
+ >
+ ),
+ shield: ,
+ signal: (
+ <>
+
+
+
+ >
+ ),
+ spark: (
+ <>
+
+
+ >
+ ),
+ warning: (
+ <>
+
+
+
+ >
+ ),
+};
diff --git a/src/shared/ui/atoms/ScoreMeter.tsx b/src/shared/ui/atoms/ScoreMeter.tsx
new file mode 100644
index 0000000..a7e5e02
--- /dev/null
+++ b/src/shared/ui/atoms/ScoreMeter.tsx
@@ -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 (
+
+
+ {label}
+ {normalizedValue.toFixed(1)}
+
+
+
+ );
+}
diff --git a/src/shared/ui/molecules/ProductCard.tsx b/src/shared/ui/molecules/ProductCard.tsx
new file mode 100644
index 0000000..fe7c3fa
--- /dev/null
+++ b/src/shared/ui/molecules/ProductCard.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+function Media({ product }: { product: DashcamProduct }) {
+ return (
+
+
+
+
+
+
+ {product.award}
+
+ score {product.score.toFixed(1)}
+
+
+
evidence log
+
{product.evidence}
+
+
+
+ );
+}
+
+function Header({ product }: { product: DashcamProduct }) {
+ return (
+
+
+
+ {product.brand}
+
+
+ {product.model}
+
+
+
{product.tagline}
+
+ );
+}
+
+function Snapshot({ product }: { product: DashcamProduct }) {
+ const specKeys = ['sensor', 'resolution', 'bitrate'] as const;
+
+ return (
+
+ {specKeys.map((key) => {
+ const spec = product.specs[key];
+
+ return (
+
+
{spec.label}
+
+ {spec.value}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function ScoreStrip({ product }: { product: DashcamProduct }) {
+ return (
+
+ {scoreDimensions.map((dimension) => (
+
+ ))}
+
+ );
+}
+
+function Verdict({ product }: { product: DashcamProduct }) {
+ return (
+
+
+
+
+ Editorial override note
+
+
{product.editorialNote}
+
+
+ {product.strengths.map((strength) => (
+
+ {strength}
+
+ ))}
+
+
+ );
+}
+
+function Actions({ product }: { product: DashcamProduct }) {
+ return (
+
+
+
{product.price}
+
+ {product.priceNote} / {product.seller}
+
+
+
+
+ {product.ctaLabel}
+
+
+ );
+}
+
+export const ProductCard = {
+ Actions,
+ Header,
+ Media,
+ Root,
+ ScoreStrip,
+ Snapshot,
+ Verdict,
+};
+
+const mediaToneClasses: Record = {
+ brass: '[--media-accent:#8F7660]',
+ graphite: '[--media-accent:#687175]',
+ oxblood: '[--media-accent:#7B4B49]',
+ petrol: '[--media-accent:#56798A]',
+ titanium: '[--media-accent:#A8A095]',
+};
diff --git a/src/shared/ui/molecules/WinnerRail.tsx b/src/shared/ui/molecules/WinnerRail.tsx
new file mode 100644
index 0000000..24ba4e5
--- /dev/null
+++ b/src/shared/ui/molecules/WinnerRail.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/shared/ui/organisms/PageHero.tsx b/src/shared/ui/organisms/PageHero.tsx
new file mode 100644
index 0000000..20bddac
--- /dev/null
+++ b/src/shared/ui/organisms/PageHero.tsx
@@ -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 (
+
+
+
+ 2026 guide
+ verified / claimed / unknown
+
+
+
+ Best Dashcams 2026
+
+
+ Evidence-led rankings for drivers who care about plate detail, parking mode, heat
+ stability, and readable specs before checkout.
+
+
+
+
+
+ View winners
+
+
+
+ Methodology
+
+
+
+
+
+
+ );
+}
diff --git a/src/shared/ui/organisms/ProductComparisonTable.tsx b/src/shared/ui/organisms/ProductComparisonTable.tsx
new file mode 100644
index 0000000..abd2de2
--- /dev/null
+++ b/src/shared/ui/organisms/ProductComparisonTable.tsx
@@ -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 (
+
+
+
hardware first
+
+ Compact comparison matrix
+
+
+ The first rows show decisive hardware specs. Outcome rows and editorial issues follow.
+
+
+
+
+
+ Dashcam hardware and outcome comparison
+
+
+
+ Spec
+
+ {products.map((product) => (
+
+
+ {product.award}
+
+
+ {product.brand} {product.model}
+
+
+ ))}
+
+
+
+ {comparisonSpecRows.map((row) => (
+
+
+ {row.label}
+
+ {products.map((product) => {
+ const spec = product.specs[row.key];
+
+ return (
+
+
+ {spec.value}
+
+
+
+ );
+ })}
+
+ ))}
+
+
+
+ Score
+
+ {products.map((product) => (
+
+
+ {product.score.toFixed(1)}
+
+
+ ))}
+
+
+ {scoreDimensions.map((dimension) => (
+
+
+ {dimension.label}
+
+ {products.map((product) => (
+
+
+ {product.scores[dimension.key].toFixed(1)}
+
+
+ ))}
+
+ ))}
+
+
+
+ Known issue
+
+ {products.map((product) => (
+
+ {product.knownIssues[0]}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/shared/ui/templates/BestDashcamsPage.tsx b/src/shared/ui/templates/BestDashcamsPage.tsx
new file mode 100644
index 0000000..276de4f
--- /dev/null
+++ b/src/shared/ui/templates/BestDashcamsPage.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+ tested winners
+
+
+ Fast decisions, visible evidence
+
+
+
+
+ Compare specs
+
+
+
+
+
+
+
+ {useCasePicks.slice(0, 2).map((product) => (
+
+ ))}
+
+
+
+
+ {useCasePicks.slice(2).map((product) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ seller comparison
+
+
+ Prices stay secondary to evidence
+
+
+
affiliate-safe layout
+
+
+
+ {products.map((product) => (
+
+
+
+ {product.seller}
+
+
+ {product.brand} {product.model}
+
+
+
+
+ {product.price}
+
+
{product.priceNote}
+
+
+ Review terms
+
+
+ ))}
+
+
+
+
+
+
+ Methodology
+
+
How winners are selected
+
+
+
+ Scores are grouped into video, night, parking, reliability, usability, and value.
+ Category winners use different weights over the same dimensions.
+
+
+ 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.
+
+
+
+
+
+ );
+}
+
+function SiteHeader() {
+ return (
+
+ );
+}