From 2f16ab8031ed1d4ab55d3198c0d34f70ef8d382f Mon Sep 17 00:00:00 2001 From: James Titus Date: Thu, 13 Aug 2026 10:03:53 +0530 Subject: [PATCH 1/6] chore: agent.md update & removed some unused files --- AGENT.md | 28 ++- src/scopes/Scope.ts | 42 ----- src/scopes/__test__/scope.test.js | 58 ------ src/scopes/lucidreactScope.ts | 286 ------------------------------ src/scopes/motionScope.ts | 9 - src/scopes/rechartScope.ts | 112 ------------ 6 files changed, 25 insertions(+), 510 deletions(-) delete mode 100644 src/scopes/Scope.ts delete mode 100644 src/scopes/__test__/scope.test.js delete mode 100644 src/scopes/lucidreactScope.ts delete mode 100644 src/scopes/motionScope.ts delete mode 100644 src/scopes/rechartScope.ts diff --git a/AGENT.md b/AGENT.md index 58a7872..f7045b6 100644 --- a/AGENT.md +++ b/AGENT.md @@ -4,7 +4,7 @@ Agent instructions for working in this repository. Read this before making chang ## What this project is -**`react-code-canvas`** is a published React/TypeScript **library** (npm package, currently `4.2.0-beta.18`). It provides a browser-based canvas that **renders React components from code strings at runtime** and supports live text editing of the rendered output. +**`react-code-canvas`** is a published React/TypeScript **library** (npm package, currently `5.0.0`). It provides a browser-based canvas that **renders React components from code strings at runtime** and supports live text editing of the rendered output. Primary use cases: previewing JSX from strings, exposing a scoped set of components/libraries to evaluated code, and validating generated React code before saving it. @@ -32,11 +32,13 @@ Top-level layout (build artifacts and `node_modules` omitted): ``` . -├── src/ # library source — the published code (see Architecture) +├── src/ # library source — the published code (see Scope resolution) ├── dist/ # build output: CJS + ESM bundles + .d.ts (gitignored) ├── doc_site/ # Docusaurus documentation site (own package + bun.lock) ├── scripts/ +│ ├── generate-scope-maps.mjs # emits src/scopes/generated/ from node_modules │ └── manual/ # manual, run-by-hand scripts (e.g. transformer walkthrough) +├── skills/ # react-code-canvas agent skill, shipped in the package ├── public/ # static assets (e.g. rrc.png used by the README) ├── storybook-static/ # built static Storybook (gitignored) ├── coverage/ # jest coverage output (gitignored) @@ -44,7 +46,7 @@ Top-level layout (build artifacts and `node_modules` omitted): ├── .github/ # workflows (test, deploy-docs), issue/PR templates ├── rollup.config.mjs # library bundler config ├── babel.config.js # babel presets used by jest (babel-jest) -├── jest.config.js # currently empty → jest defaults +├── jest.config.js # transformIgnorePatterns: lucide-react is ESM-only, must go through babel ├── tsconfig.json # TS config; emits declarations to dist/ ├── package.json # name, version, exports/types, scripts, deps ├── bun.lock # the committed lockfile (never commit package-lock.json) @@ -55,6 +57,26 @@ Top-level layout (build artifacts and `node_modules` omitted): └── bundle-analysis.html # rollup-plugin-visualizer output (gitignored via *.html) ``` +## Scope resolution + +`src/scopes/lazyScope.ts` is the **only** scope path. It regex-scans the code for +identifiers and loads just what is referenced: lucide icons one file at a time, +recharts / motion / `react-icons/fa` whole (they are not per-component +splittable). `resolveScope` is async; `useResolvedScope` returns `readyCode` so +code is never evaluated against a scope that has not finished loading. + +The name → loader tables in `src/scopes/generated/` are produced from the +installed dependencies by `bun run generate:scopes`, which runs automatically as +part of `bun run build`. **Never hand-edit `src/scopes/generated/`.** Import +specifiers must be string literals or Vite/Rollup cannot split them — that is +why the tables are generated rather than computed. + +The old eager scope (`src/scopes/Scope.ts` and the per-package +`lucidreactScope` / `rechartScope` / `motionScope` modules) was removed in +5.0.0. It spread every export of every package into one object at module load, +forcing ~300 KB (brotli) into the first chunk and handing ~5800 parameters to +`new Function` on every evaluation. Do not reintroduce it. + ## Commands Bun is the package manager; the committed lockfile is `bun.lock` (never commit `package-lock.json`). diff --git a/src/scopes/Scope.ts b/src/scopes/Scope.ts deleted file mode 100644 index 3f5eada..0000000 --- a/src/scopes/Scope.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { rechartsScope } from "./rechartScope"; -import { lucideScope } from "./lucidreactScope"; -import { editComponentScope } from "./editComponentScope"; -import motion, { motionHooksComponents } from "./motionScope"; -import helmetScope from "./helmetScope" - -import React, { - useState, - useEffect, - useContext, - useReducer, - useRef, - useMemo, - useCallback, -} from "react"; - -// react scope -const reactScope = { - React, - useState, - useEffect, - useContext, - useReducer, - useRef, - useMemo, - useCallback, -}; - -// Export all the components and scope needed. -// -// react-icons/fa was removed: it added ~424KB gzipped (roughly half the -// bundle) for 1611 icons that duplicate what lucide-react already covers with -// 5673. Code that referenced `Fa*` names must switch to lucide equivalents. -export const scope: any = { - ...lucideScope, - ...rechartsScope, - ...reactScope, - ...editComponentScope, - motion, // has to be named motion - - ...motionHooksComponents, // motion hooks, components - ...helmetScope, -}; diff --git a/src/scopes/__test__/scope.test.js b/src/scopes/__test__/scope.test.js deleted file mode 100644 index 8334f1d..0000000 --- a/src/scopes/__test__/scope.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { scope } from "../Scope"; - -// Test Scope -describe("scope object", () => { - test("contains React and hooks", () => { - expect(scope.React).toBeDefined(); - expect(scope.useState).toBeDefined(); - expect(scope.useEffect).toBeDefined(); - expect(scope.useContext).toBeDefined(); - expect(scope.useReducer).toBeDefined(); - expect(scope.useRef).toBeDefined(); - expect(scope.useMemo).toBeDefined(); - expect(scope.useCallback).toBeDefined(); - }); - - test("contains motion and motion hooks/components", () => { - expect(scope.motion).toBeDefined(); - expect(scope.useAnimate).toBeDefined(); - expect(scope.useScroll).toBeDefined(); - expect(scope.LazyMotion).toBeDefined(); - expect(scope.useReducedMotion).toBeDefined(); - }); - - test("contains recharts components", () => { - expect(scope.AreaChart).toBeDefined(); - expect(scope.ResponsiveContainer).toBeDefined(); - expect(scope.CartesianGrid).toBeDefined(); - expect(scope.LineChart).toBeDefined(); - expect(scope.XAxis).toBeDefined(); - }); - - test("contains lucid react components", () => { - expect(scope.Camera).toBeDefined(); - expect(scope.StarHalf).toBeDefined(); - expect(scope.Smile).toBeDefined(); - expect(scope.Landmark).toBeDefined(); - expect(scope.House).toBeDefined(); - }); - - // Fa icons are available, but only through the lazy scope -- referencing one - // fetches the pack (~424KB gzipped) on demand. This eager scope must stay - // free of them, or every consumer pays that cost whether they use it or not. - test("does NOT contain react-icons/fa components", () => { - expect(scope.FaBeer).toBeUndefined(); - expect(scope.FaHeart).toBeUndefined(); - expect(Object.keys(scope).filter((k) => /^Fa[A-Z0-9]/.test(k))).toHaveLength(0); - }); - - test("contains EditableText custom components", () => { - expect(scope.EditableText).toBeDefined(); - }); - - test("contains Helmet-async", () => { - expect(scope.Helmet).toBeDefined(); - expect(scope.HelmetProvider).toBeDefined(); - }); - -}); diff --git a/src/scopes/lucidreactScope.ts b/src/scopes/lucidreactScope.ts deleted file mode 100644 index b7f39c2..0000000 --- a/src/scopes/lucidreactScope.ts +++ /dev/null @@ -1,286 +0,0 @@ -// import { -// // Icons -// Activity, -// AlertCircle, -// AlertTriangle, -// Archive, -// ArrowDown, -// ArrowLeft, -// ArrowRight, -// ArrowUp, -// BarChart, -// Bell, -// Book, -// Bookmark, -// Calendar, -// Camera, -// Check, -// CheckCircle, -// ChevronDown, -// ChevronLeft, -// ChevronRight, -// ChevronUp, -// Clock, -// Cloud, -// Code, -// Coffee, -// Command, -// Copy, -// CreditCard, -// Database, -// Download, -// Edit, -// Eye, -// EyeOff, -// File, -// FileText, -// Filter, -// Folder, -// Github, -// Globe, -// Grid, -// HardDrive, -// Hash, -// Heart, -// Home, -// Image, -// Inbox, -// Info, -// Key, -// Layers, -// Layout, -// Link, -// List, -// Lock, -// LogIn, -// LogOut, -// Mail, -// Map, -// Menu, -// MessageCircle, -// MessageSquare, -// Mic, -// Moon, -// MoreHorizontal, -// MoreVertical, -// Move, -// Music, -// Paperclip, -// Pause, -// Phone, -// Play, -// Plus, -// PlusCircle, -// Power, -// Printer, -// Radio, -// RefreshCw, -// Save, -// Search, -// Send, -// Settings, -// Share, -// Shield, -// ShoppingBag, -// ShoppingCart, -// Shuffle, -// Slack, -// Slash, -// Sliders, -// Smartphone, -// Smile, -// Speaker, -// Square, -// Star, -// Sun, -// Table, -// Tablet, -// Tag, -// Target, -// Terminal, -// Thermometer, -// ThumbsDown, -// ThumbsUp, -// Trash, -// Trash2, -// Trophy, -// Twitter, -// Upload, -// User, -// UserPlus, -// Users, -// Video, -// Voicemail, -// Volume, -// Volume1, -// Volume2, -// VolumeX, -// Watch, -// Wifi, -// X, -// XCircle, -// ZapOff, -// Zap, -// // YouTube, - -// // Core attributes and properties -// // createIcon, -// // icons, - -// // Icon variants -// IconNode, -// LucideIcon, -// LucideProps -// } from "lucide-react"; - -// // lucide-react scope -// export const lucideScope = { -// // Icons -// Activity, -// AlertCircle, -// AlertTriangle, -// Archive, -// ArrowDown, -// ArrowLeft, -// ArrowRight, -// ArrowUp, -// BarChart, -// Bell, -// Book, -// Bookmark, -// Calendar, -// Camera, -// Check, -// CheckCircle, -// ChevronDown, -// ChevronLeft, -// ChevronRight, -// ChevronUp, -// Clock, -// Cloud, -// Code, -// Coffee, -// Command, -// Copy, -// CreditCard, -// Database, -// Download, -// Edit, -// Eye, -// EyeOff, -// File, -// FileText, -// Filter, -// Folder, -// Github, -// Globe, -// Grid, -// HardDrive, -// Hash, -// Heart, -// Home, -// Image, -// Inbox, -// Info, -// Key, -// Layers, -// Layout, -// Link, -// List, -// Lock, -// LogIn, -// LogOut, -// Mail, -// Map, -// Menu, -// MessageCircle, -// MessageSquare, -// Mic, -// Moon, -// MoreHorizontal, -// MoreVertical, -// Move, -// Music, -// Paperclip, -// Pause, -// Phone, -// Play, -// Plus, -// PlusCircle, -// Power, -// Printer, -// Radio, -// RefreshCw, -// Save, -// Search, -// Send, -// Settings, -// Share, -// Shield, -// ShoppingBag, -// ShoppingCart, -// Shuffle, -// Slack, -// Slash, -// Sliders, -// Smartphone, -// Smile, -// Speaker, -// Square, -// Star, -// Sun, -// Table, -// Tablet, -// Tag, -// Target, -// Terminal, -// Thermometer, -// ThumbsDown, -// ThumbsUp, -// Trash, -// Trash2, -// Trophy, -// Twitter, -// Upload, -// User, -// UserPlus, -// Users, -// Video, -// Voicemail, -// Volume, -// Volume1, -// Volume2, -// VolumeX, -// Watch, -// Wifi, -// X, -// XCircle, -// ZapOff, -// Zap, -// // YouTube, - -// // Core attributes and properties -// // createIcon, -// // icons, - -// // Icon variants -// // IconNode, -// // LucideIcon, -// // LucideProps -// }; - -// https://www.npmjs.com/package/lucide-react -// https://lucide.dev/guide/packages/lucide-react - -import * as LucideIcons from "lucide-react"; - -// Create scope with only the actual components using type assertion -const componentNames = Object.keys(LucideIcons).filter(key => - typeof (LucideIcons as any)[key] === 'function' || typeof (LucideIcons as any)[key] === 'object' -); - -// Create scope with type assertion -export const lucideScope = componentNames.reduce((acc, name) => { - acc[name] = (LucideIcons as any)[name]; - return acc; -}, {} as Record); \ No newline at end of file diff --git a/src/scopes/motionScope.ts b/src/scopes/motionScope.ts deleted file mode 100644 index d150518..0000000 --- a/src/scopes/motionScope.ts +++ /dev/null @@ -1,9 +0,0 @@ -// https://motion.dev/docs/react - - -import * as Motion from "motion/react"; - -const { motion, ...motionHooksComponents }: any = Motion; - -export default motion; -export { motionHooksComponents }; diff --git a/src/scopes/rechartScope.ts b/src/scopes/rechartScope.ts deleted file mode 100644 index b254c2d..0000000 --- a/src/scopes/rechartScope.ts +++ /dev/null @@ -1,112 +0,0 @@ -// import { -// AreaChart, -// BarChart, -// LineChart, -// ComposedChart, -// PieChart, -// RadarChart, -// RadialBarChart, -// ScatterChart, -// FunnelChart, -// Treemap, -// ResponsiveContainer, -// Legend, -// Tooltip, -// Cell, -// Text, -// Label, -// LabelList, -// Customized, -// Area, -// Bar, -// Line, -// Scatter, -// XAxis, -// YAxis, -// ZAxis, -// Brush, -// CartesianAxis, -// CartesianGrid, -// ReferenceLine, -// ReferenceDot, -// ReferenceArea, -// ErrorBar, -// Funnel, -// Cross, -// Curve, -// Dot, -// Polygon, -// Rectangle, -// Sector, -// Pie, -// Radar, -// RadialBar, -// PolarAngleAxis, -// PolarGrid, -// PolarRadiusAxis, -// } from "recharts"; - - -// // recharts scope -// export const rechartsScope = { -// AreaChart, -// BarChart, -// LineChart, -// ComposedChart, -// PieChart, -// RadarChart, -// RadialBarChart, -// ScatterChart, -// FunnelChart, -// Treemap, -// ResponsiveContainer, -// Legend, -// Tooltip, -// Cell, -// Text, -// Label, -// LabelList, -// Customized, -// Area, -// Bar, -// Line, -// Scatter, -// XAxis, -// YAxis, -// ZAxis, -// Brush, -// CartesianAxis, -// CartesianGrid, -// ReferenceLine, -// ReferenceDot, -// ReferenceArea, -// ErrorBar, -// Funnel, -// Cross, -// Curve, -// Dot, -// Polygon, -// Rectangle, -// Sector, -// Pie, -// Radar, -// RadialBar, -// PolarAngleAxis, -// PolarGrid, -// PolarRadiusAxis, -// }; - -// https://www.npmjs.com/package/recharts - -import * as ReChartComponents from 'recharts'; - -// Filter only actual components (functions or objects) -const reComponentNames = Object.keys(ReChartComponents).filter( - key => typeof (ReChartComponents as any)[key] === 'function' || typeof (ReChartComponents as any)[key] === 'object' -); - -// Create the scope -export const rechartsScope = reComponentNames.reduce((acc, name) => { - acc[name] = (ReChartComponents as any)[name]; - return acc; -}, {} as Record); \ No newline at end of file From e89762100fd753d1216d72c30b7589b085fe52b2 Mon Sep 17 00:00:00 2001 From: James Titus Date: Thu, 13 Aug 2026 11:58:07 +0530 Subject: [PATCH 2/6] chore: buildStandaloneSite is functional --- .gitignore | 4 + AGENT.md | 25 +- README.md | 49 +++ bun.lock | 13 + doc_site/docs/standalone-site.md | 151 +++++++++ doc_site/docusaurus.config.ts | 4 + doc_site/sidebars.ts | 1 + package.json | 28 +- rollup.config.mjs | 55 +++- scripts/manual/fixtures/demo-page.jsx | 94 ++++++ scripts/manual/test-standalone-site.mjs | 188 +++++++++++ skills/react-code-canvas/SKILL.md | 223 ++++++++++++- .../references/performance.md | 7 + src/analyzer/analyzeReactCode.ts | 14 +- src/builder/__tests__/entry.test.js | 164 ++++++++++ src/builder/entry.ts | 173 ++++++++++ src/builder/standalone.ts | 300 ++++++++++++++++++ src/coreComponents/core/custom-transformer.js | 2 +- src/scopes/__test__/lazyScope.test.js | 20 ++ src/scopes/lazyScope.ts | 29 +- 20 files changed, 1522 insertions(+), 22 deletions(-) create mode 100644 doc_site/docs/standalone-site.md create mode 100644 scripts/manual/fixtures/demo-page.jsx create mode 100644 scripts/manual/test-standalone-site.mjs create mode 100644 src/builder/__tests__/entry.test.js create mode 100644 src/builder/entry.ts create mode 100644 src/builder/standalone.ts diff --git a/.gitignore b/.gitignore index f2b2319..00478e0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ bundle-analysis.html # Agent .claude/ + +# manual demo output +/demo-site +/out diff --git a/AGENT.md b/AGENT.md index f7045b6..9856be4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -22,6 +22,24 @@ The package entry is `src/index.ts`. It exports two components, their prop types Prop types `ReactCanvasProps` and `EditReactCanvasProps` are exported alongside, plus the analyzer's `AnalysisResult`, `AnalyzeOptions`, `CodeIssue` and `IssueType`. +### Second entry: `react-code-canvas/builder` (Node only) + +| Export | Source | Purpose | +| --- | --- | --- | +| `buildStandaloneSite` | `src/builder/standalone.ts` | Build a code string into a deployable static site; returns a `path -> contents` map. | +| `writeStandaloneSite` | `src/builder/standalone.ts` | The same, written to a directory. | + +Emits `index.html` plus hashed `assets/`, with React, every referenced library and +compiled Tailwind bundled in — no network at runtime. **Never import this from `src/index.ts`**: it +pulls in `esbuild`, `node:*` and `react-dom/server`, none of which may reach a consumer's browser +bundle. `esbuild` and `tailwindcss` are *optional* peer dependencies, loaded via dynamic import at +point of use. + +The pure string half lives in `src/builder/entry.ts`, separate only because `standalone.ts` uses +`import.meta.url` (for esbuild's `resolveDir`), which is a syntax error under jest's CJS runtime — +so anything a test imports has to live outside it. Jest covers `entry.ts`; the end-to-end build runs +via `bun run test:manual:site` against `dist/builder.mjs`. + **Contract for consumer-supplied `code`:** must `export default` a component (or call `render(...)`), must **not** contain `import` statements — dependencies are injected through the `scope` prop instead. **Code persistence:** `ReactCanvas` and `EditTextReactCanvas` accept `persistKey?: string` (saves/restores code in `localStorage`) and `onCodeChange?: (code: string) => void` (notifies on change so the host can persist however it likes). For `EditTextReactCanvas` these operate on the **final saved JSX**, not the intermediate EditableText form. Shared, SSR-safe storage helpers live in `src/coreComponents/core/storage.ts`. @@ -37,7 +55,7 @@ Top-level layout (build artifacts and `node_modules` omitted): ├── doc_site/ # Docusaurus documentation site (own package + bun.lock) ├── scripts/ │ ├── generate-scope-maps.mjs # emits src/scopes/generated/ from node_modules -│ └── manual/ # manual, run-by-hand scripts (e.g. transformer walkthrough) +│ └── manual/ # manual, run-by-hand scripts (transformer, analyzer, site builder) ├── skills/ # react-code-canvas agent skill, shipped in the package ├── public/ # static assets (e.g. rrc.png used by the README) ├── storybook-static/ # built static Storybook (gitignored) @@ -84,7 +102,10 @@ Bun is the package manager; the committed lockfile is `bun.lock` (never commit ` ```bash bun install --frozen-lockfile bun run test # jest -bun run build # rollup -c -> dist/ +bun run build # rollup -c -> dist/ (browser entry + dist/builder.mjs) +bun run test:manual:site # end-to-end site build; needs `bun run build` first +bun run test:manual:site ./Page.jsx [outDir] # build one canvas-format file instead +bun run demo:site # the same, on scripts/manual/fixtures/demo-page.jsx bun run dev # storybook dev on :6006 bun run build-storybook # static Storybook -> storybook-static/ bun run size # size-limit check diff --git a/README.md b/README.md index 3cc40c4..4554bad 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,55 @@ import { EditTextReactCanvas } from "react-code-canvas"; `EditTextReactCanvas` adds text editing behavior to rendered React code (beta). +### Build a standalone website + +Turn a code string into a deployable static site — `index.html` plus hashed `assets/`, with React, +every library the code references and compiled Tailwind bundled in. No CDN, no network at runtime. + +```js +// Node only. Ships as a separate entry so nothing here reaches your browser bundle. +import { writeStandaloneSite } from "react-code-canvas/builder"; + +await writeStandaloneSite(CODE, "./out"); +// -> out/index.html, out/assets/app-.js, out/assets/style-.css +``` + +Then deploy `out/` anywhere that serves files. Use `buildStandaloneSite` instead to get a +`path -> contents` map and handle delivery yourself. + +Requires the optional peers it actually uses: `npm i -D esbuild`, plus `tailwindcss` if your code +uses `className`. Neither is loaded otherwise. + +#### Options + +| Option | Type | Description | +| --- | --- | --- | +| `title` | `string` | `` for code that emits none of its own. A `<Helmet><title>` in the code wins. | +| `lang` | `string` | `<html lang>`. Default `'en'`. | +| `tailwind` | `boolean` | Compile Tailwind for the classes found in the code. Default `true`; only runs when the code contains `className`. | +| `css` | `string` | Extra CSS, appended after the compiled Tailwind. | +| `head` | `string` | Raw HTML appended to `<head>` — fonts, favicon, OG tags, analytics. | +| `prerender` | `boolean` | Bake the initial markup into the HTML for SEO, then hydrate. Default `true`. | +| `minify` | `boolean` | Default `true`. | +| `target` | `string` | esbuild target. Default `'es2020'`. | + +Notes: + +- **Styling all works**: inline `style` objects, in-component `<style>` tags, and Tailwind classes + are compiled into a real stylesheet. Unlike the canvas, the built site does not need the host to + provide Tailwind. +- **Prerendering is best-effort.** Code that touches `document` or `window` *during render* throws + in Node; the build warns and emits a client-only shell instead of failing. Guard with + `typeof document !== "undefined"` to keep the prerender. `useEffect` never runs during prerender, + so it is always safe. +- `<Helmet>` finally does something here — React 19 hoists its `<title>`/`<meta>` and the builder + lifts them into `<head>`, where crawlers read them. +- Machine-generated `<EditableText>` tags from an `EditTextReactCanvas` session are reversed back to + plain JSX; a deployed page does not keep click-to-edit fields. +- **recharts charts do not prerender** — measured: they render nothing server-side, fixed size or + not, and appear on hydration. The rest of the page still prerenders normally, so only the chart + area is blank in the initial HTML. + ## Notes - Code should export a default component, for example `export default function ComponentName() {}`. diff --git a/bun.lock b/bun.lock index 0118f64..b554225 100644 --- a/bun.lock +++ b/bun.lock @@ -41,8 +41,10 @@ "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.4", "@types/react-helmet": "^6.1.11", "babel-jest": "^30.4.1", + "esbuild": "^0.28.1", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "package-size": "^2.3.0", @@ -55,11 +57,18 @@ "rollup-plugin-visualizer": "^7.0.1", "size-limit": "^13.0.2", "storybook": "^10.5.5", + "tailwindcss": "^4.3.3", }, "peerDependencies": { + "esbuild": ">=0.20", "react": "^19.0.0", "react-dom": "^19.0.0", + "tailwindcss": ">=4.0", }, + "optionalPeers": [ + "esbuild", + "tailwindcss", + ], }, }, "trustedDependencies": [ @@ -815,6 +824,8 @@ "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + "@types/react-helmet": ["@types/react-helmet@6.1.11", "", { "dependencies": { "@types/react": "*" } }, "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], @@ -2403,6 +2414,8 @@ "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "tapable": ["tapable@1.1.3", "", {}, "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="], "tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], diff --git a/doc_site/docs/standalone-site.md b/doc_site/docs/standalone-site.md new file mode 100644 index 0000000..c218a71 --- /dev/null +++ b/doc_site/docs/standalone-site.md @@ -0,0 +1,151 @@ +--- +sidebar_position: 7 +title: Standalone site +--- + +# Build a standalone website + +The canvas renders code inside a host page. `buildStandaloneSite` takes the same code string and +turns it into a **deployable static site** — `index.html` plus hashed `assets/` — with React, every +library the code references, and compiled Tailwind bundled in. + +No CDN, no import map, no network at runtime. Copy the directory to Netlify, Vercel, S3, GitHub +Pages, or any host that serves files. + +:::info Node only +This ships as a separate entry, `react-code-canvas/builder`. It imports `esbuild`, `node:*` builtins +and `react-dom/server`, so it must never reach a browser bundle. Importing +`react-code-canvas` itself never pulls any of it in. +::: + +## Install + +```bash +npm i -D esbuild +npm i -D tailwindcss # only if your code uses className +``` + +Both are **optional peer dependencies**, loaded on demand. Code styled entirely with inline `style` +never needs `tailwindcss` at all. + +## Usage + +```js +import { writeStandaloneSite } from "react-code-canvas/builder"; + +const CODE = ` +export default function Page() { + const [n, setN] = useState(0); + return ( + <main className="flex flex-col gap-4 p-8 bg-slate-900 text-white"> + <h1 className="text-3xl font-bold">Count: {n}</h1> + <button className="rounded bg-blue-600 px-4 py-2" onClick={() => setN(n + 1)}> + increment + </button> + </main> + ); +} +`; + +await writeStandaloneSite(CODE, "./out"); +``` + +``` +out/ +├── index.html +└── assets/ + ├── app-8bf13721.js react + everything the code imports + └── style-067d3221.css compiled Tailwind +``` + +```bash +npx serve out +``` + +For full control over delivery, use `buildStandaloneSite`, which returns a `path -> contents` map and +touches no filesystem: + +```js +import { buildStandaloneSite } from "react-code-canvas/builder"; + +const files = await buildStandaloneSite(CODE); +// { 'index.html': '<!doctype html>…', 'assets/app-<hash>.js': '…', … } +``` + +## Options + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `title` | `string` | — | `<title>` for code that emits none. A `<Helmet><title>` in the code wins. | +| `lang` | `string` | `'en'` | `<html lang>`. | +| `tailwind` | `boolean` | `true` | Compile Tailwind for the classes found in the code. | +| `css` | `string` | — | Extra CSS, appended after the compiled Tailwind. | +| `head` | `string` | — | Raw HTML appended to `<head>` — fonts, favicon, OG tags, analytics. | +| `prerender` | `boolean` | `true` | Bake initial markup into the HTML, then hydrate. | +| `minify` | `boolean` | `true` | Minify both the JS and the CSS. | +| `target` | `string` | `'es2020'` | esbuild target. | + +## Styling + +All three styling approaches work, and none of them need anything from a host page: + +- **Inline `style={{…}}`** — carried into the bundle as ordinary props. +- **In-component `<style>` tags** — the CSS text is part of the bundle. This is how you express + pseudo-classes, media queries and keyframes. +- **Tailwind `className`** — compiled into a real stylesheet at build time. + +This is the one place the [Scope](./scope.md) guidance about avoiding Tailwind does not apply. That +advice is about the canvas, where classes are inert unless the host loads Tailwind. Here there is a +compile step, so they simply work. + +Class names are collected from every string and template literal in the source, which covers +conditional and template forms: + +```jsx +<p className={active ? "bg-green-500" : "bg-red-500"} /> +<p className={`p-4 ${spacing}`} /> +``` + +Only the static parts of a template literal can be seen, so a fully computed class name +(`` `bg-${color}-500` ``) will not be compiled. Write the alternatives out in full. + +## Prerendering + +By default the initial markup is rendered in Node and baked into `index.html`, then hydrated on the +client — so crawlers and a slow first paint both see real content. + +Prerendering **executes your code in Node**, where there is no DOM. It is best-effort: + +- `useEffect` never runs during prerender. Always safe. +- Touching `document` or `window` *during render* throws. The build warns and emits a client-only + shell rather than failing. The page still works; it just starts empty. + +```jsx +// prerenders +const isDark = typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; + +// falls back to a client-only shell +const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; +``` + +Pass `prerender: false` to skip it entirely. + +`<Helmet>` is genuinely useful here: React 19 hoists its `<title>` and `<meta>` tags, and the builder +lifts them into `<head>` where crawlers read them. In the canvas, `<Helmet>` has no effect unless the +host renders a provider. + +**recharts charts do not prerender.** Measured: they render nothing server-side, whether sized +explicitly or wrapped in `ResponsiveContainer`, and appear once the client hydrates. Everything else +on the page still prerenders, so only the chart area is blank in the initial HTML — fine for a +dashboard, worth knowing if a chart is your above-the-fold content. + +## Notes + +- The code contract is the same as the canvas: `export default` a component (or call `render(...)`), + and **no `import` statements** — dependencies come from the scope. The builder validates with + [`analyzeReactCode`](./react-canvas.md) first and throws with the specific issues if the code + cannot render. +- Machine-generated `<EditableText>` tags from an `EditTextReactCanvas` session are reversed back to + plain JSX. A deployed page does not keep click-to-edit fields. +- Only what the code references is bundled. Two lucide icons cost about 2 KB, not the whole pack. +- Asset filenames are content-hashed, so a redeploy busts caches on its own. diff --git a/doc_site/docusaurus.config.ts b/doc_site/docusaurus.config.ts index 08d9bfa..df496d2 100644 --- a/doc_site/docusaurus.config.ts +++ b/doc_site/docusaurus.config.ts @@ -10,6 +10,10 @@ import type { // `react-code-canvas/package.json`. The package's `exports` map only exposes // ".", so a subpath import of package.json fails with ERR_PACKAGE_PATH_NOT_EXPORTED // on versions that ship that field. fs bypasses exports resolution entirely. +// +// The exports map now carries "./package.json", but this stays until the docs +// depend on a published version that has it -- the pin above is 5.0.0, which +// does not, and switching early would silently degrade to 'unknown'. function readCanvasVersion(): string { try { const pkgPath = path.join( diff --git a/doc_site/sidebars.ts b/doc_site/sidebars.ts index 53745f6..db06004 100644 --- a/doc_site/sidebars.ts +++ b/doc_site/sidebars.ts @@ -11,6 +11,7 @@ const sidebars: SidebarsConfig = { items: ['react-canvas', 'edit-text-react-canvas'], }, 'scope', + 'standalone-site', 'security', 'contributing', ], diff --git a/package.json b/package.json index 74604b2..afe2e83 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,12 @@ "types": "./dist/index.d.ts", "import": "./dist/index.esm.js", "require": "./dist/index.js" - } + }, + "./builder": { + "types": "./dist/builder/standalone.d.ts", + "import": "./dist/builder.mjs" + }, + "./package.json": "./package.json" }, "files": [ "dist", @@ -25,6 +30,8 @@ "test:coverage": "jest --coverage", "test:manual:transformer": "bun run scripts/manual/test-custom-transformer.js", "test:manual:analyzer": "bun run scripts/manual/test-analyze-react-code.js", + "test:manual:site": "bun run scripts/manual/test-standalone-site.mjs", + "demo:site": "bun run test:manual:site scripts/manual/fixtures/demo-page.jsx demo-site", "size": "bun run build && size-limit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", @@ -52,7 +59,17 @@ }, "peerDependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "esbuild": ">=0.20", + "tailwindcss": ">=4.0" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "tailwindcss": { + "optional": true + } }, "devDependencies": { "@babel/preset-env": "^7.29.7", @@ -65,7 +82,9 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", "@size-limit/preset-big-lib": "^13.0.2", + "@storybook/addon-docs": "^10.5.5", "@storybook/addon-links": "^10.5.5", + "@storybook/addon-mcp": "^0.7.0", "@storybook/addon-onboarding": "^10.5.5", "@storybook/addon-webpack5-compiler-babel": "^4.0.1", "@storybook/addon-webpack5-compiler-swc": "^4.0.3", @@ -76,8 +95,10 @@ "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.4", "@types/react-helmet": "^6.1.11", "babel-jest": "^30.4.1", + "esbuild": "^0.28.1", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "package-size": "^2.3.0", @@ -90,8 +111,7 @@ "rollup-plugin-visualizer": "^7.0.1", "size-limit": "^13.0.2", "storybook": "^10.5.5", - "@storybook/addon-mcp": "^0.7.0", - "@storybook/addon-docs": "^10.5.5" + "tailwindcss": "^4.3.3" }, "overrides": { "typescript": "^5.8.2" diff --git a/rollup.config.mjs b/rollup.config.mjs index b6ddfe1..83fd112 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -40,7 +40,7 @@ const external = [ "react-helmet-async", ]; -const config = { +const browserConfig = { input: "src/index.ts", // `dir` rather than `file`: the editor is dynamically imported (see // LazyLiveEditor), and a local dynamic import can only become its own chunk @@ -103,4 +103,55 @@ const config = { ], }; -export default config; +// The standalone site builder. Node-only, and kept out of the browser entry +// entirely: it pulls in esbuild, node:* builtins and react-dom/server, none of +// which may reach a consumer's client bundle. Published as `./builder`. +// +// ESM only, emitted as .mjs: the package has no "type" field, so a .js file +// here would be parsed as CJS and fail. The extension also makes the +// `import.meta.url` that esbuild's resolveDir depends on legal. +const builderConfig = { + input: "src/builder/standalone.ts", + output: { + dir: "dist", + format: "esm", + entryFileNames: "builder.mjs", + chunkFileNames: "chunks/builder/[name]-[hash].js", + sourcemap: false, + }, + external: (id) => + id.startsWith("node:") || + id === "esbuild" || + id === "tailwindcss" || + external.some((pkg) => id === pkg || id.startsWith(`${pkg}/`)), + plugins: [ + json(), + typescript({ + tsconfig: "./tsconfig.json", + // clean:false so this run does not wipe the cache the browser config + // above just populated -- with clean:true the two configs fight and the + // second build recompiles everything from scratch. + clean: false, + exclude: storybookExclude, + useTsconfigDeclarationDir: true, + }), + // preferBuiltins is the opposite of the browser config: node:fs and friends + // must stay external rather than being shimmed. + resolve({ + preferBuiltins: true, + extensions: [".js", ".jsx", ".ts", ".tsx", ".json"], + }), + commonjs(), + babel({ + babelHelpers: "bundled", + exclude: ["node_modules/**", ...storybookExclude], + presets: ["@babel/preset-react"], + extensions: [".js", ".jsx", ".ts", ".tsx"], + }), + terser(), + // No visualizer here: it has `open: true` and would launch a second browser + // tab on every build. + ], +}; + +export default [browserConfig, builderConfig]; diff --git a/scripts/manual/fixtures/demo-page.jsx b/scripts/manual/fixtures/demo-page.jsx new file mode 100644 index 0000000..88acc8d --- /dev/null +++ b/scripts/manual/fixtures/demo-page.jsx @@ -0,0 +1,94 @@ +// A canvas-format page for eyeballing a built site in a real browser. +// +// bun run build && bun run demo:site && npx serve demo-site +// +// Deliberately combines the things contract-level assertions cannot judge: +// hydration (the counter is in the prerendered HTML but dead until JS runs), +// a <motion.div> member expression, a recharts chart whose name also exists in +// lucide, Helmet tags that must reach <head>, an in-component <style> block, and +// Tailwind classes that only work if the compile step ran. The two bugs this +// caught -- `motion is not defined` and <LineChart> rendering as a 24x24 icon -- +// now have regression tests, but the combination is still the fastest way to see +// whether a change broke something a browser would notice. + +export default function Dashboard() { + const [count, setCount] = useState(0); + + const data = [ + { name: "Mon", v: 12 }, { name: "Tue", v: 19 }, { name: "Wed", v: 8 }, + { name: "Thu", v: 24 }, { name: "Fri", v: 17 }, { name: "Sat", v: 29 }, + ]; + + return ( + <main className="min-h-screen bg-slate-950 text-slate-100 p-8"> + <Helmet> + <title>Standalone Demo + + + + + +
+ +

Standalone site demo

+
+ +

+ Everything on this page was bundled from a single code string. No CDN, no network. + Turn off your wifi and reload — it still works. +

+ +
+
+
+ +

Interactivity

+
+

Proves hydration: this button only works after the JS loads.

+
+ + +
+
+ +
+
+ +

Animation

+
+

motion/react, bundled and tree-shaken.

+ +
+
+ +
+
+ +

recharts

+
+ + + + + + + +
+ + ); +} diff --git a/scripts/manual/test-standalone-site.mjs b/scripts/manual/test-standalone-site.mjs new file mode 100644 index 0000000..f6c9d59 --- /dev/null +++ b/scripts/manual/test-standalone-site.mjs @@ -0,0 +1,188 @@ +/** + * End-to-end check for the standalone site builder. + * + * Lives here rather than in the jest suite because it needs the built + * dist/builder.mjs: the builder is ESM-only and uses `import.meta.url`, which + * is a syntax error under jest's CJS runtime. The pure half is covered by + * src/builder/__tests__/entry.test.js, which does run in CI. + * + * bun run build && bun run test:manual:site + * + * Pass a file to build that instead of the built-in suite. The file holds the + * same canvas-format string ReactCanvas takes -- a default export (or a + * render(...) call) and no import statements, with dependencies coming from the + * scope: + * + * bun run test:manual:site ./MyPage.jsx + * bun run test:manual:site ./MyPage.jsx ./out + */ + +import { readFile, rm } from "node:fs/promises"; +import { buildStandaloneSite, writeStandaloneSite } from "../../dist/builder.mjs"; + +const OUT = "/tmp/react-code-canvas-site"; + +let failures = 0; +const check = (label, ok, detail = "") => { + console.log(`${ok ? " ok " : " FAIL"} ${label}${detail ? ` -- ${detail}` : ""}`); + if (!ok) failures++; +}; + +const rootOf = (html) => html.match(/
([\s\S]*?)<\/div>\n + + +`; + + files["index.html"] = html; + return files; +} + +/** buildStandaloneSite, written to disk. Returns the paths written. */ +export async function writeStandaloneSite( + code: string, + outDir: string, + options: StandaloneOptions = {} +): Promise { + const files = await buildStandaloneSite(code, options); + const written: string[] = []; + + for (const [name, contents] of Object.entries(files)) { + const target = join(outDir, name); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents, "utf8"); + written.push(target); + } + return written; +} diff --git a/src/coreComponents/core/custom-transformer.js b/src/coreComponents/core/custom-transformer.js index 7523aa8..b78dfaf 100644 --- a/src/coreComponents/core/custom-transformer.js +++ b/src/coreComponents/core/custom-transformer.js @@ -336,7 +336,7 @@ export function transformEditableTextToJSX(code) { return { transformedCode: transformedCode, error: null }; } catch (error) { - return { transformedCode: null, error: "error" }; + return { transformedCode: null, error }; } } diff --git a/src/scopes/__test__/lazyScope.test.js b/src/scopes/__test__/lazyScope.test.js index 6872013..d007806 100644 --- a/src/scopes/__test__/lazyScope.test.js +++ b/src/scopes/__test__/lazyScope.test.js @@ -33,6 +33,26 @@ describe('collectIdentifiers', () => { }) }) +describe('resolveScope name collisions', () => { + // Eleven names exist in both lucide and recharts. The winner used to be + // whichever dynamic import resolved last, so a warm recharts cache let the + // icon overwrite it and rendered a 24x24 icon instead of a chart. + it.each([ + 'AreaChart', 'BarChart', 'Brush', 'Cross', 'Dot', + 'Funnel', 'LineChart', 'PieChart', 'Radar', 'ScatterChart', 'Text', + ])('gives recharts %s, not the lucide icon of the same name', async (name) => { + const recharts = await import('recharts') + const scope = await resolveScope(`export default () => <${name}/>`) + expect(scope[name]).toBe(recharts[name]) + }) + + it('still resolves a non-colliding icon from lucide', async () => { + const scope = await resolveScope('export default () => ') + const camera = await import('lucide-react/dist/esm/icons/camera.js') + expect(scope.Camera).toBe(camera.default) + }) +}) + describe('iconLoaderFor', () => { it('resolves a plain icon name', () => { expect(iconLoaderFor('ChevronRight')).toBeInstanceOf(Function) diff --git a/src/scopes/lazyScope.ts b/src/scopes/lazyScope.ts index ce37b6a..5b086d2 100644 --- a/src/scopes/lazyScope.ts +++ b/src/scopes/lazyScope.ts @@ -185,10 +185,24 @@ export async function resolveScope( ); } + // The namespaces are captured here and merged after the await, NOT written + // straight into `scope` from their .then. Eleven names exist in both lucide + // and recharts (AreaChart BarChart Brush Cross Dot Funnel LineChart PieChart + // Radar ScatterChart Text), and writing from inside the callbacks made the + // winner whichever import happened to resolve last: on a cold cache the ~0.7KB + // icon file beat recharts and recharts won, but once recharts was warm it + // resolved first and the icon overwrote it -- so silently rendered + // a 24x24 icon instead of a chart. Merging in a fixed order after the await + // makes the precedence the documented one: icons first, charts/motion over + // them, matching SKILL.md's "recharts owns Text/Label/Legend/Tooltip". + let rechartsMod: Record | undefined; + let motionMod: Record | undefined; + let faMod: Record | undefined; + if (needsRecharts) { pending.push( import("recharts").then((mod) => { - assignNamespace(scope, mod); + rechartsMod = mod as Record; }), ); } @@ -197,7 +211,7 @@ export async function resolveScope( pending.push( // `motion` has to keep its name -- resolves against it. import("motion/react").then((mod) => { - assignNamespace(scope, mod); + motionMod = mod as Record; }), ); } @@ -210,13 +224,18 @@ export async function resolveScope( // modules from the pack ourselves, or move the code to lucide equivalents. pending.push( import("react-icons/fa").then((mod: Record) => { - // Assign only what the code referenced, not all 1611 exports: these - // become `new Function` parameters on every evaluation. - for (const name of faNames) scope[name] = mod[name]; + faMod = mod; }), ); } await Promise.all(pending); + + if (rechartsMod) assignNamespace(scope, rechartsMod); + if (motionMod) assignNamespace(scope, motionMod); + // Assign only what the code referenced, not all 1611 exports: these become + // `new Function` parameters on every evaluation. + if (faMod) for (const name of faNames) scope[name] = faMod[name]; + return { ...scope, ...extra }; } From 4debbf121d81870d59e3ab429911db6668569930 Mon Sep 17 00:00:00 2001 From: James Titus Date: Thu, 13 Aug 2026 12:41:10 +0530 Subject: [PATCH 3/6] chore: buildStandaloneSite css building --- scripts/manual/test-standalone-site.mjs | 16 +- skills/react-code-canvas/SKILL.md | 220 +- .../react-code-canvas/references/patterns.md | 2 +- .../references/performance.md | 6 +- src/builder/__tests__/entry.test.js | 39 + src/builder/entry.ts | 22 +- src/builder/standalone.ts | 31 +- t.jsx | 1873 +++++++++++++++++ 8 files changed, 2100 insertions(+), 109 deletions(-) create mode 100644 t.jsx diff --git a/scripts/manual/test-standalone-site.mjs b/scripts/manual/test-standalone-site.mjs index f6c9d59..6c5867c 100644 --- a/scripts/manual/test-standalone-site.mjs +++ b/scripts/manual/test-standalone-site.mjs @@ -113,16 +113,24 @@ const files = await buildStandaloneSite(page, { title: "Fallback" }); const ms = Date.now() - t0; const html = files["index.html"]; const js = files[Object.keys(files).find((k) => k.includes("app-"))]; -const css = files[Object.keys(files).find((k) => k.includes("style-"))]; +// Small CSS is inlined into the HTML rather than emitted as a file, so read it +// from whichever place it landed. +const css = files[Object.keys(files).find((k) => k.includes("style-"))] + ?? html.match(/ +
…
+``` + +That CSS ships inside the JS bundle and works — it is not Tailwind's to compile. + **Guard DOM access during render, or lose the prerender.** The builder renders the first frame in Node, where there is no DOM. Touching `document` or `window` *while rendering* throws; the build warns and falls back to a client-only shell, so the page still works but @@ -317,125 +353,125 @@ One thing that does not prerender at all: **recharts**. Charts render nothing se and appear on hydration. Everything around them still prerenders, so this only matters if a chart is your above-the-fold content. -## 9. Performance +## 9. Performance — standalone builds -Every name in the source decides what gets downloaded, and the whole string is -re-evaluated on every change — so authoring choices are directly load-time and -main-thread choices. The rules that matter, in order of cost: +**Load time of the built site.** For canvas-only code, skip to §9b. -1. **Never emit an `Fa*` name unless the page already uses Font Awesome.** One - `Fa*` reference fetches the whole ~420 KB gz pack; the lucide equivalent is - one ~0.8 KB file. `FaUser` → `User`, `FaHome` → `House`. -2. **Do not mention chart/animation names you don't render.** The scanner is a - regex over the whole source — `recharts` (~145 KB gz) and `motion` (~61 KB gz) - load even when the name only appears in a **comment or string literal**. - `// like a LineChart` costs 145 KB. -3. **Keep module level empty.** Top-level statements re-run on every evaluation, - including the re-execution after a failed edit. Constants are fine; work is not. -4. **Render the shell before the data.** The canvas paints as soon as evaluation - returns — a component that returns `null` until a `fetch` resolves wastes that - paint and pushes the host page's LCP onto your network call. -5. **Style inline, not with Tailwind.** Tailwind classes only render if the host - ships a ~124 KB browser JIT on every visit; inline `style` (plus a ` + ``` -A self-hosted or system font stack avoids the round trip entirely and is the fastest -option if the design allows it. +A system font stack avoids all of it — no request, no invisible text, no shift — and is +the fastest option whenever the design allows. Tailwind's default `font-sans` already is +one. + +## 9b. Performance — canvas rendering + +Different machine, different rules. The canvas has no bundler: it scans the source with +a **regex** and fetches whole packages at runtime, and re-evaluates the entire string on +every change. So authoring choices are directly load-time and main-thread choices: + +1. **Never emit an `Fa*` name unless the page already uses Font Awesome.** One + `Fa*` reference fetches the whole ~420 KB gz pack; the lucide equivalent is + one ~0.8 KB file. `FaUser` → `User`, `FaHome` → `House`. +2. **Do not mention chart/animation names you don't render.** The scanner is a + regex over the whole source — `recharts` (~158 KB gz) and `motion` (~64 KB gz) + load even when the name only appears in a **comment or string literal**. + `// like a LineChart` costs 158 KB. +3. **Keep module level empty.** Top-level statements re-run on every evaluation, + including the re-execution after a failed edit. Constants are fine; work is not. +4. **Render the shell before the data.** The canvas paints as soon as evaluation + returns — a component that returns `null` until a `fetch` resolves wastes that + paint and pushes the host page's LCP onto your network call. +5. **Style inline, not with Tailwind.** Tailwind classes only render if the host + ships a ~124 KB browser JIT on every visit; inline `style` (plus a ``; + } else { + const cssPath = `assets/style-${hash(css)}.css`; + files[cssPath] = css; + cssTag = ``; + } } // The code's own wins; `title` is the fallback for code that emits none. @@ -268,11 +281,11 @@ export async function buildStandaloneSite( <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -${titleTag}${hoisted}${cssLink}${head} +${titleTag}${hoisted}${cssTag}${head} +<script defer src="${jsPath}"></script> </head> <body> <div id="root">${body}</div> -<script src="${jsPath}"></script> </body> </html> `; diff --git a/t.jsx b/t.jsx new file mode 100644 index 0000000..4ded6ac --- /dev/null +++ b/t.jsx @@ -0,0 +1,1873 @@ +/* ============================================================ + MODULE-SCOPE CONSTANTS, HELPERS & ANALYTICS + ============================================================ */ + +// Declare in module scope & attach to window to prevent ReferenceError across all execution contexts +const trackAnalyticsEvent = (eventName, action, data = {}, silent = false) => { + try { + if (typeof window !== "undefined") { + if (typeof window.gtag === "function") { + window.gtag("event", action || eventName, data); + } else if (typeof window.fbq === "function") { + window.fbq("track", eventName, data); + } else if (Array.isArray(window.dataLayer)) { + window.dataLayer.push({ event: eventName, action, ...data }); + } + } + } catch (e) { + // Fail silently in development or sandbox environments + } +}; + +if (typeof window !== "undefined") { + window.trackAnalyticsEvent = trackAnalyticsEvent; +} + +const YEAR = new Date().getFullYear(); + +const cld = (url, w = 800) => { + if (!url || typeof url !== "string") return url; + if (!url.includes("/image/upload/")) return url; + return url.replace("/image/upload/", `/image/upload/f_auto,q_auto,w_${w}/`); +}; + +// BRANDING & ASSET URLS +const LOGO_ICON_URL = "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/e7060ec5-dfe9-47aa-3b9d-1afe5ff14500/public"; +const HERO_BACKGROUND_ICON_URL = "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/6925fb42-97ee-4e23-6b7e-aub77fa55800/public"; +const TEAM_PHOTO_URL = "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/f6af7456-3e61-4a44-c279-7bfa47cad400/public"; +const NEW_STRAVA_LOGO = "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/3f62b5a5-6bf0-4c41-f462-5b90f10aa100/public"; + +// GLOBAL LIGHT APPLE-STYLE CSS +const GLOBAL_CSS = ` + :root { + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + } + + html { + scroll-behavior: smooth; + } + + body { + -webkit-tap-highlight-color: transparent; + background-color: #F5F5F7; + color: #1D1D1F; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Icons", "Helvetica Neue", "Inter", sans-serif; + } + + section { + scroll-margin-top: 80px; + } + + .font-mono { font-family: 'JetBrains Mono', monospace; } + .font-manrope { font-family: 'Manrope', sans-serif; } + + .hide-scrollbar::-webkit-scrollbar { display: none; } + .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + + .apple-card { + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04); + border-radius: 24px; + } + + .apple-glass { + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + } + + .hero-mesh-bg { + background-image: + radial-gradient(circle at 50% 20%, rgba(0, 113, 227, 0.08) 0%, transparent 60%), + radial-gradient(circle at 80% 80%, rgba(0, 113, 227, 0.04) 0%, transparent 50%), + radial-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 0); + background-size: 100% 100%, 100% 100%, 24px 24px; + } +`; +const GLOBAL_CSS_HTML = { __html: GLOBAL_CSS }; + +// ECOSYSTEM PARTNER LOGOS +const PARTNER_LOGOS = [ + { name: "Strava", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/3f62b5a5-6bf0-4c41-f462-5b90f10aa100/public" }, + { name: "Garmin", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/2d62241d-5772-45b6-1d8b-e8d7824b9700/public" }, + { name: "Wahoo", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/5a377286-2b4f-44f4-7a5a-7d68d83acb00/public" }, + { name: "Intervals.icu", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/1a3df848-0e2e-4ee8-f085-2af1a3de3f00/public" }, + { name: "TrainingPeaks", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/62493290-aa25-4b4e-ebf2-85ecee52d700/public" }, + { name: "Join.cc", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/e2956a2c-bf20-4737-fd1b-4b74a225ee00/public" }, + { name: "Zwift", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/6a708b40-5084-4fa7-cdaf-6a9ace4de300/public" }, + { name: "TrainerRoad", logo: "https://imagedelivery.net/O-X2p33aPnIKIdnqc35LFA/3b457199-567a-4823-ab70-598116f24600/public" } +]; + +const APPAREL_SLIDES = [ + { title: "Domestique.live Cycling Jersey", img: cld("https://res.cloudinary.com/ddbxywpjr/image/upload/v1782984468/tshirt_salpoy.png", 600) }, + { title: "Domestique.live Cycling Short", img: cld("https://res.cloudinary.com/ddbxywpjr/image/upload/v1782984468/shorts_bygnkw.png", 600) }, + { title: "Domestique.live Leg Sleeves", img: cld("https://res.cloudinary.com/ddbxywpjr/image/upload/v1782984468/leg_sleves_zizrer.png", 600) }, + { title: "Domestique.live Ride Gloves", img: cld("https://res.cloudinary.com/ddbxywpjr/image/upload/v1782984467/gloves_jcexrx.png", 600) }, + { title: "Domestique.live Cap", img: cld("https://res.cloudinary.com/ddbxywpjr/image/upload/v1782984467/cap_tu2hvd.png", 600) }, +]; + +const FEATURES = [ + { + id: "01", + title: "Adaptive Push", + video: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1781515380/PERFORMANCE_RIDE_hiplyg.mp4", + quote: "Heart rate rising but power is dropping. Maintain effort.", + whyItMatters: "Post-ride analysis only tells you that you faded. Real-time audio catches fatigue mid-interval, giving you the micro-adjustment needed to finish the set cleanly." + }, + { + id: "02", + title: "Pacing & Group Rides", + video: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1781515364/GROUP_RIDE_1_yxjgtb.mp4", + quote: "Pacing optimal for this break. Hold current output.", + whyItMatters: "Stop getting baited into burning matches early on group rides. Your digital coach calculates your threshold headroom live so you stay fresh for the final sprint." + }, + { + id: "03", + title: "Live Course Routing", + video: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1781515350/SIGHTSEEING_1_wskvbo.mp4", + quote: "Climb starting in 200 meters. Shift gears and settle into Zone 3.", + whyItMatters: "Eliminates surprise gradient shifts. You hear gear and cadence cues ahead of terrain changes so you hit climbs with optimal momentum." + }, + { + id: "04", + title: "Threshold Protection", + video: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1781515388/COMMUTE_RIDE_1_dys5hm.mp4", + quote: "Over-pacing detected on early slope. Ease off 15 watts to save legs.", + whyItMatters: "Preventing a blowout beat-by-beat is 10x more valuable than reading a red graph on Strava 2 hours later." + }, + { + id: "05", + title: "Active Recovery", + video: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1781515384/coffee_ride_la0byt.mp4", + poster: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1781515384/coffee_ride_la0byt.jpg", + quote: "Great interval block. Drop to Zone 2 and recover for 3 minutes.", + whyItMatters: "Optimizes rest periods between efforts so you clear lactate efficiently and hit the next rep at maximum target wattage." + }, + { + id: "06", + title: "Zone Control", + video: "https://res.cloudinary.com/ddbxywpjr/video/upload/v1782300028/full_video_1_wjqxd6.mp4", + quote: "You are drifting into Zone 4 prematurely. Dial back cadence.", + whyItMatters: "Ensures Zone 2 rides actually stay in Zone 2. Protects your physiological adaptations from accidental over-exertion." + } +]; + +const PRODUCT_IMAGES = [ + "https://res.cloudinary.com/ddbxywpjr/image/upload/v1779102005/Home_Pgae_img_nt6cwl.png", + "https://res.cloudinary.com/ddbxywpjr/image/upload/v1779102372/card-2_whrzwh.png", + "https://res.cloudinary.com/ddbxywpjr/image/upload/v1779102373/card-3_lnse1m.png", + "https://res.cloudinary.com/ddbxywpjr/image/upload/v1779102372/card-4_n2g3e3.png" +]; + +const HERO_SLOGANS = [ + "LOOK AT THE ROAD. LISTEN TO THE COACH.", + "STOP WAITING FOR POST-RIDE ANALYTICS.", + "MID-RIDE GUIDANCE, NOT POST-MORTEMS.", + "YOUR DIGITAL DOMESTIQUE IN YOUR EAR.", + "MAKE THE MOST OF EVERY TRAINING RIDE." +]; + +const FLOW_STEPS = [ + { step: "01", icon: Compass, title: "Pre-Ride Target Sync", desc: "Open Domestique.live. It imports your planned workout or route, creating target zone parameters before you clip in." }, + { step: "02", icon: Gauge, title: "Live Sensor Feed", desc: "Your Bluetooth power meter, heart rate monitor, and cadence sensors stream telemetry directly into our edge engine." }, + { step: "03", icon: Volume2, title: "Open-Ear Bone Conduction", desc: "Lightweight audio sits cleanly on your cheekbones. You hear ambient traffic, your friends, and crisp voice coaching." }, + { step: "04", icon: Cpu, title: "Real-Time Adaptations", desc: "Our engine continuously compares live output against target wattage, adjusting coaching based on real-time fatigue." }, + { step: "05", icon: Zap, title: "In-Ride Execution", desc: "Immediate audio cues tell you when to push, when to recover, and how to complete intervals at maximum quality." } +]; + +const SETUP_ITEMS = ["Domestique Hardware Unit", "12-Month Intelligence License", "Magnetic Rapid-Charge Cable", "Ecosystem Integration Guide"]; + +const TABLE_ECOSYSTEM_DATA = [ + { + tool: "Garmin & Wahoo", + category: "GPS Bike Computers", + icon: Smartphone, + legacyApproach: "Shows numbers on glass screens that require you to constantly glance down at speed.", + domestiqueApproach: "Reads Bluetooth streams live and delivers voice coaching without taking eyes off the road." + }, + { + tool: "Strava & Intervals.icu", + category: "Post-Ride Analytics", + icon: Activity, + legacyApproach: "Provides post-mortem graphs telling you why you blew up 2 hours after your ride finishes.", + domestiqueApproach: "Intervenes mid-effort before you hit the wall, calculating live threshold headroom." + }, + { + tool: "TrainingPeaks & Join.cc", + category: "Training Calendars", + icon: Award, + legacyApproach: "Creates structured calendar workouts, but leaves real-world pacing execution up to memory.", + domestiqueApproach: "Executes your workout step-by-step on open roads with precise voice-guided interval timing." + }, + { + tool: "Zwift & TrainerRoad", + category: "Indoor ERG Simulators", + icon: Repeat, + legacyApproach: "Offers strict indoor ERG-mode control, but provides zero active pacing guidance outdoors.", + domestiqueApproach: "Brings structured, adaptive workout execution directly onto dynamic real-world outdoor terrain." + } +]; + +const RESEARCH_PAPERS = [ + { + title: "The Cognitive, Physiological, and Technological Efficacy of AI-Driven Auditory Coaching in Cycling", + url: "https://www.alphanome.ai/post/the-cognitive-physiological-and-technological-efficacy-of-ai-driven-auditory-coaching-in-cycling", + tag: "Cognitive Load & Auditory Science", + summary: "Examines how voice-based audio instruction significantly reduces cognitive friction and visual distraction compared to handlebar screens, lowering perceived exertion (RPE) during high-threshold intervals." + }, + { + title: "The Temporal Dynamics of AI in Endurance Cycling: A Comparative Analysis of Real-Time Auditory Coaching", + url: "https://www.alphanome.ai/post/the-temporal-dynamics-of-ai-in-endurance-cycling-a-comparative-analysis-of-real-time-auditory-coach", + tag: "Physiological Pacing & W' Bal", + summary: "Analyzes the math and physiological benefits of sub-second mid-ride feedback over post-ride data analysis, detailing how real-time W' balance calculations prevent premature anaerobic exhaustion." + }, + { + title: "How Wearable ADAS for Cyclists (Advanced Driver Assistance Systems) Prevents Fatalities", + url: "https://www.alphanome.ai/post/how-wearable-adas-for-cyclist-advanced-driver-assistance-systems-prevent-vulnerable-road-user-fata", + tag: "Acoustic Safety & Open-Ear Hardware", + summary: "Explores open-ear acoustic transparency, directional micro-audio cues, and the safety engineering behind keeping the ear canal completely unobstructed while training in dynamic traffic environments." + } +]; + +const PREORDER_FAQS = [ + { q: "Will the audio interrupt me constantly during a ride?", a: "Silence is a primary feature. Domestique.live is not a radio DJ; it acts as an experienced digital domestique. It only speaks when a biometric threshold is crossed or an interval target demands adjustment. Otherwise, it leaves you in total peace." }, + { q: "Why use this instead of a traditional bike computer?", a: "A bike computer requires constant visual glances. Domestique.live works alongside your Garmin or Wahoo to deliver live audio interpretation so you never take your eyes off the road." }, + { q: "What sensors does the Domestique app support?", a: "It connects directly via Bluetooth to standard power meters, heart rate monitors, and cadence sensors parallel to your existing head unit." }, + { q: "How is bone-conduction audio better than standard earbuds?", a: "Traditional earbuds trap wind noise and seal off your ears. Open-ear bone conduction transmits acoustic vibrations through your cheekbones, keeping your ear canal 100% open for ambient awareness." }, + { q: "What happens if I lose cellular service on a remote climb?", a: "The system runs localized edge calculations. Your strategy and route parameters are cached pre-ride, ensuring full live coaching even without cellular signal." }, + { q: "Will this interfere with my Spotify music or podcasts?", a: "No. Domestique.live utilizes intelligent 'audio ducking'. When the coach speaks, music volume drops smoothly and returns to normal immediately afterward." }, + { q: "What occurs after the included 12-month license expires?", a: "The live intelligence platform transitions to a standard €15/month subscription. You retain full ownership of your hardware unit." }, + { q: "How does this differ from Strava or TrainingPeaks?", a: "Strava and TrainingPeaks analyze past workouts. Domestique.live works live during the effort, guiding your output in real time." } +]; + +const PRIMARY_PERKS = [ + { + tier: "TIER 1", tag: "PRIORITY ACCESS", name: "THE DOMESTIQUE", price: "€290", + featured: false, delivery: "Q1 2027 - First Batch", stripeBuyButtonId: "buy_btn_1Tp94sIVuCvdmBxBbTNIwUQf", + includes: ["Domestique.live Hardware Unit", "12-Month Intelligence License", "Closed Beta App Access", "Domestique.live Performance T-Shirt"] + }, + { + tier: "TIER 2", tag: "COMPLETE SYSTEM", name: "THE PUNCHEUR", price: "€390", + featured: true, delivery: "Q1 2027 - First Batch", stripeBuyButtonId: "buy_btn_1TjmdCIVuCvdmBxBOxo03kE8", + includes: ["Everything in The Domestique plus:", "Domestique.live Cycling Jersey", "Domestique.live Cycling Short", "Domestique.live Leg Sleeves"] + }, + { + tier: "TIER 3", tag: "LIFETIME VIP", name: "THE DIRECTEUR SPORTIF", price: "€520", + featured: false, delivery: "Q1 2027 - First Batch", stripeBuyButtonId: "buy_btn_1TjmkEIVuCvdmBxBiLAtFvU6", + includes: ["Everything in The Puncheur plus:", "Domestique.live Ride Gloves", "Domestique.live Cap", "24-Month Extended License", "Lifetime 30% Vanguard Discount"] + } +]; + +const BATCH_2_PERK = { + tier: "BATCH 2 RESERVATION", tag: "SECOND DELIVERY RUN", name: "RESERVE BATCH 2 SPOT", price: "€10", + delivery: "Q2 2027 - Second Batch", stripeBuyButtonId: "buy_btn_1TyImOIVuCvdmBxB9SdcXtOw", + includes: [ + "Priority Spot in Q2 2027 Delivery Batch", + "Locks in Early Founder Discount Price", + "Fully Refundable Deposit Option", + "€10 Deposit Credited Toward Final Order" + ] +}; + +const PERKS_WITH_APPAREL = PRIMARY_PERKS.map((perk) => ({ + ...perk, + apparel: perk.includes + .map((title) => { + const index = APPAREL_SLIDES.findIndex((s) => s.title === title); + return index >= 0 ? { ...APPAREL_SLIDES[index], index } : null; + }) + .filter(Boolean) +})); + +const MAILERLITE_SUBSCRIBE_URL = "https://assets.mailerlite.com/jsonp/2507589/forms/192902572178146649/subscribe"; + +const TikTokIcon = ({ className }) => ( + <svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/> + </svg> +); + +const TwitterXIcon = ({ className }) => ( + <svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/> + </svg> +); + +/* Helper Component: Order Card Trust Bar */ +const CardTrustBadges = React.memo(function CardTrustBadges() { + return ( + <div className="mt-4 pt-3 border-t border-black/5 space-y-2"> + <div className="flex items-center justify-between text-[11px] text-[#515154] font-medium"> + <span className="flex items-center gap-1"> + <Globe className="w-3.5 h-3.5 text-[#0071E3]" /> Free Express Shipping + </span> + <span className="flex items-center gap-1"> + <RotateCcw className="w-3.5 h-3.5 text-[#0071E3]" /> 100% Guaranteed + </span> + </div> + <div className="flex items-center justify-center gap-2 pt-1 text-[10px] text-[#86868B]"> + <Lock className="w-3 h-3 text-[#0071E3]" /> + <span>256-Bit Encrypted Stripe Checkout</span> + </div> + </div> + ); +}); + +/* Helper Component: Large D Logo Background Watermark */ +const BackgroundLogoWatermark = React.memo(function BackgroundLogoWatermark() { + return ( + <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none opacity-[0.07] z-0 overflow-hidden"> + <img src={LOGO_ICON_URL} alt="" className="w-[500px] sm:w-[650px] h-[500px] sm:h-[650px] object-contain" /> + </div> + ); +}); + +/* Helper function for strictly ref-based smooth scrolling */ +const scrollToRef = (ref) => { + if (ref && ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +}; + +/* ============================================================ + NAVIGATION + ============================================================ */ +const SiteNavigation = React.memo(function SiteNavigation({ isMobileMenuOpen, setIsMobileMenuOpen, sectionRefs }) { + return ( + <nav className="fixed top-0 left-0 w-full z-[100] apple-glass border-b border-black/5" style={{ paddingTop: 'var(--safe-top)' }}> + <div className="max-w-6xl mx-auto h-16 px-4 md:px-8 flex items-center justify-between"> + <button + id="nav-logo-btn" + type="button" + className="cursor-pointer flex items-center gap-2.5 group touch-manipulation border-none bg-transparent p-0 text-left" + onClick={() => scrollToRef(sectionRefs.hero)} + > + <img src={LOGO_ICON_URL} alt="Domestique.live Icon" className="h-8 w-8 object-contain group-hover:scale-105 transition-transform" /> + <span className="font-semibold tracking-tight text-lg text-[#1D1D1F] font-manrope">Domestique<span className="text-[#0071E3]">.live</span></span> + </button> + + <div className="hidden md:flex items-center gap-8 text-sm font-medium text-[#515154]"> + <button id="nav-btn-coach" type="button" onClick={() => scrollToRef(sectionRefs.coach)} className="hover:text-[#0071E3] transition-colors">Real-Time Coach</button> + <button id="nav-btn-flow" type="button" onClick={() => scrollToRef(sectionRefs.flow)} className="hover:text-[#0071E3] transition-colors">How It Works</button> + <button id="nav-btn-ecosystem" type="button" onClick={() => scrollToRef(sectionRefs.ecosystem)} className="hover:text-[#0071E3] transition-colors">Ecosystem</button> + <button id="nav-btn-hardware" type="button" onClick={() => scrollToRef(sectionRefs.hardware)} className="hover:text-[#0071E3] transition-colors">Hardware</button> + <button id="nav-btn-team" type="button" onClick={() => scrollToRef(sectionRefs.team)} className="hover:text-[#0071E3] transition-colors">Our Story</button> + <button id="nav-btn-faq" type="button" onClick={() => scrollToRef(sectionRefs.faq)} className="hover:text-[#0071E3] transition-colors">FAQ</button> + <button id="nav-btn-community" type="button" onClick={() => scrollToRef(sectionRefs.instagram)} className="hover:text-[#0071E3] transition-colors">Community</button> + <button + id="nav-btn-preorder" + type="button" + onClick={() => { + trackAnalyticsEvent('InitiateCheckout', 'begin_checkout', { content_name: 'Nav Pre-Order Button' }); + scrollToRef(sectionRefs.pricing); + }} + className="bg-[#0071E3] text-white hover:bg-[#0077ED] px-5 py-2 rounded-full font-medium transition-all shadow-sm hover:shadow" + > + Pre-Order Now + </button> + </div> + + <button id="nav-toggle-mobile" type="button" className="md:hidden p-2 text-[#1D1D1F] touch-manipulation" onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}> + {isMobileMenuOpen ? <X className="w-6 h-6"/> : <Menu className="w-6 h-6"/>} + </button> + </div> + + {isMobileMenuOpen && ( + <div className="md:hidden apple-glass border-b border-black/10 px-6 py-6 flex flex-col gap-4 font-medium text-lg text-[#1D1D1F] animate-in slide-in-from-top-2"> + <button id="mobile-nav-coach" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.coach); }} className="text-left py-2 border-b border-black/5 touch-manipulation">Real-Time Coach</button> + <button id="mobile-nav-flow" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.flow); }} className="text-left py-2 border-b border-black/5 touch-manipulation">How It Works</button> + <button id="mobile-nav-ecosystem" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.ecosystem); }} className="text-left py-2 border-b border-black/5 touch-manipulation">Ecosystem Comparison</button> + <button id="mobile-nav-hardware" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.hardware); }} className="text-left py-2 border-b border-black/5 touch-manipulation">Hardware</button> + <button id="mobile-nav-team" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.team); }} className="text-left py-2 border-b border-black/5 touch-manipulation">Our Story & Team</button> + <button id="mobile-nav-faq" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.faq); }} className="text-left py-2 border-b border-black/5 touch-manipulation">FAQ & Questions</button> + <button id="mobile-nav-community" type="button" onClick={() => { setIsMobileMenuOpen(false); scrollToRef(sectionRefs.instagram); }} className="text-left py-2 border-b border-black/5 touch-manipulation">Community & Reels</button> + <button + id="mobile-nav-preorder" + type="button" + onClick={() => { + setIsMobileMenuOpen(false); + trackAnalyticsEvent('InitiateCheckout', 'begin_checkout', { content_name: 'Mobile Nav Pre-Order Button' }); + scrollToRef(sectionRefs.pricing); + }} + className="bg-[#0071E3] text-white text-center py-3 rounded-xl font-semibold mt-2 touch-manipulation" + > + Pre-Order Now + </button> + </div> + )} + </nav> + ); +}); + +/* ============================================================ + MAIN SINGLE-PAGE COMPONENT + ============================================================ */ +export default function DomestiqueWebsite() { + const topRef = useRef(null); + + /* STRICT SECTION REFS FOR ALL SCROLLING */ + const heroRef = useRef(null); + const coachRef = useRef(null); + const flowRef = useRef(null); + const ecosystemRef = useRef(null); + const hardwareRef = useRef(null); + const pricingRef = useRef(null); + const teamRef = useRef(null); + const faqRef = useRef(null); + const instagramRef = useRef(null); + + const sectionRefs = { + hero: heroRef, + coach: coachRef, + flow: flowRef, + ecosystem: ecosystemRef, + hardware: hardwareRef, + pricing: pricingRef, + team: teamRef, + faq: faqRef, + instagram: instagramRef + }; + + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [apparelSliderOpen, setApparelSliderOpen] = useState(false); + const [apparelSliderIndex, setApparelSliderIndex] = useState(0); + + // POLICY MODAL STATES ('return' | 'shipping' | 'privacy' | null) + const [activePolicy, setActivePolicy] = useState(null); + + const [showDiscountPopup, setShowDiscountPopup] = useState(false); + const [discountCopied, setDiscountCopied] = useState(false); + const [openFaq, setOpenFaq] = useState(0); + const [newsletterStatus, setNewsletterStatus] = useState('idle'); + const [newsletterError, setNewsletterError] = useState(''); + const [sloganIndex, setSloganIndex] = useState(0); + + // TECHNICAL ARCHITECTURE & SCIENCE MODAL STATES + const [showArchitectureModal, setShowArchitectureModal] = useState(false); + const [showScienceModal, setShowScienceModal] = useState(false); + + const [questionSent, setQuestionSent] = useState(false); + const [userQuestion, setUserQuestion] = useState({ name: '', email: '', message: '' }); + + const featuresScrollRef = useRef(null); + const productsScrollRef = useRef(null); + + useEffect(() => { + if (!document.getElementById('stripe-buy-button-script')) { + const script = document.createElement('script'); + script.id = 'stripe-buy-button-script'; + script.src = 'https://js.stripe.com/v3/buy-button.js'; + script.async = true; + document.body.appendChild(script); + } + }, []); + + useEffect(() => { + const sloganTimer = setInterval(() => { + setSloganIndex((prev) => (prev + 1) % HERO_SLOGANS.length); + }, 3500); + return () => clearInterval(sloganTimer); + }, []); + + useEffect(() => { + trackAnalyticsEvent('PageView', 'page_view'); + }, []); + + useEffect(() => { + const timer = setTimeout(() => setShowDiscountPopup(true), 30000); + return () => clearInterval(timer); + }, []); + + /* RELIABLE CROSS-PLATFORM CAROUSEL SCROLL MECHANISM */ + const scrollCarousel = (ref, direction) => { + if (ref.current) { + const scrollAmount = window.innerWidth < 768 ? Math.round(window.innerWidth * 0.8) : 400; + const delta = direction === 'left' ? -scrollAmount : scrollAmount; + ref.current.scrollBy({ left: delta, behavior: 'smooth' }); + } + }; + + const handleNewsletterSubmit = async (event) => { + event.preventDefault(); + if (newsletterStatus === 'loading') return; + + const form = event.currentTarget; + const formData = new FormData(form); + const params = new URLSearchParams(); + formData.forEach((value, key) => params.append(key, String(value))); + params.set('ajax', '1'); + + setNewsletterStatus('loading'); + setNewsletterError(''); + + try { + const response = await fetch(`${MAILERLITE_SUBSCRIBE_URL}?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' } + }); + if (!response.ok) throw new Error('Could not subscribe. Please try again.'); + + const result = await response.json(); + if (!result.success) { + const emailErrors = result.errors?.fields?.email; + throw new Error(Array.isArray(emailErrors) && emailErrors[0] ? emailErrors[0] : 'Could not subscribe.'); + } + + form.reset(); + setNewsletterStatus('success'); + trackAnalyticsEvent('Lead', 'generate_lead', { content_name: 'Newsletter Subscription' }); + } catch (error) { + setNewsletterError(error instanceof Error ? error.message : 'Could not subscribe.'); + setNewsletterStatus('error'); + } + }; + + const handleQuestionSubmit = (e) => { + e.preventDefault(); + setQuestionSent(true); + trackAnalyticsEvent('Contact', 'submit_question', { email: userQuestion.email }); + setTimeout(() => { + setQuestionSent(false); + setUserQuestion({ name: '', email: '', message: '' }); + }, 4000); + }; + + const handleCopyDiscount = () => { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + navigator.clipboard.writeText("25OFF"); + } + setDiscountCopied(true); + trackAnalyticsEvent('Lead', 'generate_lead', { content_name: 'Discount Copied' }); + setTimeout(() => setDiscountCopied(false), 2000); + }; + + return ( + <HelmetProvider> + <div ref={topRef} className="bg-[#F5F5F7] text-[#1D1D1F] min-h-screen selection:bg-[#0071E3] selection:text-white"> + <Helmet> + <title>Domestique.live - Real-Time AI Cycling Coach + + + + + + +