Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Playwright **1.63** (desktop + Pixel 7 + iPad Pro 11 landscape) · Node **20.12+
- **Today:** live `today` = Jarvis `GET /schedule/today`; Overview merges it with the calendar in `src/lib/agenda.ts`. Service alerts go in `AlertBanner` (every page) via `activeAlerts`.
- **Talk:** `server/talk.mjs` proxies the `/api/jarvis/talk` WebSocket (wired in `server/index.mjs`) to Jarvis `/talk/ws?chat=web` (Tailscale login + same-origin `Origin`);
client `src/lib/talk.ts` (worklet resamples mic to 16 kHz PCM16, plays his PCM, sends `idle`; `partial` events = live words while you talk; `working`/`working_done` events (also on the chat NDJSON) put the orb in its amber `working` sweep with the status label, shown even on /talk). Mock data → demo loop.
Full-screen, voice-only Talk page at `/talk` (or `#talk`; starts talking on load, or on the first tap where iOS needs one via `canTalkUnprompted`; one Talk/Pause button plus Mute while talking (`TalkLine.setMuted`: mic track off, silent frames sent, Jarvis still answers and speaks), no typing, no Close): `src/views/TalkPage.tsx` (picked in `src/main.tsx` via `src/lib/talkPage.ts`), `JarvisPanel page`, own `public/talk.webmanifest` (own `id`/scope; `server/talk-head.mjs` serves /talk with that manifest in its head, since iOS ignores the JS swap).
Full-screen, voice-only Talk page at `/talk` (orb size `TALK_PAGE_ORB` = clamp(40px, 12vmin, 160px) in `JarvisPanel`) (or `#talk`; starts talking on load, or on the first tap where iOS needs one via `canTalkUnprompted`; one Talk/Pause button plus Mute while talking (`TalkLine.setMuted`: mic track off, silent frames sent, Jarvis still answers and speaks), no typing, no Close): `src/views/TalkPage.tsx` (picked in `src/main.tsx` via `src/lib/talkPage.ts`), `JarvisPanel page`, own `public/talk.webmanifest` (own `id`/scope; `server/talk-head.mjs` serves /talk with that manifest in its head, since iOS ignores the JS swap).
- **Shell:** `server/shell.mjs` proxies `/shell/` (HTTP + WebSocket upgrade, wired in `server/index.mjs`)
to ttyd on 127.0.0.1:7681 (`deploy/ttyd.service`, tmux) only for `SHELL_USERS` Tailscale logins with a same-origin `Origin`.
- **Sources:** `server/sources/mock.mjs` (default; Pacific-time schedule, remembers
Expand All @@ -52,7 +52,7 @@ Playwright **1.63** (desktop + Pixel 7 + iPad Pro 11 landscape) · Node **20.12+
`src/components/AgendaDetail.tsx` (`j`, `e`, `m`).
- **Kiosk mode:** top-right button (`TopNav`, Close in kiosk) → `src/useKiosk.ts` (full screen, wake lock, cycles `KIOSK_VIEWS` in `src/lib/kiosk.ts` every 20 s, waits 60 s after a touch or while `html[data-modal]`; `home-lab:v1:kiosk` in `prefs.ts`). `KioskMode` context: Money shows only `kioskPurchases`, Services drops the terminal.
- **Always-on iPad:** `src/useWakeLock.ts` + Settings switch (`home-lab:v1:awake` in `prefs.ts`);
`public/manifest.webmanifest` + Apple meta tags in `index.html`. Overview one-screen layout keys off
`public/manifest.webmanifest` + Apple meta tags in `index.html`. `.shell` has no max-width (full width on TVs/kiosk, 2026-11-12). Overview one-screen layout keys off
`.shell[data-view='overview']` (end of `styles.css`). Swipe tabs: `src/useSwipeNav.ts` (finger-follow + glide in `src/swipeMotion.ts`, `main[data-swipe]`; swipes always slide via `swipeToView`). No view transitions: a tab tap sets
`html[data-enter=fade]` and the new panels fade in through `useReveal`; a swipe sets `next`/`prev` panel slides (translate only) (`useHashView.ts`).
- **Views:** `src/views/{Overview,Home,Claude,Money,Services,Schedule,Settings}.tsx`, chosen by
Expand Down
12 changes: 9 additions & 3 deletions src/components/JarvisOrb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ export interface OrbHandle {

interface Props {
state: OrbState
/** Diameter of the core in px; the halo and orbits scale with it. */
size: number
/** Diameter of the core: px, or any CSS length (e.g. a clamp() that follows the screen). The halo and orbits scale with it. */
size: number | string
}

/** The core's size as CSS: a number is px, a string is used as written. */
function orbSize(size: number | string): string {
if (typeof size === 'number') return `${size}px`
return size
}

/** True when the browser asks for less motion (then the orb stays still). */
Expand Down Expand Up @@ -55,7 +61,7 @@ export const JarvisOrb = forwardRef<OrbHandle, Props>(function JarvisOrb({ state
useEffect(() => () => cancelAnimationFrame(frame.current), [])

return (
<span ref={el} className="orb" data-state={state} style={{ '--orb': `${size}px` } as CSSProperties} aria-hidden="true">
<span ref={el} className="orb" data-state={state} style={{ '--orb': orbSize(size) } as CSSProperties} aria-hidden="true">
<span className="orb__halo" />
<span className="orb__wave" />
<span className="orb__scan" />
Expand Down
7 changes: 6 additions & 1 deletion src/components/JarvisPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ interface ApprovalView extends Approval {
error?: string
}

// On /talk the orb follows the screen (Dylan, 2026-11-12): smaller on a
// phone so more words fit, bigger on an iPad or TV across the room.
// 12% of the short side, never under 40 px or over 160 px.
const TALK_PAGE_ORB = 'clamp(40px, 12vmin, 160px)'

// Kept outside React so the conversation survives the Overview unmounting
// when Dylan visits another page and comes back.
let savedTurns: Turn[] = []
Expand Down Expand Up @@ -596,7 +601,7 @@ function ChatSheet({ turns, state, orbRef, text, onText, onSend, talking, page,
</button>
)}
<header className="chat__head">
<JarvisOrb ref={orbRef} state={state} size={page ? 52 : 64} />
<JarvisOrb ref={orbRef} state={state} size={page ? TALK_PAGE_ORB : 64} />
<h2 id="chat-title" className="dialog__title">Jarvis</h2>
{/* /talk shows only "Jarvis" over the orb (Dylan, 2026-11-11); the status stays for
screen readers and shows when there's something to do: a first tap, an OK, or why Talk ended. */}
Expand Down
5 changes: 3 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ code { font-size: 0.92em; background: rgba(255,255,255,.08); padding: 1px 6px; b
.shell[data-sky='day'] .timeline__card { background: rgba(12, 24, 36, 0.16); }

/* ---------- Shell ---------- */
.shell { position: relative; z-index: 1; min-height: 100vh; overflow-x: clip; /* page changes slide panels in from the sides; never a sideways scroll */ padding: 14px var(--gutter) 48px; max-width: 1440px; margin: 0 auto; }
.shell { position: relative; z-index: 1; min-height: 100vh; overflow-x: clip; /* page changes slide panels in from the sides; never a sideways scroll */ padding: 14px var(--gutter) 48px; /* full width on every screen, kiosk included: no empty bands at the sides (Dylan, 2026-11-12) */ }
/* Opened from the Home Screen, the page runs under the status bar and
around the rounded corners (viewport-fit=cover), so keep clear of them. */
@supports (padding: max(0px)) {
Expand Down Expand Up @@ -483,7 +483,8 @@ a.cloud__title:hover { text-decoration: underline; text-underline-offset: 3px; }
html:has(.dialog--chat[data-page]) { scrollbar-gutter: auto; }
/* /talk keeps the orb and title small and high so the conversation gets the room. */
.dialog--chat[data-page] .chat__head { padding-top: 0; gap: 2px; }
.dialog--chat[data-page] .chat__head .orb { margin: 10px 0 8px; }
/* The orb follows the screen there, so its room does too: its orbits reach about .4 of its size past the edge, and "Jarvis" sits clear above them. */
.dialog--chat[data-page] .chat__head .orb { margin: calc(var(--orb) * .45) 0 calc(var(--orb) * .3); }
/* "Jarvis" sits above the orb on /talk. */
.dialog--chat[data-page] .chat__head .dialog__title { order: -1; }
/* Kept for screen readers, hidden from view. */
Expand Down
16 changes: 16 additions & 0 deletions tests/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,15 @@ test('Settings Voice plays a sample, and saves a voice, speed and blend', async
await expect(panel.getByRole('button', { name: 'Save' })).toBeDisabled()
})

test('the page uses the whole width of a big screen, with no empty bands at the sides', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
await page.goto('/')
const shell = (await page.locator('.shell').boundingBox())!
expect(shell.x).toBe(0)
// All of it, less a scroll bar at most (it used to stop at 1440 px).
expect(shell.width).toBeGreaterThanOrEqual(1920 - 20)
})

test('/talk is voice only, edge to edge and already talking: no typing, no Close, Mute beside the button that pauses and resumes', async ({ page }) => {
await page.goto('/talk')
const chat = page.getByRole('dialog', { name: 'Jarvis' })
Expand All @@ -730,6 +739,13 @@ test('/talk is voice only, edge to edge and already talking: no typing, no Close
return { radius: css.borderTopLeftRadius, border: css.borderTopWidth, blur: css.backdropFilter }
})
expect(look).toEqual({ radius: '0px', border: '0px', blur: 'none' })
// The orb follows the screen: 12% of its short side, 40 to 160 px (small on a phone, big on a TV),
// and "Jarvis" sits clear above its orbits.
const orbBox = (await chat.locator('.chat__head .orb').boundingBox())!
const expected = Math.min(160, Math.max(40, Math.min(viewport.width, viewport.height) * 0.12))
expect(Math.abs(orbBox.width - expected)).toBeLessThan(1.5)
const titleBox = (await chat.getByRole('heading', { name: 'Jarvis' }).boundingBox())!
expect(titleBox.y + titleBox.height).toBeLessThanOrEqual(orbBox.y - orbBox.width * 0.38)
// Words fade out under the orb instead of a hard edge.
const mask = await chat.locator('.chat__turns').evaluate((el) => getComputedStyle(el).maskImage || getComputedStyle(el).webkitMaskImage)
expect(mask).toContain('linear-gradient')
Expand Down
Loading