iPad / tablet experience
SageOx is 100% phone-locked today. This exploration designs the iPad as the calm board in the middle of the room — a fundamentally different posture from the phone-as-instrument. Width-driven breakpoints (not Platform.isPad) ensure Split View and Stage Manager are first-class. The loved record button is preserved exactly: it lives on the bottom action bar, pops UP, and is present on every screen in both orientations. The nav rail (iPad) and the nav bar (phone) carry navigation only — never the record action.
1 Nav rail + bottom action bar — the corrected model
The nav rail carries navigation only: Home, Conversations, Bubbles, Settings pinned bottom — no record button. The bottom action bar spans the content column with the record button bottom-center, popping UP, in both landscape and portrait. This is the identical phone layout preserved at expanded width. Both dark and light, both orientations.
Uploads
Available TVs
Quick join
Quick join
Implementor notes — Nav rail + bottom action bar
- Rail = nav only, always. The nav rail (or sidebar) carries only navigation destinations: Home, Conversations, Bubbles, Settings. The record button is never in the rail. This rule holds in both orientations and all breakpoints above compact. Violation means a second code path for
useRecordFlow()that doesn't exist and shouldn't. - Bottom action bar persists at all widths >600pt. At expanded+ the bar's content is constrained to
maxWidth:720ptcentered, but the bar background spans full width. This prevents the record button from stranding at a far edge on a 13" landscape display. The constraint is on theflexDirection:'row'inner container, not the bar background view. - Record button: 60×64px at expanded, 48×48px in portrait narrow. The punch-ring gap and mic icon scale with the button. In RN:
width:60, height:60, borderRadius:30+position:'absolute', bottom:28, alignSelf:'center'relative to the action bar. - 13" portrait keeps two panes (split preserved). At 1032pt logical, even in portrait, the split view fires. The nav rail stays visible at 52px icon-only. This is the key 13" vs 11" difference — see Scene 8 for the full portrait treatment.
- Never Liquid Glass. Rail background is
theme.surface.default, 1pt border right. No blur, no frosted glass. Stated rule from memory. - Android M3 rail vs iPad Apple sidebar: active indicator is a filled pill (M3) vs a rounded-rect slot fill (Apple). Both are icon-only at this width. The critical shared invariant is that the record button lives on the bottom action bar in both cases — never replaced by a FAB, never moved to the rail.
- Android system back: the predictive back gesture pops the WebView detail back to the list. No custom animation needed. The nav rail does not change during back navigation.
- One Copper per viewport: "Join meeting" CTA in the Now Rail is Copper. The record button is green (
state.success), not Copper.
2 Table Mode — iPad as cast endpoint
Table Mode = the iPad mints and plays the same signed /cast HLS stream a TV receives, full-screen, via expo-video (LandscapeCastFullscreen). The board — transcript, decisions, reactions, layout, typography, motion, light/dark — is rendered server-side by the cloud /cast renderer (sageox-mono) and is dynamic: it responds to room interactions in real time. The mobile app's only job is mint + full-screen play. One stream, multiple endpoints: a TV and the iPad can receive the same cast simultaneously. UX label: "Table Mode" / "Show on this iPad."
iPad is a cast endpoint — it plays the signed /cast stream
The iPad mints a real pre-signed cast.* stream (same path as casting to a TV) and plays it full-screen via expo-video. Cast-proof rule satisfied: the stream is served by a cast.* host. UX entry point: "Show on this iPad" / "Table Mode" — a distinct affordance from the TV cast picker, though it uses the same mint path.
- One stream, multiple endpoints: a TV and the iPad receive the same cast simultaneously.
isPresignedCastHost(url)passes — thecast.*stream URL satisfies the cast-proof contract in.claude/rules/cast-presigned-endpoint-is-the-only-proof.md.- Board content (layout, type, motion, light/dark) is server-defined and dynamic — secret sauce stays in sageox-mono, not in native client code.
- No "cast confirmed" green indicator on the iPad — the iPad is the playback surface, not a remote. The cast-proof verdict applies to the stream URL, not to a UI badge on the iPad itself.
- Entry affordance is separate from the TV cast picker to avoid conflating iPad-glass with TV-glass in the device list. The picker shows external receivers; "Show on this iPad" is a self-cast.
Implementor notes — Table Mode (cast endpoint)
- The mobile app's only job: mint + full-screen play. Call
POST /api/v1/meetings/{rec_id}/cast/tokento mint a cast token, resolve thecast.*stream URL, then hand it toLandscapeCastFullscreen/expo-videofor full-screen playback. That's it. No native board layout, no native transcript rendering, no native decisions panel. - Board content = server-side, dynamic. The transcript lines, decision cards, raised-hands queue, reactions ticker, layout, typography, motion, and light/dark are all rendered by the cloud
/castrenderer in sageox-mono. They change in real time as participants interact. The mobile client draws none of this — it plays a video stream. - Legibility (28pt / 500 / 1.5 / 50 CPL) is a cloud /cast renderer requirement, not a native mobile spec. Cross-link to sageox-mono when implementing. The type specs shown in this mockup are requirements the renderer must meet; the mobile app merely plays back what the renderer produces.
- Light/dark choice belongs to the /cast renderer. The perceptual review finding — light survives glare on bright-lit tables; dark works in dim rooms — is a renderer requirement. The mobile app passes a preference hint at mint time if the API supports it; otherwise the renderer decides. Do not hardcode light/dark in native Table Mode code. Validate on real hardware before specifying in DESIGN.md.
- APCA contrast targets are renderer requirements. Body copy (28pt): Lc 75 min, Lc 90 on the live line. Large text (64pt title, 72pt timer): Lc 75. Non-text: Lc 45. These are acceptance criteria for the cloud renderer, not for native component styling.
- Cast-proof contract satisfied. The stream URL is served by a
cast.*host, passingisPresignedCastHost(url). The iPad is a genuine cast endpoint. Noapp-mcxvgap — no fake protocol constant needed, no "never light green confirmed" carve-out (the iPad plays the stream; it does not surface a cast-confirmed badge to the user). - One stream, two endpoints. A TV and the iPad can receive the same
cast.*stream simultaneously. The mint is shared; each endpoint establishes its own playback session. - Table Mode entry point. "Show on this iPad" / "Table Mode" is a distinct affordance on the active recording screen (cast rail or explicit card) — separate from the TV cast picker, which lists external receivers. Tapping it mints the stream and calls
LandscapeCastFullscreen. TheLandscapeCastFullscreencomponent insrc/components/cast/is the seed; it already handles theexpo-videofull-screen path. - One Copper per viewport. The stream content is server-rendered; if "End meeting" appears as a native overlay control, it is the one Copper element the mobile app injects. Do not add additional Copper overlays. If "End meeting" is rendered inside the stream itself, the mobile app injects no Copper at all — confirm with the renderer team.
- Reactions, recording pulse, timer. These are all rendered server-side inside the stream. The mockup shows them as illustrative reference for the renderer team's requirements — they are not native components.
3 Conversations → Recording detail — flagship master-detail
Nav rail (nav only) + two-pane. Conversations list (~3 cols) with selected-row state | CloudReaderView WebView detail (~6 cols) — the responsive web reader fills the detail pane; no native transcript tabs. Empty-right-pane state shown alongside. Both light and dark.
Implementor notes — Conversations master-detail + WebView detail pane
- Detail pane = CloudReaderView WebView, not native tabs. The RN
WebViewcomponent fills the detail column. The web frontend's responsive CSS (cloud reader) renders Transcript / Notes / Decisions tabs and the playback bar. Native code provides only: the pane container, the webview boundary, and the header strip showing title + "web reader" label. - Flag for the web team: confirm the CloudReader's responsive breakpoints at intermediate pane widths (~500–900pt logical). The pane is ~62% of the usable screen width at expanded; the web reader must reflow at these intermediate sizes rather than assuming a full-browser viewport.
- Exception — live-recording detail: when the selected row is a recording currently in
uploadingstatus (live meeting), the detail pane renders a native join card instead of the WebView reader. The WebView is for completed recordings only. - Nav rail is nav-only. No record button anywhere in the rail. The dashed blue border on the WebView region is a mockup annotation, not a rendered element.
- Selected-row state:
background: surface-2+ 2pt leading border incopper. The playback CTA inside the web reader is also Copper — both are scoped to the same selected item. Together they count as one Copper use per viewport. - Pane split ratio: list ~3 cols / detail ~6 cols at 840+. In RN:
SplitViewwithlistFlex={3}/detailFlex={6}. - Empty right pane: shown when no row is selected. Do not auto-select on first load. On narrow iPad (Split View half <840pt), collapse to single column (see Scene 8).
4 Active Recording on iPad
Single-column centered (max-width ~640pt) — one primary intent, not split. Nav rail present (nav only). Full-width taller live waveform. Record button on the bottom action bar (bottom-center, recording state red fill). Cast/Table-View rail as a right-side column in landscape; collapses above controls in portrait.
Available TVs
This iPad
Available TVs
This iPad
Implementor notes — Active Recording on iPad
- Single-column centered, max-width 640pt. Recording is one primary intent. Extra horizontal space goes into padding/breathing room, not a second pane. Same principle as the phone — the waveform just runs wider.
maxWidth:640, alignSelf:'center', width:'100%'on the recording container. - Four trust signals always visible: red dot, "Recording" text label, live waveform (real amplitude bars), and the running timer. All four — redundancy here is intentional per Sacred data policy.
- Waveform height: 56pt on iPad (vs 34pt phone) — same bar width (2pt) and gap (1.5pt), more bars across the wider display, taller overall. Drive bar height from real
expo-avmetering level, not faked sine. If real amplitude is unavailable, show the timer alone (no fake waveform). - Record button on the bottom action bar. Recording state = red fill + stop icon. Paused state = amber fill + pause icon. Ready state = green fill + mic icon. The nav rail is unchanged regardless of recording state.
- Cast rail side column: 180pt fixed width. Shows available TVs + "Table Mode / Show on this iPad" card. "End & upload" is the one Copper CTA per viewport.
- Table Mode entry: the "Show on this iPad" / "Table Mode" card mints a pre-signed
cast.*stream (same path as casting to a TV) and starts full-screen playback viaLandscapeCastFullscreen/expo-video. The board content is server-rendered by the cloud/castrenderer — the mobile app plays the stream, it does not compose the board natively. This entry point is separate from the TV cast picker (which targets external receivers), but uses the same mint token. - Upload status line: "12 of 14 chunks · secure" — always specific, never a spinner. Sacred data: user must know their audio is landing safely.
- Paused state: amber record button on the bottom action bar (amber fill, pause icon), amber "Paused" label, waveform frozen at 35% opacity, "audio safe on device" reassurance strip. The nav rail shows no state change during recording or pause.
5 Devices — paired TVs + per-TV detail
Paired + nearby mDNS list | per-TV detail (rename, auto-cast policy, density, Forget). Both themes.
Implementor notes — Devices two-pane
- mDNS scan in "Nearby" section: driven by
classifyAirplayinsrc/services/cast-tv/airplay/airplayCapability.ts. Audio-only devices (HomePod, AirPort Express) are hidden before reaching the list — the "Checking…" row reflects the async classify step. Never show an audio-only device as a cast target. - Auto-cast segmented control: Off / Ask / Always — maps to the existing
PATCH /devices/{id}endpoint. "Ask" is the safe default; "Always" is an opt-in power-user preference. - Stream density: 1080 / 720 / 540 — maps to the
resfield currently hardcoded insrc/services/cast-tv/stream-fetch.ts. Phase 3 work extends the cast mint body with the selected density. - One Copper per viewport: the selected-row left border uses Copper as a selection indicator (structural, not a CTA). The "Pair" button on the nearby row is secondary style — it demotes so the Copper selection marker remains focal. "Forget device" is danger-outline, not Copper.
6 Join-as-companion — quiet controller
The non-disruptive participant surface: reactions, raise-hand, chat, live insights feed. Mic muted, non-interrupting. iPad layout plus a phone-vignette for comparison (phones are the usual companion device).
join/room endpoints — phone is the usual companion; iPad gets more screen for intel feed + chat.Implementor notes — Join-as-companion
- Mic-off is non-negotiable. The companion surface is explicitly non-interrupting. The record button on the bottom action bar is dimmed to 35% opacity and pointer-events:none when in companion mode. The "Mic off" pill is always visible in the header — never hidden, never needs a tap to reveal.
- Reaction button size: the 4-reaction grid uses
flex:1equal columns. On the phone frame they're larger relative to the screen (the 2×2 grid fills more of the viewport) — this is intentional: reaction buttons must be tappable without looking at the phone. - Live insights feed: driven by the same SSE endpoint (
GET /meetings/{rec_id}/cast/events) that Table Mode uses. Decisions and concerns surface in real time. On the phone, this is collapsed behind a tab; on iPad it's always visible in a dedicated section. - One Copper per viewport: the chat send button is the single Copper CTA. Reaction buttons are surface-2 fills — deliberate secondary style. The active/selected reaction briefly highlights in sage on send.
- Raise hand endpoint:
POST /meetings/{rec_id}/join/hand— the "✋" reaction button calls this, not the generic reaction endpoint. The button label reads "Hand" not "Raise hand" at this density.
7 Compact fallback — Stage Manager narrow window
At <600pt width, the exact phone layout is preserved byte-for-byte: bottom action bar, raised record button. A Stage-Manager-style window containing the phone layout, plus an identical phone-vignette beside it. Proves "replaced" never happens.
useBreakpoint() returns compact → phone layout, bottom action bar, record pops UP. No sidebar rendered.Implementor notes — Compact fallback
- Width-driven, not device-driven.
useBreakpoint(width)returnscompactwhenwidth < 600. This fires on: phone (always), iPad in a Stage Manager window narrowed below 600pt, iPad in a Split View half at any size below 600pt.Platform.isPadis never consulted for layout decisions — it gives the wrong answer in all multitasking scenarios. - "Loved phone layout preserved byte-for-byte" means: the
ActionBarcomponent renders unchanged,useRecordFlowis called unchanged, the record button'stranslateY(-28)pop is unchanged. The iPad layout changes are gated atexpanded+; compact is a pass-through with zero new code paths. - Stage Manager window chrome (traffic lights) is mockup-only illustration. It represents any narrow window, not just Stage Manager specifically — Slide Over, Split View 1/3, and Stage Manager small all hit the same <600pt breakpoint.
- One Copper per viewport: the record button is green (
state.success). No Copper element is present in the compact Home view — the Copper CTA appears only when a user initiates a task (recording, joining). This is correct for the idle Home screen.
8 Portrait variants — first-class, width-driven
Portrait is first-class: 13" portrait (~1032pt) keeps two panes (split preserved) + bottom action bar. 11" portrait (~834pt) drops to single-column push (Bear collapse). Width-driven via useBreakpoint(), never Platform.isPad. Right detail pane = CloudReaderView WebView. Bottom action bar spans the content column in both states.
Implementor notes — Portrait variants
- Width-driven, not device-driven: portrait two-pane vs. single-column is decided by
useBreakpoint(width)— never byPlatform.isPad. An 13" iPad in portrait (~1032pt) is ≥840pt and holds two side-by-side panes. An 11" iPad in portrait (~834pt) is borderline and collapses to single-column push (Bear pattern) at the 840pt threshold. Landscape is always two panes on iPad at any size (≥840pt). Free rotation = layout is always width-computed on every frame. - Bear 3-pane collapse pattern (11" portrait): the right pane is not rendered alongside the list — it slides over with a standard push transition (
react-native-screensstack navigator inside the detail slot). The back affordance uses the Copper back-chevron + "Conversations" label. The nav rail persists as a sibling to the stack, fixed at 52px, and does not slide with the push. - Nav rail stays visible in portrait at the narrow 52px icon-only width — nav items only, no record button. The nav rail does not hide behind a hamburger at any iPad breakpoint.
- Bottom action bar persists in every portrait state: both the list state and the detail-pushed state show the bottom action bar with the raised record button at bottom-center. The bar sits below the stack content column, not below the nav rail (which has no bar). In 13" portrait where two panes coexist, the bar spans both panes at the same max-width constraint as landscape (720pt centered).
- Detail pane = CloudReaderView WebView: the pushed detail (and the right pane in 13" portrait) is a
WebViewrendering the responsive web reader. Tabs (Transcript / Notes / Decisions) and the playback scrubber are rendered inside the web reader, not as native components. Flag for web team: verify the reader's responsive CSS at intermediate pane widths (~400–900pt) covering the range from an 11" single-column push to a 13" portrait detail pane. - Back chevron color: Copper (
var(--p-copper)). This is the one Copper element in the 11" portrait detail state — it is the primary actionable affordance (returning to the list). The web reader's playback CTA is also Copper, rendered inside the WebView — both are in the same detail viewport, satisfying the "one Copper per viewport" rule as a single related action group. - Transition: tapping a row does a standard horizontal push (list translates left, detail translates in from right). No modal, no sheet. In RN this is a
Stack.Screeninside the detail slot of theSplitViewprimitive.
9 First-run onboarding on iPad — tablet reflow
The 6-step phone onboarding flow (Welcome → solo/team branch → see-it-work example → what-to-capture → land-on-Home → lazy-mic) reflowed for a larger canvas. Steps 1 + 2 (Welcome + branch) share one iPad screen; step 3 (example loop) gets the full-width canvas. Steps 4–6 follow the phone layout. Aligns with the onboarding worktree — does not redesign the flow.
Implementor notes — First-run onboarding on iPad
- Coordinates with onboarding worktree (
granola-docs-onboarding-gaps). That worktree ownsapp/onboarding/first-run.tsx,components/Coachmark.tsx, and the telemetry client that reports solo/team choice to cloud. This iPad reflow is a tablet-responsive variant of that same screen — not a new screen. Implement as a breakpoint branch insidefirst-run.tsx: atexpanded+, render the two-column Welcome+Branch layout; atcompact/medium, render the phone filmstrip unchanged. - Steps 1+2 merged onto one canvas on iPad. The phone shows Welcome (step 1) full-screen then navigates to Branch (step 2). On iPad, both fit side-by-side: Welcome + capture→context example on the left, Branch choices on the right. The user never sees a transition between these two steps — they land on the full picture. This is the main tablet-specific change.
- Capture→context example (step 3 on phone) is shown on the Welcome panel (left side) on iPad. It's shown large and early — the "show, don't force" principle works better with space. On phone it's step 3; on iPad it's visible at step 1. Do not duplicate the screen; the data is the same seeded example.
- Steps 3–6 unchanged. The phone filmstrip for steps 3 (see it work), 4 (what to capture), 5 (home + coachmark), and 6 (lazy mic) renders unchanged on iPad. Extra white space at medium/expanded widths is acceptable — these steps are short and centered. Do not pad them artificially.
- Telemetry: solo/team choice. The onboarding worktree reports this as a coarse enum to the cloud. The iPad tablet reflow does not change this signal — it fires at the same point (tapping Continue with a branch selected), regardless of which layout was shown.
- One Copper per viewport: the "Continue" CTA is the only Copper element on the Welcome+Branch canvas. The solo/team border on the selected choice is
copperas a selection indicator — that is the same Copper role (selection + action are unified), same rule as Scene 3. - Light variant: toggle Mode to light to verify — the example card uses
surfacebackgrounds which render correctly in both modes viadata-pmodevars. The wordmark usesp-copperwhich is lighter in dark mode, darker in light mode per the pmode token set.