HomePilot Deploy Bot commited on
Commit
ad48a43
Β·
1 Parent(s): d85b299

chore(hf): sync HomePilot to HF Space

Browse files
.gitattributes CHANGED
@@ -1,9 +1,9 @@
1
- *.jpg filter=lfs diff=lfs merge=lfs -text
 
2
  *.jpeg filter=lfs diff=lfs merge=lfs -text
3
  *.webp filter=lfs diff=lfs merge=lfs -text
4
  *.svg filter=lfs diff=lfs merge=lfs -text
5
  *.woff filter=lfs diff=lfs merge=lfs -text
6
- *.png filter=lfs diff=lfs merge=lfs -text
7
- *.woff2 filter=lfs diff=lfs merge=lfs -text
8
- *.ico filter=lfs diff=lfs merge=lfs -text
9
  *.hpersona filter=lfs diff=lfs merge=lfs -text
 
 
 
1
+ *.ico filter=lfs diff=lfs merge=lfs -text
2
+ *.png filter=lfs diff=lfs merge=lfs -text
3
  *.jpeg filter=lfs diff=lfs merge=lfs -text
4
  *.webp filter=lfs diff=lfs merge=lfs -text
5
  *.svg filter=lfs diff=lfs merge=lfs -text
6
  *.woff filter=lfs diff=lfs merge=lfs -text
 
 
 
7
  *.hpersona filter=lfs diff=lfs merge=lfs -text
8
+ *.jpg filter=lfs diff=lfs merge=lfs -text
9
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
frontend/.cache-bust CHANGED
@@ -1 +1 @@
1
- 1791530700
 
1
+ 1791564573
frontend/index.html CHANGED
@@ -4,12 +4,44 @@
4
  <html lang="en">
5
  <head>
6
  <meta charset="UTF-8" />
7
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
 
 
8
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9
  <title>HomePilot</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  </head>
11
  <body class="bg-black text-white" data-new-gr-c-s-check-loaded="14.1217.0" data-gr-ext-installed="">
12
- <div id="root" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  <script src="/js/speech-service.js"></script>
14
  <!-- ScreenSense: additive screen-awareness addon (desktop native / cloud share-or-upload). Defines window.hpScreenSense; inert until invoked. -->
15
  <!-- Screen sharing is reached from the composer's `+` menu, so ScreenSense must not also
 
4
  <html lang="en">
5
  <head>
6
  <meta charset="UTF-8" />
7
+ <!-- No maximum-scale: people must be able to pinch-zoom (inputs are 16px on touch, so iOS does not auto-zoom).
8
+ interactive-widget: the layout shrinks above the on-screen keyboard, keeping the composer and sticky actions visible. -->
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
10
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
11
  <title>HomePilot</title>
12
+ <!-- Boot screen: visible from the first paint until the app mounts (React
13
+ replaces #root's contents). On a slow connection the app's script can
14
+ take many seconds to arrive; without this the page is plain black. -->
15
+ <style>
16
+ html, body { background: #090909; }
17
+ .hp-boot { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 24px; text-align: center; color: #f5f5f5; font: 500 16px/1.45 Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
18
+ .hp-boot__ring { width: 28px; height: 28px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.15); border-top-color: rgba(255, 255, 255, 0.85); animation: hp-boot-spin 0.9s linear infinite; }
19
+ .hp-boot__hint { max-width: 22rem; color: #b0b0b0; font-size: 14px; font-weight: 400; opacity: 0; animation: hp-boot-show 0.3s ease-out 6s forwards; }
20
+ .hp-boot__reload { display: none; min-height: 44px; padding: 0 18px; border-radius: 999px; border: 1px solid #303030; background: #171717; color: #f5f5f5; font: inherit; font-weight: 600; cursor: pointer; }
21
+ .hp-boot[data-slow] .hp-boot__reload { display: inline-block; }
22
+ @keyframes hp-boot-spin { to { transform: rotate(360deg); } }
23
+ @keyframes hp-boot-show { to { opacity: 1; } }
24
+ @media (prefers-reduced-motion: reduce) { .hp-boot__ring { animation: none; } .hp-boot__hint { animation: none; opacity: 1; } }
25
+ </style>
26
  </head>
27
  <body class="bg-black text-white" data-new-gr-c-s-check-loaded="14.1217.0" data-gr-ext-installed="">
28
+ <div id="root" data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false"><div class="hp-boot" role="status" aria-live="polite">
29
+ <div class="hp-boot__ring" aria-hidden="true"></div>
30
+ <div>Starting HomePilot…</div>
31
+ <div class="hp-boot__hint">Loading the app. On a slow connection this can take a moment.</div>
32
+ <button type="button" class="hp-boot__reload" onclick="location.reload()">Reload</button>
33
+ </div></div>
34
+ <noscript><p style="color:#f5f5f5;font-family:system-ui,sans-serif;text-align:center;padding:24px">HomePilot needs JavaScript to run. Please enable it and reload.</p></noscript>
35
+ <script>
36
+ // Still not started after 40 s (script blocked or failed to load): say so and offer Reload.
37
+ setTimeout(function () {
38
+ var boot = document.querySelector('.hp-boot');
39
+ if (!boot) return;
40
+ boot.setAttribute('data-slow', '');
41
+ var hint = boot.querySelector('.hp-boot__hint');
42
+ if (hint) hint.textContent = 'This is taking longer than usual. Check your connection, then reload.';
43
+ }, 40000);
44
+ </script>
45
  <script src="/js/speech-service.js"></script>
46
  <!-- ScreenSense: additive screen-awareness addon (desktop native / cloud share-or-upload). Defines window.hpScreenSense; inert until invoked. -->
47
  <!-- Screen sharing is reached from the composer's `+` menu, so ScreenSense must not also
frontend/src/main.tsx CHANGED
@@ -2,6 +2,7 @@ import React from 'react'
2
  import ReactDOM from 'react-dom/client'
3
  import App from './ui/App'
4
  import AuthGate from './ui/components/AuthGate'
 
5
  import LegalPage from './ui/components/LegalPage'
6
  // Account & Computers spine (Batch 2) β€” ADDITIVE. The providers render children
7
  // unchanged and do NO network unless the feature flag is on; the dev panel
@@ -11,6 +12,11 @@ import { ComputerProvider } from './ui/account/ComputerContext'
11
  import { MirrorDevPanel } from './ui/account/MirrorDevPanel'
12
  import { RemoteOfflineBanner } from './ui/account/RemoteOfflineBanner'
13
  import './ui/styles.css'
 
 
 
 
 
14
 
15
  // Public, pre-auth legal pages. These are reached by full navigation (the login
16
  // footer links, and direct URLs); on the SPA host the server returns index.html
@@ -21,6 +27,8 @@ const legalKind =
21
 
22
  ReactDOM.createRoot(document.getElementById('root')!).render(
23
  <React.StrictMode>
 
 
24
  {legalKind ? (
25
  <LegalPage kind={legalKind} />
26
  ) : (
@@ -34,5 +42,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
34
  </HomePilotAccountProvider>
35
  </AuthGate>
36
  )}
 
37
  </React.StrictMode>,
38
  )
 
2
  import ReactDOM from 'react-dom/client'
3
  import App from './ui/App'
4
  import AuthGate from './ui/components/AuthGate'
5
+ import { AppErrorBoundary } from './ui/components/AppErrorBoundary'
6
  import LegalPage from './ui/components/LegalPage'
7
  // Account & Computers spine (Batch 2) β€” ADDITIVE. The providers render children
8
  // unchanged and do NO network unless the feature flag is on; the dev panel
 
12
  import { MirrorDevPanel } from './ui/account/MirrorDevPanel'
13
  import { RemoteOfflineBanner } from './ui/account/RemoteOfflineBanner'
14
  import './ui/styles.css'
15
+ import './ui/motion/motion.css'
16
+ import { applyMotionPrefs } from './ui/motion/prefs'
17
+
18
+ // Mirror the device's motion preference onto <html> before the first paint.
19
+ applyMotionPrefs()
20
 
21
  // Public, pre-auth legal pages. These are reached by full navigation (the login
22
  // footer links, and direct URLs); on the SPA host the server returns index.html
 
27
 
28
  ReactDOM.createRoot(document.getElementById('root')!).render(
29
  <React.StrictMode>
30
+ {/* Never fail to a blank screen: a render error shows what happened and how to recover. */}
31
+ <AppErrorBoundary label="root">
32
  {legalKind ? (
33
  <LegalPage kind={legalKind} />
34
  ) : (
 
42
  </HomePilotAccountProvider>
43
  </AuthGate>
44
  )}
45
+ </AppErrorBoundary>
46
  </React.StrictMode>,
47
  )
frontend/src/test/mobileShell.test.tsx ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Mobile shell regressions: dialogs escape transformed ancestors, Account
3
+ * Settings is one sheet with a single-line Save All and protects unsaved edits,
4
+ * startup never sits on a silent blank screen, and render errors are recoverable.
5
+ */
6
+ import React from 'react'
7
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ import { ModalSheet } from '../ui/components/ModalSheet'
11
+ import { AppErrorBoundary } from '../ui/components/AppErrorBoundary'
12
+ import AuthGate, { AUTH_CHECK_TIMEOUT_MS, SLOW_START_HINT_MS } from '../ui/components/AuthGate'
13
+ import ProfileSettingsModal from '../ui/ProfileSettingsModal'
14
+
15
+ function json(body: unknown, status = 200) {
16
+ return Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }))
17
+ }
18
+
19
+ afterEach(() => {
20
+ vi.restoreAllMocks()
21
+ vi.useRealTimers()
22
+ localStorage.clear()
23
+ document.body.style.overflow = ''
24
+ })
25
+
26
+ describe('ModalSheet', () => {
27
+ it('renders at <body>, outside a transformed drawer, as a labelled modal dialog', () => {
28
+ render(
29
+ <div data-testid="drawer" style={{ transform: 'translateX(0)' }}>
30
+ <ModalSheet labelledBy="t" onRequestClose={() => {}} header={<h2 id="t">Title</h2>} footer={<button>Save</button>}>
31
+ <input aria-label="field" />
32
+ </ModalSheet>
33
+ </div>,
34
+ )
35
+ const dialog = screen.getByRole('dialog', { name: 'Title' })
36
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
37
+ expect(screen.getByTestId('drawer')).not.toContainElement(dialog)
38
+ expect(dialog.closest('.hp-sheet-backdrop')?.parentElement).toBe(document.body)
39
+ expect(document.body.style.overflow).toBe('hidden')
40
+ })
41
+
42
+ it('closes on Escape and on a backdrop press, and keeps Tab inside', () => {
43
+ const onClose = vi.fn()
44
+ render(
45
+ <ModalSheet labelledBy="t" onRequestClose={onClose} header={<button>First</button>} footer={<button>Last</button>}>
46
+ <span id="t">x</span>
47
+ </ModalSheet>,
48
+ )
49
+ expect(screen.getByText('First')).toHaveFocus()
50
+ fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
51
+ expect(screen.getByText('Last')).toHaveFocus()
52
+ fireEvent.keyDown(document, { key: 'Escape' })
53
+ fireEvent.mouseDown(document.querySelector('.hp-sheet-backdrop')!)
54
+ expect(onClose).toHaveBeenCalledTimes(2)
55
+ })
56
+ })
57
+
58
+ describe('Account Settings', () => {
59
+ beforeEach(() => {
60
+ localStorage.setItem('homepilot_auth_token', 't0k')
61
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => {
62
+ const url = String(input)
63
+ if (url.endsWith('/v1/user-profile')) return json({ ok: true, profile: { display_name: 'Alex', timezone: 'UTC' } })
64
+ if (url.endsWith('/v1/user-profile/secrets')) return json({ ok: true, secrets: [] })
65
+ if (url.endsWith('/v1/user-memory')) return json({ ok: true, memory: { items: [] } })
66
+ return json({ ok: true })
67
+ })
68
+ })
69
+
70
+ it('is one sheet: scrollable tabs, a single-line Save All, and a labelled close button', async () => {
71
+ render(<ProfileSettingsModal backendUrl="http://hp" apiKey="" nsfwMode={false} onClose={() => {}} />)
72
+ expect(await screen.findByRole('dialog', { name: 'Account Settings' })).toBeInTheDocument()
73
+ expect(screen.getByRole('tablist', { name: 'Settings sections' })).toHaveClass('hp-tabs')
74
+ expect(screen.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true')
75
+ const save = screen.getByRole('button', { name: 'Save All' })
76
+ expect(save.className).toContain('whitespace-nowrap')
77
+ expect(save.closest('.hp-sheet__footer')).not.toBeNull()
78
+ expect(screen.getByRole('button', { name: 'Close settings' })).toBeInTheDocument()
79
+ })
80
+
81
+ it('asks before discarding unsaved edits, and closes freely when nothing changed', async () => {
82
+ const onClose = vi.fn()
83
+ const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
84
+ render(<ProfileSettingsModal backendUrl="http://hp" apiKey="" nsfwMode={false} onClose={onClose} />)
85
+ await waitFor(() => expect(screen.queryByLabelText('Loading your settings')).not.toBeInTheDocument())
86
+
87
+ fireEvent.click(screen.getByRole('button', { name: 'Close settings' }))
88
+ expect(confirm).not.toHaveBeenCalled()
89
+ expect(onClose).toHaveBeenCalledTimes(1)
90
+
91
+ fireEvent.change(screen.getByDisplayValue('Alex'), { target: { value: 'Alexandra' } })
92
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument()
93
+ fireEvent.click(screen.getByRole('button', { name: 'Close settings' }))
94
+ expect(confirm).toHaveBeenCalledTimes(1)
95
+ expect(onClose).toHaveBeenCalledTimes(1) // kept open: the person chose not to discard
96
+ })
97
+ })
98
+
99
+ describe('Startup', () => {
100
+ it('shows a visible startup screen, explains a slow start, then offers Retry instead of a blank screen', async () => {
101
+ vi.useFakeTimers()
102
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) =>
103
+ new Promise((_resolve, reject) => {
104
+ init?.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))
105
+ }),
106
+ )
107
+ vi.spyOn(console, 'error').mockImplementation(() => {})
108
+ render(<AuthGate><div>app</div></AuthGate>)
109
+ expect(screen.getByRole('status')).toHaveTextContent('Starting HomePilot…')
110
+ await act(async () => { vi.advanceTimersByTime(SLOW_START_HINT_MS + 10) })
111
+ expect(screen.getByRole('status')).toHaveTextContent('waking up')
112
+ await act(async () => { vi.advanceTimersByTime(AUTH_CHECK_TIMEOUT_MS) })
113
+ expect(screen.getByRole('alert')).toHaveTextContent('Can’t reach HomePilot')
114
+ expect(screen.queryByText('app')).not.toBeInTheDocument()
115
+
116
+ fetchMock.mockImplementation(() => json({ user: null }))
117
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Retry' })) })
118
+ expect(fetchMock).toHaveBeenCalledTimes(2)
119
+ expect(screen.getByText('app')).toBeInTheDocument()
120
+ })
121
+
122
+ it('Continue anyway keeps the old pre-auth path available', async () => {
123
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'))
124
+ vi.spyOn(console, 'error').mockImplementation(() => {})
125
+ render(<AuthGate><div>app</div></AuthGate>)
126
+ fireEvent.click(await screen.findByRole('button', { name: 'Continue anyway' }))
127
+ expect(screen.getByText('app')).toBeInTheDocument()
128
+ })
129
+ })
130
+
131
+ describe('AppErrorBoundary', () => {
132
+ it('turns a render error into a recoverable screen instead of a blank page', () => {
133
+ vi.spyOn(console, 'error').mockImplementation(() => {})
134
+ let explode = true
135
+ function Fragile() {
136
+ if (explode) throw new Error('kaboom')
137
+ return <div>recovered</div>
138
+ }
139
+ render(<AppErrorBoundary><Fragile /></AppErrorBoundary>)
140
+ expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong')
141
+ expect(screen.getByText('Error: kaboom')).toBeInTheDocument()
142
+ explode = false
143
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
144
+ expect(screen.getByText('recovered')).toBeInTheDocument()
145
+ })
146
+ })
frontend/src/test/motionSystem.test.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Motion system: preferences, the progressive reveal's pacing, the primitives'
3
+ * accessible output, and the Back gesture closing layers in order.
4
+ */
5
+ import React from 'react'
6
+ import { act, fireEvent, render, screen } from '@testing-library/react'
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ import {
10
+ AudioWaveform,
11
+ Collapsible,
12
+ DEFAULT_MOTION_PREFS,
13
+ LoadingDots,
14
+ MOTION_EVENT,
15
+ ProgressBar,
16
+ StatusText,
17
+ StreamReveal,
18
+ VoiceOrb,
19
+ partialMarkdown,
20
+ readMotionPrefs,
21
+ revealDuration,
22
+ revealStops,
23
+ waveformWeights,
24
+ writeMotionPrefs,
25
+ MotionGallery,
26
+ } from '../ui/motion'
27
+ import { useBackToClose } from '../ui/lib/useBackToClose'
28
+
29
+ afterEach(() => {
30
+ localStorage.clear()
31
+ delete document.documentElement.dataset.hpMotion
32
+ delete document.documentElement.dataset.hpReveal
33
+ vi.useRealTimers()
34
+ })
35
+
36
+ describe('motion preferences', () => {
37
+ it('default to the enterprise level with streamed answers, and ignore unknown values', () => {
38
+ expect(readMotionPrefs()).toEqual(DEFAULT_MOTION_PREFS)
39
+ localStorage.setItem('homepilot_motion', JSON.stringify({ level: 'wild', reveal: 'fade' }))
40
+ expect(readMotionPrefs()).toEqual({ level: 'enterprise', reveal: 'fade' })
41
+ localStorage.setItem('homepilot_motion', '{not json')
42
+ expect(readMotionPrefs()).toEqual(DEFAULT_MOTION_PREFS)
43
+ })
44
+
45
+ it('are saved on this device, mirrored onto <html>, and announced', () => {
46
+ const heard = vi.fn()
47
+ window.addEventListener(MOTION_EVENT, heard)
48
+ writeMotionPrefs({ level: 'minimal' })
49
+ window.removeEventListener(MOTION_EVENT, heard)
50
+ expect(readMotionPrefs()).toEqual({ level: 'minimal', reveal: 'stream' })
51
+ expect(document.documentElement.dataset.hpMotion).toBe('minimal')
52
+ expect(document.documentElement.dataset.hpReveal).toBe('stream')
53
+ expect(heard).toHaveBeenCalledTimes(1)
54
+ })
55
+ })
56
+
57
+ describe('progressive reveal', () => {
58
+ it('stops only after whole words and always ends at the full text', () => {
59
+ const text = 'Hello there, world'
60
+ const stops = revealStops(text)
61
+ expect(stops).toEqual([6, 14, 19])
62
+ expect(stops.map((i) => text.slice(0, i))).toEqual(['Hello ', 'Hello there, ', 'Hello there, world'])
63
+ expect(revealStops('')).toEqual([0])
64
+ })
65
+
66
+ it('is paced by length but never slow', () => {
67
+ expect(revealDuration(3)).toBe(450)
68
+ expect(revealDuration(60)).toBe(840)
69
+ expect(revealDuration(5000)).toBe(1600)
70
+ })
71
+
72
+ it('closes an unfinished code fence so a partial answer still renders as code', () => {
73
+ const text = 'Run this:\n\n```bash\nls -la\n```\n\nDone.'
74
+ expect(partialMarkdown(text, 'Run this:\n\n```bash\nls'.length)).toBe('Run this:\n\n```bash\nls\n```')
75
+ expect(partialMarkdown(text, text.length)).toBe(text)
76
+ })
77
+
78
+ it('renders the final answer at once where motion cannot run (tests, reduced motion)', () => {
79
+ render(<StreamReveal text="All of it" active render={(t) => <p>{t}</p>} />)
80
+ expect(screen.getByText('All of it')).toBeInTheDocument()
81
+ })
82
+ })
83
+
84
+ describe('motion primitives', () => {
85
+ it('give every animation a readable, accessible equivalent', () => {
86
+ render(
87
+ <>
88
+ <LoadingDots label="Waiting for a reply" />
89
+ <ProgressBar value={0.42} label="Upload" />
90
+ <ProgressBar label="Refreshing" />
91
+ <StatusText text="Searching the web" />
92
+ <VoiceOrb state="listening" level={2} />
93
+ <AudioWaveform level={0.5} bars={9} />
94
+ </>,
95
+ )
96
+ expect(screen.getByRole('img', { name: 'Waiting for a reply' })).toBeInTheDocument()
97
+ expect(screen.getByRole('progressbar', { name: 'Upload' })).toHaveAttribute('aria-valuenow', '42')
98
+ expect(screen.getByRole('progressbar', { name: 'Refreshing' })).not.toHaveAttribute('aria-valuenow')
99
+ expect(screen.getByRole('status')).toHaveTextContent('Searching the web')
100
+ const orb = document.querySelector('.hp-orb') as HTMLElement
101
+ expect(orb.style.getPropertyValue('--hp-level')).toBe('1.000') // clamped
102
+ expect(document.querySelectorAll('.hp-wave > span')).toHaveLength(9)
103
+ })
104
+
105
+ it('announce a changed status once, with the new label', () => {
106
+ const { rerender } = render(<StatusText text="Searching" />)
107
+ rerender(<StatusText text="Reading 3 sources" />)
108
+ expect(screen.getByRole('status')).toHaveTextContent('Reading 3 sources')
109
+ expect(screen.getByRole('status')).not.toHaveTextContent('Searching')
110
+ })
111
+
112
+ it('hide collapsed content from everyone', () => {
113
+ const { rerender } = render(<Collapsible open={false}><span>Details</span></Collapsible>)
114
+ expect(document.querySelector('.hp-collapsible')).toHaveAttribute('aria-hidden', 'true')
115
+ rerender(<Collapsible open><span>Details</span></Collapsible>)
116
+ expect(document.querySelector('.hp-collapsible')).not.toHaveAttribute('aria-hidden')
117
+ })
118
+
119
+ it('shape the waveform like a voice: tallest in the middle', () => {
120
+ const w = waveformWeights(7)
121
+ expect(w[3]).toBeCloseTo(1)
122
+ expect(w[0]).toBeCloseTo(w[6])
123
+ expect(w[0]).toBeLessThan(w[3])
124
+ })
125
+ })
126
+
127
+ describe('Settings β†’ Motion', () => {
128
+ it('lists all 22 animations and applies a choice immediately', () => {
129
+ render(<MotionGallery />)
130
+ const list = screen.getByRole('list', { name: 'Animations in HomePilot' })
131
+ expect(list.textContent).toContain('Text shimmer')
132
+ expect(list.textContent).toContain('Progress indicator')
133
+ expect(list.querySelectorAll('[data-motion-demo]')).toHaveLength(22)
134
+ fireEvent.click(screen.getByRole('radio', { name: /Off/ }))
135
+ expect(readMotionPrefs().level).toBe('off')
136
+ expect(screen.getByRole('radio', { name: /Off/ })).toHaveAttribute('aria-checked', 'true')
137
+ fireEvent.click(screen.getByRole('radio', { name: /Instant/ }))
138
+ expect(readMotionPrefs().reveal).toBe('instant')
139
+ })
140
+ })
141
+
142
+ function Layer({ name, onClose }: { name: string; onClose: () => void }) {
143
+ useBackToClose(true, onClose)
144
+ return <div>{name}</div>
145
+ }
146
+
147
+ describe('Back gesture', () => {
148
+ it('closes only the top layer, and gives its history entry back when closed another way', async () => {
149
+ vi.useFakeTimers()
150
+ const start = window.history.length
151
+ const closeDrawer = vi.fn()
152
+ const closeSettings = vi.fn()
153
+ const { rerender } = render(<Layer name="drawer" onClose={closeDrawer} />)
154
+ await act(async () => { vi.advanceTimersByTime(1) })
155
+ rerender(<><Layer name="drawer" onClose={closeDrawer} /><Layer name="settings" onClose={closeSettings} /></>)
156
+ await act(async () => { vi.advanceTimersByTime(1) })
157
+ expect(window.history.length).toBe(start + 2)
158
+ expect(window.history.state.hpLayer).toBe(2)
159
+
160
+ // Back from settings: lands on the drawer's entry.
161
+ await act(async () => {
162
+ window.dispatchEvent(new PopStateEvent('popstate', { state: { hpLayer: 1 } }))
163
+ })
164
+ expect(closeSettings).toHaveBeenCalledTimes(1)
165
+ expect(closeDrawer).not.toHaveBeenCalled()
166
+ })
167
+ })
168
+
169
+ describe('StatusText under rapid changes', () => {
170
+ it('never shows two labels when the status flips faster than it can animate', () => {
171
+ const { rerender } = render(<StatusText text="Listening for voice..." working={false} />)
172
+ for (let i = 0; i < 6; i++) {
173
+ rerender(<StatusText text={i % 2 ? 'Listening for voice...' : 'Listening...'} working={false} />)
174
+ }
175
+ expect(document.querySelectorAll('.hp-status-swap__out')).toHaveLength(0)
176
+ expect(screen.getByRole('status').textContent).toBe('Listening for voice...')
177
+ })
178
+ })
frontend/src/ui/AboutDialog.tsx CHANGED
@@ -3,6 +3,7 @@
3
  *
4
  * Displays version, author, and project links in a centered modal overlay.
5
  */
 
6
  import React, { useEffect, useRef } from 'react'
7
  import { X, ExternalLink, Github, Globe, Heart, Activity } from 'lucide-react'
8
 
@@ -23,6 +24,9 @@ export default function AboutDialog({ onClose, onOpenSystemStatus }: AboutDialog
23
  return () => document.removeEventListener('keydown', handler)
24
  }, [onClose])
25
 
 
 
 
26
  // Close on backdrop click
27
  const handleBackdropClick = (e: React.MouseEvent) => {
28
  if (e.target === e.currentTarget) onClose()
 
3
  *
4
  * Displays version, author, and project links in a centered modal overlay.
5
  */
6
+ import { useBackToClose } from './lib/useBackToClose'
7
  import React, { useEffect, useRef } from 'react'
8
  import { X, ExternalLink, Github, Globe, Heart, Activity } from 'lucide-react'
9
 
 
24
  return () => document.removeEventListener('keydown', handler)
25
  }, [onClose])
26
 
27
+ // The phone's Back gesture closes the dialog instead of leaving HomePilot.
28
+ useBackToClose(true, onClose)
29
+
30
  // Close on backdrop click
31
  const handleBackdropClick = (e: React.MouseEvent) => {
32
  if (e.target === e.currentTarget) onClose()
frontend/src/ui/App.tsx CHANGED
@@ -59,6 +59,8 @@ import { isDeafTurn, planSttRecovery } from './media/sttTurnHealth'
59
  import { getDefaultBackendUrl, resolveBackendUrl } from './lib/backendUrl'
60
  import { visionErrorMessage } from './lib/visionError'
61
  import { modeGroup, resetsConversation } from './lib/modeGroups'
 
 
62
  // Account & Computers header pill (Batch 4) β€” ADDITIVE; renders null when the
63
  // Account & Computers flag is off, so the header is unchanged by default.
64
  import { ComputerStatusPill } from './account/ComputerStatusPill'
@@ -513,6 +515,9 @@ function NavItem({
513
  <button
514
  onClick={disabled ? undefined : onClick}
515
  aria-disabled={disabled || undefined}
 
 
 
516
  className={[
517
  // hp-nav-item: min 44px tall on coarse pointers (see styles.css) β€”
518
  // WCAG touch target β€” while desktop keeps the dense 36px rows.
@@ -534,7 +539,7 @@ function NavItem({
534
  </span>
535
  {!collapsed && <span className="truncate">{label}</span>}
536
  {!collapsed && shortcut ? (
537
- <span className="absolute top-1/2 right-2 -translate-y-1/2 text-xs text-white/40 opacity-0 group-hover/menu-item:opacity-100 transition-opacity duration-100">
538
  {shortcut}
539
  </span>
540
  ) : null}
@@ -1080,6 +1085,16 @@ function cleanConversationTitle(raw: string): string {
1080
  // Not valid JSON β€” use the raw string.
1081
  }
1082
  }
 
 
 
 
 
 
 
 
 
 
1083
  return text.replace(/\s+/g, ' ').trim() || 'Conversation'
1084
  }
1085
 
@@ -1133,8 +1148,9 @@ function SidebarRecents({
1133
  <button
1134
  key={c.conversation_id}
1135
  type="button"
 
1136
  className={[
1137
- 'block w-full text-left px-3 py-1.5 text-[13px] rounded-lg transition-colors whitespace-nowrap overflow-hidden text-ellipsis',
1138
  isActive
1139
  ? 'bg-white/10 text-white'
1140
  : 'text-white/70 hover:bg-white/5 hover:text-white',
@@ -1188,7 +1204,7 @@ function SidebarRecents({
1188
  <div className="px-3 pt-1">
1189
  <button
1190
  type="button"
1191
- className="text-[12px] text-white/40 hover:text-white transition-colors"
1192
  onClick={onViewAll}
1193
  >
1194
  View all history β†’
@@ -1218,6 +1234,7 @@ function Sidebar({
1218
  onToggleCollapse,
1219
  chatReasoningMode,
1220
  showMeetingsNav = false,
 
1221
  }: {
1222
  mode: Mode
1223
  setMode: (m: Mode) => void
@@ -1241,6 +1258,8 @@ function Sidebar({
1241
  chatReasoningMode: ChatReasoningMode
1242
  /** MS28 `_CATALOG`, default off. Absent renders the sidebar unchanged. */
1243
  showMeetingsNav?: boolean
 
 
1244
  }) {
1245
  const [showAccountMenu, setShowAccountMenu] = useState(false)
1246
  const [showProfileModal, setShowProfileModal] = useState(false)
@@ -1343,8 +1362,19 @@ function Sidebar({
1343
  </span>
1344
  )}
1345
  </div>
1346
- {/* Collapse toggle */}
1347
- {!collapsed && (
 
 
 
 
 
 
 
 
 
 
 
1348
  <button
1349
  type="button"
1350
  onClick={onToggleCollapse}
@@ -1388,7 +1418,7 @@ function Sidebar({
1388
  >
1389
  <Search size={16} className="group-hover:text-white/80 transition-colors" />
1390
  <span className="group-hover:text-white/80 transition-colors">Search chats</span>
1391
- <span className="ml-auto text-xs opacity-40 bg-white/5 px-1.5 py-0.5 rounded border border-white/10">
1392
  Ctrl+K
1393
  </span>
1394
  </button>
@@ -1459,7 +1489,7 @@ function Sidebar({
1459
  <div className="px-1.5 pt-1">
1460
  <button
1461
  type="button"
1462
- className="w-full text-left px-3 py-2 text-[13px] text-white/70 hover:bg-white/5 hover:text-white rounded-xl truncate transition-colors"
1463
  onClick={onNewConversation}
1464
  >
1465
  New conversation
@@ -1530,40 +1560,50 @@ function Sidebar({
1530
  />
1531
  )}
1532
 
1533
- {/* Profile & Personalization modal (opened from account menu) */}
1534
- {showProfileModal && (
1535
- <ProfileSettingsModal
1536
- backendUrl={settingsDraft.backendUrl}
1537
- apiKey={settingsDraft.apiKey}
1538
- nsfwMode={!!settingsDraft.nsfwMode}
1539
- onClose={() => setShowProfileModal(false)}
1540
- />
1541
- )}
 
 
 
 
 
 
 
1542
 
1543
- {showSettings ? (
1544
- <SettingsPanel
1545
- value={settingsDraft}
1546
- onChangeDraft={(next) => setSettingsDraft(next)}
1547
- onSave={onSaveSettings}
1548
- onClose={() => setShowSettings(false)}
1549
- />
1550
- ) : null}
1551
 
1552
- {/* About HomePilot dialog */}
1553
- {showAboutDialog && (
1554
- <AboutDialog
1555
- onClose={() => setShowAboutDialog(false)}
1556
- onOpenSystemStatus={() => setShowSystemStatus(true)}
1557
- />
1558
- )}
1559
 
1560
- {/* System Status dashboard */}
1561
- {showSystemStatus && (
1562
- <SystemStatusDialog
1563
- backendUrl={settingsDraft.backendUrl}
1564
- apiKey={settingsDraft.apiKey}
1565
- onClose={() => setShowSystemStatus(false)}
1566
- />
 
 
 
1567
  )}
1568
  </aside>
1569
  )
@@ -1623,6 +1663,30 @@ function QueryBar({
1623
  // ---- Drag-and-drop image support ----
1624
  const [isDragging, setIsDragging] = useState(false)
1625
  const dragCounterRef = useRef(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1626
 
1627
  const handleDragEnter = useCallback((e: React.DragEvent) => {
1628
  e.preventDefault()
@@ -2150,9 +2214,10 @@ function QueryBar({
2150
  ref={modeButtonRef}
2151
  type="button"
2152
  onClick={() => setModeMenuOpen((v) => !v)}
2153
- className="h-10 px-3 rounded-full bg-white/5 border border-white/10 text-white/90 text-sm font-medium inline-flex items-center gap-1.5 hover:bg-white/10 transition-colors"
2154
  aria-haspopup="menu"
2155
  aria-expanded={modeMenuOpen}
 
2156
  title="Expert mode selector"
2157
  >
2158
  <span className="hidden sm:inline">{CHAT_REASONING_OPTIONS.find((m) => m.id === chatReasoningMode)?.label ?? 'Persona'}</span>
@@ -2226,7 +2291,7 @@ function QueryBar({
2226
  type="button"
2227
  disabled
2228
  data-testid="composer-mic"
2229
- className="h-10 w-10 rounded-full grid place-items-center bg-white/10 text-white/60 cursor-wait"
2230
  aria-label="Transcribing"
2231
  title="Transcribing…"
2232
  >
@@ -2238,8 +2303,8 @@ function QueryBar({
2238
  data-testid="composer-mic"
2239
  className={
2240
  isListening
2241
- ? 'h-10 w-10 rounded-full grid place-items-center bg-red-500/20 text-red-400 ring-2 ring-red-500/60 animate-pulse transition-colors'
2242
- : 'h-10 w-10 rounded-full bg-white/5 text-white/60 grid place-items-center hover:bg-white/10 hover:text-white transition-colors'
2243
  }
2244
  aria-label={isListening ? 'Stop recording' : 'Voice input'}
2245
  title={isListening ? 'Stop recording' : 'Voice input'}
@@ -2252,7 +2317,7 @@ function QueryBar({
2252
  <button
2253
  type="button"
2254
  onClick={onSend}
2255
- className="h-10 w-10 rounded-full bg-white text-black grid place-items-center hover:opacity-90 transition-opacity"
2256
  aria-label="Submit"
2257
  title="Submit"
2258
  >
@@ -2311,6 +2376,7 @@ function QueryBar({
2311
  screens and the full amount from `sm:` up. */}
2312
  <div className="ps-12 pe-24 sm:pe-72">
2313
  <textarea
 
2314
  value={input}
2315
  onChange={(e) => setInput(e.target.value)}
2316
  onPaste={handlePaste}
@@ -2329,9 +2395,9 @@ function QueryBar({
2329
  }
2330
  }}
2331
  rows={1}
2332
- placeholder={pendingPreviewUrl ? 'Describe what you want to do with this image…' : (placeholderOverride ?? modeHint(mode))}
2333
  className={[
2334
- 'w-full bg-transparent text-white placeholder:text-white/55',
2335
  'focus:outline-none resize-none',
2336
  'min-h-14 py-4 px-2',
2337
  'max-h-[400px] overflow-y-auto',
@@ -2413,26 +2479,14 @@ function EmptyState({
2413
  }
2414
 
2415
  function AssistantSkeleton({ label }: { label?: string }) {
2416
- // Grok-style "thinking" indicator: three dots that bounce in sequence.
2417
- // Used for every pending assistant turn (legacy chat, Expert, Agent).
2418
- // Staggered animationDelay gives the wave effect without custom keyframes.
 
2419
  return (
2420
- <div className="flex items-center gap-3">
2421
- <div className="flex items-end gap-1.5 h-4" aria-label="Thinking">
2422
- <span
2423
- className="w-1.5 h-1.5 rounded-full bg-white/70 animate-bounce"
2424
- style={{ animationDelay: '0ms', animationDuration: '1.2s' }}
2425
- />
2426
- <span
2427
- className="w-1.5 h-1.5 rounded-full bg-white/70 animate-bounce"
2428
- style={{ animationDelay: '150ms', animationDuration: '1.2s' }}
2429
- />
2430
- <span
2431
- className="w-1.5 h-1.5 rounded-full bg-white/70 animate-bounce"
2432
- style={{ animationDelay: '300ms', animationDuration: '1.2s' }}
2433
- />
2434
- </div>
2435
- <span className="text-sm text-white/60">{label ?? 'Thinking'}</span>
2436
  </div>
2437
  )
2438
  }
@@ -2506,7 +2560,27 @@ function ChatState({
2506
  allowPersonaMode: boolean
2507
  }) {
2508
  const { copied, copy } = useCopyMessage()
 
2509
  const displayMessages = useMemo(() => collapseCallTurns(messages), [messages])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2510
  const enterpriseCallRow = useEnterpriseCallRow()
2511
  const [chatSettingsOpen, setChatSettingsOpen] = useState(false)
2512
  const handleStartCall = useCallback(() => {
@@ -2554,7 +2628,10 @@ function ChatState({
2554
  with the other utility icons and only opens a dedicated call
2555
  overlay (see CallOverlay). Call mode is a distinct session,
2556
  not a styling of the header. */}
2557
- <div className="fixed top-3 right-5 z-50">
 
 
 
2558
  <div className="relative flex items-center gap-2">
2559
  {/* Live computer/presence pill (Batch 4). Null unless the Account &
2560
  Computers flag is on and a computer exists. */}
@@ -2565,7 +2642,7 @@ function ChatState({
2565
  type="button"
2566
  onClick={handleStartCall}
2567
  className={[
2568
- 'w-9 h-9 flex items-center justify-center rounded-full border',
2569
  'bg-white/5 border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors',
2570
  ].join(' ')}
2571
  title="Start call"
@@ -2583,7 +2660,7 @@ function ChatState({
2583
  <button
2584
  type="button"
2585
  onClick={() => setChatSettingsOpen((v) => !v)}
2586
- className="w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
2587
  title="Chat settings"
2588
  aria-label="Chat settings"
2589
  >
@@ -2592,7 +2669,7 @@ function ChatState({
2592
  <button
2593
  type="button"
2594
  onClick={onNewConversation}
2595
- className="w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
2596
  title="New Chat"
2597
  aria-label="New Chat"
2598
  >
@@ -2622,11 +2699,11 @@ function ChatState({
2622
  </div>
2623
  )}
2624
 
2625
- <div className={`flex-1 overflow-y-auto px-4 ${chatSettings.incognito ? 'pt-3' : 'pt-14'} pb-8 space-y-8`}>
2626
  {displayMessages.map((m) => (
2627
  <div
2628
  key={m.id}
2629
- className={`flex gap-5 ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
2630
  >
2631
  {/* Inline call-event render. Enterprise mode (default)
2632
  uses the thin CallEventRow β€” a centered timeline
@@ -2670,14 +2747,14 @@ function ChatState({
2670
  )
2671
  ) : (<>
2672
  {m.role === 'assistant' ? (
2673
- <div className="w-8 h-8 rounded-full bg-white text-black flex items-center justify-center flex-shrink-0 font-bold text-sm mt-1">
2674
  /
2675
  </div>
2676
  ) : null}
2677
 
2678
  {/* User: bubble | Assistant: bare text on background (Grok style) */}
2679
  {m.role === 'user' ? (
2680
- <div className="max-w-[85%] bg-white/10 border border-white/10 rounded-3xl px-5 py-4">
2681
  {m.media?.images?.length ? (
2682
  <div className="flex gap-2 mb-2">
2683
  {m.media.images.map((src: string, i: number) => {
@@ -2699,18 +2776,28 @@ function ChatState({
2699
  </div>
2700
  </div>
2701
  ) : (
2702
- <div className="max-w-[85%]">
2703
  <div className="text-[16px] leading-relaxed text-[#EEE] font-normal tracking-wide">
2704
  {m.pending ? (
2705
  <AssistantSkeleton label={m.text?.trim() ? m.text : undefined} />
2706
  ) : (
2707
- <div className="animate-fadeIn">
2708
- <MessageMarkdown
2709
  text={m.media?.images?.length
2710
  ? (m.text || '').replace(/!\[[^\]]*\]\(media:\/\/[^)]+\)\s*/g, '').trim()
2711
  : m.text}
2712
- onImageClick={setLightbox}
2713
- backendUrl={backendUrl}
 
 
 
 
 
 
 
 
 
 
2714
  />
2715
  </div>
2716
  )}
@@ -2718,22 +2805,24 @@ function ChatState({
2718
 
2719
  {/* Grok-style icon action row */}
2720
  {!m.pending ? (
2721
- <div className="mt-2 flex items-center gap-1 text-white/40">
2722
  <button
2723
  type="button"
2724
- onClick={() => copy(m.text || '')}
2725
- className="p-1.5 rounded-full hover:bg-white/10 hover:text-white transition-colors"
2726
- title={copied ? 'Copied!' : 'Copy'}
 
2727
  >
2728
- <Copy size={16} />
2729
  </button>
2730
 
2731
  {m.error && m.retry ? (
2732
  <button
2733
  type="button"
2734
  onClick={() => onRetryMessage(m.id)}
2735
- className="p-1.5 rounded-full hover:bg-white/10 hover:text-white transition-colors"
2736
  title="Retry"
 
2737
  >
2738
  <RotateCw size={16} />
2739
  </button>
@@ -2835,7 +2924,7 @@ function ChatState({
2835
  <div ref={endRef} className="h-6" />
2836
  </div>
2837
 
2838
- <div className="bg-black/95 backdrop-blur-sm pb-4">
2839
  <div className="px-4">
2840
  <QueryBar
2841
  centered={false}
@@ -2857,7 +2946,7 @@ function ChatState({
2857
  {/* MS32. Nothing but the disclaimer lives under the composer. The meeting control
2858
  moved to the header cluster above; its setup copy is contextual now and its
2859
  configuration lives in Settings β†’ Voice Assistant. Chat first. */}
2860
- <div className="text-center text-[11px] text-white/25 pt-4 pb-0.5">
2861
  HomePilot can make mistakes. Verify important information.
2862
  </div>
2863
  </div>
@@ -2871,18 +2960,20 @@ function uuid() {
2871
  return crypto.randomUUID()
2872
  }
2873
 
2874
- function modeHint(mode: Mode) {
 
 
2875
  switch (mode) {
2876
  case 'chat':
2877
- return 'What do you want to know?'
2878
  case 'imagine':
2879
- return 'Describe an image to generate...'
2880
  case 'edit':
2881
- return 'Upload an image or describe edits...'
2882
  case 'animate':
2883
- return 'Upload an image or describe motion...'
2884
  default:
2885
- return 'What do you want to know?'
2886
  }
2887
  }
2888
 
@@ -3204,7 +3295,8 @@ export default function App() {
3204
  })
3205
 
3206
  // ── Mobile sidebar: auto-hide on small screens, show as overlay ──
3207
- const [isMobile, setIsMobile] = useState(false)
 
3208
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
3209
 
3210
  useEffect(() => {
@@ -3219,6 +3311,67 @@ export default function App() {
3219
  if (isMobile) setMobileSidebarOpen(false)
3220
  }, [mode, isMobile])
3221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3222
 
3223
  const toggleSidebar = useCallback(() => {
3224
  if (isMobile) {
@@ -5843,20 +5996,31 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
5843
  onOpenConversation={(cid) => { void loadConversation(cid); setMode('chat') }}
5844
  >
5845
  <div className="hp-app-shell flex bg-black text-white font-sans selection:bg-white/20 overflow-hidden relative">
5846
- {/* ── Mobile sidebar overlay ── */}
5847
- {isMobile && mobileSidebarOpen && (
 
 
 
 
 
5848
  <div
5849
- className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
 
5850
  onClick={() => setMobileSidebarOpen(false)}
5851
  aria-hidden
5852
  />
5853
  )}
5854
  <div
 
5855
  className={
5856
  isMobile
5857
- ? `fixed inset-y-0 left-0 z-50 transition-transform duration-200 pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
5858
  : ''
5859
  }
 
 
 
 
5860
  >
5861
  <Sidebar
5862
  mode={mode}
@@ -5864,8 +6028,8 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
5864
  messages={messages}
5865
  conversations={conversations}
5866
  activeConversationId={conversationId}
5867
- onLoadConversation={loadConversation}
5868
- onNewConversation={onNewConversation}
5869
  onScrollToBottom={onScrollToBottom}
5870
  showSettings={showSettings}
5871
  setShowSettings={setShowSettings}
@@ -5873,11 +6037,12 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
5873
  setSettingsDraft={setSettingsDraft}
5874
  onSaveSettings={onSaveSettings}
5875
  showHistory={showHistory}
5876
- setShowHistory={setShowHistory}
5877
  collapsed={isMobile ? false : sidebarCollapsed}
5878
  onToggleCollapse={toggleSidebar}
5879
  chatReasoningMode={chatReasoningMode}
5880
  showMeetingsNav={meetingsNavEnabled}
 
5881
  />
5882
  </div>
5883
 
@@ -5889,10 +6054,12 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
5889
  the top-right gear/pencil actions. */}
5890
  {isMobile && !mobileSidebarOpen && (
5891
  <button
 
5892
  type="button"
5893
  onClick={() => setMobileSidebarOpen(true)}
5894
- className="fixed top-[max(0.6rem,env(safe-area-inset-top))] left-[max(0.6rem,env(safe-area-inset-left))] z-40 flex h-10 w-10 items-center justify-center rounded-xl bg-black/45 backdrop-blur-md border border-white/15 text-white/85 shadow-lg shadow-black/40 hover:bg-black/60 active:scale-95 transition-all"
5895
  aria-label="Open menu"
 
5896
  >
5897
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
5898
  <line x1="3" y1="6" x2="21" y2="6" />
@@ -5902,7 +6069,7 @@ ${personalityPrompt || 'You are a friendly voice assistant. Be helpful and warm.
5902
  </button>
5903
  )}
5904
 
5905
- <main className="flex-1 flex flex-col relative min-w-0">
5906
  {/* History Panel */}
5907
  {showHistory && (
5908
  <HistoryPanel
 
59
  import { getDefaultBackendUrl, resolveBackendUrl } from './lib/backendUrl'
60
  import { visionErrorMessage } from './lib/visionError'
61
  import { modeGroup, resetsConversation } from './lib/modeGroups'
62
+ import { useBackToClose } from './lib/useBackToClose'
63
+ import { LoadingDots, StatusText, StreamReveal, motionAllowed, useMotionPrefs } from './motion'
64
  // Account & Computers header pill (Batch 4) β€” ADDITIVE; renders null when the
65
  // Account & Computers flag is off, so the header is unchanged by default.
66
  import { ComputerStatusPill } from './account/ComputerStatusPill'
 
515
  <button
516
  onClick={disabled ? undefined : onClick}
517
  aria-disabled={disabled || undefined}
518
+ aria-current={active ? 'page' : undefined}
519
+ aria-label={collapsed ? label : undefined}
520
+ aria-keyshortcuts={shortcut ? shortcut.replace('Ctrl', 'Control') : undefined}
521
  className={[
522
  // hp-nav-item: min 44px tall on coarse pointers (see styles.css) β€”
523
  // WCAG touch target β€” while desktop keeps the dense 36px rows.
 
539
  </span>
540
  {!collapsed && <span className="truncate">{label}</span>}
541
  {!collapsed && shortcut ? (
542
+ <span aria-hidden className="hp-kbd-hint absolute top-1/2 right-2 -translate-y-1/2 text-xs text-white/40 opacity-0 group-hover/menu-item:opacity-100 transition-opacity duration-100">
543
  {shortcut}
544
  </span>
545
  ) : null}
 
1085
  // Not valid JSON β€” use the raw string.
1086
  }
1087
  }
1088
+ // A title is plain text: drop Markdown markers (**bold**, `code`, # headings,
1089
+ // > quotes, list bullets, link targets) that would otherwise show literally.
1090
+ text = text
1091
+ .replace(/```[a-z0-9_-]*\n?/gi, '')
1092
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
1093
+ // Only paired markers, so snake_case names and arithmetic stay intact.
1094
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1095
+ .replace(/\*(\S(?:[^*]*\S)?)\*/g, '$1')
1096
+ .replace(/`([^`]+)`/g, '$1')
1097
+ .replace(/^\s*(#{1,6}|>|[-*+]|\d+\.)\s+/gm, '')
1098
  return text.replace(/\s+/g, ' ').trim() || 'Conversation'
1099
  }
1100
 
 
1148
  <button
1149
  key={c.conversation_id}
1150
  type="button"
1151
+ aria-current={isActive ? 'true' : undefined}
1152
  className={[
1153
+ 'hp-touch-row block w-full text-left px-3 py-1.5 text-[13px] rounded-lg transition-colors whitespace-nowrap overflow-hidden text-ellipsis',
1154
  isActive
1155
  ? 'bg-white/10 text-white'
1156
  : 'text-white/70 hover:bg-white/5 hover:text-white',
 
1204
  <div className="px-3 pt-1">
1205
  <button
1206
  type="button"
1207
+ className="hp-touch-row text-[12px] text-white/55 hover:text-white transition-colors"
1208
  onClick={onViewAll}
1209
  >
1210
  View all history β†’
 
1234
  onToggleCollapse,
1235
  chatReasoningMode,
1236
  showMeetingsNav = false,
1237
+ drawer = false,
1238
  }: {
1239
  mode: Mode
1240
  setMode: (m: Mode) => void
 
1258
  chatReasoningMode: ChatReasoningMode
1259
  /** MS28 `_CATALOG`, default off. Absent renders the sidebar unchanged. */
1260
  showMeetingsNav?: boolean
1261
+ /** Rendered as the phone's off-canvas drawer: the collapse control closes it. */
1262
+ drawer?: boolean
1263
  }) {
1264
  const [showAccountMenu, setShowAccountMenu] = useState(false)
1265
  const [showProfileModal, setShowProfileModal] = useState(false)
 
1362
  </span>
1363
  )}
1364
  </div>
1365
+ {/* Collapse toggle β€” on phones it closes the drawer */}
1366
+ {!collapsed && drawer && (
1367
+ <button
1368
+ type="button"
1369
+ onClick={onToggleCollapse}
1370
+ aria-label="Close menu"
1371
+ title="Close menu"
1372
+ className="-mr-1.5 h-11 w-11 rounded-xl grid place-items-center text-white/60 hover:text-white hover:bg-white/[0.06]"
1373
+ >
1374
+ <X size={20} strokeWidth={1.8} />
1375
+ </button>
1376
+ )}
1377
+ {!collapsed && !drawer && (
1378
  <button
1379
  type="button"
1380
  onClick={onToggleCollapse}
 
1418
  >
1419
  <Search size={16} className="group-hover:text-white/80 transition-colors" />
1420
  <span className="group-hover:text-white/80 transition-colors">Search chats</span>
1421
+ <span aria-hidden className="hp-kbd-hint ml-auto text-xs opacity-40 bg-white/5 px-1.5 py-0.5 rounded border border-white/10">
1422
  Ctrl+K
1423
  </span>
1424
  </button>
 
1489
  <div className="px-1.5 pt-1">
1490
  <button
1491
  type="button"
1492
+ className="hp-touch-row w-full text-left px-3 py-2 text-[13px] text-white/70 hover:bg-white/5 hover:text-white rounded-xl truncate transition-colors"
1493
  onClick={onNewConversation}
1494
  >
1495
  New conversation
 
1560
  />
1561
  )}
1562
 
1563
+ {/* Dialogs opened from the sidebar render at <body>. On phones the sidebar
1564
+ is a drawer that slides in with a CSS transform, and a transformed
1565
+ ancestor becomes the containing block for `position: fixed` β€” so a
1566
+ "full-screen" dialog rendered here was squeezed into the drawer's
1567
+ width. Portalling makes the drawer irrelevant to their layout. */}
1568
+ {createPortal(
1569
+ <>
1570
+ {/* Profile & Personalization modal (opened from account menu) */}
1571
+ {showProfileModal && (
1572
+ <ProfileSettingsModal
1573
+ backendUrl={settingsDraft.backendUrl}
1574
+ apiKey={settingsDraft.apiKey}
1575
+ nsfwMode={!!settingsDraft.nsfwMode}
1576
+ onClose={() => setShowProfileModal(false)}
1577
+ />
1578
+ )}
1579
 
1580
+ {showSettings ? (
1581
+ <SettingsPanel
1582
+ value={settingsDraft}
1583
+ onChangeDraft={(next) => setSettingsDraft(next)}
1584
+ onSave={onSaveSettings}
1585
+ onClose={() => setShowSettings(false)}
1586
+ />
1587
+ ) : null}
1588
 
1589
+ {/* About HomePilot dialog */}
1590
+ {showAboutDialog && (
1591
+ <AboutDialog
1592
+ onClose={() => setShowAboutDialog(false)}
1593
+ onOpenSystemStatus={() => setShowSystemStatus(true)}
1594
+ />
1595
+ )}
1596
 
1597
+ {/* System Status dashboard */}
1598
+ {showSystemStatus && (
1599
+ <SystemStatusDialog
1600
+ backendUrl={settingsDraft.backendUrl}
1601
+ apiKey={settingsDraft.apiKey}
1602
+ onClose={() => setShowSystemStatus(false)}
1603
+ />
1604
+ )}
1605
+ </>,
1606
+ document.body,
1607
  )}
1608
  </aside>
1609
  )
 
1663
  // ---- Drag-and-drop image support ----
1664
  const [isDragging, setIsDragging] = useState(false)
1665
  const dragCounterRef = useRef(0)
1666
+ // Composer grows with what is typed (up to the 400px cap) instead of
1667
+ // scrolling inside a one-line box; resets when the message is sent.
1668
+ const composerRef = useRef<HTMLTextAreaElement>(null)
1669
+ // The short hint is chosen by the field's own width, not the window's: in a
1670
+ // landscape phone frame the sidebar is showing and the field is narrow even
1671
+ // though the window is wide. Window width is the fallback without ResizeObserver.
1672
+ const [compactHint, setCompactHint] = useState(() => typeof window !== 'undefined' && window.innerWidth < 480)
1673
+ useEffect(() => {
1674
+ const el = composerRef.current
1675
+ if (el && typeof ResizeObserver !== 'undefined') {
1676
+ const ro = new ResizeObserver(() => setCompactHint(el.clientWidth > 0 && el.clientWidth < 280))
1677
+ ro.observe(el)
1678
+ return () => ro.disconnect()
1679
+ }
1680
+ const onResize = () => setCompactHint(window.innerWidth < 480)
1681
+ window.addEventListener('resize', onResize)
1682
+ return () => window.removeEventListener('resize', onResize)
1683
+ }, [])
1684
+ React.useLayoutEffect(() => {
1685
+ const el = composerRef.current
1686
+ if (!el) return
1687
+ el.style.height = 'auto'
1688
+ el.style.height = `${Math.min(el.scrollHeight, 400)}px`
1689
+ }, [input])
1690
 
1691
  const handleDragEnter = useCallback((e: React.DragEvent) => {
1692
  e.preventDefault()
 
2214
  ref={modeButtonRef}
2215
  type="button"
2216
  onClick={() => setModeMenuOpen((v) => !v)}
2217
+ className="hp-icon-btn h-10 px-3 rounded-full bg-white/5 border border-white/10 text-white/90 text-sm font-medium inline-flex items-center gap-1.5 hover:bg-white/10 transition-colors"
2218
  aria-haspopup="menu"
2219
  aria-expanded={modeMenuOpen}
2220
+ aria-label={`Response mode: ${CHAT_REASONING_OPTIONS.find((m) => m.id === chatReasoningMode)?.label ?? 'Persona'}`}
2221
  title="Expert mode selector"
2222
  >
2223
  <span className="hidden sm:inline">{CHAT_REASONING_OPTIONS.find((m) => m.id === chatReasoningMode)?.label ?? 'Persona'}</span>
 
2291
  type="button"
2292
  disabled
2293
  data-testid="composer-mic"
2294
+ className="hp-icon-btn h-10 w-10 rounded-full grid place-items-center bg-white/10 text-white/60 cursor-wait"
2295
  aria-label="Transcribing"
2296
  title="Transcribing…"
2297
  >
 
2303
  data-testid="composer-mic"
2304
  className={
2305
  isListening
2306
+ ? 'hp-icon-btn h-10 w-10 rounded-full grid place-items-center bg-red-500/20 text-red-400 ring-2 ring-red-500/60 animate-pulse transition-colors'
2307
+ : 'hp-icon-btn h-10 w-10 rounded-full bg-white/5 text-white/60 grid place-items-center hover:bg-white/10 hover:text-white transition-colors'
2308
  }
2309
  aria-label={isListening ? 'Stop recording' : 'Voice input'}
2310
  title={isListening ? 'Stop recording' : 'Voice input'}
 
2317
  <button
2318
  type="button"
2319
  onClick={onSend}
2320
+ className="hp-icon-btn h-10 w-10 rounded-full bg-white text-black grid place-items-center hover:opacity-90 transition-opacity"
2321
  aria-label="Submit"
2322
  title="Submit"
2323
  >
 
2376
  screens and the full amount from `sm:` up. */}
2377
  <div className="ps-12 pe-24 sm:pe-72">
2378
  <textarea
2379
+ ref={composerRef}
2380
  value={input}
2381
  onChange={(e) => setInput(e.target.value)}
2382
  onPaste={handlePaste}
 
2395
  }
2396
  }}
2397
  rows={1}
2398
+ placeholder={pendingPreviewUrl ? (compactHint ? 'What should I do with it?' : 'Describe what you want to do with this image…') : (placeholderOverride ?? modeHint(mode, compactHint))}
2399
  className={[
2400
+ 'hp-composer-input w-full bg-transparent text-white placeholder:text-white/55',
2401
  'focus:outline-none resize-none',
2402
  'min-h-14 py-4 px-2',
2403
  'max-h-[400px] overflow-y-auto',
 
2479
  }
2480
 
2481
  function AssistantSkeleton({ label }: { label?: string }) {
2482
+ // "Thinking" indicator for every pending assistant turn (legacy chat, Expert,
2483
+ // Agent): rippling dots beside a working label that shimmers, and changes
2484
+ // ("Thinking…" β†’ "Searching…" β†’ "Retrying…") slide over each other instead
2485
+ // of jumping (motion system, animations 1, 5 and 17).
2486
  return (
2487
+ <div className="flex items-center gap-3" aria-label="Thinking">
2488
+ <LoadingDots label="Thinking" className="text-white/70" />
2489
+ <StatusText text={label ?? 'Thinking'} className="text-sm" />
 
 
 
 
 
 
 
 
 
 
 
 
 
2490
  </div>
2491
  )
2492
  }
 
2560
  allowPersonaMode: boolean
2561
  }) {
2562
  const { copied, copy } = useCopyMessage()
2563
+ const [copiedId, setCopiedId] = useState<string | null>(null)
2564
  const displayMessages = useMemo(() => collapseCallTurns(messages), [messages])
2565
+ // New answers are revealed progressively (Settings β†’ Motion). Only a turn that
2566
+ // was seen waiting in this view, or that the send flow marked `animate`, is
2567
+ // revealed; history loads as it is.
2568
+ const motionPrefs = useMotionPrefs()
2569
+ const canAnimate = motionAllowed(motionPrefs)
2570
+ const seenPendingRef = useRef<Set<string>>(new Set())
2571
+ const revealedRef = useRef<Set<string>>(new Set())
2572
+ useEffect(() => {
2573
+ for (const m of messages) if (m.pending) seenPendingRef.current.add(m.id)
2574
+ }, [messages])
2575
+ const scrollerRef = useRef<HTMLDivElement>(null)
2576
+ const keepPinned = useCallback(() => {
2577
+ const el = scrollerRef.current
2578
+ if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 160) el.scrollTop = el.scrollHeight
2579
+ }, [])
2580
+ const markRevealed = useCallback((id: string) => {
2581
+ revealedRef.current.add(id)
2582
+ window.dispatchEvent(new CustomEvent('hp:messageAnimated', { detail: { id } }))
2583
+ }, [])
2584
  const enterpriseCallRow = useEnterpriseCallRow()
2585
  const [chatSettingsOpen, setChatSettingsOpen] = useState(false)
2586
  const handleStartCall = useCallback(() => {
 
2628
  with the other utility icons and only opens a dedicated call
2629
  overlay (see CallOverlay). Call mode is a distinct session,
2630
  not a styling of the header. */}
2631
+ {/* Phones: a soft scrim under the menu button and header icons so text
2632
+ scrolling past never runs underneath them. */}
2633
+ <div className="md:hidden pointer-events-none fixed inset-x-0 top-0 z-30 h-16 hp-top-scrim" aria-hidden />
2634
+ <div className="fixed top-3 right-3 sm:right-5 z-50">
2635
  <div className="relative flex items-center gap-2">
2636
  {/* Live computer/presence pill (Batch 4). Null unless the Account &
2637
  Computers flag is on and a computer exists. */}
 
2642
  type="button"
2643
  onClick={handleStartCall}
2644
  className={[
2645
+ 'hp-icon-btn w-9 h-9 flex items-center justify-center rounded-full border',
2646
  'bg-white/5 border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors',
2647
  ].join(' ')}
2648
  title="Start call"
 
2660
  <button
2661
  type="button"
2662
  onClick={() => setChatSettingsOpen((v) => !v)}
2663
+ className="hp-icon-btn w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
2664
  title="Chat settings"
2665
  aria-label="Chat settings"
2666
  >
 
2669
  <button
2670
  type="button"
2671
  onClick={onNewConversation}
2672
+ className="hp-icon-btn w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
2673
  title="New Chat"
2674
  aria-label="New Chat"
2675
  >
 
2699
  </div>
2700
  )}
2701
 
2702
+ <div ref={scrollerRef} className={`flex-1 overflow-y-auto px-4 ${chatSettings.incognito ? 'pt-3' : 'pt-14'} pb-8 space-y-8`}>
2703
  {displayMessages.map((m) => (
2704
  <div
2705
  key={m.id}
2706
+ className={`flex gap-3 sm:gap-5 hp-msg-in ${m.role === 'user' ? 'hp-msg-in--user justify-end' : 'justify-start'}`}
2707
  >
2708
  {/* Inline call-event render. Enterprise mode (default)
2709
  uses the thin CallEventRow β€” a centered timeline
 
2747
  )
2748
  ) : (<>
2749
  {m.role === 'assistant' ? (
2750
+ <div className="hidden sm:flex w-8 h-8 rounded-full bg-white text-black items-center justify-center flex-shrink-0 font-bold text-sm mt-1" aria-hidden>
2751
  /
2752
  </div>
2753
  ) : null}
2754
 
2755
  {/* User: bubble | Assistant: bare text on background (Grok style) */}
2756
  {m.role === 'user' ? (
2757
+ <div className="max-w-[85%] min-w-0 bg-white/10 border border-white/10 rounded-3xl px-4 py-3 sm:px-5 sm:py-4">
2758
  {m.media?.images?.length ? (
2759
  <div className="flex gap-2 mb-2">
2760
  {m.media.images.map((src: string, i: number) => {
 
2776
  </div>
2777
  </div>
2778
  ) : (
2779
+ <div className="min-w-0 max-w-full sm:max-w-[85%]">
2780
  <div className="text-[16px] leading-relaxed text-[#EEE] font-normal tracking-wide">
2781
  {m.pending ? (
2782
  <AssistantSkeleton label={m.text?.trim() ? m.text : undefined} />
2783
  ) : (
2784
+ <div className={motionPrefs.reveal === 'instant' ? undefined : 'animate-fadeIn'}>
2785
+ <StreamReveal
2786
  text={m.media?.images?.length
2787
  ? (m.text || '').replace(/!\[[^\]]*\]\(media:\/\/[^)]+\)\s*/g, '').trim()
2788
  : m.text}
2789
+ active={
2790
+ canAnimate &&
2791
+ motionPrefs.reveal === 'stream' &&
2792
+ !m.error &&
2793
+ (m.animate === true || seenPendingRef.current.has(m.id)) &&
2794
+ !revealedRef.current.has(m.id)
2795
+ }
2796
+ onProgress={keepPinned}
2797
+ onDone={() => markRevealed(m.id)}
2798
+ render={(visible) => (
2799
+ <MessageMarkdown text={visible} onImageClick={setLightbox} backendUrl={backendUrl} />
2800
+ )}
2801
  />
2802
  </div>
2803
  )}
 
2805
 
2806
  {/* Grok-style icon action row */}
2807
  {!m.pending ? (
2808
+ <div className="mt-2 flex items-center gap-1 text-white/55">
2809
  <button
2810
  type="button"
2811
+ onClick={() => { void copy(m.text || ''); setCopiedId(m.id) }}
2812
+ className="hp-icon-btn p-1.5 rounded-full hover:bg-white/10 hover:text-white transition-colors"
2813
+ title={copied && copiedId === m.id ? 'Copied!' : 'Copy'}
2814
+ aria-label={copied && copiedId === m.id ? 'Copied' : 'Copy message'}
2815
  >
2816
+ {copied && copiedId === m.id ? <Check size={16} className="hp-enter-fade" /> : <Copy size={16} />}
2817
  </button>
2818
 
2819
  {m.error && m.retry ? (
2820
  <button
2821
  type="button"
2822
  onClick={() => onRetryMessage(m.id)}
2823
+ className="hp-icon-btn p-1.5 rounded-full hover:bg-white/10 hover:text-white transition-colors"
2824
  title="Retry"
2825
+ aria-label="Retry"
2826
  >
2827
  <RotateCw size={16} />
2828
  </button>
 
2924
  <div ref={endRef} className="h-6" />
2925
  </div>
2926
 
2927
+ <div className="hp-composer-dock pb-4">
2928
  <div className="px-4">
2929
  <QueryBar
2930
  centered={false}
 
2946
  {/* MS32. Nothing but the disclaimer lives under the composer. The meeting control
2947
  moved to the header cluster above; its setup copy is contextual now and its
2948
  configuration lives in Settings β†’ Voice Assistant. Chat first. */}
2949
+ <div className="text-center text-[11px] text-white/50 pt-3 pb-[max(0.125rem,env(safe-area-inset-bottom))]">
2950
  HomePilot can make mistakes. Verify important information.
2951
  </div>
2952
  </div>
 
2960
  return crypto.randomUUID()
2961
  }
2962
 
2963
+ /** Composer placeholder. `compact` is for narrow phones, where the full hint
2964
+ * would be cut off mid-word inside the one-line field. */
2965
+ function modeHint(mode: Mode, compact = false) {
2966
  switch (mode) {
2967
  case 'chat':
2968
+ return compact ? 'Ask anything' : 'What do you want to know?'
2969
  case 'imagine':
2970
+ return compact ? 'Describe an image…' : 'Describe an image to generate...'
2971
  case 'edit':
2972
+ return compact ? 'Describe an edit…' : 'Upload an image or describe edits...'
2973
  case 'animate':
2974
+ return compact ? 'Describe motion…' : 'Upload an image or describe motion...'
2975
  default:
2976
+ return compact ? 'Ask anything' : 'What do you want to know?'
2977
  }
2978
  }
2979
 
 
3295
  })
3296
 
3297
  // ── Mobile sidebar: auto-hide on small screens, show as overlay ──
3298
+ // Start from the real width so phones never render one desktop frame first.
3299
+ const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768)
3300
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
3301
 
3302
  useEffect(() => {
 
3311
  if (isMobile) setMobileSidebarOpen(false)
3312
  }, [mode, isMobile])
3313
 
3314
+ // The open drawer is a modal: Escape closes it, Tab stays inside it, the page
3315
+ // behind is inert, and focus returns to the menu button when it closes.
3316
+ const drawerRef = useRef<HTMLDivElement>(null)
3317
+ const mainRef = useRef<HTMLElement>(null)
3318
+ const menuButtonRef = useRef<HTMLButtonElement>(null)
3319
+ const drawerWasOpen = useRef(false)
3320
+ const drawerOpen = isMobile && mobileSidebarOpen
3321
+ useEffect(() => {
3322
+ const main = mainRef.current
3323
+ if (!drawerOpen) {
3324
+ main?.removeAttribute('inert')
3325
+ if (drawerWasOpen.current) {
3326
+ drawerWasOpen.current = false
3327
+ // The menu button re-mounts when the drawer closes; focus it next frame
3328
+ // unless something else (a dialog opened from the drawer) took focus.
3329
+ requestAnimationFrame(() => {
3330
+ const active = document.activeElement
3331
+ if (!active || active === document.body || drawerRef.current?.contains(active)) menuButtonRef.current?.focus({ preventScroll: true })
3332
+ })
3333
+ }
3334
+ return
3335
+ }
3336
+ drawerWasOpen.current = true
3337
+ main?.setAttribute('inert', '')
3338
+ const focusables = () =>
3339
+ Array.from(
3340
+ drawerRef.current?.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])') ?? [],
3341
+ ).filter((el) => el.offsetParent !== null || el === document.activeElement)
3342
+ requestAnimationFrame(() => focusables()[0]?.focus({ preventScroll: true }))
3343
+ const onKey = (e: KeyboardEvent) => {
3344
+ if (e.key === 'Escape') {
3345
+ // A dialog opened from the drawer handles its own Escape first.
3346
+ if (document.querySelector('[role="dialog"][aria-modal="true"]:not(.hp-drawer)') && !drawerRef.current?.contains(document.activeElement)) return
3347
+ setMobileSidebarOpen(false)
3348
+ return
3349
+ }
3350
+ if (e.key !== 'Tab' || !drawerRef.current?.contains(document.activeElement)) return
3351
+ const items = focusables()
3352
+ if (!items.length) return
3353
+ const first = items[0]
3354
+ const last = items[items.length - 1]
3355
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
3356
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
3357
+ }
3358
+ window.addEventListener('keydown', onKey)
3359
+ return () => {
3360
+ window.removeEventListener('keydown', onKey)
3361
+ main?.removeAttribute('inert')
3362
+ }
3363
+ }, [drawerOpen])
3364
+ // The phone's Back gesture closes the drawer instead of leaving HomePilot.
3365
+ useBackToClose(drawerOpen, () => setMobileSidebarOpen(false))
3366
+ // Wraps a drawer action so it also closes the drawer (phones).
3367
+ const closeDrawerAnd = useCallback(
3368
+ <T,>(fn: (v: T) => void) => (v: T) => {
3369
+ setMobileSidebarOpen(false)
3370
+ fn(v)
3371
+ },
3372
+ [],
3373
+ )
3374
+
3375
 
3376
  const toggleSidebar = useCallback(() => {
3377
  if (isMobile) {
 
5996
  onOpenConversation={(cid) => { void loadConversation(cid); setMode('chat') }}
5997
  >
5998
  <div className="hp-app-shell flex bg-black text-white font-sans selection:bg-white/20 overflow-hidden relative">
5999
+ {/* ── Mobile drawer ──
6000
+ On phones the sidebar is an off-canvas drawer over a fading backdrop
6001
+ (motion system, animation 21). While open it is a modal: focus moves
6002
+ in and stays in, the page behind is inert, Escape / the backdrop /
6003
+ the close button / the phone's Back gesture close it, and focus goes
6004
+ back to the menu button. Picking anything in it closes it. */}
6005
+ {isMobile && (
6006
  <div
6007
+ className="hp-drawer-backdrop fixed inset-0 z-[55] bg-black/60 backdrop-blur-sm"
6008
+ data-open={mobileSidebarOpen ? 'true' : 'false'}
6009
  onClick={() => setMobileSidebarOpen(false)}
6010
  aria-hidden
6011
  />
6012
  )}
6013
  <div
6014
+ ref={drawerRef}
6015
  className={
6016
  isMobile
6017
+ ? `hp-drawer fixed inset-y-0 left-0 z-[60] max-w-[88vw] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
6018
  : ''
6019
  }
6020
+ data-open={isMobile ? (mobileSidebarOpen ? 'true' : 'false') : undefined}
6021
+ role={isMobile ? 'dialog' : undefined}
6022
+ aria-modal={isMobile && mobileSidebarOpen ? true : undefined}
6023
+ aria-label={isMobile ? 'Menu' : undefined}
6024
  >
6025
  <Sidebar
6026
  mode={mode}
 
6028
  messages={messages}
6029
  conversations={conversations}
6030
  activeConversationId={conversationId}
6031
+ onLoadConversation={isMobile ? (id) => { setMobileSidebarOpen(false); loadConversation(id) } : loadConversation}
6032
+ onNewConversation={isMobile ? () => { setMobileSidebarOpen(false); onNewConversation() } : onNewConversation}
6033
  onScrollToBottom={onScrollToBottom}
6034
  showSettings={showSettings}
6035
  setShowSettings={setShowSettings}
 
6037
  setSettingsDraft={setSettingsDraft}
6038
  onSaveSettings={onSaveSettings}
6039
  showHistory={showHistory}
6040
+ setShowHistory={isMobile ? closeDrawerAnd(setShowHistory) : setShowHistory}
6041
  collapsed={isMobile ? false : sidebarCollapsed}
6042
  onToggleCollapse={toggleSidebar}
6043
  chatReasoningMode={chatReasoningMode}
6044
  showMeetingsNav={meetingsNavEnabled}
6045
+ drawer={isMobile}
6046
  />
6047
  </div>
6048
 
 
6054
  the top-right gear/pencil actions. */}
6055
  {isMobile && !mobileSidebarOpen && (
6056
  <button
6057
+ ref={menuButtonRef}
6058
  type="button"
6059
  onClick={() => setMobileSidebarOpen(true)}
6060
+ className="fixed top-[max(0.6rem,env(safe-area-inset-top))] left-[max(0.6rem,env(safe-area-inset-left))] z-40 flex h-11 w-11 items-center justify-center rounded-xl bg-black/45 backdrop-blur-md border border-white/15 text-white/85 shadow-lg shadow-black/40 hover:bg-black/60 active:scale-95 transition-all"
6061
  aria-label="Open menu"
6062
+ aria-expanded={false}
6063
  >
6064
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
6065
  <line x1="3" y1="6" x2="21" y2="6" />
 
6069
  </button>
6070
  )}
6071
 
6072
+ <main ref={mainRef} className="flex-1 flex flex-col relative min-w-0">
6073
  {/* History Panel */}
6074
  {showHistory && (
6075
  <HistoryPanel
frontend/src/ui/Models.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useEffect, useMemo, useState, useCallback } from 'react'
 
2
  import { Download, RefreshCw, Copy, CheckCircle2, AlertTriangle, XCircle, Settings2, Key, X, Trash2, Shield, ExternalLink } from 'lucide-react'
3
  import OllaBridgeModels from './components/OllaBridgeModels'
4
  import { isAccountsUxEnabled } from './account/featureFlags'
@@ -1235,20 +1236,23 @@ export default function ModelsView(props: ModelsParams) {
1235
  }, [toast])
1236
 
1237
  return (
1238
- <div className="h-full w-full bg-black text-white overflow-hidden flex flex-col">
 
 
1239
  {/* Header */}
1240
- <div className="px-8 py-6 border-b border-white/10 flex items-center justify-between bg-gradient-to-b from-white/[0.02] to-transparent">
1241
- <div>
1242
- <div className="text-2xl font-bold text-white tracking-tight">Model Management</div>
1243
- <div className="text-sm text-white/40 mt-1">Configure and deploy AI models across providers</div>
1244
  </div>
1245
 
1246
- <div className="flex items-center gap-3">
1247
  <button
1248
  type="button"
1249
  onClick={() => refreshCatalog()}
1250
  disabled={catalogLoading}
1251
- className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold flex items-center gap-2.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
 
1252
  >
1253
  <RefreshCw size={15} className={catalogLoading ? 'animate-spin' : ''} />
1254
  Refresh Catalog
@@ -1258,7 +1262,8 @@ export default function ModelsView(props: ModelsParams) {
1258
  type="button"
1259
  onClick={() => refreshInstalled()}
1260
  disabled={installedLoading}
1261
- className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold flex items-center gap-2.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
 
1262
  >
1263
  <RefreshCw size={15} className={installedLoading ? 'animate-spin' : ''} />
1264
  Refresh Installed
@@ -1268,12 +1273,20 @@ export default function ModelsView(props: ModelsParams) {
1268
  <button
1269
  type="button"
1270
  onClick={() => setApiKeysExpanded(true)}
1271
- className="p-2.5 rounded-xl bg-transparent hover:bg-white/5 border border-transparent hover:border-white/10 text-white/40 hover:text-white/70 transition-all"
1272
  title="API Keys (for gated models)"
 
1273
  >
1274
  <Key size={16} />
1275
  </button>
1276
  </div>
 
 
 
 
 
 
 
1277
  </div>
1278
 
1279
  {/* API Keys Modal */}
@@ -1477,17 +1490,19 @@ export default function ModelsView(props: ModelsParams) {
1477
  )}
1478
 
1479
  {/* Controls */}
1480
- <div className="px-8 py-5 border-b border-white/10 bg-white/[0.01]">
1481
  <div className="grid grid-cols-1 lg:grid-cols-[200px_1fr_1fr] gap-4 max-w-7xl">
1482
- <div>
1483
- <label className="text-[10px] text-white/40 font-bold uppercase tracking-wider block mb-2.5">Model Type</label>
1484
- <div className="flex flex-col gap-1.5">
1485
  {(['chat', 'multimodal', 'image', 'edit', 'video', 'enhance', 'lora', 'addons'] as const).map((t) => (
1486
  <button
1487
  key={t}
1488
  type="button"
 
 
1489
  onClick={() => setModelType(t)}
1490
- className={`px-4 py-2.5 rounded-lg border text-xs font-bold uppercase tracking-wide transition-all ${
1491
  modelType === t
1492
  ? 'bg-white text-black border-white shadow-lg shadow-white/20'
1493
  : 'bg-transparent border-white/10 text-white/60 hover:bg-white/5 hover:border-white/20 hover:text-white/80'
@@ -1500,8 +1515,9 @@ export default function ModelsView(props: ModelsParams) {
1500
  </div>
1501
 
1502
  <div>
1503
- <label className="text-[10px] text-white/40 font-bold uppercase tracking-wider block mb-2.5">Provider</label>
1504
  <select
 
1505
  value={provider}
1506
  onChange={(e) => setProvider(e.target.value)}
1507
  className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm font-medium outline-none focus:border-white/30 focus:bg-white/10 transition-all"
@@ -1531,8 +1547,9 @@ export default function ModelsView(props: ModelsParams) {
1531
  </div>
1532
 
1533
  <div>
1534
- <label className="text-[10px] text-white/40 font-bold uppercase tracking-wider block mb-2.5">Base URL Override</label>
1535
  <input
 
1536
  value={baseUrl}
1537
  onChange={(e) => setBaseUrl(e.target.value)}
1538
  placeholder={modelType === 'chat' ? 'http://localhost:11434' : 'http://localhost:8188'}
@@ -1547,7 +1564,7 @@ export default function ModelsView(props: ModelsParams) {
1547
 
1548
  {/* Civitai Input Section (only when provider is civitai) */}
1549
  {provider === 'civitai' && (
1550
- <div className="px-8 py-4 border-b border-white/10 bg-gradient-to-br from-blue-500/5 to-blue-500/0">
1551
  <div className="max-w-7xl">
1552
  {/* Search Bar */}
1553
  <div className="mb-6">
@@ -1805,7 +1822,7 @@ export default function ModelsView(props: ModelsParams) {
1805
  )}
1806
 
1807
  {/* Body */}
1808
- <div className="flex-1 overflow-y-auto px-4 sm:px-8 py-6 scrollbar-hide">
1809
  <div className="flex flex-col gap-4 max-w-7xl mx-auto">
1810
  {/* OllaBridge provider class β€” a special provider whose models are
1811
  synced from the user's linked HomePilot / GPU nodes (relay), shown
@@ -1852,7 +1869,7 @@ export default function ModelsView(props: ModelsParams) {
1852
 
1853
  {/* Models table */}
1854
  <div className="rounded-2xl border border-white/10 overflow-hidden bg-gradient-to-b from-white/[0.02] to-transparent">
1855
- <div className="bg-white/5 px-6 py-4 flex items-center justify-between border-b border-white/10">
1856
  <div className="text-xs font-bold text-white uppercase tracking-wider">Available Models</div>
1857
  <div className="text-xs text-white/50 font-semibold">
1858
  {(() => {
@@ -1870,7 +1887,17 @@ export default function ModelsView(props: ModelsParams) {
1870
  </div>
1871
 
1872
  <div className="divide-y divide-white/5">
1873
- {merged.length === 0 ? (
 
 
 
 
 
 
 
 
 
 
1874
  <div className="p-12 text-white/50 text-sm text-center font-medium">
1875
  {provider === 'civitai' ? (
1876
  <div className="space-y-2">
 
1
  import React, { useEffect, useMemo, useState, useCallback } from 'react'
2
+ import { ProgressBar, Skeleton } from './motion'
3
  import { Download, RefreshCw, Copy, CheckCircle2, AlertTriangle, XCircle, Settings2, Key, X, Trash2, Shield, ExternalLink } from 'lucide-react'
4
  import OllaBridgeModels from './components/OllaBridgeModels'
5
  import { isAccountsUxEnabled } from './account/featureFlags'
 
1236
  }, [toast])
1237
 
1238
  return (
1239
+ // Phones: the whole page is one scroller (controls + list). Tablet/desktop
1240
+ // keep the fixed controls with the list scrolling under them.
1241
+ <div className="h-full w-full bg-black text-white overflow-y-auto overscroll-contain md:overflow-hidden flex flex-col">
1242
  {/* Header */}
1243
+ <div className="hp-page-header relative px-8 py-6 border-b border-white/10 bg-gradient-to-b from-white/[0.02] to-transparent">
1244
+ <div className="hp-page-header__titles">
1245
+ <h1 className="text-xl md:text-2xl font-bold text-white tracking-tight leading-tight">Model Management</h1>
1246
+ <div className="text-sm text-white/55 mt-1">Configure and deploy AI models across providers</div>
1247
  </div>
1248
 
1249
+ <div className="hp-page-header__actions items-center md:gap-3">
1250
  <button
1251
  type="button"
1252
  onClick={() => refreshCatalog()}
1253
  disabled={catalogLoading}
1254
+ aria-busy={catalogLoading || undefined}
1255
+ className="hp-grow-action px-3 md:px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold flex items-center justify-center gap-2 md:gap-2.5 whitespace-nowrap transition-all disabled:opacity-50 disabled:cursor-not-allowed"
1256
  >
1257
  <RefreshCw size={15} className={catalogLoading ? 'animate-spin' : ''} />
1258
  Refresh Catalog
 
1262
  type="button"
1263
  onClick={() => refreshInstalled()}
1264
  disabled={installedLoading}
1265
+ aria-busy={installedLoading || undefined}
1266
+ className="hp-grow-action px-3 md:px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold flex items-center justify-center gap-2 md:gap-2.5 whitespace-nowrap transition-all disabled:opacity-50 disabled:cursor-not-allowed"
1267
  >
1268
  <RefreshCw size={15} className={installedLoading ? 'animate-spin' : ''} />
1269
  Refresh Installed
 
1273
  <button
1274
  type="button"
1275
  onClick={() => setApiKeysExpanded(true)}
1276
+ className="hp-header-corner p-2.5 rounded-xl bg-transparent hover:bg-white/5 border border-transparent hover:border-white/10 text-white/55 hover:text-white/80 transition-all"
1277
  title="API Keys (for gated models)"
1278
+ aria-label="API keys for gated models"
1279
  >
1280
  <Key size={16} />
1281
  </button>
1282
  </div>
1283
+ {/* While either refresh runs, a thin sweep along the header's lower edge (motion system, animation 22). */}
1284
+ {catalogLoading || installedLoading ? (
1285
+ <ProgressBar
1286
+ label={catalogLoading ? 'Refreshing the model catalog' : 'Refreshing installed models'}
1287
+ className="!absolute left-0 right-0 bottom-0 !h-[2px] !rounded-none"
1288
+ />
1289
+ ) : null}
1290
  </div>
1291
 
1292
  {/* API Keys Modal */}
 
1490
  )}
1491
 
1492
  {/* Controls */}
1493
+ <div className="px-4 md:px-8 py-4 md:py-5 border-b border-white/10 bg-white/[0.01]">
1494
  <div className="grid grid-cols-1 lg:grid-cols-[200px_1fr_1fr] gap-4 max-w-7xl">
1495
+ <div className="hp-cq min-w-0">
1496
+ <div id="hp-model-type-label" className="text-[11px] text-white/55 font-bold uppercase tracking-wider block mb-2.5">Model Type</div>
1497
+ <div className="hp-choice-grid lg:!flex lg:flex-col lg:!gap-1.5" role="radiogroup" aria-labelledby="hp-model-type-label">
1498
  {(['chat', 'multimodal', 'image', 'edit', 'video', 'enhance', 'lora', 'addons'] as const).map((t) => (
1499
  <button
1500
  key={t}
1501
  type="button"
1502
+ role="radio"
1503
+ aria-checked={modelType === t}
1504
  onClick={() => setModelType(t)}
1505
+ className={`px-3 md:px-4 py-2.5 rounded-xl lg:rounded-lg border text-xs font-bold uppercase tracking-wide transition-all truncate ${
1506
  modelType === t
1507
  ? 'bg-white text-black border-white shadow-lg shadow-white/20'
1508
  : 'bg-transparent border-white/10 text-white/60 hover:bg-white/5 hover:border-white/20 hover:text-white/80'
 
1515
  </div>
1516
 
1517
  <div>
1518
+ <label htmlFor="hp-models-provider" className="text-[11px] text-white/55 font-bold uppercase tracking-wider block mb-2.5">Provider</label>
1519
  <select
1520
+ id="hp-models-provider"
1521
  value={provider}
1522
  onChange={(e) => setProvider(e.target.value)}
1523
  className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm font-medium outline-none focus:border-white/30 focus:bg-white/10 transition-all"
 
1547
  </div>
1548
 
1549
  <div>
1550
+ <label htmlFor="hp-models-base-url" className="text-[11px] text-white/55 font-bold uppercase tracking-wider block mb-2.5">Base URL Override</label>
1551
  <input
1552
+ id="hp-models-base-url"
1553
  value={baseUrl}
1554
  onChange={(e) => setBaseUrl(e.target.value)}
1555
  placeholder={modelType === 'chat' ? 'http://localhost:11434' : 'http://localhost:8188'}
 
1564
 
1565
  {/* Civitai Input Section (only when provider is civitai) */}
1566
  {provider === 'civitai' && (
1567
+ <div className="px-4 md:px-8 py-4 border-b border-white/10 bg-gradient-to-br from-blue-500/5 to-blue-500/0">
1568
  <div className="max-w-7xl">
1569
  {/* Search Bar */}
1570
  <div className="mb-6">
 
1822
  )}
1823
 
1824
  {/* Body */}
1825
+ <div className="shrink-0 md:shrink md:flex-1 md:min-h-0 md:overflow-y-auto px-4 sm:px-8 py-6 scrollbar-hide">
1826
  <div className="flex flex-col gap-4 max-w-7xl mx-auto">
1827
  {/* OllaBridge provider class β€” a special provider whose models are
1828
  synced from the user's linked HomePilot / GPU nodes (relay), shown
 
1869
 
1870
  {/* Models table */}
1871
  <div className="rounded-2xl border border-white/10 overflow-hidden bg-gradient-to-b from-white/[0.02] to-transparent">
1872
+ <div className="bg-white/5 px-4 md:px-6 py-4 flex items-center justify-between gap-3 border-b border-white/10">
1873
  <div className="text-xs font-bold text-white uppercase tracking-wider">Available Models</div>
1874
  <div className="text-xs text-white/50 font-semibold">
1875
  {(() => {
 
1887
  </div>
1888
 
1889
  <div className="divide-y divide-white/5">
1890
+ {merged.length === 0 && installedLoading ? (
1891
+ // Loading, not empty: placeholder rows instead of "No models found".
1892
+ <div className="p-4 md:p-6 space-y-4" role="status" aria-label="Loading models">
1893
+ {[0, 1, 2].map((i) => (
1894
+ <div key={i} className="flex items-center gap-4">
1895
+ <Skeleton lines={2} height={10} className="flex-1" />
1896
+ <span className="hp-skeleton h-8 w-20 shrink-0" style={{ borderRadius: 10 }} />
1897
+ </div>
1898
+ ))}
1899
+ </div>
1900
+ ) : merged.length === 0 ? (
1901
  <div className="p-12 text-white/50 text-sm text-center font-medium">
1902
  {provider === 'civitai' ? (
1903
  <div className="space-y-2">
frontend/src/ui/ProfileSettingsModal.tsx CHANGED
@@ -6,8 +6,11 @@
6
  * 2. Preferences β€” personalization, companion, content prefs, memory
7
  * 3. Integrations β€” secrets vault
8
  */
9
- import React, { useEffect, useMemo, useState } from 'react'
 
10
  import { useAuth } from './components/AuthGate'
 
 
11
  import type {
12
  UserProfile,
13
  SecretListItem,
@@ -205,6 +208,23 @@ export default function ProfileSettingsModal({
205
 
206
  const canUseBackend = useMemo(() => !!backendUrl, [backendUrl])
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  async function refreshAll() {
209
  if (!canUseBackend) return
210
  setLoading(true)
@@ -232,6 +252,7 @@ export default function ProfileSettingsModal({
232
  ? await fetchUserMemory(backendUrl, authToken)
233
  : await fetchMemory(backendUrl, apiKey)
234
  setMemory(m)
 
235
 
236
  // Load avatar from stored user data (if available)
237
  try {
@@ -271,6 +292,7 @@ export default function ProfileSettingsModal({
271
  display_name: profile.display_name,
272
  email: profile.email,
273
  })
 
274
  setSavedMsg(true)
275
  setTimeout(() => setSavedMsg(false), 2500)
276
  } catch (e: any) {
@@ -348,59 +370,100 @@ export default function ProfileSettingsModal({
348
  }
349
  }
350
 
 
 
 
 
 
 
 
 
351
  return (
352
- <div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
353
- <div className="w-full max-w-3xl rounded-3xl border border-white/10 bg-[#0b0b0b] shadow-2xl shadow-black/40 overflow-hidden">
354
- {/* Header */}
355
- <div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
356
- <div>
357
- <div className="text-white/90 font-semibold">Account Settings</div>
358
- <div className="text-xs text-white/40">
359
- Profile, preferences, integrations, and security for your account.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  </div>
361
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
362
  <button
363
  type="button"
364
- onClick={onClose}
365
- className="px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70"
 
366
  >
367
- Close
368
  </button>
369
  </div>
370
-
371
- {/* Tabs */}
372
- <div className="px-6 pt-4">
373
- <div className="inline-flex rounded-2xl bg-white/5 border border-white/10 p-1">
374
- {[
375
- { key: 'profile', label: 'Profile' },
376
- { key: 'prefs', label: 'Preferences' },
377
- { key: 'communication', label: 'Communication' },
378
- { key: 'integrations', label: 'Integrations' },
379
- { key: 'security', label: 'Security' },
380
- ].map((t) => (
381
- <button
382
- key={t.key}
383
- type="button"
384
- onClick={() => setTab(t.key as TabKey)}
385
- className={[
386
- 'px-4 py-2 rounded-2xl text-sm transition-all',
387
- tab === t.key ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80',
388
- ].join(' ')}
389
- >
390
- {t.label}
391
  </button>
392
- ))}
393
- </div>
394
- {/* Mode hint (helpful for enterprise debugging) */}
395
- <div className="ml-auto text-[10px] text-white/40 self-center pr-1">
396
- {usePerUser ? 'Per-user (Bearer)' : 'Instance-wide (API key)'}
397
- </div>
398
- </div>
399
-
400
- {/* Content */}
401
- <div className="px-6 py-5 max-h-[70vh] overflow-y-auto">
402
- {loading ? <div className="text-sm text-white/50">Loading...</div> : null}
403
- {err ? <div className="mb-4 text-sm text-red-400/80">{err}</div> : null}
404
  {savedMsg ? <div className="mb-4 text-sm text-green-400/80">Saved successfully.</div> : null}
405
 
406
  {/* ================================================================ */}
@@ -925,25 +988,7 @@ export default function ProfileSettingsModal({
925
  )
926
  ) : null}
927
  </div>
928
-
929
- {/* Footer */}
930
- <div className="px-6 py-4 border-t border-white/10 flex items-center justify-between">
931
- <div className="text-xs text-white/35">
932
- {usePerUser
933
- ? 'Profile, memory & secrets are scoped to your account.'
934
- : 'Profile stores preferences, memory, and integrations (instance-wide).'}
935
- </div>
936
- <button
937
- type="button"
938
- onClick={onSaveAll}
939
- disabled={saving}
940
- className="px-4 py-2 rounded-2xl bg-blue-600 hover:bg-blue-500 border border-white/10 text-sm text-white font-semibold disabled:opacity-50"
941
- >
942
- {saving ? 'Saving...' : 'Save All'}
943
- </button>
944
- </div>
945
- </div>
946
- </div>
947
  )
948
  }
949
 
 
6
  * 2. Preferences β€” personalization, companion, content prefs, memory
7
  * 3. Integrations β€” secrets vault
8
  */
9
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
10
+ import { X } from 'lucide-react'
11
  import { useAuth } from './components/AuthGate'
12
+ import { ModalSheet } from './components/ModalSheet'
13
+ import { PulseText, Spinner } from './motion'
14
  import type {
15
  UserProfile,
16
  SecretListItem,
 
208
 
209
  const canUseBackend = useMemo(() => !!backendUrl, [backendUrl])
210
 
211
+ // What was last loaded or saved, to tell whether closing would lose edits.
212
+ const [baseline, setBaseline] = useState<string | null>(null)
213
+ const snapshot = JSON.stringify({ profile, memory })
214
+ const dirty = !loading && baseline !== null && snapshot !== baseline
215
+ const tabsRef = useRef<HTMLDivElement>(null)
216
+
217
+ function requestClose() {
218
+ if (dirty && !window.confirm('Discard your unsaved changes?')) return
219
+ onClose()
220
+ }
221
+
222
+ // Keep the active tab visible when the tab row scrolls sideways on phones.
223
+ useEffect(() => {
224
+ const el = tabsRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')
225
+ el?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
226
+ }, [tab])
227
+
228
  async function refreshAll() {
229
  if (!canUseBackend) return
230
  setLoading(true)
 
252
  ? await fetchUserMemory(backendUrl, authToken)
253
  : await fetchMemory(backendUrl, apiKey)
254
  setMemory(m)
255
+ setBaseline(JSON.stringify({ profile: merged, memory: m }))
256
 
257
  // Load avatar from stored user data (if available)
258
  try {
 
292
  display_name: profile.display_name,
293
  email: profile.email,
294
  })
295
+ setBaseline(JSON.stringify({ profile, memory }))
296
  setSavedMsg(true)
297
  setTimeout(() => setSavedMsg(false), 2500)
298
  } catch (e: any) {
 
370
  }
371
  }
372
 
373
+ const TABS: { key: TabKey; label: string }[] = [
374
+ { key: 'profile', label: 'Profile' },
375
+ { key: 'prefs', label: 'Preferences' },
376
+ { key: 'communication', label: 'Communication' },
377
+ { key: 'integrations', label: 'Integrations' },
378
+ { key: 'security', label: 'Security' },
379
+ ]
380
+
381
  return (
382
+ <ModalSheet
383
+ labelledBy="hp-account-settings-title"
384
+ onRequestClose={requestClose}
385
+ header={
386
+ <>
387
+ <div className="flex items-center gap-3 px-4 sm:px-6 py-2 sm:py-3 border-b border-white/10">
388
+ <div className="min-w-0 flex-1">
389
+ <h2 id="hp-account-settings-title" className="text-[17px] sm:text-base text-white/90 font-semibold leading-6">
390
+ Account Settings
391
+ </h2>
392
+ <p className="hidden sm:block text-xs text-white/50">
393
+ Profile, preferences, integrations, and security for your account.
394
+ </p>
395
+ </div>
396
+ <button
397
+ type="button"
398
+ onClick={requestClose}
399
+ aria-label="Close settings"
400
+ className="shrink-0 -mr-1 h-11 w-11 inline-flex items-center justify-center rounded-full text-white/70 hover:text-white hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/60"
401
+ >
402
+ <X size={20} aria-hidden />
403
+ </button>
404
+ </div>
405
+ <div className="px-4 sm:px-6 pt-3 pb-2 border-b border-white/5">
406
+ <div ref={tabsRef} className="hp-tabs rounded-2xl bg-white/5 border border-white/10 p-1" role="tablist" aria-label="Settings sections">
407
+ {TABS.map((t) => (
408
+ <button
409
+ key={t.key}
410
+ type="button"
411
+ role="tab"
412
+ aria-selected={tab === t.key}
413
+ onClick={() => setTab(t.key)}
414
+ className={[
415
+ 'px-4 rounded-xl text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/60',
416
+ tab === t.key ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80',
417
+ ].join(' ')}
418
+ >
419
+ {t.label}
420
+ </button>
421
+ ))}
422
+ </div>
423
+ {/* Mode hint (helpful for enterprise debugging) */}
424
+ <div className="mt-1.5 text-[11px] text-white/45">
425
+ {usePerUser ? 'Per-user (Bearer)' : 'Instance-wide (API key)'}
426
  </div>
427
  </div>
428
+ </>
429
+ }
430
+ footer={
431
+ <div className="px-4 sm:px-6 pt-3 border-t border-white/10 bg-[#0b0b0b] flex items-center gap-3">
432
+ <div className="min-w-0 flex-1 text-xs text-white/50" aria-live="polite">
433
+ {saving ? <PulseText>Saving…</PulseText>
434
+ : savedMsg ? <span className="text-green-400/90 hp-enter-fade">Saved</span>
435
+ : dirty ? <span className="text-amber-300/90">Unsaved changes</span>
436
+ : <span className="hidden sm:inline">{usePerUser
437
+ ? 'Profile, memory & secrets are scoped to your account.'
438
+ : 'Profile stores preferences, memory, and integrations (instance-wide).'}</span>}
439
+ </div>
440
  <button
441
  type="button"
442
+ onClick={onSaveAll}
443
+ disabled={saving || loading}
444
+ className="shrink-0 whitespace-nowrap min-h-[44px] px-5 rounded-full bg-blue-600 hover:bg-blue-500 text-sm text-white font-semibold disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/70"
445
  >
446
+ {saving ? <span className="inline-flex items-center gap-2"><Spinner size={14} />Saving…</span> : 'Save All'}
447
  </button>
448
  </div>
449
+ }
450
+ >
451
+ <div className="px-4 sm:px-6 py-5">
452
+ {loading ? (
453
+ <div role="status" className="space-y-3" aria-label="Loading your settings">
454
+ <span className="hp-skeleton h-16 w-16" style={{ borderRadius: 999 }} />
455
+ {[0, 1, 2, 3].map((i) => <span key={i} className="hp-skeleton h-12" style={{ borderRadius: 16 }} />)}
456
+ <span className="text-sm text-white/50">Loading your settings…</span>
457
+ </div>
458
+ ) : null}
459
+ {err ? (
460
+ <div role="alert" className="mb-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200 flex items-start gap-3">
461
+ <span className="flex-1 min-w-0 break-words">{err}</span>
462
+ <button type="button" onClick={() => refreshAll()} className="shrink-0 min-h-[36px] px-3 rounded-full bg-white/10 hover:bg-white/15 text-white text-xs font-semibold">
463
+ Retry
 
 
 
 
 
 
464
  </button>
465
+ </div>
466
+ ) : null}
 
 
 
 
 
 
 
 
 
 
467
  {savedMsg ? <div className="mb-4 text-sm text-green-400/80">Saved successfully.</div> : null}
468
 
469
  {/* ================================================================ */}
 
988
  )
989
  ) : null}
990
  </div>
991
+ </ModalSheet>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992
  )
993
  }
994
 
frontend/src/ui/SettingsPanel.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import React, { useEffect, useMemo, useRef, useState } from "react";
2
  import {
3
  Settings as SettingsIcon,
@@ -21,6 +22,7 @@ import {
21
  Loader2,
22
  Cloud,
23
  Cpu,
 
24
  } from "lucide-react";
25
  import OllamaHealthBanner from "./OllamaHealthBanner";
26
  import OllaBridgeLink from "./components/OllaBridgeLink";
@@ -34,6 +36,7 @@ import OllaBridgeModels from "./components/OllaBridgeModels";
34
  import ComputeSettingsTabs from "./components/compute/ComputeSettingsTabs";
35
  import ModelExecutionSelector from "./components/compute/ModelExecutionSelector";
36
  import ProfileSettingsModal from "./ProfileSettingsModal";
 
37
  import TtsEngineSection from "./components/TtsEngineSection";
38
  import VoiceAssistantSelfTest from "./components/VoiceAssistantSelfTest";
39
  import SpeechRecognitionSettings from "./components/SpeechRecognitionSettings";
@@ -331,6 +334,7 @@ const SECTIONS = [
331
  { id: "tools", label: "Tools & Agents", Icon: Wrench },
332
  { id: "matrixhub", label: "MatrixHub", Icon: Grid3x3 },
333
  { id: "ollabridge", label: "OllaBridge API", Icon: Code2 },
 
334
  { id: "advanced", label: "Advanced", Icon: FlaskConical },
335
  ] as const;
336
 
@@ -687,6 +691,9 @@ export default function SettingsPanel({
687
  if (!dirty) baselineRef.current = JSON.stringify(value);
688
  }, [value, dirty]);
689
 
 
 
 
690
  // ESC closes the modal (respecting the unsaved-changes guard).
691
  useEffect(() => {
692
  const onKey = (e: KeyboardEvent) => {
@@ -1589,6 +1596,15 @@ export default function SettingsPanel({
1589
  case "tools": return renderTools();
1590
  case "matrixhub": return renderMatrixHub();
1591
  case "ollabridge": return renderOllaBridge();
 
 
 
 
 
 
 
 
 
1592
  case "advanced": return renderAdvanced();
1593
  default: return null;
1594
  }
 
1
+ import { useBackToClose } from "./lib/useBackToClose";
2
  import React, { useEffect, useMemo, useRef, useState } from "react";
3
  import {
4
  Settings as SettingsIcon,
 
22
  Loader2,
23
  Cloud,
24
  Cpu,
25
+ Sparkles,
26
  } from "lucide-react";
27
  import OllamaHealthBanner from "./OllamaHealthBanner";
28
  import OllaBridgeLink from "./components/OllaBridgeLink";
 
36
  import ComputeSettingsTabs from "./components/compute/ComputeSettingsTabs";
37
  import ModelExecutionSelector from "./components/compute/ModelExecutionSelector";
38
  import ProfileSettingsModal from "./ProfileSettingsModal";
39
+ import { MotionGallery } from "./motion/MotionGallery";
40
  import TtsEngineSection from "./components/TtsEngineSection";
41
  import VoiceAssistantSelfTest from "./components/VoiceAssistantSelfTest";
42
  import SpeechRecognitionSettings from "./components/SpeechRecognitionSettings";
 
334
  { id: "tools", label: "Tools & Agents", Icon: Wrench },
335
  { id: "matrixhub", label: "MatrixHub", Icon: Grid3x3 },
336
  { id: "ollabridge", label: "OllaBridge API", Icon: Code2 },
337
+ { id: "motion", label: "Motion", Icon: Sparkles },
338
  { id: "advanced", label: "Advanced", Icon: FlaskConical },
339
  ] as const;
340
 
 
691
  if (!dirty) baselineRef.current = JSON.stringify(value);
692
  }, [value, dirty]);
693
 
694
+ // The phone's Back gesture closes Settings (also respecting the guard).
695
+ useBackToClose(true, requestClose);
696
+
697
  // ESC closes the modal (respecting the unsaved-changes guard).
698
  useEffect(() => {
699
  const onKey = (e: KeyboardEvent) => {
 
1596
  case "tools": return renderTools();
1597
  case "matrixhub": return renderMatrixHub();
1598
  case "ollabridge": return renderOllaBridge();
1599
+ case "motion": return (
1600
+ <SettingsCard
1601
+ title="Motion & animations"
1602
+ description="How much HomePilot animates on this device. Applies right away β€” no need to Save."
1603
+ icon={<Sparkles size={16} />}
1604
+ >
1605
+ <MotionGallery />
1606
+ </SettingsCard>
1607
+ );
1608
  case "advanced": return renderAdvanced();
1609
  default: return null;
1610
  }
frontend/src/ui/SystemStatusDialog.tsx CHANGED
@@ -4,6 +4,7 @@
4
  * Shows a donut chart (pure CSS conic-gradient), service health cards,
5
  * and architecture overview. No chart library needed.
6
  */
 
7
  import React, { useEffect, useMemo, useState } from 'react'
8
  import { X, Activity, Database, Cpu, Server, Bot, PlugZap } from 'lucide-react'
9
  import { fetchSystemOverview, type SystemOverviewResponse } from './systemApi'
@@ -53,6 +54,9 @@ export default function SystemStatusDialog({
53
  return () => document.removeEventListener('keydown', onEsc)
54
  }, [onClose])
55
 
 
 
 
56
  /* Fetch data */
57
  useEffect(() => {
58
  let mounted = true
 
4
  * Shows a donut chart (pure CSS conic-gradient), service health cards,
5
  * and architecture overview. No chart library needed.
6
  */
7
+ import { useBackToClose } from './lib/useBackToClose'
8
  import React, { useEffect, useMemo, useState } from 'react'
9
  import { X, Activity, Database, Cpu, Server, Bot, PlugZap } from 'lucide-react'
10
  import { fetchSystemOverview, type SystemOverviewResponse } from './systemApi'
 
54
  return () => document.removeEventListener('keydown', onEsc)
55
  }, [onClose])
56
 
57
+ /* The phone's Back gesture closes the dialog instead of leaving HomePilot. */
58
+ useBackToClose(true, onClose)
59
+
60
  /* Fetch data */
61
  useEffect(() => {
62
  let mounted = true
frontend/src/ui/VoiceModeGrok.tsx CHANGED
@@ -1197,14 +1197,16 @@ export default function VoiceModeGrok({
1197
  <header className="absolute top-0 right-0 z-30 p-4 flex items-center gap-2 hp-fade-in">
1198
  <button
1199
  onClick={() => setShowSystemSettings(true)}
1200
- className="w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
1201
  title="System Settings"
 
1202
  >
1203
  <Settings size={18} />
1204
  </button>
1205
  <button
1206
  onClick={clearConversation}
1207
- className="w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
 
1208
  title="New Chat"
1209
  >
1210
  <PenLine size={16} />
@@ -1514,6 +1516,8 @@ export default function VoiceModeGrok({
1514
  {/* Mic Button with Mini Level Meter */}
1515
  <button
1516
  onClick={toggleMic}
 
 
1517
  className={`h-10 px-4 rounded-full border flex items-center gap-2.5 transition-all ${
1518
  isListening
1519
  ? 'bg-[#97C4FF]/20 border-[#97C4FF]/40 text-white'
@@ -1531,7 +1535,9 @@ export default function VoiceModeGrok({
1531
  {/* Volume Toggle */}
1532
  <button
1533
  onClick={() => setIsMuted(!isMuted)}
1534
- className="w-10 h-10 rounded-full border border-white/10 bg-white/5 flex items-center justify-center text-white/70 hover:bg-white/10 transition-colors"
 
 
1535
  >
1536
  {isMuted ? (
1537
  <VolumeX size={18} className="opacity-50" />
@@ -1543,6 +1549,8 @@ export default function VoiceModeGrok({
1543
  {/* Voice Settings Trigger */}
1544
  <button
1545
  onClick={() => setShowVoiceSettings(!showVoiceSettings)}
 
 
1546
  className={`h-10 px-3.5 rounded-full border flex items-center gap-2 transition-all text-sm ${
1547
  showVoiceSettings
1548
  ? 'bg-white/10 border-white/20'
@@ -1578,7 +1586,9 @@ export default function VoiceModeGrok({
1578
  {/* Hands-Free Toggle (Auto/Manual) - Replaces close button */}
1579
  <button
1580
  onClick={() => voice.setHandsFree(!voice.isHandsFree)}
1581
- className={`w-10 h-10 rounded-full flex items-center justify-center transition-all shadow-lg ${
 
 
1582
  voice.isHandsFree
1583
  ? 'bg-white text-black hover:bg-gray-200'
1584
  : 'bg-white/10 border border-white/20 text-white/80 hover:bg-white/20'
 
1197
  <header className="absolute top-0 right-0 z-30 p-4 flex items-center gap-2 hp-fade-in">
1198
  <button
1199
  onClick={() => setShowSystemSettings(true)}
1200
+ className="hp-icon-btn w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
1201
  title="System Settings"
1202
+ aria-label="Voice settings"
1203
  >
1204
  <Settings size={18} />
1205
  </button>
1206
  <button
1207
  onClick={clearConversation}
1208
+ aria-label="New voice chat"
1209
+ className="hp-icon-btn w-9 h-9 flex items-center justify-center rounded-full bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
1210
  title="New Chat"
1211
  >
1212
  <PenLine size={16} />
 
1516
  {/* Mic Button with Mini Level Meter */}
1517
  <button
1518
  onClick={toggleMic}
1519
+ aria-label={isListening ? 'Stop listening' : isSpeaking ? 'Stop speaking' : 'Start listening'}
1520
+ aria-pressed={isListening}
1521
  className={`h-10 px-4 rounded-full border flex items-center gap-2.5 transition-all ${
1522
  isListening
1523
  ? 'bg-[#97C4FF]/20 border-[#97C4FF]/40 text-white'
 
1535
  {/* Volume Toggle */}
1536
  <button
1537
  onClick={() => setIsMuted(!isMuted)}
1538
+ aria-label={isMuted ? 'Unmute replies' : 'Mute replies'}
1539
+ aria-pressed={isMuted}
1540
+ className="hp-icon-btn w-10 h-10 rounded-full border border-white/10 bg-white/5 flex items-center justify-center text-white/70 hover:bg-white/10 transition-colors"
1541
  >
1542
  {isMuted ? (
1543
  <VolumeX size={18} className="opacity-50" />
 
1549
  {/* Voice Settings Trigger */}
1550
  <button
1551
  onClick={() => setShowVoiceSettings(!showVoiceSettings)}
1552
+ aria-expanded={showVoiceSettings}
1553
+ aria-label={`Voice: ${activeVoice.name}, ${activePersonality.label}`}
1554
  className={`h-10 px-3.5 rounded-full border flex items-center gap-2 transition-all text-sm ${
1555
  showVoiceSettings
1556
  ? 'bg-white/10 border-white/20'
 
1586
  {/* Hands-Free Toggle (Auto/Manual) - Replaces close button */}
1587
  <button
1588
  onClick={() => voice.setHandsFree(!voice.isHandsFree)}
1589
+ aria-label={voice.isHandsFree ? 'Hands-free on β€” switch to manual' : 'Manual β€” switch to hands-free'}
1590
+ aria-pressed={voice.isHandsFree}
1591
+ className={`hp-icon-btn w-10 h-10 rounded-full flex items-center justify-center transition-all shadow-lg ${
1592
  voice.isHandsFree
1593
  ? 'bg-white text-black hover:bg-gray-200'
1594
  : 'bg-white/10 border border-white/20 text-white/80 hover:bg-white/20'
frontend/src/ui/avatar/AvatarLandingPage.tsx CHANGED
@@ -129,7 +129,7 @@ export function AvatarLandingPage({
129
  <div className="h-full w-full bg-black text-white font-sans overflow-hidden flex flex-col relative">
130
 
131
  {/* ═══════════════ HEADER ═══════════════ */}
132
- <div className="absolute top-0 left-0 right-0 z-20 flex justify-between items-center px-6 py-4 bg-gradient-to-b from-black/90 via-black/60 to-transparent pointer-events-none">
133
  <div className="pointer-events-auto flex items-center gap-3">
134
  <div className="flex items-center gap-2.5">
135
  <Sparkles size={18} className="text-purple-400" />
 
129
  <div className="h-full w-full bg-black text-white font-sans overflow-hidden flex flex-col relative">
130
 
131
  {/* ═══════════════ HEADER ═══════════════ */}
132
+ <div className="hp-clear-nav absolute top-0 left-0 right-0 z-20 flex justify-between items-center px-6 py-4 bg-gradient-to-b from-black/90 via-black/60 to-transparent pointer-events-none">
133
  <div className="pointer-events-auto flex items-center gap-3">
134
  <div className="flex items-center gap-2.5">
135
  <Sparkles size={18} className="text-purple-400" />
frontend/src/ui/components/AppErrorBoundary.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AppErrorBoundary β€” the app never fails to an unexplained black screen.
3
+ *
4
+ * Without a boundary, any exception thrown while rendering unmounts the whole
5
+ * React tree and leaves only the page background: on a phone that is a black
6
+ * screen with nothing to tap. This shows what happened, offers Try again /
7
+ * Reload / Copy details, and logs the failure with enough context to diagnose.
8
+ */
9
+ import React from 'react'
10
+
11
+ type State = { error: Error | null; componentStack: string; copied: boolean }
12
+
13
+ export function describeFailure(error: Error, componentStack: string): string {
14
+ return [
15
+ `HomePilot UI error: ${error.name}: ${error.message}`,
16
+ `When: ${new Date().toISOString()}`,
17
+ `Where: ${typeof window !== 'undefined' ? window.location.pathname + window.location.hash : ''}`,
18
+ `Viewport: ${typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : ''}`,
19
+ `Agent: ${typeof navigator !== 'undefined' ? navigator.userAgent : ''}`,
20
+ '',
21
+ (error.stack || '').split('\n').slice(0, 8).join('\n'),
22
+ componentStack ? `\nComponents:${componentStack.split('\n').slice(0, 8).join('\n')}` : '',
23
+ ].join('\n')
24
+ }
25
+
26
+ export class AppErrorBoundary extends React.Component<{ children: React.ReactNode; label?: string }, State> {
27
+ state: State = { error: null, componentStack: '', copied: false }
28
+
29
+ static getDerivedStateFromError(error: Error): Partial<State> {
30
+ return { error }
31
+ }
32
+
33
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
34
+ const componentStack = info?.componentStack || ''
35
+ this.setState({ componentStack })
36
+ // One structured line that is easy to find in browser and Space logs.
37
+ console.error('[HomePilot] UI crashed', {
38
+ area: this.props.label || 'app',
39
+ name: error?.name,
40
+ message: error?.message,
41
+ path: typeof window !== 'undefined' ? window.location.pathname + window.location.hash : '',
42
+ componentStack,
43
+ })
44
+ }
45
+
46
+ private copy = async () => {
47
+ const { error, componentStack } = this.state
48
+ if (!error) return
49
+ try {
50
+ await navigator.clipboard.writeText(describeFailure(error, componentStack))
51
+ this.setState({ copied: true })
52
+ } catch {
53
+ /* clipboard unavailable (e.g. insecure context) β€” the details stay on screen */
54
+ }
55
+ }
56
+
57
+ render() {
58
+ const { error, copied } = this.state
59
+ if (!error) return this.props.children
60
+ return (
61
+ <div className="hp-status-screen" role="alert">
62
+ <div className="hp-status-card">
63
+ <div aria-hidden style={{ fontSize: 28 }}>⚠️</div>
64
+ <h1>Something went wrong</h1>
65
+ <p>
66
+ HomePilot hit an unexpected error while showing this screen. Your conversations and settings are saved on
67
+ the server. Try again, or reload the page.
68
+ </p>
69
+ <div className="hp-status-actions">
70
+ <button type="button" className="hp-primary" onClick={() => this.setState({ error: null, componentStack: '', copied: false })}>
71
+ Try again
72
+ </button>
73
+ <button type="button" onClick={() => window.location.reload()}>Reload</button>
74
+ <button type="button" onClick={this.copy}>{copied ? 'Copied' : 'Copy details'}</button>
75
+ </div>
76
+ <details className="hp-accordion" style={{ marginTop: 16, textAlign: 'left', color: 'var(--hp-color-text-2)', fontSize: 12 }}>
77
+ <summary style={{ cursor: 'pointer', minHeight: 32 }}>Technical details</summary>
78
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', marginTop: 8 }}>
79
+ {`${error.name}: ${error.message}`}
80
+ </pre>
81
+ </details>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+ }
87
+
88
+ export default AppErrorBoundary
frontend/src/ui/components/AuthGate.tsx CHANGED
@@ -93,8 +93,16 @@ interface AuthGateProps {
93
  children: React.ReactNode
94
  }
95
 
 
 
 
 
 
 
96
  export default function AuthGate({ children }: AuthGateProps) {
97
- const [state, setState] = useState<'loading' | 'login' | 'onboarding' | 'ready'>('loading')
 
 
98
  const [user, setUser] = useState<AuthUser | null>(null)
99
  const [token, setToken] = useState<string>('')
100
  const [loggedOutMessage, setLoggedOutMessage] = useState<string>('')
@@ -107,11 +115,18 @@ export default function AuthGate({ children }: AuthGateProps) {
107
 
108
  async function checkAuth() {
109
  const savedToken = localStorage.getItem(LS_TOKEN_KEY) || ''
 
 
 
 
 
 
110
 
111
  try {
112
  const res = await fetch(`${backendUrl}/v1/auth/me`, {
113
  headers: savedToken ? { 'Authorization': `Bearer ${savedToken}` } : {},
114
  credentials: 'include',
 
115
  })
116
 
117
  if (!res.ok) {
@@ -155,9 +170,20 @@ export default function AuthGate({ children }: AuthGateProps) {
155
 
156
  // Fallback: let through
157
  setState('ready')
158
- } catch {
159
- // Backend unreachable β€” skip auth (backward compatible with pre-auth setups)
160
- setState('ready')
 
 
 
 
 
 
 
 
 
 
 
161
  }
162
  }
163
 
@@ -229,20 +255,35 @@ export default function AuthGate({ children }: AuthGateProps) {
229
  setState('login')
230
  }, [user, backendUrl])
231
 
232
- // Loading spinner β€” uses same neutral bg as login page
 
 
233
  if (state === 'loading') {
234
  return (
235
- <div style={{
236
- minHeight: '100vh',
237
- display: 'flex',
238
- alignItems: 'center',
239
- justifyContent: 'center',
240
- background: '#050506',
241
- color: 'rgba(255, 255, 255, 0.30)',
242
- fontFamily: 'Inter, system-ui, sans-serif',
243
- fontSize: 14,
244
- }}>
245
- Loading...
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  </div>
247
  )
248
  }
 
93
  children: React.ReactNode
94
  }
95
 
96
+ /** How long the first auth check may take before we stop waiting silently. A
97
+ * sleeping Hugging Face Space can take tens of seconds to answer. */
98
+ export const AUTH_CHECK_TIMEOUT_MS = 25000
99
+ /** After this long, the startup screen explains the wait. */
100
+ export const SLOW_START_HINT_MS = 5000
101
+
102
  export default function AuthGate({ children }: AuthGateProps) {
103
+ const [state, setState] = useState<'loading' | 'login' | 'onboarding' | 'ready' | 'unreachable'>('loading')
104
+ const [slowStart, setSlowStart] = useState(false)
105
+ const [failure, setFailure] = useState<string>('')
106
  const [user, setUser] = useState<AuthUser | null>(null)
107
  const [token, setToken] = useState<string>('')
108
  const [loggedOutMessage, setLoggedOutMessage] = useState<string>('')
 
115
 
116
  async function checkAuth() {
117
  const savedToken = localStorage.getItem(LS_TOKEN_KEY) || ''
118
+ setState('loading')
119
+ setSlowStart(false)
120
+ setFailure('')
121
+ const slowTimer = setTimeout(() => setSlowStart(true), SLOW_START_HINT_MS)
122
+ const controller = new AbortController()
123
+ const timeout = setTimeout(() => controller.abort(), AUTH_CHECK_TIMEOUT_MS)
124
 
125
  try {
126
  const res = await fetch(`${backendUrl}/v1/auth/me`, {
127
  headers: savedToken ? { 'Authorization': `Bearer ${savedToken}` } : {},
128
  credentials: 'include',
129
+ signal: controller.signal,
130
  })
131
 
132
  if (!res.ok) {
 
170
 
171
  // Fallback: let through
172
  setState('ready')
173
+ } catch (e) {
174
+ // The server didn't answer (still waking up, offline, or blocked). Say so
175
+ // and offer Retry, instead of mounting an app that can't load anything β€”
176
+ // which is how a phone ended up showing a blank black screen. "Continue
177
+ // anyway" keeps the old pre-auth behaviour available.
178
+ const aborted = (e as { name?: string } | null)?.name === 'AbortError'
179
+ setFailure(aborted
180
+ ? `The server didn't answer within ${Math.round(AUTH_CHECK_TIMEOUT_MS / 1000)} seconds.`
181
+ : 'The server could not be reached.')
182
+ console.error('[HomePilot] startup check failed', { backendUrl, aborted, error: String(e) })
183
+ setState('unreachable')
184
+ } finally {
185
+ clearTimeout(slowTimer)
186
+ clearTimeout(timeout)
187
  }
188
  }
189
 
 
255
  setState('login')
256
  }, [user, backendUrl])
257
 
258
+ // Startup screen β€” clearly visible (the old one was 30%-opacity text on
259
+ // near-black, which read as a blank black screen on phones), and honest
260
+ // about a slow start.
261
  if (state === 'loading') {
262
  return (
263
+ <div className="hp-status-screen" role="status" aria-live="polite">
264
+ <div className="hp-status-card">
265
+ <div className="hp-spinner" aria-hidden />
266
+ <h1>Starting HomePilot…</h1>
267
+ <p>{slowStart
268
+ ? 'The server is waking up. On a sleeping Hugging Face Space this can take up to a minute.'
269
+ : 'Connecting to your HomePilot server.'}</p>
270
+ </div>
271
+ </div>
272
+ )
273
+ }
274
+
275
+ if (state === 'unreachable') {
276
+ return (
277
+ <div className="hp-status-screen" role="alert">
278
+ <div className="hp-status-card">
279
+ <div aria-hidden style={{ fontSize: 28 }}>πŸ“‘</div>
280
+ <h1>Can’t reach HomePilot</h1>
281
+ <p>{failure} Check your connection, or wait a moment if the Space is starting, then try again.</p>
282
+ <div className="hp-status-actions">
283
+ <button type="button" className="hp-primary" onClick={() => { void checkAuth() }}>Retry</button>
284
+ <button type="button" onClick={() => setState('ready')}>Continue anyway</button>
285
+ </div>
286
+ </div>
287
  </div>
288
  )
289
  }
frontend/src/ui/components/ComposerPlusMenu.tsx CHANGED
@@ -229,7 +229,7 @@ export function ComposerPlusMenu({ onAddFile, onScreenshot }: ComposerPlusMenuPr
229
  title="Add files, screen or a meeting"
230
  data-testid="composer-plus-button"
231
  className={[
232
- 'h-10 w-10 rounded-full grid place-items-center transition-colors',
233
  open ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white hover:bg-white/5',
234
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/25',
235
  ].join(' ')}
 
229
  title="Add files, screen or a meeting"
230
  data-testid="composer-plus-button"
231
  className={[
232
+ 'hp-icon-btn h-10 w-10 rounded-full grid place-items-center transition-colors',
233
  open ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white hover:bg-white/5',
234
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/25',
235
  ].join(' ')}
frontend/src/ui/components/MessageMarkdown.tsx CHANGED
@@ -36,8 +36,9 @@ function CodeBlock({ lang, raw }: { lang: string; raw: string }) {
36
  <button
37
  type="button"
38
  onClick={() => copy(raw)}
39
- className="inline-flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/10 hover:border-white/20 text-white/75 hover:text-white transition"
40
  title="Copy code"
 
41
  >
42
  {copied ? <Check size={13} /> : <Copy size={13} />}
43
  {copied ? 'Copied' : 'Copy'}
 
36
  <button
37
  type="button"
38
  onClick={() => copy(raw)}
39
+ className="hp-icon-btn inline-flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/10 hover:border-white/20 text-white/75 hover:text-white transition"
40
  title="Copy code"
41
+ aria-label={copied ? 'Code copied' : 'Copy code'}
42
  >
43
  {copied ? <Check size={13} /> : <Copy size={13} />}
44
  {copied ? 'Copied' : 'Copy'}
frontend/src/ui/components/ModalSheet.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ModalSheet β€” a dialog that is full-screen on phones and a centred card on
3
+ * larger screens.
4
+ *
5
+ * Why it exists: dialogs used to be rendered wherever they were opened. When
6
+ * that place was the mobile nav drawer (which slides in with a CSS transform),
7
+ * the dialog's `position: fixed` was resolved against the drawer instead of the
8
+ * screen β€” a transformed ancestor becomes the containing block for fixed
9
+ * descendants β€” so "full-screen" settings came out as a 280px column pinned to
10
+ * the left. The sheet is portalled to <body>, so where it is opened from can
11
+ * never change how it is laid out.
12
+ *
13
+ * It also owns the behaviour every dialog needs: one scrolling body between a
14
+ * fixed header and a sticky footer (above the home indicator), Escape or the
15
+ * phone's Back gesture to close, focus moved in on open and back on close, Tab
16
+ * kept inside, no background scrolling while open, and a short enter/leave
17
+ * transition (motion system, animation 20).
18
+ */
19
+ import React, { useEffect, useRef } from 'react'
20
+ import { createPortal } from 'react-dom'
21
+ import { useBackToClose } from '../lib/useBackToClose'
22
+ import { motionAllowed } from '../motion/prefs'
23
+
24
+ /**
25
+ * Plays the leave transition on a copy of the sheet after React has removed
26
+ * it, so owners can keep rendering `{open && <Sheet/>}` and still get an exit.
27
+ * The copy is inert and hidden from assistive technology.
28
+ */
29
+ function playLeave(node: HTMLElement | null) {
30
+ if (!node || !motionAllowed()) return
31
+ const ghost = node.cloneNode(true) as HTMLElement
32
+ ghost.setAttribute('data-leaving', '')
33
+ ghost.setAttribute('aria-hidden', 'true')
34
+ ghost.setAttribute('inert', '')
35
+ ghost.removeAttribute('id')
36
+ ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'))
37
+ document.body.appendChild(ghost)
38
+ const remove = () => ghost.remove()
39
+ ghost.addEventListener('animationend', (e) => { if (e.target === ghost) remove() })
40
+ window.setTimeout(remove, 400)
41
+ }
42
+
43
+ const FOCUSABLE =
44
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
45
+
46
+ export function ModalSheet({
47
+ labelledBy,
48
+ onRequestClose,
49
+ header,
50
+ footer,
51
+ children,
52
+ zIndex = 200,
53
+ bodyRef,
54
+ }: {
55
+ /** id of the element that names the dialog */
56
+ labelledBy: string
57
+ /** Escape or a backdrop click; the owner decides (e.g. confirm unsaved changes). */
58
+ onRequestClose: () => void
59
+ header: React.ReactNode
60
+ footer?: React.ReactNode
61
+ children: React.ReactNode
62
+ zIndex?: number
63
+ bodyRef?: React.Ref<HTMLDivElement>
64
+ }) {
65
+ const sheetRef = useRef<HTMLDivElement>(null)
66
+ const backdropRef = useRef<HTMLDivElement>(null)
67
+ const closeRef = useRef(onRequestClose)
68
+ closeRef.current = onRequestClose
69
+ useBackToClose(true, () => closeRef.current())
70
+
71
+ useEffect(() => {
72
+ const previouslyFocused = document.activeElement as HTMLElement | null
73
+ const { overflow } = document.body.style
74
+ document.body.style.overflow = 'hidden'
75
+ // Focus the first control (usually the close button) so keyboard and
76
+ // screen-reader users start inside the dialog.
77
+ const first = sheetRef.current?.querySelector<HTMLElement>(FOCUSABLE)
78
+ ;(first || sheetRef.current)?.focus({ preventScroll: true })
79
+
80
+ const onKey = (e: KeyboardEvent) => {
81
+ if (e.key === 'Escape') {
82
+ e.stopPropagation()
83
+ closeRef.current()
84
+ return
85
+ }
86
+ if (e.key !== 'Tab' || !sheetRef.current) return
87
+ const items = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => {
88
+ if (el.hidden || el.closest('[hidden], [aria-hidden="true"]')) return false
89
+ const style = getComputedStyle(el)
90
+ return style.display !== 'none' && style.visibility !== 'hidden'
91
+ })
92
+ if (!items.length) return
93
+ const firstItem = items[0]
94
+ const lastItem = items[items.length - 1]
95
+ if (e.shiftKey && document.activeElement === firstItem) {
96
+ e.preventDefault()
97
+ lastItem.focus()
98
+ } else if (!e.shiftKey && document.activeElement === lastItem) {
99
+ e.preventDefault()
100
+ firstItem.focus()
101
+ }
102
+ }
103
+ document.addEventListener('keydown', onKey, true)
104
+ const backdrop = backdropRef.current
105
+ return () => {
106
+ // Only a real close removes the node; StrictMode's dev-only effect re-run keeps it.
107
+ window.setTimeout(() => { if (backdrop && !backdrop.isConnected) playLeave(backdrop) }, 0)
108
+ document.removeEventListener('keydown', onKey, true)
109
+ document.body.style.overflow = overflow
110
+ previouslyFocused?.focus?.({ preventScroll: true })
111
+ }
112
+ }, [])
113
+
114
+ return createPortal(
115
+ <div
116
+ ref={backdropRef}
117
+ className="hp-sheet-backdrop"
118
+ style={{ zIndex }}
119
+ onMouseDown={(e) => {
120
+ if (e.target === e.currentTarget) closeRef.current()
121
+ }}
122
+ >
123
+ <div
124
+ ref={sheetRef}
125
+ className="hp-sheet"
126
+ role="dialog"
127
+ aria-modal="true"
128
+ aria-labelledby={labelledBy}
129
+ tabIndex={-1}
130
+ >
131
+ <div className="hp-sheet__header">{header}</div>
132
+ <div className="hp-sheet__body" ref={bodyRef}>{children}</div>
133
+ {footer ? <div className="hp-sheet__footer">{footer}</div> : null}
134
+ </div>
135
+ </div>,
136
+ document.body,
137
+ )
138
+ }
139
+
140
+ export default ModalSheet
frontend/src/ui/lib/backendUrl.test.ts CHANGED
@@ -32,4 +32,18 @@ describe('resolveBackendUrl', () => {
32
 
33
  expect(resolveBackendUrl()).toBe('http://localhost:8010')
34
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  })
 
32
 
33
  expect(resolveBackendUrl()).toBe('http://localhost:8010')
34
  })
35
+
36
+ it('keeps a stored Vite dev address when the build is configured to use it (proxying dev server)', () => {
37
+ vi.stubGlobal('location', {
38
+ hostname: '127.0.0.1',
39
+ origin: 'http://127.0.0.1:3000',
40
+ port: '3000',
41
+ protocol: 'http:',
42
+ })
43
+ vi.stubEnv('VITE_API_URL', 'http://127.0.0.1:3000/')
44
+ window.localStorage.setItem('homepilot_backend_url', 'http://127.0.0.1:3000')
45
+
46
+ expect(resolveBackendUrl()).toBe('http://127.0.0.1:3000')
47
+ vi.unstubAllEnvs()
48
+ })
49
  })
frontend/src/ui/lib/backendUrl.ts CHANGED
@@ -91,7 +91,11 @@ export function resolveBackendUrl(override?: string | null): string {
91
  // chat-REST fallback. Redirect to :8000 (the default backend port)
92
  // so the stored preference doesn't silently break voice-call
93
  // session creation.
94
- if (storedIsLocal && DEV_VITE_PORTS.has(storedPort)) {
 
 
 
 
95
  return "http://localhost:8000";
96
  }
97
  return clean;
 
91
  // chat-REST fallback. Redirect to :8000 (the default backend port)
92
  // so the stored preference doesn't silently break voice-call
93
  // session creation.
94
+ // …unless the build itself names that address (VITE_API_URL pointing
95
+ // at a Vite dev server that proxies the API): a configured value is
96
+ // not stale, and rewriting it made every reload unreachable.
97
+ const configured = (import.meta.env?.VITE_API_URL as string | undefined)?.trim();
98
+ if (storedIsLocal && DEV_VITE_PORTS.has(storedPort) && clean !== stripTrailingSlash(configured || "")) {
99
  return "http://localhost:8000";
100
  }
101
  return clean;
frontend/src/ui/lib/useBackToClose.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * useBackToClose β€” the phone's Back gesture closes the open layer (drawer,
3
+ * full-screen settings) instead of leaving HomePilot.
4
+ *
5
+ * While `open`, one history entry is added for the layer. Back pops it and
6
+ * `onClose` runs; closing the layer any other way removes the entry again.
7
+ * Layers stack: each records its depth, and Back only closes layers above the
8
+ * entry it lands on β€” so Back from Settings opened over the drawer closes
9
+ * Settings and leaves the drawer open.
10
+ *
11
+ * If the owner declines to close (e.g. "Discard unsaved changes?" β†’ Cancel),
12
+ * the entry is restored so the next Back is caught again.
13
+ */
14
+ import { useEffect, useRef } from 'react'
15
+
16
+ type LayerState = { hpLayer?: number }
17
+
18
+ function depthOf(state: unknown): number {
19
+ const d = (state as LayerState | null)?.hpLayer
20
+ return typeof d === 'number' ? d : 0
21
+ }
22
+
23
+ export function useBackToClose(open: boolean, onClose: () => void): void {
24
+ const closeRef = useRef(onClose)
25
+ closeRef.current = onClose
26
+
27
+ useEffect(() => {
28
+ if (!open || typeof window === 'undefined' || !window.history?.pushState) return
29
+ let alive = true
30
+ let pushed = false
31
+ let depth = 0
32
+
33
+ const push = () => {
34
+ depth = depthOf(window.history.state) + 1
35
+ window.history.pushState({ ...(window.history.state || {}), hpLayer: depth }, '')
36
+ pushed = true
37
+ }
38
+ // Deferred one tick so React StrictMode's mount β†’ unmount β†’ mount in
39
+ // development doesn't leave a stray entry behind.
40
+ const startTimer = window.setTimeout(() => {
41
+ if (alive) push()
42
+ }, 0)
43
+
44
+ const onPop = (e: PopStateEvent) => {
45
+ if (!pushed || depthOf(e.state) >= depth) return
46
+ pushed = false
47
+ closeRef.current()
48
+ // Still open after asking to close β†’ the owner kept it; catch the next Back too.
49
+ window.setTimeout(() => {
50
+ if (alive && !pushed) push()
51
+ }, 0)
52
+ }
53
+ window.addEventListener('popstate', onPop)
54
+
55
+ return () => {
56
+ alive = false
57
+ window.clearTimeout(startTimer)
58
+ window.removeEventListener('popstate', onPop)
59
+ if (pushed && depthOf(window.history.state) === depth) window.history.back()
60
+ }
61
+ }, [open])
62
+ }
63
+
64
+ export default useBackToClose
frontend/src/ui/motion/Motion.tsx ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Motion primitives β€” small, dependency-free components over motion.css.
3
+ *
4
+ * Each renders something meaningful with motion switched off (a readable label,
5
+ * a static bar, the final content), so an animation is never the only signal.
6
+ */
7
+ import React, { useEffect, useRef, useState } from 'react'
8
+ import { motionAllowed, useMotionPrefs } from './prefs'
9
+
10
+ type Tag = 'span' | 'div' | 'p'
11
+
12
+ /** 1 β€” A working label with a moving band of light ("Searching the web…"). */
13
+ export function ShimmerText({ children, className = '', as: As = 'span' }: { children: React.ReactNode; className?: string; as?: Tag }) {
14
+ return <As className={`hp-shimmer-text ${className}`}>{children}</As>
15
+ }
16
+
17
+ /** 2 β€” A label that gently brightens and dims ("Saving…"). */
18
+ export function PulseText({ children, className = '', as: As = 'span' }: { children: React.ReactNode; className?: string; as?: Tag }) {
19
+ return <As className={`hp-pulse-text ${className}`}>{children}</As>
20
+ }
21
+
22
+ /** 4 β€” The cursor shown while text is still arriving. Decorative. */
23
+ export function TypingCursor({ className = '' }: { className?: string }) {
24
+ return <span className={`hp-caret ${className}`} aria-hidden="true" />
25
+ }
26
+
27
+ /** 5 β€” Three dots that ripple. Announced once as `label`. */
28
+ export function LoadingDots({ label = 'Loading', className = '' }: { label?: string; className?: string }) {
29
+ return (
30
+ <span className={`hp-dots ${className}`} role="img" aria-label={label}>
31
+ <span />
32
+ <span />
33
+ <span />
34
+ </span>
35
+ )
36
+ }
37
+
38
+ /** 6 β€” An inline spinner sized to the text around it. */
39
+ export function Spinner({ size = 16, label, className = '' }: { size?: number; label?: string; className?: string }) {
40
+ return (
41
+ <span
42
+ className={`hp-spin ${className}`}
43
+ style={{ ['--hp-spin-size' as string]: `${size}px` }}
44
+ role={label ? 'img' : undefined}
45
+ aria-label={label}
46
+ aria-hidden={label ? undefined : true}
47
+ />
48
+ )
49
+ }
50
+
51
+ /** 7 β€” Placeholder blocks with a light sweep while content loads. */
52
+ export function Skeleton({
53
+ lines = 1,
54
+ width = '100%',
55
+ height = 12,
56
+ gap = 8,
57
+ radius = 8,
58
+ className = '',
59
+ }: {
60
+ lines?: number
61
+ width?: number | string
62
+ height?: number
63
+ gap?: number
64
+ radius?: number
65
+ className?: string
66
+ }) {
67
+ return (
68
+ <span className={`flex flex-col ${className}`} style={{ gap }} aria-hidden="true">
69
+ {Array.from({ length: lines }, (_, i) => (
70
+ <span
71
+ key={i}
72
+ className="hp-skeleton"
73
+ // The last line of a paragraph is shorter, as real text would be.
74
+ style={{ width: lines > 1 && i === lines - 1 ? '62%' : width, height, borderRadius: radius }}
75
+ />
76
+ ))}
77
+ </span>
78
+ )
79
+ }
80
+
81
+ /** 22 β€” Progress: determinate when `value` (0..1) is known, otherwise a sweep. */
82
+ export function ProgressBar({ value, label, className = '' }: { value?: number | null; label: string; className?: string }) {
83
+ const known = typeof value === 'number' && Number.isFinite(value)
84
+ const pct = known ? Math.round(Math.max(0, Math.min(1, value as number)) * 100) : undefined
85
+ return (
86
+ <span
87
+ className={`hp-progress ${className}`}
88
+ role="progressbar"
89
+ aria-label={label}
90
+ aria-valuemin={known ? 0 : undefined}
91
+ aria-valuemax={known ? 100 : undefined}
92
+ aria-valuenow={pct}
93
+ data-indeterminate={known ? 'false' : 'true'}
94
+ >
95
+ <span style={known ? { width: `${pct}%` } : undefined} />
96
+ </span>
97
+ )
98
+ }
99
+
100
+ /** 12/13 β€” Content that opens and closes smoothly. Closed content is hidden from everyone. */
101
+ export function Collapsible({ open, children, id, className = '' }: { open: boolean; children: React.ReactNode; id?: string; className?: string }) {
102
+ return (
103
+ <div id={id} className={`hp-collapsible ${className}`} data-open={open ? 'true' : 'false'} aria-hidden={open ? undefined : true}>
104
+ <div>{children}</div>
105
+ </div>
106
+ )
107
+ }
108
+
109
+ /**
110
+ * 17 β€” A status line that changes ("Searching" β†’ "Reading 3 sources" β†’ "Writing").
111
+ * The previous label leaves upward while the new one arrives; while work is
112
+ * under way the label shimmers. Screen readers hear each new label once.
113
+ */
114
+ export function StatusText({ text, working = true, className = '' }: { text: string; working?: boolean; className?: string }) {
115
+ const prefs = useMotionPrefs()
116
+ const [current, setCurrent] = useState(text)
117
+ const [leaving, setLeaving] = useState<string | null>(null)
118
+ const timer = useRef<number | null>(null)
119
+ const lastChange = useRef(0)
120
+
121
+ useEffect(() => {
122
+ if (text === current) return
123
+ // Animate only calm changes. A status that flips several times a second
124
+ // (a voice detector toggling between two states) swaps instantly β€” two
125
+ // labels sliding past each other all the time read as overlapping text.
126
+ const now = Date.now()
127
+ const calm = now - lastChange.current > 700
128
+ lastChange.current = now
129
+ if (timer.current) window.clearTimeout(timer.current)
130
+ if (calm && motionAllowed(prefs)) {
131
+ setLeaving(current)
132
+ timer.current = window.setTimeout(() => setLeaving(null), 260)
133
+ } else {
134
+ setLeaving(null)
135
+ }
136
+ setCurrent(text)
137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
138
+ }, [text])
139
+ useEffect(() => () => { if (timer.current) window.clearTimeout(timer.current) }, [])
140
+
141
+ const label = working ? <ShimmerText>{current}</ShimmerText> : <span>{current}</span>
142
+ return (
143
+ <span className={`hp-status-swap ${className}`} role="status" aria-live="polite">
144
+ {leaving ? (
145
+ <span key={`out-${leaving}`} className="hp-status-swap__out" aria-hidden="true">
146
+ {leaving}
147
+ </span>
148
+ ) : null}
149
+ <span key={current} className={leaving ? 'hp-status-swap__in' : undefined}>
150
+ {label}
151
+ </span>
152
+ </span>
153
+ )
154
+ }
155
+
156
+ /**
157
+ * 8–11 β€” Keeps content mounted long enough to animate out.
158
+ * `variant` picks fade, slide from the left (panels) or slide up (sheets).
159
+ */
160
+ export function Presence({
161
+ show,
162
+ children,
163
+ variant = 'fade',
164
+ className = '',
165
+ }: {
166
+ show: boolean
167
+ children: React.ReactNode
168
+ variant?: 'fade' | 'slide-left' | 'slide-up'
169
+ className?: string
170
+ }) {
171
+ const prefs = useMotionPrefs()
172
+ const [mounted, setMounted] = useState(show)
173
+ const [leaving, setLeaving] = useState(false)
174
+ useEffect(() => {
175
+ if (show) {
176
+ setMounted(true)
177
+ setLeaving(false)
178
+ return
179
+ }
180
+ if (!mounted) return
181
+ if (!motionAllowed(prefs)) {
182
+ setMounted(false)
183
+ return
184
+ }
185
+ setLeaving(true)
186
+ const t = window.setTimeout(() => {
187
+ setMounted(false)
188
+ setLeaving(false)
189
+ }, 180)
190
+ return () => window.clearTimeout(t)
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ }, [show])
193
+ if (!mounted) return null
194
+ const enter = variant === 'slide-left' ? 'hp-enter-slide-left' : variant === 'slide-up' ? 'hp-enter-slide-up' : 'hp-enter-fade'
195
+ const exit = variant === 'slide-left' ? 'hp-exit-slide-left' : variant === 'slide-up' ? 'hp-exit-slide-down' : 'hp-exit-fade'
196
+ return <div className={`${leaving ? exit : enter} ${className}`} aria-hidden={leaving || undefined}>{children}</div>
197
+ }
198
+
199
+ export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'off'
200
+
201
+ /** 18 β€” The voice orb: breathes when idle, follows your voice, orbits while thinking, rings while speaking. */
202
+ export function VoiceOrb({ state, level = 0, size = 128, className = '' }: { state: OrbState; level?: number; size?: number; className?: string }) {
203
+ const lvl = Math.max(0, Math.min(1, Number.isFinite(level) ? level : 0))
204
+ return (
205
+ <div
206
+ className={`hp-orb ${className}`}
207
+ data-state={state}
208
+ style={{ ['--hp-level' as string]: state === 'listening' || state === 'speaking' ? lvl.toFixed(3) : '0', ['--hp-orb-size' as string]: `${size}px` }}
209
+ aria-hidden="true"
210
+ />
211
+ )
212
+ }
213
+
214
+ /** Bar weights: tallest in the middle, like a voice envelope. */
215
+ export function waveformWeights(count: number): number[] {
216
+ const n = Math.max(1, Math.floor(count))
217
+ return Array.from({ length: n }, (_, i) => {
218
+ const x = n === 1 ? 0 : (i / (n - 1)) * 2 - 1 // -1..1
219
+ return 0.35 + 0.65 * Math.cos((x * Math.PI) / 2) ** 2
220
+ })
221
+ }
222
+
223
+ /** 19 β€” An audio waveform whose bars follow the live level. */
224
+ export function AudioWaveform({
225
+ level = 0,
226
+ active = true,
227
+ bars = 24,
228
+ height = 28,
229
+ className = '',
230
+ }: {
231
+ level?: number
232
+ active?: boolean
233
+ bars?: number
234
+ height?: number
235
+ className?: string
236
+ }) {
237
+ const lvl = Math.max(0, Math.min(1, Number.isFinite(level) ? level : 0))
238
+ const weights = React.useMemo(() => waveformWeights(bars), [bars])
239
+ return (
240
+ <span className={`hp-wave ${className}`} data-active={active ? 'true' : 'false'} style={{ ['--hp-wave-h' as string]: `${height}px` }} aria-hidden="true">
241
+ {weights.map((w, i) => {
242
+ // A floor keeps the shape visible at silence; the level lifts every bar by its weight.
243
+ const s = active ? 0.14 + (0.18 + lvl * 0.82) * w * 0.86 : 0.12 + w * 0.08
244
+ return <span key={i} style={{ transform: `scaleY(${s.toFixed(3)})`, animationDelay: `${(i % 6) * -0.17}s` }} />
245
+ })}
246
+ </span>
247
+ )
248
+ }
frontend/src/ui/motion/MotionGallery.tsx ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Settings β†’ Motion: choose how much HomePilot animates, and see every
3
+ * animation in the motion system running live.
4
+ *
5
+ * Applies immediately and is remembered on this device; it does not touch the
6
+ * Save/Reset draft of the settings around it.
7
+ */
8
+ import React, { useEffect, useState } from 'react'
9
+ import { MotionLevel, RevealStyle, prefersReducedMotion, useMotionPrefs, writeMotionPrefs } from './prefs'
10
+ import {
11
+ AudioWaveform,
12
+ Collapsible,
13
+ LoadingDots,
14
+ PulseText,
15
+ ProgressBar,
16
+ ShimmerText,
17
+ Skeleton,
18
+ Spinner,
19
+ StatusText,
20
+ TypingCursor,
21
+ VoiceOrb,
22
+ type OrbState,
23
+ } from './Motion'
24
+ import { StreamReveal } from './StreamReveal'
25
+
26
+ const LEVELS: { id: MotionLevel; label: string; note: string }[] = [
27
+ { id: 'enterprise', label: 'Enterprise', note: 'Every animation, quick and restrained.' },
28
+ { id: 'minimal', label: 'Minimal', note: 'Fades and pulses only β€” no sweeps or slides.' },
29
+ { id: 'off', label: 'Off', note: 'Nothing moves.' },
30
+ ]
31
+ const REVEALS: { id: RevealStyle; label: string; note: string }[] = [
32
+ { id: 'stream', label: 'Stream', note: 'New answers appear word by word with a cursor. Tap to show all.' },
33
+ { id: 'fade', label: 'Fade in', note: 'New answers fade in whole.' },
34
+ { id: 'instant', label: 'Instant', note: 'New answers appear at once.' },
35
+ ]
36
+
37
+ const SAMPLE = 'HomePilot reveals a new answer **progressively**, so you can start reading while the rest settles in.'
38
+ const TOOL_STEPS = ['Searching the web', 'Reading 3 sources', 'Comparing results', 'Writing the answer']
39
+ const ORB_STATES: OrbState[] = ['idle', 'listening', 'thinking', 'speaking']
40
+
41
+ /** Just enough Markdown for the sample: **bold** runs (an unclosed run is bold too). */
42
+ function renderBold(text: string): React.ReactNode {
43
+ return text.split('**').map((part, i) => (i % 2 ? <strong key={i}>{part}</strong> : <React.Fragment key={i}>{part}</React.Fragment>))
44
+ }
45
+
46
+ function useTicker(ms: number) {
47
+ const [n, setN] = useState(0)
48
+ useEffect(() => {
49
+ const t = window.setInterval(() => setN((v) => v + 1), ms)
50
+ return () => window.clearInterval(t)
51
+ }, [ms])
52
+ return n
53
+ }
54
+
55
+ function Choice<T extends string>({
56
+ name,
57
+ value,
58
+ options,
59
+ onChange,
60
+ }: {
61
+ name: string
62
+ value: T
63
+ options: { id: T; label: string; note: string }[]
64
+ onChange: (v: T) => void
65
+ }) {
66
+ return (
67
+ <div role="radiogroup" aria-label={name} className="grid gap-2 sm:grid-cols-3">
68
+ {options.map((o) => {
69
+ const on = o.id === value
70
+ return (
71
+ <button
72
+ key={o.id}
73
+ type="button"
74
+ role="radio"
75
+ aria-checked={on}
76
+ onClick={() => onChange(o.id)}
77
+ className={[
78
+ 'min-h-[44px] rounded-xl border px-3 py-2.5 text-left',
79
+ on ? 'border-white/40 bg-white/[0.08] text-white' : 'border-white/10 bg-white/[0.02] text-white/70 hover:bg-white/[0.05]',
80
+ ].join(' ')}
81
+ >
82
+ <span className="block text-sm font-semibold">{o.label}</span>
83
+ <span className="block text-xs text-white/55 mt-0.5 leading-snug">{o.note}</span>
84
+ </button>
85
+ )
86
+ })}
87
+ </div>
88
+ )
89
+ }
90
+
91
+ function Demo({ n, name, where, children }: { n: number; name: string; where: string; children: React.ReactNode }) {
92
+ return (
93
+ <li data-motion-demo={n} className="flex items-center gap-3 py-3 border-t border-white/[0.06] first:border-t-0 min-w-0">
94
+ <span className="w-6 shrink-0 text-right text-xs tabular-nums text-white/45">{n}</span>
95
+ <span className="min-w-0 flex-1">
96
+ <span className="block text-sm text-white/90">{name}</span>
97
+ <span className="block text-xs text-white/50 leading-snug">{where}</span>
98
+ </span>
99
+ <span className="shrink-0 flex items-center justify-end w-[42%] max-w-[13rem] min-h-[28px] text-sm text-white/80">{children}</span>
100
+ </li>
101
+ )
102
+ }
103
+
104
+ export function MotionGallery() {
105
+ const prefs = useMotionPrefs()
106
+ const tick = useTicker(1600)
107
+ const [replay, setReplay] = useState(0)
108
+ const [open, setOpen] = useState(true)
109
+ const [shown, setShown] = useState(true)
110
+ const reduced = prefersReducedMotion()
111
+
112
+ // Cycle the stateful demos.
113
+ const step = TOOL_STEPS[tick % TOOL_STEPS.length]
114
+ const orb = ORB_STATES[Math.floor(tick / 2) % ORB_STATES.length]
115
+ const level = orb === 'listening' || orb === 'speaking' ? 0.25 + 0.5 * Math.abs(Math.sin(tick * 1.7)) : 0
116
+ const progress = ((tick % 6) + 1) / 6
117
+
118
+ return (
119
+ <div className="space-y-5">
120
+ {reduced ? (
121
+ <p className="text-xs text-amber-200/90 bg-amber-500/10 border border-amber-500/25 rounded-lg px-3 py-2">
122
+ Your device asks for reduced motion, so animations stay off whatever you choose here.
123
+ </p>
124
+ ) : null}
125
+
126
+ <div className="space-y-2">
127
+ <div className="text-xs font-semibold uppercase tracking-wider text-white/55">Animation level</div>
128
+ <Choice name="Animation level" value={prefs.level} options={LEVELS} onChange={(level) => writeMotionPrefs({ level })} />
129
+ </div>
130
+
131
+ <div className="space-y-2">
132
+ <div className="text-xs font-semibold uppercase tracking-wider text-white/55">New answers</div>
133
+ <Choice name="How new answers appear" value={prefs.reveal} options={REVEALS} onChange={(reveal) => writeMotionPrefs({ reveal })} />
134
+ </div>
135
+
136
+ <div className="rounded-xl border border-white/10 bg-black/30 p-4 space-y-2">
137
+ <div className="flex items-center justify-between gap-3">
138
+ <div className="text-xs font-semibold uppercase tracking-wider text-white/55">Preview</div>
139
+ <button
140
+ type="button"
141
+ onClick={() => setReplay((r) => r + 1)}
142
+ className="min-h-[36px] px-3 rounded-lg border border-white/10 bg-white/[0.04] text-xs text-white/80 hover:bg-white/[0.08]"
143
+ >
144
+ Replay
145
+ </button>
146
+ </div>
147
+ <div className="text-[15px] leading-relaxed text-white/90" key={`${replay}-${prefs.reveal}-${prefs.level}`}>
148
+ <div className={prefs.reveal === 'fade' ? 'hp-msg-in' : undefined}>
149
+ <StreamReveal
150
+ text={SAMPLE}
151
+ active={prefs.reveal === 'stream' && prefs.level !== 'off' && !reduced}
152
+ render={renderBold}
153
+ />
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <div>
159
+ <div className="text-xs font-semibold uppercase tracking-wider text-white/55 mb-1">All animations</div>
160
+ <ol className="list-none p-0 m-0" aria-label="Animations in HomePilot">
161
+ <Demo n={1} name="Text shimmer" where="Working labels while HomePilot thinks or searches">
162
+ <ShimmerText>Thinking</ShimmerText>
163
+ </Demo>
164
+ <Demo n={2} name="Text opacity pulse" where="Saving and syncing labels">
165
+ <PulseText>Saving…</PulseText>
166
+ </Demo>
167
+ <Demo n={3} name="Streaming text" where="A new answer appears progressively">
168
+ <span key={replay} className="truncate">
169
+ <StreamReveal text="Words arrive one after another." active={prefs.level !== 'off' && !reduced} render={(t) => t} />
170
+ </span>
171
+ </Demo>
172
+ <Demo n={4} name="Typing cursor" where="Rides the end of the text being revealed">
173
+ <span>Writing<TypingCursor /></span>
174
+ </Demo>
175
+ <Demo n={5} name="Loading dots" where="Waiting for a reply">
176
+ <LoadingDots label="Waiting" />
177
+ </Demo>
178
+ <Demo n={6} name="Loading spinner" where="Buttons while an action runs">
179
+ <Spinner size={18} label="Working" />
180
+ </Demo>
181
+ <Demo n={7} name="Skeleton shimmer" where="Placeholders while settings and lists load">
182
+ <Skeleton lines={2} height={8} className="w-full" />
183
+ </Demo>
184
+ <Demo n={8} name="Fade in" where="New content arriving">
185
+ <span key={`fi-${tick}`} className="hp-enter-fade">Hello</span>
186
+ </Demo>
187
+ <Demo n={9} name="Fade out" where="Content being dismissed">
188
+ <span key={`fo-${tick}`} className={tick % 2 ? 'hp-exit-fade' : undefined}>Goodbye</span>
189
+ </Demo>
190
+ <Demo n={10} name="Slide in" where="Panels entering from the side">
191
+ <span key={`si-${tick}`} className="hp-enter-slide-left">Panel</span>
192
+ </Demo>
193
+ <Demo n={11} name="Slide out" where="Panels leaving the screen">
194
+ <span key={`so-${tick}`} className={tick % 2 ? 'hp-exit-slide-left' : undefined}>Panel</span>
195
+ </Demo>
196
+ <Demo n={12} name="Accordion expand" where="Sections opening (details, transcripts)">
197
+ <button type="button" onClick={() => setOpen((v) => !v)} className="text-xs text-white/80 underline underline-offset-2 min-h-[28px]" aria-expanded={open}>
198
+ {open ? 'Collapse' : 'Expand'}
199
+ </button>
200
+ </Demo>
201
+ <li className="pl-9 -mt-1 pb-2">
202
+ <Collapsible open={open}>
203
+ <p className="text-xs text-white/60 py-1">Sections open and close smoothly instead of jumping.</p>
204
+ </Collapsible>
205
+ </li>
206
+ <Demo n={13} name="Accordion collapse" where="Sections closing">
207
+ <span className="text-xs text-white/55">Same control, reversed</span>
208
+ </Demo>
209
+ <Demo n={14} name="Button hover transition" where="Every button's hover state eases in">
210
+ <button type="button" className="min-h-[32px] px-3 rounded-lg border border-white/10 bg-white/[0.04] hover:bg-white/[0.12] text-xs">Hover me</button>
211
+ </Demo>
212
+ <Demo n={15} name="Button press feedback" where="Buttons dip slightly when pressed">
213
+ <button type="button" className="min-h-[32px] px-3 rounded-lg border border-white/10 bg-white/[0.04] text-xs">Press me</button>
214
+ </Demo>
215
+ <Demo n={16} name="Message reveal" where="Each new turn rises into place">
216
+ <span key={`mr-${tick}`} className="hp-msg-in rounded-full bg-white/10 px-3 py-1 text-xs">New message</span>
217
+ </Demo>
218
+ <Demo n={17} name="Tool status transition" where="Searching β†’ reading β†’ writing">
219
+ <StatusText text={step} />
220
+ </Demo>
221
+ <Demo n={18} name="Voice orb" where={`Available for voice visuals β€” now: ${orb}`}>
222
+ <VoiceOrb state={orb} level={level} size={40} />
223
+ </Demo>
224
+ <Demo n={19} name="Audio waveform" where="Available for live audio levels">
225
+ <AudioWaveform level={level || 0.2} bars={14} height={24} />
226
+ </Demo>
227
+ <Demo n={20} name="Modal transition" where="Dialogs rise in and fade out">
228
+ <span key={`mt-${tick}`} className="hp-enter-slide-up rounded-lg border border-white/15 bg-white/[0.06] px-3 py-1 text-xs">Dialog</span>
229
+ </Demo>
230
+ <Demo n={21} name="Sidebar transition" where="The phone menu slides in over a fading backdrop">
231
+ <button type="button" onClick={() => setShown((v) => !v)} className="text-xs text-white/80 underline underline-offset-2 min-h-[28px]">
232
+ {shown ? 'Hide' : 'Show'}
233
+ </button>
234
+ </Demo>
235
+ <li className="pl-9 -mt-1 pb-2 h-8">
236
+ {shown ? <span className="hp-enter-slide-left inline-block rounded-md bg-white/10 px-2 py-1 text-xs text-white/70">Menu</span> : null}
237
+ </li>
238
+ <Demo n={22} name="Progress indicator" where="Uploads, refreshes and long tasks">
239
+ <span className="w-full space-y-1.5">
240
+ <ProgressBar value={progress} label="Upload progress" />
241
+ <ProgressBar label="Refreshing" />
242
+ </span>
243
+ </Demo>
244
+ </ol>
245
+ </div>
246
+ </div>
247
+ )
248
+ }
249
+
250
+ export default MotionGallery
frontend/src/ui/motion/StreamReveal.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * StreamReveal β€” reveals a newly arrived answer progressively, with a typing
3
+ * cursor riding the end of the last line (animations 3, 4 and 16).
4
+ *
5
+ * HomePilot receives an answer whole, so this is presentation only: the full
6
+ * text is already in state, copy works on all of it, and a tap shows the rest
7
+ * at once. The reveal is paced by length β€” short replies settle in about half
8
+ * a second, long ones never take more than ~1.6 s β€” so it reads as lively
9
+ * rather than slow.
10
+ *
11
+ * With `active` false (history, the "fade"/"instant" preference, motion off,
12
+ * reduced motion) it renders the final content in the same element, so
13
+ * switching between the two never remounts what is on screen.
14
+ */
15
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
16
+ import { motionAllowed } from './prefs'
17
+
18
+ const MIN_MS = 450
19
+ const MAX_MS = 1600
20
+ const MS_PER_WORD = 14
21
+ const FRAME_MS = 33 // ~30 fps is plenty for text and keeps Markdown re-renders cheap
22
+
23
+ /** Character offsets at which the visible text may end: after each word. */
24
+ export function revealStops(text: string): number[] {
25
+ const stops: number[] = []
26
+ const re = /\S+\s*/g
27
+ let m: RegExpExecArray | null
28
+ while ((m = re.exec(text))) stops.push(m.index + m[0].length)
29
+ if (!stops.length || stops[stops.length - 1] !== text.length) stops.push(text.length)
30
+ return stops
31
+ }
32
+
33
+ export function revealDuration(words: number): number {
34
+ return Math.max(MIN_MS, Math.min(MAX_MS, words * MS_PER_WORD))
35
+ }
36
+
37
+ /**
38
+ * The text to render for a partial reveal. An unfinished code fence is closed
39
+ * so the partial answer still renders as code rather than as a paragraph of
40
+ * backticks.
41
+ */
42
+ export function partialMarkdown(text: string, end: number): string {
43
+ const head = text.slice(0, end)
44
+ const fences = head.match(/^\s*(```|~~~)/gm)?.length ?? 0
45
+ return fences % 2 === 1 ? `${head}\n\`\`\`` : head
46
+ }
47
+
48
+ /** The element that should carry the cursor: the deepest last text block. */
49
+ function caretHost(root: HTMLElement): HTMLElement | null {
50
+ let el: Element | null = root
51
+ let host: HTMLElement | null = null
52
+ while (el && el.lastElementChild) {
53
+ el = el.lastElementChild
54
+ if (el.matches('p, li, h1, h2, h3, h4, h5, h6, td, th, blockquote, code')) host = el as HTMLElement
55
+ }
56
+ return host
57
+ }
58
+
59
+ export function StreamReveal({
60
+ text,
61
+ active: requested,
62
+ render,
63
+ onProgress,
64
+ onDone,
65
+ className = '',
66
+ }: {
67
+ text: string
68
+ active: boolean
69
+ render: (visibleText: string) => React.ReactNode
70
+ onProgress?: () => void
71
+ onDone?: () => void
72
+ className?: string
73
+ }) {
74
+ // Never animate where motion can't or shouldn't run (Settings β†’ Motion off,
75
+ // the OS reduce-motion setting, unit tests): show the answer whole.
76
+ const active = requested && motionAllowed()
77
+ const [end, setEnd] = useState<number>(() => (active ? 0 : text.length))
78
+ const [running, setRunning] = useState(active)
79
+ const rootRef = useRef<HTMLDivElement>(null)
80
+ const rafRef = useRef(0)
81
+ const doneRef = useRef(onDone)
82
+ const progressRef = useRef(onProgress)
83
+ doneRef.current = onDone
84
+ progressRef.current = onProgress
85
+
86
+ useEffect(() => {
87
+ if (!active) {
88
+ setEnd(text.length)
89
+ setRunning(false)
90
+ return
91
+ }
92
+ const stops = revealStops(text)
93
+ const total = revealDuration(stops.length)
94
+ const started = performance.now()
95
+ let last = 0
96
+ setRunning(true)
97
+ const tick = (now: number) => {
98
+ const t = Math.min(1, (now - started) / total)
99
+ if (t >= 1) {
100
+ setEnd(text.length)
101
+ setRunning(false)
102
+ doneRef.current?.()
103
+ return
104
+ }
105
+ if (now - last >= FRAME_MS) {
106
+ last = now
107
+ // Ease out: quick start, gentle finish.
108
+ const eased = 1 - (1 - t) ** 2
109
+ setEnd(stops[Math.min(stops.length - 1, Math.floor(eased * stops.length))])
110
+ progressRef.current?.()
111
+ }
112
+ rafRef.current = requestAnimationFrame(tick)
113
+ }
114
+ rafRef.current = requestAnimationFrame(tick)
115
+ return () => cancelAnimationFrame(rafRef.current)
116
+ }, [active, text])
117
+
118
+ // Put the cursor at the end of the last line that is showing.
119
+ useLayoutEffect(() => {
120
+ const root = rootRef.current
121
+ if (!root) return
122
+ root.querySelectorAll('[data-hp-caret]').forEach((el) => el.removeAttribute('data-hp-caret'))
123
+ if (running) caretHost(root)?.setAttribute('data-hp-caret', '')
124
+ })
125
+
126
+ const finish = () => {
127
+ if (!running) return
128
+ cancelAnimationFrame(rafRef.current)
129
+ setEnd(text.length)
130
+ setRunning(false)
131
+ doneRef.current?.()
132
+ }
133
+
134
+ return (
135
+ <div
136
+ ref={rootRef}
137
+ className={`${running ? 'hp-stream' : ''} ${className}`}
138
+ aria-busy={running || undefined}
139
+ onClick={running ? finish : undefined}
140
+ title={running ? 'Show the whole answer' : undefined}
141
+ >
142
+ {render(running ? partialMarkdown(text, end) : text)}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ export default StreamReveal
frontend/src/ui/motion/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export * from './prefs'
2
+ export * from './Motion'
3
+ export { StreamReveal, revealStops, revealDuration, partialMarkdown } from './StreamReveal'
4
+ export { MotionGallery } from './MotionGallery'
frontend/src/ui/motion/motion.css ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════════════════════════════════
2
+ HomePilot motion system
3
+ ──────────────────────────────────────────────────────────────────────────
4
+ One restrained vocabulary of animations used by chat, voice, dialogs and
5
+ navigation. Everything here is additive: a class does nothing until a
6
+ component opts in, and the existing animations elsewhere are untouched.
7
+
8
+ <html data-hp-motion="enterprise|minimal|off"> (set from Settings β†’ Motion)
9
+ chooses how much plays; the OS "reduce motion" setting always wins.
10
+
11
+ Numbers follow the catalogue in docs/chat-motion.md (1–22).
12
+ ══════════════════════════════════════════════════════════════════════════ */
13
+
14
+ :root {
15
+ --hp-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
16
+ --hp-ease-in: cubic-bezier(0.4, 0, 1, 1);
17
+ --hp-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
18
+ --hp-dur-fast: 140ms;
19
+ --hp-dur: 220ms;
20
+ --hp-dur-slow: 320ms;
21
+ --hp-motion-ink: rgba(255, 255, 255, 0.5); /* 5.3:1 on the app background */
22
+ --hp-motion-ink-peak: rgba(255, 255, 255, 0.95);
23
+ }
24
+
25
+ /* 1 β€” Text shimmer: a band of light sweeps across a working label. */
26
+ .hp-shimmer-text {
27
+ color: var(--hp-motion-ink);
28
+ background-image: linear-gradient(
29
+ 100deg,
30
+ var(--hp-motion-ink) 0%,
31
+ var(--hp-motion-ink) 42%,
32
+ var(--hp-motion-ink-peak) 50%,
33
+ var(--hp-motion-ink) 58%,
34
+ var(--hp-motion-ink) 100%
35
+ );
36
+ background-size: 250% 100%;
37
+ background-position: 100% 0;
38
+ -webkit-background-clip: text;
39
+ background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ animation: hp-shimmer 2.2s linear infinite;
42
+ }
43
+ @keyframes hp-shimmer {
44
+ from { background-position: 100% 0; }
45
+ to { background-position: 0% 0; }
46
+ }
47
+
48
+ /* 2 β€” Text opacity pulse: a label gently brightens and dims. */
49
+ .hp-pulse-text { animation: hp-opacity-pulse 1.8s ease-in-out infinite; }
50
+ @keyframes hp-opacity-pulse {
51
+ 0%, 100% { opacity: 1; }
52
+ 50% { opacity: 0.5; }
53
+ }
54
+
55
+ /* 3 + 16 β€” Streaming text / message reveal: the cursor rides the end of the
56
+ last line while an answer is revealed (see StreamReveal.tsx). */
57
+ [data-hp-caret]::after,
58
+ .hp-caret {
59
+ content: '';
60
+ display: inline-block;
61
+ width: 0.55em;
62
+ height: 0.55em;
63
+ margin-left: 0.3em;
64
+ vertical-align: 0.05em;
65
+ border-radius: 50%;
66
+ background: currentColor;
67
+ animation: hp-caret 1s ease-in-out infinite;
68
+ }
69
+ /* 4 β€” Typing cursor */
70
+ @keyframes hp-caret {
71
+ 0%, 100% { opacity: 1; transform: scale(1); }
72
+ 50% { opacity: 0.35; transform: scale(0.8); }
73
+ }
74
+ .hp-stream { cursor: default; }
75
+
76
+ /* 5 β€” Loading dots */
77
+ .hp-dots { display: inline-flex; align-items: center; gap: 4px; height: 1em; }
78
+ .hp-dots > span {
79
+ width: 6px;
80
+ height: 6px;
81
+ border-radius: 50%;
82
+ background: currentColor;
83
+ opacity: 0.3;
84
+ animation: hp-dot 1.2s var(--hp-ease-out) infinite;
85
+ }
86
+ .hp-dots > span:nth-child(2) { animation-delay: 0.15s; }
87
+ .hp-dots > span:nth-child(3) { animation-delay: 0.3s; }
88
+ @keyframes hp-dot {
89
+ 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
90
+ 40% { opacity: 1; transform: translateY(-3px); }
91
+ }
92
+
93
+ /* 6 β€” Loading spinner (inline size; the large one is .hp-spinner) */
94
+ .hp-spin {
95
+ display: inline-block;
96
+ flex: 0 0 auto;
97
+ width: var(--hp-spin-size, 16px);
98
+ height: var(--hp-spin-size, 16px);
99
+ border-radius: 50%;
100
+ border: 2px solid rgba(255, 255, 255, 0.18);
101
+ border-top-color: currentColor;
102
+ animation: hp-spin 0.8s linear infinite;
103
+ }
104
+
105
+ /* 7 β€” Skeleton shimmer */
106
+ .hp-skeleton {
107
+ position: relative;
108
+ display: block;
109
+ overflow: hidden;
110
+ background: rgba(255, 255, 255, 0.06);
111
+ border-radius: 8px;
112
+ }
113
+ .hp-skeleton::after {
114
+ content: '';
115
+ position: absolute;
116
+ inset: 0;
117
+ transform: translateX(-100%);
118
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
119
+ animation: hp-skeleton 1.4s ease-in-out infinite;
120
+ }
121
+ @keyframes hp-skeleton { to { transform: translateX(100%); } }
122
+
123
+ /* 8 + 9 β€” Fade in / fade out */
124
+ .hp-enter-fade { animation: hp-fade-in var(--hp-dur) var(--hp-ease-out) backwards; }
125
+ .hp-exit-fade { animation: hp-fade-out var(--hp-dur-fast) var(--hp-ease-in) forwards; }
126
+ @keyframes hp-fade-in { from { opacity: 0; } }
127
+ @keyframes hp-fade-out { to { opacity: 0; } }
128
+
129
+ /* 10 + 11 β€” Slide in / slide out */
130
+ .hp-enter-slide-left { animation: hp-slide-from-left var(--hp-dur) var(--hp-ease-out) backwards; }
131
+ .hp-exit-slide-left { animation: hp-slide-to-left var(--hp-dur-fast) var(--hp-ease-in) forwards; }
132
+ .hp-enter-slide-up { animation: hp-slide-from-below var(--hp-dur) var(--hp-ease-out) backwards; }
133
+ .hp-exit-slide-down { animation: hp-slide-to-below var(--hp-dur-fast) var(--hp-ease-in) forwards; }
134
+ @keyframes hp-slide-from-left { from { opacity: 0; transform: translateX(-16px); } }
135
+ @keyframes hp-slide-to-left { to { opacity: 0; transform: translateX(-16px); } }
136
+ @keyframes hp-slide-from-below { from { opacity: 0; transform: translateY(16px); } }
137
+ @keyframes hp-slide-to-below { to { opacity: 0; transform: translateY(16px); } }
138
+
139
+ /* 12 + 13 β€” Accordion expand / collapse (Collapsible, and <details class="hp-accordion">) */
140
+ .hp-collapsible {
141
+ display: grid;
142
+ grid-template-rows: 0fr;
143
+ opacity: 0;
144
+ visibility: hidden;
145
+ transition: grid-template-rows var(--hp-dur) var(--hp-ease-out), opacity var(--hp-dur) var(--hp-ease-out), visibility 0s linear var(--hp-dur);
146
+ }
147
+ .hp-collapsible[data-open='true'] {
148
+ grid-template-rows: 1fr;
149
+ opacity: 1;
150
+ visibility: visible;
151
+ transition: grid-template-rows var(--hp-dur) var(--hp-ease-out), opacity var(--hp-dur) var(--hp-ease-out), visibility 0s;
152
+ }
153
+ .hp-collapsible > * { min-height: 0; overflow: hidden; }
154
+ details.hp-accordion > summary { cursor: pointer; list-style: none; }
155
+ details.hp-accordion > summary::-webkit-details-marker { display: none; }
156
+ details.hp-accordion > summary::before {
157
+ content: '';
158
+ display: inline-block;
159
+ width: 0.45em;
160
+ height: 0.45em;
161
+ margin-right: 0.5em;
162
+ border-right: 1.5px solid currentColor;
163
+ border-bottom: 1.5px solid currentColor;
164
+ transform: rotate(-45deg);
165
+ transition: transform var(--hp-dur) var(--hp-ease-out);
166
+ }
167
+ details.hp-accordion[open] > summary::before { transform: rotate(45deg); }
168
+ @supports selector(::details-content) {
169
+ details.hp-accordion { interpolate-size: allow-keywords; }
170
+ details.hp-accordion::details-content {
171
+ block-size: 0;
172
+ overflow-y: clip;
173
+ opacity: 0;
174
+ transition: block-size var(--hp-dur) var(--hp-ease-out), opacity var(--hp-dur) var(--hp-ease-out),
175
+ content-visibility var(--hp-dur) allow-discrete;
176
+ }
177
+ details.hp-accordion[open]::details-content { block-size: auto; opacity: 1; }
178
+ }
179
+
180
+ /* 14 + 15 β€” Button hover transition and press feedback, for every button that
181
+ does not already declare its own. `scale` composes with any transform a
182
+ button already has, and :where() keeps this weaker than any utility class. */
183
+ :where(button, [role='button'], a, summary) {
184
+ transition-property: background-color, border-color, color, box-shadow, opacity, scale;
185
+ transition-duration: var(--hp-dur-fast);
186
+ transition-timing-function: ease-out;
187
+ }
188
+ :where(button, [role='button']):not(:disabled, [aria-disabled='true'], [data-hp-press='off']):active { scale: 0.97; }
189
+
190
+ /* 16 β€” Message reveal: new turns rise into place. */
191
+ .hp-msg-in { animation: hp-msg-in var(--hp-dur-slow) var(--hp-ease-out) backwards; }
192
+ .hp-msg-in--user { animation-name: hp-msg-in-user; transform-origin: 100% 100%; }
193
+ @keyframes hp-msg-in { from { opacity: 0; transform: translateY(8px); } }
194
+ @keyframes hp-msg-in-user { from { opacity: 0; transform: translateY(6px) scale(0.98); } }
195
+
196
+ /* 17 β€” Tool status transition: the old label leaves upward as the new one arrives. */
197
+ .hp-status-swap { display: inline-grid; vertical-align: bottom; overflow: hidden; }
198
+ .hp-status-swap > * { grid-area: 1 / 1; white-space: nowrap; }
199
+ .hp-status-swap__in { animation: hp-status-in var(--hp-dur) var(--hp-ease-out) backwards; }
200
+ .hp-status-swap__out { animation: hp-status-out var(--hp-dur) var(--hp-ease-in) forwards; }
201
+ @keyframes hp-status-in { from { opacity: 0; transform: translateY(60%); } }
202
+ @keyframes hp-status-out { to { opacity: 0; transform: translateY(-60%); } }
203
+
204
+ /* 18 β€” Voice orb. --hp-level (0..1) is the live audio level. */
205
+ .hp-orb {
206
+ --hp-level: 0;
207
+ position: relative;
208
+ width: var(--hp-orb-size, 128px);
209
+ aspect-ratio: 1;
210
+ border-radius: 50%;
211
+ isolation: isolate;
212
+ background:
213
+ radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.95) 0%, rgba(232, 236, 248, 0.75) 18%, rgba(160, 172, 205, 0.45) 46%, rgba(70, 78, 102, 0.35) 72%, rgba(30, 32, 40, 0.6) 100%);
214
+ box-shadow:
215
+ inset 0 -10px 24px rgba(0, 0, 0, 0.35),
216
+ 0 0 calc(18px + var(--hp-level) * 46px) rgba(200, 210, 240, calc(0.12 + var(--hp-level) * 0.28));
217
+ transform: scale(calc(1 + var(--hp-level) * 0.14));
218
+ transition: transform 90ms linear, box-shadow 120ms linear;
219
+ }
220
+ .hp-orb::before,
221
+ .hp-orb::after {
222
+ content: '';
223
+ position: absolute;
224
+ inset: 0;
225
+ border-radius: 50%;
226
+ pointer-events: none;
227
+ }
228
+ .hp-orb[data-state='idle'] { animation: hp-orb-breathe 4.5s ease-in-out infinite; }
229
+ .hp-orb[data-state='thinking']::before {
230
+ background: conic-gradient(from 0deg, transparent 0 60%, rgba(255, 255, 255, 0.45) 75%, transparent 90%);
231
+ mask: radial-gradient(circle, transparent 58%, #000 60%, #000 70%, transparent 72%);
232
+ -webkit-mask: radial-gradient(circle, transparent 58%, #000 60%, #000 70%, transparent 72%);
233
+ animation: hp-orb-orbit 1.6s linear infinite;
234
+ }
235
+ .hp-orb[data-state='speaking']::after {
236
+ border: 1.5px solid rgba(220, 228, 248, 0.5);
237
+ animation: hp-orb-ring 1.8s var(--hp-ease-out) infinite;
238
+ }
239
+ .hp-orb[data-state='listening']::after {
240
+ border: 1px solid rgba(220, 228, 248, calc(0.15 + var(--hp-level) * 0.6));
241
+ transform: scale(calc(1.08 + var(--hp-level) * 0.2));
242
+ transition: transform 90ms linear;
243
+ }
244
+ @keyframes hp-orb-breathe {
245
+ 0%, 100% { scale: 1; }
246
+ 50% { scale: 1.035; }
247
+ }
248
+ @keyframes hp-orb-orbit { to { transform: rotate(360deg); } }
249
+ @keyframes hp-orb-ring {
250
+ from { opacity: 0.8; transform: scale(1); }
251
+ to { opacity: 0; transform: scale(1.45); }
252
+ }
253
+
254
+ /* 19 β€” Audio waveform. Each bar's height follows the level; a slow ripple
255
+ (on `scale`, which composes with the level transform) keeps it alive. */
256
+ .hp-wave { display: inline-flex; align-items: center; gap: 3px; height: var(--hp-wave-h, 28px); }
257
+ .hp-wave > span {
258
+ width: 3px;
259
+ height: 100%;
260
+ border-radius: 999px;
261
+ background: currentColor;
262
+ transform-origin: 50% 50%;
263
+ transition: transform 90ms linear;
264
+ }
265
+ .hp-wave[data-active='true'] > span { animation: hp-wave-ripple 1.1s ease-in-out infinite; }
266
+ .hp-wave[data-active='false'] > span { opacity: 0.45; }
267
+ @keyframes hp-wave-ripple {
268
+ 0%, 100% { scale: 1 0.82; }
269
+ 50% { scale: 1 1; }
270
+ }
271
+
272
+ /* 20 β€” Modal transition (ModalSheet): rises in; leaves as a fading copy. */
273
+ .hp-sheet-backdrop { animation: hp-fade-in var(--hp-dur) var(--hp-ease-out) backwards; }
274
+ .hp-sheet { animation: hp-sheet-in var(--hp-dur) var(--hp-ease-out) backwards; }
275
+ .hp-sheet-backdrop[data-leaving] { animation: hp-fade-out 180ms var(--hp-ease-in) forwards; pointer-events: none; }
276
+ .hp-sheet-backdrop[data-leaving] .hp-sheet { animation: hp-sheet-out 180ms var(--hp-ease-in) forwards; }
277
+ @keyframes hp-sheet-in { from { opacity: 0; transform: translateY(10px) scale(0.985); } }
278
+ @keyframes hp-sheet-out { to { opacity: 0; transform: translateY(6px) scale(0.985); } }
279
+ @media (max-width: 639.98px) {
280
+ .hp-sheet { animation-name: hp-sheet-up; }
281
+ .hp-sheet-backdrop[data-leaving] .hp-sheet { animation-name: hp-sheet-down; }
282
+ @keyframes hp-sheet-up { from { transform: translateY(24px); opacity: 0; } }
283
+ @keyframes hp-sheet-down { to { transform: translateY(24px); opacity: 0; } }
284
+ }
285
+
286
+ /* 21 β€” Sidebar transition (mobile drawer and its backdrop) */
287
+ .hp-drawer { transition: transform var(--hp-dur-slow) var(--hp-ease-drawer), visibility 0s linear var(--hp-dur-slow); }
288
+ .hp-drawer[data-open='true'] { transition: transform var(--hp-dur-slow) var(--hp-ease-drawer), visibility 0s; }
289
+ .hp-drawer[data-open='false'] { visibility: hidden; }
290
+ .hp-drawer-backdrop {
291
+ opacity: 0;
292
+ pointer-events: none;
293
+ transition: opacity var(--hp-dur-slow) var(--hp-ease-drawer);
294
+ }
295
+ .hp-drawer-backdrop[data-open='true'] { opacity: 1; pointer-events: auto; }
296
+
297
+ /* 22 β€” Progress indicator */
298
+ .hp-progress {
299
+ position: relative;
300
+ display: block;
301
+ width: 100%;
302
+ height: 3px;
303
+ overflow: hidden;
304
+ border-radius: 999px;
305
+ background: rgba(255, 255, 255, 0.1);
306
+ }
307
+ .hp-progress > span {
308
+ display: block;
309
+ height: 100%;
310
+ border-radius: inherit;
311
+ background: var(--hp-color-text, #f5f5f5);
312
+ transition: width var(--hp-dur-slow) var(--hp-ease-out);
313
+ }
314
+ .hp-progress[data-indeterminate='true'] > span {
315
+ width: 38%;
316
+ animation: hp-progress-sweep 1.3s var(--hp-ease-out) infinite;
317
+ }
318
+ @keyframes hp-progress-sweep {
319
+ from { transform: translateX(-100%); }
320
+ to { transform: translateX(265%); }
321
+ }
322
+
323
+ /* ── Levels ───────────────────────────────────────────────────────────────
324
+ minimal: no sweeps, slides, orbits or press scaling β€” fades and pulses only. */
325
+ html[data-hp-motion='minimal'] .hp-shimmer-text {
326
+ background: none;
327
+ -webkit-text-fill-color: currentColor;
328
+ animation: hp-opacity-pulse 1.8s ease-in-out infinite;
329
+ }
330
+ html[data-hp-motion='minimal'] :is(.hp-enter-slide-left, .hp-enter-slide-up, .hp-msg-in, .hp-sheet) { animation-name: hp-fade-in; }
331
+ html[data-hp-motion='minimal'] :is(.hp-exit-slide-left, .hp-exit-slide-down) { animation-name: hp-fade-out; }
332
+ html[data-hp-motion='minimal'] .hp-sheet-backdrop[data-leaving] .hp-sheet { animation-name: hp-fade-out; }
333
+ html[data-hp-motion='minimal'] :is(.hp-skeleton::after, .hp-orb::before, .hp-orb::after) { animation: none; }
334
+ html[data-hp-motion='minimal'] :where(button, [role='button']):active { scale: none; }
335
+
336
+ /* off (and the OS reduce-motion setting): nothing moves; working labels stay readable. */
337
+ html[data-hp-motion='off'] *,
338
+ html[data-hp-motion='off'] *::before,
339
+ html[data-hp-motion='off'] *::after {
340
+ animation-duration: 0.01ms !important;
341
+ animation-iteration-count: 1 !important;
342
+ transition-duration: 0.01ms !important;
343
+ scroll-behavior: auto !important;
344
+ }
345
+ html[data-hp-motion='off'] :where(button, [role='button']):active { scale: none; }
346
+ html[data-hp-motion='off'] .hp-shimmer-text { background: none; -webkit-text-fill-color: currentColor; }
347
+ @media (prefers-reduced-motion: reduce) {
348
+ .hp-shimmer-text { background: none; -webkit-text-fill-color: currentColor; }
349
+ :where(button, [role='button']):active { scale: none; }
350
+ }
351
+
352
+ /* The orb at rest when voice is off: present, but clearly asleep. */
353
+ .hp-orb[data-state='off'] { opacity: 0.55; filter: saturate(0.5); }
frontend/src/ui/motion/prefs.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Motion preferences β€” how much HomePilot animates, kept per device.
3
+ *
4
+ * Two independent choices:
5
+ * level enterprise every animation in the motion system (the default)
6
+ * minimal quiet: fades and pulses only, no sweeps, slides or press scaling
7
+ * off no animation at all (same effect as the OS "reduce motion")
8
+ * reveal stream a new answer is revealed progressively with a typing cursor
9
+ * fade a new answer fades in whole (HomePilot's previous behaviour)
10
+ * instant a new answer appears at once
11
+ *
12
+ * The choice lives in localStorage (it describes this screen, not the account),
13
+ * is mirrored onto <html data-hp-motion data-hp-reveal> so CSS can follow it,
14
+ * and is announced with a `hp:motion-change` event so open views update live.
15
+ * The operating system's "reduce motion" setting always wins.
16
+ */
17
+ import { useEffect, useReducer, useState } from 'react'
18
+
19
+ export type MotionLevel = 'enterprise' | 'minimal' | 'off'
20
+ export type RevealStyle = 'stream' | 'fade' | 'instant'
21
+ export type MotionPrefs = { level: MotionLevel; reveal: RevealStyle }
22
+
23
+ export const MOTION_STORAGE_KEY = 'homepilot_motion'
24
+ export const MOTION_EVENT = 'hp:motion-change'
25
+ export const DEFAULT_MOTION_PREFS: MotionPrefs = { level: 'enterprise', reveal: 'stream' }
26
+
27
+ const LEVELS: readonly MotionLevel[] = ['enterprise', 'minimal', 'off']
28
+ const REVEALS: readonly RevealStyle[] = ['stream', 'fade', 'instant']
29
+ const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
30
+
31
+ export function readMotionPrefs(): MotionPrefs {
32
+ try {
33
+ const raw = localStorage.getItem(MOTION_STORAGE_KEY)
34
+ if (!raw) return { ...DEFAULT_MOTION_PREFS }
35
+ const parsed = JSON.parse(raw) ?? {}
36
+ return {
37
+ level: LEVELS.includes(parsed.level) ? parsed.level : DEFAULT_MOTION_PREFS.level,
38
+ reveal: REVEALS.includes(parsed.reveal) ? parsed.reveal : DEFAULT_MOTION_PREFS.reveal,
39
+ }
40
+ } catch {
41
+ return { ...DEFAULT_MOTION_PREFS }
42
+ }
43
+ }
44
+
45
+ /** Mirror the preference onto <html> so stylesheets can follow it. */
46
+ export function applyMotionPrefs(prefs: MotionPrefs = readMotionPrefs()): void {
47
+ if (typeof document === 'undefined') return
48
+ const root = document.documentElement
49
+ root.dataset.hpMotion = prefs.level
50
+ root.dataset.hpReveal = prefs.reveal
51
+ }
52
+
53
+ export function writeMotionPrefs(next: Partial<MotionPrefs>): MotionPrefs {
54
+ const merged: MotionPrefs = { ...readMotionPrefs(), ...next }
55
+ try {
56
+ localStorage.setItem(MOTION_STORAGE_KEY, JSON.stringify(merged))
57
+ } catch {
58
+ /* private mode / storage full β€” the choice still applies to this page */
59
+ }
60
+ applyMotionPrefs(merged)
61
+ if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(MOTION_EVENT, { detail: merged }))
62
+ return merged
63
+ }
64
+
65
+ export function prefersReducedMotion(): boolean {
66
+ try {
67
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(REDUCED_QUERY).matches
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Unit tests run in jsdom, which has no layout and no real animation frames.
75
+ * Components render their final state there, so tests see what a person sees
76
+ * once an animation has finished.
77
+ */
78
+ function isTestDom(): boolean {
79
+ return typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || '')
80
+ }
81
+
82
+ /** Whether time-based animation (progressive reveal, exit transitions) should run. */
83
+ export function motionAllowed(prefs: MotionPrefs = readMotionPrefs()): boolean {
84
+ return prefs.level !== 'off' && !prefersReducedMotion() && !isTestDom()
85
+ }
86
+
87
+ /** The current preference, kept up to date when it changes here, in another tab, or in the OS. */
88
+ export function useMotionPrefs(): MotionPrefs {
89
+ const [prefs, setPrefs] = useState<MotionPrefs>(readMotionPrefs)
90
+ const [, rerender] = useReducer((n: number) => n + 1, 0)
91
+ useEffect(() => {
92
+ const sync = () => setPrefs(readMotionPrefs())
93
+ const onStorage = (e: StorageEvent) => {
94
+ if (e.key === MOTION_STORAGE_KEY) {
95
+ applyMotionPrefs()
96
+ sync()
97
+ }
98
+ }
99
+ window.addEventListener(MOTION_EVENT, sync)
100
+ window.addEventListener('storage', onStorage)
101
+ let mq: MediaQueryList | null = null
102
+ try {
103
+ mq = typeof window.matchMedia === 'function' ? window.matchMedia(REDUCED_QUERY) : null
104
+ mq?.addEventListener?.('change', rerender)
105
+ } catch {
106
+ mq = null
107
+ }
108
+ return () => {
109
+ window.removeEventListener(MOTION_EVENT, sync)
110
+ window.removeEventListener('storage', onStorage)
111
+ mq?.removeEventListener?.('change', rerender)
112
+ }
113
+ }, [])
114
+ return prefs
115
+ }
frontend/src/ui/routines/RoutinesView.tsx CHANGED
@@ -583,7 +583,7 @@ export default function RoutinesView({
583
  <div className="mx-auto max-w-6xl px-5 py-8 sm:px-8 sm:py-10">
584
  <header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
585
  <div>
586
- <div className="flex items-center gap-2.5 text-white/40 text-sm">
587
  <CalendarClock size={17} />
588
  Personal automation
589
  </div>
 
583
  <div className="mx-auto max-w-6xl px-5 py-8 sm:px-8 sm:py-10">
584
  <header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
585
  <div>
586
+ <div className="hp-clear-nav -mt-4 sm:mt-0 flex items-center gap-2.5 text-white/55 text-sm">
587
  <CalendarClock size={17} />
588
  Personal automation
589
  </div>
frontend/src/ui/routines/api.ts CHANGED
@@ -23,6 +23,15 @@ export type RoutineTargetProject = {
23
  function headers(apiKey?: string): HeadersInit {
24
  const out: Record<string, string> = { 'Content-Type': 'application/json' }
25
  if (apiKey) out['X-API-Key'] = apiKey
 
 
 
 
 
 
 
 
 
26
  return out
27
  }
28
 
 
23
  function headers(apiKey?: string): HeadersInit {
24
  const out: Record<string, string> = { 'Content-Type': 'application/json' }
25
  if (apiKey) out['X-API-Key'] = apiKey
26
+ // Routines belong to the signed-in user. Without the session token a
27
+ // multi-user install answers "Authentication required" even when signed in
28
+ // (the same Bearer convention as personaApi / profileApi).
29
+ try {
30
+ const token = localStorage.getItem('homepilot_auth_token')
31
+ if (token) out['Authorization'] = `Bearer ${token}`
32
+ } catch {
33
+ /* storage unavailable β€” the session cookie still applies same-origin */
34
+ }
35
  return out
36
  }
37
 
frontend/src/ui/styles.css CHANGED
@@ -462,3 +462,292 @@ select option:hover {
462
  .ms-cap__mode-name { font-weight: 500; }
463
  .ms-cap__mode-note { font-size: 11px; color: rgba(255, 255, 255, 0.4); line-height: 1.45; }
464
  .ms-cap__done { justify-content: center; margin-top: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  .ms-cap__mode-name { font-weight: 500; }
463
  .ms-cap__mode-note { font-size: 11px; color: rgba(255, 255, 255, 0.4); line-height: 1.45; }
464
  .ms-cap__done { justify-content: center; margin-top: 2px; }
465
+
466
+ /* ══════════════════════════════════════════════════════════════════════════
467
+ Mobile design system β€” tokens and layout primitives
468
+ ──────────────────────────────────────────────────────────────────────────
469
+ A restrained, near-black palette with lighter surfaces and quiet borders.
470
+ These are HomePilot's own tokens (not copied from any other product).
471
+ Primitives here fix layout at the root so individual screens don't need
472
+ one-off overrides:
473
+ .hp-page-header β€” a page header that always clears the floating mobile
474
+ menu button, with title, description and actions rows
475
+ .hp-sheet β€” a dialog that is full-screen on phones and a centred
476
+ card on larger screens, with ONE scrolling body and a
477
+ sticky footer above the home indicator
478
+ .hp-tabs β€” tab row that scrolls sideways instead of clipping
479
+ .hp-choice-grid β€” compact category grid (2 columns on phones)
480
+ ══════════════════════════════════════════════════════════════════════════ */
481
+ :root {
482
+ --hp-color-bg: #090909;
483
+ --hp-color-surface: #171717;
484
+ --hp-color-input: #202020;
485
+ --hp-color-text: #f5f5f5;
486
+ --hp-color-text-2: #b0b0b0; /* 9.1:1 on --hp-color-bg */
487
+ --hp-color-border: #303030;
488
+ --hp-space: 4px;
489
+ --hp-pad-x: 16px;
490
+ --hp-control-h: 44px;
491
+ --hp-radius-card: 14px;
492
+ --hp-radius-sheet: 20px;
493
+ /* The floating menu button (40px at a 0.6rem inset) needs this much room. */
494
+ --hp-nav-clear: 3.75rem;
495
+ }
496
+
497
+ /* Page header: on phones the title row starts after the menu button and is
498
+ vertically aligned with it; actions wrap onto their own row. */
499
+ .hp-page-header {
500
+ display: flex;
501
+ flex-wrap: wrap;
502
+ align-items: center;
503
+ justify-content: space-between;
504
+ gap: 0.75rem 1rem;
505
+ min-width: 0;
506
+ }
507
+ .hp-page-header__titles { min-width: 0; flex: 1 1 16rem; }
508
+ .hp-page-header__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; min-width: 0; }
509
+ @media (max-width: 767.98px) {
510
+ .hp-page-header {
511
+ padding-left: max(var(--hp-pad-x), calc(env(safe-area-inset-left) + var(--hp-nav-clear))) !important;
512
+ padding-right: var(--hp-pad-x) !important;
513
+ padding-top: max(0.6rem, env(safe-area-inset-top)) !important;
514
+ padding-bottom: 0.875rem !important;
515
+ }
516
+ .hp-page-header__titles { flex-basis: 100%; min-height: 2.5rem; display: flex; flex-direction: column; justify-content: center; }
517
+ /* Actions get the full width (no longer squeezed beside the title). */
518
+ .hp-page-header__actions {
519
+ flex-basis: 100%;
520
+ margin-left: calc(-1 * (var(--hp-nav-clear) - var(--hp-pad-x)));
521
+ }
522
+ .hp-page-header__actions > * { min-height: var(--hp-control-h); }
523
+ }
524
+
525
+ /* Sheet: full-screen on phones, centred card above. Exactly one scroller. */
526
+ .hp-sheet-backdrop {
527
+ position: fixed;
528
+ inset: 0;
529
+ display: flex;
530
+ align-items: center;
531
+ justify-content: center;
532
+ padding: 1rem;
533
+ background: rgba(0, 0, 0, 0.6);
534
+ -webkit-backdrop-filter: blur(4px);
535
+ backdrop-filter: blur(4px);
536
+ }
537
+ .hp-sheet {
538
+ display: flex;
539
+ flex-direction: column;
540
+ width: 100%;
541
+ max-width: 48rem;
542
+ max-height: min(90vh, 52rem);
543
+ max-height: min(90dvh, 52rem);
544
+ min-height: 0;
545
+ overflow: hidden;
546
+ background: var(--hp-color-bg);
547
+ border: 1px solid var(--hp-color-border);
548
+ border-radius: var(--hp-radius-sheet);
549
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
550
+ }
551
+ .hp-sheet__header, .hp-sheet__footer { flex: 0 0 auto; }
552
+ .hp-sheet__body {
553
+ flex: 1 1 auto;
554
+ min-height: 0;
555
+ overflow-y: auto;
556
+ overscroll-behavior: contain;
557
+ -webkit-overflow-scrolling: touch;
558
+ }
559
+ .hp-sheet__footer { padding-bottom: max(1rem, env(safe-area-inset-bottom)); }
560
+ @media (max-width: 639.98px) {
561
+ .hp-sheet-backdrop { padding: 0; align-items: stretch; }
562
+ .hp-sheet {
563
+ max-width: none;
564
+ height: 100%;
565
+ max-height: none;
566
+ border: 0;
567
+ border-radius: 0;
568
+ box-shadow: none;
569
+ padding-top: env(safe-area-inset-top);
570
+ }
571
+ }
572
+
573
+ /* Tabs scroll sideways rather than clipping; the active tab is scrolled into view. */
574
+ .hp-tabs {
575
+ display: flex;
576
+ gap: 0.25rem;
577
+ overflow-x: auto;
578
+ scrollbar-width: none;
579
+ -webkit-overflow-scrolling: touch;
580
+ scroll-snap-type: x proximity;
581
+ max-width: 100%;
582
+ }
583
+ .hp-tabs::-webkit-scrollbar { display: none; }
584
+ .hp-tabs > * { flex: 0 0 auto; scroll-snap-align: start; white-space: nowrap; min-height: 2.5rem; }
585
+
586
+ /* Compact category choices: two columns on phones, four on tablets. */
587
+ .hp-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; }
588
+ .hp-choice-grid > * { min-height: var(--hp-control-h); min-width: 0; }
589
+ @media (min-width: 640px) { .hp-choice-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
590
+ /* Where the grid sits in a measured container (.hp-cq), its own width decides β€”
591
+ so a narrow column inside a wide window (or a narrow embedded frame) still
592
+ gets two columns. The viewport rule above is the fallback. */
593
+ .hp-cq { container-type: inline-size; }
594
+ @container (max-width: 29.99rem) { .hp-choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
595
+ @container (min-width: 30rem) { .hp-choice-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
596
+
597
+ /* Startup and error screens share one calm, legible layout. */
598
+ .hp-status-screen {
599
+ min-height: 100vh;
600
+ min-height: 100dvh;
601
+ display: flex;
602
+ align-items: center;
603
+ justify-content: center;
604
+ padding: 1.5rem;
605
+ background: var(--hp-color-bg);
606
+ color: var(--hp-color-text);
607
+ font-family: Inter, system-ui, sans-serif;
608
+ }
609
+ .hp-status-card { width: 100%; max-width: 26rem; text-align: center; }
610
+ .hp-status-card h1 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0 0.5rem; }
611
+ .hp-status-card p { color: var(--hp-color-text-2); font-size: 0.95rem; line-height: 1.5; margin: 0 0 1rem; }
612
+ .hp-status-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
613
+ .hp-status-actions button {
614
+ min-height: var(--hp-control-h);
615
+ padding: 0 1.1rem;
616
+ border-radius: 999px;
617
+ border: 1px solid var(--hp-color-border);
618
+ background: var(--hp-color-surface);
619
+ color: var(--hp-color-text);
620
+ font-weight: 600;
621
+ }
622
+ .hp-status-actions button.hp-primary { background: var(--hp-color-text); color: #000; border-color: transparent; }
623
+ .hp-spinner {
624
+ width: 28px;
625
+ height: 28px;
626
+ margin: 0 auto;
627
+ border-radius: 50%;
628
+ border: 3px solid rgba(255, 255, 255, 0.15);
629
+ border-top-color: rgba(255, 255, 255, 0.85);
630
+ animation: hp-spin 0.9s linear infinite;
631
+ }
632
+ @keyframes hp-spin { to { transform: rotate(360deg); } }
633
+
634
+ @media (prefers-reduced-motion: reduce) {
635
+ *, *::before, *::after {
636
+ animation-duration: 0.01ms !important;
637
+ animation-iteration-count: 1 !important;
638
+ transition-duration: 0.01ms !important;
639
+ scroll-behavior: auto !important;
640
+ }
641
+ }
642
+
643
+ /* Any header row that sits under the floating mobile menu button. */
644
+ @media (max-width: 767.98px) {
645
+ .hp-clear-nav {
646
+ padding-left: max(var(--hp-pad-x), calc(env(safe-area-inset-left) + var(--hp-nav-clear))) !important;
647
+ min-height: 2.75rem;
648
+ }
649
+ }
650
+
651
+ /* The composer placeholder stays on one line (it used to wrap inside the
652
+ one-line field on phones and get cut off mid-word). */
653
+ .hp-composer-input::placeholder {
654
+ white-space: nowrap;
655
+ overflow: hidden;
656
+ text-overflow: ellipsis;
657
+ }
658
+
659
+ /* Header actions share a row when they fit and stack on very narrow phones. */
660
+ @media (max-width: 767.98px) {
661
+ .hp-grow-action { flex: 1 1 9.5rem; }
662
+ }
663
+
664
+ /* A small icon action pinned to the header's top-right corner on phones, so it
665
+ doesn't wrap onto a row of its own under the main actions. */
666
+ @media (max-width: 767.98px) {
667
+ .hp-page-header:has(.hp-header-corner) .hp-page-header__titles { padding-right: 2.75rem; }
668
+ .hp-header-corner {
669
+ position: absolute;
670
+ top: max(0.6rem, env(safe-area-inset-top));
671
+ right: max(0.5rem, env(safe-area-inset-right));
672
+ min-width: var(--hp-control-h);
673
+ min-height: var(--hp-control-h) !important;
674
+ display: inline-flex;
675
+ align-items: center;
676
+ justify-content: center;
677
+ }
678
+ }
679
+
680
+ /* ── Touch targets ────────────────────────────────────────────────────────
681
+ Small icon buttons keep their look but get a 44Γ—44 hit area on touch
682
+ screens: an invisible ::before grows the target to at least 44px each way. */
683
+ .hp-icon-btn:not(.absolute, .fixed, .sticky) { position: relative; }
684
+ @media (pointer: coarse) {
685
+ .hp-icon-btn::before {
686
+ content: '';
687
+ position: absolute;
688
+ inset: min(0px, calc((100% - 44px) / 2));
689
+ }
690
+ /* List rows in the drawer (recent chats, "New conversation", "View all"). */
691
+ .hp-touch-row { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; }
692
+ /* Keyboard shortcut hints mean nothing on a phone. */
693
+ .hp-kbd-hint { display: none !important; }
694
+ /* iOS zooms into any focused field under 16px β€” selects and untyped inputs too. */
695
+ input:not([type]), input[type="tel"], select { font-size: max(16px, 1em); }
696
+ }
697
+
698
+ /* ── Keyboard focus ───────────────────────────────────────────────────────
699
+ Every control shows a clear ring when reached by keyboard (never on mouse
700
+ or touch). Stronger than `outline-none` utilities. Text fields keep their
701
+ own focus styling β€” they match :focus-visible on every click. */
702
+ :is(a, button, summary, [tabindex]:not(input, textarea, select), [role="button"], [role="tab"], [role="radio"], [role="menuitem"], [role="switch"]):focus-visible {
703
+ outline: 2px solid rgba(245, 245, 245, 0.85);
704
+ outline-offset: 2px;
705
+ }
706
+
707
+ /* ── Secondary text contrast ──────────────────────────────────────────────
708
+ Muted labels used to go as low as 25–40 % white (2–3.6:1 on the app
709
+ background). These floors keep the same hierarchy while meeting the 4.5:1
710
+ WCAG AA minimum for text; hover variants and decorative 10–20 % tints are
711
+ untouched. */
712
+ .text-white\/25 { color: rgb(255 255 255 / 0.47); }
713
+ .text-white\/30 { color: rgb(255 255 255 / 0.5); }
714
+ .text-white\/35 { color: rgb(255 255 255 / 0.52); }
715
+ .text-white\/40 { color: rgb(255 255 255 / 0.55); }
716
+ .placeholder\:text-white\/20::placeholder,
717
+ .placeholder\:text-white\/25::placeholder,
718
+ .placeholder\:text-white\/30::placeholder,
719
+ .placeholder\:text-white\/35::placeholder,
720
+ .placeholder\:text-white\/40::placeholder { color: rgb(255 255 255 / 0.5); }
721
+
722
+ /* The shell sits on the shared background token. */
723
+ .hp-app-shell { background-color: var(--hp-color-bg); }
724
+
725
+ /* Short screens (a phone on its side, or any phone with the keyboard open):
726
+ the sheet goes full-screen and scrolls as one page β€” header and tabs move
727
+ away so the field being edited has room β€” while Save stays pinned above the
728
+ keyboard. scroll-padding keeps a focused field clear of the pinned footer. */
729
+ @media (max-height: 500px) {
730
+ .hp-sheet-backdrop { padding: 0; align-items: stretch; }
731
+ .hp-sheet {
732
+ max-width: none;
733
+ height: 100%;
734
+ max-height: none;
735
+ border: 0;
736
+ border-radius: 0;
737
+ box-shadow: none;
738
+ overflow-y: auto;
739
+ overscroll-behavior: contain;
740
+ scroll-padding-bottom: 5.5rem;
741
+ }
742
+ .hp-sheet__body { flex: 0 0 auto; overflow: visible; min-height: auto; }
743
+ .hp-sheet__footer { position: sticky; bottom: 0; z-index: 1; }
744
+ }
745
+
746
+ /* Chat: the composer dock and the phone header scrim blend into the page
747
+ background instead of drawing a slightly darker box. */
748
+ .hp-composer-dock {
749
+ background: linear-gradient(to top, var(--hp-color-bg) 78%, rgba(9, 9, 9, 0));
750
+ }
751
+ .hp-top-scrim {
752
+ background: linear-gradient(to bottom, var(--hp-color-bg) 0%, rgba(9, 9, 9, 0.85) 55%, rgba(9, 9, 9, 0) 100%);
753
+ }
frontend/tailwind.config.js CHANGED
@@ -1,6 +1,9 @@
1
  /** @type {import('tailwindcss').Config} */
2
  export default {
3
  content: ["./index.html", "./src/**/*.{ts,tsx}"],
 
 
 
4
  theme: {
5
  extend: {
6
  boxShadow: { soft: "0 10px 30px rgba(0,0,0,0.35)" },
 
1
  /** @type {import('tailwindcss').Config} */
2
  export default {
3
  content: ["./index.html", "./src/**/*.{ts,tsx}"],
4
+ // `hover:` styles only where a real hover exists, so a tapped button on a
5
+ // phone doesn't stay highlighted after the finger lifts.
6
+ future: { hoverOnlyWhenSupported: true },
7
  theme: {
8
  extend: {
9
  boxShadow: { soft: "0 10px 30px rgba(0,0,0,0.35)" },