diff --git a/AGENTS.md b/AGENTS.md
index a93f436..b42a0ec 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -272,7 +272,7 @@ npm run e2e # Playwright end-to-end tests
npm run ci # lint + test + build + e2e
```
-Single-page React/Vite app — no router, anchor-scroll only. Entry is `src/main.jsx`, which mounts `src/App.jsx` into `#root` in the top-level `index.html` and imports the page styles (`App.css`, `lumen-overrides.css`, `mobile-polish.css`; `App.jsx` also imports `theme.css` and `simulator.css`). `src/styles.css` is unused if present. The hero embeds an interactive iOS simulator (`src/InteractivePhone.jsx`) that mirrors the native app's surfaces — all five tabs (Home rhythm/stats/rooms/"Lumen noticed", Rooms→room→device drill-down, Intel device list, Auto scenes, Settings incl. the Sensory Profile) and the consent flow (Awareness → Reasoning → Action → Execution). Its markup deliberately reuses the app's copy, seeded scenes, and dark palette; `simulator.css` holds the styles the earlier demo didn't cover. Keep it in step with the Swift UI when that changes. The same app UI (the shared `AppShell`) also powers a **full-screen mode** — `FullscreenApp`, launched from the hero's "Open the app full screen" button, fills the viewport (edge-to-edge on phones, a centered phone column on desktop) sharing one `PhoneProvider` state. Running a scene applies its light preset to every light and tints the dashboard (`activeSceneAmbient`), and the Rooms `+` opens an Add-Device sheet (mirrors `AddDeviceView`) that adds a planned device to the room. Unit tests run on Vitest (`src/main.test.jsx`, setup in `src/test/setup.js`); end-to-end coverage uses Playwright (`playwright.config.js`, `testDir: ./e2e`). `public/privacy/index.html` is fully self-contained (no React); the `vercel.json` rewrite maps `/privacy` → `/privacy/index.html`.
+Single-page React/Vite app — no router, anchor-scroll only. Entry is `src/main.jsx`, which mounts `src/App.jsx` into `#root` in the top-level `index.html` and imports the page styles (`App.css`, `lumen-overrides.css`, `mobile-polish.css`; `App.jsx` also imports `theme.css` and `simulator.css`). `src/styles.css` is unused if present. The hero is a **demo-first in-page preview** (`src/InteractivePhone.jsx`): one `AppShell` mount at usable size (~390px on desktop, viewport-height with no nested bezel on mobile). It mirrors the native app's surfaces — all five tabs (Home rhythm/stats/rooms/"Lumen noticed", Rooms→room→device drill-down, Intel device list, Auto scenes, Settings incl. the Sensory Profile) and the consent flow (Awareness → Reasoning → Action → Execution). Optional **Expand preview** promotes that same `AppShell` to focus mode (page chrome hidden) instead of mounting a second tree. Sensory Profile settings in the preview are wired: Quiet hides the suggestion card, Reduced motion / Calm Mode mute springs and page ambient, Soft contrast actually changes the stage. Public copy stays calm and unnamed — do not add autism, ADHD, or neurodivergent claims on the site. Markup reuses the app's copy, seeded scenes, and dark palette; `simulator.css` holds the preview-stage scale. Keep it in step with the Swift UI when that changes. Running a scene applies its light preset to every light and tints the dashboard (`activeSceneAmbient`), and the Rooms `+` opens an Add-Device sheet (mirrors `AddDeviceView`) that adds a planned device to the room. Unit tests run on Vitest (`src/main.test.jsx`, setup in `src/test/setup.js`); end-to-end coverage uses Playwright (`playwright.config.js`, `testDir: ./e2e`). `public/privacy/index.html` is fully self-contained (no React); the `vercel.json` rewrite maps `/privacy` → `/privacy/index.html`.
Waitlist validation/delivery logic (`normalizeWaitlistPayload`, `isValidWaitlistEmail`, `postWebhook`/`deliverWaitlist`) is factored into `lib/waitlist.js` and shared by both sides of the submit path: `api/waitlist.js` (the Vercel serverless handler) imports it server-side, and `src/waitlistSubmit.js` (the browser-side submit + provider-fallback chain) imports `DEFAULT_TO_EMAIL` from it. `lib/waitlist.test.js` covers the shared module directly.
diff --git a/CLAUDE.md b/CLAUDE.md
index a93f436..b42a0ec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -272,7 +272,7 @@ npm run e2e # Playwright end-to-end tests
npm run ci # lint + test + build + e2e
```
-Single-page React/Vite app — no router, anchor-scroll only. Entry is `src/main.jsx`, which mounts `src/App.jsx` into `#root` in the top-level `index.html` and imports the page styles (`App.css`, `lumen-overrides.css`, `mobile-polish.css`; `App.jsx` also imports `theme.css` and `simulator.css`). `src/styles.css` is unused if present. The hero embeds an interactive iOS simulator (`src/InteractivePhone.jsx`) that mirrors the native app's surfaces — all five tabs (Home rhythm/stats/rooms/"Lumen noticed", Rooms→room→device drill-down, Intel device list, Auto scenes, Settings incl. the Sensory Profile) and the consent flow (Awareness → Reasoning → Action → Execution). Its markup deliberately reuses the app's copy, seeded scenes, and dark palette; `simulator.css` holds the styles the earlier demo didn't cover. Keep it in step with the Swift UI when that changes. The same app UI (the shared `AppShell`) also powers a **full-screen mode** — `FullscreenApp`, launched from the hero's "Open the app full screen" button, fills the viewport (edge-to-edge on phones, a centered phone column on desktop) sharing one `PhoneProvider` state. Running a scene applies its light preset to every light and tints the dashboard (`activeSceneAmbient`), and the Rooms `+` opens an Add-Device sheet (mirrors `AddDeviceView`) that adds a planned device to the room. Unit tests run on Vitest (`src/main.test.jsx`, setup in `src/test/setup.js`); end-to-end coverage uses Playwright (`playwright.config.js`, `testDir: ./e2e`). `public/privacy/index.html` is fully self-contained (no React); the `vercel.json` rewrite maps `/privacy` → `/privacy/index.html`.
+Single-page React/Vite app — no router, anchor-scroll only. Entry is `src/main.jsx`, which mounts `src/App.jsx` into `#root` in the top-level `index.html` and imports the page styles (`App.css`, `lumen-overrides.css`, `mobile-polish.css`; `App.jsx` also imports `theme.css` and `simulator.css`). `src/styles.css` is unused if present. The hero is a **demo-first in-page preview** (`src/InteractivePhone.jsx`): one `AppShell` mount at usable size (~390px on desktop, viewport-height with no nested bezel on mobile). It mirrors the native app's surfaces — all five tabs (Home rhythm/stats/rooms/"Lumen noticed", Rooms→room→device drill-down, Intel device list, Auto scenes, Settings incl. the Sensory Profile) and the consent flow (Awareness → Reasoning → Action → Execution). Optional **Expand preview** promotes that same `AppShell` to focus mode (page chrome hidden) instead of mounting a second tree. Sensory Profile settings in the preview are wired: Quiet hides the suggestion card, Reduced motion / Calm Mode mute springs and page ambient, Soft contrast actually changes the stage. Public copy stays calm and unnamed — do not add autism, ADHD, or neurodivergent claims on the site. Markup reuses the app's copy, seeded scenes, and dark palette; `simulator.css` holds the preview-stage scale. Keep it in step with the Swift UI when that changes. Running a scene applies its light preset to every light and tints the dashboard (`activeSceneAmbient`), and the Rooms `+` opens an Add-Device sheet (mirrors `AddDeviceView`) that adds a planned device to the room. Unit tests run on Vitest (`src/main.test.jsx`, setup in `src/test/setup.js`); end-to-end coverage uses Playwright (`playwright.config.js`, `testDir: ./e2e`). `public/privacy/index.html` is fully self-contained (no React); the `vercel.json` rewrite maps `/privacy` → `/privacy/index.html`.
Waitlist validation/delivery logic (`normalizeWaitlistPayload`, `isValidWaitlistEmail`, `postWebhook`/`deliverWaitlist`) is factored into `lib/waitlist.js` and shared by both sides of the submit path: `api/waitlist.js` (the Vercel serverless handler) imports it server-side, and `src/waitlistSubmit.js` (the browser-side submit + provider-fallback chain) imports `DEFAULT_TO_EMAIL` from it. `lib/waitlist.test.js` covers the shared module directly.
diff --git a/e2e/live-demo.e2e.js b/e2e/live-demo.e2e.js
index b5d8115..5e8b228 100644
--- a/e2e/live-demo.e2e.js
+++ b/e2e/live-demo.e2e.js
@@ -1,15 +1,27 @@
import { test, expect } from '@playwright/test';
test.describe('Lumen live demo', () => {
- test('hero shows interactive phone and guided steps', async ({ page }) => {
+ test('hero shows interactive preview and guided steps', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toContainText(/when your home shifts/i);
- await expect(page.getByText(/live interactive demo/i)).toBeVisible();
+ await expect(page.getByText(/^interactive preview$/i)).toBeVisible();
await expect(page.getByRole('button', { name: /home tab/i })).toBeVisible();
await expect(page.getByText(/try the flow/i)).toBeVisible();
});
+ test('consent loop runs on the in-page stage', async ({ page }) => {
+ await page.goto('/');
+ const demo = page.locator('#demo');
+
+ await demo.getByRole('button', { name: /review evening scene/i }).click();
+ await expect(demo.getByText(/why lumen noticed/i)).toBeVisible();
+ await demo.getByRole('button', { name: /apply evening/i }).click();
+ await expect(demo.getByText('Lumen will', { exact: true })).toBeVisible();
+ await demo.getByRole('button', { name: /^apply$/i }).click();
+ await expect(demo.getByText(/evening scene applied/i)).toBeVisible();
+ });
+
test('auto tab opens scene approval sheet', async ({ page }) => {
await page.goto('/');
const demo = page.locator('#demo');
@@ -17,8 +29,6 @@ test.describe('Lumen live demo', () => {
await demo.getByRole('button', { name: /auto tab/i }).click();
await demo.getByRole('button', { name: /morning/i }).click();
- // The scene approval sheet mirrors the app's SceneApprovalSheet: an "Apply
- // scene" header for Morning and a plain "Apply" confirm button.
await expect(demo.getByText(/apply scene/i)).toBeVisible();
await expect(demo.getByRole('button', { name: /^apply$/i })).toBeVisible();
await expect(demo.getByText('Power', { exact: true })).toBeVisible();
@@ -28,13 +38,22 @@ test.describe('Lumen live demo', () => {
await page.goto('/');
const demo = page.locator('#demo');
- await page.getByRole('button', { name: /lumen noticed/i }).click();
+ await page.getByRole('button', { name: /lumen noticed: tap the suggestion card/i }).click();
await expect(demo.getByText(/why lumen noticed/i)).toBeVisible();
await expect(demo.getByRole('button', { name: /apply evening/i })).toBeVisible();
});
- test('flow card drives the live demo', async ({ page }) => {
+ test('guided execution step applies the scene', async ({ page }) => {
+ await page.goto('/');
+ const demo = page.locator('#demo');
+
+ await page.getByRole('button', { name: /runs: see it apply/i }).click();
+
+ await expect(demo.getByText(/evening scene applied/i)).toBeVisible();
+ });
+
+ test('flow card drives the preview', async ({ page }) => {
await page.goto('/');
const demo = page.locator('#demo');
const flow = page.locator('#flow');
@@ -45,14 +64,13 @@ test.describe('Lumen live demo', () => {
await expect(demo.getByText(/why lumen noticed/i)).toBeVisible();
});
- test('opens full-screen app mode and plans a device', async ({ page }) => {
+ test('expands preview and plans a device', async ({ page }) => {
await page.goto('/');
- await page.getByRole('button', { name: /open the app full screen/i }).click();
+ await page.getByRole('button', { name: /expand preview/i }).click();
const app = page.locator('.app-fullscreen');
await expect(app).toBeVisible();
- // Rooms → Office (empty) → add a planned device.
await app.getByRole('button', { name: /rooms tab/i }).click();
await app.getByRole('button', { name: /office/i }).first().click();
await app.getByRole('button', { name: /add a device/i }).click();
@@ -64,8 +82,20 @@ test.describe('Lumen live demo', () => {
await expect(app.getByRole('button', { name: /reading lamp/i })).toBeVisible();
await expect(app.locator('.planned-dot')).toBeVisible();
- // Close returns to the marketing page.
await app.getByRole('button', { name: /close app preview/i }).click();
await expect(page.locator('.app-fullscreen')).toHaveCount(0);
});
+
+ test('mobile stage is tappable without a nested bezel', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+
+ const stage = page.locator('.app-preview-stage');
+ await expect(stage).toBeVisible();
+ await expect(page.locator('#demo .phone.phone-featured')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /home tab/i })).toBeVisible();
+
+ await page.getByRole('button', { name: /auto tab/i }).click({ force: false });
+ await expect(page.locator('#demo').getByRole('button', { name: /morning/i })).toBeVisible();
+ });
});
diff --git a/src/App.css b/src/App.css
index 2653567..6949b72 100644
--- a/src/App.css
+++ b/src/App.css
@@ -75,7 +75,7 @@ h1, h2, h3, h4, p {
/* ===== AMBIENT BACKDROP =====
A slow-shifting color wash behind the whole page. Two full-viewport
layers crossfade opacity so the tint eases from one moment's color to
- the next instead of snapping - driven by the live demo's current step,
+ the next instead of snapping - driven by the preview after you tap,
and by whichever section is centered in the viewport once you scroll
past the hero. */
@@ -333,13 +333,13 @@ h1, h2, h3, h4, p {
padding: 88px clamp(20px, 5%, 64px) 80px;
}
-.hero-inner {
+.hero-split .hero-inner {
position: relative;
z-index: 1;
display: grid;
- grid-template-columns: 1fr 1fr;
- gap: clamp(32px, 5vw, 72px);
- align-items: center;
+ grid-template-columns: minmax(240px, 0.85fr) minmax(360px, 1.2fr);
+ gap: clamp(28px, 4vw, 64px);
+ align-items: start;
max-width: 1180px;
width: 100%;
margin: 0 auto;
@@ -370,11 +370,11 @@ h1, h2, h3, h4, p {
margin-top: 0;
flex-direction: column;
align-items: center;
+ width: 100%;
}
-.hero-split .live-demo .phone-featured {
- width: min(320px, 100%);
- filter: drop-shadow(0 32px 80px rgba(0, 0, 0, 0.55));
+.hero-split .live-demo {
+ width: 100%;
}
.demo-live-badge {
@@ -2310,6 +2310,23 @@ p {
text-align: center;
}
+ .hero-demo-first {
+ padding: 0 0 32px;
+ }
+
+ .hero-demo-first .hero-copy {
+ padding: 28px 20px 0;
+ }
+
+ .hero-demo-first .hero-copy h1 {
+ font-size: clamp(28px, 8vw, 42px);
+ }
+
+ .hero-demo-first .hero-lede {
+ font-size: 16px;
+ max-width: 36em;
+ }
+
.hero-split .hero-copy {
align-items: center;
text-align: center;
@@ -2829,7 +2846,7 @@ p {
}
.live-demo .phone-featured {
- width: 260px;
+ width: auto;
}
}
@@ -2860,12 +2877,12 @@ p {
}
.hero-demo {
- margin-top: 40px;
+ margin-top: 24px;
width: 100%;
}
.live-demo .phone-featured {
- width: min(290px, 88vw);
+ width: auto;
}
}
@@ -3350,6 +3367,10 @@ p {
gap: 8px;
}
+.guided-demo-strip [role="listitem"] {
+ width: 100%;
+}
+
.guided-demo-step {
display: flex;
align-items: center;
diff --git a/src/App.jsx b/src/App.jsx
index 8583402..942344d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -3,11 +3,10 @@ import {
Activity, Apple, ArrowRight, Blinds, Check, DoorClosed, Droplets, ExternalLink, Home, Lightbulb, Lock,
Mail, Menu, Moon, Sparkle, Sun, SunMedium, Thermometer, X, Zap,
} from 'lucide-react';
-import { PhoneProvider, InteractivePhone, FullscreenApp, usePhone } from './InteractivePhone.jsx';
+import { PhoneProvider, InteractivePhone, usePhone } from './InteractivePhone.jsx';
import { submitWaitlist } from './waitlistSubmit.js';
import { trackWaitlistEvent } from './waitlistAnalytics.js';
import { TESTFLIGHT_URL } from './siteConfig.js';
-import { useReducedMotion } from './hooks/useReducedMotion.js';
import { FadeIn } from './components/FadeIn.jsx';
import { GuidedDemoSteps, MobileDemoFAB } from './components/GuidedDemoSteps.jsx';
import { GettingStartedSection } from './components/GettingStartedSection.jsx';
@@ -72,7 +71,6 @@ const actionFlowModes = [
];
const FLOW_AMBIENT = ['138,180,248', '197,138,249', '124,197,255', '150,130,250'];
-const FLOW_ADVANCE_MS = 4200;
const AMBIENT_IDLE = '120,150,235';
const THEME_STORAGE_KEY = 'lumen-theme';
@@ -171,7 +169,6 @@ function useAmbientRegion(ref, color) {
}
function HeroCopy() {
- const phone = usePhone();
return (
@@ -180,17 +177,12 @@ function HeroCopy() {
when your home shifts, you stay calm.
- Lumen reads the moment — time, presence, your devices — and surfaces one
- gentle suggestion. You read why and approve what changes. Opted-in arrival
- and departure scenes can still run with a notification.
+ One gentle suggestion, in plain language, before anything in your home changes.
{phone.touched
- ? 'The cards below follow what you are doing in the live demo.'
- : 'Tap a card to drive the iPhone demo, or watch the loop cycle on its own.'}
+ ? 'The cards below follow what you are doing in the preview.'
+ : 'Tap a card to move the preview through the loop.'}
@@ -164,7 +164,7 @@ export function FeaturesOverviewSection() {
explore
everything your home needs. nothing extra.
- Each part of the app has one job. Tap a card to read more, or try it live in the phone above.
+ Each part of the app has one job. Tap a card to read more, or try it in the preview above.
@@ -230,7 +230,7 @@ export function ScenesFeatureSection() {
'Geofence automation on arrival or departure',
'Morning, Evening, Movie Night, Sleep presets included',
]} />
-
+
@@ -292,7 +292,7 @@ export function RoomsFeatureSection() {
'Brightness, color temperature, and power sliders',
'Local preview mode enabled by default in beta',
]} />
-
+
@@ -324,7 +324,7 @@ export function PresenceFeatureSection() {
'Notifications when automation runs or needs attention',
'Welcome Home and Away Mode overlays on the home screen',
]} />
-
+