apps/viewerZero-backend replay SPA for .rewind session files. Part of the ExcelRewind architecture documentation suite.
apps/viewer is the recipient-facing surface of ExcelRewind: a React 18 + TypeScript single-page app
(Vite, port 5173 in dev) that opens a .rewind companion file — dropped, picked, fetched
as a demo/sample, or eventually delivered via a share link — and replays the recorded cell-change timeline entirely
client-side. It never uploads the file and requires no account: per CLAUDE.md's constraints, the web
viewer must require zero install for recipients and never expose the raw .rewind for download by
default. This module is the load-bearing enforcement point for that promise.
The app has exactly two real UI states, both driven from apps/viewer/src/App.tsx: Landing
(no file loaded — drop zone, live demo, sample, or an UnlockDialog if the dropped file is encrypted) and
Viewer (a loaded RewindDocument being replayed — grid, sidebar, player bar, and a set of
optional overlays for charts, cross-workbook Provenance Spotlight, webcam PiP, and exam/kiosk audit). All parsing,
decompression, integrity verification, and (for encrypted files) AES-256-GCM decryption happen in-browser via
@excelrewind/format — the shared container/crypto contract also used by the Excel add-in and Google
Sheets add-on.
A second, unrelated entry point ships from the same Vite build: google-signin.html /
src/google-signin.ts. This is not part of file replay — it is the OAuth handoff page the
Excel add-in's task pane opens in the user's system browser (Google/Apple block OAuth flows inside Office.js
webviews), because the viewer's origin is a plain HTTPS site outside any Office AppDomains
restriction. It exchanges the PKCE code for a Supabase session and stashes the tokens via the stash_handoff
RPC for the add-in to poll and claim. It is documented here because it physically lives in this app and deploys to
the same origin, but it has no code-path overlap with file replay.
Build/deploy: npm run build (workspace @excelrewind/viewer) runs vite build
with two Rollup entry points (index.html, google-signin.html); npm run deploy
at the repo root runs wrangler pages deploy apps/viewer/dist --project-name excelrewind-viewer --branch master
to Cloudflare Pages, serving at viewer.excelrewind.com. public/_headers sets a strict CSP
(self + the Supabase project origin only), X-Robots-Tag: noindex (share links must never be indexed),
and Cache-Control: no-store on index.html so a redeploy is picked up immediately. There is
no server-side component for this app at all — no API routes, no SSR. Beyond same-origin GETs for its
own static assets (the app shell, /demo.rewind, /sample-session.rewind), the only network
calls it makes are outbound, anonymous telemetry inserts and (on the sign-in page only) Supabase auth calls — see
§5 for the full external-systems table.
Internal components of apps/viewer/src, grouped by responsibility. Solid arrows are direct
imports/calls; dashed arrows mark an interaction that isn't a plain top-level import (a DOM click handler, a lazy
dynamic import()). The tan-filled OAUTHPAGE subgraph — google-signin.ts — has
no edges into the rest of the diagram at all: it is bundled into the same build (a second Vite/Rollup entry point,
google-signin.html) but shares no code path with file replay.
flowchart TB
subgraph BOOT["Bootstrap"]
MAIN["main.tsx"]
APP["App.tsx
(top-level state machine)"]
end
subgraph FMT["@excelrewind/format (shared contract)"]
CONTAINER["container.ts
readRewind / peekRewind / writeRewind"]
CRYPTO["crypto.ts
AES-256-GCM, PBKDF2, RSA-OAEP"]
REPLAY["replay.ts
replayTo / currentEventIndex"]
HISTORY["history.ts
cellHistory"]
SPOTFMT["spotlight.ts
spotlightCues"]
A1["a1.ts
parseA1 / toA1 / colToIndex"]
end
subgraph LANDING_GRP["Pre-load UI"]
LANDING["Landing.tsx
dropzone, demo, sample"]
UNLOCK["UnlockDialog.tsx
passphrase / recovery code"]
UNLOCKCACHE["unlock-cache.ts
in-memory last-good secret"]
end
subgraph VIEWERSHELL["Viewer shell (Viewer.tsx)"]
CLOCK["playback clock
(rAF / audio-driven)"]
SCHEDULE["schedule.ts
activeTrackAt / stripSegments"]
SILENCE["silence.ts
findSilentIntervals (WebAudio)"]
end
subgraph GRIDVIEW["Grid & history"]
GRID["Grid.tsx
spreadsheet render + chart anchors"]
HISTPANEL["HistoryPanel.tsx
'explain this cell'"]
end
subgraph SIDE["Sidebar & transport"]
SIDEBAR["Sidebar.tsx
Notes / Changes tabs"]
PLAYERBAR["PlayerBar.tsx
scrubber, speed, skip-silence"]
SEGSTRIP["SegmentStrip.tsx
multi-author track chips"]
HINTS["hints.ts
'where in Excel' tips"]
end
subgraph MEDIA["Media overlays"]
CHARTSTRIP["ChartStrip.tsx
floating chart images"]
VIDEOPIP["VideoPiP.tsx
draggable webcam overlay"]
SPOTOVERLAY["SpotlightOverlay.tsx
lazy xlsx (SheetJS) render"]
REFSBAR["RefsBar.tsx
attached-workbook strip"]
SPOTVIEW["spotlight.ts
activeCueAt / cueKey"]
end
subgraph SUPPORT["Cross-cutting"]
THEME["theme.ts / ThemeButton.tsx"]
TELEMETRY["telemetry.ts
anonymous PostgREST insert"]
REF["ref.ts
captureRef / storedRef (?ref=)"]
EXTREF["externalRef.ts
hasExternalRef / fmtSize"]
UTIL["util.ts
fmtTime / fmtValue / downloadBytes"]
end
subgraph OAUTHPAGE["Separate entry point"]
GSIGNIN["google-signin.ts
PKCE exchange + stash_handoff"]
end
MAIN --> APP
APP --> LANDING
APP --> UNLOCK
APP -->|"VIEWERSHELL"| CLOCK
LANDING -->|"onFile / onBytes"| APP
UNLOCK --> CONTAINER
UNLOCK --> UNLOCKCACHE
APP --> CONTAINER
CONTAINER --> CRYPTO
APP --> TELEMETRY
APP --> REF
CLOCK --> SCHEDULE
CLOCK --> SILENCE
CLOCK --> REPLAY
CLOCK --> SPOTFMT
CLOCK --> SPOTVIEW
CLOCK --> GRID
CLOCK --> SIDEBAR
CLOCK --> PLAYERBAR
CLOCK --> REFSBAR
CLOCK --> SPOTOVERLAY
CLOCK --> VIDEOPIP
CLOCK --> CHARTSTRIP
CLOCK --> TELEMETRY
GRID --> A1
GRID --> UTIL
HISTPANEL --> HISTORY
HISTPANEL --> UTIL
GRID -.->|"click cell"| HISTPANEL
SIDEBAR --> HINTS
SIDEBAR --> EXTREF
SIDEBAR --> UTIL
PLAYERBAR --> UTIL
SEGSTRIP --> SCHEDULE
PLAYERBAR --> SEGSTRIP
SPOTOVERLAY --> A1
SPOTOVERLAY -.->|"dynamic import('xlsx')"| XLSX["xlsx (SheetJS)
lazy-loaded"]
REFSBAR --> EXTREF
REFSBAR --> UTIL
APP --> THEME
VIEWERSHELL --> THEME
%% GSIGNIN has no import edge to MAIN/APP on purpose: google-signin.html loads
%% google-signin.ts directly via its own script tag (see vite.config.ts's
%% second rollup input) — it shares nothing with the replay component tree.
style OAUTHPAGE fill:#f3f0e6,stroke:#b9822f
Every open path (drag-drop, file picker, sample fetch, demo fetch, or a future share-link fetch) converges on the
same two functions in App.tsx: loadFile → loadBytes. Nothing is sent over the
network at this stage; the only network calls in this diagram are the two static-asset fetch calls for
the bundled demo/sample files and the outbound-only telemetry beacon.
flowchart LR
DROP["User drops / picks a .rewind file"] -->|"File object"| LOADFILE["App.loadFile()"]
FETCHDEMO["GET /demo.rewind or
/sample-session.rewind
(static, same-origin)"] -->|"ArrayBuffer"| LOADBYTES
LOADFILE -->|"Uint8Array"| LOADBYTES["App.loadBytes()"]
LOADBYTES --> PEEK["peekRewind(bytes)
(container.ts — inflates ONLY meta.bin)"]
PEEK -->|"encrypted:false"| READ["readRewind(bytes,
{limited:true})"]
PEEK -->|"encrypted:true"| CACHECHECK{"cached secret
in unlock-cache.ts?"}
CACHECHECK -->|"yes, retry silently"| READENC1["readRewind(bytes,
{limited:true, unlock})"]
READENC1 -->|"success"| DOC
READENC1 -->|"throws (different key)"| DIALOG["UnlockDialog opens"]
CACHECHECK -->|"no"| DIALOG
DIALOG -->|"passphrase / recovery code"| READENC2["readRewind(bytes,
{limited:true, unlock})"]
READENC2 -->|"success"| SETCACHE["setCachedSecret()
(memory only, this tab)"]
SETCACHE --> DOC
READ --> DOC["RewindDocument
{meta, timeline, annotations,
audio/video tracks, snapshot,
baseline, gallery, media, refs}"]
DOC --> ONDOC["App.onDoc()"]
ONDOC --> STATE["React state: doc, fileName, autoPlay"]
ONDOC --> TRACK1["track('file_open', {size_kb,
event_count, note_count,
duration_s, has_audio,
has_snapshot, platform})"]
ONDOC --> TRACK2["track('viewer_opened',
{recipient})"]
STATE --> VIEWERCMP["Viewer.tsx renders"]
TRACK1 -.->|"POST (anon key)"| SUPA["Supabase
viewer_events table"]
TRACK2 -.-> SUPA
Once a RewindDocument is loaded, everything downstream is pure client-side computation over the
in-memory object — no further parsing of the ZIP occurs. The playhead t is the single source of truth
that every panel derives from.
flowchart TB DOC["RewindDocument (in memory)"] --> TIMELINE["doc.timeline CellChangeEvent[]"] DOC --> ANNOT["doc.annotations"] DOC --> MEDIA["doc.media / doc.gallery (PNG chart snapshots)"] DOC --> REFS["doc.refs (attached source .xlsx bytes)"] DOC --> AUDIO["doc.audioTracks (webm bytes per narrator)"] DOC --> VIDEO["doc.videoTracks (webcam webm)"] DOC --> SNAPSHOT["doc.snapshot (final .xlsx, optional)"] CLOCKT["playhead t (seconds)"] --> REPLAYTO["replayTo(timeline, t, baseline) -> GridState"] REPLAYTO --> GRIDRENDER["Grid.tsx table cells"] CLOCKT --> CURIDX["currentEventIndex(changes, t)"] CURIDX --> SIDEBARHL["Sidebar.tsx active row highlight"] CLOCKT --> ACTIVECUE["activeCueAt(spotlightCues(doc), t)"] ACTIVECUE --> SPOTOVERLAY2["SpotlightOverlay renders refs[cue.file] via xlsx"] CLOCKT --> SCHEDULER["activeTrackAt(schedTracks, t)"] SCHEDULER -->|"play/pause/seek"| AUDIOELS["<audio> elements (one per track, Blob URL)"] CLOCKT --> VIDEOSYNC["VideoPiP seeks <video> to t - startT"] TIMELINE --> HASHISTORY["Grid: hasHistory set (click affordance)"] MEDIA --> CHARTURLS["URL.createObjectURL per chart id"] CHARTURLS --> GRIDRENDER CHARTURLS --> CHARTSTRIP2["ChartStrip (unanchored charts)"] SNAPSHOT -->|"click 'Workbook'"| DOWNLOAD["downloadBytes() browser download, local only"] REFS -->|"click download in RefsBar"| DOWNLOAD GRIDRENDER -->|"click a touched cell"| CELLHIST["cellHistory(doc, sheet, cell) (history.ts)"] CELLHIST --> HISTPANEL2["HistoryPanel.tsx"]
sequenceDiagram
participant User
participant Landing
participant App as "App.tsx"
participant Format as "container.ts"
participant Viewer as "Viewer.tsx"
participant Supabase
User->>Landing: click "Try the live demo"
Landing->>Landing: fetch("/demo.rewind")
alt asset missing (fixture not built)
Landing-->>User: sampleError "Demo not found — run npm run fixture"
else 200 OK
Landing->>App: onBytes(bytes, "demo.rewind", "demo")
App->>Format: peekRewind(bytes)
Format-->>App: {encrypted:false, ...}
App->>Format: readRewind(bytes, {limited:true})
Format->>Format: unzipSync + bounded inflate per entry
Format->>Format: SHA-256 integrity sweep (meta.integrity)
alt hash mismatch
Format-->>App: throw "Integrity check failed for X"
App-->>Landing: setError(ERR_TAMPERED)
else all hashes match
Format-->>App: RewindDocument
App->>App: onDoc(doc, name, "demo", bytes.length)
App->>Supabase: POST viewer_events (file_open, metadata only)
App->>Supabase: POST viewer_events (viewer_opened, recipient:false)
App->>Viewer: render, autoPlay=true (source==="demo")
Viewer->>Viewer: rAF loop advances t, PlayerBar/Grid/Sidebar re-render each frame
end
end
sequenceDiagram
participant User
participant App as "App.tsx"
participant Cache as "unlock-cache.ts"
participant Dialog as "UnlockDialog.tsx"
participant Format as "container.ts / crypto.ts"
User->>App: drop encrypted-2.rewind
App->>Format: peekRewind(bytes)
Format-->>App: {encrypted:true}
App->>Cache: getCachedSecret()
alt a secret worked earlier this tab
Cache-->>App: {kind, value}
App->>Format: readRewind(bytes,{limited:true,unlock})
alt same org key
Format-->>App: RewindDocument
App->>App: onDoc(...) — no prompt shown
else different key (throws)
Format-->>App: reject
App->>Dialog: open (fresh file, no error shown)
end
else no cached secret
App->>Dialog: open
end
User->>Dialog: type passphrase, submit
Dialog->>Format: readRewind(bytes,{limited:true,
unlock:{passphrase}})
Format->>Format: unwrapCek(envelope, unlock)
PBKDF2-SHA256 x310,000 iters (~0.5-1s)
alt wrong passphrase (no recipient wrap opens)
Format-->>Dialog: throw "Wrong passphrase or key"
Dialog-->>User: "That didn't unlock this file." (fails++)
Dialog-->>User: after 2 fails, show "rotated key / open via add-in" hint
else correct key, GCM auth fails on an entry (tamper)
Format-->>Dialog: throw "Integrity check failed for X"
Dialog-->>User: "The secret is correct, but the file failed
its integrity check — corrupted or tampered."
else correct key, all entries authenticate
Format-->>Dialog: RewindDocument
Dialog->>Cache: setCachedSecret({kind, value})
Dialog->>App: onUnlocked(doc)
App->>App: onDoc(doc, name, source, bytes.length)
end
{passphrase} or {recoveryCode} to UnlockOptions —
it has no Supabase session, so the org KEK and pre-unwrapped rsaPriv unlock paths in
unwrapCek() (used by the add-in/team console) are dead code from the viewer's perspective. Recovery-code
unlock walks the escrows chain embedded in the file itself (secret → master AES key → unwrap RSA
priv → RSA-unwrap the CEK), so it works fully offline with no DB round-trip.
sequenceDiagram
participant User
participant Viewer as "Viewer.tsx"
participant Audio as "<audio> elements"
participant Schedule as "schedule.ts"
User->>Viewer: click Play
alt exactly one audio track (legacy path, zero tracks falls to the wall-clock branch below)
Viewer->>Audio: audioRefs[0].play()
loop every rAF tick
Audio-->>Viewer: currentTime
Viewer->>Viewer: skip-silence check (skipRef intervals)
alt inside a silent interval
Viewer->>Audio: currentTime = interval.end
end
Viewer->>Viewer: setT(audio.currentTime)
end
else no audio, or multiple narration tracks
loop every rAF tick (wall-clock master)
Viewer->>Viewer: setT(prev + dt * speed)
Viewer->>Schedule: activeTrackAt(schedTracks, t)
alt an active track index found
Viewer->>Audio: seek(if drifted >0.25s), set playbackRate, play()
Viewer->>Audio: pause all other tracks
else t falls in a gap between tracks
Viewer->>Audio: pause all tracks
end
end
end
Viewer->>Viewer: t >= duration -> setPlaying(false)
User->>Viewer: close tab / navigate away
Viewer->>Viewer: pagehide -> sendPlaybackEnd()
Viewer->>Viewer: track('playback_end',
{watched_s, completed}) via keepalive fetch
sequenceDiagram
participant Pane as "Excel add-in task pane"
participant Browser as "System browser"
participant Page as "google-signin.ts"
participant Google
participant Supabase
Pane->>Browser: window.open("https://viewer.excelrewind.com/
google-signin.html#nonce=X&provider=google")
Browser->>Page: load, no ?code= yet
Page->>Page: stash nonce+provider in sessionStorage
Page->>Supabase: signInWithOAuth({provider, redirectTo: self})
Supabase-->>Google: redirect
Google-->>Browser: user consents
Google-->>Page: redirect back with ?code=... (PKCE)
Page->>Page: read nonce from sessionStorage (hash was dropped by the round-trip)
alt nonce missing (link expired / opened stale)
Page-->>Browser: "This link has expired — start sign-in again"
else nonce present
Page->>Supabase: exchangeCodeForSession(code)
alt exchange fails
Page-->>Browser: "Google sign-in failed: ..."
else session obtained
Page->>Supabase: rpc('stash_handoff', {p_nonce, p_access, p_refresh})
Supabase-->>Page: ok / error
Page-->>Browser: "Signed in — return to Excel. Close this tab."
Note over Pane: pane's pollOAuthHandoff() (add-in side)
claims the tokens by nonce and adopts the session
end
end
sequenceDiagram
participant Viewer as "Viewer.tsx"
participant SpotFmt as "spotlightCues() (format)"
participant SpotView as "activeCueAt() (viewer)"
participant Overlay as "SpotlightOverlay.tsx"
participant XLSX as "xlsx (SheetJS, dynamic import)"
Viewer->>SpotFmt: spotlightCues(doc, {attachedFiles})
SpotFmt-->>Viewer: SpotlightCue[] (recorded spans, or
derived from formula external-refs)
loop every t tick
Viewer->>SpotView: activeCueAt(cues, t)
alt a cue's [atT, atT+holdS) window contains t, spotlight is on,
not user-dismissed, and not at end of playback
SpotView-->>Viewer: cue
Viewer->>Overlay: mount with cue + doc.refs[cue.file] bytes
Overlay->>XLSX: import('xlsx') (lazy — only paid the first time a cue fires)
XLSX-->>Overlay: parsed workbook
Overlay->>Overlay: bound to MAX_ROWS=200 x MAX_COLS=50,
compute highlight rect from cue.range
Overlay-->>Viewer: renders source sheet, scrolls hit cell into view
Viewer->>Viewer: track('feature_use', {feature:'spotlight'}) (once per doc)
else no active cue, or window not attached (doc.refs missing)
SpotView-->>Viewer: null
end
end
Note over Viewer: the master clock never pauses for the overlay —
it is purely a visual layer over ongoing playback
flowchart LR
VIEWER["Web Viewer
(viewer.excelrewind.com)"]
SUPA[("Supabase
tohvepsjfeecjycspocg.supabase.co")]
GOOGLE["Google OAuth"]
APPLE["Apple OAuth"]
ADDIN["addin.excelrewind.com
install page"]
ADDINPANE["Excel add-in task pane
(addin.excelrewind.com — different origin)"]
STATIC["Same-origin static assets
/demo.rewind, /sample-session.rewind"]
VIEWER -->|"POST /rest/v1/viewer_events
(PostgREST, anon key, metadata-only JSON)"| SUPA
VIEWER -->|"only on /google-signin.html:
signInWithOAuth / exchangeCodeForSession"| SUPA
VIEWER -->|"rpc('stash_handoff')"| SUPA
VIEWER -->|"OAuth redirect"| GOOGLE
VIEWER -->|"OAuth redirect"| APPLE
VIEWER -->|"GET (static)"| STATIC
VIEWER -->|"'Get the add-in' CTA link,
utm_source/campaign/ref query params"| ADDIN
ADDINPANE -.->|"window.open(google-signin.html#nonce=...)"| VIEWER
ADDINPANE -.->|"polls stashed tokens via Supabase, not directly"| SUPA
| Target | Protocol / call | Purpose | Trigger / file |
|---|---|---|---|
Supabase PostgREST — viewer_events table |
HTTPS POST to {VITE_SUPABASE_URL}/rest/v1/viewer_events, anon key in apikey/Authorization, Prefer: return=minimal, keepalive:true |
Anonymous usage telemetry: viewer_open, file_open, playback_end, feature_use, viewer_opened, cta_install_click. Metadata only — never file names, workbook/sheet names, or note text. |
src/telemetry.ts track(), called from App.tsx and Viewer.tsx |
Supabase Auth — signInWithOAuth / exchangeCodeForSession |
@supabase/supabase-js, PKCE flow, detectSessionInUrl:false |
Completes the Google/Apple OAuth round-trip for the Excel add-in's sign-in (runs in the system browser, not a webview) | src/google-signin.ts — only reachable at /google-signin.html, not part of the replay app |
Supabase RPC — stash_handoff |
supabase.rpc('stash_handoff', {p_nonce, p_access, p_refresh}) |
Hands the freshly obtained access/refresh tokens back to the Excel add-in pane, keyed by a one-time nonce generated by the pane | src/google-signin.ts |
| Google / Apple OAuth endpoints | Browser redirect (302), standard OAuth2/PKCE | Identity provider consent screens | Indirect, via Supabase's signInWithOAuth |
Same-origin static files: /demo.rewind, /sample-session.rewind |
fetch(), same origin, no auth |
Bundled demo/sample content for the "Try the live demo" and "load the DCF sample" CTAs (built by npm run fixture) |
src/components/Landing.tsx |
addin.excelrewind.com/install.html |
Plain <a href> link (new tab), hardcoded same-product HTTPS literal + utm_source=share&utm_medium=viral and optionally utm_campaign/ref |
Post-replay "Record your own" viral CTA and the "Get the add-in" link | src/components/Viewer.tsx installUrl() |
xlsx (SheetJS) |
In-browser library, no network — parses bytes already embedded in the opened .rewind file |
Renders the referenced source workbook inside the Provenance Spotlight overlay | src/components/SpotlightOverlay.tsx, dynamic import('xlsx') |
@excelrewind/format (workspace package, not network) |
Direct TS import | The entire container/crypto/replay contract — this is the module boundary that defines what a .rewind file is |
throughout — see §6 |
Notably absent: there is no call to any ExcelRewind API server for file replay (none exists — see CLAUDE.md
"Still no API server"), no call ever carries spreadsheet content, and the raw .rewind bytes never leave
the browser tab that opened them.
| File | Function / export | What it does | Why it matters |
|---|---|---|---|
src/App.tsx | loadBytes() | The single funnel every open path goes through: peekRewind → cached-secret silent retry → UnlockDialog fallback → readRewind. | The one place to change if the unlock/open flow needs new behavior; every entry point (drop/picker/demo/sample) is required to call this, not read bytes directly. |
src/App.tsx | onDoc() | Shared success path for plaintext AND unlocked-encrypted opens: sets state, fires file_open/viewer_opened telemetry. | Guarantees telemetry and UI state can never diverge between the two unlock paths — a common source of "works when unencrypted, broken when encrypted" bugs elsewhere. |
src/unlock-cache.ts | getCachedSecret / setCachedSecret | Module-level (not React state) in-memory cache of the last secret that worked. | Explicitly documented as never touching localStorage/sessionStorage/disk — a secret must not outlive the tab. Auditors should verify no other code path persists it. |
packages/format/src/container.ts | readRewind() | Unzips (bounded, decompression-bomb-capped when limited:true), decrypts if meta.enc is present, verifies every entry against meta.integrity SHA-256 hashes, reconstructs the RewindDocument. | The viewer always calls this with {limited:true} — untrusted-input bounds (256MB/entry, 512MB total, 1024 entries, 100:1 inflate ratio) are ON for every file a recipient opens, unlike the add-in's own-file path. |
packages/format/src/container.ts | peekRewind() | Inflates only meta.bin (or its plaintext envelope) to report encrypted/size/duration cheaply before committing to a full parse. | Lets App.tsx decide unlock-dialog-vs-not without paying for a full container read, even on multi-GB audio/video files. |
packages/format/src/container.ts | unwrapCek() | Tries, in order: direct passphrase/org recipient wraps, pre-unwrapped RSA privs, then the file-embedded escrow chain (secret → master key → RSA priv → CEK). | The viewer only ever exercises the passphrase and escrow-recoveryCode branches (no Supabase session available) — the org/rsaPriv branches are add-in/team-console-only, dead from this module's perspective. |
packages/format/src/crypto.ts | deriveKek(), encryptBytes/decryptBytes | PBKDF2-SHA256 @ 310,000 iterations → AES-256-GCM with per-entry random IV and entry-name AAD. | The ~0.5-1s deriveKek cost is why UnlockDialog has a busy state; AAD binding to the entry name means ciphertext can't be silently swapped between container entries even by someone with write access to the ZIP. |
src/components/UnlockDialog.tsx | message branching in submit() | Distinguishes "wrong secret" from "right secret, tampered file" by checking for the string Integrity check failed in the thrown error. | A subtle but important UX/security distinction — never tell the user their passphrase might be wrong when the real problem is a corrupted/tampered file (which would otherwise train users to keep guessing). |
packages/format/src/replay.ts | replayTo(timeline, t, baseline) | Pure reducer: replays every event up to time t into a GridState (per-sheet cell map). | Re-run on every playhead tick from a fixed baseline+timeline — no incremental/mutable state, so seeking backward is just as cheap as forward and there's no drift risk. |
packages/format/src/history.ts | cellHistory(doc, sheet, cell) | Full change history for one cell, including baseline seed and fill-expansion entries. | Backs the "explain this cell" popover — the closest thing the viewer has to a per-cell audit trail. |
packages/format/src/spotlight.ts | spotlightCues(doc, opts) | Recorded-span cues (Tier 2, doc.meta.spotlights) win when present; else derives cues from formula external-refs (Tier 1). | Explains why some sessions show Spotlight windows that don't exactly match a formula's edit moment — recorded spans have real start/end, derived ones are inferred and capped by MAX_HOLD_S. |
src/spotlight.ts | activeCueAt(cues, t) | Picks which cue's window is "live" at time t: on overlap, the cue with the latest atT <= t wins. | Pure/unit-testable without any DOM/xlsx dependency — see src/__tests__/spotlight.test.ts. |
src/schedule.ts | activeTrackAt(tracks, t) | Which of N narration audio tracks is audible at wall-clock time t, and its internal offset. | The core of multi-author narration playback — ensures only one narrator's audio ever plays even if learned track durations momentarily overlap. |
src/silence.ts | findSilentIntervals() | Decodes narration audio at 16kHz via AudioContext, computes an adaptive RMS-threshold silence map, protects windows around recorded events/notes. | Runs entirely client-side, lazily, only when the user enables "Skip silence" — not run on multi-track sessions (flagged as "not yet available"). |
src/components/Grid.tsx | render-window clamping (MAX_RENDER_ROWS/MAX_RENDER_COLS) | Bounds the DOM table size independent of the logical sheet bounds a malicious/corrupt session could claim. | A hostile .rewind claiming a write to row 1,048,576 renders a bounded window (2000×256), not a browser-crashing DOM. |
src/components/SpotlightOverlay.tsx | dynamic import('xlsx'), MAX_ROWS/MAX_COLS | SheetJS is loaded only when a Spotlight cue first fires; parse is bounded to 200 rows × 50 cols. | Keeps SheetJS (a heavy dependency) out of every viewer session that never touches a cross-workbook reference. |
src/telemetry.ts | shouldSend(), sanitizeProps() | Hard gate on DNT/GPC/missing env; strips any telemetry prop down to ≤10 primitive-typed keys. | The client-side half of the "metadata only, never content" guarantee — server-side CHECK constraints in the Supabase migration are the actual trust boundary, this is defense-in-depth. |
src/ref.ts | captureRef(), storedRef() | Validates (^[A-Za-z0-9_-]{1,32}$) and persists a viral ?ref= code to localStorage. | Threads attribution through to the "Get the add-in" CTA and viewer_opened telemetry without ever accepting free-text or PII in the URL. |
src/google-signin.ts | main() | PKCE code exchange + stash_handoff RPC, gated on a nonce round-tripped via sessionStorage (URL hash is dropped by the OAuth redirect). | Only page in this app that touches auth/session tokens at all; whitelists provider to google|apple before ever using it in a request. |
apps/viewer/public/_headers | Cloudflare Pages headers config | CSP restricted to 'self' + the Supabase project origin; X-Robots-Tag: noindex; no-cache on index.html. | Deploy-time enforcement of the "share links never enter search" and "no third-party script/style origins" constraints — a change here must keep the Supabase origin in connect-src or telemetry/OAuth silently breaks (see memory note on CSP silent breakage in the add-in). |
apps/viewer/vite.config.ts | build.rollupOptions.input | Declares two independent HTML entry points (main, google-signin) in one build. | Explains why google-signin.html deploys to the same origin as the replay app despite having nothing to do with it — it must live off any Office AppDomains list. |
src/components/Viewer.tsx | kioskAudit / pasteCount (exam-audit header block) | When doc.meta.kiosk is set, renders a banner (org, exam name, student identity, time limit) and a paste-event count derived by filtering the timeline for event.flag === 'paste'. | Not in the original architecture sketch (only namechecked in §1) — the Kiosk/Exam workstream (WS-4). Purely a read-only replay of what the recorder chose to stamp into meta.identity/meta.exam and per-event flag; the viewer performs no verification of its own — see the invariant below. |
src/components/PlayerBar.tsx | paste markers in the scrubber | Every CellChangeEvent with flag === 'paste' gets a distinct red marker on the transport track, clickable like an event/note marker. | The only other UI surface for kiosk data — makes paste events (a proctoring signal) scannable without opening the Sidebar's Changes list. |
.rewind bytes handed to it and, at most, offers a client-side download of
doc.snapshot (a bundled copy the recording session chose to embed) or an attached reference
workbook. There is no upload path for the opened file at all.App.tsx
/ Viewer.tsx — Supabase is only touched by telemetry.ts (write-only, anonymous) and by
the unrelated google-signin.html page..rewind is never exposed for download by default. The Landing page's "Download
sample" button is the one deliberate exception (a bundled demo fixture, not a recipient's real session); nothing
in the Viewer surfaces a "download the original file" affordance for an opened session.track() call site in this module was audited for
this doc — none pass a filename, workbook/sheet name, cell value, or note text. sanitizeProps() is a
second line of defense that mechanically drops any non-primitive value.packages/format/src/crypto.ts); the passphrase/recovery code never leaves the browser, and
the in-memory-only unlock-cache.ts deliberately avoids any persistent storage of the secret.ENC_ENVELOPE_VERSION =
FORMAT_VERSION + 1 exists purely so a pre-encryption reader fails cleanly ("Unsupported format version: 2")
instead of choking on ciphertext — plaintext files still carry the real FORMAT_VERSION
(currently 1); encryption-aware code gates on enc.v, not this number.peekRewind() reports
encrypted straight from the unverified plaintext envelope in meta.bin — it exists to
route UI, not as a security check; the real integrity/auth verification happens inside readRewind's
per-entry GCM decrypt and SHA-256 sweep.unwrapCek; a correct passphrase against tampered ciphertext throws from the
per-entry GCM decrypt with Integrity check failed for X. UnlockDialog string-matches on
that phrase to give an honest message — if that error string ever changes in container.ts, this UI
branch silently breaks.useEffect in Viewer.tsx: single-track sessions are audio-clock-driven (the
<audio> element's own currentTime is the source of truth, enabling skip-silence
seeking); multi-track sessions are wall-clock-driven (rAF) with a separate scheduler effect that plays/pauses/seeks
each track by span. Skip-silence is explicitly unsupported on the multi-track path — the toggle renders disabled
with a tooltip rather than being hidden.google-signin.html exists as a page on this app's origin at all, opened via
window.open into the user's system browser, with state handed back to the add-in through a
Supabase-stashed, nonce-keyed row rather than any direct window messaging.readRewind(bytes,
{limited:true})): decompression-bomb caps (256MB/entry, 512MB total, 1024 entries, 100:1 inflate ratio) and
the Grid.tsx render-window clamp (2000 rows × 256 cols) both exist specifically because a recipient
may open a .rewind file from someone they don't trust. The Excel add-in's own-file read path
deliberately omits these caps.activeCueVisible is purely a visual
overlay computed from the same t that drives the grid; closing it early marks that cue's window
"dismissed" (by a composite key of atT|file|sheet|range) so it won't reopen until its window passes,
but the clock underneath is untouched throughout.doc.meta.kiosk,
meta.identity, meta.exam, and each event's flag ('paste') are set by the
recorder (add-in) during a locked-down session, then simply rendered as-is by Viewer.tsx/
PlayerBar.tsx — the viewer has no independent way to confirm a .rewind claiming
kiosk:true was actually recorded under lockdown, or that no paste events were stripped before the
file was shared. Same trust model as everything else in this module (the file is the record); auditors relying on
the exam banner as proof of proctoring integrity should look at the recorder/kiosk workstream, not this display.findSilentIntervals() only
runs on first toggle-on, decodes the entire audio track via AudioContext, and silently degrades to
"feature off" (empty interval list) on any decode failure rather than surfacing an error.xlsx (SheetJS) is dynamically imported, not a top-level dependency of the initial
bundle — sessions with no cross-workbook references never pay its parse-time or bundle-size cost.