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.

Join the beta - - See how it works -

iPhone & iPad · TestFlight · free @@ -202,7 +194,6 @@ function HeroCopy() {

) : null} -

{phone.hint}

); } @@ -252,20 +243,10 @@ function CompatibilitySection() { function ActionFlowSection() { const phone = usePhone(); - const reducedMotion = useReducedMotion(); - const [idleActive, setIdleActive] = useState(0); const sectionRef = useRef(null); - const active = phone.touched ? phone.flowMode : idleActive; + const active = phone.touched ? phone.flowMode : -1; - useAmbientRegion(sectionRef, FLOW_AMBIENT[active]); - - useEffect(() => { - if (phone.touched || reducedMotion) return undefined; - const id = setInterval(() => { - setIdleActive(a => (a + 1) % actionFlowModes.length); - }, FLOW_ADVANCE_MS); - return () => clearInterval(id); - }, [phone.touched, reducedMotion]); + useAmbientRegion(sectionRef, FLOW_AMBIENT[Math.max(active, 0)]); const flowStepIds = ['home', 'reasoning', 'action', 'scenes']; @@ -276,8 +257,8 @@ function ActionFlowSection() {

the same calm loop,
every suggestion.

{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.'}

@@ -400,7 +381,7 @@ function SiteShell() {
features - live demo + preview how it works privacy
@@ -427,7 +408,7 @@ function SiteShell() { {menuOpen && (
e.stopPropagation()}> - live demo + preview features how it works privacy @@ -438,20 +419,21 @@ function SiteShell() {
)} -
+
- Live interactive demo + Interactive preview
- - + setAppOpen(true)} + onCloseFocus={() => setAppOpen(false)} + />
@@ -474,8 +456,6 @@ function SiteShell() { - setAppOpen(false)} /> -
LUMEN @@ -485,7 +465,7 @@ function SiteShell() { request early access - live demo + preview privacy GitHub contact diff --git a/src/FeatureSections.jsx b/src/FeatureSections.jsx index 22c8794..30f1ac7 100644 --- a/src/FeatureSections.jsx +++ b/src/FeatureSections.jsx @@ -5,7 +5,7 @@ import { import { usePhone } from './InteractivePhone.jsx'; import { FadeIn } from './components/FadeIn.jsx'; -function DemoLink({ tab, label = 'Try in the phone' }) { +function DemoLink({ tab, label = 'Try in the preview' }) { const phone = usePhone(); return ( @@ -90,8 +90,8 @@ export function AppShowcaseSection() {

simple by
design.

Five calm tabs — Home, Rooms, Intel, Auto, Settings — each with one job. - The live iPhone demo above is the real beta flow: rhythm, suggestions, scenes, - and consent sheets you can tap through right now. + The interactive preview above is the same loop: rhythm, one suggestion, + scenes, and consent sheets you can tap through right now.

@@ -110,7 +110,7 @@ export function AppShowcaseSection() { - Try the live demo + Try the preview
@@ -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', ]} /> - +
diff --git a/src/InteractivePhone.jsx b/src/InteractivePhone.jsx index 5ff73cf..b517d5a 100644 --- a/src/InteractivePhone.jsx +++ b/src/InteractivePhone.jsx @@ -1,10 +1,12 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { createPortal } from 'react-dom'; +import { AnimatePresence, motion } from 'framer-motion'; import { - Activity, BedDouble, Blinds, Camera, ChevronRight, DoorClosed, DoorOpen, Droplets, Flame, Home, Laptop, + Activity, ArrowRight, BedDouble, Blinds, Camera, ChevronRight, DoorClosed, DoorOpen, Droplets, Flame, Home, Laptop, Lightbulb, Lock, LogOut, Moon, MoonStar, Plus, Popcorn, Power, Radar, Settings, SlidersHorizontal, Snowflake, Sofa, Sparkle, Sparkles, Speaker, Sunrise, SunDim, SunMedium, Thermometer, Tv, Utensils, Wind, X, } from 'lucide-react'; +import { useReducedMotion } from './hooks/useReducedMotion.js'; // Shared demo data. This mirrors the native iOS app (Lumen/) as closely as a // web mockup can — same tab set, seeded scenes, dark palette (#0E0819 / accent @@ -157,7 +159,7 @@ const SHEET_AMBIENT = { }; export const PHONE_HINTS = { - idle: 'Tap through the phone like the real app. Calm tabs, gentle suggestions, and consent before anything runs.', + idle: 'Tap through the preview. Calm tabs, one suggestion, and consent before anything runs.', Home: 'Home shows rhythm, rooms, and one gentle suggestion. Tap “Lumen noticed” to begin.', Rooms: 'Open a room, then a device, to try the controls — or tap + to add your own device.', Intel: 'Every Apple Home device in one calm list, grouped by type.', @@ -185,6 +187,7 @@ const DEFAULT_LIGHT = { power: true, brightness: 62, temp: 40 }; let plannedSeq = 0; export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) { + const osReducedMotion = useReducedMotion(); const [tab, setTab] = useState('Home'); const [sheet, setSheet] = useState(null); const [approvalScene, setApprovalScene] = useState(null); @@ -196,6 +199,14 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) { const [plannedDevices, setPlannedDevices] = useState({}); const [toast, setToast] = useState(null); const [touched, setTouched] = useState(false); + const [calmMode, setCalmMode] = useState(false); + const [cadence, setCadenceState] = useState('Balanced'); + const [motionPref, setMotionPrefState] = useState('Balanced'); + const [contrast, setContrastState] = useState('Balanced'); + const [transitions, setTransitionsState] = useState(10); + + const reduceMotion = osReducedMotion || motionPref === 'Reduced'; + const suggestionsQuiet = cadence === 'Quiet'; const eveningScene = scenes.find(s => s.name === 'Evening') ?? scenes[1]; @@ -216,6 +227,40 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) { const markTouched = useCallback(() => setTouched(true), []); + const setCadence = useCallback(value => { + markTouched(); + setCadenceState(value); + }, [markTouched]); + + const setMotionPref = useCallback(value => { + markTouched(); + setMotionPrefState(value); + }, [markTouched]); + + const setContrast = useCallback(value => { + markTouched(); + setContrastState(value); + }, [markTouched]); + + const setTransitions = useCallback(value => { + markTouched(); + setTransitionsState(value); + }, [markTouched]); + + const toggleCalmMode = useCallback(() => { + markTouched(); + setCalmMode(prev => { + const next = !prev; + if (next) { + setCadenceState('Quiet'); + setMotionPrefState('Reduced'); + setContrastState('Soft'); + setTransitionsState(t => Math.max(t, 15)); + } + return next; + }); + }, [markTouched]); + const getDevice = useCallback(id => deviceCatalog[id] ?? plannedDevices[id], [plannedDevices]); const lightState = useCallback(id => deviceStates[id] ?? DEFAULT_LIGHT, [deviceStates]); @@ -340,18 +385,27 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) { setTab('Home'); setSheet('action'); setApprovalScene(null); break; case 'scenes': - setTab('Auto'); setApprovalScene(eveningScene); setSheet('approval'); + setTab('Home'); + setSheet(null); + setApprovalScene(null); + setActiveScene(eveningScene.name); + applyScenePreset(eveningScene.name); + setToast(`${eveningScene.name} scene applied`); + setTimeout(() => setToast(null), 2800); break; default: break; } - document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, [eveningScene, markTouched]); + const demo = document.getElementById('demo'); + demo?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'nearest' }); + }, [eveningScene, markTouched, applyScenePreset, reduceMotion]); useEffect(() => { + if (!touched || reduceMotion) return undefined; const ambient = sheet ? SHEET_AMBIENT[sheet] : TAB_AMBIENT[tab]; onAmbientChange?.(ambient); - }, [tab, sheet, onAmbientChange]); + return undefined; + }, [tab, sheet, onAmbientChange, touched, reduceMotion]); useEffect(() => { onFlowModeChange?.(flowMode, touched); @@ -360,10 +414,13 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) { const value = { tab, sheet, approvalScene, activeScene, activeSceneAmbient, selectedRoom, selectedDevice, toast, touched, flowMode, hint, eveningScene, + calmMode, cadence, motionPref, contrast, transitions, + reduceMotion, suggestionsQuiet, lightState, setLight, getDevice, plannedByRoom, selectTab, openReasoning, openAction, dismissSheet, applySuggestion, openSceneApproval, confirmScene, openRoom, openDevice, openAddDevice, addDevice, markTouched, runGuidedStep, + setCadence, setMotionPref, setContrast, setTransitions, toggleCalmMode, }; return {children}; @@ -393,7 +450,7 @@ function TabBar({ active, onSelect }) { aria-label={`${label} tab`} aria-current={active === label ? 'page' : undefined} > - + {label} ))} @@ -484,7 +541,7 @@ function DashboardScreen() {
{ambient && (
)} @@ -526,17 +583,21 @@ function DashboardScreen() {

Lumen noticed

-
- + + )}
); } @@ -544,14 +605,15 @@ function DashboardScreen() { // ===== Consent sheets ===== function SheetMotion({ children, className }) { - const reducedMotion = useReducedMotion(); + const phone = usePhone(); + const reducedMotion = phone.reduceMotion; return ( {children} @@ -754,7 +816,7 @@ function DeviceControlScreen({ deviceId }) { {device.kind === 'light' && (
)} - phone.openDevice(null)} /> + phone.openDevice(null)} />

Status

@@ -936,6 +998,12 @@ function RoomsScreen() { // ===== Intel (Devices) ===== function IntelScreen() { + const phone = usePhone(); + + if (phone.selectedDevice) { + return ; + } + return (
@@ -947,17 +1015,7 @@ function IntelScreen() {

{category}

- {ids.map(id => { - const d = deviceCatalog[id]; - const Icon = d.icon; - return ( -
-
-
{d.name}{d.room}
- -
- ); - })} + {ids.map(id => )}
))} @@ -990,26 +1048,6 @@ function Segmented({ options, value, onChange }) { function SettingsScreen() { const phone = usePhone(); - const [calm, setCalm] = useState(false); - const [cadence, setCadence] = useState('Balanced'); - const [motionPref, setMotionPref] = useState('Balanced'); - const [contrast, setContrast] = useState('Balanced'); - const [transitions, setTransitions] = useState(10); - - // Enabling Calm Mode applies the calm defaults (mirrors applyCalmModeDefaults). - const toggleCalm = () => { - phone.markTouched(); - setCalm(prev => { - const next = !prev; - if (next) { - setCadence('Quiet'); - setMotionPref('Reduced'); - setContrast('Soft'); - setTransitions(t => Math.max(t, 15)); - } - return next; - }); - }; return (
@@ -1037,15 +1075,15 @@ function SettingsScreen() {

Sensory Profile

-
Profile{calm ? 'Calm Mode' : 'Balanced'}
+
Profile{phone.calmMode ? 'Calm Mode' : 'Balanced'}
Calm Mode - +
-
Suggestions { phone.markTouched(); setCadence(v); }} />
-
Motion { phone.markTouched(); setMotionPref(v); }} />
-
Contrast { phone.markTouched(); setContrast(v); }} />
-
Transitions{transitions === 0 ? 'Off' : `${transitions} min`}
+
Suggestions
+
Motion
+
Contrast
+
Transitions{phone.transitions === 0 ? 'Off' : `${phone.transitions} min`}

About

@@ -1060,9 +1098,17 @@ function SettingsScreen() { // ===== Toast + shell ===== function PhoneToast({ message }) { + const phone = usePhone(); if (!message) return null; + const reducedMotion = phone.reduceMotion; return ( - + {message} ); @@ -1102,7 +1148,7 @@ function PhoneScreen() { ); } -// The app UI itself — reused by the framed hero demo and the full-screen mode. +// The app UI itself — one mount for the in-page preview and focus mode. function AppShell() { const phone = usePhone(); return ( @@ -1116,59 +1162,59 @@ function AppShell() { ); } -export function InteractivePhone() { +export function InteractivePhone({ focusMode = false, onRequestFocus, onCloseFocus }) { const phone = usePhone(); - return ( -
-
-
- -
-
-

{phone.hint}

-
- ); -} - -// Full-screen "open the app" experience — the same interactive app filling the -// viewport (edge-to-edge on phones, a tall centered phone on desktop). -export function FullscreenApp({ open, onClose }) { - const reducedMotion = useReducedMotion(); - useEffect(() => { - if (!open) return undefined; + if (!focusMode) return undefined; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; - const onKey = e => { if (e.key === 'Escape') onClose(); }; + document.body.classList.add('preview-focus'); + const onKey = e => { if (e.key === 'Escape') onCloseFocus?.(); }; window.addEventListener('keydown', onKey); return () => { document.body.style.overflow = prev; + document.body.classList.remove('preview-focus'); window.removeEventListener('keydown', onKey); }; - }, [open, onClose]); + }, [focusMode, onCloseFocus]); - return ( - - {open && ( - - -
- -
-
+ const tree = ( +
+ {focusMode ? ( + + ) : null} +
+ +
+ {focusMode ? null : ( + <> +

{phone.hint}

+ {onRequestFocus ? ( + + ) : null} + )} - +
); + + if (focusMode && typeof document !== 'undefined') { + return createPortal(tree, document.body); + } + + return tree; } diff --git a/src/components/GuidedDemoSteps.jsx b/src/components/GuidedDemoSteps.jsx index 8cca5a5..5a026a7 100644 --- a/src/components/GuidedDemoSteps.jsx +++ b/src/components/GuidedDemoSteps.jsx @@ -5,9 +5,10 @@ import { usePhone } from '../InteractivePhone.jsx'; import { FadeIn } from './FadeIn.jsx'; function deriveActiveStep(phone) { - if (phone.tab === 'Auto' && phone.sheet === 'approval') return 'scenes'; - if (phone.sheet === 'action' || phone.toast) return 'action'; + if (phone.toast) return 'scenes'; + if (phone.sheet === 'action' || phone.sheet === 'approval') return 'action'; if (phone.sheet === 'reasoning') return 'reasoning'; + if (phone.tab === 'Home' && !phone.sheet) return 'home'; return 'home'; } @@ -20,20 +21,21 @@ export function GuidedDemoSteps() {

Try the flow

{guidedDemoSteps.map((step, i) => ( - +
+ +
))}
@@ -48,7 +50,7 @@ export function MobileDemoFAB() { return ( - Live demo + Back to Lumen ); diff --git a/src/main.test.jsx b/src/main.test.jsx index c33ca26..bc70e24 100644 --- a/src/main.test.jsx +++ b/src/main.test.jsx @@ -31,14 +31,14 @@ describe('Lumen landing page', () => { render(); const nav = screen.getByRole('navigation'); expect(within(nav).getByRole('link', { name: /features/i })).toHaveAttribute('href', '#features'); - expect(within(nav).getByRole('link', { name: /live demo/i })).toHaveAttribute('href', '#demo'); + expect(within(nav).getByRole('link', { name: /preview/i })).toHaveAttribute('href', '#demo'); expect(within(nav).getByRole('link', { name: /privacy/i })).toHaveAttribute('href', '/privacy'); }); it('renders guided demo steps', () => { render(); expect(screen.getByText(/try the flow/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /lumen noticed/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /lumen noticed: tap the suggestion card/i })).toBeInTheDocument(); }); it('renders getting started section', () => { @@ -74,11 +74,29 @@ describe('Lumen landing page', () => { expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument(); }); - it('renders the live interactive demo', () => { + it('renders the interactive preview without an overlay', () => { render(); - expect(screen.getByText(/live interactive demo/i)).toBeInTheDocument(); + expect(screen.getByText(/^interactive preview$/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /auto tab/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /home tab/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /close app preview/i })).not.toBeInTheDocument(); + }); + + it('does not name a clinical audience in public copy', () => { + render(); + expect(document.body.textContent).not.toMatch(/neurodiverg|autism|\bADHD\b|sensory processing/i); + }); + + it('hides the suggestion card when Suggestions is Quiet', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /settings tab/i })); + await user.click(screen.getByRole('button', { name: /^quiet$/i })); + await user.click(screen.getByRole('button', { name: /home tab/i })); + + expect(screen.getByText(/lumen is staying quiet/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /review evening scene/i })).not.toBeInTheDocument(); }); it('states the honest Apple Home / HomeKit + Matter compatibility', () => { diff --git a/src/simulator.css b/src/simulator.css index 54a6ffa..ef161ad 100644 --- a/src/simulator.css +++ b/src/simulator.css @@ -314,22 +314,251 @@ font-weight: 500; } -/* ===== Phone frame proportions ===== - * The hero rule (.hero-split .live-demo .phone-featured, 3 classes) outranks the - * responsive overrides, so size it here and keep a true phone aspect (was reading - * tablet-wide at 320×490). */ -.hero-split .live-demo .phone-featured { - width: 264px; +/* ===== In-page preview stage (usable size, not a 264px toy phone) ===== */ + +.app-preview-stage { + width: min(390px, 100%); + height: min(80vh, 780px); + min-height: 620px; + display: flex; + flex-direction: column; + background: #0E0819; + border: 8px solid #181818; + border-radius: 40px; + overflow: hidden; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.07), + 0 40px 100px rgba(0, 0, 0, 0.8), + inset 0 0 0 1px rgba(255, 255, 255, 0.04); } -@media (max-width: 600px) { - .hero-split .live-demo .phone-featured { - width: min(264px, 82vw); - } +.app-preview-stage .app-stage { + flex: 1; + min-height: 0; +} + +.app-preview-stage .phone-status { + padding: 12px 22px 6px; + font-size: 13px; +} + +.app-preview-stage .dynamic-island { + width: 108px; + height: 28px; +} + +.app-preview-stage .app-screen { + padding: 12px 18px 14px; + gap: 10px; +} + +.app-preview-stage .app-tabbar { + padding: 8px 6px 14px; +} + +.app-preview-stage .app-tabbar span, +.app-preview-stage .app-tabbar button { + font-size: 11px; + gap: 4px; + min-height: 44px; + min-width: 44px; + padding: 6px 4px; +} + +.app-preview-stage .sim-title { + font-size: 22px; +} + +.app-preview-stage .sim-eyebrow { + font-size: 10px; +} + +.app-preview-stage .sim-round { + width: 32px; + height: 32px; + font-size: 16px; +} + +.app-preview-stage .app-greeting.serif { + font-size: 22px; +} + +.app-preview-stage .app-wordmark { + font-size: 11px; +} + +.app-preview-stage .app-mode { + font-size: 10px; +} + +.app-preview-stage .app-subtitle { + font-size: 13px !important; +} + +.app-preview-stage .app-label { + font-size: 11px; +} + +.app-preview-stage .app-cstats { + font-size: 12px; +} + +.app-preview-stage .noticed-head { + font-size: 11px; +} + +.app-preview-stage .noticed-msg { + font-size: 14px !important; +} + +.app-preview-stage .noticed-action b { + font-size: 14px; +} + +.app-preview-stage .noticed-action span { + font-size: 12px; +} + +.app-preview-stage .fav-room-card { + min-height: 44px; + padding: 12px 10px; +} + +.app-preview-stage .fav-room-card b { + font-size: 13px; } -.phone-app .phone-screen { - min-height: 544px; +.app-preview-stage .fav-room-card span { + font-size: 11px; +} + +.app-preview-stage .scene-row { + padding: 10px 12px; + min-height: 44px; +} + +.app-preview-stage .scene-meta b { + font-size: 14px; +} + +.app-preview-stage .scene-meta span { + font-size: 11px; +} + +.app-preview-stage .device-row { + min-height: 44px; + padding: 10px 12px; +} + +.app-preview-stage .device-meta b { + font-size: 13px; +} + +.app-preview-stage .device-meta span { + font-size: 11px; +} + +.app-preview-stage .settings-row2, +.app-preview-stage .settings-stack { + font-size: 13px; + padding: 12px 14px; +} + +.app-preview-stage .seg-opt { + font-size: 11px; + padding: 8px 6px; + min-height: 36px; +} + +.app-preview-stage .sheet-kicker { + font-size: 11px !important; +} + +.app-preview-stage .sheet-title { + font-size: 22px; +} + +.app-preview-stage .sheet-apply, +.app-preview-stage .sheet-dismiss { + min-height: 44px; + font-size: 15px; + padding: 12px 16px; +} + +.app-preview-stage .factor-row b, +.app-preview-stage .signal-label, +.app-preview-stage .approval-action-capability { + font-size: 13px; +} + +.app-preview-stage .factor-row span, +.app-preview-stage .signal-value, +.app-preview-stage .approval-action-detail { + font-size: 12px; +} + +.app-preview-stage .add-device-input { + font-size: 15px; + padding: 12px 14px; + min-height: 44px; +} + +.app-preview-stage .type-chip { + font-size: 11px; + padding: 10px 6px; + min-height: 44px; +} + +.app-preview-stage .room-empty-msg { + font-size: 13px; +} + +.app-preview-stage .room-empty-cta { + font-size: 14px; + min-height: 44px; + padding: 10px 16px; +} + +.app-preview-stage .nownext-now b, +.app-preview-stage .nownext-next b { + font-size: 15px; +} + +.app-preview-stage .control-row { + font-size: 13px; +} + +.noticed-quiet { + margin: 0; + padding: 14px 16px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 14px; + line-height: 1.45; + color: rgba(232, 224, 212, 0.55); + text-align: left; +} + +.app-preview-stage[data-contrast="soft"] { + filter: contrast(0.86) saturate(0.92); +} + +.app-preview-stage[data-contrast="clear"] { + filter: contrast(1.08); +} + +.app-preview-stage[data-motion="reduced"] .active-pulse, +.app-preview-stage[data-motion="reduced"] .demo-live-dot { + animation: none; +} + +.app-preview-stage[data-motion="reduced"] .home-ambient { + transition: none; +} + +.app-preview-stage[data-motion="reduced"] .app-screen.dimmed { + filter: none; } /* ===== Applied-scene ambient on the dashboard ===== */ @@ -342,13 +571,17 @@ transition: background 0.6s ease; } -/* ===== "Open the app" launch button ===== */ +.home-ambient.is-still { + transition: none; +} + +/* ===== Expand preview ===== */ .demo-openapp { display: inline-flex; align-items: center; gap: 8px; - margin-top: 18px; + margin-top: 4px; padding: 11px 20px; border-radius: 99px; border: 1px solid rgba(196, 154, 108, 0.4); @@ -359,67 +592,56 @@ font-weight: 500; letter-spacing: 0.01em; cursor: pointer; - transition: background 0.2s, border-color 0.2s, transform 0.2s; + transition: background 0.2s, border-color 0.2s; } .demo-openapp:hover { background: rgba(196, 154, 108, 0.2); border-color: rgba(196, 154, 108, 0.6); - transform: translateY(-1px); } -/* ===== Full-screen app mode ===== */ +@media (prefers-reduced-motion: reduce) { + .demo-openapp { + transition: none; + } +} -.app-fullscreen { +/* ===== Focus mode (same AppShell, hides page chrome) ===== */ + +.live-demo.is-focus.app-fullscreen { position: fixed; inset: 0; z-index: 300; background: #0b0716; display: flex; flex-direction: column; + align-items: center; + gap: 0; + padding: 0; } -.app-fullscreen-stage { +.live-demo.is-focus .app-preview-stage { flex: 1; - min-height: 0; width: 100%; max-width: 460px; + height: auto; + min-height: 0; + border-radius: 0; + border: none; margin: 0 auto; - display: flex; - flex-direction: column; - background: #0E0819; box-shadow: 0 0 120px rgba(0, 0, 0, 0.6); } -/* Full-screen app chrome scales up from the tiny embedded frame. */ -.app-fullscreen-stage .phone-status { - padding: max(12px, env(safe-area-inset-top)) 22px 6px; - font-size: 13px; -} - -.app-fullscreen-stage .dynamic-island { - width: 108px; - height: 28px; +.live-demo.is-focus .phone-status { + padding-top: max(12px, env(safe-area-inset-top)); } -/* The Close pill lives top-right, so drop the decorative signal indicator. */ -.app-fullscreen-stage .phone-status span:last-child { +.live-demo.is-focus .phone-status span:last-child { visibility: hidden; } -.app-fullscreen-stage .app-screen { - padding: 10px 18px 12px; - gap: 9px; -} - -.app-fullscreen-stage .app-tabbar { - padding: 9px 6px max(12px, env(safe-area-inset-bottom)); -} - -.app-fullscreen-stage .app-tabbar span, -.app-fullscreen-stage .app-tabbar button { - font-size: 9px; - gap: 3px; +.live-demo.is-focus .app-tabbar { + padding-bottom: max(14px, env(safe-area-inset-bottom)); } .app-fullscreen-close { @@ -448,6 +670,27 @@ color: #fff; } +@media (max-width: 900px) { + .hero-demo-first .hero-inner { + display: flex; + flex-direction: column; + } + + .hero-demo-first .hero-demo { + order: -1; + margin-top: 0; + } + + .app-preview-stage { + width: 100%; + height: calc(100svh - 64px); + min-height: 0; + border-radius: 0; + border: none; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); + } +} + /* ===== Add-device sheet ===== */ .add-device-sheet { diff --git a/src/siteConfig.js b/src/siteConfig.js index 273961a..07eeef2 100644 --- a/src/siteConfig.js +++ b/src/siteConfig.js @@ -29,7 +29,7 @@ export const guidedDemoSteps = [ }, { id: 'scenes', - label: 'Scenes', - short: 'Try manual approval', + label: 'Runs', + short: 'See it apply', }, ];