Today Page V3
Native integration and offline behavior for the mixed Today V3 feed.
Surface and authentication
Load /embed/feeds/v3?language=en&uid=<current-user-id>. This is a static
Next.js Embed document with client-side data loading. It uses the existing
Embed session exchange and common WebView bridge. uid selects an account's
local snapshot; it never authenticates a request. The verified session identity
replaces the hint online. Replace uid and reload on account switch.
The existing language, padding, showLoading, and requestId parameters
retain their meanings. For a local fixture demonstration, use
/embed/feeds/v3?mock=true; &scenario=empty demonstrates an empty feed.
Embed owns /api/embed/v3/today/batch, exact ClientCard revision reads, and
V3 bundle downloads through its own BFF. Requests do not depend on a Web
server deployment. Batch debug data is excluded. Widget assets use the shared
virtual TCK URLs with tck-bundle-source=today-page-v3.
Scroll ownership
V3 uses document scrolling, matching the V2 feed at /embed/feeds/v1.
The feed stays in normal document flow; it must not introduce a viewport-height
inner scroll container, including a ScrollerVirtualBar wrapper. The native
WebView owns scrolling and scrollbar presentation on this surface.
Android uses the WebView's vertical scroll range to gate pull-to-refresh and
its scrollY to drive the header fade. iOS uses WKWebView.scrollView for
content offsets and native header/footer insets. An independently scrolling
DOM element leaves those native values at the top even while cards move.
Native window.scrollTo and todayPage.scrollToSection must therefore move
the same document scroll position as touch scrolling.
Render and refresh lifecycle
V3 reuses the Today Page render events and visibility lifecycle:
webView.loadingPresentationis the loading gate on this surface, and it is the only event Native should key its global loader on. V3 sizes its own skeletons from remembered card heights and reveals a bounded landing-card window in one step. A server batch remains the navigation and read identity, but it is not a rendering budget: an oversized batch is admitted over several card windows. Once batch data lands, the first bounded window is a credible screen and Web takes ownership withowner: 'web'andreason: 'placeholders_visible'. That is typically several hundred milliseconds before the first widget bundle has executed.firstBatchRenderedkeeps its legacy event name and reports the first bounded presentation window, using that window's firstbatchIdas its identity. It waits for every Widget and ClientCard in that window to reach a render outcome, and remains therefreshResulttrigger and render-timing telemetry point.allBatchesRenderedwaits until every card in the loaded response has been progressively admitted and reached a terminal outcome; it may therefore follow later scrolling. An empty feed completes without waiting for a Widget mount and hands the loader over withreason: 'content_visible'because the empty state has no placeholder.- Existing
totalWidgetCount,mountedWidgetCount, andfailedWidgetCountfields count both card kinds across the loaded response, including cards not yet admitted whenfirstBatchRenderedfires. A failed card may show a fallback while the feed completes withstatus: 'partial'. todayPage.exposureChangedstill producescontentVisibleafter the ready content remains visible for 300 ms, orcontentFailedfor a pre-content failure.todayPage.refreshRequestedrefetches batches, then active ClientCard revisions, and returns one terminalrefreshResultfor its request id. Cached content remains visible on network errors. A soft refresh result describes data revalidation; newly referenced Widgets mount independently.today_page.v3.updates, visible native foreground events, and browser reconnect trigger revalidation. Native owns the socket subscription and may relay the V3 update as a common native event; this page opens no socket.todayPage.scrollToSectiontargets the first card of a batch, even when multiple batches share one natural-day section. Web loads older pages inside the continuous feed's retention window, advances toward the target in bounded card windows, and waits for preceding skeletons to settle before scrolling. V3 does not add a section highlight.
Brief entry and repeated navigation
V3 Chat keeps the V2 brief_card and 72-hour split based on pushedAt.
Fresh entries show the existing Today surface and send a new JSB request on every
click. Older entries open /feed/YYYYMMDD-today-page-v3, which reads that day
through the single-batch API without the continuous feed's 14-day retention.
The permalink shows current saved content, not a morning snapshot. Server-side
V3 eligibility still applies: selecting the V3 reader does not bypass an API
feature gate or authorization check. /feeds
selects V2/V3 through today_page_v3_enabled; explicit V2 permalinks keep V2.
// Native -> Web, requestId is additive; old callers remain supported.
{ type: 'todayPage.scrollToSection', schemaVersion: 1,
requestId: '<per-click-id>', batchId: '20260914-today-page-v3',
behavior: 'auto', highlight: false }
// Web -> Native, only for requests carrying requestId.
{ type: 'todayPage.scrollToSectionResult', schemaVersion: 1,
requestId: '<per-click-id>', batchId: '20260914-today-page-v3',
status: 'succeeded' } // also not_found, out_of_range, failed, cancelled
// Native -> Web, e.g. the user reselects Today to scroll to the top.
{ type: 'todayPage.cancelScrollToSection', schemaVersion: 1,
requestId: '<per-click-id>' }Latest request wins, including repeat clicks on the same batch. A matching cancel, user scroll gesture, or leaving the visible surface cancels pending movement. Prerendered pages can prepare data but do not scroll until visible. Native sends visibility state before the navigation command, keeps only the latest pending request across document replacement, and never replays a completed request. Web moves the document scroll position; Native must not perform a second final scroll. iOS keeps its native header/footer inset contract.
not_found, out_of_range, and failed let Native fall back to the original
permalink. cancelled must not open that fallback. Native may cancel an outstanding
request before its own timeout fallback. A late result for an older request must
not affect the current navigation.
Agent name
ClientCard and Widget CTAs share the host-owned name through the additive
Agent Name protocol. Embed reads it on mount,
rename notifications, and every visible exposure. Older hosts retain the
Today fallback until they implement agent.getName.
ClientCard chat references
A host that implements todayPage.openChat advertises
today_page_chat_reference.v1 in its document-start capabilities. Web then
emits this payload through the common bridge:
type TodayPageOpenChatPayload = {
schemaVersion: 1
text: ''
replyTo:
| {
targetType: 'day_in_progress_event'
quote: string
payload: { eventId: string }
}
| {
targetType: 'today_page_card'
payload: {
widgetId: string
kind: 'health_sleep' | 'health_workout'
summary: string
}
}
}Native opens a chat draft and preserves the complete reference. It must not
send a message automatically. The health reference's widgetId contains the
ClientCard id, consistent with the shared card renderer's semantic target.
Native hosts without this capability render chat CTAs disabled. When Embed is
opened in a normal browser, the CTA serializes the same reference into the
existing todayReply Web-chat route and opens a draft, matching the V2 Embed
browser fallback. External URL actions and Widget actions retain the existing
today:// routing. Advertising the native capability requires a native
implementation; the browser fallback does not provide that native handler.
Offline and worker ownership
There are three independent persistence layers:
/embed/sw.js, scoped to/embed/, precaches the V1, V3, and Live Widget static documents plus build JS/CSS and referenced fonts/host assets. Lazy ClientCard renderer chunks are included. The existing waiting-worker activation policy remains in place.- The existing TCK resource cache stores downloaded hash-addressed Widget bundles/assets. V3 uses the same worker and the BFF fallback when no worker controls the page.
- V3 batches and validated, exact ClientCard revisions persist in a separate per-account IndexedDB snapshot. The snapshot expires after 24 hours and revalidates on opening. It is independent of the build's asset version.
API responses and authentication cookies are not part of shell precaching. Offline reopening requires a completed online warm-up, persisted data, and available WebView storage. Previously unseen Widget resources or revisions cannot be fetched offline; their cards show fallbacks. First use while offline and storage eviction are not covered. A host without Service Worker support can load online through the BFF, but cannot rely on this offline shell.
Local verification
Build and start Embed in two terminals, then run the browser checks:
pnpm --filter @todayai-labs/embed build
pnpm --filter @todayai-labs/embed exec next start -p 4078
# In another terminal; uses installed Chrome, or set EMBED_TEST_ORIGIN for another local port.
pnpm --filter @todayai-labs/embed exec tsx scripts/verify-v3-offline.mtsThe script verifies mixed cards, native draft references and refresh/exposure messages, an offline reopen, the no-worker online BFF fallback, and an empty feed's offline shell. It uses fixture data and a simulated native bridge; real account authentication and native WebView storage support still require validation on the integrating client.
Chromeless Web V3 permalinks also gate semantic ClientCard chat CTAs on
today_page_chat_reference.v1 for native hosts. Native hosts without that
capability retain Widget draft navigation through the established
today://chat/send scheme, but do not show unsupported semantic ClientCard chat
actions. Normal browsers route both Embed and chromeless Web semantic actions to
a Web chat draft. Web-link CTAs remain available. Browser and supported native
drafts never auto-send.