truth-seeker

← Home · ~/truth-seeker · updated 5 days ago

truth-seeker

Native iOS app that unifies Mark's three reading surfaces — the newsfeed digest, the daily arxiv paper pick, and the Feller study library — into one installable app with offline reading, a home-screen widget, push, and a share-sheet "read later" target.

Status: P1 (MVP) in development. P2/P3 planned (see Phasing).

Scope

One app, three tabs, hybrid architecture: a native SwiftUI shell (tab bar, navigation, offline cache, widget, push, share-sheet) wrapping the rich web surfaces that webpage-server already serves, plus native SwiftUI for the one surface that has no web frontend (Papers).

The shell is what makes this a real app and not the PWA it replaces: a shipped .ipa with home-screen presence, offline snapshots, a widget, push, and share-sheet ingest. The reading views themselves reuse the existing, working web frontend instead of being rebuilt in Swift.

In scope (the app is a client): - Surface the three reading streams in one native shell. - Offline snapshot of the last-loaded News + Study pages. - Native Papers tab (the digest has no web surface today — this app gives it one).

Out of scope: - No rewrite of the three content backends. They stay exactly as they are (newsfeed pipeline, daily-paper-digest launchd job, Feller renderer). - No new server. The single backend is the existing webpage-server, extended by one endpoint (/api/papers). - Pure-native reading views (SwiftUI re-implementations of infinite scroll / swipe / streaming reader / a math renderer for Feller). Explicitly rejected for v1 — it's 3–4× the work and forks the frontend in two. Revisit per-tab in P3 only if a webview proves insufficient.

Architecture

iPhone — Truth Seeker (SwiftUI shell, bottom tab bar)
  ├─ News   → WKWebView  ──┐
  ├─ Study  → WKWebView  ──┤   HTTPS over Tailscale Funnel
  └─ Papers → native list ─┘            │
                                        ▼
                       webpage-server.py (:8899, existing)
                         ├─ /newsfeed.html            (existing)
                         ├─ /<feller-uuid>.html       (existing)
                         └─ GET /api/papers           (NEW, P1)
                                        │
                                        ▼
              existing content backends (unchanged):
              newsfeed pipeline · daily-paper-digest history.json · Feller renderer
Tab Surface How
News newsfeed.html WKWebView over the Funnel URL. Inherits swipe feedback, infinite scroll, streaming reader + ElevenLabs audio, accent theming — all free.
Papers daily arxiv picks Native SwiftUI list from GET /api/papers (reads history.json). Row = hook + date + source; tap opens the arxiv URL (in-app SFSafariViewController).
Study Feller Vol. II library Native SwiftUI NavigationStack (Book → Chapter → content leaf, titled by the chapter); tapping a chapter opens its single leaf directly (no picker). The leaf is a WKWebView loading an app-bundled HTML file with vendored KaTeX — fully offline, no Funnel. Native math rendering is a tar pit — deliberately avoided; KaTeX is bundled instead.

Offline: the shell snapshots the last successfully-loaded News and Study HTML (+ critical assets) to the app container and serves them when the network is down or the page fails to load. Papers caches the last /api/papers JSON.

Data flow is one-way pull (app → webpage-server). The only writes are the ones the web pages themselves already make (swipe feedback, TTS) via their embedded token — the native shell adds no new write paths in P1.

Study tab — native, offline, app-bundled

Reversal note: the Study tab was a WKWebView pointed at a hand-authored Funnel index page (Settings.studyPathac54e507-…html). That is gone. Study is now a native SwiftUI NavigationStack (Book → Chapter → content leaf) whose leaves are compiled into the app — either HTML (rendered with vendored KaTeX) or a full PDF book (rendered with vendored PDF.js, see "PDF books" below) — zero network, works on a plane. Settings.studyPath/studyURL and the web-only study/verify.sh ship gate were removed; the ship gate is now the swift-testing StudyTests suite.

A chapter owns exactly one content leaf, so tapping a chapter opens that leaf directly — there is no Verbatim/Study-Guide picker level, and no "Verbatim" label. The leaf screen is titled by the chapter. (The earlier two-leaf "Verbatim + Study Guide" picker and the study-guide leaf were removed.)

The hierarchy the app shows comes from a hand-authored manifest; the leaf body comes from the render pipeline below:

Node Source of truth Notes
Manifest content/feller-vol-2/build/study-manifest.json Book → Chapter → leaf tree (one leaf per chapter); decoded by StudyLibrary. Hand-authored — add a book/chapter/leaf here.
Content leaf (Feller) content/feller-vol-2/ch01/chapter.md Feller Vol. II Ch. 1, page-faithful transcription (pp. 1–44). Rendered to build/feller-vol-2-verbatim.html.
Content leaf (Cover & Thomas) content/cover-thomas/chNN/chapter.md (NN ∈ 07,08,09,10,11,12) Elements of Information Theory, 2nd ed., Ch. 7–12, page-faithful transcription (book pp. 183–426; PDF page = book page + 26). Figures are cropped page images under each chapter's figures/ dir, referenced as ![Figure N.M](figures/*.png) and inlined by render.py as base64 data:image/png URIs. Rendered to build/cover-thomas-chNN.html.
Content leaf (Shreve) content/shreve-stochastic-calculus/chNN/chapter.md (NN ∈ 0134) Shreve, Stochastic Calculus and Finance, 1997 CMU lecture notes, Ch. 1–34, page-faithful transcription (book pp. 11–347; PDF page = book page + 1). A handful of chapters carry cropped figures under their figures/ dir, same inlining as Cover & Thomas. Rendered to build/shreve-stochastic-calculus-chNN.html.
Content leaf (PDF) content/cover-thomas-pdf/{pdf-shell.html,pdfjs/} + ~/Library/Application Support/truth-seeker/cover-thomas-elements-2e.pdf Elements of Information Theory, 2nd ed., the complete 774pp book rendered by a vendored PDF.js viewer instead of transcribed — see "PDF books" below.

Study content pipeline

The Feller renderer lives here now (content/feller-vol-2/), promoted from ~/reading/feller-vol-2/. Three scripts run in one fixed order — render → sync → publish (render-all.pysync-to-bundle.shpublish-web.py), each a one-way copy from the one before it; skipping the third is what let the six hand-made car editions freeze four generations behind the app. render.py turns a chapter .md into a self-contained, offline HTML leaf:

cd content/feller-vol-2
# The content leaf ships LIVE: anchored comment threads + Ask-Claude online.
# --feedback-mode reply (the only mode) bakes the dedicated verbatim token +
# absolute Funnel endpoints (/api/verbatim-feedback ask + /api/verbatim-threads
# pure-op/GET). The leaf is rendered full-bleed (no in-page header).
python3 render.py --md ch01/chapter.md \
  --html build/feller-vol-2-verbatim.html \
  --title 'Feller Vol. II — Ch. 1' --header 'Chapter 1' \
  --feedback-mode reply

--book/--chapter/--leaf/--footer parameterize the leaf-book/leaf-chapter/leaf-leaf metas and footer credit line baked into the page (thread backend keys); all four default to the Feller values above (feller-vol-2/ch01/verbatim/the Feller credit line), so the invocation above is byte-stable. A second book passes its own values, e.g. Cover & Thomas Ch. 7:

python3 render.py --md ../cover-thomas/ch07/chapter.md \
  --html build/cover-thomas-ch07.html \
  --title 'Cover & Thomas — Ch. 7' --header 'Chapter 7' \
  --book cover-thomas --chapter ch07 \
  --footer 'Cover & Thomas, Elements of Information Theory, 2nd ed. — personal study notes.' \
  --feedback-mode reply

(repeat per chapter ch08ch12, titles/headers incrementing). A third book follows the same pattern: Shreve's 34 chapters render with --book shreve-stochastic-calculus --chapter chNN --footer 'Shreve, Stochastic Calculus and Finance, 1997 CMU lecture notes — personal study notes.' --feedback-mode reply (repeat ch01ch34).

All 41 invocations are data, not prose. content/feller-vol-2/leaves.py holds the table — md source, output name, title, header, book, chapter, footer — and render-all.py replays it:

cd content/feller-vol-2
/usr/bin/python3 render-all.py                # rewrite build/
/usr/bin/python3 render-all.py --out /tmp/x   # render elsewhere, to diff against build/

Use /usr/bin/python3: the markdown package is installed for the system 3.9, not for Homebrew's python3. Adding a chapter means adding a row to leaves.py, not editing this list — the table is what render-all.py and the staleness guard below both read, so they cannot disagree about what the tracked set is.

publish-web.py puts those same bytes on the web, for reading in a browser (the Tesla's in-dash one, or macOS) rather than in the app:

cd content/feller-vol-2
/usr/bin/python3 publish-web.py               # publish to the car directory under ~/www
/usr/bin/python3 publish-web.py --out /tmp/x  # publish elsewhere, to diff

It copies build/*.html; it never re-renders. That is the whole design: the car reads the identical file the app bundles, so there is one artifact behind all three surfaces and no second edition that can drift. It reproduces the bundle's layout — leaf at the root, mathjax/ and design/fonts/ as siblings — so the leaves' relative refs resolve unchanged, and it reaches those two asset dirs by symlink into this repo rather than by copying (one MathJax tree on the machine, and the served engine is by construction the build the leaves were rendered against). index.html is generated from build/study-manifest.json and styled purely in content/design/tokens.css variables, so it cannot fork the palette; tests/test_publish_web.py fails on a hex literal, on a copied asset dir, or on any published byte that differs from the bundle. Run it from the main checkout — it refuses to publish the car directory from a git worktree, whose removal would leave the asset links dangling.

Any local ![alt](relative.png) image reference in the source markdown is inlined by render.py as a data:image/png;base64, URI at render time — a missing target file is a hard render error, not a silent broken image — so every leaf stays a single self-contained offline HTML file with zero relative <img src.

  • Vendored MathJax v4 under content/feller-vol-2/mathjax/ (tex-chtml.js, fonts/{woff2,dynamic}). render.py's MATHJAX_HEAD references these by relative path (mathjax/…) — no CDN — so the leaf renders math fully offline once it sits beside mathjax/ in the bundle. The swap from KaTeX was deliberate: MathJax line-breaks wide display equations and KaTeX cannot. content/feller-vol-2/katex/ stays vendored for report-shell.html, which is not a rendered leaf.
  • Each equation reaches the accessibility tree as one labelled node, not as its glyphs. MathJax's speech/Braille/enrichment engines are all off (their worker fetches per-language rule sets, which WebKit blocks over file:// — it hangs startup rather than erroring), and with enrichment off MathJax emits no assistive MathML and never hides the visual CHTML. That left ~25.9k mjx-* elements per chapter — 96.5% of the leaf's entire DOM — in the accessibility tree, so VoiceOver read an equation as its glyphs one at a time. MATHJAX_HEAD adds enableMenu: false and a pageReady pass that puts role="math" plus the equation's own TeX (mjx-math[data-latex]) as aria-label on each mjx-container, and aria-hidden on its glyph subtree — the same shape KaTeX has always emitted. enableMenu: false is load-bearing, not tidying: it removes the tabindex="0" MathJax puts on every container, and an aria-hidden subtree holding a focusable node is invalid ARIA that WebKit keeps exposing, so hiding the glyphs without it is a no-op. Measured 26,866 → 2,385 accessibility nodes with byte-identical rendering. Pinned by content/feller-vol-2/tests/test_math_a11y.py. The label carries raw LaTeX, which is strictly better than glyph soup but is not proper math speech; MathJax's own speech generation stays unavailable for the file:// reason above. This pass is not what cured the XCUITest wedge — see the selection-suppression note below.
  • Native selection suppression sets no style at all. The drag gesture must keep iOS from raising its own Copy/Look Up callout. That used to mean user-select: none over the whole reading container, armed for the length of the gesture (body.notouchsel). It is now a refused selectstart plus a standing -webkit-touch-callout: none, because the property cost the gesture 251ms of presented frame at each end — ~58ms of style recalc across 28,364 elements plus a re-raster of the visible MathJax, measured on cover-thomas-ch07 in iOS WebKit. Scoping the same rule to one block still cost 200ms; the repaint was the cost, not the selector. Arming and releasing it is what Mark felt as a laggy drag and a composer that took half a second to appear. Standing user-select: none makes WebKit's Position::isCandidate() false for every position in the subtree, so canonicalPosition() walks clear out of the container hunting a selectable candidate — once per accessibility label, and a chapter has ~2000 of them. Measured on Feller ch01 in the iOS 26.4 Simulator, one web.buttons["Ask"].exists cost 182s with the class standing and 0.90s without it; building the element tree itself was never slow (web.staticTexts.count = 6.7s over the same 2057 elements). The 182s overran XCUITest's snapshot timeout, so every Study-leaf UI assertion failed with kAXErrorIPCTimeout against an element that was plainly on screen — AskClaudeUITests, ThreadCacheOfflineUITests and ThreadKitUITests all wedged on it, on iOS 18.5 and 26.4 alike, and on builds predating the makeover. report-shell.html runs the same layer and passed only because its document is short enough for the quadratic not to bite. VoiceOver paid the same walk. Refusing selectstart retires that hazard structurally rather than relying on a lift to undo it, and it is what WebKit fires before it begins any selection of its own — including the one its ~500ms long-press recognizer raises behind a slow finger, so nothing native ever forms. selectstart is not a touch event, so R14 is untouched: every touch listener in the layer is still { passive: true }. Pinned by ThreadLayerTests.suppressesSelectionOnlyForTheDurationOfTheGesture and test_render_modes.test_verbatim_leaf_suppresses_selection_only_during_the_gesture.
  • The leaf declares no palette, no type scale and no spacing of its own. Every rule is written in design/tokens.css's names (--surface, --text-primary, --border, --accent, the --radius-* scale, --space-unit multiples), which render.py inlines along with markdown.css; the leaf's old --bg/--fg/--rule/--link alias layer is gone, and <meta name="theme-color"> — the one thing on a page that cannot take a var() — is read out of the sheet at render time rather than restated. The one deviation is the face: leaves keep Iowan Old Style at --text-lg, because body text sits beside MathJax's Computer Modern on every display equation and SF Pro against a serif is a visible mismatch, and 18px because a 40-page chapter is sustained reading. Headings scale from the vendored steps and none of them sets color — rank reads from size, weight and the rule above, never hue. The one highlight colour anywhere, selection and thread band alike, is --mark-rgb. Guarded at all three seams: StudyTests.verbatimLeaf* on the bundled artifact, tests/test_render_modes.py on the renderer, and tests/test_build_artifacts_current.py on the gap between them.
  • The car reads the same leaf at roughly twice the type scale, and the leaf's own rules never mention it. tokens.css closes with one additive block — @media (pointer: coarse) and (min-width: 900px) and (min-height: 500px) — that overrides the nine type-scale steps (1.875x–1.917x), --space-unit (1.5x) and --measure (33em). It overrides tokens, not rules, so every surface that was already written in token names — body, headings, code, the composer, the thread layer, MathJax's 1em-relative boxes — scales together for free, and no second stylesheet exists to drift. The reason it is needed at all is DPR 1: the probe (content/design/car-probe-2026-07-26.json) measured the dash at 1180x919 CSS px on a 1920x1200 panel, so 18px is 18 device pixels at ~30in, where a phone renders the same 18px across 54 device pixels at ~14in.
  • The three-way separation is the whole query. pointer: coarse alone would catch the phone; min-width: 900px alone would catch macOS in a wide window (which is pointer: fine). Coarse and wide is the dash and nothing else. The min-height: 500px floor is load-bearing rather than decorative: an iPhone 16 Pro Max in landscape is 932 CSS px wide and would otherwise cross a width-only 900px threshold and get dash type in the hand.
  • Because the block ships inside every leaf (inlined at render time, like the rest of tokens.css), the phone rendering must come out byte-identical to the pre-car build — that is the guard that proves an additive media query stayed additive, and it is what test_ch01_default_render_is_byte_stable pins.
  • The committed leaves are checked against the renderer that produced them. build/*.html is tracked, so a change to render.py or content/design/ that is not followed by a re-render leaves 41 stale artifacts on device while every source-level assertion stays green — which is exactly how the leaf restyle landed red (StudyTests reads the bundle, and 15 of its assertions failed on a fresh checkout). tests/test_build_artifacts_current.py re-renders every row of leaves.py and byte-compares against build/, with the feedback token pinned to whatever the tracked artifact already carries so a rotated machine-local token is not mistaken for staleness. It is the complement of test_ch01_default_render_is_byte_stable, not a duplicate: that one fails when the renderer's output moves, this one fails when the committed copies did not move with it.
  • Feedback mode (--feedback-mode reply, the only mode — the study-guide edit mode was removed): the answer surfaces as a comment thread anchored to the passage, never mutating the verbatim transcription. Bakes the dedicated verbatim token (verbatim-token.txt) + absolute Funnel endpoints. The thread layer (op model + locate() + DOM-band highlights + suggestion fence) is the single shared content/design/thread-layer.{css,js} — one implementation for all three reading surfaces, inlined into each leaf at render time (see "The shared thread layer" below), with its pane internals ported from Wayfinder's review pane (~/wayfinder/Wayfinder/Resources/review/) and its op model from the prose plugin. Highlights paint as absolutely-positioned DOM bands behind the text (a sibling #vb-hlayer layer at z-index:-1, pointer-events:none), so the live DOM is never mutated. This replaced the CSS Custom Highlight API, which in the iOS-17.2-era WKWebView does not paint over user-select:none text (back when a drag put that property on the reading container, both the persistent tint and the live band went invisible on device) and cannot paint KaTeX math. Threads live only in the Mac's isolated store (~/Library/Application Support/feller-feedback/verbatim-threads.json). Reading + already-fetched threads work offline (the native offline thread cache, below); asking needs the Mac (and a running verbatim-feedback poll session). Backend: webpage-server's /api/verbatim-feedback (ask) + /api/verbatim-threads (pure-op/GET). Selecting text is a long-press + finger-drag gesture (ported from the prose plugin); the question composer is an IN-PAGE popover. There is no iOS text-selection menu item and no native question sheet — native selection (blue handles, Copy/Look Up callout) is refused on the reading container by a cancelled selectstart plus a standing .vb-content { -webkit-touch-callout: none }. Instead the reader drags across a line to arm — no dwell, no timer (the word under the finger snaps into an amber band the moment the gesture is classified as a selection) — extending the band per-line in either direction, and lifts to raise a small in-page composer popover (#vb-popover, the prose #popover model) anchored beneath the highlighted range. The gesture lives in the page JS (that's where the DOM ranges are): it paints the live band as a .vb-sel-band DOM band (distinct from the persistent .vb-thread-band thread tint), and on lift freezes the anchor into pending and shows the popover (showPopover). Typing a question and tapping Ask calls the leaf's existing ask() path directly — the question never crosses into native, so the reply still renders as the anchored web thread. (submitQuestion reads the frozen pending range, not window.getSelection(); dismissing the popover clears the amber band — and a tap-outside is guarded so it can't discard a half-typed draft.) wordSnap is plain-JS string-offset expansion, NOT WebKit's word-mode range expansion — that API respects user-select:none and relocates the anchor to page chrome, so it must never be used (a regression-guard test asserts its absence in both built HTML outputs). The persistent passage highlight is painted only for passages with a real thread (renderThreadspaintBands), as a background tint — the underline was dropped in build 21 as redundant; the tint alone marks the span. Each threaded passage is anchored in the DOM, Wayfinder's wrapTextRange model: renderThreads wraps the resolved range's text in transparent <mark class="prose-mark" data-thread-id=…> elements, and the wash is measured from those rects (threadBandRects) rather than from a re-resolved Range. The anchors are geometry and identity only — background: transparent; color: inherit resets the UA's yellow Mark fill and MarkText foreground, either of which would be a second highlight colour beside --mark-rgb (and on the PDF surface would make pdf.js's deliberately invisible OCR text layer show through as doubled glyphs over the page bitmap). Three invariants hold them together: math is never wrapped (a mjx-container is a rendered glyph tree; its rect folds in whole instead), every thread is sliced before any is wrapped (two threads that fall back to the same block share one range exactly, and wrapping the first would collapse the second's live boundaries onto the new mark), and unwrap calls normalize() so the splitText fragments rejoin and the tree does not get permanently more fragmented on every render. The live drag band stays on the Range path — a gesture in flight has no thread to key an anchor to. The painter emits one .vb-thread-band per contiguous run of lines, clipped to a rounded hull (design/hull.js), not one per line and not one per thread: two divs meeting at a fractional y paint a hairline seam because WebKit rounds each one's edges independently, and overlapping threads are merged into a single deepened rect stamped with the count (data-threads, the 45/65/80 ramp) so stacked washes don't compound. A <pre>'s opaque background sits above the z-index: -1 layer, so each pre gets its own .vb-pre-paint layer. bandRects folds in the visible math element rects so a band over math is painted too. Web seam: the gesture + composer JS lives entirely in the shared content/design/thread-layer.js (see "The shared thread layer" below); there is no web→native ask channelStudyLeafView.swift / ReportWebView.swift only inject config and read scroll signals (vbScroll on both; vbChrome on the report shell only — the leaf's guarded vbChrome emitter is inert since nothing native listens).
  • Offline thread cache + non-silent failure UI (build 29). fetchThreads() does a live GET /api/verbatim-threads on every open; its old .catch swallowed the error and set threads = [], so a single unreachable-Mac moment (iCloud Private Relay — the #1 Funnel gotcha — or a transient Tailscale drop) silently wiped every highlight even though the threads are safe server-side. Now the last successful response is persisted natively per document in ThreadCacheStore (Study/ThreadCacheStore.swift — a file-per-document JSON cache under <AppSupport>/threadcache/, mirroring SnapshotStore; native treats the payload as opaque JSON and never parses thread internals, so a thread-schema change needs no native change). On didFinish, StudyLeafView / ReportWebView inject window.__THREADS_CACHE__ and call the page's window.__vbApplyThreadCache() hook (keyed by leaf.id / report topic, the same id ReadingPositionStore uses) — highlights paint immediately from cache, before the network resolves (the apply-hook resolves the seed/live race: the page's own on-load fetchThreads() may run before native injects). On a live success the page posts the fresh array back to native over the vbThreads channel (postThreads) to refresh the cache, and hides the banner; on a fetch failure it KEEPS the cached threads (no wipe) and shows a small dismissible in-page banner (#vb-cachebanner) — "Showing your last saved highlights" when served from cache, or the actionable "Couldn't reach your Mac to load highlights — check Tailscale or turn off iCloud Private Relay." when there was no cache. vbThreads carries threads data only — it is NOT a web→native ask channel (vbAsk/__vbAsk stay retired; the ThreadLayerTests.threadCacheIsNotAskChannel re-asserts that invariant under the new channel). All three surfaces run one implementation of this — see "The shared thread layer" below. Off-device there is no native host at all, so the browser surfaces (car, macOS) would lose both of the states native holds — the thread cache and the reading position — and a single failed fetch in the car would wipe every highlight, exactly the regression build 29 fixed for iOS. thread-layer.js therefore mirrors both into localStorage, but only when the matching messageHandlers entry is absent: postThreads writes the threads array under vbThreadCache.<id> and fetchThreads seeds window.__THREADS_CACHE__ from it before the live load; reading position mirrors under ReadingPositionStore's own key names (vbReadingPos.<id>, vbReadingPos.<id>.offset). Both feed the same two globals native seeds, so there is one restore path, not two. Native always wins when present — the app keeps using ThreadCacheStore/UserDefaults and never switches stores. <id> is the document's filename, which is Leaf.id; book + '-' + chapter does not reconstruct it (Feller is feller-vol-2-verbatim), and the null-origin loadHTMLString report surface yields an empty id and opts out entirely — which is also why native, not localStorage, has to hold these states on device. Covered by tests/test_web_storage_fallback.py (driven live in real Chromium over a real HTTP origin — a source-level string assertion would pass against a fallback that never round-trips) and seven source-contract tests in ThreadLayerTests.swift, one composing the key from ReadingPositionStore.keyPrefix itself so a rename fails the build instead of silently orphaning saved positions. StudyWebView.swift is an empty WKWebView subclass shell — all the old callout machinery (askClaude(_:), canPerformAction, buildMenu, menuChildren(prepending:to:), onAskClaude) was removed because there is no native callout on these surfaces; the iOS-26 "Ask Claude must be first" UIEditMenuInteraction ordering problem is moot. The leaf renders full-bleed inside a native screen (nav bar hidden, no back button — the system edge-swipe pop is the only exit, re-armed by Study/SwipeBackEnabler.swift because UIKit disables it while the bar is hidden; build 28 removed the leaf's floating BackPill); it is titled by the chapter.
  • Threaded-span tap → one-thread bottom sheet (build 20). Tapping a highlighted (threaded) span opens that single thread as a bottom-anchored in-page sheet (#vb-sheet: grab handle, Done button, the thread's Edit/Resolve/Delete actions, and a reply composer). Dismiss by tapping Done or swiping the header down. Hit-testing uses document.caretRangeFromPoint + range.comparePoint/getClientRects() on a delegated click. There is no grouped threads side panel and no ⋯/💬 per-passage marker (both removed in build 20 — a span's own background tint is the only affordance; build 21 dropped the underline as redundant).
  • No floating Back on the leaf (build 28; report detail keeps its pill). The leaf's BackPill was removed — swipe-back is the only exit. The report detail's floating Back still follows the LAST scroll direction and HOLDS at rest (build 20): scroll down → Back hides and stays hidden when motion stops; scroll up → Back shows and stays shown at rest. No idle re-reveal (the build-19 bug).
  • SILENT, EXACT reading-position resume (build 21; exact within-block in build 22). A leaf/report reopens at the exact pixel where the reader last left off — there is no user-facing bookmark (no button, list, indicator, or affordance); it just remembers the scroll position. The position is the topmost visible block's ordinal index (the reflow-stable anchor) plus the within-block pixel offset (Math.round(-rect.top) — how far into that block the viewport top sits). Build 21 stored only the index and snapped the block's top to the viewport top, so a reader mid-tall-block (e.g. a long KaTeX display) reopened ~one screen too high; build 22 restores to blockTop + offset, where blockTop is recomputed from live post-settle layout (so the offset rides on top of the reflow-stable anchor, not a stale measurement). Both are captured over a second vbScroll web→native channel (mirrors vbChrome, posting {anchor, offset}, deduped on block-change OR ≥8 px offset delta) and persisted natively in UserDefaults keyed by document id (vbReadingPos.<id> for the index, vbReadingPos.<id>.offset for the offset — reports load via loadHTMLString with a null origin, so localStorage is unreliable, hence native). On load the native side injects window.__vbInitialScrollAnchor = {block, offset} and the page restores it after a renderer-agnostic height-stability watcher (whenLayoutSettled: 3 stable 120 ms samples, 2.5 s cap) confirms KaTeX/marked layout has settled, then re-applies once at 250 ms to absorb any late KaTeX reflow. Native seam: Study/ReadingPositionStore.swift
    • StudyLeafView.swift/ReportWebView.swift (the vbScroll handler + the didFinish inject). A test-only -resetBookmarks 1 launch arg clears all saved positions (no-op in production). Build 23 corrected the precision UITests (not the app): they snapped the “left-off” frame before the small scroll-up that reveals the Back button, and that scroll-up is itself captured by the 200 ms debounce — so it moved the saved position and a faithful restore then looked “a screen too high.” Snapping after the reveal-scroll, the reopened frame is now pixel-identical to the left-off frame on both surfaces — confirming build 22’s offset restore was always correct; the earlier QA observation was a test artifact.
  • Open to the last-read page on cold launch (build 22). A cold launch lands directly inside the document the reader last had open (the full-bleed leaf/report), not a tab or list — one less tap back to where they were. The last-read location (.study(leafId:) or .report(topic:)) is persisted in UserDefaults (vbLastRead, JSON) on the web view's didFinish, and restored in ContentView.init, which seeds the selected tab and the NavigationStack path (a typed [StudyRoute] array for Study; a pending-topic that ReportsView appends after its async load for Reports) so SwiftUI renders straight into the document. Graceful fallbacks: an unknown/removed leaf id (ContentView.resolveStudy → nil) or no saved location falls back to the default News tab — never an empty push or a crash. The -resetBookmarks 1 launch arg also clears vbLastRead. Native seam: LastReadLocation.swift (LastReadStore) + ContentView.swift + Study/StudyListView.swift (StudyRoute) + PapersTabView.swift/ReportsView.swift.
  • --feedback-token <tok> overrides the baked token; an explicit empty token always wins (disabling the thread layer — reading still works).
  • The rendered leaves and study-manifest.json are committed (build/*), so a fresh checkout builds without a render step.

PDF books

Not every Study leaf is transcribed to HTML — Cover & Thomas ships twice: Ch. 7–12 as page-faithful HTML transcriptions (above), and the complete 774pp book as a PDF leaf, rendered by a small vendored PDF.js viewer (pdfjs-dist 6.1.200) with its own shell — not the prebuilt viewer.html — so the highlight→ask→thread layer could be ported onto it.

  • Committed source: content/cover-thomas-pdf/pdf-shell.html + pdfjs/ (vendored build/pdf.min.mjs, build/pdf.worker.min.mjs, standard_fonts/, wasm/). Tracked in git like katex/ — it's a fixed third-party asset, not build output.
  • The PDF itself is NOT in the repo (copyrighted, ~10MB). It lives at ~/Library/Application Support/truth-seeker/cover-thomas-elements-2e.pdf and is synced into the bundle as Study/cover-thomas-pdf.pdf by sync-to-bundle.sh, which hard-errors if that source is missing. .gitignore excludes only the PDF at the bundle destination — the shell and pdfjs/ copies under Resources/Study/ ARE tracked, mirroring how the katex/ copy is tracked (the synced copy is a byte-identical duplicate of the committed source, not build output).
  • Manifest leaf: pdfResourceName: "cover-thomas-pdf" instead of htmlResourceName (StudyLeaf accepts either; the leaf id is pdfResourceName ?? htmlResourceName ?? title). It's nested as its own chapter (pdf-full) inside the existing cover-thomas book, alongside the ch07–ch12 chapters — not a separate book. Routes to PdfLeafScreen/PdfLeafView (Study/PdfLeafView.swift) instead of StudyLeafScreen — same loadFileURL + config-injection pattern as the HTML leaves, via a .atDocumentStart WKUserScript (config can't be spliced into a bundled file the way ReportWebView splices a template).
  • Highlight→ask→thread is the same layer, page-scoped. The shell links the shared design/thread-layer.{css,js} like the Reports shell does (see the pipeline above for the full mechanism) and configures it for PDF.js's per-page text layer instead of a DOM leaf: it supplies its own locate, stamps a 1-based PDF page onto every anchor via captureExtra, scopes affix matching and drag clamping to one page's .textLayer, and opts out of short-quote block promotion (anchorBlockSelector: '' — the enclosing block here is a whole page). Only mounted pages exist in the DOM, so a whole-document locate() would search a hole; __pdfPageMounted/__pdfPageUnmounted drive layer.relocate(). Threads route through the same /api/verbatim-feedback//api/verbatim-threads endpoints at coords {book: "cover-thomas-pdf", chapter: "book", leaf: "pdf"} — the Mac-side poll session grounds answers by reading the actual PDF pages (page-ranged, via the PDF_BOOKS registry in verbatim_feedback.py), not a transcription.
  • The .textLayer rule is vendored verbatim from pdfjs/web/pdf_viewer.css, with exactly two documented edits. Both are colour, both are the same point: upstream washes ::selection in the system AccentColor, and selecting text in that layer is precisely how a highlight→ask thread starts here — so it was iOS blue under the one gesture that paints --mark-rgb on every other surface. It is now rgb(var(--mark-rgb) / var(--mark-wash)), and pdf.js's find-in-page .highlight pair (inert — no find bar in this shell) was retuned to the same token so nothing on the surface can paint a second highlight colour. color:transparent on the selection is upstream's and stays: the text layer's glyphs are invisible proxies over the canvas bitmap, so visible selection ink double-prints the page. Its color-scheme:only light also stays — pdf.js measures glyph positions against a paper-white bitmap, and that declaration is scoped to the selector, not a theme. Everything else in the rule is upstream; re-vendor by copying it fresh and reapplying these two. One consequence worth knowing before you touch either file: the rule that hides the OCR layer is :is(span, br), which a mark.prose-mark anchor is neither, so the shared sheet's color: inherit on the anchor is what keeps a threaded quote's proxy text invisible here. Pinned by test_pdf_text_layer_anchors.py, in WebKit, along with the fact that wrapping does not change what a selection across the split spans returns.
  • Updating the book: replace the file at the App Support path above, then re-run sync-to-bundle.sh and ios/test.sh. No render step — the PDF ships as-is.

The shared thread layer

The highlight→Ask→reply-thread layer is one implementation, content/design/thread-layer.css + thread-layer.js (ours, not vendored — see content/design/README.md). It used to be hand-copied into three places and kept in step by mirrored string assertions across three test suites; those copies had already drifted (both shells predated the prefix/suffix quote disambiguation and the collision-narrowed hit rects, which reached only the leaves before build 42).

  • The layer is configured, never forked. A surface calls window.VBThreadLayer.init(config) once and gets a handle back (relocate / repaint / openSheet / closeSheet / threads). Every difference between surfaces is a config key — the container, the block selectors, the math selector and renderMath, a whenReady gate, label, captureExtra, affixScope, clampScope, locate, askExtra, chrome, the demo seam. thread-layer.js must never branch on which surface it is running in; ThreadLayerTests.layerDoesNotKnowItsSurface enforces that. Full contract: content/design/thread-layer.API.md.
  • Its internals are Wayfinder's review pane. The pane the layer renders is a port of ~/wayfinder/Wayfinder/Resources/review/: the two-grade glass overlay (a .vb-overlay-card for a thread, the bare grade for a comment with no messages yet), the no-fill composer box, the live-markdown editor, the hull-clipped wash over transparent mark.prose-mark anchors (its wrapTextRange / wrapNode), Wayfinder's message shapes, and morphReplyIntoThread. What did NOT come over is Truth Seeker's own: the long-press + finger-drag selection (its wordSnap stays plain JS — Range.expand is the bug that shipped once) and the bottom-anchored #vb-sheet, which stays because a card holding four messages plus a reply field fights the keyboard on a 402pt phone. The layer carries two vendored dependencies loaded before it: design/hull.js (the wash's rounded-hull clip path) and design/composer-markdown.js (the editor). Provenance and the four documented deviations: content/design/README.md.
  • Colour comes from design/tokens.css, nowhere else. The old per-surface --vb-* colour palette (--vb-bg-soft, --vb-fg, --vb-accent, --vb-link, --vb-code-bg, --vb-input-bg, --vb-btn-*, …) is retired: each shell used to map the tokens into a private set the layer then read, so a shell that forgot a row silently rendered against initial. The layer reads the tokens directly. The one --vb-* survivor is structural, not colour: --vb-hlayer-z (-1 on a leaf and the report shell, where the wash sits behind the text; 5 on the PDF shell, where a canvas page is opaque). A colour literal anywhere in the layer means a missing token — ThreadLayerTests.layerDeclaresNoColourLiterals fails on one.
  • How hard the mark washes is a token too: --mark-wash / -2 / -3. Every marked span on every surface reads it — the thread tint, the drag band, native ::selection on both shells and the leaves, and pdf.js's find-bar pair — so "make the highlight subtler" is one edit instead of five files hunting 45%. Wayfinder's 45/65/80 ramp is halved to 22/32/40 (Mark, 2026-07-26: a marker pen on a phone; a highlight is a note about where he stopped). The ramp's proportions are kept so two and three overlapping threads still read as deeper without a second colour. TokensTests.markWashIsSubtlerThanWayfindersAndStillRamps asserts the intent — under Wayfinder's at every level, monotone, non-zero — not the digits.
  • The composer shows no prompt text, and a question in flight shows only Working…. The box opens under the passage just highlighted with the caret in it, so the placeholder said nothing its position didn't (the :empty::before rule and the data-placeholder attribute are both gone, and composerBox takes no placeholder argument to put one back). What the reader can't see is whether the ask is moving: the provisional thread a fresh ask raises is Riff's shimmer line and nothing else — no meta row, no actions (each would 404 without a server id), no reply composer — and any thread whose last message is the reader's carries the same line, so a follow-up doesn't land in silence either. The sweep is Gui/ShimmerText.swift in CSS: a --text-primary band travelling through tertiary text, painted as the glyphs' background and clipped to them through the vendored wayfinder-shimmer keyframes.
  • Cancelling clears the band on touchstart, not on the click behind it. The dismissal used to hang off a click on document and the dwell decided whether one arrived: under 400ms the long press never arms and the click lands, past 400ms it arms and touchend's preventDefault — there to keep the click off onHighlightTap — takes the dismissal with it. A finger that came down between two words then hit wordSnap's collapsed range, whose branch clears selRange but not pending, so the band kept burning under a composer Mark had dismissed. Same tap, different dwell, opposite outcome. Both listeners now share one guard (dismissOutside), so a typed draft still survives a stray tap either way.
  • Two delivery mechanisms, one source. The two shells (report-shell.html, pdf-shell.html) link the layer and both its dependencies as design/{hull,composer-markdown,thread-layer}.{css,js} — they are bundled files with siblings. A rendered Study leaf cannot: it must be a single self-contained offline file with zero relative src/href (StudyTests.leafHTMLHasNoRemoteScripts), so render.py inlines all of them — plus design/tokens.css and design/markdown.css — at render time. sync-to-bundle.sh copies them into Study/design/.
  • One consequence worth knowing: KaTeX cannot ship on a leaf (its stylesheet needs a sibling katex/ directory the single-file contract forbids), so the composer's cmMathReady() guard is false there and a closed $math$ stays visible as serif-italic source instead of collapsing to an invisible run. The two shells link KaTeX and get the real islands.
  • Code is sized relative to the body it sits in, on every reading surface. --text-mono (10.8px) is tuned for Riff's terminal; a leaf sets code beside 18px serif and a report beside 18px SF Pro, so the token renders inline code as a footnote inside its own sentence. The leaves answered this first — see the comment above the code rule in content/feller-vol-2/render.py — and both shells now carry the same override: .md-body code/pre at 0.88em, pre code back at 1em so a fenced block does not compound the reduction. Face (--font-family-mono), ground (--surface-sunken) and corners (--radius-4 / --radius-bubble) stay markdown.css's. The token itself is never moved: Riff's terminal is load-bearing on it, and the palette is frozen at two overrides.
  • The two shells wear .md-body too, with one documented deviation. Both hand- maintained shells (report-shell.html, pdf-shell.html) link design/markdown.css, and the report's <main id="report"> carries the class, so a code fence in a report is literally the same object as a fence in a thread message. Beyond the code size above, the report overrides exactly one thing: the heading SCALE. markdown.css is tuned for a chat message at --text-chat-body (16px) — h1 20 / h2 18 / h3 16 — and against the report's --text-lg (18px) body that puts h2 at body size and h3 below it, so a long document loses its hierarchy. The report steps the same token scale up one: --text-xl / --text-heading-lg / --text-heading-md. Colour is untouched (--text-primary, same as body) and the h2 top rule stays as the section separator. The report body has no narrow-screen step-down — the old rule dropped to 17px under 600px, which is every phone, so the 18px claim was false on the only device that renders it.
  • --mark-rgb is the highlight on every surface, including the PDF's text layer. Selecting text is how a highlight→Ask thread starts, so a surface that leaves ::selection at the system default paints iOS blue under the same gesture that paints the marker yellow everywhere else. render.py already set it on the leaves; the report shell now does, and pdf-shell.html's vendored pdf.js text layer had its color-mix(in srgb, AccentColor, …) retuned to rgb(var(--mark-rgb) / var(--mark-wash)) — the only edit to that verbatim block, besides pointing pdf.js's (inert, no find bar) find-in-page magenta at the same token. color:transparent stays: the text layer's glyphs are invisible proxies over the canvas bitmap, and visible selection ink double-prints the page.
  • The per-thread edits toggle is client-side. Wayfinder persists it with a toggle-edits op; this server's op model is add | reply | edit | status | delete and verbatim_threads.py::_validate raises on anything else, so the preference is held in localStorage and ridden along on the ask payload (where _do_verbatim_feedback ignores unknown top-level keys rather than rejecting them). Changing that is a server change, deliberately not made here.
  • Tests are de-duplicated to match. TruthSeekerTests/ThreadLayerTests asserts the layer's invariants once against content/design/; each surface keeps one thin test that it pulls the layer in and one for its own config (StudyTests.verbatimLeafInlinesThreadLayer, ReportRenderTests.reportShellLinksThreadLayer, PdfShellTests.pdfShellLinksThreadLayer). ThreadLayerTests.noThreadLayerFunctionIsDefinedTwice scans the three consumer sources for any redefinition of a layer function, so the triplication cannot come back.
  • Editing it: change content/design/thread-layer.{css,js} (or hull.js / composer-markdown.js), re-render the leaves (step 2 below), re-run sync-to-bundle.sh, then cd ios && ./test.sh. Every leaf inlines the layer, so skipping the re-render leaves 41 stale copies on device while every source-level assertion stays green. test_ch01_default_render_is_byte_stable does not catch that on its own — it renders to a temp file and compares a pinned hash, so it fires on the source change and is satisfied by updating the hash, which is how the leaf restyle shipped a red main. The guard that catches it is tests/test_build_artifacts_current.py, which byte-compares every tracked build/*.html against a fresh render. All four bundled files are byte-compared against their sources by ThreadLayerTests.bundledLayerMatchesSource, so a stale sync fails the gate too.

Updating Study content

  1. Edit the source .md under content/feller-vol-2/ch01/ or content/cover-thomas/chNN/ (or add a chapter).
  2. /usr/bin/python3 render-all.py → refreshes every build/*.html. (A new chapter needs its row in leaves.py first.) Re-running one leaf's render.py command by hand also works, but re-render all of them after any change to render.py or content/design/, since every leaf inlines both. HTML leaves only — for the PDF leaf, see "Updating the book" under PDF books above.
  3. If the hierarchy changed (new chapter/leaf/book), edit build/study-manifest.json to match (one leaf per chapter).
  4. ./content/feller-vol-2/sync-to-bundle.sh — copies build/* and katex/ into ios/TruthSeeker/Resources/Study/ (the folder-reference the app bundles).
  5. /usr/bin/python3 publish-web.py — puts the same bytes on the web for the car and macOS (see above). Ordered last of the three scripts and never skipped: re-rendering without re-publishing is exactly how the six hand-made car editions froze four generations behind the app.
  6. cd ios && ./test.shStudyTests is the ship gate (manifest decodes, every leaf is bundled, no remote src=/href=, KaTeX js/css/fonts bundled).
  7. Ship an OTA build (the leaves are in-bundle, so a content change needs a rebuild — unlike the old web-only deploy).

The car surface (reading chapters in the Tesla)

Every chapter is readable in the Tesla's in-dash browser, at https://marks-mac-mini.tail20af9f.ts.net/280de112-a164-4fcf-9e42-861119cf47f7/index.html listing all 41, each leaf beside it, mathjax/ and design/fonts/ symlinked in. publish-web.py writes it (see the pipeline above); nothing there is hand-edited.

It is the same artifact, not a car edition. The published bytes are byte-identical to ios/TruthSeeker/Resources/Study/*.html, so iOS (offline, bundled), macOS and the car all read one file. The two surface-specific accommodations both live in the shared content/design/ sources and ship to every surface: the car type scale (a media query in tokens.css) and the web storage fallbacks (thread layer) — both documented above.

Exposure is deliberate. The Tesla is on cellular and cannot join the tailnet, so tailnet-only serving is impossible and the directory is on the public Funnel. It is an unguessable UUID path — a public bearer URL, readable by anyone who has it — with directory listing suppressed (a request for the directory or mathjax/ 404s rather than enumerating the library) and X-Robots-Tag: noindex, nofollow, noarchive on every response, plus render.py's own <meta name="robots">. Both guards are pinned by webpage-server/tests/test_car_probe.py. Publishing leaks no new secret: the 8-character verbatim feedback token baked into every leaf was already publicly downloadable inside truth-seeker-ota/TruthSeeker.ipa. What is genuinely newly exposed is the transcribed chapter text itself, on a public URL — an accepted tradeoff, not an oversight.

The PDF leaf is deliberately excluded. The 774pp Cover & Thomas scan is ~10MB over cellular and pdf.js on a dash reads worse than the Ch. 7–12 HTML that already covers the same material. The 41 published leaves are the HTML ones.

The six hand-made car editions from 2026-07-18 are retired: each old UUID now serves a redirect to its chapter here, and the originals are archived under ~/Library/Application Support/truth-seeker/car-editions-2026-07-18/ (nothing regenerates them).

Car probe

content/design/car-probe.html + content/feller-vol-2/publish-probe.py — a capability report the Tesla's browser fills in itself. It is not part of the chapter pipeline above: it publishes one diagnostic page, on demand, and no chapter depends on it.

/usr/bin/python3 publish-probe.py    # → ~/www/175d38a1-….html, prints the URL

The car's Chromium version is unknown and varies by MCU hardware and software release, so nothing downstream may assume one. The probe measures instead: one pass/fail row per dependency the leaves actually have (contenteditable= "plaintext-only", rgb(from …), backdrop-filter, caretRangeFromPoint, Range.getClientRects, localStorage, dvh, the modern JS the shared layer ships, MathJax, an authenticated threads GET), the viewport numbers a car type scale would key on, a live long-press trial running the real design/thread-layer.js, and a real plaintext-only composer to type into. It POSTs the report to /api/car-probe (webpage-server stores it under ~/Library/Application Support/truth-seeker/car-probe/) and shows everything on screen, because a dash has no console and no way to copy text out.

The source page is a template: tokens, the thread layer, the MathJax config, the endpoints and the feedback token are substituted in from render.py and content/design/ at publish time, so the probe cannot drift from what the leaves ship. Publishing also relinks ~/www/mathjax and ~/www/design/fonts — symlinks into this checkout, the two siblings a leaf's relative refs need. Re-run publish-probe.py after any change to content/design/.

Backend addition (webpage-server)

One new endpoint, landed in the sibling webpage-server/ project (not here):

  • GET /api/papers → JSON array from ~/Library/Application Support/daily-paper-digest/history.json, newest first. Each entry: {date, arxiv_id, title, url, hook?}. Read-only, public (content is public arxiv links already), X-Robots-Tag: noindex. No token — consistent with the public HTML pages behind Funnel.

daily-paper-digest delivers its daily pick via an APNs push to the Papers tab (see Push notifications); bb-send is retained solely for the failure alert when all attempts are exhausted. /api/papers just exposes the history it already writes. (If a hook per paper is wanted in the list, that's a small additive write in paper-digest.sh — note as a P1 sub-task, not a blocker; the arxiv title is a fine fallback.)

Per-topic evolving reports (Papers tab → Reports)

The Papers tab carries a Latest | Reports segmented control. Latest is the existing daily-papers list (unchanged). Reports lists a small set of narrow topics, each a faithful map of the absolute frontier of its topic — the current limit of understanding, broad across every real sub-direction (the failure mode is omission of a whole family/camp), with strict primary-source citation; not a "what's new this week" changelog or a dated paper list. Pitch level is free (rudimentary or advanced, as the material needs) — the invariants are fidelity + completeness w.r.t. the SOTA and source discipline. Topics render with math (KaTeX, client-side from $…$/$$…$$) and read offline. No APNs push for reports (they update rarely).

  • Producer: a second, best-effort step in jobs/paper-digest.sh runs after the digest delivery (the digest's exit codes are decided first; the report step is wrapped so it can never break the digest). For each topic it drops an event for the same daily-paper-digest poll session (distinguished by prompt body — see jobs/poll-instructions-topic-report.md), which does a targeted WebSearch sweep and replies with either the sentinel UNCHANGED or the full new markdown plus a trailing SOURCES: line. The deterministic merge/sentinel logic lives in jobs/report-merge.sh (sourced by the job, unit-tested standalone) and writes ~/Library/Application Support/daily-paper-digest/reports.json atomically only on a real change. The merge hardens the reply before writing: it splits SOURCES: on the last token anywhere in the body (not line-anchored — a mid-line SOURCES: no longer leaks into the markdown); normalizes block structure deterministically and idempotently (a blank line after every heading, each $$…$$ display block isolated on its own line with surrounding blanks, 3+ blank lines collapsed to 2); and rejects (keeps the prior version, logs a WARNING … rejected) any reply that would still mangle — unbalanced $$, a collapsed-blob signature (a heading fused to a whole paragraph, a ## marker embedded mid-line, or a single-line body with 2+ ##), or a changed report carrying empty sources (REPORT_REQUIRE_SOURCES_ON_CHANGE=1, default ON — an unverifiable change must not overwrite a verified one). Normalization only fixes the common near-misses; it never re-inserts paragraph breaks into a run-on blob (impossible to do correctly), so the fully-collapsed case is rejected, not guessed. The output contract (jobs/poll-instructions-topic-report.md) demands well-formed Markdown + primary-source-only, post-hoc-citable claims to keep the reply on the normalize-and-accept path.
  • Endpoint: GET /api/reports in webpage-server/ exposes reports.json as {"reports":[{topic,title,updated,markdown,sources}]} — public, no token, X-Robots-Tag: noindex, order preserved (display order), missing/dirty → 200 {"reports": []}.
  • iOS: Report.swift / ReportsClient.swift / ReportsViewModel.swift mirror the Papers data layer (own cache key reports); PapersTabView hosts the segmented control, ReportsView the topic list, ReportWebView the offline KaTeX+markdown renderer (reuses the bundled Resources/Study/katex/ plus a vendored marked.min.js and report-shell.html). The shell also runs a small idempotent normalizeReportMd pre-pass before marked.parse (belt-and-suspenders mirroring the merge's _normalize_report_md; upstream is the primary fix).
  • The shell declares no palette of its own. report-shell.html links design/tokens.css and design/markdown.css from the bundled content/design/ drop — the same sheets the Study leaves inline and ios/TruthSeeker/Design/Tokens.swift mirrors, so the native chrome and the web report read as one system. Those sheets are vendored from Riff's transcript/ (Wayfinder for the review pane; see content/design/README.md for which file came from where and why), byte-for-byte except two colour overrides: --surface: #18160f and --text-primary: #ece4cf, a warm ground and cream ink in place of upstream's neutral #181818/#ffffff, because this is a reading app and the lower-halation pair suits sustained reading and the serif body the leaves keep. Everything derived from --text-primary (--text-secondary, --text-tertiary, the --border-* set) follows through CSS relative-colour syntax. <main id="report"> wears .md-body, so a code fence in a report is the same object as one in a thread message; the report overrides exactly one non-colour thing, the heading scale (see "The shared thread layer"). Headings set no color of their own and --mark-rgb is the only highlight anywhere.
  • Reports highlight→Ask→thread (parity with the verbatim leaf): selecting any passage in a report is the long-press (~400 ms) + finger-drag gesture (the same mechanic as the verbatim leaf — native selection suppressed, amber band, the in-page composer popover raised on lift); typing a question and tapping Ask calls the shell's ask() directly and the answer surfaces as a comment thread anchored to the passage (DOM-band highlights, background-tint affordance — no underline as of build 21), never mutating the report. As on the verbatim leaf (build 20), tapping a threaded span opens that one thread as the bottom-anchored in-page sheet (Done + Edit/Resolve/Delete + "Ask a follow-up"); there is no side panel and no 💬 marker. report-shell.html links the shared design/thread-layer.{css,js} and configures it (DOM-band painter + in-page composer; no web→native ask channel; container #report; a whenReady gate on the marked+KaTeX render, since #report is empty at parse time; askExtra adds the report markdown as context). Reports reuse the verbatim endpoints (/api/verbatim-feedback ask
  • /api/verbatim-threads) with coords book="reports", chapter=<topic-id>, leaf="report" (Phase 3 generalized them). ReportWebView injects the token + absolute endpoints + coords as window.__REPORT_* globals; the token is read at load from a bundled Resources/Study/report-token.txt written by sync-to-bundle.sh from the verbatim token source and gitignored (the committed shell stays token-free; empty token → asking silently disabled, reading still works). Reading + already-fetched threads work offline — the report shares the verbatim leaf's offline thread cache (ThreadCacheStore, keyed by report topic; seeded on didFinish via window.__THREADS_CACHE__ + __vbApplyThreadCache, refreshed over vbThreads, with the same #vb-cachebanner failure UI); asking needs the Mac.
  • Reports detail is full-bleed (parity with the verbatim leaf). The pushed ReportDetailView hides the SwiftUI nav bar and the bottom tab bar (.toolbar(.hidden, for: .tabBar)), fills the top+bottom safe areas, and shows a single floating Back pill that follows the last scroll direction (hides on scroll-down, reveals on scroll-up) and holds that state at rest — no idle re-reveal (build 20). report-shell.html posts the same {visible:Bool} vbChrome scroll-direction signal the verbatim shell does; ReportWebView's Coordinator routes it to a shared StudyChromeModel (default visible; garbage body never hides). Because the Latest | Reports segmented control is a sibling of the Reports NavigationStack (not inside it), the stack's path is lifted to PapersTabView, which hides the segmented control while a detail is pushed (!reportsPath.isEmpty) so the detail is full-bleed to the very top; it returns on pop. Latest is unchanged.

iOS project layout

Mirrors Riff's ios/ conventions (xcodegen, xcconfig secrets, Makefile):

truth-seeker/
  README.md                 ← this spec (canonical)
  install.sh                ← symlinks the two deploy skills into ~/.claude/skills/
  verify.sh                 ← migration ship-gate (de-leak + skill cutover + build)
  content/
    feller-vol-2/
      ch01/                 ← chapter.md, pages/*.tex (transcription source)
      katex/                ← vendored KaTeX 0.16.11 (css/js/auto-render + fonts/*.woff2)
      render.py             ← .md → self-contained offline HTML leaf (local KaTeX, --feedback-mode reply)
      build/                ← rendered leaves + study-manifest.json (committed)
      sync-to-bundle.sh     ← copy build/* + katex/ → ios/.../Resources/Study/
  skills/
    truth-seeker-ota/       ← /truth-seeker-ota   skill (OTA dev build → Funnel page)
    truth-seeker-update/    ← /truth-seeker-update skill (cabled devicectl deploy)
  ios/
    project.yml             ← xcodegen; bundleIdPrefix: mark
    TruthSeeker.xcconfig            ← host URL etc. (gitignored)
    TruthSeeker.xcconfig.example    ← template (committed)
    Makefile                ← generate / build / deploy helpers
    ExportOptions.plist     ← TestFlight export (method=app-store-connect, P2+)
    ExportOptions-adhoc.plist  ← OTA export (development-signed; /truth-seeker-ota)
    TruthSeeker/            ← app sources, Info.plist, entitlements, assets
      Study/                ← native Study tab: manifest/library/list/leaf views
      Resources/Study/      ← folder-reference bundled into the app: leaves + katex/ (synced)
    TruthSeekerWidget/      ← widget extension (P2)
    TruthSeeker.xcodeproj/  ← generated by xcodegen (gitignored)
  • Bundle id mark.truthseeker; app group group.mark.truthseeker (widget data sharing, P2). Deployment target iOS 17, automatic signing, team 6C63UU27YB (same as Riff).
  • The Funnel base URL is injected via TruthSeeker.xcconfig ($(...) into Info.plist), read at runtime — never hard-coded into a distributed build (mirror Riff's /riff-publish secret guard; the .example ships a placeholder).
  • Standalone repo (extracted from ~/agents 2026-05-30). This is its own git repo at ~/truth-seeker. install.sh symlinks the two deploy skills (skills/truth-seeker-{ota,update}) into ~/.claude/skills/; verify.sh is the migration ship gate (it runs make sim). The unit suite (ios/test.sh, added with the push feature) is the per-change test gate — see Testing. The only residual coupling is the shared ~/agents/webpage-server, which serves the OTA payload at ~/www/truth-seeker-ota/ via its generic /<slug>-ota/ route (shared infra, like the day-trading /trade dashboard — not a leak).

Phasing

  • P1 (MVP) — app scaffold (mirror Riff), bottom tab bar, News + Study WKWebViews, native Papers tab, GET /api/papers in webpage-server, offline snapshot, app icon + launch screen, on-device deploy. This is the approvable first build.
  • P2 — home-screen widget (today's paper hook + unread newsfeed count via the app group), share-sheet extension → read-later queue, TestFlight publish. Push is done (see Push notifications): native APNs for the daily paper digest (→ Papers tab) and the newsfeed refresh (→ News tab), reusing Riff's apns.py.
  • P3 — polish: pull-to-refresh, native reading settings (font size), read- later surface; selectively nativize the News tab only if the webview ever feels insufficient.

Push notifications

Native APNs pushes for the two scheduled content events, each tap routing to the right tab. Reuses Riff's already-working push stack — same team-wide .p8 (6C63UU27YB), same apns.py client (imported, not copied) — only the apns-topic is mark.truthseeker.

Event Fired by Tap opens
New daily paper digest (~12:00) daily-paper-digest.sh shells out to the sender CLI after recording a fresh pick Papers tab
Newsfeed refresh (08:00 + 18:00) newsfeed.py _run_pipeline() calls the sender in-process after publish News tab

Up to 3 pushes/day (2 newsfeed + 1 paper) — intended; no throttle.

daily-paper-digest.sh ─CLI──┐
                            ├─► truth_seeker_push.py ─► APNsClient ─► APNs ─► iPhone ─► tap ─► tab
newsfeed.py ─import─────────┘            ▲
                                         │ reads
                       push-tokens.json (App Support)  ◄── POST /api/register-push ◄── app on launch
  • Server side lives in ~/agents (with the two producers), not this repo: ~/agents/truth-seeker-push/ (token_store.py + truth_seeker_push.py) and the POST /api/register-push endpoint on webpage-server. Token store: ~/Library/Application Support/truth-seeker/push-tokens.json (atomic, 64-hex validated, capped, self-prunes on APNs 410). Sender is best-effort — a push failure never breaks a producer.
  • iOS side (this repo): AppDelegate.swift (token capture + best-effort POST to /api/register-push, skipped when unchanged; banner + tap routing), NotificationRouter.swift (pure tab(for:) payload→tab map, unit-tested), TruthSeeker.entitlements (aps-environment = development), the inline entitlements: block in project.yml, and TruthSeekerApp/ContentView wiring (prompt on launch, badge clear on foreground, selectTab → tab).
  • development / sandbox, not production. The OTA build is dev-signed, so its token is only valid against the APNs sandbox host — the sender defaults to env="sandbox" to match. Flip both (aps-environment = production + sender --prod) only for a future TestFlight build. A dev token sent to the prod host silently never arrives — the #1 "push didn't show up" bug; check ~/Library/Logs/truth-seeker-push.log for the APNs status/reason.
  • One-time provisioning gate. mark.truthseeker had no Push capability, so the first signed/OTA build needs a one-time Xcode-GUI enable: open TruthSeeker.xcodeproj → target → Signing & Capabilities → + Capability → Push Notifications, let Xcode register push on the App ID + regenerate the managed profile, then resume headless OTA. Simulator unit tests don't need it.

Testing

ios/test.sh runs the swift-testing unit suite on the simulator (mirrors Riff's ios/test.sh). Green = exit 0 + ** TEST SUCCEEDED **.

cd ~/truth-seeker/ios && ./test.sh                 # default sim (iPhone 17 Pro)
SIM='iPhone 17' ./test.sh                           # override the sim
  • TruthSeekerTests/NotificationRouterTests covers the push payload→tab routing (NotificationRouter.tab(for:)) and guards that AppTab and ContentView.Tab keep identical raw values.
  • Reports merge/sentinel unit test (pure shell + jq, no network/poll): bash ~/truth-seeker/jobs/tests/test_reports_merge.sh — exits 0 on pass. Covers the UNCHANGED/empty/blank sentinels (byte-identical no-op), markdown+SOURCES: split (including a mid-line SOURCES: — the build-17 leak regression), the $/quote/backslash round-trip guarantee, new-topic append, block-structure normalization (blank line after headings, $$…$$ isolation, idempotency), and the reject gate (collapsed-blob via the real build-17 backup body, unbalanced $$, changed-with-empty-sources → prior kept + WARNING). The three merge helpers (_split_sources, _normalize_report_md, _report_md_is_safe) are independently sourceable and unit-asserted.
  • The Study renderer has its own pytest suite, not run by test.sh: cd ~/truth-seeker/content/feller-vol-2 && /usr/bin/python3 -m pytest tests/ -q (the system 3.9 is the interpreter with markdown installed). It covers render.py's output contract, math layout, anchor relocation — and test_build_artifacts_current.py, which byte-compares every tracked build/*.html against a fresh render. Run it after any change under content/, because test.sh cannot see a stale committed artifact until it reaches the bundle.
  • The server side has its own pytest suites under ~/agents (truth-seeker-push/tests/, webpage-server/tests/test_register_push.py).
  • verify.sh remains the migration/self-containment gate (make sim build).
  • Reading-position precision (UI screenshot gate)TruthSeekerUITests (separate scheme, run on demand, NOT in test.sh): StudyLeafChromeUITests.testReadingPositionIsRemembered and ReportAskUITests.testReportReadingPositionIsRemembered scroll deep, reveal Back, snap the left-off frame, leave, re-enter, snap the reopened frame; the two must be pixel-identical. Snap the left-off frame AFTER the Back-reveal scroll-up — that scroll-up is captured by the 200 ms debounce and becomes the saved position, so snapping before it compares against the wrong frame (the build-22→23 false alarm).
  • AppIcon golden test — the icon is an Icon Composer document, ios/TruthSeeker.icon (regenerated by swift ios/make_appicon.swift): an ivory Ψ on a warm-charcoal gradient, set in Noto Serif Display at wght 400, vendored under ios/fonts/ with its OFL licence. The face is vendored rather than taken from the system because macOS Didot carries Greek in its Bold cut only — Regular and Italic have no Ψ at all, so "sharp and classical but not bold" is unreachable from the installed fonts. actool compiles the document into the iOS 26 layered icon and the flat back-deploy sizes, so it is the only icon source — there is no .appiconset. TruthSeekerTests/AppIconTests gates it in four layers: a SHA-256 pin on the vector artwork (Assets/psi.svg — a revert to the old fat Palatino Ψ, or a nudge of the weight axis, fails it), the icon.json contract (specular / shadow / translucency all OFF, since each one bevels the hairline serifs back into the "fat" look), that the compiled icon actually reached the app bundle (CFBundleIconName + the back-deploy PNG) — the artwork can be perfect and still not ship — and that the vendored face is still readable and still has U+03A8, because a font without it doesn't error, it falls through CoreText's cascade and yields a monoline sans Ψ. The authoritative proof is still the Simulator home-screen screenshot; the pins are the automated regression gate. An intentional redesign updates the pinned digest and the screenshot together.
  • Long-press-drag selection + in-page composer — source string assertions in test.sh, all in TruthSeekerTests/ThreadLayerTests against content/design/thread-layer.{css,js}: hasDragToSelectGesture greps the gesture (selectstart, wordSnap, the vb-sel-band drag band, touchstart, showPopover) and asserts Range.expand / .expand('word') is absent (the WebKit user-select:none gotcha guard); paintsDOMBandsNotCustomHighlights / usesInPageComposer assert the DOM-band painter (vb-hlayer, vb-thread-band, bandRects) + the in-page popover (vb-popover, showPopover) are present and that the CSS Custom Highlight API (CSS.highlights / ::highlight() and the web→native ask channel (messageHandlers.vbAsk, __vbCaptureAnchor / __vbAsk / __vbClearSelection) are gone. composerReadsPendingNotSelection scope-asserts submitQuestion reads the frozen pending range, not getSelection. The Python content/feller-vol-2/tests/test_render_modes.py mirrors these against the render.py output. The real-surface proof is the on-demand UI test (TruthSeekerUITests, NOT in test.sh): AskClaudeUITests.testLongPressDragRaisesInPageComposer drives press(forDuration:thenDragTo:) on the live WKWebView, asserts the in-page composer popover is raised on lift and that no Copy/Look Up callout appears (selection suppressed); ReportAskUITests gives the report surface the same treatment.
  • The ported review pane, end to end (UI gate)ThreadKitUITests.testComposeMorphFollowUpResolveReopen (TruthSeekerUITests, NOT in test.sh) drives the whole arc on the real WKWebView: long-press to select, type **bold** in the live-markdown box, submit, catch the morph mid-flight, reopen the threaded span's sheet by tapping it, post a follow-up, resolve, reopen. Every item R2 ported is a runtime behaviour, so the string assertions in ThreadLayerTests cannot reach it.
  • Two preconditions: the Mac's webpage-server must be reachable (mutate() applies a status change only after the server confirms it, so the resolve leg has no offline path), and TS_COMPOSER_PROBE=1 must be set. That flag injects StudyLeafView.composerProbeJS, which mirrors two DOM facts — a **bold** run became .cm-bold, and its markers collapsed — into a badge XCUITest can read. XCUITest sees only the accessibility tree, where the editor is one text value with the asterisks still in it (collapsed markers keep their characters in the flow at 0.1px, which is the whole point), so without the probe there is no observable difference between live markdown and a plain textarea. The probe is injected from a gated branch and is not in the shipped layer.

Deploy

Two install paths, symmetric to how Riff's /riff-ota and /riff-update relate: an OTA path (off-LAN, the preferred remote install) and a cabled fallback (devicectl over USB).

OTA deploy path (/truth-seeker-ota)

/truth-seeker-ota builds an ad-hoc (method=development) .ipa and publishes .ipa + manifest.plist + install.html to ~/www/truth-seeker-ota/, served over the Tailscale Funnel. Install by opening the install page in Safari and tapping Install Truth Seeker — off-LAN, over cellular, no cable, no devicectl. (--release flips Debug→Release; --no-send skips the iMessage.)

  • Unlike Riff, the OTA build bakes the (public) FUNNEL_HOST and carries no secret, so there is no secret-less guard and no onboarding step — the OTA build is the production build, host included.
  • Open the install page in Safari, not the raw itms-services link (SpringBoard intercepts the raw link; tapping it in Messages/Mail does nothing).
  • Dev-profile expiry self-heals. A build signed with an expired development profile installs but won't launch; re-running /truth-seeker-ota re-signs.
  • webpage-server MIME/Range dependency: the manifest must be served as text/xml and the .ipa with Range/206 — the generic /<slug>-ota/... route (see webpage-server/README.md). Kick the server after a handler change.
  • ExportOptions-adhoc.plist (method=development) lives in ios/ alongside the TestFlight ExportOptions.plist (method=app-store-connect, P2).

Cabled fallback (/truth-seeker-update)

cd ~/truth-seeker/ios
xcodegen generate          # regenerate the project from project.yml
# build Debug, install to the iPhone over devicectl (USB reliable; the
# wireless tunnel drops even on wifi — keep a cable handy)
  • Build without the device attached; an auto-installer polls for a reachable phone (Riff pattern — reference_riff_deploy_install_channel).
  • Install-over, never clean-uninstall for an already-installed app (reference_riff_install_over_not_uninstall).
  • /truth-seeker-update mirrors /riff-update; it's the cabled fallback when the wireless tunnel is flaky (USB reliable).
  • TestFlight via altool + ExportOptions.plist is P2 (/riff-publish pattern), and only if Mark wants others to have it — personal use needs only the device install.

Config & secrets

  • TruthSeeker.xcconfig (gitignored) holds the Funnel host; .example is the committed template. Same split Riff uses for MAC_MINI_HOST / RIFF_SHARED_SECRET.
  • No new secrets in git. The repo .gitignore already blocks *.xcconfig-class patterns — confirm the truth-seeker xcconfig is covered when scaffolding.

Gotchas

  • iCloud Private Relay breaks Funnel pages (reference_icloud_private_relay_funnel). Both WiFi and cellular fail the same TLS error under Private Relay. The WKWebView surfaces will fail to load for the same reason Safari does — the offline snapshot is the mitigation, and a clear "can't reach your Mac" state beats a blank webview.
  • SourceKit/editor diagnostics are noise (reference_riff_sourcekit_noise). Trust xcodebuild, not editor squiggles.
  • codesign over SSH needs in-session keychain unlock (reference_riff_codesign_keychain_ssh) — security unlock-keychain -p inline in the same shell.
  • Mac pip3 is broken (libexpat) (reference_mac_pip_libexpat_broken). If any install.sh here grows a pip step, expect it to die — keep deploy paths pip-free (iOS build doesn't need it).
  • Trunk-based, no worktrees (user_trunk_based_no_worktrees). Build on the shared branch; do not spin up git worktrees for this.
  • App Store "minimum functionality" (4.2) — a pure webview wrapper gets rejected. The native tab bar, Papers tab, offline, widget, and share-sheet are what make this a legitimate app. (Irrelevant for personal device install; matters only if P2 goes to TestFlight/App Store.)

Dependencies

  • Existing, unchanged: webpage-server (backend), newsfeed, daily-paper-digest.
  • Vendored in-repo: the Feller renderer (content/feller-vol-2/, promoted from ~/reading/feller-vol-2/) and KaTeX 0.16.11 (content/feller-vol-2/katex/).
  • New: the iOS app target(s) under ios/, and the one /api/papers endpoint in webpage-server.
  • Toolchain: xcodegen, Xcode, a development-signed Apple account (team 6C63UU27YB), a USB cable for reliable device install. render.py needs Python 3 + the markdown package (already installed; do not pip).