Skip to content

Rewrite Cue as a native Expo app over a shared core - #24

Draft
arun279 wants to merge 523 commits into
mainfrom
feat/expo-native
Draft

arun279 wants to merge 523 commits into
mainfrom
feat/expo-native

Conversation

@arun279

@arun279 arun279 commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Cue becomes a native app. iOS and Android ship as one React Native app built with Expo,
with platform navigation, gestures, haptics and notifications, over a shared TypeScript core.
The web app is retired with the shells; a PWA, if kept, is Expo's web export of the same screens. When this merges the repository reads as an
Expo project: the Capacitor shells, the web UI and their plumbing are gone, packages/core holds the domain
and the Trakt data layer once, and packages/native is the app.

Status

Every user-visible change lands with its screenshots and recordings attached to its merge
comment below, and each screen ships to TestFlight and Firebase App Distribution from
release/expo as it lands, so the app is tested on a phone, not from clips.

Landed, each part verified before merge (root pnpm check, native jest on both platforms,
Playwright, the fake Trakt lane twice, the native launch flow on a signed simulator build):

  • Workspace split: @cue/core extracted with zero cache busts for shipping users; the core
    cannot import an app, and it typechecks and tests itself without one.
  • Native app scaffold: expo-router with native tabs, a stack per tab, the account modal,
    the platform adapters, a local Swift and Kotlin haptics module, device-code OAuth with
    PKCE, migration of the token and the pending write queue from the previous shell.
  • Sync contract: typed read failures, one retry ladder per failure kind honouring
    Retry-After, an observable rate-limit pause, a retrying state, the mark control's three
    states, one episode mark costing three requests instead of nine on the seeded account.
  • Deterministic checks: Biome warnings as errors, cognitive complexity gated at 15 with a
    downward-only suppression baseline (core at zero), zero type suppressions, zero clones,
    size ceilings that can only be lowered plus a per-PR size delta gate, the Play download
    estimate at Play's reference density, the asset allowlist, CodeQL, and a footprint comment
    recreated on every push with line, bundle, complexity and attribution deltas.
  • Design foundation: tokens gated against the web stylesheet, type roles anchored to Apple's
    and Material's scales, snackbar hosts, the accessibility id vocabulary gated both ways.
  • Maestro launch flow on a signed simulator build in CI, the fake Trakt's seed states and
    fault endpoints, a deterministic native build with its framework linkage asserted.
  • Core cleanup: the browser crypto polyfill gone, the freshness poll mounted on native, one
    view-status shape, one write-outstanding state, the write lock keyed per show.
  • Up Next: swipe to mark with haptics, pull to refresh, the strip in flow, the marquee,
    "On the way", the History footer, the lapsed drawer, every state, light, dark and the
    largest text size; media in its merge comment.
  • The native release lane: TestFlight and Firebase from release/expo, version 2.0.0.
  • Capacitor removal (in progress on its own branch).
  • The core's read layer as query factories and selectors instead of per-screen hooks.
  • The remaining screens: show detail and the episode sheet, Library, Calendar, Search,
    movie detail, History, Profile, Settings, Onboarding.
  • Native conveniences that are not one screen's: the tab shell, predictive back, Dynamic
    Type passes; per-episode local notifications for the shows being watched.
  • The web UI's removal with the PWA served from the Expo web export.

How to read the footprint comment

The merge base has no packages/ tree, so the bundle and complexity base columns read n/a on
this PR. Merge-scoped runs (base pinned to the previous tip) are posted in the comments below
as each part lands.

The workspace split

packages/web is the Vite app. packages/core is @cue/core: the domain, the
Trakt data layer, the durable write queue, the runtime and composition root, the
auth store, the hooks, the stores, the preferences and the URL parsers, plus the
ports each app fills (key-value storage, preference storage, token storage,
haptics, reminders, connectivity, app visibility, the OAuth redirect handoff).
It is TypeScript source with no build step, reached through one wildcard subpath
(@cue/core/domain/up-next), and it contains no .tsx and no .css.

The rules are enforced rather than agreed. dependency-cruiser grew from 8 rules
to 16 over packages, every one of them exercised against a planted violation:
the core cannot import an app, the domain cannot reach the data layer, a port
cannot grow an implementation, the web app owns the DOM and the native app owns
Expo, and neither app may import the other. pnpm check:core-portable asserts
the core's file types out of git rather than out of a config, and biome bans
eight browser and node globals inside it.

Behaviour-preserving for the web app, proven

The persisted query cache is keyed on a buster that used to be a Vite define
hashing three source trees by path, so the workspace move alone would have
dropped every shipping user's cache, and the extraction would have dropped it
again. scripts/write-buster.mjs replaces it with a path-independent shape
witness: it hashes the trees that define every persisted shape, in source order,
with each import statement's specifier collapsed to the digest of the module it
resolves to, so a file that moves or an import that is respelled produces the
same witness, while a field added to a type does not. pnpm buster:check fails
the build while the committed witness and the computed one disagree.

The buster literal is seeded with the value main's own build already produces, so
this branch ships zero cache busts:

Build Shipped PERSIST_BUSTER
main, vite build --mode test d0c3d97c58b8
this branch, vite build --mode test d0c3d97c58b8

Both were read out of the built dist/assets/index-*.js, and main's value also
recomputes from source with the old function's own logic. buster:check at the
tip reads shape b76d12c06a3e, buster d0c3d97c58b8: the two differ, which is
what a mechanism change with no shape change looks like.

packages/native

An Expo app on expo-router, running the shared core through its own composition
root. What is in it:

  • The nine platform adapters that fill the core's ports: an expo-sqlite
    key-value store, expo-secure-store for the token, a synchronous preference
    storage over the same database in its own pref. namespace, expo-network
    for connectivity, AppState for visibility, expo-notifications for the
    reminders planner (inexact triggers, since the exact-alarm permission is
    blocked), and expo-application for the version.
  • A local Expo module, 109 lines of Swift and 127 of Kotlin, implementing the
    seven-verb haptics port with each platform's own system feedback, plus the
    read side of the previous shell's stored preferences.
  • Device-code OAuth with a real S256 PKCE pair, built on expo-crypto rather
    than a hand-rolled encoder, because a phone has no page to redirect.
  • Migration from the previous shell: the token moves to the Keychain and the
    pending write queue is read once and removed, so an upgrade in place lands
    signed in with its undelivered writes intact and can never replay them twice.
  • Four native tabs, a stack per tab, shared detail routes inside every stack, and
    the account area as a full-screen modal.
  • Three config plugins over app.config.ts, which blocks 25 permissions the
    dependency tree would otherwise ask for and adds none of its own; the release
    APK asks for 7.

ios/ and android/ are generated rather than committed, so both projects are
rebuilt from app.config.ts on every build.

What is deliberately not here

The screens. Everything under packages/native/src/screens is a placeholder
that renders real data from the shared hooks with no styling: rows of text, no
artwork, no theme. They land next, on this same branch, as it is worked. The
scaffolding is complete: routing, the composition root, the ports, auth,
migration and the gates are all in place and exercised on a device, so the
screens are the remaining work rather than the risky part.

Also not here, and tracked as the next steps after the screens: the art pipeline
(the shared hook still hands Element to its consumers), the theme port, a
snackbar host on the native side, and the Maestro launch flow.

Verification

  • pnpm check at the root: biome, dprint, cspell, three tsc programs,
    dependency-cruiser over 507 modules and 1,741 dependencies, knip, jscpd,
    buster:check, verify-bundle, and vitest with coverage: 106 files, 875
    tests. Then jest-expo: 8 suites, 70 tests across the ios and android projects.
  • Playwright from packages/web: chromium 239 passed, mobile-chromium 40 passed,
    and the mock-mode equivalence lane 16 passed, which drives 15 scripted flows
    against the local fake Trakt with no interception and fails if any of the six
    write paths stops being sent.
  • Android, both lines: the web shell's assembleDebug plus verify-apk.sh
    (9.9.9 (42), 5 permissions, backup off, every storage domain excluded from both
    channels), and the native app's assembleRelease plus the same check on the
    expo line (7 permissions, same privacy assertions).
  • iOS: the native app built with xcodebuild and run on an iPhone 17 Pro
    simulator against the local fake Trakt. It signed in through the device-code
    grant (the request log carries POST /oauth/device/code with an S256
    challenge, then POST /oauth/device/token with the matching 43-character
    verifier) and painted Up Next from the shared useUpNext hook with the four
    native tabs beneath it.

The numbers

507 files changed, 13,502 insertions, 1,946 deletions. Of those, 338 are renames
(153 byte-identical, 185 carrying an edit), 133 files are new, 23 are modified in
place and 12 are deleted: most of the diff is the move and the import rewrite,
not new code.

The web app's 24,211 product lines become 11,212 in @cue/core and 13,548 in
packages/web, so 45 percent of the product code now runs on both targets, at a
cost of about 550 lines for the ports and seams. packages/native is 2,621
tracked lines: 1,016 of composition root and adapters, 378 of route tree, 323 of
local module (236 of them Swift and Kotlin), 441 of tests and 223 of config
plugins.

Coverage-Rationale: measured against main, 474 lines of packages/core have no test because the shared core predates the changed-lines gate, which every child pull request into feat/expo-native has passed since it landed; covering or deleting those lines is a plan item before this pull request merges, and the count is reported here so it cannot be forgotten.

@arun279
arun279 force-pushed the feat/expo-native branch 3 times, most recently from 25e77e7 to 5b9f030 Compare August 24, 2026 23:14
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@arun279 arun279 changed the title Split the workspace behind a shared core, and add the native Expo app Rewrite Cue as a native Expo app over a shared core Sep 6, 2026
@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: engine hardening for the native scaffold.

The legacy token is adopted only when the previous shell actually stored one; the account modal gained its Done item with a router-level test that the modal closes; PlistBuddy failures in the iOS privacy script surface through the script's own exit path and the aps-environment key follows the build configuration; the expo-crypto digest shim has its own test. Verified with root pnpm check, native jest on both platforms, and a full CI run.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: the deterministic checks.

Biome runs with warnings as errors and a cognitive complexity gate at 15, every suppression carrying a written reason. size-limit budgets cover the web initial load, all web JS and CSS, and both Hermes bundles. The release lanes assert the APK and the exported IPA against download ceilings out of the built artifacts. CodeQL scans source and workflows and gates the mobile release. The footprint job measures base and head in one run and recreates its comment on every push. Every gate was shown to fail under a planted violation before it was kept.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: the sync contract.

Read failures are typed rather than collapsed to one flag. The read pool owns rate limits, honouring Retry-After and publishing one observable pause that every screen reads; the query layer owns transport and 5xx retries, so each failure kind has exactly one bounded ladder. The strip's states, in precedence: offline, rate limited with a live countdown, retrying, unreachable with the cause named and a Retry action, pending. A mark is green for the 5 second undo window, the same number the snackbar uses, then advancing, checked and disabled with a quiet dot while the write is queued, until Trakt confirms and the next episode takes the row. The fake Trakt gained fault modes (429 with Retry-After, 5xx, delay, hold, drop) and a reset control, and the mock lane drives both defects end to end.

Merge-scoped footprint against the previous tip: product +1173, tests +1789, web initial load +1.2 kB, Hermes bundles +47.8 kB iOS and +32.2 kB Android, complexity profile unchanged.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: the native design foundation.

packages/native/src/ui holds the tokens (dynamic colour pairs on iOS, scheme-resolved on Android, gated token for token against the web stylesheet's two theme blocks), eleven type roles per platform bound to Dynamic Type styles and Material 3 tokens with a test anchored to Apple's Large sizes and the Material scale, the snackbar hosts for the root, the sheet and the account modal, the app-idle marker, the accessibility id vocabulary, and the first primitives (row, check control, section header, empty state, sync strip). The splash stays up until the stores and fonts settle. Screen readers get the longer snackbar window.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Two fixed behaviours, recorded on the web app running against the repository's fake Trakt (scripts/mock-trakt) with faults injected through its /__fault control plane. iPhone class viewport, Chromium. The native screens render these from the same contract, packages/core/src/sync-contract.ts, so the strings below are what both targets say.

Marking an episode

cue-marking-an-episode.mp4

First take: the check on the queue row is tapped and turns green, and the snackbar offers Undo ("Midnight Cartography S2 E3 marked"). The row advances to S2 E4 in the same frame, on the clock rather than on a round trip. The write is held by the fake for 7 seconds, so the undo window (UNDO_WINDOW_MS, 5s) closes first: the check becomes the checked and disabled state with the quiet dot, labelled "Watched. Not synced yet." The write lands, the dot clears and the row settles on the confirmed next episode, "S2 E4 · The Undertow". Second take: the same tap, then Undo inside the window, and the row goes back to "S2 E3 · Half Measures".

green check, snackbar offers Undo

checked and disabled with the quiet dot, write still queued

dot cleared, confirmed next episode in place

The sync strip under faults

cue-sync-strip-under-faults.mp4

Healthy is silence: no strip. One 429 with Retry-After: 3 on the library read gives "Trakt is limiting requests. Retrying in 3s.", counting down, with no Retry button because the retry is automatic, and it retracts on its own when the window reopens. Two 503s give "Couldn't refresh from Trakt. Retrying…", which clears when the third attempt succeeds. A 503 that stays on spends the read ladder, and the strip settles on the cause with a manual retry: "Trakt is having trouble. Showing your cached data." plus Retry. Tapping Retry with the fault cleared takes the strip away. The queue stays on screen the whole time; a failed refresh is a note over cached content, never the screen's error state.

rate limited, counting down, no Retry

couldn't refresh, still retrying

ladder spent, cause named, Retry offered

Two things in the recordings are worth a look before the native screens copy them:

  • The unreachable line does not fit. .sync-strip__text is a single line ellipsis clamp and the Retry button takes the rest of the row, so at this width the user reads "Trakt is having trouble. Showing your cache..." and the half that promises the cached data is the half that gets cut.
  • In clip 2, The Quiet Frontier is marked at its last aired episode, S2 E10, and the projected row reads "S2 E11 · 0 left" while the confirming read is blocked. Season 2 has ten episodes, so that coordinate does not exist. It resolves the moment a read lands and the show leaves the queue, but until then the row names an episode that is not there.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: size and quality budgets anchored to published norms.

A committed quality baseline ratchets the count of cognitive-complexity suppressions (21 today) and the worst measured complexity (71 today) downward only, with zero suppressions allowed under the native app, and the check refuses a baseline that outruns the measurement. TypeScript suppressions are rejected across every package's source, tests, app routes and modules. Android release builds now ship with R8 and resource shrinking; CI builds the App Bundle, estimates the Play download for an arm64 xxhdpi device with bundletool (17.4 MB today, gated at 20 MB), gates the bundle itself, and derives the universal APK for the permission gate. The old 200 MB universal-APK ceiling is gone. The footprint comment reports comment density per package.

Two gates written for the deleted web app (Vite size budgets, Lighthouse against vite preview) were dropped before merge, since the web UI is retired with the shells.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Maestro launch harness and the fake Trakt's missing controls.

The fake Trakt gains six selectable seed states behind its reset control and the fault modes the parity flows need (a one-shot 401, a refused refresh, a 429 mid fan-out, a held and a dropped write, a failing history page, a pre-seeded op-log), each proven observable from a client. A Maestro launch flow asserts the onboarding screen, the connect button, the device code and the arrival on Up Next, with a test that every id a flow names exists in the app's id vocabulary. CI's iOS job now uploads the simulator app and a new native-e2e job on macOS installs it, boots a simulator, starts the fake and runs the flow. Getting that job green found and fixed a real launch defect: an unsigned simulator binary carries no entitlements, so every Keychain call failed and the auth store never left loading; the build is signed for the simulator now, the packaging step asserts the entitlements section is present, a token store that cannot answer drops to onboarding with a message instead of hanging, and the boot gates render a one-point frame so a hierarchy dump names whichever gate is holding.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

feat/sync-follow-up has landed on this branch. In plain words, what changed:

One mark no longer refetches the whole library. Marking an episode used to invalidate the Up
Next aggregate, which re-read every show's progress, the hidden set, the watchlist and a history
page. Measured against the fake Trakt on the seeded 8-show account, driving the built app in a
real browser and counting the fake's own journal: one mark cost 9 requests (1 POST + 8 GET) and
now costs 3 (1 POST + 2 GET)
- the write, the marked show's own progress snapshot, and the
history page the home screen's "Previously" section renders from. The eight GETs it replaces were the whole
library aggregate; extrapolating that arithmetic, one mark on a 60-show backlog would have cost
about 65 requests and now costs 3. Cold start (19), Sync now (1), a foreground return
(1) and the idle poll (1) are unchanged, and the mock lane now holds all five flows to a ceiling
counted by the fake rather than by the app.

A read's Retry-After is honored. Trakt's GET window is five minutes and it can name a wait
of minutes; Cue clamped every wait to 30 seconds and then told the reader a deadline that was not
Trakt's. Reads now pause for as long as Trakt asks, up to that window, plus a small margin. The
write queue keeps its 30 second ceiling, because the POST period is one second and a longer wait
there is anomalous.

A mark never names an episode that has not aired. The optimistic advance projects the next
coordinate only inside the season the snapshot's own aired frontier ends in and only below it. A
client cannot infer where a season boundary falls or what follows the last aired episode, so past
either the row advances carrying no coordinate at all, still checked and still locked, until
Trakt names the real next one. The queue's own exclusions apply to that row exactly as they do to
a projected one, so finishing a show still ends on "you're all caught up".

Requests are bounded, and the fake can let go. Every Trakt request now aborts after 15
seconds and surfaces as a transient failure instead of hanging forever, and the fake Trakt's
hold fault - built for this and never used - can now be released, so the test can watch a held
request finish rather than only watch it hang.

Cloudflare's answers stop reading as your connection. A 429 or 403 issued in front of the API
carries no CORS headers, so in a browser it reaches the app as a bare fetch rejection and drew
"Can't reach Trakt" over a working connection. On the web, a rejection for Trakt's own origin is
now a server failure; native is unchanged, and a request the app itself timed out stays a network
failure, because that one really is the connection.

The exact-alarm claim is corrected, not just deleted. expo-notifications has not declared
SCHEDULE_EXACT_ALARM since before SDK 57, so the Expo config no longer blocks a permission
nothing merges. The Capacitor line, which is what ships today, genuinely does strip it in its own
manifest, its privacy-claims test pins that, and the README still says so.

The sync strip wraps. At phone width "Can't reach Trakt. Showing your cached data." was cut
after "Can't reach Trakt." by an ellipsis, losing the half that says your data is fine. The strip
grows and wraps instead, with Retry still on the row.

@arun279

arun279 commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner Author

Merged feat/size-gates. What is gated now is the size of what a person downloads, rather than the size of the files CI happens to produce.

Android. The release bundle is measured with bundletool at the device configuration Play Console itself reports against, XXXHDPI ARMv8, which means screen density 640 rather than the 480 that was there before. A second run measures every SDK, ABI, density and language combination and takes the largest. Both are held under 20 MB. On the merged tip CI reports 16,904,619 bytes at Play's reference configuration and 17,657,277 bytes across every configuration. The old ceiling on the App Bundle's own bytes is gone: every Play limit is stated on the compressed download, so an upload ceiling had no anchor, and the all-dimensions run does the job it was given properly.

The font nobody imported. Expo Router's Android tab icons pulled in @expo-google-fonts/material-symbols through expo-symbols, and 962,968 bytes of it were in every Android build. The tabs now use generated Android drawables, and the font is excluded through Expo's own EXPO_ROUTER_DISABLE_NATIVE_TABS_MD, set once in the Metro config so every bundle the project builds agrees, including the one Gradle embeds. That also drops the 111,392 byte glyph table that came with it. Nothing was deleted from node_modules. Against the merge base the Android bytecode is 93.5 kB smaller and the Play download 495.6 kB smaller.

Budgets that can only go down. Every .size-limit.json entry now records the measurement it was cut from, the date, and a reduction target 20 percent below it, which is web.dev's method for choosing an interim budget. A ratchet reads the committed history of the file and fails any raise, so a limit cannot be edited upward in the pull request that needs it raised.

entry ceiling measured reduction target
web initial load 170 kB 138,056 110,445
web all JavaScript and CSS 285 kB 229,484 183,587
expo iOS bundle 4450 kB 3,609,323 2,887,458
expo Android bundle 4830 kB 3,803,699 3,042,959

The Play download budget is a decision rather than an anchor: 20 MB sits between Expo's minimal-app floor and the comparables, and Google's published curve prices the gap at roughly a point of install conversion per 6 MB. It re-anchors against the Play Console peer group once there is a production release.

Growth needs a reason. The footprint job compares the merge base and the head for both Hermes bundles and the Play estimate, and fails above 64,000 bytes of growth. The only way past it is a Binary-Size: <rationale> line in the pull request body, which is Chromium's shape for the same problem. A merge base without these packages reports n/a, which is this pull request's own case against main.

Assets are enumerated. Every asset in the built export is listed with its bytes in .native-assets.json, read out of the export's own manifest rather than a source directory. An arrival nobody declared and a disappearance both fail. 38 files, 1,486,280 bytes today.

Attribution. The footprint comment carries an Expo Atlas table of the largest contributing packages per platform, and the Atlas file uploads as an artifact, so a failing delta names its cause instead of leaving you to guess.

pnpm check also runs the native size check now, so a push sees both Hermes bundles and the asset inventory before CI does.

Two things worth knowing from the review before the merge.

iOS has no download gate yet. Xcode documents its thinning export option as applying to non App Store exports only, so the App Thinning Size Report the branch tried to assert on is never produced by the release lane's App Store export, and every iOS build would have failed on the missing file. That assertion is removed and the IPA smoke ceiling stays. A real number needs either an ad hoc provisioning profile in CI for a second export of the same archive, or App Store Connect's App File Sizes after the first upload.

The generated Android tab icons were invisible. Material Symbols path data is authored in the SVG viewBox 0 -960 960 960, every coordinate negative, and a VectorDrawable viewport starts at the origin, so all four icons were painted above the canvas. They are wrapped in a translation group now. Nothing in the suite would have caught it; an Android visual lane would.

@arun279

arun279 commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner Author

The core's cognitive-complexity debt is retired. Nine functions carried a suppression; all nine are
split by responsibility and nothing under packages/core suppresses the rule any more.

scripts/quality-budget.json reads 12 suppressions and a worst function of 41, down from 21 and
71 when this started (20 and 71 on the branch, since the Up Next fix had already retired one). The
twelve that remain are all in packages/web/src/ui, which item 20 deletes. The footprint job on this
PR reports functions over 15 at 20 to 12, worst 71 to 41, mean 4.98 to 4.60.

What was split into what:

  • useMarkSeason's toggleEpisode (71) is a two-line dispatch over unmarkEpisode and
    markEpisode; the unmark side splits again into submitQueuedEpisodeUnmark, which enqueues the
    inverse of a still-queued mark, and submitLiveEpisodeUnmark plus submitEpisodePlayRemoval,
    which resolve the real plays and settle a rewatch or a single play. Worst resulting function 13.
  • useMarkSeason's unmarkSeason (17) separates resolveSeasonUnmark from submitSeasonUnmark.
    Worst 10.
  • The runtime's reconcile (37) becomes createReconcile over reconcileHidden, reconcileMovie,
    reconcileAdditiveEpisode, reconcileAdditiveSeason and reconcileMark, each with its own
    authoritative Trakt read. Worst 5.
  • createAuthStore's pollLoop (32) keeps pacing; pollAttempt owns attempt ownership and
    applyDevicePoll owns what an outcome does to the store. Worst 11.
  • groupUpNext (24) separates classifyUpNextShow from toUpNextItem, over the queue rules this
    branch's Up Next fix established. Worst 12.
  • assembleEpisodeDetail (20) flattens progress once, then picks ordered neighbours. Worst 8.
  • The authorized fetch (19) separates the idempotent read retry from the write deferral. Worst 6.
  • assembleLibrary (18) and assembleMovieLibrary (18) build watched and watchlist-only entries
    independently, then merge. Worst 6 and 5.

Behaviour is unchanged: every test that covered these paths passed before each split and passes now.
Reconcile and device-code polling had no direct coverage and gained behavioural tests written against
the old code first; reconcile now also proves the other half, that an operation an authoritative read
cannot find stays queued and is re-dispatched.

Comments: 415 comment lines removed, 71 added, net -344 across the eight files, taking core
density from 28.04 to 25.32 percent. What went was restatement, provenance and workaround narration.
What stayed, or came back after review, is the facts the code cannot state: the cross-account replay
rule and clear order at sign-out, the Trakt token lifetime the refresh throttle is sized against and
its persist-before-publish order, the reconcile anchor's contract with startup reconcile, the
Specials paging order, why the rewatch paths carry no Undo, and the two TODO markers recording open
defects that the first pass had deleted.

Item 22's other target, core comment density at or under 14 percent, is not met and stays open: this
pass touched only the files it split.

Verified before merge: root pnpm check exit 0 on the merge itself, the mock lane four times at 20
of 20, Playwright chromium twice at 240 passed, and the budget ratchet mutation-tested in both
directions (a raise and an under-record both fail). All fourteen checks are green on this tip.

native-e2e took three attempts to get there, and none of the failures were this work: it had been
failing on every branch since some time between 14:52 and 15:45 UTC today, and feat/expo-native at
e25554b, with none of this work on it, failed the same way. Two symptoms, an app that exits in dyld
before its first frame and a cold sync that outlasts the assertion's window, both pointed at the
runner rather than at the app, and the fake Trakt's own log for the second one shows the device token
granted and the whole Up Next read sequence served while the assertion was still waiting. It passes
now on an unchanged tip. Worth knowing it can do this.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Merged: a deterministic simulator build.

The native end-to-end job had gone red on every branch: React Native decides at pod install, through a live request to Maven, whether to use its prebuilt core, and when that request fails it silently builds from source without embedding React.framework while the Expo module frameworks still link it, so the app died in the dynamic linker at launch. Expo's precompiled modules are now off so the two halves are always built the same way, and the packaging step walks every embedded framework's load commands and refuses an artifact whose transitive frameworks are not in the bundle.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Merged: Up Next on the native app.

The queue rows are built on the foundation primitives with the three mark states (unwatched, just marked for the undo window, advancing), swipe to mark at the 96 pt threshold with the threshold and success haptics, pull to refresh that ends immediately during a rate-limit pause, the sync strip in flow, the marquee, "On the way" capped at three rows, the History footer, the lapsed drawer, and the empty, loading, degraded and offline states, in light and dark and at the largest accessibility text size. Two build defects were found and fixed on the way (an Expo UI release that does not compile against the pinned SDK, now pinned level with a test; a snack message built from a DOM element inside the shared store, which crashes a native text tree), and five visual defects were found by operating the screen on the simulator and fixed with tests. The placeholder onboarding screen is now readable on both appearances. Clips and stills below.

clip-1-mark-undo-swipe.mp4
clip-2-refresh-and-strip.mp4

light-01-onboarding

dark-01-onboarding

light-03-up-next

dark-03-up-next

light-04-marked

dark-04-marked

light-06-lapsed-drawer

light-08-strip-unreachable

dark-09-strip-rate-limited

ax5-03-up-next

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged feat/core-cleanup: a cleanup pass over the shared core and the native wiring. In plain words:

The native app syncs on its own now. useActivitiesPoll was written for the AppVisibility port and mounted only by the web shell, so on a phone nothing refreshed in the background: a mark made on another device never arrived until you pulled to refresh. It is mounted at the native root, with a composition test that fails if the mount is removed.

The core no longer needs browser globals. A 40-line module used to install btoa, crypto.getRandomValues, crypto.randomUUID and crypto.subtle.digest onto globalThis at boot, because the core was written against Web Crypto and Hermes has none of it. That is gone. Identity and digest are a three-method port each platform implements (expo-crypto on device, Web Crypto in the browser), PKCE encodes base64url itself instead of borrowing btoa, and the linter now refuses crypto, btoa, atob and URLSearchParams under the core, so the next one fails a check rather than the device. The polyfill file went from 40 lines to 8, the base64-js dependency went away, and so did the only as unknown as in the tree.

The accessibility ids are one vocabulary again. TEST_IDS claimed to be the only place ids were spelled while 108 of its 138 entries had no reference and 15 screens spelled 58 ids inline. It is 89 entries now, every one of them used, and the check runs in three directions: every id a flow selects must be declared, no screen may spell a testID literal, and no declared id may be dead. Inline literals: 15 files before, 0 now.

Render counts are gated. Three surfaces (the queue row, the check control, the Up Next screen) are measured with Reassure on every CI run and pinned at 2 renders with zero allowed deviation, so an accidental extra state update fails the build instead of arriving as a stutter. Durations are printed but do not gate: one machine's timing is not a threshold. render-performance is a required check for the release gate.

The tests that hold the core to its coverage floors live in the core. 26 test files and their support moved from packages/web/test to packages/core/test, including the only tests for the Trakt client, transport, endpoints and repositories. Deleting the web app will no longer take the core's gate with it. Fifteen files that test a web screen or a web platform adapter stayed with the web app, so the core package builds and tests itself without it.

Duplicated concepts collapsed. One DAY_MS, one local-day-key formatter, one declaration per storage key, one QueryStatus shape across the read hooks instead of eleven hand-rolled subsets, and one lock map for "a write is outstanding" instead of six containers. Four of those are held in place by a check that names the single file each belongs to, and each was proved by reverting the change and watching the check fail.

Also: a browser-hidden response is its own failure kind rather than a 503 Trakt never sent; pagination without headers keeps going until a short page instead of stopping after page one; concurrent identical reads share one request; write retry pacing keeps a 100 ms margin under Trakt's limit; and 420, 423 and 426 are named permanent account failures.

Numbers, from the footprint. packages/*/src went from 29,582 lines to 28,972: 610 lines deleted, 513 added and 1,123 removed. Of that, 391 are code lines and 164 are comment lines, so core comment density fell from 25.64 to 25.16 percent and native from 21.04 to 20.49. The seven deleted screens moved into the Expo route files, so counting packages/native/app too the net is 127 lines smaller. Nine files are gone: two single-caller hook layers and seven native screen modules the routes now own. Across everything, 1,804 lines added and 1,315 removed over 158 files.

Nothing regressed: worst cognitive complexity 41 with 11 functions over 15, unchanged. Web initial load 139.1 kB brotli against a 170 kB limit, all JavaScript and CSS 230.9 kB against 285 kB, iOS bundle 5.12 MB against 5250 kB, Android 5.15 MB against 5400 kB, Play download 17.30 MB against 20 MB. 1,001 vitest tests and 288 jest tests pass and every coverage floor holds.

Still owed: a startup timing gate. The simulator was unreachable where this was built, so there is no launch measurement and therefore no defensible ceiling to ratchet. That is the last item from the performance work.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the native release lane, and a settle wait in the launch flow.

The mobile release workflow gains a line choice, expo by default and capacitor for rollback. The Expo line prebuilds both platforms in the workflow, then fastlane builds the workspace with the existing signing, asserts the built IPA's version and build number against the values the config wrote, checks TestFlight for a strictly greater build number within the marketing version, and uploads to the internal group; on Android it builds the release App Bundle with R8 and resource shrinking, derives the universal APK with bundletool for the permission gate and for Firebase App Distribution, and checks the latest Firebase release the same way. The Expo line starts at version 2.0.0 so it never collides with the Capacitor 1.x builds. The launch flow now waits explicitly for the device-code screen after the connect tap instead of relying on the driver's automatic settle, which raced the screen transition.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the push entitlement removed, 235 lines deleted and 20 added.

expo-notifications wrote an APNs push entitlement into the iOS build, and the distribution profile has no push capability, so the first iOS archive failed. The module is out of the native app until per-episode notifications are built (local notifications need no push entitlement), along with its plugin, its permission suppressions and the entitlement check in the privacy script.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Capacitor shells removed, 4,300 lines deleted and 147 added (net -4,153). Bundles: web initial load -3.3 kB (135.8 kB), web all js and css -5.2 kB (225.6 kB), Expo iOS bundle -43 B (5.03 MB), Expo Android bundle -23 B (5.06 MB), Play download -87 B (16.85 MB). Functions over cognitive complexity 15 unchanged at 11, worst 41, all in the web UI.

The Capacitor iOS and Android shells, their five packages, the platform seams, the ios and android CI jobs and the Capacitor release lanes are gone; the Expo app is the only native codebase. The reader that migrates the old app's data on first launch stays until its sunset.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Android launch crash fixed, 118 lines added and 3 deleted. Bundles: web unchanged, Expo iOS bundle -99 B, Expo Android bundle +8 B, Play download -11 B.

Build 1201 died at boot with "EXPO_PUBLIC_TRAKT_CLIENT_ID is not set". The release workflow set the variable on the prebuild step only, and Metro inlines it while bundling inside the Gradle and Xcode builds, which ran without it; both platforms shipped an empty client id. The Fastlane steps and the CI Android bundle now receive it, a workflow test requires it on every bundle-producing step, and a new android-e2e job installs the signed CI build on an API 35 emulator and fails on any fatal exception at launch.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the web app removed, 30,797 lines deleted and 891 added (net -29,906). Bundles: Expo iOS bundle -1.8 kB (5.03 MB), Expo Android bundle -1.9 kB (5.06 MB), Play download -100 B (16.85 MB); the web size ceilings left with the web build. Functions over cognitive complexity 15: 0 (was 11, all in the web UI); worst function now 15.

The DOM application, its unit tests, its Playwright suite and its Vite tooling are gone; the Expo app is the only UI over the shared core. Twelve network-behaviour checks that only the browser suite exercised (per-flow request budgets from the fake Trakt's journal, the fault modes, token refresh, the rate-limit pause) now run headless in the core's harness against the same fake Trakt, each proven by a mutation. The repository-level CI tests moved to a root test project. Coverage percentages rose in every category.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the core's read layer reshaped, 1,662 lines deleted and 1,403 added (net -259; core source -787, native screens and tests +528). Bundles: Expo iOS bundle -10.1 kB, Expo Android bundle -10.0 kB, Play download -3.0 kB.

Reads are query option factories under the core's queries/ (14, each taking the runtime first), module state is read through selectors under stores/, and the pure derivations the page-shaped aggregates were hiding live under domain/; the hooks directory went from 3,932 lines to 2,591 and keeps only behaviour hooks. Screens compose their own reads. Two new gates: every use-named file exports exactly one hook and nothing else, and queries/ cannot import React; the scoped-revalidation rule now also covers queries, stores and native routes. Four mutations bit. Coverage thresholds rose (overall lines 79.8 to 86.9 percent) and the render-count gate now measures real subscriptions through a seeded query client instead of mocked hooks.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the startup-time gate, 348 lines added and 12 deleted. Bundles: Expo iOS bundle +478 B, Expo Android bundle +567 B, Play download +155 B.

The simulator lane now measures a returning user's launch: the app publishes the time from process start to its idle marker (React Native's own startup timing) in the accessibility tree, and a second-launch Maestro flow reads it and asserts it against a ceiling kept in a ratchet file that carries its measurement, run and date and can only be edited downward. The ceiling is 600 ms from a measured 466.5 ms plus the observed run-to-run band; a planted three-second block at boot failed the flow on a throwaway PR, and raising the ceiling fails the ratchet test. The runner-timed launch-to-idle duration is reported in the job summary.

@github-actions

Copy link
Copy Markdown

Diff footprint

area added removed net
product (packages/*/src/) 14116 0 +14116
tests (packages/*/test/) 14352 0 +14352
e2e (packages/*/e2e/) 0 0 +0
other 12040 51041 -39001
total 40508 51041 -10533

Product lines: code +9997 / -0 (net +9997), comments +2997 / -0, blank +1122 / -0
Line types are split by line prefix after leading whitespace.

Bundle size

bundle base head delta limit
expo ios bundle (raw) n/a 5.02 MB n/a 5250 kB
expo android bundle (raw) n/a 5.05 MB n/a 5400 kB
play download (xxxhdpi arm64) n/a 16.85 MB n/a 20000 kB

Complexity and comments

metric base head delta
functions over cognitive complexity 15 n/a 0 n/a
worst cognitive complexity n/a 15 n/a
mean cognitive complexity (functions scoring 2 or more) n/a 4.29 n/a
product comment density n/a 22.43 percent n/a
core comment density n/a 23.93 percent n/a
native comment density n/a 19.12 percent n/a

The merge base does not contain the measured packages, so its columns read n/a.

Expo Atlas top contributors

platform contributor transformed bytes
iOS react-native-reanimated 1548313
iOS react-native 1485475
iOS expo-router 1237023
iOS zod 662854
iOS Cue app 548458
iOS react-native-gesture-handler 335466
iOS react-native-svg 294809
iOS react-native-worklets 208108
iOS expo 198113
iOS react-native-screens 174484
Android react-native-reanimated 1548313
Android react-native 1497122
Android expo-router 1215699
Android zod 662854
Android Cue app 548216
Android react-native-gesture-handler 339545
Android react-native-svg 294824
Android react-native-worklets 208108
Android @expo/ui 204031
Android expo 198117

Maestro's HTTP client is documented for script files with a config
object, and the inline expression form left both detail flows dead at
their first step.
A row that stops under the floating tab bar is visible to Maestro but
its centre tap lands on the Library tab.
Release tags now have one strict version shape and must match the version embedded in the app. Removing branch releases makes the path partition and its architecture mirror unnecessary.
The release trigger no longer partitions tracked files, so its test suite no longer needs picomatch or its type declarations.
…shot, and a background download waited on at once
Run every flow on Android and publish screenshots from both platforms
…l-platform-pass

# Conflicts:
#	.maestro/flows/episode-sheet.yaml
#	.maestro/flows/launch.yaml
#	.maestro/flows/library.yaml
#	.maestro/flows/search.yaml
#	packages/native/src/TabStack.tsx
The footprint gate failed pull request #70, which changed only Maestro flows:
the Play download estimate measured 20144986 bytes against 20144985 on its base
b81bf85, while the tester APK and every JavaScript bundle measured the same.

Both runs restored the fingerprinted Android build and re-embedded the
JavaScript with scripts/reembed-android.sh, which compiled
$work/index.android.bundle with hermesc. Hermes writes the source path it is
given into the bytecode, and $work comes from mktemp, so each run's
base/assets/index.android.bundle carried six different random characters and a
different trailing hash. The length stays fixed, so the tester APK, which stores
the entry uncompressed, kept its size; the Play estimate compresses the split
APKs, so the random bytes moved it by one.

scripts/compile-hermes.sh compiles from the bundle's own directory with a
relative source path, and both re-embed scripts use it. The test compiles the
same source in two fresh temporary directories and requires identical bytes; it
fails when the script passes the absolute path.
Compile re-embedded Hermes bundles from a fixed path
Give the post sign-in app idle wait the app's full read retry budget
Make the tab shell follow each platform: splash, search, bars, Done, tabs, sheet
Dismiss the episode sheet from its grabber in the Maestro flows
Ready the iOS simulator before Maestro and restart a driver that exits on first launch
Clear TestFlight export compliance and wait for processing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants