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.studyPath → ac54e507-…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  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 ∈ 01…34) |
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.py → sync-to-bundle.sh → publish-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 ch08…ch12, 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 ch01…ch34).
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 
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'sMATHJAX_HEADreferences these by relative path (mathjax/…) — no CDN — so the leaf renders math fully offline once it sits besidemathjax/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 forreport-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.9kmjx-*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_HEADaddsenableMenu: falseand apageReadypass that putsrole="math"plus the equation's own TeX (mjx-math[data-latex]) asaria-labelon eachmjx-container, andaria-hiddenon its glyph subtree — the same shape KaTeX has always emitted.enableMenu: falseis load-bearing, not tidying: it removes thetabindex="0"MathJax puts on every container, and anaria-hiddensubtree 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 bycontent/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 thefile://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: noneover the whole reading container, armed for the length of the gesture (body.notouchsel). It is now a refusedselectstartplus 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 oncover-thomas-ch07in 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. Standinguser-select: nonemakes WebKit'sPosition::isCandidate()false for every position in the subtree, socanonicalPosition()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, oneweb.buttons["Ask"].existscost 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 withkAXErrorIPCTimeoutagainst an element that was plainly on screen —AskClaudeUITests,ThreadCacheOfflineUITestsandThreadKitUITestsall wedged on it, on iOS 18.5 and 26.4 alike, and on builds predating the makeover.report-shell.htmlruns the same layer and passed only because its document is short enough for the quadratic not to bite. VoiceOver paid the same walk. Refusingselectstartretires 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.selectstartis not a touch event, so R14 is untouched: every touch listener in the layer is still{ passive: true }. Pinned byThreadLayerTests.suppressesSelectionOnlyForTheDurationOfTheGestureandtest_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-unitmultiples), whichrender.pyinlines along withmarkdown.css; the leaf's old--bg/--fg/--rule/--linkalias layer is gone, and<meta name="theme-color">— the one thing on a page that cannot take avar()— 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 setscolor— 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.pyon the renderer, andtests/test_build_artifacts_current.pyon 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.csscloses 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's1em-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: coarsealone would catch the phone;min-width: 900pxalone would catch macOS in a wide window (which ispointer: fine). Coarse and wide is the dash and nothing else. Themin-height: 500pxfloor 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 whattest_ch01_default_render_is_byte_stablepins. - The committed leaves are checked against the renderer that produced them.
build/*.htmlis tracked, so a change torender.pyorcontent/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 (StudyTestsreads the bundle, and 15 of its assertions failed on a fresh checkout).tests/test_build_artifacts_current.pyre-renders every row ofleaves.pyand byte-compares againstbuild/, 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 oftest_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-guideeditmode 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 +suggestionfence) is the single sharedcontent/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 theproseplugin. Highlights paint as absolutely-positioned DOM bands behind the text (a sibling#vb-hlayerlayer atz-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 overuser-select:nonetext (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 runningverbatim-feedbackpoll 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 cancelledselectstartplus 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#popovermodel) 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-bandDOM band (distinct from the persistent.vb-thread-bandthread tint), and on lift freezes the anchor intopendingand shows the popover (showPopover). Typing a question and tapping Ask calls the leaf's existingask()path directly — the question never crosses into native, so the reply still renders as the anchored web thread. (submitQuestionreads the frozenpendingrange, notwindow.getSelection(); dismissing the popover clears the amber band — and a tap-outside is guarded so it can't discard a half-typed draft.)wordSnapis plain-JS string-offset expansion, NOT WebKit's word-mode range expansion — that API respectsuser-select:noneand 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 (renderThreads→paintBands), 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'swrapTextRangemodel:renderThreadswraps 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-resolvedRange. The anchors are geometry and identity only —background: transparent; color: inheritresets the UA's yellowMarkfill andMarkTextforeground, 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 (amjx-containeris 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 callsnormalize()so thesplitTextfragments rejoin and the tree does not get permanently more fragmented on every render. The live drag band stays on theRangepath — a gesture in flight has no thread to key an anchor to. The painter emits one.vb-thread-bandper 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 thez-index: -1layer, so eachpregets its own.vb-pre-paintlayer.bandRectsfolds in the visible math element rects so a band over math is painted too. Web seam: the gesture + composer JS lives entirely in the sharedcontent/design/thread-layer.js(see "The shared thread layer" below); there is no web→native ask channel —StudyLeafView.swift/ReportWebView.swiftonly inject config and read scroll signals (vbScrollon both;vbChromeon the report shell only — the leaf's guardedvbChromeemitter is inert since nothing native listens). - Offline thread cache + non-silent failure UI (build 29).
fetchThreads()does a liveGET /api/verbatim-threadson every open; its old.catchswallowed the error and setthreads = [], 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 inThreadCacheStore(Study/ThreadCacheStore.swift— a file-per-document JSON cache under<AppSupport>/threadcache/, mirroringSnapshotStore; native treats the payload as opaque JSON and never parses thread internals, so a thread-schema change needs no native change). OndidFinish,StudyLeafView/ReportWebViewinjectwindow.__THREADS_CACHE__and call the page'swindow.__vbApplyThreadCache()hook (keyed byleaf.id/ reporttopic, the same idReadingPositionStoreuses) — highlights paint immediately from cache, before the network resolves (the apply-hook resolves the seed/live race: the page's own on-loadfetchThreads()may run before native injects). On a live success the page posts the fresh array back to native over thevbThreadschannel (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.vbThreadscarries threads data only — it is NOT a web→native ask channel (vbAsk/__vbAskstay retired; theThreadLayerTests.threadCacheIsNotAskChannelre-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.jstherefore mirrors both intolocalStorage, but only when the matchingmessageHandlersentry is absent:postThreadswrites the threads array undervbThreadCache.<id>andfetchThreadsseedswindow.__THREADS_CACHE__from it before the live load; reading position mirrors underReadingPositionStore'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 usingThreadCacheStore/UserDefaultsand never switches stores.<id>is the document's filename, which isLeaf.id;book + '-' + chapterdoes not reconstruct it (Feller isfeller-vol-2-verbatim), and the null-originloadHTMLStringreport surface yields an empty id and opts out entirely — which is also why native, notlocalStorage, has to hold these states on device. Covered bytests/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 inThreadLayerTests.swift, one composing the key fromReadingPositionStore.keyPrefixitself so a rename fails the build instead of silently orphaning saved positions.StudyWebView.swiftis an emptyWKWebViewsubclass 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"UIEditMenuInteractionordering 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 byStudy/SwipeBackEnabler.swiftbecause 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 usesdocument.caretRangeFromPoint+range.comparePoint/getClientRects()on a delegatedclick. 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 toblockTop + offset, whereblockTopis 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 secondvbScrollweb→native channel (mirrorsvbChrome, posting{anchor, offset}, deduped on block-change OR ≥8 px offset delta) and persisted natively inUserDefaultskeyed by document id (vbReadingPos.<id>for the index,vbReadingPos.<id>.offsetfor the offset — reports load vialoadHTMLStringwith a null origin, solocalStorageis unreliable, hence native). On load the native side injectswindow.__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.swiftStudyLeafView.swift/ReportWebView.swift(thevbScrollhandler + thedidFinishinject). A test-only-resetBookmarks 1launch 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 inUserDefaults(vbLastRead, JSON) on the web view'sdidFinish, and restored inContentView.init, which seeds the selected tab and theNavigationStackpath (a typed[StudyRoute]array for Study; a pending-topic thatReportsViewappends 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 1launch arg also clearsvbLastRead. 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.jsonare 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/(vendoredbuild/pdf.min.mjs,build/pdf.worker.min.mjs,standard_fonts/,wasm/). Tracked in git likekatex/— 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.pdfand is synced into the bundle asStudy/cover-thomas-pdf.pdfbysync-to-bundle.sh, which hard-errors if that source is missing..gitignoreexcludes only the PDF at the bundle destination — the shell andpdfjs/copies underResources/Study/ARE tracked, mirroring how thekatex/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 ofhtmlResourceName(StudyLeafaccepts either; the leaf id ispdfResourceName ?? htmlResourceName ?? title). It's nested as its own chapter (pdf-full) inside the existingcover-thomasbook, alongside the ch07–ch12 chapters — not a separate book. Routes toPdfLeafScreen/PdfLeafView(Study/PdfLeafView.swift) instead ofStudyLeafScreen— sameloadFileURL+ config-injection pattern as the HTML leaves, via a.atDocumentStartWKUserScript(config can't be spliced into a bundled file the wayReportWebViewsplices 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 ownlocate, stamps a 1-based PDFpageonto every anchor viacaptureExtra, 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-documentlocate()would search a hole;__pdfPageMounted/__pdfPageUnmounteddrivelayer.relocate(). Threads route through the same/api/verbatim-feedback//api/verbatim-threadsendpoints 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 thePDF_BOOKSregistry inverbatim_feedback.py), not a transcription. - The
.textLayerrule is vendored verbatim frompdfjs/web/pdf_viewer.css, with exactly two documented edits. Both are colour, both are the same point: upstream washes::selectionin the systemAccentColor, 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-rgbon every other surface. It is nowrgb(var(--mark-rgb) / var(--mark-wash)), and pdf.js's find-in-page.highlightpair (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:transparenton 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. Itscolor-scheme:only lightalso 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 amark.prose-markanchor is neither, so the shared sheet'scolor: inheriton the anchor is what keeps a threaded quote's proxy text invisible here. Pinned bytest_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.shandios/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 andrenderMath, awhenReadygate,label,captureExtra,affixScope,clampScope,locate,askExtra,chrome, the demo seam.thread-layer.jsmust never branch on which surface it is running in;ThreadLayerTests.layerDoesNotKnowItsSurfaceenforces 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-cardfor 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 transparentmark.prose-markanchors (itswrapTextRange/wrapNode), Wayfinder's message shapes, andmorphReplyIntoThread. What did NOT come over is Truth Seeker's own: the long-press + finger-drag selection (itswordSnapstays plain JS —Range.expandis 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) anddesign/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 againstinitial. The layer reads the tokens directly. The one--vb-*survivor is structural, not colour:--vb-hlayer-z(-1on a leaf and the report shell, where the wash sits behind the text;5on the PDF shell, where a canvas page is opaque). A colour literal anywhere in the layer means a missing token —ThreadLayerTests.layerDeclaresNoColourLiteralsfails 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::selectionon both shells and the leaves, and pdf.js's find-bar pair — so "make the highlight subtler" is one edit instead of five files hunting45%. 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.markWashIsSubtlerThanWayfindersAndStillRampsasserts 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::beforerule and thedata-placeholderattribute are both gone, andcomposerBoxtakes 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 isGui/ShimmerText.swiftin CSS: a--text-primaryband travelling through tertiary text, painted as the glyphs' background and clipped to them through the vendoredwayfinder-shimmerkeyframes. - Cancelling clears the band on
touchstart, not on the click behind it. The dismissal used to hang off aclickondocumentand the dwell decided whether one arrived: under 400ms the long press never arms and the click lands, past 400ms it arms andtouchend'spreventDefault— there to keep the click offonHighlightTap— takes the dismissal with it. A finger that came down between two words then hitwordSnap's collapsed range, whose branch clearsselRangebut notpending, 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 asdesign/{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 relativesrc/href(StudyTests.leafHTMLHasNoRemoteScripts), sorender.pyinlines all of them — plusdesign/tokens.cssanddesign/markdown.css— at render time.sync-to-bundle.shcopies them intoStudy/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'scmMathReady()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 thecoderule incontent/feller-vol-2/render.py— and both shells now carry the same override:.md-body code/preat0.88em,pre codeback at1emso a fenced block does not compound the reduction. Face (--font-family-mono), ground (--surface-sunken) and corners (--radius-4/--radius-bubble) staymarkdown.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-bodytoo, with one documented deviation. Both hand- maintained shells (report-shell.html,pdf-shell.html) linkdesign/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.cssis 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-rgbis 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::selectionat the system default paints iOS blue under the same gesture that paints the marker yellow everywhere else.render.pyalready set it on the leaves; the report shell now does, andpdf-shell.html's vendored pdf.js text layer had itscolor-mix(in srgb, AccentColor, …)retuned torgb(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:transparentstays: the text layer's glyphs are invisible proxies over the canvas bitmap, and visible selection ink double-prints the page.- The per-thread
editstoggle is client-side. Wayfinder persists it with atoggle-editsop; this server's op model isadd | reply | edit | status | deleteandverbatim_threads.py::_validateraises on anything else, so the preference is held inlocalStorageand ridden along on the ask payload (where_do_verbatim_feedbackignores 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/ThreadLayerTestsasserts the layer's invariants once againstcontent/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.noThreadLayerFunctionIsDefinedTwicescans 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}(orhull.js/composer-markdown.js), re-render the leaves (step 2 below), re-runsync-to-bundle.sh, thencd 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_stabledoes 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 redmain. The guard that catches it istests/test_build_artifacts_current.py, which byte-compares every trackedbuild/*.htmlagainst a fresh render. All four bundled files are byte-compared against their sources byThreadLayerTests.bundledLayerMatchesSource, so a stale sync fails the gate too.
Updating Study content
- Edit the source
.mdundercontent/feller-vol-2/ch01/orcontent/cover-thomas/chNN/(or add a chapter). /usr/bin/python3 render-all.py→ refreshes everybuild/*.html. (A new chapter needs its row inleaves.pyfirst.) Re-running one leaf'srender.pycommand by hand also works, but re-render all of them after any change torender.pyorcontent/design/, since every leaf inlines both. HTML leaves only — for the PDF leaf, see "Updating the book" under PDF books above.- If the hierarchy changed (new chapter/leaf/book), edit
build/study-manifest.jsonto match (one leaf per chapter). ./content/feller-vol-2/sync-to-bundle.sh— copiesbuild/*andkatex/intoios/TruthSeeker/Resources/Study/(the folder-reference the app bundles)./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.cd ios && ./test.sh—StudyTestsis the ship gate (manifest decodes, every leaf is bundled, no remotesrc=/href=, KaTeX js/css/fonts bundled).- 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.shruns 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 samedaily-paper-digestpoll session (distinguished by prompt body — seejobs/poll-instructions-topic-report.md), which does a targeted WebSearch sweep and replies with either the sentinelUNCHANGEDor the full new markdown plus a trailingSOURCES:line. The deterministic merge/sentinel logic lives injobs/report-merge.sh(sourced by the job, unit-tested standalone) and writes~/Library/Application Support/daily-paper-digest/reports.jsonatomically only on a real change. The merge hardens the reply before writing: it splitsSOURCES:on the last token anywhere in the body (not line-anchored — a mid-lineSOURCES: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 aWARNING … 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/reportsinwebpage-server/exposesreports.jsonas{"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.swiftmirror the Papers data layer (own cache keyreports);PapersTabViewhosts the segmented control,ReportsViewthe topic list,ReportWebViewthe offline KaTeX+markdown renderer (reuses the bundledResources/Study/katex/plus a vendoredmarked.min.jsandreport-shell.html). The shell also runs a small idempotentnormalizeReportMdpre-pass beforemarked.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.htmllinksdesign/tokens.cssanddesign/markdown.cssfrom the bundledcontent/design/drop — the same sheets the Study leaves inline andios/TruthSeeker/Design/Tokens.swiftmirrors, so the native chrome and the web report read as one system. Those sheets are vendored from Riff'stranscript/(Wayfinder for the review pane; seecontent/design/README.mdfor which file came from where and why), byte-for-byte except two colour overrides:--surface: #18160fand--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 nocolorof their own and--mark-rgbis 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.htmllinks the shareddesign/thread-layer.{css,js}and configures it (DOM-band painter + in-page composer; no web→native ask channel; container#report; awhenReadygate on themarked+KaTeX render, since#reportis empty at parse time;askExtraadds the report markdown ascontext). Reports reuse the verbatim endpoints (/api/verbatim-feedbackask /api/verbatim-threads) with coordsbook="reports", chapter=<topic-id>, leaf="report"(Phase 3 generalized them).ReportWebViewinjects the token + absolute endpoints + coords aswindow.__REPORT_*globals; the token is read at load from a bundledResources/Study/report-token.txtwritten bysync-to-bundle.shfrom 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 reporttopic; seeded ondidFinishviawindow.__THREADS_CACHE__+__vbApplyThreadCache, refreshed overvbThreads, with the same#vb-cachebannerfailure UI); asking needs the Mac.- Reports detail is full-bleed (parity with the verbatim leaf). The pushed
ReportDetailViewhides 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.htmlposts the same{visible:Bool}vbChromescroll-direction signal the verbatim shell does;ReportWebView's Coordinator routes it to a sharedStudyChromeModel(default visible; garbage body never hides). Because theLatest | Reportssegmented control is a sibling of the ReportsNavigationStack(not inside it), the stack'spathis lifted toPapersTabView, 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 groupgroup.mark.truthseeker(widget data sharing, P2). Deployment target iOS 17, automatic signing, team6C63UU27YB(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-publishsecret guard; the.exampleships a placeholder). - Standalone repo (extracted from
~/agents2026-05-30). This is its own git repo at~/truth-seeker.install.shsymlinks the two deploy skills (skills/truth-seeker-{ota,update}) into~/.claude/skills/;verify.shis the migration ship gate (it runsmake 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/tradedashboard — not a leak).
Phasing
- P1 (MVP) — app scaffold (mirror Riff), bottom tab bar, News + Study
WKWebViews, native Papers tab,GET /api/papersin 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 thePOST /api/register-pushendpoint onwebpage-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(puretab(for:)payload→tab map, unit-tested),TruthSeeker.entitlements(aps-environment = development), the inlineentitlements:block inproject.yml, andTruthSeekerApp/ContentViewwiring (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 toenv="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.logfor the APNs status/reason.- One-time provisioning gate.
mark.truthseekerhad no Push capability, so the first signed/OTA build needs a one-time Xcode-GUI enable: openTruthSeeker.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/NotificationRouterTestscovers the push payload→tab routing (NotificationRouter.tab(for:)) and guards thatAppTabandContentView.Tabkeep 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 theUNCHANGED/empty/blank sentinels (byte-identical no-op), markdown+SOURCES:split (including a mid-lineSOURCES:— 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 withmarkdowninstalled). It coversrender.py's output contract, math layout, anchor relocation — andtest_build_artifacts_current.py, which byte-compares every trackedbuild/*.htmlagainst a fresh render. Run it after any change undercontent/, becausetest.shcannot 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.shremains the migration/self-containment gate (make simbuild).- Reading-position precision (UI screenshot gate) —
TruthSeekerUITests(separate scheme, run on demand, NOT intest.sh):StudyLeafChromeUITests.testReadingPositionIsRememberedandReportAskUITests.testReportReadingPositionIsRememberedscroll 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 byswift ios/make_appicon.swift): an ivory Ψ on a warm-charcoal gradient, set in Noto Serif Display at wght 400, vendored underios/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/AppIconTestsgates 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), theicon.jsoncontract (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 inTruthSeekerTests/ThreadLayerTestsagainstcontent/design/thread-layer.{css,js}:hasDragToSelectGesturegreps the gesture (selectstart,wordSnap, thevb-sel-banddrag band,touchstart,showPopover) and assertsRange.expand/.expand('word')is absent (the WebKituser-select:nonegotcha guard);paintsDOMBandsNotCustomHighlights/usesInPageComposerassert 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.composerReadsPendingNotSelectionscope-assertssubmitQuestionreads the frozenpendingrange, notgetSelection. The Pythoncontent/feller-vol-2/tests/test_render_modes.pymirrors these against therender.pyoutput. The real-surface proof is the on-demand UI test (TruthSeekerUITests, NOT intest.sh):AskClaudeUITests.testLongPressDragRaisesInPageComposerdrivespress(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);ReportAskUITestsgives the report surface the same treatment. - The ported review pane, end to end (UI gate) —
ThreadKitUITests.testComposeMorphFollowUpResolveReopen(TruthSeekerUITests, NOT intest.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 inThreadLayerTestscannot 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), andTS_COMPOSER_PROBE=1must be set. That flag injectsStudyLeafView.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_HOSTand 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-serviceslink (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-otare-signs. - webpage-server MIME/Range dependency: the manifest must be served as
text/xmland the.ipawithRange/206 — the generic/<slug>-ota/...route (seewebpage-server/README.md). Kick the server after a handler change. ExportOptions-adhoc.plist(method=development) lives inios/alongside the TestFlightExportOptions.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-updatemirrors/riff-update; it's the cabled fallback when the wireless tunnel is flaky (USB reliable).- TestFlight via
altool+ExportOptions.plistis P2 (/riff-publishpattern), 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;.exampleis the committed template. Same split Riff uses forMAC_MINI_HOST/RIFF_SHARED_SECRET.- No new secrets in git. The repo
.gitignorealready 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). Trustxcodebuild, not editor squiggles. - codesign over SSH needs in-session keychain unlock
(
reference_riff_codesign_keychain_ssh) —security unlock-keychain -pinline in the same shell. - Mac
pip3is broken (libexpat) (reference_mac_pip_libexpat_broken). If anyinstall.shhere 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/papersendpoint inwebpage-server. - Toolchain: xcodegen, Xcode, a development-signed Apple account
(team
6C63UU27YB), a USB cable for reliable device install.render.pyneeds Python 3 + themarkdownpackage (already installed; do not pip).