diff --git a/docker/README.md b/docker/README.md
index bf98003..8855198 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -47,6 +47,12 @@ project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
`paramDefs` `skiaImage` `toneShader`.
+The landing page needs no CDN: `frontend/public/assets/Syne.woff2` is the
+bundled display face and `frontend/public/assets/samples/s*.jpg` are the six
+placeholder negatives the film strip, preset tester and QR card show (swap them
+for real graded stills whenever we have them). Its one foreign request is the
+QR image from `api.qrserver.com`, which degrades to an empty slot offline.
+
When the app changes one of them, copy it back in — the renderer is only
"parity" for as long as these stay in sync:
diff --git a/docker/frontend/public/assets/Syne.woff2 b/docker/frontend/public/assets/Syne.woff2
new file mode 100644
index 0000000..e311600
Binary files /dev/null and b/docker/frontend/public/assets/Syne.woff2 differ
diff --git a/docker/frontend/public/assets/samples/s1015.jpg b/docker/frontend/public/assets/samples/s1015.jpg
new file mode 100644
index 0000000..be511e5
Binary files /dev/null and b/docker/frontend/public/assets/samples/s1015.jpg differ
diff --git a/docker/frontend/public/assets/samples/s1025.jpg b/docker/frontend/public/assets/samples/s1025.jpg
new file mode 100644
index 0000000..4b342ff
Binary files /dev/null and b/docker/frontend/public/assets/samples/s1025.jpg differ
diff --git a/docker/frontend/public/assets/samples/s1035.jpg b/docker/frontend/public/assets/samples/s1035.jpg
new file mode 100644
index 0000000..89bfa0e
Binary files /dev/null and b/docker/frontend/public/assets/samples/s1035.jpg differ
diff --git a/docker/frontend/public/assets/samples/s1043.jpg b/docker/frontend/public/assets/samples/s1043.jpg
new file mode 100644
index 0000000..ea03952
Binary files /dev/null and b/docker/frontend/public/assets/samples/s1043.jpg differ
diff --git a/docker/frontend/public/assets/samples/s1050.jpg b/docker/frontend/public/assets/samples/s1050.jpg
new file mode 100644
index 0000000..b888250
Binary files /dev/null and b/docker/frontend/public/assets/samples/s1050.jpg differ
diff --git a/docker/frontend/public/assets/samples/s1069.jpg b/docker/frontend/public/assets/samples/s1069.jpg
new file mode 100644
index 0000000..262bc0e
Binary files /dev/null and b/docker/frontend/public/assets/samples/s1069.jpg differ
diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx
index 214a6fa..fa500c9 100644
--- a/docker/frontend/src/Landing.tsx
+++ b/docker/frontend/src/Landing.tsx
@@ -1,84 +1,465 @@
-import { useI18n, LANGS } from './i18n/I18nProvider';
-import { ACCENTS, useTheme } from './theme/ThemeProvider';
+import { useCallback, useEffect, useRef, useState } from 'react';
-// Landing page. The store links are placeholders on purpose — the real URLs are
-// still unknown (see WEB_PLAN.md, W6); they live in one place so filling them in
-// later is a one-line change.
-const STORE_LINKS = {
- play: '#',
- appstore: '#',
-};
+// Landing page, built to the landing blueprint: dark cinematic 10-section
+// funnel. Marketing copy is English (the blueprint's own copy) — the workspace
+// keeps its VI/EN dictionary. The store URLs are still unknown (WEB_PLAN.md W6),
+// so both badges raise the toast instead of navigating; fill STORE_LINKS in and
+// drop the onClick to go live.
+const STORE_LINKS = { play: '#', appstore: '#' };
+
+const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`;
+
+interface Look {
+ tag: string;
+ title: string;
+ meta: string;
+ photo: string;
+ filter: string;
+}
+
+// 35mm reel: eight looks over six sample negatives, each with its own CSS
+// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page
+// does not need a 12MP render; swap in real graded stills when we have them.
+const REEL: Look[] = [
+ { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' },
+ { tag: '#FUJI_CLASSIC_CHROME', title: 'Quiet Street, 7am', meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' },
+ { tag: '#CYBER_NEON_88', title: 'Neon Crossing', meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' },
+ { tag: '#CINESTILL_800T', title: 'Harbour Tungsten', meta: 'ISO 800 · GRAIN 40 · WARMTH -14', photo: SAMPLE(1043), filter: 'saturate(1.1) contrast(1.1) hue-rotate(186deg) brightness(.96)' },
+ { tag: '#ILFORD_HP5_BW', title: 'Alley Study', meta: 'ISO 400 · GRAIN 55 · MONO', photo: SAMPLE(1050), filter: 'grayscale(1) contrast(1.35) brightness(1.02)' },
+ { tag: '#VELVIA_50', title: 'Ridge Line', meta: 'ISO 50 · GRAIN 12 · WARMTH +6', photo: SAMPLE(1069), filter: 'saturate(1.55) contrast(1.22)' },
+ { tag: '#ACROS_100', title: 'Stone Facade', meta: 'ISO 100 · GRAIN 30 · MONO', photo: SAMPLE(1025), filter: 'grayscale(1) contrast(1.18) brightness(1.06)' },
+ { tag: '#ETRNA_VINTAGE', title: 'Faded Postcard', meta: 'ISO 200 · GRAIN 25 · WARMTH +10', photo: SAMPLE(1015), filter: 'sepia(.28) saturate(.9) contrast(.94) brightness(1.08)' },
+];
+
+interface Preset {
+ id: string;
+ label: string;
+ name: string;
+ desc: string;
+ filter: string;
+ // warmth, grain, contrast, halation bloom — 0..100
+ metrics: [number, number, number, number];
+}
+
+const PRESETS: Preset[] = [
+ { id: 'portra', label: 'Kodak Portra 400', name: 'PORTRA 400', desc: 'Warm daylight stock, forgiving highlights and natural skin tones — the everyday portrait recipe.', filter: 'sepia(.18) saturate(1.15) contrast(1.03) brightness(1.05)', metrics: [72, 34, 48, 22] },
+ { id: 'chrome', label: 'Fuji Classic Chrome', name: 'CLASSIC CHROME', desc: 'Deep greens, muted reds and a documentary edge straight out of the rangefinder era.', filter: 'saturate(.76) contrast(1.16) hue-rotate(-8deg)', metrics: [38, 22, 68, 14] },
+ { id: 'sunset', label: 'Warm Sunset Glow', name: 'SUNSET GLOW', desc: 'Amber backlight with a soft halation around every highlight, made for late golden hour.', filter: 'sepia(.34) saturate(1.35) contrast(1.06) brightness(1.06)', metrics: [88, 28, 52, 61] },
+ { id: 'cyber', label: "Cyber Neon '88", name: 'CYBER NEON 88', desc: 'Magenta and cyan push, crushed blacks and a bloom that reads like wet asphalt at night.', filter: 'saturate(1.8) contrast(1.3) hue-rotate(266deg)', metrics: [24, 46, 84, 78] },
+ { id: 'mono', label: 'Monochromatic Film', name: 'ACROS 100', desc: 'High-contrast black and white with a fine grain structure and clean mid-tone separation.', filter: 'grayscale(1) contrast(1.4) brightness(1.02)', metrics: [6, 41, 92, 18] },
+];
+
+const BAR_LABELS = ['WARMTH', 'FILM GRAIN', 'CONTRAST', 'HALATION BLOOM'];
+
+interface Feature { title: string; text: string; icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw' }
+const FEATURES: Feature[] = [
+ { icon: 'aperture', title: 'Live LUT Viewfinder', text: 'See the recipe through the lens before you press the shutter — no round trip to an editor.' },
+ { icon: 'grain', title: '35mm & 120 Film Grain', text: 'Authentic grain structures plus halation bloom, tuned per stock rather than one global overlay.' },
+ { icon: 'batch', title: 'Batch Color Editing', text: 'Apply one recipe across hundreds of photos at once and export the whole set in a single pass.' },
+ { icon: 'qr', title: 'QR Recipe Sharing', text: 'Export a recipe as a scannable card and let a friend import the exact look in one scan.' },
+ { icon: 'hsl', title: 'HSL & Selective Color', text: 'Fine-tune eight individual colour channels when a stock needs a nudge, not a replacement.' },
+ { icon: 'raw', title: 'RAW Support & 4K Export', text: 'Full EXIF retention, 300 ppi print metadata and lossless JPEG at up to 4K.' },
+];
+
+const QUOTES = [
+ { text: 'I grade a whole trip before the flight lands. The QR hand-off means my editor gets the exact same look, not a screenshot.', name: 'Mai Anh', role: '@maianh.travel · Travel Blogger' },
+ { text: 'Classic Chrome with a whisper of grain is 90% of my feed now. It reads like film without the scanning workflow.', name: 'Daniel Rowe', role: '@rowe.frames · Street Photographer' },
+ { text: 'The live viewfinder is the whole trick — clients pick a recipe on set instead of asking for fifteen revisions later.', name: 'Trúc Lâm', role: '@truclam.studio · Content Creator' },
+];
+
+const FAQ = [
+ { q: 'Does it preserve original image quality?', a: 'Yes. The web studio renders from your untouched original at export time, writes 300 ppi JFIF density and keeps the camera EXIF and GPS tags. RAW and 4K export are covered on the Pro plan.' },
+ { q: 'How do I scan a recipe QR code?', a: 'Open the Scan QR button inside the app and point it at the card. The recipe imports with its full parameter set — no manual re-typing.' },
+ { q: 'Is there an export watermark?', a: 'No watermarks on your own exports. Only the free guest tier on the web adds a small corner mark; sign in and it disappears.' },
+];
+
+function Icon({ name }: { name: Feature['icon'] }) {
+ const common = { width: 26, height: 26, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
+ switch (name) {
+ case 'aperture': return ;
+ case 'grain': return ;
+ case 'batch': return ;
+ case 'qr': return ;
+ case 'hsl': return ;
+ default: return ;
+ }
+}
export function Landing() {
- const { t, lang, setLang } = useI18n();
- const { mode, accent, setMode, setAccent } = useTheme();
+ const [menu, setMenu] = useState(false);
+ const [presetId, setPresetId] = useState(PRESETS[0].id);
+ const [temp, setTemp] = useState(0);
+ const [sat, setSat] = useState(100);
+ const [con, setCon] = useState(100);
+ const [toast, setToast] = useState(null);
+ const [qrOk, setQrOk] = useState(true);
+ const toastTimer = useRef>();
+
+ const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0];
+
+ const showToast = useCallback((text: string) => {
+ setToast(text);
+ clearTimeout(toastTimer.current);
+ toastTimer.current = setTimeout(() => setToast(null), 3400);
+ }, []);
+ useEffect(() => () => clearTimeout(toastTimer.current), []);
+
+ // The simulator's output: three knobs, one CSS filter string, applied live.
+ const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`;
+
+ const nav = [
+ ['#film', 'Film Strip'],
+ ['#presets', 'Try Recipes'],
+ ['#creator', 'Custom Recipe Creator'],
+ ['#features', 'Features'],
+ ['#qr', 'QR Code'],
+ ['#pricing', 'Pricing'],
+ ] as const;
return (
-