diff --git a/.gitignore b/.gitignore
index bae016b..124ef6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,7 @@ Packages/
.env
.env.local
.env.*
-```
\ No newline at end of file
+```
+# Node / web build
+node_modules/
+dist/
diff --git a/AGENTS.md b/AGENTS.md
index 66bc79e..2e1d469 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`). `src/styles.css` is unused if present. 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 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. 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 66bc79e..2e1d469 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`). `src/styles.css` is unused if present. 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 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. 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 b75b3af..df58033 100644
--- a/e2e/live-demo.e2e.js
+++ b/e2e/live-demo.e2e.js
@@ -17,7 +17,10 @@ test.describe('Lumen live demo', () => {
await demo.getByRole('button', { name: /auto tab/i }).click();
await demo.getByRole('button', { name: /morning/i }).click();
- await expect(demo.getByRole('button', { name: /apply morning/i })).toBeVisible();
+ // 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();
});
diff --git a/src/App.jsx b/src/App.jsx
index 22a2d48..c9e86bf 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -25,6 +25,7 @@ import {
} from './FeatureSections.jsx';
import './theme.css';
import './App.css';
+import './simulator.css';
const supportedCategories = [
{ label: 'Lights', icon: Lightbulb },
diff --git a/src/InteractivePhone.jsx b/src/InteractivePhone.jsx
index a1af83e..9fbfc2e 100644
--- a/src/InteractivePhone.jsx
+++ b/src/InteractivePhone.jsx
@@ -1,48 +1,45 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import {
- Activity, BedDouble, ChevronRight, Home, Laptop, Lightbulb, Lock, Settings, Sofa, Sparkle, Sparkles, SunMedium, Thermometer, Utensils,
-} from 'lucide-react';
-import {
- Moon, MoonStar, Popcorn, Sunrise,
+ Activity, BedDouble, Blinds, ChevronRight, DoorOpen, Flame, Home, Laptop, Lightbulb, Lock,
+ LogOut, Moon, MoonStar, Popcorn, Settings, SlidersHorizontal, Snowflake, Sofa, Sparkle, Sparkles,
+ Sunrise, SunMedium, Thermometer, Utensils,
} from 'lucide-react';
-// Shared demo data (mirrors the iOS app seed content)
+// 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
+// #C49A6C), consent-first flow (Awareness → Reasoning → Action → Execution),
+// and the calm surfaces (rhythm card, sensory profile) that distinguish Lumen
+// from a generic HomeKit controller.
export const phoneTabs = [
{ label: 'Home', icon: Home },
- { label: 'Rooms', icon: Sofa },
+ { label: 'Rooms', icon: DoorOpen },
{ label: 'Intel', icon: Sparkle },
{ label: 'Auto', icon: Sparkles },
{ label: 'Settings', icon: Settings },
];
-export const favoriteRooms = [
- { name: 'Living Room', icon: Sofa, count: '3 active' },
- { name: 'Bedroom', icon: BedDouble, count: '2 active' },
- { name: 'Kitchen', icon: Utensils, count: '1 active' },
- { name: 'Office', icon: Laptop, count: 'No devices' },
-];
-
+// Seeded scenes match SceneService.seedDefaultScenesIfNeeded (first three favorited).
export const scenes = [
{
- name: 'Morning', icon: Sunrise, devices: '4 devices', mood: 'Bright & energising',
+ name: 'Morning', icon: Sunrise, favorite: true, mood: 'Bright & cool',
actions: [
{ capability: 'Power', detail: 'On' },
{ capability: 'Brightness', detail: '90%' },
- { capability: 'Temperature', detail: '5000K' },
+ { capability: 'Color Temperature', detail: '5000K' },
],
},
{
- name: 'Evening', icon: MoonStar, devices: '3 devices', mood: 'Warm & dim',
+ name: 'Evening', icon: MoonStar, favorite: true, mood: 'Warm & dim',
actions: [
{ capability: 'Power', detail: 'On' },
{ capability: 'Brightness', detail: '40%' },
- { capability: 'Temperature', detail: '2700K' },
+ { capability: 'Color Temperature', detail: '2700K' },
],
},
{
- name: 'Movie Night', icon: Popcorn, devices: '5 devices', mood: 'Dim & ambient',
+ name: 'Movie Night', icon: Popcorn, favorite: true, mood: 'Dim & ambient',
actions: [
{ capability: 'Brightness', detail: '12%' },
{ capability: 'Color', detail: 'Custom color' },
@@ -50,13 +47,61 @@ export const scenes = [
],
},
{
- name: 'Sleep', icon: Moon, devices: '6 devices', mood: 'All lights off',
+ name: 'Sleep', icon: Moon, favorite: false, mood: 'All lights off',
actions: [
{ capability: 'Power', detail: 'Off' },
{ capability: 'Lock', detail: 'Locked' },
{ capability: 'Mode', detail: 'Away' },
],
},
+ {
+ name: 'Away', icon: LogOut, favorite: false, mood: 'Away mode',
+ actions: [
+ { capability: 'Power', detail: 'Off' },
+ { capability: 'Lock', detail: 'Locked' },
+ { capability: 'Mode', detail: 'Away' },
+ ],
+ },
+];
+
+// Rooms shown on the dashboard + Rooms tab. Each drills into a room detail
+// that lists its devices, and each device drills into a capability-driven
+// control screen (mirrors RoomDetailView → DeviceDetailView).
+export const rooms = [
+ { name: 'Living Room', type: 'Living Room', icon: Sofa, devices: ['ceiling', 'floorlamp', 'thermostat'] },
+ { name: 'Bedroom', type: 'Bedroom', icon: BedDouble, devices: ['bedside', 'blind'] },
+ { name: 'Kitchen', type: 'Kitchen', icon: Utensils, devices: ['undercab'] },
+ { name: 'Office', type: 'Office', icon: Laptop, devices: [] },
+];
+
+const deviceCatalog = {
+ ceiling: { name: 'Ceiling Light', room: 'Living Room', category: 'Lighting', icon: Lightbulb, kind: 'light', online: true },
+ floorlamp: { name: 'Floor Lamp', room: 'Living Room', category: 'Lighting', icon: Lightbulb, kind: 'light', online: true },
+ thermostat: { name: 'Thermostat', room: 'Living Room', category: 'Climate', icon: Thermometer, kind: 'climate', online: true },
+ bedside: { name: 'Bedside Lamp', room: 'Bedroom', category: 'Lighting', icon: Lightbulb, kind: 'light', online: true },
+ blind: { name: 'Blackout Blind', room: 'Bedroom', category: 'Other', icon: Blinds, kind: 'blind', online: true },
+ undercab: { name: 'Under-cabinet Light', room: 'Kitchen', category: 'Lighting', icon: Lightbulb, kind: 'light', online: true },
+ desklamp: { name: 'Desk Lamp', room: 'Office', category: 'Lighting', icon: Lightbulb, kind: 'light', online: true },
+ frontdoor: { name: 'Front Door', room: 'Entryway', category: 'Security', icon: Lock, kind: 'lock', online: false },
+ motion: { name: 'Living Room Motion', room: 'Living Room', category: 'Sensors', icon: Activity, kind: 'sensor', online: true },
+};
+
+// Global device list for the Intel tab, grouped by category (8 devices, 7 online),
+// matching the discovery banner "HomeKit · 8 devices discovered · 7 online".
+const intelCategories = [
+ { category: 'Lighting', ids: ['ceiling', 'floorlamp', 'bedside', 'undercab', 'desklamp'] },
+ { category: 'Climate', ids: ['thermostat'] },
+ { category: 'Security', ids: ['frontdoor'] },
+ { category: 'Sensors', ids: ['motion'] },
+];
+
+// Evening reasoning — mirrors ReasoningCalculator + SuggestionEngine output.
+const reasoningHeadline = 'Sunset is moving across your home.';
+
+const reasoningFactors = [
+ { label: 'Fits the evening', detail: 'Warm, low light suits winding down.' },
+ { label: 'Your usual routine', detail: 'You’ve run Evening 3× around this hour.' },
+ { label: 'Devices are ready', detail: '7 reachable right now.' },
];
const reasoningSignals = [
@@ -64,13 +109,8 @@ const reasoningSignals = [
{ label: 'Presence', value: 'At home', weight: 'high' },
{ label: 'Reachable devices', value: '7', weight: 'medium' },
{ label: 'Matching scene', value: 'Evening', weight: 'high' },
-];
-
-const devices = [
- { name: 'Ceiling Light', room: 'Living Room', icon: Lightbulb, online: true },
- { name: 'Desk Lamp', room: 'Office', icon: Lightbulb, online: true },
- { name: 'Thermostat', room: 'Hallway', icon: Thermometer, online: true },
- { name: 'Front Door', room: 'Entryway', icon: Lock, online: false },
+ { label: 'Usual routine', value: '3× around now', weight: 'medium' },
+ { label: 'Confidence', value: '72%', weight: 'high' },
];
const TAB_AMBIENT = {
@@ -89,15 +129,17 @@ 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.',
- Home: 'Home shows rhythm, rooms, and gentle suggestions. Tap Lumen noticed to begin.',
- Rooms: 'Open Living Room to try planned-device controls before hardware arrives.',
- Intel: 'Every Apple Home device in one calm list.',
- Auto: 'Tap a scene to preview the approval sheet before anything runs.',
- Settings: 'Remotes, homes, and calm preferences live here in the beta.',
- reasoning: 'Reasoning shows the signals behind a suggestion.',
+ Home: 'Home shows rhythm, rooms, and one gentle suggestion. Tap “Lumen noticed” to begin.',
+ Rooms: 'Open a room, then a device, to try the capability controls before hardware arrives.',
+ Intel: 'Every Apple Home device in one calm list, grouped by type.',
+ Auto: 'Tap a scene to preview its approval sheet before anything runs.',
+ Settings: 'Sensory profile, bridges, and calm preferences live here in the beta.',
+ room: 'Tap a device to open its controls.',
+ device: 'Drag the sliders — brightness and warmth respond live.',
+ reasoning: 'Reasoning shows the signals and the “why this scene”.',
action: 'Action confirms exactly what Lumen will change.',
approval: 'Manual scene runs use the same approval pattern.',
- applied: 'Evening is live. Lights and locks updated after your tap.',
+ applied: 'Evening is live. Lights updated only after your tap.',
};
const PhoneContext = createContext(null);
@@ -108,15 +150,16 @@ export function usePhone() {
return ctx;
}
+const DEFAULT_LIGHT = { power: true, brightness: 62, temp: 40 };
+
export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) {
const [tab, setTab] = useState('Home');
const [sheet, setSheet] = useState(null);
const [approvalScene, setApprovalScene] = useState(null);
const [activeScene, setActiveScene] = useState(null);
const [selectedRoom, setSelectedRoom] = useState(null);
- const [lightOn, setLightOn] = useState(true);
- const [brightness, setBrightness] = useState(62);
- const [colorTemp, setColorTemp] = useState(40);
+ const [selectedDevice, setSelectedDevice] = useState(null);
+ const [deviceStates, setDeviceStates] = useState({ ceiling: { ...DEFAULT_LIGHT } });
const [toast, setToast] = useState(null);
const [touched, setTouched] = useState(false);
@@ -132,29 +175,31 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) {
const hint = useMemo(() => {
if (toast) return PHONE_HINTS.applied;
if (sheet) return PHONE_HINTS[sheet];
+ if (selectedDevice) return PHONE_HINTS.device;
+ if (selectedRoom) return PHONE_HINTS.room;
return PHONE_HINTS[tab] ?? PHONE_HINTS.idle;
- }, [tab, sheet, toast]);
+ }, [tab, sheet, toast, selectedRoom, selectedDevice]);
const markTouched = useCallback(() => setTouched(true), []);
+ const lightState = useCallback(id => deviceStates[id] ?? DEFAULT_LIGHT, [deviceStates]);
+
+ const setLight = useCallback((id, patch) => {
+ setDeviceStates(prev => ({ ...prev, [id]: { ...DEFAULT_LIGHT, ...prev[id], ...patch } }));
+ }, []);
+
const selectTab = useCallback(label => {
markTouched();
setTab(label);
setSheet(null);
setApprovalScene(null);
setSelectedRoom(null);
+ setSelectedDevice(null);
setToast(null);
}, [markTouched]);
- const openReasoning = useCallback(() => {
- markTouched();
- setSheet('reasoning');
- }, [markTouched]);
-
- const openAction = useCallback(() => {
- markTouched();
- setSheet('action');
- }, [markTouched]);
+ const openReasoning = useCallback(() => { markTouched(); setSheet('reasoning'); }, [markTouched]);
+ const openAction = useCallback(() => { markTouched(); setSheet('action'); }, [markTouched]);
const dismissSheet = useCallback(() => {
markTouched();
@@ -166,12 +211,10 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) {
markTouched();
setSheet(null);
setActiveScene(eveningScene.name);
- setLightOn(true);
- setBrightness(40);
- setColorTemp(25);
+ setLight('ceiling', { power: true, brightness: 40, temp: 19 });
setToast('Evening scene applied');
setTimeout(() => setToast(null), 2800);
- }, [eveningScene.name, markTouched]);
+ }, [eveningScene.name, markTouched, setLight]);
const openSceneApproval = useCallback(scene => {
markTouched();
@@ -192,37 +235,29 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) {
const openRoom = useCallback(name => {
markTouched();
setSelectedRoom(name);
+ setSelectedDevice(null);
}, [markTouched]);
+ const openDevice = useCallback(id => { markTouched(); setSelectedDevice(id); }, [markTouched]);
+
const runGuidedStep = useCallback(stepId => {
markTouched();
setToast(null);
+ setSelectedRoom(null);
+ setSelectedDevice(null);
switch (stepId) {
case 'home':
- setTab('Home');
- setSheet(null);
- setApprovalScene(null);
- setSelectedRoom(null);
+ setTab('Home'); setSheet(null); setApprovalScene(null);
break;
case 'noticed':
- setTab('Home');
- setSheet('reasoning');
- setApprovalScene(null);
- break;
case 'reasoning':
- setTab('Home');
- setSheet('reasoning');
- setApprovalScene(null);
+ setTab('Home'); setSheet('reasoning'); setApprovalScene(null);
break;
case 'action':
- setTab('Home');
- setSheet('action');
- setApprovalScene(null);
+ setTab('Home'); setSheet('action'); setApprovalScene(null);
break;
case 'scenes':
- setTab('Auto');
- setApprovalScene(eveningScene);
- setSheet('approval');
+ setTab('Auto'); setApprovalScene(eveningScene); setSheet('approval');
break;
default:
break;
@@ -240,37 +275,18 @@ export function PhoneProvider({ children, onAmbientChange, onFlowModeChange }) {
}, [flowMode, touched, onFlowModeChange]);
const value = {
- tab,
- sheet,
- approvalScene,
- activeScene,
- selectedRoom,
- lightOn,
- brightness,
- colorTemp,
- toast,
- touched,
- flowMode,
- hint,
- eveningScene,
- selectTab,
- openReasoning,
- openAction,
- dismissSheet,
- applySuggestion,
- openSceneApproval,
- confirmScene,
- openRoom,
- setLightOn,
- setBrightness,
- setColorTemp,
- markTouched,
- runGuidedStep,
+ tab, sheet, approvalScene, activeScene, selectedRoom, selectedDevice,
+ toast, touched, flowMode, hint, eveningScene,
+ lightState, setLight,
+ selectTab, openReasoning, openAction, dismissSheet, applySuggestion,
+ openSceneApproval, confirmScene, openRoom, openDevice, markTouched, runGuidedStep,
};
return
Rhythm
+7 of 8 devices online. All looking good.
+7 of 8 devices online — all looking good.
+ +Lumen noticed
- +Favorite Rooms
Lumen noticed
+ ); } +// ===== Consent sheets ===== + function SheetMotion({ children, className }) { const reducedMotion = useReducedMotion(); return ( @@ -393,9 +463,23 @@ function ReasoningSheet() { return (Why Lumen noticed
-Why this scene
+Signals
Apply suggested scene
Lumen will
@@ -440,7 +525,7 @@ function SceneApprovalSheet({ scene }) { return (Apply scene
Lumen will
@@ -452,35 +537,38 @@ function SceneApprovalSheet({ scene }) { ))} - +All Scenes
Ceiling Light
-Status
+Controls
+Temperature
+Lock
+Position
+Plan this room
+Add the devices you expect here first. They stay as preview controls until real hardware is linked.
Active Devices
+All Rooms
{category}
+Home
+Bridges
+Connections
+Sensory Profile
+About
+