Skip to content

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

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

arun279 wants to merge 391 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.

@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.

The browser reclassification is about a response Cloudflare sent and the browser refused to show. A socket this client aborted itself is the connection failing, so on the web it was telling a reader with no network that Trakt was having trouble.
An advancing row with no episode was let into the queue past the rules a projected one has always obeyed, so finishing an ended show left it on the list. The advance is one flag on the show, the exclusions apply to both shapes of it, and the zero-id sentinel stops being a second answer to the same question. On the web the marquee keeps its slot as a row rather than headlining an episode nobody can name, and an absent episode line no longer leaves an empty one behind.
The Capacitor line is the one that ships today, and its own manifest really does strip the permission its notifications plugin merges, which the privacy claims test pins; the README sentence saying so was true and had to come back. The APK gate's count of what app.config.ts drops moved with the list. The config test that was left asserting the absence of a string nothing declares is gone: the built APK is the only place that set can be checked honestly, and the gate already says so.
On a dark simulator the bar drew the light page fill: cream behind the large
title, a light status bar over a dark screen, and the app's own theme starting
one band down. The navigators had no theme, so they kept the library's, which
is fixed light.

They now take a theme built from the palette for the scheme, which is what the
bar's fill, its title, its back chevron and the hairline under a scrolled bar
all read. A bar is a UIKit surface configured through props rather than styles,
so it resolves a color once when it is set and against the default traits: it
has to be handed a resolved one and a new theme when the scheme changes, which
is the opposite of every surface React Native draws.

The screen's own header background goes with it. It existed to make the bar
opaque over poster artwork, and the theme's card color already is.
Three things the card only gets wrong once it stops being artwork, and all
three show at the largest text size, where every card is this shape.

Its fill is #ffffff on a #fbfaf7 page, 1.04:1, so without an edge the card is
not a card. It now carries the same hairline every other surface with that
problem carries.

Its stack sat on the bottom, which is where a stack belongs on a scrim whose
strongest end is down there. On a plain surface it starts at the top, beside a
poster that no longer has artwork the height of the card to sit in the middle
of.

Its eyebrow was amber on both compositions. Over artwork it takes the quiet
ink the rest of the text over artwork takes; on the plain surface it takes the
accent ink, because amber reads 1.97:1 against that fill and this is the one
line on the screen that says an episode is new.
At the accessibility sizes a queue row is several lines tall and its poster is
still 48 by 72, so centring the two left the poster floating against the middle
of a paragraph with the title beginning well above it. The row already changes
shape at this threshold to put its controls below the text; the artwork goes to
the top of the text with it, which is the composition the row is drawn as.
Keep the pre-push aggregate aligned with CI so the size-limit regression guard covers both Hermes bundles before changes leave a workstation.
Record each measurement and web.dev reduction target beside its regression ceiling. Preserve the 20 MB Play decision and reject any later increase across committed history.
Apply Chromium binary size policy to both Hermes bundles and the Play download estimate. Growth above 64 kB requires a Binary-Size rationale in the pull request body.
Measure Android at Play reference density and across every bundletool dimension instead of gating AAB bytes. Gate the largest compressed variant from Apple App Thinning reports at 40 MB while retaining the IPA smoke ceiling.
Use Expo Router drawable tab icons so Android no longer loads the Material Symbols font. Gate Expo export metadata against the committed asset inventory so transitive additions require an explicit decision.
Pin Expo Atlas and preserve its production bundle graph as a pull request artifact. Add per-platform package totals to the footprint comment so Chromium-style size deltas have actionable attribution.
Seven behaviour changes landed on assertions written for the old behaviour, and the lanes that would have caught it never ran. Scoping the mark means a mark no longer re-reads the aggregate, so a 429 armed on that read never fires and a failed confirming read no longer reaches the strip: what carries it is the advancing row, which is what these now assert. The honest projection means the queue's lead show, one episode from its finale, names none rather than inventing S2 E11. And in a browser an unreadable answer from Trakt's own origin now says so instead of naming the reader's connection.
The per-flow budget asserted exact counts and one mark at 2; a mark costs 3, because the home screen's Previously section reads its own history page, so the gate could not have passed. It is a ceiling now, set from a measurement plus room for one retry, with the write counted separately so a mark that stopped reaching Trakt fails it too. MOCK_TRAKT_PORT moves the fake off its fixed port for the same reason E2E_PREVIEW_PORT exists, and the account reset now lets go of anything a hold fault left waiting, which its own docblock already promised.
Splitting the exclusions and the air test out of groupUpNext took it under the cognitive-complexity limit, so its biome-ignore is gone and the ratchet wants the lower number.
js-yaml and @xmldom/xmldom picked up high-severity advisories after this branch was cut, both through Expo's build tooling and neither reachable from anything Cue ships. The lockfile is unchanged by the rest of this branch, so the gate is red on the base too. Three overrides in the shape the file already uses, each a patch release inside its own major: xmldom 0.9.10 to 0.9.12, js-yaml 3.15.1 to 3.15.2 and 4.3.1 to 4.3.2.
Xcode documents the thinning export option as applying to non App Store exports only, and the release lane exports for the App Store, so the report the assertion reads is never produced and every iOS build would fail on a missing file. The IPA smoke ceiling stays; the App Store download figure has to come from App Store Connect until an ad hoc export lane exists.
Expo CLI ships EXPO_ROUTER_DISABLE_NATIVE_TABS_MD, which swaps the Android material icon converter for a stub and tree-shakes expo-symbols out of the bundle. Setting it in the Metro config covers every bundle the project builds, including the one Gradle embeds, and removes both the hand written resolver override and the 111 kB glyph table the override left behind. The Android bundle drops from 3896638 to 3803699 bytes.
The fake omitted movie fanart and related media, so simulator checks could not exercise the detail hero or recommendations. Add contract-backed fixture variants and flow assertions to keep those surfaces visible.
Android needs markers above system navigation, while iOS timing markers must remain outside scroll content to stay in the accessibility hierarchy.
The first iOS CI pass exposed a ghost tap on the visible season action after the related rail loaded. Let Maestro retry an ignored tap so the flow reaches its existing confirmation assertion reliably.
@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Search screen, 1279 lines added and 106 deleted (net +1173). Product-Growth: about 450 product lines are the screen (native header search field, session recent terms, browse grids per enabled medium with zero-hit grids dropped, results with the Watchlist pill in three faces and its optimistic add, searching, error, no-results, media-hidden and offline states); the Library tile is reused for browse; the rest is tests, a flow and fake Trakt routes. Binary-Size: JavaScript only; no native change (numbers in the footprint comment on PR #59).

Also in this merge: the shared sign-in flow now waits for the app's own idle marker instead of asserting the skeleton gone on a fixed timeout, which removes the intermittent failure several PRs hit; the coming-soon placeholder and its test are deleted because no screen uses them any more. The search tab stays a plain tab: the iOS 26 search-tab role swallows the tab bar into its own field and never presents the screen's search controller. Stills in matched light and dark pairs and a clip are on PR #59; a strict visual review found them clean apart from the system-drawn search field clipping at the largest text size, recorded for the Dynamic Type pass.

Related content length changed the scroll position enough for the native header to cover the season action during the bulk-mark flow. Center that action before tapping so the visual path exercises both bulk marking and the related rail.
The base branch advanced with search fixtures and CI flow coverage while this detail work was in progress. Combine both fixture sets and both iOS flows so the PR remains mergeable without dropping either feature.
Maestro considered the Season 2 action visible while the native header still covered its tap target. Center the known episode row so the season action is safely hittable before opening the bulk-mark sheet.
A form sheet presented while the account full screen modal is already up
never registers in the iOS accessibility tree: the sheet draws, but its
container holds no elements, so VoiceOver and the Maestro flow cannot
reach a month. The same happens to the episode sheet opened from a
History row, so this is the nesting rather than the sheet's contents.

The jump now draws inside the History screen as a modal panel over a
dimmed backdrop, with an explicit Close where the sheet had a grabber.
Picking sets the search params in place, which retires the route, its
parameter round trip and the medium it had to carry back.
The centered episode lookup still settles at the sticky Continue boundary on iOS, leaving the Season 2 action covered. Drag the detail content down before tapping so the confirmation control is safely hittable.
The CI artifact proved the original tap could land under sticky content. Keep the corrected scroll position and retry an ignored iOS tap so the confirmation sheet opens reliably.
At the largest accessibility text the six column grids broke year labels
across lines ("202" over "6") and the panel grew past the top of the
screen, taking its Close control with it. The grids now fall to three
columns above a 1.3 text scale, and the panel caps at four fifths of the
window with its heading pinned and its body scrolling.

The leading time column was fixed at 62 pt, which broke "4:14 PM" inside
the meridiem; it now scales with the text. The panel draws on the
overlay token so it reads as raised against the dimmed page on dark.
Account navigation needs an opaque surface so sticky day labels remain visible while scrolling. A default same-day repeat makes the collapse badge testable in the device flow.
Season 2's header sat under the native nav bar once the flow scrolled
past it: the scroll centered on an episode row further down the list,
and a blind corrective swipe added after that did not bring it back.
On load the header is already visible below the continue bar, so tap
season-check-2 there instead of scrolling to it, and drop the swipe
and tap-retry that were compensating for the wrong scroll target.
The preceding episode flow mutates the fake account even after Undo, which can move a single play ahead of the seeded repeat. Resetting before authentication makes the History assertion independent of suite order.
@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Movie detail screen, 966 lines added and 39 deleted (net +927). Product-Growth: about 360 product lines are the screen, built from show detail's hero, check control, overflow menu and related row; the rest is tests, a flow and fake Trakt fixtures. Binary-Size: JavaScript only; no native change (numbers in the footprint comment on PR #58).

The screen has the opaque bar, hero with backdrop, facts, the 56 pt check with snackbar and undo, the overflow (watchlist, Open on Trakt), More like this capped at six, the refusal to unmark a rewatched film with its Open history action, and loading, error and movies-off states. The fake Trakt now serves movie backdrops and related titles for movies and shows (it served neither, which hid both from every check), parsed through the app's contracts; both detail flows assert the related row and its cap. A strict visual review in light, dark and the largest text size found the rendered UI clean; stills are on PR #58.

iOS exposes the repeated-play count in the row label while correctly omitting the decorative badge as a separate accessibility node. The device flow now checks the user-facing count on that row.
Install the standalone update client and bind each self-built binary to a fingerprint runtime and release channel. Keep publication manual and require green CI for the exact commit so updates cannot bypass the native release safeguards.
The confirm alert's title and its button both carry the words of the danger
row, and the row stays in the hierarchy under the alert, so an unanchored
text tap matched the title and the confirm was never pressed. Name the
confirm by the Cancel button beside it and wait for the alert before aiming.

Drop the queued write rather than holding it. A held socket only refuses
after five fifteen second request timeouts; a dropped one refuses in
seconds, and an unreachable Trakt is what the refusal copy describes. Then
let writes through, refuse the revoke, and assert the sign out still lands
on onboarding: revoking is best effort and must not strand a session.

The flow runs last in the iOS suite now, because it ends on onboarding and
the app idle measurement before it needs a signed in session.
@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: the History screen, 1130 lines added and 71 deleted (net +1059). Product-Growth: about 570 product lines are the screen (day-grouped sections with sticky opaque headers, the native title filter, paging that stops while a page load has failed, same-item same-day collapse with a count in the row's accessible label, the row menu, removal with an honest remainder and an undo that awaits the removal, the month jump); the rest is tests, a flow and fake Trakt history. Binary-Size: JavaScript only; no native change (numbers in the footprint comment on PR #60).

The month jump draws inside the screen as a panel with an explicit Close, because a second system presentation on top of the account modal never registers in the iOS accessibility tree; every month is a button reachable by name. The account stack's header is opaque so day headers do not pin behind it, sharing the tab stacks' options. Four layout breaks at the largest text size were found on the simulator and fixed. Fourteen matched light and dark stills are on PR #60.

@arun279

arun279 commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Merged: deliberate JavaScript updates with EAS Update, 341 lines added and 86 deleted (net +255). Binary-Size: the expo-updates native module adds 564.1 kB to the tester APK and 1.09 MB to the Play download estimate; JavaScript bundles are effectively unchanged.

Nothing publishes automatically. An update goes out only through the manually run Publish update workflow, which requires green CI for the exact commit (the release workflow and this one now share one gate script), checks that the robot account can reach the project, and publishes to the chosen channel. The runtime version follows the native fingerprint, so an update only reaches builds with the same native code. No analytics package is added; a test keeps a deny list. The Expo project is @arunkris/cue. A fresh tester build is needed once before updates can reach a phone.

The error block centres its headline and body, but the action sets its own
alignSelf, so a retry button landed hard left under centred text. Centre it
on the main axis, which is the one alignSelf does not own.

The lapsed order value wrapped to a second line inside a 190 pt cap while
its label was already wrapping, which read as three ragged columns. The cap
now clears the longest value on one line and the label still wraps to two.

Account.yaml signs back in at the end instead of moving down the suite: the
app-idle measurement that follows it is gated to run last and needs the
returning user the other flows leave behind.
@arun279

arun279 commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Merged #61 Profile and Settings as e6ecdf9.

Diffstat of this landing: +1,481 / -85 (net +1,396): product +711, tests +522, the rest flows and fixtures. PR #24 is now +37,381 / -38,476 (net -1,095).

Sizes from the PR's footprint: iOS JavaScript bundle +55.3 kB (5.19 to 5.25 MB), Android JavaScript bundle +55.4 kB, tester APK 39.62 to 41.23 MB (+1.61 MB), Play estimate 18.15 to 19.67 MB (+1.51 MB). The change is JavaScript plus one 2.9 kB image and adds no dependency, so the APK delta is larger than this change explains; the base it was measured against predates the update client merged in #62. The next footprint on this pull request is the number to trust and I will correct this comment against it.

What landed: Profile (stats tiles, avatar, links) and Settings (theme, sort, data section, sign out) on the account stack; sign out refuses while a queued write cannot reach Trakt and completes even when token revocation fails. The iOS flow proves both. 16 matched light and dark stills: #61 (comment)

Known and owed: two different avatar fallback glyphs (tab bar and Profile); the word EPISODES breaks mid-word in the Profile tiles at the largest text size; no deletions were available in this landing, stated in its Product-Growth line.

Screens merged: 10 of 10 built (Onboarding's visual layer is in progress separately).

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