HomePilot Deploy Bot commited on
Commit Β·
ad48a43
1
Parent(s): d85b299
chore(hf): sync HomePilot to HF Space
Browse files- .gitattributes +4 -4
- frontend/.cache-bust +1 -1
- frontend/index.html +34 -2
- frontend/src/main.tsx +9 -0
- frontend/src/test/mobileShell.test.tsx +146 -0
- frontend/src/test/motionSystem.test.tsx +178 -0
- frontend/src/ui/AboutDialog.tsx +4 -0
- frontend/src/ui/App.tsx +268 -101
- frontend/src/ui/Models.tsx +47 -20
- frontend/src/ui/ProfileSettingsModal.tsx +109 -64
- frontend/src/ui/SettingsPanel.tsx +16 -0
- frontend/src/ui/SystemStatusDialog.tsx +4 -0
- frontend/src/ui/VoiceModeGrok.tsx +14 -4
- frontend/src/ui/avatar/AvatarLandingPage.tsx +1 -1
- frontend/src/ui/components/AppErrorBoundary.tsx +88 -0
- frontend/src/ui/components/AuthGate.tsx +57 -16
- frontend/src/ui/components/ComposerPlusMenu.tsx +1 -1
- frontend/src/ui/components/MessageMarkdown.tsx +2 -1
- frontend/src/ui/components/ModalSheet.tsx +140 -0
- frontend/src/ui/lib/backendUrl.test.ts +14 -0
- frontend/src/ui/lib/backendUrl.ts +5 -1
- frontend/src/ui/lib/useBackToClose.ts +64 -0
- frontend/src/ui/motion/Motion.tsx +248 -0
- frontend/src/ui/motion/MotionGallery.tsx +250 -0
- frontend/src/ui/motion/StreamReveal.tsx +147 -0
- frontend/src/ui/motion/index.ts +4 -0
- frontend/src/ui/motion/motion.css +353 -0
- frontend/src/ui/motion/prefs.ts +115 -0
- frontend/src/ui/routines/RoutinesView.tsx +1 -1
- frontend/src/ui/routines/api.ts +9 -0
- frontend/src/ui/styles.css +289 -0
- frontend/tailwind.config.js +3 -0
.gitattributes
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
*.
|
|
|
|
| 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 |
-
|
|
|
|
| 1 |
+
1791564573
|
frontend/index.html
CHANGED
|
@@ -4,12 +4,44 @@
|
|
| 4 |
<html lang="en">
|
| 5 |
<head>
|
| 6 |
<meta charset="UTF-8" />
|
| 7 |
-
<
|
|
|
|
|
|
|
| 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"><
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 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 |
-
{/*
|
| 1534 |
-
|
| 1535 |
-
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1542 |
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 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 |
-
//
|
| 2417 |
-
//
|
| 2418 |
-
//
|
|
|
|
| 2419 |
return (
|
| 2420 |
-
<div className="flex items-center gap-3">
|
| 2421 |
-
<
|
| 2422 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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=
|
| 2708 |
-
<
|
| 2709 |
text={m.media?.images?.length
|
| 2710 |
? (m.text || '').replace(/!\[[^\]]*\]\(media:\/\/[^)]+\)\s*/g, '').trim()
|
| 2711 |
: m.text}
|
| 2712 |
-
|
| 2713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 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="
|
| 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/
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 5847 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5848 |
<div
|
| 5849 |
-
className="fixed inset-0 z-
|
|
|
|
| 5850 |
onClick={() => setMobileSidebarOpen(false)}
|
| 5851 |
aria-hidden
|
| 5852 |
/>
|
| 5853 |
)}
|
| 5854 |
<div
|
|
|
|
| 5855 |
className={
|
| 5856 |
isMobile
|
| 5857 |
-
? `fixed inset-y-0 left-0 z-
|
| 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-
|
| 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 |
-
|
|
|
|
|
|
|
| 1239 |
{/* Header */}
|
| 1240 |
-
<div className="px-8 py-6 border-b border-white/10
|
| 1241 |
-
<div>
|
| 1242 |
-
<
|
| 1243 |
-
<div className="text-sm text-white/
|
| 1244 |
</div>
|
| 1245 |
|
| 1246 |
-
<div className="
|
| 1247 |
<button
|
| 1248 |
type="button"
|
| 1249 |
onClick={() => refreshCatalog()}
|
| 1250 |
disabled={catalogLoading}
|
| 1251 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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/
|
| 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-[
|
| 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-[
|
| 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-[
|
| 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 |
-
<
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
<div className="
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
</div>
|
| 361 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
<button
|
| 363 |
type="button"
|
| 364 |
-
onClick={
|
| 365 |
-
|
|
|
|
| 366 |
>
|
| 367 |
-
|
| 368 |
</button>
|
| 369 |
</div>
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
<div className="px-6
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
{
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
//
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
}
|
| 163 |
|
|
@@ -229,20 +255,35 @@ export default function AuthGate({ children }: AuthGateProps) {
|
|
| 229 |
setState('login')
|
| 230 |
}, [user, backendUrl])
|
| 231 |
|
| 232 |
-
//
|
|
|
|
|
|
|
| 233 |
if (state === 'loading') {
|
| 234 |
return (
|
| 235 |
-
<div
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 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)" },
|