From 3aac9ad3c712f70b2f457a0bd1ece3efe3472182 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 19:16:50 +0000 Subject: [PATCH] Make the web demo a faithful iOS simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the hero's interactive phone (src/InteractivePhone.jsx) so it mirrors the native SwiftUI app instead of an approximation of it, using the app source as the reference: - Home: adds the RHYTHM (NowNextCard) card and the compact rooms/devices/ automations stat row, uses the app's "Welcome Home," greeting and "N of M devices online — all looking good." subtitle, and moves the "Lumen noticed" card below the rooms to match the dashboard order. - Reasoning sheet: adds the "Why this scene" factor list alongside the weighted SIGNALS, matching LumenReasoningView (headline, confidence, habit). - Consent flow: Action and Approval sheets now use the app's "Apply" / "Not now" / "Cancel" wording and "LUMEN WILL" action lists. - Auto: large-title header, active-scene hero, and all five seeded scenes (Morning, Evening, Movie Night, Sleep, Away) with edit affordance. - Rooms: Rooms grid → room detail (device list) → capability-driven device control (STATUS + POWER/BRIGHTNESS/COLOR TEMPERATURE), with a live ambient glow; running a scene updates the device's brightness/warmth. - Intel: category-grouped device list with the "8 devices · 7 online" discovery banner. - Settings: HOME / BRIDGES / CONNECTIONS / SENSORY PROFILE / ABOUT sections; the Sensory Profile (Calm Mode + Suggestions/Motion/Contrast/Transitions) surfaces Lumen's neurodivergent-first positioning, and enabling Calm Mode applies its defaults like applyCalmModeDefaults(). New styles live in src/simulator.css (imported after App.css). The e2e scene assertion is updated to the app-accurate "Apply" label, and the byte-for-byte CLAUDE.md / AGENTS.md web-app section documents the simulator. Also adds a Node section to .gitignore so node_modules/ and dist/ can't be committed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MwXGKcD6HtsThXsp5KzCMB --- .gitignore | 5 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- e2e/live-demo.e2e.js | 5 +- src/App.jsx | 1 + src/InteractivePhone.jsx | 618 ++++++++++++++++++++++++++++----------- src/simulator.css | 315 ++++++++++++++++++++ 7 files changed, 770 insertions(+), 178 deletions(-) create mode 100644 src/simulator.css 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 {children}; } +// ===== Chrome ===== + function StatusBar() { return (
@@ -301,6 +317,24 @@ function TabBar({ active, onSelect }) { ); } +// Large-title header used by the secondary screens (Rooms / Intel / Auto / +// Settings and the detail views), matching the app's eyebrow + big title. +function SimHeader({ eyebrow, title, back, onBack, action }) { + return ( +
+
+ {back ? ( + + ) : ( + {eyebrow} + )} +

{title}

+
+ {action ? : null} +
+ ); +} + function DragSlider({ value, onChange, children }) { const trackRef = React.useRef(null); const [dragging, setDragging] = useState(false); @@ -331,6 +365,29 @@ function DragSlider({ value, onChange, children }) { ); } +// ===== Home (dashboard) ===== + +function RhythmCard() { + return ( +
+

Rhythm

+
+ Now +
+ Evening + Winding down softly. +
+
+
+
+ Next + Night + at 9:00 PM +
+
+ ); +} + function DashboardScreen() { const phone = usePhone(); @@ -340,39 +397,52 @@ function DashboardScreen() { LUMEN HOME MODE
-

Good evening,

+

Welcome Home,

Home

-

7 of 8 devices online. All looking good.

+

7 of 8 devices online — all looking good.

+ +
+ 4 rooms + · + 8 devices + · + 5 automations + +
-

Lumen noticed

- +

Favorite Rooms

- {favoriteRooms.map(({ name, icon: Icon, count }) => ( + {rooms.map(({ name, icon: Icon, devices }) => ( ))}
+ +

Lumen noticed

+ ); } +// ===== Consent sheets ===== + function SheetMotion({ children, className }) { const reducedMotion = useReducedMotion(); return ( @@ -393,9 +463,23 @@ function ReasoningSheet() { return ( -
+

Why Lumen noticed

-

Sunset is moving
across your home.

+

{reasoningHeadline}

+ +

Why this scene

+
+ {reasoningFactors.map(({ label, detail }) => ( +
+ +
+ {label} + {detail} +
+
+ ))} +
+

Signals

{reasoningSignals.map(({ label, value, weight }) => ( @@ -406,6 +490,7 @@ function ReasoningSheet() {
))} +
@@ -417,7 +502,7 @@ function ActionConfirmationSheet({ scene }) { return ( -
+

Apply suggested scene

{scene.name}

Lumen will

@@ -440,7 +525,7 @@ function SceneApprovalSheet({ scene }) { return ( -
+

Apply scene

{scene.name}

Lumen will

@@ -452,35 +537,38 @@ function SceneApprovalSheet({ scene }) { ))} - +
); } +// ===== Auto (Scenes) ===== + function AutoScreen() { const phone = usePhone(); const running = phone.activeScene; + const runningScene = scenes.find(s => s.name === running); return (
-
LUMEN
-

Scenes

- {running && ( + + {runningScene && (
ACTIVE NOW
{running} - {scenes.find(s => s.name === running)?.devices ?? ''} · {scenes.find(s => s.name === running)?.mood ?? ''} + {runningScene.actions.length} devices · {runningScene.mood}
)}

All Scenes

{scenes.map(scene => { - const { name, icon: Icon, devices: d, mood } = scene; + const { name, icon: Icon, actions, mood } = scene; return ( ); @@ -490,58 +578,148 @@ function AutoScreen() { ); } -function RoomsScreen() { +// ===== Rooms ===== + +function DeviceControlScreen({ deviceId }) { const phone = usePhone(); + const device = deviceCatalog[deviceId]; + const state = phone.lightState(deviceId); - if (phone.selectedRoom === 'Living Room') { - const warm = [212, 130, 90]; - const cool = [120, 170, 230]; - const t = phone.colorTemp / 100; - const rgb = warm.map((w, i) => Math.round(w + (cool[i] - w) * t)); - const glowOpacity = phone.lightOn ? 0.15 + (phone.brightness / 100) * 0.55 : 0.04; + const warm = [212, 130, 90]; + const cool = [120, 170, 230]; + const t = state.temp / 100; + const rgb = warm.map((w, i) => Math.round(w + (cool[i] - w) * t)); + const glowOpacity = device.kind === 'light' && state.power ? 0.14 + (state.brightness / 100) * 0.5 : 0.04; + const kelvin = Math.round(1800 + (state.temp / 100) * 4700); - return ( -
+ return ( +
+ {device.kind === 'light' && (
-
- - Try it -
-

Living Room

-

Ceiling Light

-
-
- Power - + )} + phone.openDevice(null)} + /> + +

Status

+
+
Status {device.online ? 'Online' : 'Offline'}
+
Room{device.room}
+
Category{device.category}
+
+ + {device.kind === 'light' && ( + <> +

Controls

+
+
+ Power + +
+
+ + { phone.markTouched(); phone.setLight(deviceId, { brightness: v }); }} /> + + {state.brightness}% +
+
+ + { phone.markTouched(); phone.setLight(deviceId, { temp: v }); }} /> + + {kelvin}K +
-
- - { phone.markTouched(); phone.setBrightness(v); }} /> - - {phone.brightness}% + + )} + + {device.kind === 'climate' && ( + <> +

Temperature

+
+
Current21.0 °C
-
- Warm - { phone.markTouched(); phone.setColorTemp(v); }} /> - Cool - {Math.round(1800 + (phone.colorTemp / 100) * 4700)}K + + )} + + {device.kind === 'lock' && ( + <> +

Lock

+
+
LockedTap to unlock
+ + )} + + {device.kind === 'blind' && ( + <> +

Position

+
+
Open100%
+
+ + )} +
+ ); +} + +function RoomDetailScreen({ roomName }) { + const phone = usePhone(); + const room = rooms.find(r => r.name === roomName); + + if (phone.selectedDevice) { + return ; + } + + return ( +
+ phone.openRoom(null)} action="+" /> + {room.devices.length === 0 ? ( +
+

Plan this room

+

Add the devices you expect here first. They stay as preview controls until real hardware is linked.

-
- ); + ) : ( + <> +

Active Devices

+
+ {room.devices.map(id => { + const d = deviceCatalog[id]; + const Icon = d.icon; + return ( + + ); + })} +
+ + )} +
+ ); +} + +function RoomsScreen() { + const phone = usePhone(); + + if (phone.selectedRoom) { + return ; } return (
-
LUMEN
-

Rooms

+

All Rooms

- {favoriteRooms.map(({ name, icon: Icon, count }) => ( + {rooms.map(({ name, icon: Icon, devices }) => ( ))}
@@ -549,40 +727,132 @@ function RoomsScreen() { ); } +// ===== Intel (Devices) ===== + function IntelScreen() { return (
-
LUMEN
-

Intel

-
Apple Home · 8 devices · 7 online
-
- {devices.map(({ name, room, icon: Icon, online }) => ( -
-
-
{name}{room}
- -
- ))} + +
+ HomeKit · 8 devices discovered + 7 online
+ {intelCategories.map(({ category, ids }) => ( +
+

{category}

+
+ {ids.map(id => { + const d = deviceCatalog[id]; + const Icon = d.icon; + return ( +
+
+
{d.name}{d.room}
+ +
+ ); + })} +
+
+ ))} +
+ ); +} + +// ===== Settings ===== + +const cadenceOptions = ['Quiet', 'Balanced', 'Supportive']; +const motionOptions = ['Reduced', 'Balanced']; +const contrastOptions = ['Soft', 'Balanced', 'Clear']; + +function Segmented({ options, value, onChange }) { + return ( +
+ {options.map(opt => ( + + ))}
); } 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 ( -
-
LUMEN
-

Settings

-
-
Home
-
Remotes
-
Location & arrival
-
Local preview devicesOn
+
+ + +

Home

+
+
NameHome
+
Rooms4
+
Devices8
+
Home locationSet
+
+ +

Bridges

+
+
HomekitConnected
+
Local networkConnected
+
+ +

Connections

+
+
IR Remotes
+
Wi-Fi Devices
+
+ +

Sensory Profile

+
+
Profile{calm ? '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`}
+
+ +

About

+
+
Version1.0
+
Build1
); } +// ===== Toast + stage ===== + function PhoneToast({ message }) { if (!message) return null; return ( diff --git a/src/simulator.css b/src/simulator.css new file mode 100644 index 0000000..43711d2 --- /dev/null +++ b/src/simulator.css @@ -0,0 +1,315 @@ +/* ===== iOS simulator additions ===== + * Styles for the higher-fidelity in-phone simulator (InteractivePhone.jsx). + * These mirror the native app's surfaces that the earlier demo didn't cover: + * large-title headers, the compact stats row, the reasoning "why this scene" + * factors, capability-driven device controls, and the sensory profile settings. + * Tokens follow the app: bg #0E0819, accent #C49A6C, ink #e8e0d4. + * ------------------------------------------------------------------------- */ + +/* Let the taller screens scroll like the app's ScrollView, without a visible + scrollbar inside the phone frame. The demo wrapper sets text-align:center for + the caption; the app screens and the consent-sheet lists are left-aligned. */ +.app-screen { + overflow-y: auto; + scrollbar-width: none; + text-align: left; +} + +.factor-row, +.factor-row b, +.factor-row span, +.signal-row, +.signal-label, +.approval-action-row, +.approval-action-capability { + text-align: left; +} + +.signal-value, +.approval-action-detail { + text-align: right; +} + +.app-screen::-webkit-scrollbar { + display: none; +} + +/* ===== Large-title header (Rooms / Intel / Auto / Settings + detail views) ===== */ + +.sim-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 2px 0 1px; +} + +.sim-head-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.sim-eyebrow { + font-size: 7px; + font-weight: 600; + letter-spacing: 0.34em; + color: rgba(232, 224, 212, 0.32); + text-transform: uppercase; +} + +.sim-title { + font-size: 15.5px; + font-weight: 700; + color: #e8e0d4; + margin: 0; + line-height: 1.05; + letter-spacing: -0.01em; +} + +.sim-round { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + color: rgba(232, 224, 212, 0.6); + font-size: 11px; + line-height: 1; + flex-shrink: 0; + margin-top: 2px; +} + +/* ===== Compact stats row (dashboard) ===== */ + +.app-cstats { + display: flex; + align-items: center; + gap: 5px; + font-size: 8.5px; + color: rgba(232, 224, 212, 0.4); +} + +.app-cstats b { + font-weight: 500; + color: rgba(232, 224, 212, 0.72); + font-variant-numeric: tabular-nums; +} + +.app-cstats i { + color: rgba(232, 224, 212, 0.2); + font-style: normal; +} + +.app-cstats .app-plus { + margin-left: auto; +} + +/* ===== Reasoning "why this scene" factors ===== */ + +.factor-row { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 5px 7px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); +} + +.factor-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #C49A6C; + flex-shrink: 0; + margin-top: 4px; +} + +.factor-row > div { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.factor-row b { + font-size: 8.5px; + font-weight: 500; + color: rgba(232, 224, 212, 0.82); +} + +.factor-row span { + font-size: 7.5px; + line-height: 1.35; + color: rgba(232, 224, 212, 0.45); +} + +/* Reasoning sheet holds more content now — let it scroll within the sheet. */ +.reason-sheet { + overflow-y: auto; + scrollbar-width: none; +} + +.reason-sheet::-webkit-scrollbar { + display: none; +} + +/* ===== Scene rows ===== */ + +/* Explicit app-style surface (SceneDarkRow uses white 0.06) so scene names stay + readable and no default