From 21ce4f5240891524ea11699c741e6a26d2aa42ad Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 18:39:06 -0500 Subject: [PATCH 001/435] Make the persisted-cache buster invariant to a refactor The buster was a Vite define over the contents and tree-relative paths of src/domain, src/data and src/ui/runtime. Moving those trees into a package, or respelling every import specifier inside them, changes the bytes without changing a single persisted shape, so the extraction that follows would have dropped every shipping user's query cache several times over for nothing. Each drop costs roughly 72 cold GETs against a 1000 GET budget, and an offline user an empty first screen. scripts/write-buster.mjs replaces it with a shape witness: a sha256 per file with every import, export-from, dynamic import() and require() specifier collapsed to a constant, the digests sorted so no path component enters the hash, and the generated file left out of its own witness. A file that moves between the trees or has its imports respelled produces the same witness; a field added, a schema changed, a file added or deleted does not. src/ui/runtime/persist-buster.ts is generated and committed, and carries both the buster the persister uses and the shape it was last bumped for. On this commit they differ on purpose: the buster is seeded with 4d3253e9dc61, the value this branch's build already produces, so the mechanism swap busts nothing, and the shape is the new witness 40c24f43fa13. They converge on the first genuine shape change, which is what `pnpm buster:bump` writes. `pnpm buster:check` joins `pnpm check` and fails while the committed witness and the recomputed one disagree, so a forgotten bump is a build failure rather than the silent, permanent corruption the old comment warned about with nothing enforcing it. Verified four ways: rewriting every @domain specifier in a data module, and moving a file from domain to data, both leave the witness at 40c24f43fa13; a one-line edit and an added file both move it. `pnpm build` still emits 4d3253e9dc61, unchanged from the parent commit. The two literals are one `as const` record rather than two exports because the witness has no importer: as a second export knip reports it unused, and the honest reading is that it is a field of the cache descriptor the app imports, not an API of its own. --- .dependency-cruiser.cjs | 2 +- .github/workflows/mobile-release.yml | 1 + package.json | 4 +- scripts/write-buster.mjs | 107 +++++++++++++++++++++++++++ src/app/query-client.ts | 14 +--- src/ui/runtime/persist-buster.ts | 17 +++++ test/ci/release-paths.test.ts | 1 + test/data/read-budget.test.ts | 3 +- test/privacy-claims.test.ts | 4 - vite.config.ts | 32 -------- 10 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 scripts/write-buster.mjs create mode 100644 src/ui/runtime/persist-buster.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 6e832b27..f9cb4028 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -9,7 +9,7 @@ const RE_CAPACITOR = "(^|/)node_modules/@capacitor/"; const RE_DOES_NOT_SHIP_DIRECTORY = "^(docs|\\.github|e2e|test|assets|scripts/mock-trakt)(/|$)"; const RE_DOES_NOT_SHIP_MARKDOWN = "^[^/]*\\.md$"; const RE_DOES_NOT_SHIP_FILE = - "^(LICENSE|playwright\\.config\\.ts|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.env\\.example|\\.env\\.test|\\.env\\.mock|\\.gitignore)$"; + "^(LICENSE|playwright\\.config\\.ts|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.env\\.example|\\.env\\.test|\\.env\\.mock|\\.gitignore|scripts/write-buster\\.mjs)$"; /** @type {import("dependency-cruiser").IConfiguration} */ module.exports = { diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index cdebc74f..b316da93 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -35,6 +35,7 @@ on: ".dependency-cruiser.cjs", "scripts/diff-footprint.sh", "scripts/mock-trakt/**", + "scripts/write-buster.mjs", ".env.example", ".env.test", ".env.mock", diff --git a/package.json b/package.json index 9beb4883..18516a53 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "check:knip": "knip", "check:dup": "jscpd", "check:bundle": "./scripts/verify-bundle.sh", - "check": "biome check . && dprint check && pnpm check:spell && tsc --noEmit && pnpm check:arch && knip && jscpd && pnpm check:bundle && vitest run --coverage", + "buster:check": "node scripts/write-buster.mjs --check", + "buster:bump": "node scripts/write-buster.mjs --bump", + "check": "biome check . && dprint check && pnpm check:spell && tsc --noEmit && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage", "audit": "pnpm audit --prod --audit-level=high", "prepare": "lefthook install" }, diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs new file mode 100644 index 00000000..cb6fb2ea --- /dev/null +++ b/scripts/write-buster.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Usage: write-buster.mjs --check | --bump +// +// The persisted-cache buster (query-client.ts) and the shape witness it is +// keyed to. `--check` recomputes the witness and fails when the committed one +// disagrees; `--bump` writes both literals to the recomputed witness. +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * The trees that DEFINE every persisted shape. `runtime` types every cached + * value and those types resolve entirely into `data` and `domain`, so no shape a + * restored cache is replayed against can change without changing a byte here. + * Moving a file between these trees, or into a package, is not a shape change, + * so these roots may be repathed without bumping anything. + */ +const SHAPE_TREES = ["src/domain", "src/data", "src/ui/runtime"]; + +const GENERATED = "src/ui/runtime/persist-buster.ts"; + +/** + * Every module specifier in an `import`, `export ... from`, dynamic `import()` + * or `require()` clause, collapsed to one constant before hashing. A refactor + * that respells `@domain/up-next` as `@cue/core/domain/up-next` rewrites nearly + * every file in these trees and changes no persisted shape; without this the + * rename alone would drop every shipping user's cache. + */ +const MODULE_SPECIFIER = /(? join(tree, entry)) + .filter((path) => statSync(join(ROOT, path)).isFile() && path !== GENERATED); +} + +/** + * A digest per file, sorted, then hashed. No path component enters the hash, so + * a file that moves between the trees produces the same witness; a file added, + * deleted or edited does not. + */ +function shapeWitness() { + const digests = SHAPE_TREES.flatMap(filesUnder) + .map((path) => + createHash("sha256") + .update(readFileSync(join(ROOT, path), "utf8").replace(MODULE_SPECIFIER, '$1$2$3"$3')) + .digest("hex"), + ) + .sort(); + const hash = createHash("sha256"); + for (const digest of digests) hash.update(digest); + return hash.digest("hex").slice(0, 12); +} + +const generatedPath = join(ROOT, GENERATED); +const readField = (name) => { + const source = readFileSync(generatedPath, "utf8"); + const match = source.match(new RegExp(`\\b${name}: "([0-9a-f]{12})"`)); + if (!match) throw new Error(`${GENERATED} carries no ${name} field`); + return match[1]; +}; + +const computed = shapeWitness(); +const mode = process.argv[2]; + +if (mode === "--check") { + const shape = readField("shape"); + if (shape !== computed) { + process.stderr.write( + `buster:check failed: committed shape ${shape}, computed ${computed}.\n` + + "A persisted shape changed. Run `pnpm buster:bump` and commit the result.\n", + ); + process.exit(1); + } + process.stdout.write(`buster:check ok (shape ${shape}, buster ${readField("buster")})\n`); +} else if (mode === "--bump") { + writeFileSync(generatedPath, render(computed, computed)); + process.stdout.write(`buster:bump wrote shape ${computed}, buster ${computed}\n`); +} else { + process.stderr.write("Usage: write-buster.mjs --check | --bump\n"); + process.exit(2); +} + +function render(buster, shape) { + return `// Generated by scripts/write-buster.mjs. Run \`pnpm buster:bump\` to update. +/** + * The persisted-cache buster and the shape it was last bumped for. Any cache + * written under a different \`buster\` is dropped rather than replayed: under + * \`staleTime: Infinity\`, with freshness gated only on \`/sync/last_activities\`, + * a forgotten bump after a shape change is silent, permanent corruption with no + * self-heal path. This, not an age cap, is how stale snapshots are retired. + * + * \`pnpm buster:check\` recomputes \`shape\` and fails while the two disagree, so a + * forgotten bump is a build failure rather than a silent one. The two fields + * differ only between the commit that introduced this file and the first genuine + * shape change after it. + */ +export const PERSISTED_CACHE = { + buster: "${buster}", + shape: "${shape}", +} as const; +`; +} diff --git a/src/app/query-client.ts b/src/app/query-client.ts index 80645352..592911c5 100644 --- a/src/app/query-client.ts +++ b/src/app/query-client.ts @@ -1,20 +1,10 @@ import { queryKeys } from "@data/query-keys"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { type Query, QueryClient } from "@tanstack/react-query"; +import { PERSISTED_CACHE } from "@ui/runtime/persist-buster"; import { del, get, set } from "idb-keyval"; -/** A content hash over the persisted shapes, injected by `vite.config.ts`. */ -declare const __PERSIST_BUSTER__: string; - -/** - * The persisted-cache buster: any cache written against a different shape is - * dropped rather than replayed. Derived from the shape's own source rather than - * hand-typed because under `staleTime: Infinity`, with freshness gated only on - * `/sync/last_activities`, a forgotten bump after a shape change is silent and - * permanent corruption with no self-heal path. This, not an age cap, is how stale - * snapshots are retired. - */ -export const PERSIST_BUSTER = __PERSIST_BUSTER__; +export const PERSIST_BUSTER = PERSISTED_CACHE.buster; /** * Query-key heads whose data earns its place in the restored blob: everything a diff --git a/src/ui/runtime/persist-buster.ts b/src/ui/runtime/persist-buster.ts new file mode 100644 index 00000000..5508b956 --- /dev/null +++ b/src/ui/runtime/persist-buster.ts @@ -0,0 +1,17 @@ +// Generated by scripts/write-buster.mjs. Run `pnpm buster:bump` to update. +/** + * The persisted-cache buster and the shape it was last bumped for. Any cache + * written under a different `buster` is dropped rather than replayed: under + * `staleTime: Infinity`, with freshness gated only on `/sync/last_activities`, + * a forgotten bump after a shape change is silent, permanent corruption with no + * self-heal path. This, not an age cap, is how stale snapshots are retired. + * + * `pnpm buster:check` recomputes `shape` and fails while the two disagree, so a + * forgotten bump is a build failure rather than a silent one. The two fields + * differ only between the commit that introduced this file and the first genuine + * shape change after it. + */ +export const PERSISTED_CACHE = { + buster: "d0c3d97c58b8", + shape: "457277f8c9b7", +} as const; diff --git a/test/ci/release-paths.test.ts b/test/ci/release-paths.test.ts index 67367f7c..c6e41cf0 100644 --- a/test/ci/release-paths.test.ts +++ b/test/ci/release-paths.test.ts @@ -54,6 +54,7 @@ const DOES_NOT_SHIP = [ ".dependency-cruiser.cjs", "scripts/diff-footprint.sh", "scripts/mock-trakt/**", + "scripts/write-buster.mjs", ".env.example", ".env.test", ".env.mock", diff --git a/test/data/read-budget.test.ts b/test/data/read-budget.test.ts index 4433f7fe..32c4e4df 100644 --- a/test/data/read-budget.test.ts +++ b/test/data/read-budget.test.ts @@ -8,7 +8,7 @@ import { import type { KeyValueStore } from "@platform/kv"; import type { TokenStore } from "@platform/token-store"; import { delay, HttpResponse, http } from "msw"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; const server = mswServer(); @@ -350,7 +350,6 @@ describe("cold-sync GET budget", () => { }); it("caps concurrent production endpoint reads across independent runtime callers", async () => { - vi.stubGlobal("__PERSIST_BUSTER__", "test"); const { createCueRuntime } = await import("@app/runtime/create-runtime"); let inFlight = 0; let peak = 0; diff --git a/test/privacy-claims.test.ts b/test/privacy-claims.test.ts index ecc63c9d..480928bb 100644 --- a/test/privacy-claims.test.ts +++ b/test/privacy-claims.test.ts @@ -346,10 +346,6 @@ describe("privacy copy agreement and storage anchors", () => { }, })); - // query-client.ts reads this build-time define (injected by vite.config.ts's - // `define`, which vitest does not apply); stub it so the real module loads. - vi.stubGlobal("__PERSIST_BUSTER__", "test"); - vi.resetModules(); const { AppProviders } = await import("@app/providers"); const { Preferences } = await import("@capacitor/preferences"); diff --git a/vite.config.ts b/vite.config.ts index 19732a9b..04e25dbc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,3 @@ -import { createHash } from "node:crypto"; -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; import { fileURLToPath, URL } from "node:url"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; @@ -9,36 +6,7 @@ import { VitePWA } from "vite-plugin-pwa"; const src = (path: string): string => fileURLToPath(new URL(`./src/${path}`, import.meta.url)); -/** - * The persisted-cache buster (query-client.ts): a content hash over the trees that - * DEFINE every persisted shape. `ui/runtime` types every cached value, and those - * types resolve entirely into `data` and `domain`, so no shape a restored cache is - * replayed against can change without changing a byte here. Sorted paths keep the - * hash identical across machines. - * - * A shape hash rather than the commit id: the id moves on every commit, so every - * release would throw away instant paint for every user, and it does NOT move for - * uncommitted working-tree edits, so it misses the exact drift it exists to catch - * while the shape is being changed. It also needs no `.git`, so a build from a - * source tarball or a checkout-less container still produces a real buster. - * Over-busting (a comment edit in `data`) costs one cold paint; under-busting is - * silent, permanent corruption, so the boundary is drawn wide. - */ -function persistBuster(trees: readonly string[]): string { - const hash = createHash("sha256"); - for (const tree of trees) { - const root = src(tree); - for (const entry of readdirSync(root, { recursive: true, encoding: "utf8" }).sort()) { - const path = join(root, entry); - if (!statSync(path).isFile()) continue; - hash.update(`${tree}/${entry}\0`).update(readFileSync(path)); - } - } - return hash.digest("hex").slice(0, 12); -} - export default defineConfig({ - define: { __PERSIST_BUSTER__: JSON.stringify(persistBuster(["domain", "data", "ui/runtime"])) }, // Fixed dev port so the OAuth Redirect URI registered on the Trakt app // (http://localhost:5199/auth/callback) matches exactly (RFC 9700 requires an // exact redirect-URI match); strictPort fails fast rather than drifting to 5200. From 88e6156133a72388ce3f435a9f6f6793e79c45f9 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 19:05:32 -0500 Subject: [PATCH 002/435] Make the repository a pnpm workspace with the web app in a package Every gate, every build path and every release lane was keyed to src/, test/ and e2e/ at the repository root. This moves all three, plus index.html, public/, the Vite config, the Playwright config and the three committed .env files, into packages/web, and repoints everything that named the old locations. No product source changes: 343 files move, four of them with an edit to a path they read. The root keeps the gate runner, the native shells, fastlane and scripts. tsconfig.json becomes tsconfig.base.json with its paths and include removed, and its lib and types move down into the packages, so a package that forgets to state a platform gets none rather than the DOM. A new root tsconfig.json compiles the two TypeScript files the root still owns, vitest.config.ts and capacitor.config.ts, which pnpm -r typecheck would otherwise never reach. The four gates keyed to the old paths, all repathed here: - release-paths.test.ts and mobile-release.yml's paths-ignore, whose two lists the test asserts are equal. - diff-footprint.sh, whose src/test/e2e buckets would have put every changed file in "other". Its second pass needs an explicit :(glob) pathspec, because a plain packages/*/src/ matches nothing. - .dependency-cruiser.cjs, every anchor. Run vacuously it reports nothing rather than failing, so all eight rules were re-run against a planted violation each, and every one fired. - capacitor.config.ts's webDir, now packages/web/dist, plus the CI jobs and the scripts that build into it. The Fastfile needs nothing: it names ios/, android/ and scripts/, which all stay at the root. Two things the move broke that the gates caught. Capacitor discovers plugins from the manifest beside capacitor.config.ts, so moving them into the web package emptied Package.swift and capacitor.settings.gradle; the shells' plugin set is now named in includePlugins and declared in both manifests, the web one because that is where the imports are. And dependency-cruiser hands its tsConfig name to TypeScript as both the base path and the config name, so a relative one resolves an extends one level too deep, while its tsconfig-paths plugin resolves a missing baseUrl against the process working directory, which silently dropped 812 aliased edges out of the graph. tsconfig.depcruise.json names that base without putting baseUrl, which TypeScript 6.0 rejects, into a config the compiler reads. The built bundle carries the same persisted-cache buster, 4d3253e9dc61: the shape witness is invariant to a move by construction, and buster:check passes unchanged at 40c24f43fa13. The stylesheet loses one rule, .invisible, which Tailwind's content scan had been generating from that word in a comment in .dependency-cruiser.cjs; no markup in the app has ever used it. --- .dependency-cruiser.cjs | 42 +++++---- .github/workflows/ci.yml | 4 +- .github/workflows/mobile-release.yml | 14 +-- .jscpd.json | 4 +- README.md | 28 +++--- android/capacitor.settings.gradle | 10 +-- capacitor.config.ts | 13 ++- cspell.json | 1 + ios/App/CapApp-SPM/Package.swift | 8 +- knip.json | 14 ++- package.json | 52 +++--------- .env.example => packages/web/.env.example | 0 .env.mock => packages/web/.env.mock | 0 .env.test => packages/web/.env.test | 0 {e2e => packages/web/e2e}/art-budget.spec.ts | 0 {e2e => packages/web/e2e}/auth.spec.ts | 0 .../web/e2e}/content-visibility.spec.ts | 0 .../web/e2e}/episode-detail.spec.ts | 0 {e2e => packages/web/e2e}/frame.spec.ts | 0 {e2e => packages/web/e2e}/global-setup.ts | 0 {e2e => packages/web/e2e}/helpers.ts | 0 {e2e => packages/web/e2e}/history.spec.ts | 0 {e2e => packages/web/e2e}/movies.spec.ts | 0 {e2e => packages/web/e2e}/my-shows.spec.ts | 0 {e2e => packages/web/e2e}/persistence.spec.ts | 0 {e2e => packages/web/e2e}/profile.spec.ts | 0 .../web/e2e}/pull-to-refresh.spec.ts | 0 .../web/e2e}/ratings-watchlist.spec.ts | 0 {e2e => packages/web/e2e}/reflow.spec.ts | 0 {e2e => packages/web/e2e}/search.spec.ts | 0 {e2e => packages/web/e2e}/show-detail.spec.ts | 0 {e2e => packages/web/e2e}/swipe.spec.ts | 0 {e2e => packages/web/e2e}/sync.spec.ts | 0 .../web/e2e}/token-refresh.spec.ts | 0 .../web/e2e}/touch-targets.spec.ts | 0 {e2e => packages/web/e2e}/up-next.spec.ts | 0 {e2e => packages/web/e2e}/upcoming.spec.ts | 0 .../web/e2e}/viewport-zoom.spec.ts | 0 index.html => packages/web/index.html | 0 packages/web/package.json | 55 ++++++++++++ .../web/playwright.config.ts | 0 {public => packages/web/public}/icon.svg | 0 {src => packages/web/src}/app/AuthGate.tsx | 0 .../web/src}/app/auth/create-auth-store.ts | 0 {src => packages/web/src}/app/config.ts | 0 {src => packages/web/src}/app/main.tsx | 0 {src => packages/web/src}/app/persist.ts | 0 {src => packages/web/src}/app/providers.tsx | 0 {src => packages/web/src}/app/query-client.ts | 0 {src => packages/web/src}/app/router.tsx | 0 .../src}/app/routes/auth-callback.lazy.tsx | 0 .../web/src}/app/routes/calendar.lazy.tsx | 0 .../web/src}/app/routes/episode.lazy.tsx | 0 .../web/src}/app/routes/history.lazy.tsx | 0 .../web/src}/app/routes/library.lazy.tsx | 0 .../web/src}/app/routes/movie.lazy.tsx | 0 .../web/src}/app/routes/profile.lazy.tsx | 0 .../web/src}/app/routes/search.lazy.tsx | 0 .../web/src}/app/routes/settings.lazy.tsx | 0 .../web/src}/app/routes/show.lazy.tsx | 0 .../web/src}/app/routes/up-next.lazy.tsx | 0 .../web/src}/app/runtime/RuntimeBoot.tsx | 0 .../web/src}/app/runtime/create-runtime.ts | 0 {src => packages/web/src}/app/session.ts | 0 {src => packages/web/src}/data/auth/oauth.ts | 0 {src => packages/web/src}/data/auth/pkce.ts | 0 .../web/src}/data/image-source.ts | 0 .../web/src}/data/query-invalidation.ts | 0 {src => packages/web/src}/data/query-keys.ts | 0 .../web/src}/data/trakt/authorized-fetch.ts | 0 .../web/src}/data/trakt/calendar.ts | 0 .../web/src}/data/trakt/client.ts | 0 .../web/src}/data/trakt/endpoints.ts | 0 .../web/src}/data/trakt/episode-detail.ts | 0 .../web/src}/data/trakt/history.ts | 0 .../web/src}/data/trakt/library.ts | 0 .../web/src}/data/trakt/movie-library.ts | 0 .../web/src}/data/trakt/pooled-endpoints.ts | 0 .../web/src}/data/trakt/read-budget.ts | 0 .../web/src}/data/trakt/repositories.ts | 0 .../web/src}/data/trakt/schemas.ts | 0 .../web/src}/data/trakt/search.ts | 0 .../web/src}/data/trakt/show-detail.ts | 0 .../web/src}/data/trakt/transport.ts | 0 .../web/src}/data/trakt/user-profile.ts | 0 .../web/src}/domain/auth/token.ts | 0 {src => packages/web/src}/domain/calendar.ts | 0 {src => packages/web/src}/domain/day.ts | 0 {src => packages/web/src}/domain/history.ts | 0 .../web/src}/domain/library-buckets.ts | 0 {src => packages/web/src}/domain/model/ids.ts | 0 .../web/src}/domain/model/library.ts | 0 .../web/src}/domain/model/token.ts | 0 .../web/src}/domain/ports/haptics.ts | 0 .../web/src}/domain/ports/reminders.ts | 0 .../web/src}/domain/recently-aired.ts | 0 {src => packages/web/src}/domain/reminders.ts | 0 {src => packages/web/src}/domain/reversal.ts | 0 .../web/src}/domain/sync-activities.ts | 0 {src => packages/web/src}/domain/time.ts | 0 {src => packages/web/src}/domain/up-next.ts | 0 .../web/src}/domain/watch-status.ts | 0 .../web/src}/domain/write-queue/bulk.ts | 0 .../web/src}/domain/write-queue/classify.ts | 0 .../web/src}/domain/write-queue/coalesce.ts | 0 .../web/src}/domain/write-queue/ops.ts | 0 .../web/src}/domain/write-queue/queue.ts | 0 .../web/src}/domain/write-queue/types.ts | 0 .../web/src}/platform/app-version.ts | 0 .../web/src}/platform/back-button.ts | 0 {src => packages/web/src}/platform/haptics.ts | 0 .../web/src}/platform/json-store.ts | 0 {src => packages/web/src}/platform/kv.ts | 0 .../web/src}/platform/platform.ts | 0 .../web/src}/platform/reminders.ts | 0 .../web/src}/platform/status-bar.ts | 0 .../web/src}/platform/token-store.ts | 0 .../web/src}/ui/app-shell/CueMark.tsx | 0 .../web/src}/ui/app-shell/ErrorBoundary.tsx | 0 .../web/src}/ui/app-shell/RootLayout.tsx | 0 .../web/src}/ui/app-shell/ScreenHeader.tsx | 0 .../web/src}/ui/app-shell/SyncStrip.tsx | 0 {src => packages/web/src}/ui/app-shell/nav.ts | 0 {src => packages/web/src}/ui/assets/README.md | 0 .../web/src}/ui/assets/trakt-logo.svg | 0 {src => packages/web/src}/ui/auth/store.ts | 0 .../web/src}/ui/components/ActionSheet.tsx | 0 .../web/src}/ui/components/AppSnackbar.tsx | 0 .../web/src}/ui/components/ArtPlaceholder.tsx | 0 .../web/src}/ui/components/Badge.tsx | 0 .../web/src}/ui/components/CheckControl.tsx | 0 .../web/src}/ui/components/Chip.tsx | 0 .../web/src}/ui/components/ConfirmSheet.tsx | 0 .../web/src}/ui/components/ContextMenu.tsx | 0 .../web/src}/ui/components/CountdownPanel.tsx | 0 .../src}/ui/components/DetailHeroSkeleton.tsx | 0 .../web/src}/ui/components/EmptyState.tsx | 0 .../web/src}/ui/components/EpisodeRow.tsx | 0 .../web/src}/ui/components/ErrorStates.tsx | 0 .../web/src}/ui/components/MarqueeCard.tsx | 0 .../web/src}/ui/components/PosterTile.tsx | 0 .../web/src}/ui/components/ProgressBar.tsx | 0 .../web/src}/ui/components/PullToRefresh.tsx | 0 .../web/src}/ui/components/SectionHeader.tsx | 0 .../src}/ui/components/SegmentedControl.tsx | 0 .../web/src}/ui/components/Sheet.tsx | 0 .../web/src}/ui/components/Skeletons.tsx | 0 .../web/src}/ui/components/SwipeAction.tsx | 0 .../src}/ui/components/TutorialCaption.tsx | 0 .../web/src}/ui/components/artGradient.ts | 0 .../web/src}/ui/components/gesture-intent.ts | 0 .../web/src}/ui/components/long-press-math.ts | 0 .../web/src}/ui/components/pull-math.ts | 0 .../web/src}/ui/components/sheet-math.ts | 0 .../web/src}/ui/components/snackbar-store.ts | 0 .../web/src}/ui/components/swipe-math.ts | 0 {src => packages/web/src}/ui/format.ts | 0 .../web/src}/ui/hooks/apply-reconcile.ts | 0 .../web/src}/ui/hooks/library-cache.ts | 0 .../web/src}/ui/hooks/mark-store.ts | 0 .../web/src}/ui/hooks/mark-undo-window.ts | 0 .../web/src}/ui/hooks/query-freshness.ts | 0 .../web/src}/ui/hooks/queue-order.ts | 0 .../web/src}/ui/hooks/resolveUnmark.ts | 0 .../web/src}/ui/hooks/sync-activity-store.ts | 0 .../web/src}/ui/hooks/useActivitiesPoll.ts | 0 .../web/src}/ui/hooks/useBrowse.ts | 0 .../web/src}/ui/hooks/useCalendar.ts | 0 .../web/src}/ui/hooks/useDetailHeader.ts | 0 .../web/src}/ui/hooks/useDocumentTitle.ts | 0 .../web/src}/ui/hooks/useEpisode.ts | 0 .../web/src}/ui/hooks/useEpisodePlays.ts | 0 .../web/src}/ui/hooks/useEpisodeReminders.ts | 0 {src => packages/web/src}/ui/hooks/useFlip.ts | 0 .../web/src}/ui/hooks/useHideShow.ts | 0 .../web/src}/ui/hooks/useHistory.ts | 0 .../web/src}/ui/hooks/useIsOffline.ts | 0 .../web/src}/ui/hooks/useLibraryBuckets.ts | 0 .../web/src}/ui/hooks/useLibrarySnapshot.ts | 0 .../web/src}/ui/hooks/useMarkSeason.ts | 0 .../web/src}/ui/hooks/useMarkSnacks.ts | 0 .../web/src}/ui/hooks/useMarkWatched.ts | 0 .../web/src}/ui/hooks/useMovieActions.ts | 0 .../web/src}/ui/hooks/useMovieDetail.ts | 0 .../web/src}/ui/hooks/useMovieLibrary.ts | 0 .../web/src}/ui/hooks/useMovieRelated.ts | 0 .../web/src}/ui/hooks/useOptimisticWrite.ts | 0 .../web/src}/ui/hooks/useQueuedWrite.ts | 0 .../web/src}/ui/hooks/useRemovalSnacks.ts | 0 .../web/src}/ui/hooks/useResumeOnMark.ts | 0 .../web/src}/ui/hooks/useSearch.ts | 0 .../web/src}/ui/hooks/useSeasonReversal.ts | 0 .../web/src}/ui/hooks/useSeasons.ts | 0 .../web/src}/ui/hooks/useShowArt.ts | 0 .../web/src}/ui/hooks/useShowDetail.ts | 0 .../web/src}/ui/hooks/useStats.ts | 0 .../web/src}/ui/hooks/useSyncNow.ts | 0 .../web/src}/ui/hooks/useToggleWatchlist.ts | 0 .../web/src}/ui/hooks/useUpNext.ts | 0 .../web/src}/ui/hooks/useUserProfile.ts | 0 .../web/src}/ui/hooks/useWatchlistAdd.ts | 0 .../web/src}/ui/prefs/device-prefs.ts | 0 .../web/src}/ui/prefs/media-visibility.ts | 0 .../web/src}/ui/prefs/pref-storage.ts | 0 .../web/src}/ui/prefs/prefs-store.ts | 0 .../web/src}/ui/prefs/threshold.ts | 0 .../web/src}/ui/prefs/tracking.ts | 0 .../web/src}/ui/runtime/app-version.ts | 0 .../web/src}/ui/runtime/haptics.ts | 0 .../web/src}/ui/runtime/persist-buster.ts | 0 .../web/src}/ui/runtime/reminders.ts | 0 .../web/src}/ui/runtime/runtime.ts | 0 .../web/src}/ui/screens/calendar/Calendar.tsx | 0 .../ui/screens/calendar/CalendarAgenda.tsx | 0 .../src}/ui/screens/calendar/CalendarRow.tsx | 0 .../web/src}/ui/screens/calendar/agenda.ts | 0 .../screens/episode-detail/EpisodeSheet.tsx | 0 .../ui/screens/episode-detail/sheet-logic.ts | 0 .../ui/screens/episode-detail/sheet-return.ts | 0 .../web/src}/ui/screens/history/History.tsx | 0 .../src}/ui/screens/history/HistorySearch.tsx | 0 .../ui/screens/history/MonthJumpSheet.tsx | 0 .../src}/ui/screens/history/history-view.ts | 0 .../web/src}/ui/screens/library/Library.tsx | 0 .../ui/screens/library/LibrarySkeleton.tsx | 0 .../web/src}/ui/screens/library/MovieTile.tsx | 0 .../src}/ui/screens/library/PosterGrid.tsx | 0 .../web/src}/ui/screens/library/ShowTile.tsx | 0 .../ui/screens/movie-detail/MovieDetail.tsx | 0 .../src}/ui/screens/onboarding/Onboarding.tsx | 0 .../web/src}/ui/screens/profile/Profile.tsx | 0 .../web/src}/ui/screens/profile/stats.ts | 0 .../web/src}/ui/screens/search/HitTile.tsx | 0 .../web/src}/ui/screens/search/ResultRow.tsx | 0 .../web/src}/ui/screens/search/Search.tsx | 0 .../src}/ui/screens/search/SearchField.tsx | 0 .../src}/ui/screens/settings/SettingRow.tsx | 0 .../web/src}/ui/screens/settings/Settings.tsx | 0 .../src}/ui/screens/settings/SignOutRow.tsx | 0 .../src}/ui/screens/settings/sync-status.ts | 0 .../src}/ui/screens/settings/useSyncStatus.ts | 0 .../ui/screens/show-detail/ContinueBar.tsx | 0 .../ui/screens/show-detail/DetailChrome.tsx | 0 .../ui/screens/show-detail/SeasonList.tsx | 0 .../ui/screens/show-detail/ShowDetail.tsx | 0 .../ui/screens/show-detail/detail-logic.ts | 0 .../src}/ui/screens/up-next/LapsedDrawer.tsx | 0 .../web/src}/ui/screens/up-next/OnTheWay.tsx | 0 .../web/src}/ui/screens/up-next/Poster.tsx | 0 .../src}/ui/screens/up-next/Previously.tsx | 0 .../web/src}/ui/screens/up-next/QueueRow.tsx | 0 .../web/src}/ui/screens/up-next/UpNext.tsx | 0 .../src}/ui/screens/up-next/useQueueCheck.ts | 0 {src => packages/web/src}/ui/styles.css | 0 {src => packages/web/src}/ui/styles/base.css | 0 .../web/src}/ui/styles/components.css | 0 .../web/src}/ui/styles/layout.css | 0 .../web/src}/ui/styles/screens-account.css | 0 .../web/src}/ui/styles/screens-calendar.css | 0 .../web/src}/ui/styles/screens-detail.css | 0 .../web/src}/ui/styles/screens-library.css | 0 .../web/src}/ui/styles/screens-search.css | 0 .../web/src}/ui/styles/screens.css | 0 .../web/src}/ui/theme/ThemeToggle.tsx | 0 .../web/src}/ui/theme/theme-store.ts | 0 {src => packages/web/src}/ui/theme/theme.ts | 0 {src => packages/web/src}/vite-env.d.ts | 0 .../web/test}/ci/diff-footprint.test.ts | 24 +++--- .../web/test}/ci/git-env.test.ts | 0 .../web/test}/ci/release-paths.test.ts | 26 +++--- {test => packages/web/test}/data/_msw.ts | 0 .../web/test}/data/auth-oauth.test.ts | 0 .../web/test}/data/auth-pkce.test.ts | 0 .../web/test}/data/authorized-fetch.test.ts | 0 .../web/test}/data/image-source.test.ts | 0 .../web/test}/data/query-invalidation.test.ts | 0 .../web/test}/data/query-keys.test.ts | 0 .../web/test}/data/read-budget.test.ts | 0 .../web/test}/data/trakt-calendar.test.ts | 0 .../web/test}/data/trakt-client.test.ts | 0 .../web/test}/data/trakt-endpoints.test.ts | 0 .../test}/data/trakt-episode-detail.test.ts | 0 .../web/test}/data/trakt-history.test.ts | 0 .../web/test}/data/trakt-library.test.ts | 0 .../test}/data/trakt-movie-library.test.ts | 0 .../test}/data/trakt-pooled-endpoints.test.ts | 0 .../web/test}/data/trakt-repositories.test.ts | 0 .../web/test}/data/trakt-search.test.ts | 0 .../web/test}/data/trakt-show-detail.test.ts | 0 .../web/test}/data/trakt-transport.test.ts | 0 .../web/test}/data/trakt-user-profile.test.ts | 0 .../web/test}/domain/_helpers.ts | 0 .../web/test}/domain/auth-token.test.ts | 0 .../web/test}/domain/calendar.test.ts | 0 .../web/test}/domain/history.test.ts | 0 .../web/test}/domain/library-buckets.test.ts | 0 .../web/test}/domain/recently-aired.test.ts | 0 .../web/test}/domain/reminders.test.ts | 0 .../web/test}/domain/reversal.test.ts | 0 .../web/test}/domain/sync-activities.test.ts | 0 .../web/test}/domain/time.test.ts | 0 .../web/test}/domain/up-next.test.ts | 0 .../web/test}/domain/watch-status.test.ts | 0 .../web/test}/domain/write-queue-bulk.test.ts | 0 .../test}/domain/write-queue-classify.test.ts | 0 .../test}/domain/write-queue-coalesce.test.ts | 0 .../web/test}/domain/write-queue-ops.test.ts | 0 .../test}/domain/write-queue-queue.test.ts | 0 .../web/test}/harness/mock-trakt.test.ts | 2 +- {test => packages/web/test}/native.test.ts | 0 .../web/test}/platform/back-button.test.ts | 0 .../web/test}/platform/haptics.test.ts | 0 .../web/test}/platform/json-store.test.ts | 0 .../web/test}/platform/native-feel.test.ts | 0 .../web/test}/platform/platform.test.ts | 0 .../web/test}/platform/reminders.test.ts | 0 .../web/test}/privacy-claims.test.ts | 16 ++-- {test => packages/web/test}/setup.ts | 0 .../support/capacitor-preferences-mock.ts | 0 .../test}/support/composition-root-mocks.tsx | 0 .../web/test}/support/git-env.ts | 0 {test => packages/web/test}/ui/_mount.tsx | 0 .../web/test}/ui/activities-poll.test.tsx | 0 .../web/test}/ui/calendar-agenda.test.ts | 0 .../web/test}/ui/continue-bar.test.tsx | 0 .../web/test}/ui/countdown-format.test.ts | 0 .../web/test}/ui/detail-logic.test.ts | 0 .../test}/ui/detail-unmark-resolution.test.ts | 0 .../web/test}/ui/episode-reminders.test.tsx | 0 {test => packages/web/test}/ui/format.test.ts | 0 .../web/test}/ui/gesture-intent.test.ts | 0 .../web/test}/ui/history-view.test.ts | 0 .../web/test}/ui/library-chips.test.ts | 0 .../web/test}/ui/library-snapshot.test.tsx | 0 .../web/test}/ui/long-press-math.test.ts | 0 .../web/test}/ui/mark-pipeline.test.tsx | 0 .../web/test}/ui/mark-store.test.ts | 0 .../web/test}/ui/mark-undo-window.test.ts | 0 .../web/test}/ui/media-visibility.test.ts | 0 .../web/test}/ui/on-the-way.test.ts | 0 .../web/test}/ui/onboarding-screen.test.tsx | 0 .../web/test}/ui/optimistic-write.test.ts | 0 .../web/test}/ui/overlay-primitives.test.tsx | 0 .../web/test}/ui/poster.test.tsx | 0 .../web/test}/ui/pref-storage.test.ts | 0 .../web/test}/ui/profile-stats.test.ts | 0 .../web/test}/ui/pull-math.test.ts | 0 .../web/test}/ui/pull-to-refresh.test.tsx | 0 .../web/test}/ui/queue-order.test.ts | 0 .../web/test}/ui/resolve-movie-unmark.test.ts | 0 .../web/test}/ui/search-visibility.test.ts | 0 .../web/test}/ui/settings-sync-status.test.ts | 0 .../test}/ui/settings-version-web.test.tsx | 0 .../web/test}/ui/settings-version.test.tsx | 0 .../web/test}/ui/sheet-logic.test.ts | 0 .../web/test}/ui/sheet-math.test.ts | 0 .../web/test}/ui/swipe-action.test.tsx | 0 .../web/test}/ui/swipe-math.test.ts | 0 .../web/test}/ui/sync-strip-pending.test.tsx | 0 .../web/test}/ui/use-calendar.test.tsx | 0 .../web/test}/ui/use-sync-status.test.tsx | 0 .../web/test}/ui/watchlist-add.test.tsx | 0 packages/web/tsconfig.json | 17 ++++ vite.config.ts => packages/web/vite.config.ts | 0 packages/web/vitest.config.ts | 25 ++++++ pnpm-lock.yaml | 85 ++++++++++++------- pnpm-workspace.yaml | 9 ++ scripts/diff-footprint.sh | 14 +-- scripts/verify-bundle.sh | 16 ++-- scripts/write-buster.mjs | 8 +- tsconfig.base.json | 28 ++++++ tsconfig.depcruise.json | 14 +++ tsconfig.json | 51 ++--------- vitest.config.ts | 27 ++---- 374 files changed, 371 insertions(+), 236 deletions(-) rename .env.example => packages/web/.env.example (100%) rename .env.mock => packages/web/.env.mock (100%) rename .env.test => packages/web/.env.test (100%) rename {e2e => packages/web/e2e}/art-budget.spec.ts (100%) rename {e2e => packages/web/e2e}/auth.spec.ts (100%) rename {e2e => packages/web/e2e}/content-visibility.spec.ts (100%) rename {e2e => packages/web/e2e}/episode-detail.spec.ts (100%) rename {e2e => packages/web/e2e}/frame.spec.ts (100%) rename {e2e => packages/web/e2e}/global-setup.ts (100%) rename {e2e => packages/web/e2e}/helpers.ts (100%) rename {e2e => packages/web/e2e}/history.spec.ts (100%) rename {e2e => packages/web/e2e}/movies.spec.ts (100%) rename {e2e => packages/web/e2e}/my-shows.spec.ts (100%) rename {e2e => packages/web/e2e}/persistence.spec.ts (100%) rename {e2e => packages/web/e2e}/profile.spec.ts (100%) rename {e2e => packages/web/e2e}/pull-to-refresh.spec.ts (100%) rename {e2e => packages/web/e2e}/ratings-watchlist.spec.ts (100%) rename {e2e => packages/web/e2e}/reflow.spec.ts (100%) rename {e2e => packages/web/e2e}/search.spec.ts (100%) rename {e2e => packages/web/e2e}/show-detail.spec.ts (100%) rename {e2e => packages/web/e2e}/swipe.spec.ts (100%) rename {e2e => packages/web/e2e}/sync.spec.ts (100%) rename {e2e => packages/web/e2e}/token-refresh.spec.ts (100%) rename {e2e => packages/web/e2e}/touch-targets.spec.ts (100%) rename {e2e => packages/web/e2e}/up-next.spec.ts (100%) rename {e2e => packages/web/e2e}/upcoming.spec.ts (100%) rename {e2e => packages/web/e2e}/viewport-zoom.spec.ts (100%) rename index.html => packages/web/index.html (100%) create mode 100644 packages/web/package.json rename playwright.config.ts => packages/web/playwright.config.ts (100%) rename {public => packages/web/public}/icon.svg (100%) rename {src => packages/web/src}/app/AuthGate.tsx (100%) rename {src => packages/web/src}/app/auth/create-auth-store.ts (100%) rename {src => packages/web/src}/app/config.ts (100%) rename {src => packages/web/src}/app/main.tsx (100%) rename {src => packages/web/src}/app/persist.ts (100%) rename {src => packages/web/src}/app/providers.tsx (100%) rename {src => packages/web/src}/app/query-client.ts (100%) rename {src => packages/web/src}/app/router.tsx (100%) rename {src => packages/web/src}/app/routes/auth-callback.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/calendar.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/episode.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/history.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/library.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/movie.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/profile.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/search.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/settings.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/show.lazy.tsx (100%) rename {src => packages/web/src}/app/routes/up-next.lazy.tsx (100%) rename {src => packages/web/src}/app/runtime/RuntimeBoot.tsx (100%) rename {src => packages/web/src}/app/runtime/create-runtime.ts (100%) rename {src => packages/web/src}/app/session.ts (100%) rename {src => packages/web/src}/data/auth/oauth.ts (100%) rename {src => packages/web/src}/data/auth/pkce.ts (100%) rename {src => packages/web/src}/data/image-source.ts (100%) rename {src => packages/web/src}/data/query-invalidation.ts (100%) rename {src => packages/web/src}/data/query-keys.ts (100%) rename {src => packages/web/src}/data/trakt/authorized-fetch.ts (100%) rename {src => packages/web/src}/data/trakt/calendar.ts (100%) rename {src => packages/web/src}/data/trakt/client.ts (100%) rename {src => packages/web/src}/data/trakt/endpoints.ts (100%) rename {src => packages/web/src}/data/trakt/episode-detail.ts (100%) rename {src => packages/web/src}/data/trakt/history.ts (100%) rename {src => packages/web/src}/data/trakt/library.ts (100%) rename {src => packages/web/src}/data/trakt/movie-library.ts (100%) rename {src => packages/web/src}/data/trakt/pooled-endpoints.ts (100%) rename {src => packages/web/src}/data/trakt/read-budget.ts (100%) rename {src => packages/web/src}/data/trakt/repositories.ts (100%) rename {src => packages/web/src}/data/trakt/schemas.ts (100%) rename {src => packages/web/src}/data/trakt/search.ts (100%) rename {src => packages/web/src}/data/trakt/show-detail.ts (100%) rename {src => packages/web/src}/data/trakt/transport.ts (100%) rename {src => packages/web/src}/data/trakt/user-profile.ts (100%) rename {src => packages/web/src}/domain/auth/token.ts (100%) rename {src => packages/web/src}/domain/calendar.ts (100%) rename {src => packages/web/src}/domain/day.ts (100%) rename {src => packages/web/src}/domain/history.ts (100%) rename {src => packages/web/src}/domain/library-buckets.ts (100%) rename {src => packages/web/src}/domain/model/ids.ts (100%) rename {src => packages/web/src}/domain/model/library.ts (100%) rename {src => packages/web/src}/domain/model/token.ts (100%) rename {src => packages/web/src}/domain/ports/haptics.ts (100%) rename {src => packages/web/src}/domain/ports/reminders.ts (100%) rename {src => packages/web/src}/domain/recently-aired.ts (100%) rename {src => packages/web/src}/domain/reminders.ts (100%) rename {src => packages/web/src}/domain/reversal.ts (100%) rename {src => packages/web/src}/domain/sync-activities.ts (100%) rename {src => packages/web/src}/domain/time.ts (100%) rename {src => packages/web/src}/domain/up-next.ts (100%) rename {src => packages/web/src}/domain/watch-status.ts (100%) rename {src => packages/web/src}/domain/write-queue/bulk.ts (100%) rename {src => packages/web/src}/domain/write-queue/classify.ts (100%) rename {src => packages/web/src}/domain/write-queue/coalesce.ts (100%) rename {src => packages/web/src}/domain/write-queue/ops.ts (100%) rename {src => packages/web/src}/domain/write-queue/queue.ts (100%) rename {src => packages/web/src}/domain/write-queue/types.ts (100%) rename {src => packages/web/src}/platform/app-version.ts (100%) rename {src => packages/web/src}/platform/back-button.ts (100%) rename {src => packages/web/src}/platform/haptics.ts (100%) rename {src => packages/web/src}/platform/json-store.ts (100%) rename {src => packages/web/src}/platform/kv.ts (100%) rename {src => packages/web/src}/platform/platform.ts (100%) rename {src => packages/web/src}/platform/reminders.ts (100%) rename {src => packages/web/src}/platform/status-bar.ts (100%) rename {src => packages/web/src}/platform/token-store.ts (100%) rename {src => packages/web/src}/ui/app-shell/CueMark.tsx (100%) rename {src => packages/web/src}/ui/app-shell/ErrorBoundary.tsx (100%) rename {src => packages/web/src}/ui/app-shell/RootLayout.tsx (100%) rename {src => packages/web/src}/ui/app-shell/ScreenHeader.tsx (100%) rename {src => packages/web/src}/ui/app-shell/SyncStrip.tsx (100%) rename {src => packages/web/src}/ui/app-shell/nav.ts (100%) rename {src => packages/web/src}/ui/assets/README.md (100%) rename {src => packages/web/src}/ui/assets/trakt-logo.svg (100%) rename {src => packages/web/src}/ui/auth/store.ts (100%) rename {src => packages/web/src}/ui/components/ActionSheet.tsx (100%) rename {src => packages/web/src}/ui/components/AppSnackbar.tsx (100%) rename {src => packages/web/src}/ui/components/ArtPlaceholder.tsx (100%) rename {src => packages/web/src}/ui/components/Badge.tsx (100%) rename {src => packages/web/src}/ui/components/CheckControl.tsx (100%) rename {src => packages/web/src}/ui/components/Chip.tsx (100%) rename {src => packages/web/src}/ui/components/ConfirmSheet.tsx (100%) rename {src => packages/web/src}/ui/components/ContextMenu.tsx (100%) rename {src => packages/web/src}/ui/components/CountdownPanel.tsx (100%) rename {src => packages/web/src}/ui/components/DetailHeroSkeleton.tsx (100%) rename {src => packages/web/src}/ui/components/EmptyState.tsx (100%) rename {src => packages/web/src}/ui/components/EpisodeRow.tsx (100%) rename {src => packages/web/src}/ui/components/ErrorStates.tsx (100%) rename {src => packages/web/src}/ui/components/MarqueeCard.tsx (100%) rename {src => packages/web/src}/ui/components/PosterTile.tsx (100%) rename {src => packages/web/src}/ui/components/ProgressBar.tsx (100%) rename {src => packages/web/src}/ui/components/PullToRefresh.tsx (100%) rename {src => packages/web/src}/ui/components/SectionHeader.tsx (100%) rename {src => packages/web/src}/ui/components/SegmentedControl.tsx (100%) rename {src => packages/web/src}/ui/components/Sheet.tsx (100%) rename {src => packages/web/src}/ui/components/Skeletons.tsx (100%) rename {src => packages/web/src}/ui/components/SwipeAction.tsx (100%) rename {src => packages/web/src}/ui/components/TutorialCaption.tsx (100%) rename {src => packages/web/src}/ui/components/artGradient.ts (100%) rename {src => packages/web/src}/ui/components/gesture-intent.ts (100%) rename {src => packages/web/src}/ui/components/long-press-math.ts (100%) rename {src => packages/web/src}/ui/components/pull-math.ts (100%) rename {src => packages/web/src}/ui/components/sheet-math.ts (100%) rename {src => packages/web/src}/ui/components/snackbar-store.ts (100%) rename {src => packages/web/src}/ui/components/swipe-math.ts (100%) rename {src => packages/web/src}/ui/format.ts (100%) rename {src => packages/web/src}/ui/hooks/apply-reconcile.ts (100%) rename {src => packages/web/src}/ui/hooks/library-cache.ts (100%) rename {src => packages/web/src}/ui/hooks/mark-store.ts (100%) rename {src => packages/web/src}/ui/hooks/mark-undo-window.ts (100%) rename {src => packages/web/src}/ui/hooks/query-freshness.ts (100%) rename {src => packages/web/src}/ui/hooks/queue-order.ts (100%) rename {src => packages/web/src}/ui/hooks/resolveUnmark.ts (100%) rename {src => packages/web/src}/ui/hooks/sync-activity-store.ts (100%) rename {src => packages/web/src}/ui/hooks/useActivitiesPoll.ts (100%) rename {src => packages/web/src}/ui/hooks/useBrowse.ts (100%) rename {src => packages/web/src}/ui/hooks/useCalendar.ts (100%) rename {src => packages/web/src}/ui/hooks/useDetailHeader.ts (100%) rename {src => packages/web/src}/ui/hooks/useDocumentTitle.ts (100%) rename {src => packages/web/src}/ui/hooks/useEpisode.ts (100%) rename {src => packages/web/src}/ui/hooks/useEpisodePlays.ts (100%) rename {src => packages/web/src}/ui/hooks/useEpisodeReminders.ts (100%) rename {src => packages/web/src}/ui/hooks/useFlip.ts (100%) rename {src => packages/web/src}/ui/hooks/useHideShow.ts (100%) rename {src => packages/web/src}/ui/hooks/useHistory.ts (100%) rename {src => packages/web/src}/ui/hooks/useIsOffline.ts (100%) rename {src => packages/web/src}/ui/hooks/useLibraryBuckets.ts (100%) rename {src => packages/web/src}/ui/hooks/useLibrarySnapshot.ts (100%) rename {src => packages/web/src}/ui/hooks/useMarkSeason.ts (100%) rename {src => packages/web/src}/ui/hooks/useMarkSnacks.ts (100%) rename {src => packages/web/src}/ui/hooks/useMarkWatched.ts (100%) rename {src => packages/web/src}/ui/hooks/useMovieActions.ts (100%) rename {src => packages/web/src}/ui/hooks/useMovieDetail.ts (100%) rename {src => packages/web/src}/ui/hooks/useMovieLibrary.ts (100%) rename {src => packages/web/src}/ui/hooks/useMovieRelated.ts (100%) rename {src => packages/web/src}/ui/hooks/useOptimisticWrite.ts (100%) rename {src => packages/web/src}/ui/hooks/useQueuedWrite.ts (100%) rename {src => packages/web/src}/ui/hooks/useRemovalSnacks.ts (100%) rename {src => packages/web/src}/ui/hooks/useResumeOnMark.ts (100%) rename {src => packages/web/src}/ui/hooks/useSearch.ts (100%) rename {src => packages/web/src}/ui/hooks/useSeasonReversal.ts (100%) rename {src => packages/web/src}/ui/hooks/useSeasons.ts (100%) rename {src => packages/web/src}/ui/hooks/useShowArt.ts (100%) rename {src => packages/web/src}/ui/hooks/useShowDetail.ts (100%) rename {src => packages/web/src}/ui/hooks/useStats.ts (100%) rename {src => packages/web/src}/ui/hooks/useSyncNow.ts (100%) rename {src => packages/web/src}/ui/hooks/useToggleWatchlist.ts (100%) rename {src => packages/web/src}/ui/hooks/useUpNext.ts (100%) rename {src => packages/web/src}/ui/hooks/useUserProfile.ts (100%) rename {src => packages/web/src}/ui/hooks/useWatchlistAdd.ts (100%) rename {src => packages/web/src}/ui/prefs/device-prefs.ts (100%) rename {src => packages/web/src}/ui/prefs/media-visibility.ts (100%) rename {src => packages/web/src}/ui/prefs/pref-storage.ts (100%) rename {src => packages/web/src}/ui/prefs/prefs-store.ts (100%) rename {src => packages/web/src}/ui/prefs/threshold.ts (100%) rename {src => packages/web/src}/ui/prefs/tracking.ts (100%) rename {src => packages/web/src}/ui/runtime/app-version.ts (100%) rename {src => packages/web/src}/ui/runtime/haptics.ts (100%) rename {src => packages/web/src}/ui/runtime/persist-buster.ts (100%) rename {src => packages/web/src}/ui/runtime/reminders.ts (100%) rename {src => packages/web/src}/ui/runtime/runtime.ts (100%) rename {src => packages/web/src}/ui/screens/calendar/Calendar.tsx (100%) rename {src => packages/web/src}/ui/screens/calendar/CalendarAgenda.tsx (100%) rename {src => packages/web/src}/ui/screens/calendar/CalendarRow.tsx (100%) rename {src => packages/web/src}/ui/screens/calendar/agenda.ts (100%) rename {src => packages/web/src}/ui/screens/episode-detail/EpisodeSheet.tsx (100%) rename {src => packages/web/src}/ui/screens/episode-detail/sheet-logic.ts (100%) rename {src => packages/web/src}/ui/screens/episode-detail/sheet-return.ts (100%) rename {src => packages/web/src}/ui/screens/history/History.tsx (100%) rename {src => packages/web/src}/ui/screens/history/HistorySearch.tsx (100%) rename {src => packages/web/src}/ui/screens/history/MonthJumpSheet.tsx (100%) rename {src => packages/web/src}/ui/screens/history/history-view.ts (100%) rename {src => packages/web/src}/ui/screens/library/Library.tsx (100%) rename {src => packages/web/src}/ui/screens/library/LibrarySkeleton.tsx (100%) rename {src => packages/web/src}/ui/screens/library/MovieTile.tsx (100%) rename {src => packages/web/src}/ui/screens/library/PosterGrid.tsx (100%) rename {src => packages/web/src}/ui/screens/library/ShowTile.tsx (100%) rename {src => packages/web/src}/ui/screens/movie-detail/MovieDetail.tsx (100%) rename {src => packages/web/src}/ui/screens/onboarding/Onboarding.tsx (100%) rename {src => packages/web/src}/ui/screens/profile/Profile.tsx (100%) rename {src => packages/web/src}/ui/screens/profile/stats.ts (100%) rename {src => packages/web/src}/ui/screens/search/HitTile.tsx (100%) rename {src => packages/web/src}/ui/screens/search/ResultRow.tsx (100%) rename {src => packages/web/src}/ui/screens/search/Search.tsx (100%) rename {src => packages/web/src}/ui/screens/search/SearchField.tsx (100%) rename {src => packages/web/src}/ui/screens/settings/SettingRow.tsx (100%) rename {src => packages/web/src}/ui/screens/settings/Settings.tsx (100%) rename {src => packages/web/src}/ui/screens/settings/SignOutRow.tsx (100%) rename {src => packages/web/src}/ui/screens/settings/sync-status.ts (100%) rename {src => packages/web/src}/ui/screens/settings/useSyncStatus.ts (100%) rename {src => packages/web/src}/ui/screens/show-detail/ContinueBar.tsx (100%) rename {src => packages/web/src}/ui/screens/show-detail/DetailChrome.tsx (100%) rename {src => packages/web/src}/ui/screens/show-detail/SeasonList.tsx (100%) rename {src => packages/web/src}/ui/screens/show-detail/ShowDetail.tsx (100%) rename {src => packages/web/src}/ui/screens/show-detail/detail-logic.ts (100%) rename {src => packages/web/src}/ui/screens/up-next/LapsedDrawer.tsx (100%) rename {src => packages/web/src}/ui/screens/up-next/OnTheWay.tsx (100%) rename {src => packages/web/src}/ui/screens/up-next/Poster.tsx (100%) rename {src => packages/web/src}/ui/screens/up-next/Previously.tsx (100%) rename {src => packages/web/src}/ui/screens/up-next/QueueRow.tsx (100%) rename {src => packages/web/src}/ui/screens/up-next/UpNext.tsx (100%) rename {src => packages/web/src}/ui/screens/up-next/useQueueCheck.ts (100%) rename {src => packages/web/src}/ui/styles.css (100%) rename {src => packages/web/src}/ui/styles/base.css (100%) rename {src => packages/web/src}/ui/styles/components.css (100%) rename {src => packages/web/src}/ui/styles/layout.css (100%) rename {src => packages/web/src}/ui/styles/screens-account.css (100%) rename {src => packages/web/src}/ui/styles/screens-calendar.css (100%) rename {src => packages/web/src}/ui/styles/screens-detail.css (100%) rename {src => packages/web/src}/ui/styles/screens-library.css (100%) rename {src => packages/web/src}/ui/styles/screens-search.css (100%) rename {src => packages/web/src}/ui/styles/screens.css (100%) rename {src => packages/web/src}/ui/theme/ThemeToggle.tsx (100%) rename {src => packages/web/src}/ui/theme/theme-store.ts (100%) rename {src => packages/web/src}/ui/theme/theme.ts (100%) rename {src => packages/web/src}/vite-env.d.ts (100%) rename {test => packages/web/test}/ci/diff-footprint.test.ts (72%) rename {test => packages/web/test}/ci/git-env.test.ts (100%) rename {test => packages/web/test}/ci/release-paths.test.ts (94%) rename {test => packages/web/test}/data/_msw.ts (100%) rename {test => packages/web/test}/data/auth-oauth.test.ts (100%) rename {test => packages/web/test}/data/auth-pkce.test.ts (100%) rename {test => packages/web/test}/data/authorized-fetch.test.ts (100%) rename {test => packages/web/test}/data/image-source.test.ts (100%) rename {test => packages/web/test}/data/query-invalidation.test.ts (100%) rename {test => packages/web/test}/data/query-keys.test.ts (100%) rename {test => packages/web/test}/data/read-budget.test.ts (100%) rename {test => packages/web/test}/data/trakt-calendar.test.ts (100%) rename {test => packages/web/test}/data/trakt-client.test.ts (100%) rename {test => packages/web/test}/data/trakt-endpoints.test.ts (100%) rename {test => packages/web/test}/data/trakt-episode-detail.test.ts (100%) rename {test => packages/web/test}/data/trakt-history.test.ts (100%) rename {test => packages/web/test}/data/trakt-library.test.ts (100%) rename {test => packages/web/test}/data/trakt-movie-library.test.ts (100%) rename {test => packages/web/test}/data/trakt-pooled-endpoints.test.ts (100%) rename {test => packages/web/test}/data/trakt-repositories.test.ts (100%) rename {test => packages/web/test}/data/trakt-search.test.ts (100%) rename {test => packages/web/test}/data/trakt-show-detail.test.ts (100%) rename {test => packages/web/test}/data/trakt-transport.test.ts (100%) rename {test => packages/web/test}/data/trakt-user-profile.test.ts (100%) rename {test => packages/web/test}/domain/_helpers.ts (100%) rename {test => packages/web/test}/domain/auth-token.test.ts (100%) rename {test => packages/web/test}/domain/calendar.test.ts (100%) rename {test => packages/web/test}/domain/history.test.ts (100%) rename {test => packages/web/test}/domain/library-buckets.test.ts (100%) rename {test => packages/web/test}/domain/recently-aired.test.ts (100%) rename {test => packages/web/test}/domain/reminders.test.ts (100%) rename {test => packages/web/test}/domain/reversal.test.ts (100%) rename {test => packages/web/test}/domain/sync-activities.test.ts (100%) rename {test => packages/web/test}/domain/time.test.ts (100%) rename {test => packages/web/test}/domain/up-next.test.ts (100%) rename {test => packages/web/test}/domain/watch-status.test.ts (100%) rename {test => packages/web/test}/domain/write-queue-bulk.test.ts (100%) rename {test => packages/web/test}/domain/write-queue-classify.test.ts (100%) rename {test => packages/web/test}/domain/write-queue-coalesce.test.ts (100%) rename {test => packages/web/test}/domain/write-queue-ops.test.ts (100%) rename {test => packages/web/test}/domain/write-queue-queue.test.ts (100%) rename {test => packages/web/test}/harness/mock-trakt.test.ts (99%) rename {test => packages/web/test}/native.test.ts (100%) rename {test => packages/web/test}/platform/back-button.test.ts (100%) rename {test => packages/web/test}/platform/haptics.test.ts (100%) rename {test => packages/web/test}/platform/json-store.test.ts (100%) rename {test => packages/web/test}/platform/native-feel.test.ts (100%) rename {test => packages/web/test}/platform/platform.test.ts (100%) rename {test => packages/web/test}/platform/reminders.test.ts (100%) rename {test => packages/web/test}/privacy-claims.test.ts (96%) rename {test => packages/web/test}/setup.ts (100%) rename {test => packages/web/test}/support/capacitor-preferences-mock.ts (100%) rename {test => packages/web/test}/support/composition-root-mocks.tsx (100%) rename {test => packages/web/test}/support/git-env.ts (100%) rename {test => packages/web/test}/ui/_mount.tsx (100%) rename {test => packages/web/test}/ui/activities-poll.test.tsx (100%) rename {test => packages/web/test}/ui/calendar-agenda.test.ts (100%) rename {test => packages/web/test}/ui/continue-bar.test.tsx (100%) rename {test => packages/web/test}/ui/countdown-format.test.ts (100%) rename {test => packages/web/test}/ui/detail-logic.test.ts (100%) rename {test => packages/web/test}/ui/detail-unmark-resolution.test.ts (100%) rename {test => packages/web/test}/ui/episode-reminders.test.tsx (100%) rename {test => packages/web/test}/ui/format.test.ts (100%) rename {test => packages/web/test}/ui/gesture-intent.test.ts (100%) rename {test => packages/web/test}/ui/history-view.test.ts (100%) rename {test => packages/web/test}/ui/library-chips.test.ts (100%) rename {test => packages/web/test}/ui/library-snapshot.test.tsx (100%) rename {test => packages/web/test}/ui/long-press-math.test.ts (100%) rename {test => packages/web/test}/ui/mark-pipeline.test.tsx (100%) rename {test => packages/web/test}/ui/mark-store.test.ts (100%) rename {test => packages/web/test}/ui/mark-undo-window.test.ts (100%) rename {test => packages/web/test}/ui/media-visibility.test.ts (100%) rename {test => packages/web/test}/ui/on-the-way.test.ts (100%) rename {test => packages/web/test}/ui/onboarding-screen.test.tsx (100%) rename {test => packages/web/test}/ui/optimistic-write.test.ts (100%) rename {test => packages/web/test}/ui/overlay-primitives.test.tsx (100%) rename {test => packages/web/test}/ui/poster.test.tsx (100%) rename {test => packages/web/test}/ui/pref-storage.test.ts (100%) rename {test => packages/web/test}/ui/profile-stats.test.ts (100%) rename {test => packages/web/test}/ui/pull-math.test.ts (100%) rename {test => packages/web/test}/ui/pull-to-refresh.test.tsx (100%) rename {test => packages/web/test}/ui/queue-order.test.ts (100%) rename {test => packages/web/test}/ui/resolve-movie-unmark.test.ts (100%) rename {test => packages/web/test}/ui/search-visibility.test.ts (100%) rename {test => packages/web/test}/ui/settings-sync-status.test.ts (100%) rename {test => packages/web/test}/ui/settings-version-web.test.tsx (100%) rename {test => packages/web/test}/ui/settings-version.test.tsx (100%) rename {test => packages/web/test}/ui/sheet-logic.test.ts (100%) rename {test => packages/web/test}/ui/sheet-math.test.ts (100%) rename {test => packages/web/test}/ui/swipe-action.test.tsx (100%) rename {test => packages/web/test}/ui/swipe-math.test.ts (100%) rename {test => packages/web/test}/ui/sync-strip-pending.test.tsx (100%) rename {test => packages/web/test}/ui/use-calendar.test.tsx (100%) rename {test => packages/web/test}/ui/use-sync-status.test.tsx (100%) rename {test => packages/web/test}/ui/watchlist-add.test.tsx (100%) create mode 100644 packages/web/tsconfig.json rename vite.config.ts => packages/web/vite.config.ts (100%) create mode 100644 packages/web/vitest.config.ts create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 tsconfig.depcruise.json diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index f9cb4028..4ce4adc9 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -6,10 +6,13 @@ const RE_REACT = "(^|/)node_modules/react/"; const RE_REACT_DOM = "(^|/)node_modules/react-dom/"; const RE_CAPACITOR = "(^|/)node_modules/@capacitor/"; -const RE_DOES_NOT_SHIP_DIRECTORY = "^(docs|\\.github|e2e|test|assets|scripts/mock-trakt)(/|$)"; +const RE_DOES_NOT_SHIP_DIRECTORY = + "^(docs|\\.github|assets|scripts/mock-trakt|packages/[^/]+/(e2e|test|__tests__))(/|$)"; const RE_DOES_NOT_SHIP_MARKDOWN = "^[^/]*\\.md$"; const RE_DOES_NOT_SHIP_FILE = - "^(LICENSE|playwright\\.config\\.ts|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.env\\.example|\\.env\\.test|\\.env\\.mock|\\.gitignore|scripts/write-buster\\.mjs)$"; + "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|scripts/write-buster\\.mjs|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|\\.env\\.(example|test|mock)))$"; + +const { join } = require("node:path"); /** @type {import("dependency-cruiser").IConfiguration} */ module.exports = { @@ -37,7 +40,7 @@ module.exports = { severity: "error", comment: "Importing a non-shipping path into src can put it in the production bundle while mobile release paths-ignore still skips changes to it.", - from: { path: "^src/" }, + from: { path: "^packages/[^/]+/src/" }, to: { path: [RE_DOES_NOT_SHIP_DIRECTORY, RE_DOES_NOT_SHIP_MARKDOWN, RE_DOES_NOT_SHIP_FILE], }, @@ -47,17 +50,17 @@ module.exports = { severity: "error", comment: "src/domain is runtime-agnostic: global fetch + zod only. No data/ui/app/platform, no react, no react-dom, no capacitor.", - from: { path: "^src/domain/" }, + from: { path: "^packages/web/src/domain/" }, to: { - path: ["^src/(data|ui|platform|app)/", RE_REACT, RE_REACT_DOM, RE_CAPACITOR], + path: ["^packages/web/src/(data|ui|platform|app)/", RE_REACT, RE_REACT_DOM, RE_CAPACITOR], }, }, { name: "domain-no-node-builtins", severity: "error", comment: - "src/domain must not touch Node built-ins (fs/path/crypto/...); it runs in browser + native.", - from: { path: "^src/domain/" }, + "domain must not touch Node built-ins (fs/path/crypto/...); it runs in browser + native.", + from: { path: "^packages/web/src/domain/" }, to: { dependencyTypes: ["core"] }, }, { @@ -65,16 +68,16 @@ module.exports = { severity: "error", comment: "src/data (clients/repos) may import domain, but never ui/app/platform, react, or react-dom.", - from: { path: "^src/data/" }, - to: { path: ["^src/(ui|app|platform)/", RE_REACT, RE_REACT_DOM] }, + from: { path: "^packages/web/src/data/" }, + to: { path: ["^packages/web/src/(ui|app|platform)/", RE_REACT, RE_REACT_DOM] }, }, { name: "ui-no-platform-impl", severity: "error", comment: "src/ui depends on domain/data abstractions; platform impls and the app composition root are injected, not imported directly.", - from: { path: "^src/ui/" }, - to: { path: "^src/(platform|app)/" }, + from: { path: "^packages/web/src/ui/" }, + to: { path: "^packages/web/src/(platform|app)/" }, }, { name: "trakt-reads-stay-pooled", @@ -87,17 +90,17 @@ module.exports = { "fails here by naming the unpooled importer, instead of only an instance test " + "that a mutation can dodge by pooling one caller and leaving the rest raw.", from: { - path: "^src/", - pathNot: "^src/data/trakt/(read-budget|pooled-endpoints)\\.ts$", + path: "^packages/[^/]+/src/", + pathNot: "^packages/web/src/data/trakt/(read-budget|pooled-endpoints)\\.ts$", }, - to: { path: "^src/data/trakt/endpoints\\.ts$" }, + to: { path: "^packages/web/src/data/trakt/endpoints\\.ts$" }, }, { name: "capacitor-only-in-platform", severity: "error", comment: "@capacitor/* is imported ONLY in src/platform, keeping domain/data/ui/app portable and testable without native mocks.", - from: { path: "^src/", pathNot: "^src/platform/" }, + from: { path: "^packages/[^/]+/src/", pathNot: "^packages/web/src/platform/" }, to: { path: RE_CAPACITOR }, }, ], @@ -106,9 +109,14 @@ module.exports = { // Anchor to project root: an unanchored `(^|/)dist/` also matched // node_modules/@capacitor/core/dist/*, silently excluding capacitor from the // graph so every capacitor ban passed. - exclude: { path: "^(dist|coverage|ios|android)/" }, + exclude: { path: "^(packages/[^/]+/(dist|coverage)|coverage|ios|android)/" }, tsPreCompilationDeps: true, - tsConfig: { fileName: "tsconfig.json" }, + // The web package's aliases, reached through the wrapper that names their + // base directory; tsconfig.depcruise.json says why. Absolute, because + // dependency-cruiser hands this name to TypeScript as both the base path + // and the config name, and a relative one makes an `extends` resolve one + // directory level too deep. + tsConfig: { fileName: join(__dirname, "tsconfig.depcruise.json") }, enhancedResolveOptions: { // Capacitor ships only `main`/`module` (no `exports`), so an // `exportsFields`-only resolver drops it from the graph entirely and every diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13cc58e5..5404467b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-node-pnpm - - run: pnpm exec playwright install --with-deps chromium webkit + - run: pnpm --filter @cue/web exec playwright install --with-deps chromium webkit - run: pnpm e2e - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: name: playwright-report - path: playwright-report/ + path: packages/web/playwright-report/ retention-days: 7 diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index b316da93..8866165c 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -22,9 +22,13 @@ on: "*.md", ".github/**", "LICENSE", - "e2e/**", - "test/**", - "playwright.config.ts", + "packages/*/e2e/**", + "packages/*/test/**", + "packages/*/playwright.config.ts", + "packages/*/vitest.config.ts", + "packages/*/.env.example", + "packages/*/.env.test", + "packages/*/.env.mock", "vitest.config.ts", "lefthook.yml", "cspell.json", @@ -36,9 +40,7 @@ on: "scripts/diff-footprint.sh", "scripts/mock-trakt/**", "scripts/write-buster.mjs", - ".env.example", - ".env.test", - ".env.mock", + "tsconfig.depcruise.json", ".gitignore", "assets/**", ] diff --git a/.jscpd.json b/.jscpd.json index fa69efa1..52483e9f 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -1,6 +1,6 @@ { - "path": ["src", "test"], - "pattern": "**/*.{ts,tsx}", + "path": ["packages"], + "pattern": "*/{src,test}/**/*.{ts,tsx}", "minLines": 8, "threshold": 0 } diff --git a/README.md b/README.md index 0bdc0d86..1eaa6023 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ Cue authenticates as a public OAuth client, so it ships **no secret**: the app a 1. Register a free API app with Trakt. Current instructions live at [docs.trakt.tv](https://docs.trakt.tv). 2. Set its Redirect URI to `http://localhost:5199/auth/callback` for local development, plus `/auth/callback` for deploys. (Trakt matches the redirect URI exactly, so register every origin you serve from.) -3. Copy `.env.example` to `.env` and set `VITE_TRAKT_CLIENT_ID` to the app's **Client ID**. It is public: it ships in the built JS and there is no client secret. +3. Copy `packages/web/.env.example` to `packages/web/.env` and set `VITE_TRAKT_CLIENT_ID` to the app's **Client ID**. It is public: it ships in the built JS and there is no client secret. -The client id is public by design. Cue keeps each user's Trakt OAuth token, settings, and a local data cache on-device. Your real `.env` stays local (gitignored); `.env.example`, `.env.test` and `.env.mock` are the committed placeholders. +The client id is public by design. Cue keeps each user's Trakt OAuth token, settings, and a local data cache on-device. Your real `.env` stays local (gitignored); the committed placeholders `.env.example`, `.env.test` and `.env.mock` sit beside it in `packages/web`. ## Development @@ -37,13 +37,13 @@ Requires Node 22+ and pnpm. ```sh pnpm install # install dependencies pnpm dev # start the Vite dev server -pnpm build # build the static SPA into dist/ +pnpm build # build the static SPA into packages/web/dist/ pnpm check # run the full deterministic check harness pnpm e2e # run the Playwright end-to-end suite pnpm e2e:mobile # run focused Pixel/Chromium + iPhone/WebKit checks ``` -First e2e run only: `pnpm exec playwright install chromium webkit`. +First e2e run only: `pnpm --filter @cue/web exec playwright install chromium webkit`. Use `pnpm e2e:mobile --headed` when you want to watch the mobile browser checks run. @@ -57,10 +57,10 @@ Every e2e run builds the app and starts its own preview server on port 4173. Set - **dprint**: Markdown formatting. - **cspell**: spelling across TS/TSX/CSS/MD. - **tsc**: strict TypeScript type-check (`--noEmit`). -- **dependency-cruiser**: layering rules (`@capacitor/*` confined to `src/platform`). +- **dependency-cruiser**: layering rules (`@capacitor/*` confined to `packages/web/src/platform`), cruised over every package in one pass. - **knip**: no unused files, dependencies, or exports. - **jscpd**: duplicate-code detection. -- **Vitest**: unit tests with coverage thresholds on `src/domain` and `src/data`. +- **Vitest**: unit tests, one project per package, with coverage thresholds on `domain` and `data`. - **Vite build**: a production build must compile. `pnpm e2e` runs the Playwright suite (chromium). `pnpm audit` (high/critical production advisories) is deliberately kept out of `pnpm check` because it reads live advisory state; it runs as its own CI job on every push and on a weekly schedule. @@ -76,17 +76,17 @@ pnpm mock:trakt # serve the fake Trakt on http://127.0.0.1:8787 (MOCK_TRAKT_PORT pnpm dev:mock # run the dev server against it ``` -`--mode mock` loads the committed `.env.mock`, which sets a dummy client id and `VITE_TRAKT_API_BASE=http://127.0.0.1:8787`. That variable is the whole switch: unset, which is every shipped build and every other mode, the app talks to `api.trakt.tv` and `trakt.tv`; set, it talks to the mock instead, sign-in included. `pnpm exec vite build --mode mock` produces the same thing as a static bundle to preview or to `cap sync` into a shell. +`--mode mock` loads the committed `.env.mock`, which sets a dummy client id and `VITE_TRAKT_API_BASE=http://127.0.0.1:8787`. That variable is the whole switch: unset, which is every shipped build and every other mode, the app talks to `api.trakt.tv` and `trakt.tv`; set, it talks to the mock instead, sign-in included. `pnpm --filter @cue/web exec vite build --mode mock` produces the same thing as a static bundle to preview or to `cap sync` into a shell. -Every write the app makes moves the mock's in-memory account: history marks and their removals (by item, by the bulk season subtree, and by history-play id), hiding and unhiding a show, and watchlist adds and removals. So progress, history, the hidden set, the watchlist and the calendar all stay consistent across a session, and a write naming something the seed does not have comes back in `not_found` rather than as a success the account never took. Any endpoint the mock does not model answers 404 with a logged line rather than an empty success, so a missing fixture reads as a hole instead of an account with nothing in it. Deliberately absent: the browse rails, served as the empty lists a demo account has; search, which is not modelled at all, so typing into it reaches the app's error state rather than an empty one; and rate limiting and failure of any kind, since the mock authorizes anybody and never answers 429. `test/harness/mock-trakt.test.ts` boots the mock in-process and reads every seeded endpoint back through the app's own client and zod contracts, which is what keeps the two from drifting. +Every write the app makes moves the mock's in-memory account: history marks and their removals (by item, by the bulk season subtree, and by history-play id), hiding and unhiding a show, and watchlist adds and removals. So progress, history, the hidden set, the watchlist and the calendar all stay consistent across a session, and a write naming something the seed does not have comes back in `not_found` rather than as a success the account never took. Any endpoint the mock does not model answers 404 with a logged line rather than an empty success, so a missing fixture reads as a hole instead of an account with nothing in it. Deliberately absent: the browse rails, served as the empty lists a demo account has; search, which is not modelled at all, so typing into it reaches the app's error state rather than an empty one; and rate limiting and failure of any kind, since the mock authorizes anybody and never answers 429. `packages/web/test/harness/mock-trakt.test.ts` boots the mock in-process and reads every seeded endpoint back through the app's own client and zod contracts, which is what keeps the two from drifting. -The Trakt wire shapes are built twice, here and in `e2e/helpers.ts`. Both run in Node and either could import the other, so what keeps them apart is the fixtures, not the module boundary: the mock seeds an account (a `library` with a linear `completed` counter, images served from its own origin, per-play watch stamps), while each Playwright spec seeds its own `shows` array with `hidden` and `inWatchlist` flags, serves no images at all, and gates every field on the `extended` level so a request that drops one breaks the suite. Unifying them means reconciling those two seed models, not moving a function. The duplication detector does not report the overlap either way: it reads `src` and `test`. +The Trakt wire shapes are built twice, here and in `packages/web/e2e/helpers.ts`. Both run in Node and either could import the other, so what keeps them apart is the fixtures, not the module boundary: the mock seeds an account (a `library` with a linear `completed` counter, images served from its own origin, per-play watch stamps), while each Playwright spec seeds its own `shows` array with `hidden` and `inWatchlist` flags, serves no images at all, and gates every field on the `extended` level so a request that drops one breaks the suite. Unifying them means reconciling those two seed models, not moving a function. The duplication detector does not report the overlap either way: it reads each package's `src` and `test`. -Reaching it from the iOS simulator needs one thing this branch deliberately does not do: a Debug-only `NSAppTransportSecurity` dictionary in `ios/App/App/Info.plist`, either `NSAllowsLocalNetworking` or an `NSExceptionDomains` entry for `127.0.0.1`, because App Transport Security blocks plaintext HTTP. It must never reach a release build, and `test/privacy-claims.test.ts` fails if `NSAppTransportSecurity` appears in the committed `Info.plist` at all. +Reaching it from the iOS simulator needs one thing this branch deliberately does not do: a Debug-only `NSAppTransportSecurity` dictionary in `ios/App/App/Info.plist`, either `NSAllowsLocalNetworking` or an `NSExceptionDomains` entry for `127.0.0.1`, because App Transport Security blocks plaintext HTTP. It must never reach a release build, and `packages/web/test/privacy-claims.test.ts` fails if `NSAppTransportSecurity` appears in the committed `Info.plist` at all. ## Mobile -iOS and Android ship from the same code via [Capacitor](https://capacitorjs.com). The web build in `dist/` is the source of truth for everything the user sees; the shells around it are committed, because they are hand-edited: the scene delegate, the bridge controller and the haptics plugin on iOS, the Kotlin haptics plugin and the manifest's permission removal on Android, plus both project files. `cap sync` rewrites the derived parts in place: the web assets it copies (`ios/App/App/public`, `android/app/src/main/assets/public`) and the generated config JSON are the only native paths git ignores, while the plugin manifests it writes (`Package.swift`, `capacitor.settings.gradle`, `capacitor.build.gradle`) are committed, so a plugin appearing or leaving shows up in review. +iOS and Android ship from the same code via [Capacitor](https://capacitorjs.com). The web build in `packages/web/dist/` is the source of truth for everything the user sees; the shells around it are committed, because they are hand-edited: the scene delegate, the bridge controller and the haptics plugin on iOS, the Kotlin haptics plugin and the manifest's permission removal on Android, plus both project files. `cap sync` rewrites the derived parts in place: the web assets it copies (`ios/App/App/public`, `android/app/src/main/assets/public`) and the generated config JSON are the only native paths git ignores, while the plugin manifests it writes (`Package.swift`, `capacitor.settings.gradle`, `capacitor.build.gradle`) are committed, so a plugin appearing or leaving shows up in review. ```sh pnpm build @@ -120,14 +120,14 @@ Build numbers come from that workflow's run counter, which every branch shares a - **Tailwind CSS v4** (`@theme` tokens) for styling. - **TanStack Query** (with persistence) and **TanStack Router** for data and routing. - **TanStack Virtual** for large lists, **Zustand** for local state, **Zod** for runtime boundary validation, **Radix UI** for primitives. -- **Capacitor 8** thin shell for iOS/Android: all `@capacitor/*` imports confined to `src/platform`. -- Source is layered under `src/domain`, `src/data`, `src/ui`, `src/app`, and `src/platform`. +- **Capacitor 8** thin shell for iOS/Android: all `@capacitor/*` imports confined to `packages/web/src/platform`. +- The repository is a pnpm workspace. `packages/web` is the Vite app, layered under `src/domain`, `src/data`, `src/ui`, `src/app`, and `src/platform`; the root carries the gate runner, the native shells and the release lanes. ## Attribution Powered by [Trakt](https://trakt.tv). -Cue uses the Trakt API but is not created, endorsed, or sponsored by Trakt. The app name is deliberately Trakt-free so Cue is never mistaken for an official Trakt product. The unaltered official Trakt logo, from [trakt.tv/branding](https://trakt.tv/branding), appears in the Settings → About credit and ships in `src/ui/assets/trakt-logo.svg` (see that folder's README). +Cue uses the Trakt API but is not created, endorsed, or sponsored by Trakt. The app name is deliberately Trakt-free so Cue is never mistaken for an official Trakt product. The unaltered official Trakt logo, from [trakt.tv/branding](https://trakt.tv/branding), appears in the Settings → About credit and ships in `packages/web/src/ui/assets/trakt-logo.svg` (see that folder's README). ## Privacy diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index d9cdfb5c..8cfe7c9d 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -1,15 +1,15 @@ // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN include ':capacitor-android' -project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.5.0_@capacitor+core@8.5.0/node_modules/@capacitor/android/capacitor') +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') include ':capacitor-app' -project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.0/node_modules/@capacitor/app/android') +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') include ':capacitor-local-notifications' -project(':capacitor-local-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+local-notifications@8.3.1_@capacitor+core@8.5.0/node_modules/@capacitor/local-notifications/android') +project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') include ':capacitor-preferences' -project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.0/node_modules/@capacitor/preferences/android') +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') include ':capacitor-status-bar' -project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@capacitor+status-bar@8.0.3_@capacitor+core@8.5.0/node_modules/@capacitor/status-bar/android') +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index feac60d9..2a509c48 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -3,7 +3,18 @@ import type { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { appId: "app.cuetracker", appName: "Cue", - webDir: "dist", + webDir: "packages/web/dist", + // The web app is a workspace package, so its Capacitor plugins are declared in + // packages/web/package.json, where the code that imports them lives. Capacitor + // discovers plugins from the manifest beside this file, which is the root's, + // so the shells' plugin set is named here instead of inferred. Adding a plugin + // to the web package without adding it here leaves it out of the binaries. + includePlugins: [ + "@capacitor/app", + "@capacitor/local-notifications", + "@capacitor/preferences", + "@capacitor/status-bar", + ], // Capacitor ships zoom off on both platforms by default, which strands anyone who needs to // magnify a poster or a synopsis. Worse, the off state is not even coherent on iOS: double-tap // to zoom still fires while the pinch recognizer is killed, so a reader can land magnified with diff --git a/cspell.json b/cspell.json index 4a8013d5..66d7d796 100644 --- a/cspell.json +++ b/cspell.json @@ -15,6 +15,7 @@ "contenteditable", "cspell", "cuetracker", + "depcruise", "dprint", "fanart", "favorited", diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 230de41a..08f6b6b5 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -12,10 +12,10 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.0"), - .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.0/node_modules/@capacitor/app"), - .package(name: "CapacitorLocalNotifications", path: "../../../node_modules/.pnpm/@capacitor+local-notifications@8.3.1_@capacitor+core@8.5.0/node_modules/@capacitor/local-notifications"), - .package(name: "CapacitorPreferences", path: "../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.0/node_modules/@capacitor/preferences"), - .package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.3_@capacitor+core@8.5.0/node_modules/@capacitor/status-bar") + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"), + .package(name: "CapacitorPreferences", path: "../../../node_modules/@capacitor/preferences"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") ], targets: [ .target( diff --git a/knip.json b/knip.json index 2cc19dfa..7184385d 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,14 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "project": ["src/**/*.{ts,tsx}"], - "ignoreDependencies": ["tailwindcss"], - "ignoreBinaries": ["defaults"] + "ignoreBinaries": ["defaults"], + "workspaces": { + ".": { + "entry": ["scripts/*.mjs", "scripts/mock-trakt/*.mjs"], + "project": ["scripts/**/*.mjs", "*.ts"] + }, + "packages/web": { + "project": ["src/**/*.{ts,tsx}"], + "ignoreDependencies": ["tailwindcss"] + } + } } diff --git a/package.json b/package.json index 18516a53..620ca02a 100644 --- a/package.json +++ b/package.json @@ -9,80 +9,52 @@ "node": ">=22.12.0" }, "scripts": { - "dev": "vite", - "dev:mock": "vite --mode mock", + "dev": "pnpm --filter @cue/web dev", + "dev:mock": "pnpm --filter @cue/web dev:mock", "mock:trakt": "node scripts/mock-trakt/server.mjs", - "build": "vite build", - "preview": "vite preview", + "build": "pnpm --filter @cue/web build", + "preview": "pnpm --filter @cue/web preview", "sync": "cap sync", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && pnpm -r typecheck", "lint": "biome check .", "format": "biome check --write . && dprint fmt", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "e2e": "playwright test", - "e2e:mobile": "playwright test --project=mobile-chromium --project=mobile-webkit", + "e2e": "pnpm --filter @cue/web e2e", + "e2e:mobile": "pnpm --filter @cue/web e2e:mobile", "check:md": "dprint check", "check:spell": "cspell --no-progress --no-summary \"**/*.{ts,tsx,css,md}\"", - "check:arch": "depcruise src --config .dependency-cruiser.cjs", + "check:arch": "depcruise packages --config .dependency-cruiser.cjs", "check:knip": "knip", "check:dup": "jscpd", "check:bundle": "./scripts/verify-bundle.sh", "buster:check": "node scripts/write-buster.mjs --check", "buster:bump": "node scripts/write-buster.mjs --bump", - "check": "biome check . && dprint check && pnpm check:spell && tsc --noEmit && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage", + "check": "biome check . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage", "audit": "pnpm audit --prod --audit-level=high", "prepare": "lefthook install" }, "dependencies": { - "@capacitor/android": "^8.5.0", "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.0", - "@capacitor/ios": "^8.5.0", "@capacitor/local-notifications": "^8.3.1", "@capacitor/preferences": "8.0.1", - "@capacitor/status-bar": "^8.0.3", - "@fontsource-variable/inter": "^5.2.8", - "@fontsource-variable/space-grotesk": "^5.2.10", - "@tanstack/query-async-storage-persister": "5.101.2", - "@tanstack/react-query": "5.101.2", - "@tanstack/react-query-persist-client": "5.101.2", - "@tanstack/react-router": "1.170.17", - "@tanstack/react-virtual": "3.14.5", - "idb-keyval": "6.2.6", - "lucide-react": "^1.24.0", - "radix-ui": "1.6.1", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "zod": "^4.4.3", - "zustand": "5.0.14" + "@capacitor/status-bar": "^8.0.3" }, "devDependencies": { "@biomejs/biome": "^2.5.2", + "@capacitor/android": "^8.5.0", "@capacitor/cli": "^8.5.0", - "@playwright/test": "^1.61.1", - "@tailwindcss/vite": "^4.3.2", - "@testing-library/jest-dom": "^6.9.1", - "@types/node": "^22.12.0", - "@types/picomatch": "^4.0.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.3.1", + "@capacitor/ios": "^8.5.0", "@vitest/coverage-v8": "^4.1.9", "cspell": "^9.8.0", "dependency-cruiser": "^18.0.0", "dprint": "^0.55.1", "jscpd": "^5.0.11", - "jsdom": "^29.1.1", "knip": "^6.24.0", "lefthook": "^2.1.9", - "msw": "^2.14.6", - "picomatch": "^4.0.5", - "tailwindcss": "^4.3.2", "typescript": "^6.0.3", - "vite": "^8.1.3", - "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.9" } } diff --git a/.env.example b/packages/web/.env.example similarity index 100% rename from .env.example rename to packages/web/.env.example diff --git a/.env.mock b/packages/web/.env.mock similarity index 100% rename from .env.mock rename to packages/web/.env.mock diff --git a/.env.test b/packages/web/.env.test similarity index 100% rename from .env.test rename to packages/web/.env.test diff --git a/e2e/art-budget.spec.ts b/packages/web/e2e/art-budget.spec.ts similarity index 100% rename from e2e/art-budget.spec.ts rename to packages/web/e2e/art-budget.spec.ts diff --git a/e2e/auth.spec.ts b/packages/web/e2e/auth.spec.ts similarity index 100% rename from e2e/auth.spec.ts rename to packages/web/e2e/auth.spec.ts diff --git a/e2e/content-visibility.spec.ts b/packages/web/e2e/content-visibility.spec.ts similarity index 100% rename from e2e/content-visibility.spec.ts rename to packages/web/e2e/content-visibility.spec.ts diff --git a/e2e/episode-detail.spec.ts b/packages/web/e2e/episode-detail.spec.ts similarity index 100% rename from e2e/episode-detail.spec.ts rename to packages/web/e2e/episode-detail.spec.ts diff --git a/e2e/frame.spec.ts b/packages/web/e2e/frame.spec.ts similarity index 100% rename from e2e/frame.spec.ts rename to packages/web/e2e/frame.spec.ts diff --git a/e2e/global-setup.ts b/packages/web/e2e/global-setup.ts similarity index 100% rename from e2e/global-setup.ts rename to packages/web/e2e/global-setup.ts diff --git a/e2e/helpers.ts b/packages/web/e2e/helpers.ts similarity index 100% rename from e2e/helpers.ts rename to packages/web/e2e/helpers.ts diff --git a/e2e/history.spec.ts b/packages/web/e2e/history.spec.ts similarity index 100% rename from e2e/history.spec.ts rename to packages/web/e2e/history.spec.ts diff --git a/e2e/movies.spec.ts b/packages/web/e2e/movies.spec.ts similarity index 100% rename from e2e/movies.spec.ts rename to packages/web/e2e/movies.spec.ts diff --git a/e2e/my-shows.spec.ts b/packages/web/e2e/my-shows.spec.ts similarity index 100% rename from e2e/my-shows.spec.ts rename to packages/web/e2e/my-shows.spec.ts diff --git a/e2e/persistence.spec.ts b/packages/web/e2e/persistence.spec.ts similarity index 100% rename from e2e/persistence.spec.ts rename to packages/web/e2e/persistence.spec.ts diff --git a/e2e/profile.spec.ts b/packages/web/e2e/profile.spec.ts similarity index 100% rename from e2e/profile.spec.ts rename to packages/web/e2e/profile.spec.ts diff --git a/e2e/pull-to-refresh.spec.ts b/packages/web/e2e/pull-to-refresh.spec.ts similarity index 100% rename from e2e/pull-to-refresh.spec.ts rename to packages/web/e2e/pull-to-refresh.spec.ts diff --git a/e2e/ratings-watchlist.spec.ts b/packages/web/e2e/ratings-watchlist.spec.ts similarity index 100% rename from e2e/ratings-watchlist.spec.ts rename to packages/web/e2e/ratings-watchlist.spec.ts diff --git a/e2e/reflow.spec.ts b/packages/web/e2e/reflow.spec.ts similarity index 100% rename from e2e/reflow.spec.ts rename to packages/web/e2e/reflow.spec.ts diff --git a/e2e/search.spec.ts b/packages/web/e2e/search.spec.ts similarity index 100% rename from e2e/search.spec.ts rename to packages/web/e2e/search.spec.ts diff --git a/e2e/show-detail.spec.ts b/packages/web/e2e/show-detail.spec.ts similarity index 100% rename from e2e/show-detail.spec.ts rename to packages/web/e2e/show-detail.spec.ts diff --git a/e2e/swipe.spec.ts b/packages/web/e2e/swipe.spec.ts similarity index 100% rename from e2e/swipe.spec.ts rename to packages/web/e2e/swipe.spec.ts diff --git a/e2e/sync.spec.ts b/packages/web/e2e/sync.spec.ts similarity index 100% rename from e2e/sync.spec.ts rename to packages/web/e2e/sync.spec.ts diff --git a/e2e/token-refresh.spec.ts b/packages/web/e2e/token-refresh.spec.ts similarity index 100% rename from e2e/token-refresh.spec.ts rename to packages/web/e2e/token-refresh.spec.ts diff --git a/e2e/touch-targets.spec.ts b/packages/web/e2e/touch-targets.spec.ts similarity index 100% rename from e2e/touch-targets.spec.ts rename to packages/web/e2e/touch-targets.spec.ts diff --git a/e2e/up-next.spec.ts b/packages/web/e2e/up-next.spec.ts similarity index 100% rename from e2e/up-next.spec.ts rename to packages/web/e2e/up-next.spec.ts diff --git a/e2e/upcoming.spec.ts b/packages/web/e2e/upcoming.spec.ts similarity index 100% rename from e2e/upcoming.spec.ts rename to packages/web/e2e/upcoming.spec.ts diff --git a/e2e/viewport-zoom.spec.ts b/packages/web/e2e/viewport-zoom.spec.ts similarity index 100% rename from e2e/viewport-zoom.spec.ts rename to packages/web/e2e/viewport-zoom.spec.ts diff --git a/index.html b/packages/web/index.html similarity index 100% rename from index.html rename to packages/web/index.html diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 00000000..516992d4 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cue/web", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Cue's web app: the Vite PWA and the web bundle the Capacitor shells load.", + "scripts": { + "dev": "vite", + "dev:mock": "vite --mode mock", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "e2e": "playwright test", + "e2e:mobile": "playwright test --project=mobile-chromium --project=mobile-webkit" + }, + "dependencies": { + "@capacitor/app": "^8.1.1", + "@capacitor/core": "^8.5.0", + "@capacitor/local-notifications": "^8.3.1", + "@capacitor/preferences": "8.0.1", + "@capacitor/status-bar": "^8.0.3", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/space-grotesk": "^5.2.10", + "@tanstack/query-async-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", + "@tanstack/react-router": "1.170.17", + "@tanstack/react-virtual": "3.14.5", + "idb-keyval": "6.2.6", + "lucide-react": "^1.24.0", + "radix-ui": "1.6.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "zod": "^4.4.3", + "zustand": "5.0.14" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@tailwindcss/vite": "^4.3.2", + "@testing-library/jest-dom": "^6.9.1", + "@types/node": "^22.12.0", + "@types/picomatch": "^4.0.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.3.1", + "jsdom": "^29.1.1", + "msw": "^2.14.6", + "picomatch": "^4.0.5", + "tailwindcss": "^4.3.2", + "typescript": "^6.0.3", + "vite": "^8.1.3", + "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.9" + } +} diff --git a/playwright.config.ts b/packages/web/playwright.config.ts similarity index 100% rename from playwright.config.ts rename to packages/web/playwright.config.ts diff --git a/public/icon.svg b/packages/web/public/icon.svg similarity index 100% rename from public/icon.svg rename to packages/web/public/icon.svg diff --git a/src/app/AuthGate.tsx b/packages/web/src/app/AuthGate.tsx similarity index 100% rename from src/app/AuthGate.tsx rename to packages/web/src/app/AuthGate.tsx diff --git a/src/app/auth/create-auth-store.ts b/packages/web/src/app/auth/create-auth-store.ts similarity index 100% rename from src/app/auth/create-auth-store.ts rename to packages/web/src/app/auth/create-auth-store.ts diff --git a/src/app/config.ts b/packages/web/src/app/config.ts similarity index 100% rename from src/app/config.ts rename to packages/web/src/app/config.ts diff --git a/src/app/main.tsx b/packages/web/src/app/main.tsx similarity index 100% rename from src/app/main.tsx rename to packages/web/src/app/main.tsx diff --git a/src/app/persist.ts b/packages/web/src/app/persist.ts similarity index 100% rename from src/app/persist.ts rename to packages/web/src/app/persist.ts diff --git a/src/app/providers.tsx b/packages/web/src/app/providers.tsx similarity index 100% rename from src/app/providers.tsx rename to packages/web/src/app/providers.tsx diff --git a/src/app/query-client.ts b/packages/web/src/app/query-client.ts similarity index 100% rename from src/app/query-client.ts rename to packages/web/src/app/query-client.ts diff --git a/src/app/router.tsx b/packages/web/src/app/router.tsx similarity index 100% rename from src/app/router.tsx rename to packages/web/src/app/router.tsx diff --git a/src/app/routes/auth-callback.lazy.tsx b/packages/web/src/app/routes/auth-callback.lazy.tsx similarity index 100% rename from src/app/routes/auth-callback.lazy.tsx rename to packages/web/src/app/routes/auth-callback.lazy.tsx diff --git a/src/app/routes/calendar.lazy.tsx b/packages/web/src/app/routes/calendar.lazy.tsx similarity index 100% rename from src/app/routes/calendar.lazy.tsx rename to packages/web/src/app/routes/calendar.lazy.tsx diff --git a/src/app/routes/episode.lazy.tsx b/packages/web/src/app/routes/episode.lazy.tsx similarity index 100% rename from src/app/routes/episode.lazy.tsx rename to packages/web/src/app/routes/episode.lazy.tsx diff --git a/src/app/routes/history.lazy.tsx b/packages/web/src/app/routes/history.lazy.tsx similarity index 100% rename from src/app/routes/history.lazy.tsx rename to packages/web/src/app/routes/history.lazy.tsx diff --git a/src/app/routes/library.lazy.tsx b/packages/web/src/app/routes/library.lazy.tsx similarity index 100% rename from src/app/routes/library.lazy.tsx rename to packages/web/src/app/routes/library.lazy.tsx diff --git a/src/app/routes/movie.lazy.tsx b/packages/web/src/app/routes/movie.lazy.tsx similarity index 100% rename from src/app/routes/movie.lazy.tsx rename to packages/web/src/app/routes/movie.lazy.tsx diff --git a/src/app/routes/profile.lazy.tsx b/packages/web/src/app/routes/profile.lazy.tsx similarity index 100% rename from src/app/routes/profile.lazy.tsx rename to packages/web/src/app/routes/profile.lazy.tsx diff --git a/src/app/routes/search.lazy.tsx b/packages/web/src/app/routes/search.lazy.tsx similarity index 100% rename from src/app/routes/search.lazy.tsx rename to packages/web/src/app/routes/search.lazy.tsx diff --git a/src/app/routes/settings.lazy.tsx b/packages/web/src/app/routes/settings.lazy.tsx similarity index 100% rename from src/app/routes/settings.lazy.tsx rename to packages/web/src/app/routes/settings.lazy.tsx diff --git a/src/app/routes/show.lazy.tsx b/packages/web/src/app/routes/show.lazy.tsx similarity index 100% rename from src/app/routes/show.lazy.tsx rename to packages/web/src/app/routes/show.lazy.tsx diff --git a/src/app/routes/up-next.lazy.tsx b/packages/web/src/app/routes/up-next.lazy.tsx similarity index 100% rename from src/app/routes/up-next.lazy.tsx rename to packages/web/src/app/routes/up-next.lazy.tsx diff --git a/src/app/runtime/RuntimeBoot.tsx b/packages/web/src/app/runtime/RuntimeBoot.tsx similarity index 100% rename from src/app/runtime/RuntimeBoot.tsx rename to packages/web/src/app/runtime/RuntimeBoot.tsx diff --git a/src/app/runtime/create-runtime.ts b/packages/web/src/app/runtime/create-runtime.ts similarity index 100% rename from src/app/runtime/create-runtime.ts rename to packages/web/src/app/runtime/create-runtime.ts diff --git a/src/app/session.ts b/packages/web/src/app/session.ts similarity index 100% rename from src/app/session.ts rename to packages/web/src/app/session.ts diff --git a/src/data/auth/oauth.ts b/packages/web/src/data/auth/oauth.ts similarity index 100% rename from src/data/auth/oauth.ts rename to packages/web/src/data/auth/oauth.ts diff --git a/src/data/auth/pkce.ts b/packages/web/src/data/auth/pkce.ts similarity index 100% rename from src/data/auth/pkce.ts rename to packages/web/src/data/auth/pkce.ts diff --git a/src/data/image-source.ts b/packages/web/src/data/image-source.ts similarity index 100% rename from src/data/image-source.ts rename to packages/web/src/data/image-source.ts diff --git a/src/data/query-invalidation.ts b/packages/web/src/data/query-invalidation.ts similarity index 100% rename from src/data/query-invalidation.ts rename to packages/web/src/data/query-invalidation.ts diff --git a/src/data/query-keys.ts b/packages/web/src/data/query-keys.ts similarity index 100% rename from src/data/query-keys.ts rename to packages/web/src/data/query-keys.ts diff --git a/src/data/trakt/authorized-fetch.ts b/packages/web/src/data/trakt/authorized-fetch.ts similarity index 100% rename from src/data/trakt/authorized-fetch.ts rename to packages/web/src/data/trakt/authorized-fetch.ts diff --git a/src/data/trakt/calendar.ts b/packages/web/src/data/trakt/calendar.ts similarity index 100% rename from src/data/trakt/calendar.ts rename to packages/web/src/data/trakt/calendar.ts diff --git a/src/data/trakt/client.ts b/packages/web/src/data/trakt/client.ts similarity index 100% rename from src/data/trakt/client.ts rename to packages/web/src/data/trakt/client.ts diff --git a/src/data/trakt/endpoints.ts b/packages/web/src/data/trakt/endpoints.ts similarity index 100% rename from src/data/trakt/endpoints.ts rename to packages/web/src/data/trakt/endpoints.ts diff --git a/src/data/trakt/episode-detail.ts b/packages/web/src/data/trakt/episode-detail.ts similarity index 100% rename from src/data/trakt/episode-detail.ts rename to packages/web/src/data/trakt/episode-detail.ts diff --git a/src/data/trakt/history.ts b/packages/web/src/data/trakt/history.ts similarity index 100% rename from src/data/trakt/history.ts rename to packages/web/src/data/trakt/history.ts diff --git a/src/data/trakt/library.ts b/packages/web/src/data/trakt/library.ts similarity index 100% rename from src/data/trakt/library.ts rename to packages/web/src/data/trakt/library.ts diff --git a/src/data/trakt/movie-library.ts b/packages/web/src/data/trakt/movie-library.ts similarity index 100% rename from src/data/trakt/movie-library.ts rename to packages/web/src/data/trakt/movie-library.ts diff --git a/src/data/trakt/pooled-endpoints.ts b/packages/web/src/data/trakt/pooled-endpoints.ts similarity index 100% rename from src/data/trakt/pooled-endpoints.ts rename to packages/web/src/data/trakt/pooled-endpoints.ts diff --git a/src/data/trakt/read-budget.ts b/packages/web/src/data/trakt/read-budget.ts similarity index 100% rename from src/data/trakt/read-budget.ts rename to packages/web/src/data/trakt/read-budget.ts diff --git a/src/data/trakt/repositories.ts b/packages/web/src/data/trakt/repositories.ts similarity index 100% rename from src/data/trakt/repositories.ts rename to packages/web/src/data/trakt/repositories.ts diff --git a/src/data/trakt/schemas.ts b/packages/web/src/data/trakt/schemas.ts similarity index 100% rename from src/data/trakt/schemas.ts rename to packages/web/src/data/trakt/schemas.ts diff --git a/src/data/trakt/search.ts b/packages/web/src/data/trakt/search.ts similarity index 100% rename from src/data/trakt/search.ts rename to packages/web/src/data/trakt/search.ts diff --git a/src/data/trakt/show-detail.ts b/packages/web/src/data/trakt/show-detail.ts similarity index 100% rename from src/data/trakt/show-detail.ts rename to packages/web/src/data/trakt/show-detail.ts diff --git a/src/data/trakt/transport.ts b/packages/web/src/data/trakt/transport.ts similarity index 100% rename from src/data/trakt/transport.ts rename to packages/web/src/data/trakt/transport.ts diff --git a/src/data/trakt/user-profile.ts b/packages/web/src/data/trakt/user-profile.ts similarity index 100% rename from src/data/trakt/user-profile.ts rename to packages/web/src/data/trakt/user-profile.ts diff --git a/src/domain/auth/token.ts b/packages/web/src/domain/auth/token.ts similarity index 100% rename from src/domain/auth/token.ts rename to packages/web/src/domain/auth/token.ts diff --git a/src/domain/calendar.ts b/packages/web/src/domain/calendar.ts similarity index 100% rename from src/domain/calendar.ts rename to packages/web/src/domain/calendar.ts diff --git a/src/domain/day.ts b/packages/web/src/domain/day.ts similarity index 100% rename from src/domain/day.ts rename to packages/web/src/domain/day.ts diff --git a/src/domain/history.ts b/packages/web/src/domain/history.ts similarity index 100% rename from src/domain/history.ts rename to packages/web/src/domain/history.ts diff --git a/src/domain/library-buckets.ts b/packages/web/src/domain/library-buckets.ts similarity index 100% rename from src/domain/library-buckets.ts rename to packages/web/src/domain/library-buckets.ts diff --git a/src/domain/model/ids.ts b/packages/web/src/domain/model/ids.ts similarity index 100% rename from src/domain/model/ids.ts rename to packages/web/src/domain/model/ids.ts diff --git a/src/domain/model/library.ts b/packages/web/src/domain/model/library.ts similarity index 100% rename from src/domain/model/library.ts rename to packages/web/src/domain/model/library.ts diff --git a/src/domain/model/token.ts b/packages/web/src/domain/model/token.ts similarity index 100% rename from src/domain/model/token.ts rename to packages/web/src/domain/model/token.ts diff --git a/src/domain/ports/haptics.ts b/packages/web/src/domain/ports/haptics.ts similarity index 100% rename from src/domain/ports/haptics.ts rename to packages/web/src/domain/ports/haptics.ts diff --git a/src/domain/ports/reminders.ts b/packages/web/src/domain/ports/reminders.ts similarity index 100% rename from src/domain/ports/reminders.ts rename to packages/web/src/domain/ports/reminders.ts diff --git a/src/domain/recently-aired.ts b/packages/web/src/domain/recently-aired.ts similarity index 100% rename from src/domain/recently-aired.ts rename to packages/web/src/domain/recently-aired.ts diff --git a/src/domain/reminders.ts b/packages/web/src/domain/reminders.ts similarity index 100% rename from src/domain/reminders.ts rename to packages/web/src/domain/reminders.ts diff --git a/src/domain/reversal.ts b/packages/web/src/domain/reversal.ts similarity index 100% rename from src/domain/reversal.ts rename to packages/web/src/domain/reversal.ts diff --git a/src/domain/sync-activities.ts b/packages/web/src/domain/sync-activities.ts similarity index 100% rename from src/domain/sync-activities.ts rename to packages/web/src/domain/sync-activities.ts diff --git a/src/domain/time.ts b/packages/web/src/domain/time.ts similarity index 100% rename from src/domain/time.ts rename to packages/web/src/domain/time.ts diff --git a/src/domain/up-next.ts b/packages/web/src/domain/up-next.ts similarity index 100% rename from src/domain/up-next.ts rename to packages/web/src/domain/up-next.ts diff --git a/src/domain/watch-status.ts b/packages/web/src/domain/watch-status.ts similarity index 100% rename from src/domain/watch-status.ts rename to packages/web/src/domain/watch-status.ts diff --git a/src/domain/write-queue/bulk.ts b/packages/web/src/domain/write-queue/bulk.ts similarity index 100% rename from src/domain/write-queue/bulk.ts rename to packages/web/src/domain/write-queue/bulk.ts diff --git a/src/domain/write-queue/classify.ts b/packages/web/src/domain/write-queue/classify.ts similarity index 100% rename from src/domain/write-queue/classify.ts rename to packages/web/src/domain/write-queue/classify.ts diff --git a/src/domain/write-queue/coalesce.ts b/packages/web/src/domain/write-queue/coalesce.ts similarity index 100% rename from src/domain/write-queue/coalesce.ts rename to packages/web/src/domain/write-queue/coalesce.ts diff --git a/src/domain/write-queue/ops.ts b/packages/web/src/domain/write-queue/ops.ts similarity index 100% rename from src/domain/write-queue/ops.ts rename to packages/web/src/domain/write-queue/ops.ts diff --git a/src/domain/write-queue/queue.ts b/packages/web/src/domain/write-queue/queue.ts similarity index 100% rename from src/domain/write-queue/queue.ts rename to packages/web/src/domain/write-queue/queue.ts diff --git a/src/domain/write-queue/types.ts b/packages/web/src/domain/write-queue/types.ts similarity index 100% rename from src/domain/write-queue/types.ts rename to packages/web/src/domain/write-queue/types.ts diff --git a/src/platform/app-version.ts b/packages/web/src/platform/app-version.ts similarity index 100% rename from src/platform/app-version.ts rename to packages/web/src/platform/app-version.ts diff --git a/src/platform/back-button.ts b/packages/web/src/platform/back-button.ts similarity index 100% rename from src/platform/back-button.ts rename to packages/web/src/platform/back-button.ts diff --git a/src/platform/haptics.ts b/packages/web/src/platform/haptics.ts similarity index 100% rename from src/platform/haptics.ts rename to packages/web/src/platform/haptics.ts diff --git a/src/platform/json-store.ts b/packages/web/src/platform/json-store.ts similarity index 100% rename from src/platform/json-store.ts rename to packages/web/src/platform/json-store.ts diff --git a/src/platform/kv.ts b/packages/web/src/platform/kv.ts similarity index 100% rename from src/platform/kv.ts rename to packages/web/src/platform/kv.ts diff --git a/src/platform/platform.ts b/packages/web/src/platform/platform.ts similarity index 100% rename from src/platform/platform.ts rename to packages/web/src/platform/platform.ts diff --git a/src/platform/reminders.ts b/packages/web/src/platform/reminders.ts similarity index 100% rename from src/platform/reminders.ts rename to packages/web/src/platform/reminders.ts diff --git a/src/platform/status-bar.ts b/packages/web/src/platform/status-bar.ts similarity index 100% rename from src/platform/status-bar.ts rename to packages/web/src/platform/status-bar.ts diff --git a/src/platform/token-store.ts b/packages/web/src/platform/token-store.ts similarity index 100% rename from src/platform/token-store.ts rename to packages/web/src/platform/token-store.ts diff --git a/src/ui/app-shell/CueMark.tsx b/packages/web/src/ui/app-shell/CueMark.tsx similarity index 100% rename from src/ui/app-shell/CueMark.tsx rename to packages/web/src/ui/app-shell/CueMark.tsx diff --git a/src/ui/app-shell/ErrorBoundary.tsx b/packages/web/src/ui/app-shell/ErrorBoundary.tsx similarity index 100% rename from src/ui/app-shell/ErrorBoundary.tsx rename to packages/web/src/ui/app-shell/ErrorBoundary.tsx diff --git a/src/ui/app-shell/RootLayout.tsx b/packages/web/src/ui/app-shell/RootLayout.tsx similarity index 100% rename from src/ui/app-shell/RootLayout.tsx rename to packages/web/src/ui/app-shell/RootLayout.tsx diff --git a/src/ui/app-shell/ScreenHeader.tsx b/packages/web/src/ui/app-shell/ScreenHeader.tsx similarity index 100% rename from src/ui/app-shell/ScreenHeader.tsx rename to packages/web/src/ui/app-shell/ScreenHeader.tsx diff --git a/src/ui/app-shell/SyncStrip.tsx b/packages/web/src/ui/app-shell/SyncStrip.tsx similarity index 100% rename from src/ui/app-shell/SyncStrip.tsx rename to packages/web/src/ui/app-shell/SyncStrip.tsx diff --git a/src/ui/app-shell/nav.ts b/packages/web/src/ui/app-shell/nav.ts similarity index 100% rename from src/ui/app-shell/nav.ts rename to packages/web/src/ui/app-shell/nav.ts diff --git a/src/ui/assets/README.md b/packages/web/src/ui/assets/README.md similarity index 100% rename from src/ui/assets/README.md rename to packages/web/src/ui/assets/README.md diff --git a/src/ui/assets/trakt-logo.svg b/packages/web/src/ui/assets/trakt-logo.svg similarity index 100% rename from src/ui/assets/trakt-logo.svg rename to packages/web/src/ui/assets/trakt-logo.svg diff --git a/src/ui/auth/store.ts b/packages/web/src/ui/auth/store.ts similarity index 100% rename from src/ui/auth/store.ts rename to packages/web/src/ui/auth/store.ts diff --git a/src/ui/components/ActionSheet.tsx b/packages/web/src/ui/components/ActionSheet.tsx similarity index 100% rename from src/ui/components/ActionSheet.tsx rename to packages/web/src/ui/components/ActionSheet.tsx diff --git a/src/ui/components/AppSnackbar.tsx b/packages/web/src/ui/components/AppSnackbar.tsx similarity index 100% rename from src/ui/components/AppSnackbar.tsx rename to packages/web/src/ui/components/AppSnackbar.tsx diff --git a/src/ui/components/ArtPlaceholder.tsx b/packages/web/src/ui/components/ArtPlaceholder.tsx similarity index 100% rename from src/ui/components/ArtPlaceholder.tsx rename to packages/web/src/ui/components/ArtPlaceholder.tsx diff --git a/src/ui/components/Badge.tsx b/packages/web/src/ui/components/Badge.tsx similarity index 100% rename from src/ui/components/Badge.tsx rename to packages/web/src/ui/components/Badge.tsx diff --git a/src/ui/components/CheckControl.tsx b/packages/web/src/ui/components/CheckControl.tsx similarity index 100% rename from src/ui/components/CheckControl.tsx rename to packages/web/src/ui/components/CheckControl.tsx diff --git a/src/ui/components/Chip.tsx b/packages/web/src/ui/components/Chip.tsx similarity index 100% rename from src/ui/components/Chip.tsx rename to packages/web/src/ui/components/Chip.tsx diff --git a/src/ui/components/ConfirmSheet.tsx b/packages/web/src/ui/components/ConfirmSheet.tsx similarity index 100% rename from src/ui/components/ConfirmSheet.tsx rename to packages/web/src/ui/components/ConfirmSheet.tsx diff --git a/src/ui/components/ContextMenu.tsx b/packages/web/src/ui/components/ContextMenu.tsx similarity index 100% rename from src/ui/components/ContextMenu.tsx rename to packages/web/src/ui/components/ContextMenu.tsx diff --git a/src/ui/components/CountdownPanel.tsx b/packages/web/src/ui/components/CountdownPanel.tsx similarity index 100% rename from src/ui/components/CountdownPanel.tsx rename to packages/web/src/ui/components/CountdownPanel.tsx diff --git a/src/ui/components/DetailHeroSkeleton.tsx b/packages/web/src/ui/components/DetailHeroSkeleton.tsx similarity index 100% rename from src/ui/components/DetailHeroSkeleton.tsx rename to packages/web/src/ui/components/DetailHeroSkeleton.tsx diff --git a/src/ui/components/EmptyState.tsx b/packages/web/src/ui/components/EmptyState.tsx similarity index 100% rename from src/ui/components/EmptyState.tsx rename to packages/web/src/ui/components/EmptyState.tsx diff --git a/src/ui/components/EpisodeRow.tsx b/packages/web/src/ui/components/EpisodeRow.tsx similarity index 100% rename from src/ui/components/EpisodeRow.tsx rename to packages/web/src/ui/components/EpisodeRow.tsx diff --git a/src/ui/components/ErrorStates.tsx b/packages/web/src/ui/components/ErrorStates.tsx similarity index 100% rename from src/ui/components/ErrorStates.tsx rename to packages/web/src/ui/components/ErrorStates.tsx diff --git a/src/ui/components/MarqueeCard.tsx b/packages/web/src/ui/components/MarqueeCard.tsx similarity index 100% rename from src/ui/components/MarqueeCard.tsx rename to packages/web/src/ui/components/MarqueeCard.tsx diff --git a/src/ui/components/PosterTile.tsx b/packages/web/src/ui/components/PosterTile.tsx similarity index 100% rename from src/ui/components/PosterTile.tsx rename to packages/web/src/ui/components/PosterTile.tsx diff --git a/src/ui/components/ProgressBar.tsx b/packages/web/src/ui/components/ProgressBar.tsx similarity index 100% rename from src/ui/components/ProgressBar.tsx rename to packages/web/src/ui/components/ProgressBar.tsx diff --git a/src/ui/components/PullToRefresh.tsx b/packages/web/src/ui/components/PullToRefresh.tsx similarity index 100% rename from src/ui/components/PullToRefresh.tsx rename to packages/web/src/ui/components/PullToRefresh.tsx diff --git a/src/ui/components/SectionHeader.tsx b/packages/web/src/ui/components/SectionHeader.tsx similarity index 100% rename from src/ui/components/SectionHeader.tsx rename to packages/web/src/ui/components/SectionHeader.tsx diff --git a/src/ui/components/SegmentedControl.tsx b/packages/web/src/ui/components/SegmentedControl.tsx similarity index 100% rename from src/ui/components/SegmentedControl.tsx rename to packages/web/src/ui/components/SegmentedControl.tsx diff --git a/src/ui/components/Sheet.tsx b/packages/web/src/ui/components/Sheet.tsx similarity index 100% rename from src/ui/components/Sheet.tsx rename to packages/web/src/ui/components/Sheet.tsx diff --git a/src/ui/components/Skeletons.tsx b/packages/web/src/ui/components/Skeletons.tsx similarity index 100% rename from src/ui/components/Skeletons.tsx rename to packages/web/src/ui/components/Skeletons.tsx diff --git a/src/ui/components/SwipeAction.tsx b/packages/web/src/ui/components/SwipeAction.tsx similarity index 100% rename from src/ui/components/SwipeAction.tsx rename to packages/web/src/ui/components/SwipeAction.tsx diff --git a/src/ui/components/TutorialCaption.tsx b/packages/web/src/ui/components/TutorialCaption.tsx similarity index 100% rename from src/ui/components/TutorialCaption.tsx rename to packages/web/src/ui/components/TutorialCaption.tsx diff --git a/src/ui/components/artGradient.ts b/packages/web/src/ui/components/artGradient.ts similarity index 100% rename from src/ui/components/artGradient.ts rename to packages/web/src/ui/components/artGradient.ts diff --git a/src/ui/components/gesture-intent.ts b/packages/web/src/ui/components/gesture-intent.ts similarity index 100% rename from src/ui/components/gesture-intent.ts rename to packages/web/src/ui/components/gesture-intent.ts diff --git a/src/ui/components/long-press-math.ts b/packages/web/src/ui/components/long-press-math.ts similarity index 100% rename from src/ui/components/long-press-math.ts rename to packages/web/src/ui/components/long-press-math.ts diff --git a/src/ui/components/pull-math.ts b/packages/web/src/ui/components/pull-math.ts similarity index 100% rename from src/ui/components/pull-math.ts rename to packages/web/src/ui/components/pull-math.ts diff --git a/src/ui/components/sheet-math.ts b/packages/web/src/ui/components/sheet-math.ts similarity index 100% rename from src/ui/components/sheet-math.ts rename to packages/web/src/ui/components/sheet-math.ts diff --git a/src/ui/components/snackbar-store.ts b/packages/web/src/ui/components/snackbar-store.ts similarity index 100% rename from src/ui/components/snackbar-store.ts rename to packages/web/src/ui/components/snackbar-store.ts diff --git a/src/ui/components/swipe-math.ts b/packages/web/src/ui/components/swipe-math.ts similarity index 100% rename from src/ui/components/swipe-math.ts rename to packages/web/src/ui/components/swipe-math.ts diff --git a/src/ui/format.ts b/packages/web/src/ui/format.ts similarity index 100% rename from src/ui/format.ts rename to packages/web/src/ui/format.ts diff --git a/src/ui/hooks/apply-reconcile.ts b/packages/web/src/ui/hooks/apply-reconcile.ts similarity index 100% rename from src/ui/hooks/apply-reconcile.ts rename to packages/web/src/ui/hooks/apply-reconcile.ts diff --git a/src/ui/hooks/library-cache.ts b/packages/web/src/ui/hooks/library-cache.ts similarity index 100% rename from src/ui/hooks/library-cache.ts rename to packages/web/src/ui/hooks/library-cache.ts diff --git a/src/ui/hooks/mark-store.ts b/packages/web/src/ui/hooks/mark-store.ts similarity index 100% rename from src/ui/hooks/mark-store.ts rename to packages/web/src/ui/hooks/mark-store.ts diff --git a/src/ui/hooks/mark-undo-window.ts b/packages/web/src/ui/hooks/mark-undo-window.ts similarity index 100% rename from src/ui/hooks/mark-undo-window.ts rename to packages/web/src/ui/hooks/mark-undo-window.ts diff --git a/src/ui/hooks/query-freshness.ts b/packages/web/src/ui/hooks/query-freshness.ts similarity index 100% rename from src/ui/hooks/query-freshness.ts rename to packages/web/src/ui/hooks/query-freshness.ts diff --git a/src/ui/hooks/queue-order.ts b/packages/web/src/ui/hooks/queue-order.ts similarity index 100% rename from src/ui/hooks/queue-order.ts rename to packages/web/src/ui/hooks/queue-order.ts diff --git a/src/ui/hooks/resolveUnmark.ts b/packages/web/src/ui/hooks/resolveUnmark.ts similarity index 100% rename from src/ui/hooks/resolveUnmark.ts rename to packages/web/src/ui/hooks/resolveUnmark.ts diff --git a/src/ui/hooks/sync-activity-store.ts b/packages/web/src/ui/hooks/sync-activity-store.ts similarity index 100% rename from src/ui/hooks/sync-activity-store.ts rename to packages/web/src/ui/hooks/sync-activity-store.ts diff --git a/src/ui/hooks/useActivitiesPoll.ts b/packages/web/src/ui/hooks/useActivitiesPoll.ts similarity index 100% rename from src/ui/hooks/useActivitiesPoll.ts rename to packages/web/src/ui/hooks/useActivitiesPoll.ts diff --git a/src/ui/hooks/useBrowse.ts b/packages/web/src/ui/hooks/useBrowse.ts similarity index 100% rename from src/ui/hooks/useBrowse.ts rename to packages/web/src/ui/hooks/useBrowse.ts diff --git a/src/ui/hooks/useCalendar.ts b/packages/web/src/ui/hooks/useCalendar.ts similarity index 100% rename from src/ui/hooks/useCalendar.ts rename to packages/web/src/ui/hooks/useCalendar.ts diff --git a/src/ui/hooks/useDetailHeader.ts b/packages/web/src/ui/hooks/useDetailHeader.ts similarity index 100% rename from src/ui/hooks/useDetailHeader.ts rename to packages/web/src/ui/hooks/useDetailHeader.ts diff --git a/src/ui/hooks/useDocumentTitle.ts b/packages/web/src/ui/hooks/useDocumentTitle.ts similarity index 100% rename from src/ui/hooks/useDocumentTitle.ts rename to packages/web/src/ui/hooks/useDocumentTitle.ts diff --git a/src/ui/hooks/useEpisode.ts b/packages/web/src/ui/hooks/useEpisode.ts similarity index 100% rename from src/ui/hooks/useEpisode.ts rename to packages/web/src/ui/hooks/useEpisode.ts diff --git a/src/ui/hooks/useEpisodePlays.ts b/packages/web/src/ui/hooks/useEpisodePlays.ts similarity index 100% rename from src/ui/hooks/useEpisodePlays.ts rename to packages/web/src/ui/hooks/useEpisodePlays.ts diff --git a/src/ui/hooks/useEpisodeReminders.ts b/packages/web/src/ui/hooks/useEpisodeReminders.ts similarity index 100% rename from src/ui/hooks/useEpisodeReminders.ts rename to packages/web/src/ui/hooks/useEpisodeReminders.ts diff --git a/src/ui/hooks/useFlip.ts b/packages/web/src/ui/hooks/useFlip.ts similarity index 100% rename from src/ui/hooks/useFlip.ts rename to packages/web/src/ui/hooks/useFlip.ts diff --git a/src/ui/hooks/useHideShow.ts b/packages/web/src/ui/hooks/useHideShow.ts similarity index 100% rename from src/ui/hooks/useHideShow.ts rename to packages/web/src/ui/hooks/useHideShow.ts diff --git a/src/ui/hooks/useHistory.ts b/packages/web/src/ui/hooks/useHistory.ts similarity index 100% rename from src/ui/hooks/useHistory.ts rename to packages/web/src/ui/hooks/useHistory.ts diff --git a/src/ui/hooks/useIsOffline.ts b/packages/web/src/ui/hooks/useIsOffline.ts similarity index 100% rename from src/ui/hooks/useIsOffline.ts rename to packages/web/src/ui/hooks/useIsOffline.ts diff --git a/src/ui/hooks/useLibraryBuckets.ts b/packages/web/src/ui/hooks/useLibraryBuckets.ts similarity index 100% rename from src/ui/hooks/useLibraryBuckets.ts rename to packages/web/src/ui/hooks/useLibraryBuckets.ts diff --git a/src/ui/hooks/useLibrarySnapshot.ts b/packages/web/src/ui/hooks/useLibrarySnapshot.ts similarity index 100% rename from src/ui/hooks/useLibrarySnapshot.ts rename to packages/web/src/ui/hooks/useLibrarySnapshot.ts diff --git a/src/ui/hooks/useMarkSeason.ts b/packages/web/src/ui/hooks/useMarkSeason.ts similarity index 100% rename from src/ui/hooks/useMarkSeason.ts rename to packages/web/src/ui/hooks/useMarkSeason.ts diff --git a/src/ui/hooks/useMarkSnacks.ts b/packages/web/src/ui/hooks/useMarkSnacks.ts similarity index 100% rename from src/ui/hooks/useMarkSnacks.ts rename to packages/web/src/ui/hooks/useMarkSnacks.ts diff --git a/src/ui/hooks/useMarkWatched.ts b/packages/web/src/ui/hooks/useMarkWatched.ts similarity index 100% rename from src/ui/hooks/useMarkWatched.ts rename to packages/web/src/ui/hooks/useMarkWatched.ts diff --git a/src/ui/hooks/useMovieActions.ts b/packages/web/src/ui/hooks/useMovieActions.ts similarity index 100% rename from src/ui/hooks/useMovieActions.ts rename to packages/web/src/ui/hooks/useMovieActions.ts diff --git a/src/ui/hooks/useMovieDetail.ts b/packages/web/src/ui/hooks/useMovieDetail.ts similarity index 100% rename from src/ui/hooks/useMovieDetail.ts rename to packages/web/src/ui/hooks/useMovieDetail.ts diff --git a/src/ui/hooks/useMovieLibrary.ts b/packages/web/src/ui/hooks/useMovieLibrary.ts similarity index 100% rename from src/ui/hooks/useMovieLibrary.ts rename to packages/web/src/ui/hooks/useMovieLibrary.ts diff --git a/src/ui/hooks/useMovieRelated.ts b/packages/web/src/ui/hooks/useMovieRelated.ts similarity index 100% rename from src/ui/hooks/useMovieRelated.ts rename to packages/web/src/ui/hooks/useMovieRelated.ts diff --git a/src/ui/hooks/useOptimisticWrite.ts b/packages/web/src/ui/hooks/useOptimisticWrite.ts similarity index 100% rename from src/ui/hooks/useOptimisticWrite.ts rename to packages/web/src/ui/hooks/useOptimisticWrite.ts diff --git a/src/ui/hooks/useQueuedWrite.ts b/packages/web/src/ui/hooks/useQueuedWrite.ts similarity index 100% rename from src/ui/hooks/useQueuedWrite.ts rename to packages/web/src/ui/hooks/useQueuedWrite.ts diff --git a/src/ui/hooks/useRemovalSnacks.ts b/packages/web/src/ui/hooks/useRemovalSnacks.ts similarity index 100% rename from src/ui/hooks/useRemovalSnacks.ts rename to packages/web/src/ui/hooks/useRemovalSnacks.ts diff --git a/src/ui/hooks/useResumeOnMark.ts b/packages/web/src/ui/hooks/useResumeOnMark.ts similarity index 100% rename from src/ui/hooks/useResumeOnMark.ts rename to packages/web/src/ui/hooks/useResumeOnMark.ts diff --git a/src/ui/hooks/useSearch.ts b/packages/web/src/ui/hooks/useSearch.ts similarity index 100% rename from src/ui/hooks/useSearch.ts rename to packages/web/src/ui/hooks/useSearch.ts diff --git a/src/ui/hooks/useSeasonReversal.ts b/packages/web/src/ui/hooks/useSeasonReversal.ts similarity index 100% rename from src/ui/hooks/useSeasonReversal.ts rename to packages/web/src/ui/hooks/useSeasonReversal.ts diff --git a/src/ui/hooks/useSeasons.ts b/packages/web/src/ui/hooks/useSeasons.ts similarity index 100% rename from src/ui/hooks/useSeasons.ts rename to packages/web/src/ui/hooks/useSeasons.ts diff --git a/src/ui/hooks/useShowArt.ts b/packages/web/src/ui/hooks/useShowArt.ts similarity index 100% rename from src/ui/hooks/useShowArt.ts rename to packages/web/src/ui/hooks/useShowArt.ts diff --git a/src/ui/hooks/useShowDetail.ts b/packages/web/src/ui/hooks/useShowDetail.ts similarity index 100% rename from src/ui/hooks/useShowDetail.ts rename to packages/web/src/ui/hooks/useShowDetail.ts diff --git a/src/ui/hooks/useStats.ts b/packages/web/src/ui/hooks/useStats.ts similarity index 100% rename from src/ui/hooks/useStats.ts rename to packages/web/src/ui/hooks/useStats.ts diff --git a/src/ui/hooks/useSyncNow.ts b/packages/web/src/ui/hooks/useSyncNow.ts similarity index 100% rename from src/ui/hooks/useSyncNow.ts rename to packages/web/src/ui/hooks/useSyncNow.ts diff --git a/src/ui/hooks/useToggleWatchlist.ts b/packages/web/src/ui/hooks/useToggleWatchlist.ts similarity index 100% rename from src/ui/hooks/useToggleWatchlist.ts rename to packages/web/src/ui/hooks/useToggleWatchlist.ts diff --git a/src/ui/hooks/useUpNext.ts b/packages/web/src/ui/hooks/useUpNext.ts similarity index 100% rename from src/ui/hooks/useUpNext.ts rename to packages/web/src/ui/hooks/useUpNext.ts diff --git a/src/ui/hooks/useUserProfile.ts b/packages/web/src/ui/hooks/useUserProfile.ts similarity index 100% rename from src/ui/hooks/useUserProfile.ts rename to packages/web/src/ui/hooks/useUserProfile.ts diff --git a/src/ui/hooks/useWatchlistAdd.ts b/packages/web/src/ui/hooks/useWatchlistAdd.ts similarity index 100% rename from src/ui/hooks/useWatchlistAdd.ts rename to packages/web/src/ui/hooks/useWatchlistAdd.ts diff --git a/src/ui/prefs/device-prefs.ts b/packages/web/src/ui/prefs/device-prefs.ts similarity index 100% rename from src/ui/prefs/device-prefs.ts rename to packages/web/src/ui/prefs/device-prefs.ts diff --git a/src/ui/prefs/media-visibility.ts b/packages/web/src/ui/prefs/media-visibility.ts similarity index 100% rename from src/ui/prefs/media-visibility.ts rename to packages/web/src/ui/prefs/media-visibility.ts diff --git a/src/ui/prefs/pref-storage.ts b/packages/web/src/ui/prefs/pref-storage.ts similarity index 100% rename from src/ui/prefs/pref-storage.ts rename to packages/web/src/ui/prefs/pref-storage.ts diff --git a/src/ui/prefs/prefs-store.ts b/packages/web/src/ui/prefs/prefs-store.ts similarity index 100% rename from src/ui/prefs/prefs-store.ts rename to packages/web/src/ui/prefs/prefs-store.ts diff --git a/src/ui/prefs/threshold.ts b/packages/web/src/ui/prefs/threshold.ts similarity index 100% rename from src/ui/prefs/threshold.ts rename to packages/web/src/ui/prefs/threshold.ts diff --git a/src/ui/prefs/tracking.ts b/packages/web/src/ui/prefs/tracking.ts similarity index 100% rename from src/ui/prefs/tracking.ts rename to packages/web/src/ui/prefs/tracking.ts diff --git a/src/ui/runtime/app-version.ts b/packages/web/src/ui/runtime/app-version.ts similarity index 100% rename from src/ui/runtime/app-version.ts rename to packages/web/src/ui/runtime/app-version.ts diff --git a/src/ui/runtime/haptics.ts b/packages/web/src/ui/runtime/haptics.ts similarity index 100% rename from src/ui/runtime/haptics.ts rename to packages/web/src/ui/runtime/haptics.ts diff --git a/src/ui/runtime/persist-buster.ts b/packages/web/src/ui/runtime/persist-buster.ts similarity index 100% rename from src/ui/runtime/persist-buster.ts rename to packages/web/src/ui/runtime/persist-buster.ts diff --git a/src/ui/runtime/reminders.ts b/packages/web/src/ui/runtime/reminders.ts similarity index 100% rename from src/ui/runtime/reminders.ts rename to packages/web/src/ui/runtime/reminders.ts diff --git a/src/ui/runtime/runtime.ts b/packages/web/src/ui/runtime/runtime.ts similarity index 100% rename from src/ui/runtime/runtime.ts rename to packages/web/src/ui/runtime/runtime.ts diff --git a/src/ui/screens/calendar/Calendar.tsx b/packages/web/src/ui/screens/calendar/Calendar.tsx similarity index 100% rename from src/ui/screens/calendar/Calendar.tsx rename to packages/web/src/ui/screens/calendar/Calendar.tsx diff --git a/src/ui/screens/calendar/CalendarAgenda.tsx b/packages/web/src/ui/screens/calendar/CalendarAgenda.tsx similarity index 100% rename from src/ui/screens/calendar/CalendarAgenda.tsx rename to packages/web/src/ui/screens/calendar/CalendarAgenda.tsx diff --git a/src/ui/screens/calendar/CalendarRow.tsx b/packages/web/src/ui/screens/calendar/CalendarRow.tsx similarity index 100% rename from src/ui/screens/calendar/CalendarRow.tsx rename to packages/web/src/ui/screens/calendar/CalendarRow.tsx diff --git a/src/ui/screens/calendar/agenda.ts b/packages/web/src/ui/screens/calendar/agenda.ts similarity index 100% rename from src/ui/screens/calendar/agenda.ts rename to packages/web/src/ui/screens/calendar/agenda.ts diff --git a/src/ui/screens/episode-detail/EpisodeSheet.tsx b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx similarity index 100% rename from src/ui/screens/episode-detail/EpisodeSheet.tsx rename to packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx diff --git a/src/ui/screens/episode-detail/sheet-logic.ts b/packages/web/src/ui/screens/episode-detail/sheet-logic.ts similarity index 100% rename from src/ui/screens/episode-detail/sheet-logic.ts rename to packages/web/src/ui/screens/episode-detail/sheet-logic.ts diff --git a/src/ui/screens/episode-detail/sheet-return.ts b/packages/web/src/ui/screens/episode-detail/sheet-return.ts similarity index 100% rename from src/ui/screens/episode-detail/sheet-return.ts rename to packages/web/src/ui/screens/episode-detail/sheet-return.ts diff --git a/src/ui/screens/history/History.tsx b/packages/web/src/ui/screens/history/History.tsx similarity index 100% rename from src/ui/screens/history/History.tsx rename to packages/web/src/ui/screens/history/History.tsx diff --git a/src/ui/screens/history/HistorySearch.tsx b/packages/web/src/ui/screens/history/HistorySearch.tsx similarity index 100% rename from src/ui/screens/history/HistorySearch.tsx rename to packages/web/src/ui/screens/history/HistorySearch.tsx diff --git a/src/ui/screens/history/MonthJumpSheet.tsx b/packages/web/src/ui/screens/history/MonthJumpSheet.tsx similarity index 100% rename from src/ui/screens/history/MonthJumpSheet.tsx rename to packages/web/src/ui/screens/history/MonthJumpSheet.tsx diff --git a/src/ui/screens/history/history-view.ts b/packages/web/src/ui/screens/history/history-view.ts similarity index 100% rename from src/ui/screens/history/history-view.ts rename to packages/web/src/ui/screens/history/history-view.ts diff --git a/src/ui/screens/library/Library.tsx b/packages/web/src/ui/screens/library/Library.tsx similarity index 100% rename from src/ui/screens/library/Library.tsx rename to packages/web/src/ui/screens/library/Library.tsx diff --git a/src/ui/screens/library/LibrarySkeleton.tsx b/packages/web/src/ui/screens/library/LibrarySkeleton.tsx similarity index 100% rename from src/ui/screens/library/LibrarySkeleton.tsx rename to packages/web/src/ui/screens/library/LibrarySkeleton.tsx diff --git a/src/ui/screens/library/MovieTile.tsx b/packages/web/src/ui/screens/library/MovieTile.tsx similarity index 100% rename from src/ui/screens/library/MovieTile.tsx rename to packages/web/src/ui/screens/library/MovieTile.tsx diff --git a/src/ui/screens/library/PosterGrid.tsx b/packages/web/src/ui/screens/library/PosterGrid.tsx similarity index 100% rename from src/ui/screens/library/PosterGrid.tsx rename to packages/web/src/ui/screens/library/PosterGrid.tsx diff --git a/src/ui/screens/library/ShowTile.tsx b/packages/web/src/ui/screens/library/ShowTile.tsx similarity index 100% rename from src/ui/screens/library/ShowTile.tsx rename to packages/web/src/ui/screens/library/ShowTile.tsx diff --git a/src/ui/screens/movie-detail/MovieDetail.tsx b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx similarity index 100% rename from src/ui/screens/movie-detail/MovieDetail.tsx rename to packages/web/src/ui/screens/movie-detail/MovieDetail.tsx diff --git a/src/ui/screens/onboarding/Onboarding.tsx b/packages/web/src/ui/screens/onboarding/Onboarding.tsx similarity index 100% rename from src/ui/screens/onboarding/Onboarding.tsx rename to packages/web/src/ui/screens/onboarding/Onboarding.tsx diff --git a/src/ui/screens/profile/Profile.tsx b/packages/web/src/ui/screens/profile/Profile.tsx similarity index 100% rename from src/ui/screens/profile/Profile.tsx rename to packages/web/src/ui/screens/profile/Profile.tsx diff --git a/src/ui/screens/profile/stats.ts b/packages/web/src/ui/screens/profile/stats.ts similarity index 100% rename from src/ui/screens/profile/stats.ts rename to packages/web/src/ui/screens/profile/stats.ts diff --git a/src/ui/screens/search/HitTile.tsx b/packages/web/src/ui/screens/search/HitTile.tsx similarity index 100% rename from src/ui/screens/search/HitTile.tsx rename to packages/web/src/ui/screens/search/HitTile.tsx diff --git a/src/ui/screens/search/ResultRow.tsx b/packages/web/src/ui/screens/search/ResultRow.tsx similarity index 100% rename from src/ui/screens/search/ResultRow.tsx rename to packages/web/src/ui/screens/search/ResultRow.tsx diff --git a/src/ui/screens/search/Search.tsx b/packages/web/src/ui/screens/search/Search.tsx similarity index 100% rename from src/ui/screens/search/Search.tsx rename to packages/web/src/ui/screens/search/Search.tsx diff --git a/src/ui/screens/search/SearchField.tsx b/packages/web/src/ui/screens/search/SearchField.tsx similarity index 100% rename from src/ui/screens/search/SearchField.tsx rename to packages/web/src/ui/screens/search/SearchField.tsx diff --git a/src/ui/screens/settings/SettingRow.tsx b/packages/web/src/ui/screens/settings/SettingRow.tsx similarity index 100% rename from src/ui/screens/settings/SettingRow.tsx rename to packages/web/src/ui/screens/settings/SettingRow.tsx diff --git a/src/ui/screens/settings/Settings.tsx b/packages/web/src/ui/screens/settings/Settings.tsx similarity index 100% rename from src/ui/screens/settings/Settings.tsx rename to packages/web/src/ui/screens/settings/Settings.tsx diff --git a/src/ui/screens/settings/SignOutRow.tsx b/packages/web/src/ui/screens/settings/SignOutRow.tsx similarity index 100% rename from src/ui/screens/settings/SignOutRow.tsx rename to packages/web/src/ui/screens/settings/SignOutRow.tsx diff --git a/src/ui/screens/settings/sync-status.ts b/packages/web/src/ui/screens/settings/sync-status.ts similarity index 100% rename from src/ui/screens/settings/sync-status.ts rename to packages/web/src/ui/screens/settings/sync-status.ts diff --git a/src/ui/screens/settings/useSyncStatus.ts b/packages/web/src/ui/screens/settings/useSyncStatus.ts similarity index 100% rename from src/ui/screens/settings/useSyncStatus.ts rename to packages/web/src/ui/screens/settings/useSyncStatus.ts diff --git a/src/ui/screens/show-detail/ContinueBar.tsx b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx similarity index 100% rename from src/ui/screens/show-detail/ContinueBar.tsx rename to packages/web/src/ui/screens/show-detail/ContinueBar.tsx diff --git a/src/ui/screens/show-detail/DetailChrome.tsx b/packages/web/src/ui/screens/show-detail/DetailChrome.tsx similarity index 100% rename from src/ui/screens/show-detail/DetailChrome.tsx rename to packages/web/src/ui/screens/show-detail/DetailChrome.tsx diff --git a/src/ui/screens/show-detail/SeasonList.tsx b/packages/web/src/ui/screens/show-detail/SeasonList.tsx similarity index 100% rename from src/ui/screens/show-detail/SeasonList.tsx rename to packages/web/src/ui/screens/show-detail/SeasonList.tsx diff --git a/src/ui/screens/show-detail/ShowDetail.tsx b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx similarity index 100% rename from src/ui/screens/show-detail/ShowDetail.tsx rename to packages/web/src/ui/screens/show-detail/ShowDetail.tsx diff --git a/src/ui/screens/show-detail/detail-logic.ts b/packages/web/src/ui/screens/show-detail/detail-logic.ts similarity index 100% rename from src/ui/screens/show-detail/detail-logic.ts rename to packages/web/src/ui/screens/show-detail/detail-logic.ts diff --git a/src/ui/screens/up-next/LapsedDrawer.tsx b/packages/web/src/ui/screens/up-next/LapsedDrawer.tsx similarity index 100% rename from src/ui/screens/up-next/LapsedDrawer.tsx rename to packages/web/src/ui/screens/up-next/LapsedDrawer.tsx diff --git a/src/ui/screens/up-next/OnTheWay.tsx b/packages/web/src/ui/screens/up-next/OnTheWay.tsx similarity index 100% rename from src/ui/screens/up-next/OnTheWay.tsx rename to packages/web/src/ui/screens/up-next/OnTheWay.tsx diff --git a/src/ui/screens/up-next/Poster.tsx b/packages/web/src/ui/screens/up-next/Poster.tsx similarity index 100% rename from src/ui/screens/up-next/Poster.tsx rename to packages/web/src/ui/screens/up-next/Poster.tsx diff --git a/src/ui/screens/up-next/Previously.tsx b/packages/web/src/ui/screens/up-next/Previously.tsx similarity index 100% rename from src/ui/screens/up-next/Previously.tsx rename to packages/web/src/ui/screens/up-next/Previously.tsx diff --git a/src/ui/screens/up-next/QueueRow.tsx b/packages/web/src/ui/screens/up-next/QueueRow.tsx similarity index 100% rename from src/ui/screens/up-next/QueueRow.tsx rename to packages/web/src/ui/screens/up-next/QueueRow.tsx diff --git a/src/ui/screens/up-next/UpNext.tsx b/packages/web/src/ui/screens/up-next/UpNext.tsx similarity index 100% rename from src/ui/screens/up-next/UpNext.tsx rename to packages/web/src/ui/screens/up-next/UpNext.tsx diff --git a/src/ui/screens/up-next/useQueueCheck.ts b/packages/web/src/ui/screens/up-next/useQueueCheck.ts similarity index 100% rename from src/ui/screens/up-next/useQueueCheck.ts rename to packages/web/src/ui/screens/up-next/useQueueCheck.ts diff --git a/src/ui/styles.css b/packages/web/src/ui/styles.css similarity index 100% rename from src/ui/styles.css rename to packages/web/src/ui/styles.css diff --git a/src/ui/styles/base.css b/packages/web/src/ui/styles/base.css similarity index 100% rename from src/ui/styles/base.css rename to packages/web/src/ui/styles/base.css diff --git a/src/ui/styles/components.css b/packages/web/src/ui/styles/components.css similarity index 100% rename from src/ui/styles/components.css rename to packages/web/src/ui/styles/components.css diff --git a/src/ui/styles/layout.css b/packages/web/src/ui/styles/layout.css similarity index 100% rename from src/ui/styles/layout.css rename to packages/web/src/ui/styles/layout.css diff --git a/src/ui/styles/screens-account.css b/packages/web/src/ui/styles/screens-account.css similarity index 100% rename from src/ui/styles/screens-account.css rename to packages/web/src/ui/styles/screens-account.css diff --git a/src/ui/styles/screens-calendar.css b/packages/web/src/ui/styles/screens-calendar.css similarity index 100% rename from src/ui/styles/screens-calendar.css rename to packages/web/src/ui/styles/screens-calendar.css diff --git a/src/ui/styles/screens-detail.css b/packages/web/src/ui/styles/screens-detail.css similarity index 100% rename from src/ui/styles/screens-detail.css rename to packages/web/src/ui/styles/screens-detail.css diff --git a/src/ui/styles/screens-library.css b/packages/web/src/ui/styles/screens-library.css similarity index 100% rename from src/ui/styles/screens-library.css rename to packages/web/src/ui/styles/screens-library.css diff --git a/src/ui/styles/screens-search.css b/packages/web/src/ui/styles/screens-search.css similarity index 100% rename from src/ui/styles/screens-search.css rename to packages/web/src/ui/styles/screens-search.css diff --git a/src/ui/styles/screens.css b/packages/web/src/ui/styles/screens.css similarity index 100% rename from src/ui/styles/screens.css rename to packages/web/src/ui/styles/screens.css diff --git a/src/ui/theme/ThemeToggle.tsx b/packages/web/src/ui/theme/ThemeToggle.tsx similarity index 100% rename from src/ui/theme/ThemeToggle.tsx rename to packages/web/src/ui/theme/ThemeToggle.tsx diff --git a/src/ui/theme/theme-store.ts b/packages/web/src/ui/theme/theme-store.ts similarity index 100% rename from src/ui/theme/theme-store.ts rename to packages/web/src/ui/theme/theme-store.ts diff --git a/src/ui/theme/theme.ts b/packages/web/src/ui/theme/theme.ts similarity index 100% rename from src/ui/theme/theme.ts rename to packages/web/src/ui/theme/theme.ts diff --git a/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to packages/web/src/vite-env.d.ts diff --git a/test/ci/diff-footprint.test.ts b/packages/web/test/ci/diff-footprint.test.ts similarity index 72% rename from test/ci/diff-footprint.test.ts rename to packages/web/test/ci/diff-footprint.test.ts index b83c078e..d7ca18fe 100644 --- a/test/ci/diff-footprint.test.ts +++ b/packages/web/test/ci/diff-footprint.test.ts @@ -36,21 +36,21 @@ describe("diff footprint", () => { git(repository, "config", "user.name", "Cue Tests"); git(repository, "config", "user.email", "cue-tests@example.invalid"); - write(repository, "src/removed.ts", "const removed = true;\n// removed\n\n"); - write(repository, "test/moved.ts", "one\ntwo\n"); - write(repository, "e2e/removed.ts", "removed\n"); + write(repository, "packages/web/src/removed.ts", "const removed = true;\n// removed\n\n"); + write(repository, "packages/web/test/moved.ts", "one\ntwo\n"); + write(repository, "packages/web/e2e/removed.ts", "removed\n"); write(repository, "docs/removed.md", "removed\n"); write(repository, "assets/image.bin", new Uint8Array([0, 1, 2])); git(repository, "add", "."); git(repository, "commit", "--quiet", "-m", "base"); - git(repository, "mv", "test/moved.ts", "src/moved.ts"); - rmSync(path.join(repository, "src/removed.ts")); - rmSync(path.join(repository, "e2e/removed.ts")); + git(repository, "mv", "packages/web/test/moved.ts", "packages/web/src/moved.ts"); + rmSync(path.join(repository, "packages/web/src/removed.ts")); + rmSync(path.join(repository, "packages/web/e2e/removed.ts")); rmSync(path.join(repository, "docs/removed.md")); - write(repository, "src/added.ts", "const added = true;\n /* added */\n \n"); - write(repository, "test/added.ts", "added\n"); - write(repository, "e2e/added.ts", "one\ntwo\n"); + write(repository, "packages/web/src/added.ts", "const added = true;\n /* added */\n \n"); + write(repository, "packages/web/test/added.ts", "added\n"); + write(repository, "packages/web/e2e/added.ts", "one\ntwo\n"); write(repository, "docs/added.md", "one\ntwo\nthree\n"); write(repository, "assets/image.bin", new Uint8Array([0, 3, 4])); git(repository, "add", "-A"); @@ -63,9 +63,9 @@ describe("diff footprint", () => { }); expect(output.split("\n")[0]).toBe(""); - expect(output).toContain("| product (src/) | 5 | 3 | +2 |"); - expect(output).toContain("| tests (test/) | 1 | 2 | -1 |"); - expect(output).toContain("| e2e (e2e/) | 2 | 1 | +1 |"); + expect(output).toContain("| product (packages/*/src/) | 5 | 3 | +2 |"); + expect(output).toContain("| tests (packages/*/test/) | 1 | 2 | -1 |"); + expect(output).toContain("| e2e (packages/*/e2e/) | 2 | 1 | +1 |"); expect(output).toContain("| other | 3 | 1 | +2 |"); expect(output).toContain("| total | 11 | 7 | +4 |"); expect(output).toContain( diff --git a/test/ci/git-env.test.ts b/packages/web/test/ci/git-env.test.ts similarity index 100% rename from test/ci/git-env.test.ts rename to packages/web/test/ci/git-env.test.ts diff --git a/test/ci/release-paths.test.ts b/packages/web/test/ci/release-paths.test.ts similarity index 94% rename from test/ci/release-paths.test.ts rename to packages/web/test/ci/release-paths.test.ts index c6e41cf0..bba8a664 100644 --- a/test/ci/release-paths.test.ts +++ b/packages/web/test/ci/release-paths.test.ts @@ -17,12 +17,15 @@ const NOT_REQUIRED = ["footprint"]; // dist verbatim, so excluding *.md by extension misclassified those files; the // existing "when in doubt, SHIPS" rule applies to every shipping tree. const SHIPS = [ - "src/**", - "index.html", - "public/**", - "vite.config.ts", + "packages/*/src/**", + "packages/*/index.html", + "packages/*/public/**", + "packages/*/vite.config.ts", + "packages/*/package.json", + "packages/*/tsconfig.json", "package.json", "pnpm-lock.yaml", + "pnpm-workspace.yaml", "capacitor.config.ts", "android/**", "ios/**", @@ -34,6 +37,7 @@ const SHIPS = [ "scripts/verify-apk.sh", "scripts/verify-bundle.sh", "tsconfig.json", + "tsconfig.base.json", ] as const; const DOES_NOT_SHIP = [ @@ -41,9 +45,13 @@ const DOES_NOT_SHIP = [ "*.md", ".github/**", "LICENSE", - "e2e/**", - "test/**", - "playwright.config.ts", + "packages/*/e2e/**", + "packages/*/test/**", + "packages/*/playwright.config.ts", + "packages/*/vitest.config.ts", + "packages/*/.env.example", + "packages/*/.env.test", + "packages/*/.env.mock", "vitest.config.ts", "lefthook.yml", "cspell.json", @@ -55,9 +63,7 @@ const DOES_NOT_SHIP = [ "scripts/diff-footprint.sh", "scripts/mock-trakt/**", "scripts/write-buster.mjs", - ".env.example", - ".env.test", - ".env.mock", + "tsconfig.depcruise.json", ".gitignore", "assets/**", ] as const; diff --git a/test/data/_msw.ts b/packages/web/test/data/_msw.ts similarity index 100% rename from test/data/_msw.ts rename to packages/web/test/data/_msw.ts diff --git a/test/data/auth-oauth.test.ts b/packages/web/test/data/auth-oauth.test.ts similarity index 100% rename from test/data/auth-oauth.test.ts rename to packages/web/test/data/auth-oauth.test.ts diff --git a/test/data/auth-pkce.test.ts b/packages/web/test/data/auth-pkce.test.ts similarity index 100% rename from test/data/auth-pkce.test.ts rename to packages/web/test/data/auth-pkce.test.ts diff --git a/test/data/authorized-fetch.test.ts b/packages/web/test/data/authorized-fetch.test.ts similarity index 100% rename from test/data/authorized-fetch.test.ts rename to packages/web/test/data/authorized-fetch.test.ts diff --git a/test/data/image-source.test.ts b/packages/web/test/data/image-source.test.ts similarity index 100% rename from test/data/image-source.test.ts rename to packages/web/test/data/image-source.test.ts diff --git a/test/data/query-invalidation.test.ts b/packages/web/test/data/query-invalidation.test.ts similarity index 100% rename from test/data/query-invalidation.test.ts rename to packages/web/test/data/query-invalidation.test.ts diff --git a/test/data/query-keys.test.ts b/packages/web/test/data/query-keys.test.ts similarity index 100% rename from test/data/query-keys.test.ts rename to packages/web/test/data/query-keys.test.ts diff --git a/test/data/read-budget.test.ts b/packages/web/test/data/read-budget.test.ts similarity index 100% rename from test/data/read-budget.test.ts rename to packages/web/test/data/read-budget.test.ts diff --git a/test/data/trakt-calendar.test.ts b/packages/web/test/data/trakt-calendar.test.ts similarity index 100% rename from test/data/trakt-calendar.test.ts rename to packages/web/test/data/trakt-calendar.test.ts diff --git a/test/data/trakt-client.test.ts b/packages/web/test/data/trakt-client.test.ts similarity index 100% rename from test/data/trakt-client.test.ts rename to packages/web/test/data/trakt-client.test.ts diff --git a/test/data/trakt-endpoints.test.ts b/packages/web/test/data/trakt-endpoints.test.ts similarity index 100% rename from test/data/trakt-endpoints.test.ts rename to packages/web/test/data/trakt-endpoints.test.ts diff --git a/test/data/trakt-episode-detail.test.ts b/packages/web/test/data/trakt-episode-detail.test.ts similarity index 100% rename from test/data/trakt-episode-detail.test.ts rename to packages/web/test/data/trakt-episode-detail.test.ts diff --git a/test/data/trakt-history.test.ts b/packages/web/test/data/trakt-history.test.ts similarity index 100% rename from test/data/trakt-history.test.ts rename to packages/web/test/data/trakt-history.test.ts diff --git a/test/data/trakt-library.test.ts b/packages/web/test/data/trakt-library.test.ts similarity index 100% rename from test/data/trakt-library.test.ts rename to packages/web/test/data/trakt-library.test.ts diff --git a/test/data/trakt-movie-library.test.ts b/packages/web/test/data/trakt-movie-library.test.ts similarity index 100% rename from test/data/trakt-movie-library.test.ts rename to packages/web/test/data/trakt-movie-library.test.ts diff --git a/test/data/trakt-pooled-endpoints.test.ts b/packages/web/test/data/trakt-pooled-endpoints.test.ts similarity index 100% rename from test/data/trakt-pooled-endpoints.test.ts rename to packages/web/test/data/trakt-pooled-endpoints.test.ts diff --git a/test/data/trakt-repositories.test.ts b/packages/web/test/data/trakt-repositories.test.ts similarity index 100% rename from test/data/trakt-repositories.test.ts rename to packages/web/test/data/trakt-repositories.test.ts diff --git a/test/data/trakt-search.test.ts b/packages/web/test/data/trakt-search.test.ts similarity index 100% rename from test/data/trakt-search.test.ts rename to packages/web/test/data/trakt-search.test.ts diff --git a/test/data/trakt-show-detail.test.ts b/packages/web/test/data/trakt-show-detail.test.ts similarity index 100% rename from test/data/trakt-show-detail.test.ts rename to packages/web/test/data/trakt-show-detail.test.ts diff --git a/test/data/trakt-transport.test.ts b/packages/web/test/data/trakt-transport.test.ts similarity index 100% rename from test/data/trakt-transport.test.ts rename to packages/web/test/data/trakt-transport.test.ts diff --git a/test/data/trakt-user-profile.test.ts b/packages/web/test/data/trakt-user-profile.test.ts similarity index 100% rename from test/data/trakt-user-profile.test.ts rename to packages/web/test/data/trakt-user-profile.test.ts diff --git a/test/domain/_helpers.ts b/packages/web/test/domain/_helpers.ts similarity index 100% rename from test/domain/_helpers.ts rename to packages/web/test/domain/_helpers.ts diff --git a/test/domain/auth-token.test.ts b/packages/web/test/domain/auth-token.test.ts similarity index 100% rename from test/domain/auth-token.test.ts rename to packages/web/test/domain/auth-token.test.ts diff --git a/test/domain/calendar.test.ts b/packages/web/test/domain/calendar.test.ts similarity index 100% rename from test/domain/calendar.test.ts rename to packages/web/test/domain/calendar.test.ts diff --git a/test/domain/history.test.ts b/packages/web/test/domain/history.test.ts similarity index 100% rename from test/domain/history.test.ts rename to packages/web/test/domain/history.test.ts diff --git a/test/domain/library-buckets.test.ts b/packages/web/test/domain/library-buckets.test.ts similarity index 100% rename from test/domain/library-buckets.test.ts rename to packages/web/test/domain/library-buckets.test.ts diff --git a/test/domain/recently-aired.test.ts b/packages/web/test/domain/recently-aired.test.ts similarity index 100% rename from test/domain/recently-aired.test.ts rename to packages/web/test/domain/recently-aired.test.ts diff --git a/test/domain/reminders.test.ts b/packages/web/test/domain/reminders.test.ts similarity index 100% rename from test/domain/reminders.test.ts rename to packages/web/test/domain/reminders.test.ts diff --git a/test/domain/reversal.test.ts b/packages/web/test/domain/reversal.test.ts similarity index 100% rename from test/domain/reversal.test.ts rename to packages/web/test/domain/reversal.test.ts diff --git a/test/domain/sync-activities.test.ts b/packages/web/test/domain/sync-activities.test.ts similarity index 100% rename from test/domain/sync-activities.test.ts rename to packages/web/test/domain/sync-activities.test.ts diff --git a/test/domain/time.test.ts b/packages/web/test/domain/time.test.ts similarity index 100% rename from test/domain/time.test.ts rename to packages/web/test/domain/time.test.ts diff --git a/test/domain/up-next.test.ts b/packages/web/test/domain/up-next.test.ts similarity index 100% rename from test/domain/up-next.test.ts rename to packages/web/test/domain/up-next.test.ts diff --git a/test/domain/watch-status.test.ts b/packages/web/test/domain/watch-status.test.ts similarity index 100% rename from test/domain/watch-status.test.ts rename to packages/web/test/domain/watch-status.test.ts diff --git a/test/domain/write-queue-bulk.test.ts b/packages/web/test/domain/write-queue-bulk.test.ts similarity index 100% rename from test/domain/write-queue-bulk.test.ts rename to packages/web/test/domain/write-queue-bulk.test.ts diff --git a/test/domain/write-queue-classify.test.ts b/packages/web/test/domain/write-queue-classify.test.ts similarity index 100% rename from test/domain/write-queue-classify.test.ts rename to packages/web/test/domain/write-queue-classify.test.ts diff --git a/test/domain/write-queue-coalesce.test.ts b/packages/web/test/domain/write-queue-coalesce.test.ts similarity index 100% rename from test/domain/write-queue-coalesce.test.ts rename to packages/web/test/domain/write-queue-coalesce.test.ts diff --git a/test/domain/write-queue-ops.test.ts b/packages/web/test/domain/write-queue-ops.test.ts similarity index 100% rename from test/domain/write-queue-ops.test.ts rename to packages/web/test/domain/write-queue-ops.test.ts diff --git a/test/domain/write-queue-queue.test.ts b/packages/web/test/domain/write-queue-queue.test.ts similarity index 100% rename from test/domain/write-queue-queue.test.ts rename to packages/web/test/domain/write-queue-queue.test.ts diff --git a/test/harness/mock-trakt.test.ts b/packages/web/test/harness/mock-trakt.test.ts similarity index 99% rename from test/harness/mock-trakt.test.ts rename to packages/web/test/harness/mock-trakt.test.ts index fcb83b35..39b583a8 100644 --- a/test/harness/mock-trakt.test.ts +++ b/packages/web/test/harness/mock-trakt.test.ts @@ -33,7 +33,7 @@ import { loadUpNextEntries } from "@data/trakt/read-budget"; import { groupUpNext } from "@domain/up-next"; import { DEFAULT_STALENESS_THRESHOLD_MS } from "@domain/watch-status"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createMockTrakt } from "../../scripts/mock-trakt/server.mjs"; +import { createMockTrakt } from "../../../../scripts/mock-trakt/server.mjs"; /** * What stops `scripts/mock-trakt` drifting from the app it exists to feed. The diff --git a/test/native.test.ts b/packages/web/test/native.test.ts similarity index 100% rename from test/native.test.ts rename to packages/web/test/native.test.ts diff --git a/test/platform/back-button.test.ts b/packages/web/test/platform/back-button.test.ts similarity index 100% rename from test/platform/back-button.test.ts rename to packages/web/test/platform/back-button.test.ts diff --git a/test/platform/haptics.test.ts b/packages/web/test/platform/haptics.test.ts similarity index 100% rename from test/platform/haptics.test.ts rename to packages/web/test/platform/haptics.test.ts diff --git a/test/platform/json-store.test.ts b/packages/web/test/platform/json-store.test.ts similarity index 100% rename from test/platform/json-store.test.ts rename to packages/web/test/platform/json-store.test.ts diff --git a/test/platform/native-feel.test.ts b/packages/web/test/platform/native-feel.test.ts similarity index 100% rename from test/platform/native-feel.test.ts rename to packages/web/test/platform/native-feel.test.ts diff --git a/test/platform/platform.test.ts b/packages/web/test/platform/platform.test.ts similarity index 100% rename from test/platform/platform.test.ts rename to packages/web/test/platform/platform.test.ts diff --git a/test/platform/reminders.test.ts b/packages/web/test/platform/reminders.test.ts similarity index 100% rename from test/platform/reminders.test.ts rename to packages/web/test/platform/reminders.test.ts diff --git a/test/privacy-claims.test.ts b/packages/web/test/privacy-claims.test.ts similarity index 96% rename from test/privacy-claims.test.ts rename to packages/web/test/privacy-claims.test.ts index 480928bb..4cab9326 100644 --- a/test/privacy-claims.test.ts +++ b/packages/web/test/privacy-claims.test.ts @@ -4,13 +4,13 @@ import { REMINDER_WINDOW_DAYS } from "@domain/reminders"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; -import androidManifestSource from "../android/app/src/main/AndroidManifest.xml?raw"; -import extractionRulesSource from "../android/app/src/main/res/xml/data_extraction_rules.xml?raw"; -import capacitorConfig from "../capacitor.config"; -import servedPolicy from "../docs/index.html?raw"; -import infoPlistSource from "../ios/App/App/Info.plist?raw"; -import policy from "../PRIVACY.md?raw"; -import readme from "../README.md?raw"; +import androidManifestSource from "../../../android/app/src/main/AndroidManifest.xml?raw"; +import extractionRulesSource from "../../../android/app/src/main/res/xml/data_extraction_rules.xml?raw"; +import capacitorConfig from "../../../capacitor.config"; +import servedPolicy from "../../../docs/index.html?raw"; +import infoPlistSource from "../../../ios/App/App/Info.plist?raw"; +import policy from "../../../PRIVACY.md?raw"; +import readme from "../../../README.md?raw"; import runtimeSource from "../src/app/runtime/create-runtime.ts?raw"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -74,7 +74,7 @@ const postureAttributes = [ ]; const variantManifests = Object.entries( - import.meta.glob("../android/app/src/*/AndroidManifest.xml", { + import.meta.glob("../../../android/app/src/*/AndroidManifest.xml", { query: "?raw", import: "default", eager: true, diff --git a/test/setup.ts b/packages/web/test/setup.ts similarity index 100% rename from test/setup.ts rename to packages/web/test/setup.ts diff --git a/test/support/capacitor-preferences-mock.ts b/packages/web/test/support/capacitor-preferences-mock.ts similarity index 100% rename from test/support/capacitor-preferences-mock.ts rename to packages/web/test/support/capacitor-preferences-mock.ts diff --git a/test/support/composition-root-mocks.tsx b/packages/web/test/support/composition-root-mocks.tsx similarity index 100% rename from test/support/composition-root-mocks.tsx rename to packages/web/test/support/composition-root-mocks.tsx diff --git a/test/support/git-env.ts b/packages/web/test/support/git-env.ts similarity index 100% rename from test/support/git-env.ts rename to packages/web/test/support/git-env.ts diff --git a/test/ui/_mount.tsx b/packages/web/test/ui/_mount.tsx similarity index 100% rename from test/ui/_mount.tsx rename to packages/web/test/ui/_mount.tsx diff --git a/test/ui/activities-poll.test.tsx b/packages/web/test/ui/activities-poll.test.tsx similarity index 100% rename from test/ui/activities-poll.test.tsx rename to packages/web/test/ui/activities-poll.test.tsx diff --git a/test/ui/calendar-agenda.test.ts b/packages/web/test/ui/calendar-agenda.test.ts similarity index 100% rename from test/ui/calendar-agenda.test.ts rename to packages/web/test/ui/calendar-agenda.test.ts diff --git a/test/ui/continue-bar.test.tsx b/packages/web/test/ui/continue-bar.test.tsx similarity index 100% rename from test/ui/continue-bar.test.tsx rename to packages/web/test/ui/continue-bar.test.tsx diff --git a/test/ui/countdown-format.test.ts b/packages/web/test/ui/countdown-format.test.ts similarity index 100% rename from test/ui/countdown-format.test.ts rename to packages/web/test/ui/countdown-format.test.ts diff --git a/test/ui/detail-logic.test.ts b/packages/web/test/ui/detail-logic.test.ts similarity index 100% rename from test/ui/detail-logic.test.ts rename to packages/web/test/ui/detail-logic.test.ts diff --git a/test/ui/detail-unmark-resolution.test.ts b/packages/web/test/ui/detail-unmark-resolution.test.ts similarity index 100% rename from test/ui/detail-unmark-resolution.test.ts rename to packages/web/test/ui/detail-unmark-resolution.test.ts diff --git a/test/ui/episode-reminders.test.tsx b/packages/web/test/ui/episode-reminders.test.tsx similarity index 100% rename from test/ui/episode-reminders.test.tsx rename to packages/web/test/ui/episode-reminders.test.tsx diff --git a/test/ui/format.test.ts b/packages/web/test/ui/format.test.ts similarity index 100% rename from test/ui/format.test.ts rename to packages/web/test/ui/format.test.ts diff --git a/test/ui/gesture-intent.test.ts b/packages/web/test/ui/gesture-intent.test.ts similarity index 100% rename from test/ui/gesture-intent.test.ts rename to packages/web/test/ui/gesture-intent.test.ts diff --git a/test/ui/history-view.test.ts b/packages/web/test/ui/history-view.test.ts similarity index 100% rename from test/ui/history-view.test.ts rename to packages/web/test/ui/history-view.test.ts diff --git a/test/ui/library-chips.test.ts b/packages/web/test/ui/library-chips.test.ts similarity index 100% rename from test/ui/library-chips.test.ts rename to packages/web/test/ui/library-chips.test.ts diff --git a/test/ui/library-snapshot.test.tsx b/packages/web/test/ui/library-snapshot.test.tsx similarity index 100% rename from test/ui/library-snapshot.test.tsx rename to packages/web/test/ui/library-snapshot.test.tsx diff --git a/test/ui/long-press-math.test.ts b/packages/web/test/ui/long-press-math.test.ts similarity index 100% rename from test/ui/long-press-math.test.ts rename to packages/web/test/ui/long-press-math.test.ts diff --git a/test/ui/mark-pipeline.test.tsx b/packages/web/test/ui/mark-pipeline.test.tsx similarity index 100% rename from test/ui/mark-pipeline.test.tsx rename to packages/web/test/ui/mark-pipeline.test.tsx diff --git a/test/ui/mark-store.test.ts b/packages/web/test/ui/mark-store.test.ts similarity index 100% rename from test/ui/mark-store.test.ts rename to packages/web/test/ui/mark-store.test.ts diff --git a/test/ui/mark-undo-window.test.ts b/packages/web/test/ui/mark-undo-window.test.ts similarity index 100% rename from test/ui/mark-undo-window.test.ts rename to packages/web/test/ui/mark-undo-window.test.ts diff --git a/test/ui/media-visibility.test.ts b/packages/web/test/ui/media-visibility.test.ts similarity index 100% rename from test/ui/media-visibility.test.ts rename to packages/web/test/ui/media-visibility.test.ts diff --git a/test/ui/on-the-way.test.ts b/packages/web/test/ui/on-the-way.test.ts similarity index 100% rename from test/ui/on-the-way.test.ts rename to packages/web/test/ui/on-the-way.test.ts diff --git a/test/ui/onboarding-screen.test.tsx b/packages/web/test/ui/onboarding-screen.test.tsx similarity index 100% rename from test/ui/onboarding-screen.test.tsx rename to packages/web/test/ui/onboarding-screen.test.tsx diff --git a/test/ui/optimistic-write.test.ts b/packages/web/test/ui/optimistic-write.test.ts similarity index 100% rename from test/ui/optimistic-write.test.ts rename to packages/web/test/ui/optimistic-write.test.ts diff --git a/test/ui/overlay-primitives.test.tsx b/packages/web/test/ui/overlay-primitives.test.tsx similarity index 100% rename from test/ui/overlay-primitives.test.tsx rename to packages/web/test/ui/overlay-primitives.test.tsx diff --git a/test/ui/poster.test.tsx b/packages/web/test/ui/poster.test.tsx similarity index 100% rename from test/ui/poster.test.tsx rename to packages/web/test/ui/poster.test.tsx diff --git a/test/ui/pref-storage.test.ts b/packages/web/test/ui/pref-storage.test.ts similarity index 100% rename from test/ui/pref-storage.test.ts rename to packages/web/test/ui/pref-storage.test.ts diff --git a/test/ui/profile-stats.test.ts b/packages/web/test/ui/profile-stats.test.ts similarity index 100% rename from test/ui/profile-stats.test.ts rename to packages/web/test/ui/profile-stats.test.ts diff --git a/test/ui/pull-math.test.ts b/packages/web/test/ui/pull-math.test.ts similarity index 100% rename from test/ui/pull-math.test.ts rename to packages/web/test/ui/pull-math.test.ts diff --git a/test/ui/pull-to-refresh.test.tsx b/packages/web/test/ui/pull-to-refresh.test.tsx similarity index 100% rename from test/ui/pull-to-refresh.test.tsx rename to packages/web/test/ui/pull-to-refresh.test.tsx diff --git a/test/ui/queue-order.test.ts b/packages/web/test/ui/queue-order.test.ts similarity index 100% rename from test/ui/queue-order.test.ts rename to packages/web/test/ui/queue-order.test.ts diff --git a/test/ui/resolve-movie-unmark.test.ts b/packages/web/test/ui/resolve-movie-unmark.test.ts similarity index 100% rename from test/ui/resolve-movie-unmark.test.ts rename to packages/web/test/ui/resolve-movie-unmark.test.ts diff --git a/test/ui/search-visibility.test.ts b/packages/web/test/ui/search-visibility.test.ts similarity index 100% rename from test/ui/search-visibility.test.ts rename to packages/web/test/ui/search-visibility.test.ts diff --git a/test/ui/settings-sync-status.test.ts b/packages/web/test/ui/settings-sync-status.test.ts similarity index 100% rename from test/ui/settings-sync-status.test.ts rename to packages/web/test/ui/settings-sync-status.test.ts diff --git a/test/ui/settings-version-web.test.tsx b/packages/web/test/ui/settings-version-web.test.tsx similarity index 100% rename from test/ui/settings-version-web.test.tsx rename to packages/web/test/ui/settings-version-web.test.tsx diff --git a/test/ui/settings-version.test.tsx b/packages/web/test/ui/settings-version.test.tsx similarity index 100% rename from test/ui/settings-version.test.tsx rename to packages/web/test/ui/settings-version.test.tsx diff --git a/test/ui/sheet-logic.test.ts b/packages/web/test/ui/sheet-logic.test.ts similarity index 100% rename from test/ui/sheet-logic.test.ts rename to packages/web/test/ui/sheet-logic.test.ts diff --git a/test/ui/sheet-math.test.ts b/packages/web/test/ui/sheet-math.test.ts similarity index 100% rename from test/ui/sheet-math.test.ts rename to packages/web/test/ui/sheet-math.test.ts diff --git a/test/ui/swipe-action.test.tsx b/packages/web/test/ui/swipe-action.test.tsx similarity index 100% rename from test/ui/swipe-action.test.tsx rename to packages/web/test/ui/swipe-action.test.tsx diff --git a/test/ui/swipe-math.test.ts b/packages/web/test/ui/swipe-math.test.ts similarity index 100% rename from test/ui/swipe-math.test.ts rename to packages/web/test/ui/swipe-math.test.ts diff --git a/test/ui/sync-strip-pending.test.tsx b/packages/web/test/ui/sync-strip-pending.test.tsx similarity index 100% rename from test/ui/sync-strip-pending.test.tsx rename to packages/web/test/ui/sync-strip-pending.test.tsx diff --git a/test/ui/use-calendar.test.tsx b/packages/web/test/ui/use-calendar.test.tsx similarity index 100% rename from test/ui/use-calendar.test.tsx rename to packages/web/test/ui/use-calendar.test.tsx diff --git a/test/ui/use-sync-status.test.tsx b/packages/web/test/ui/use-sync-status.test.tsx similarity index 100% rename from test/ui/use-sync-status.test.tsx rename to packages/web/test/ui/use-sync-status.test.tsx diff --git a/test/ui/watchlist-add.test.tsx b/packages/web/test/ui/watchlist-add.test.tsx similarity index 100% rename from test/ui/watchlist-add.test.tsx rename to packages/web/test/ui/watchlist-add.test.tsx diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 00000000..d0fccfb2 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "paths": { + "@domain/*": ["./src/domain/*"], + "@data/*": ["./src/data/*"], + "@ui/*": ["./src/ui/*"], + "@app/*": ["./src/app/*"], + "@platform/*": ["./src/platform/*"] + } + }, + "include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"], + "exclude": ["node_modules", "dist", "coverage"] +} diff --git a/vite.config.ts b/packages/web/vite.config.ts similarity index 100% rename from vite.config.ts rename to packages/web/vite.config.ts diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 00000000..6f86414f --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,25 @@ +import { fileURLToPath, URL } from "node:url"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vitest/config"; + +const src = (path: string): string => fileURLToPath(new URL(`./src/${path}`, import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@domain": src("domain"), + "@data": src("data"), + "@ui": src("ui"), + "@app": src("app"), + "@platform": src("platform"), + }, + }, + test: { + name: "web", + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.{ts,tsx}"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a45a8ce2..25c25caf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,70 @@ importers: .: dependencies: - '@capacitor/android': - specifier: ^8.5.0 - version: 8.5.0(@capacitor/core@8.5.0) '@capacitor/app': specifier: ^8.1.1 version: 8.1.1(@capacitor/core@8.5.0) '@capacitor/core': specifier: ^8.5.0 version: 8.5.0 + '@capacitor/local-notifications': + specifier: ^8.3.1 + version: 8.3.1(@capacitor/core@8.5.0) + '@capacitor/preferences': + specifier: 8.0.1 + version: 8.0.1(@capacitor/core@8.5.0) + '@capacitor/status-bar': + specifier: ^8.0.3 + version: 8.0.3(@capacitor/core@8.5.0) + devDependencies: + '@biomejs/biome': + specifier: ^2.5.2 + version: 2.5.2 + '@capacitor/android': + specifier: ^8.5.0 + version: 8.5.0(@capacitor/core@8.5.0) + '@capacitor/cli': + specifier: ^8.5.0 + version: 8.5.0 '@capacitor/ios': specifier: ^8.5.0 version: 8.5.0(@capacitor/core@8.5.0) + '@vitest/coverage-v8': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) + cspell: + specifier: ^9.8.0 + version: 9.8.0 + dependency-cruiser: + specifier: ^18.0.0 + version: 18.0.0 + dprint: + specifier: ^0.55.1 + version: 0.55.1 + jscpd: + specifier: ^5.0.11 + version: 5.0.11 + knip: + specifier: ^6.24.0 + version: 6.24.0 + lefthook: + specifier: ^2.1.9 + version: 2.1.9 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.20.0)(typescript@6.0.3))(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/web: + dependencies: + '@capacitor/app': + specifier: ^8.1.1 + version: 8.1.1(@capacitor/core@8.5.0) + '@capacitor/core': + specifier: ^8.5.0 + version: 8.5.0 '@capacitor/local-notifications': specifier: ^8.3.1 version: 8.3.1(@capacitor/core@8.5.0) @@ -72,12 +124,6 @@ importers: specifier: 5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: - '@biomejs/biome': - specifier: ^2.5.2 - version: 2.5.2 - '@capacitor/cli': - specifier: ^8.5.0 - version: 8.5.0 '@playwright/test': specifier: ^1.61.1 version: 1.61.1 @@ -102,30 +148,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.3.1 version: 4.3.1(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - '@vitest/coverage-v8': - specifier: ^4.1.9 - version: 4.1.9(vitest@4.1.9) - cspell: - specifier: ^9.8.0 - version: 9.8.0 - dependency-cruiser: - specifier: ^18.0.0 - version: 18.0.0 - dprint: - specifier: ^0.55.1 - version: 0.55.1 - jscpd: - specifier: ^5.0.11 - version: 5.0.11 jsdom: specifier: ^29.1.1 version: 29.1.1 - knip: - specifier: ^6.24.0 - version: 6.24.0 - lefthook: - specifier: ^2.1.9 - version: 2.1.9 msw: specifier: ^2.14.6 version: 2.14.6(@types/node@22.20.0)(typescript@6.0.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..22d1482d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - packages/* + +# Expo supports isolated installs from SDK 54 but warns that some React Native +# libraries fail to build or resolve under them, and names this as the remedy. +# The native package is not here yet; the linker is set now so the tree the web +# app is verified against is the tree the native package will join. +# https://docs.expo.dev/guides/monorepos/ +nodeLinker: hoisted diff --git a/scripts/diff-footprint.sh b/scripts/diff-footprint.sh index c93e4f2d..6596f392 100755 --- a/scripts/diff-footprint.sh +++ b/scripts/diff-footprint.sh @@ -16,9 +16,9 @@ base_commit=$(git rev-parse --verify --end-of-options "${base_ref}^{commit}" 2>/ git diff --no-renames --numstat "$base_commit"...HEAD | awk ' BEGIN { FS = "\t" } function area(path) { - if (path ~ /^src\//) return "product" - if (path ~ /^test\//) return "tests" - if (path ~ /^e2e\//) return "e2e" + if (path ~ /^packages\/[^\/]+\/src\//) return "product" + if (path ~ /^packages\/[^\/]+\/(test|__tests__)\//) return "tests" + if (path ~ /^packages\/[^\/]+\/e2e\//) return "e2e" return "other" } { @@ -41,16 +41,16 @@ git diff --no-renames --numstat "$base_commit"...HEAD | awk ' print "" print "| area | added | removed | net |" print "| --- | ---: | ---: | ---: |" - row("product (src/)", "product") - row("tests (test/)", "tests") - row("e2e (e2e/)", "e2e") + row("product (packages/*/src/)", "product") + row("tests (packages/*/test/)", "tests") + row("e2e (packages/*/e2e/)", "e2e") row("other", "other") printf "| total | %d | %d | %+d |\n", total_added, total_removed, total_added - total_removed print "" } ' -git diff --no-renames --unified=0 --no-color "$base_commit"...HEAD -- src/ | awk ' +git diff --no-renames --unified=0 --no-color "$base_commit"...HEAD -- ':(glob)packages/*/src/**' | awk ' function classify(line, direction) { sub(/^[[:space:]]+/, "", line) if (line == "") kind = "blank" diff --git a/scripts/verify-bundle.sh b/scripts/verify-bundle.sh index d9c9d5d5..604b6a6b 100755 --- a/scripts/verify-bundle.sh +++ b/scripts/verify-bundle.sh @@ -11,21 +11,21 @@ # `import.meta.env.NAME` with that variable's own literal, and a read of the # whole object with every VITE_ variable present at build time, so the name # landing in dist is the signature of a whole-object read however that read is -# spelled, in any mode. src/app/config.ts is checked for the mode gate by -# test/privacy-claims.test.ts: that assertion is about intent, this one is about -# the artifact. VITE_TRAKT_CLIENT_ID is not usable as the same signal because +# spelled, in any mode. config.ts is checked for the mode gate by the web +# package's privacy-claims test: that assertion is about intent, this one is +# about the artifact. VITE_TRAKT_CLIENT_ID is not usable as the same signal because # Cue's own startup error names it, so it is in every build by design. set -euo pipefail stray_origin="http://127.0.0.1:8787" -VITE_TRAKT_API_BASE="$stray_origin" pnpm exec vite build "$@" +VITE_TRAKT_API_BASE="$stray_origin" pnpm --filter @cue/web exec vite build "$@" # The whole of dist, not just dist/assets: the service worker and its precache # manifest are written at the root, and they are shipped files like any other. refuse() { local carriers - carriers=$(grep -rl "$1" dist || true) + carriers=$(grep -rl "$1" packages/web/dist || true) [ -n "$carriers" ] || return 0 echo "verify-bundle: $2" >&2 echo "$carriers" >&2 @@ -33,8 +33,8 @@ refuse() { } refuse "VITE_TRAKT_API_BASE" \ - "dist names a build variable, so the build inlined the whole import.meta.env object. Name each variable it reads in src/app/config.ts." + "packages/web/dist names a build variable, so the build inlined the whole import.meta.env object. Name each variable it reads in packages/web/src/app/config.ts." refuse "$stray_origin" \ - "dist carries $stray_origin, so this build took the local fake Trakt's origin outside the mock mode." + "packages/web/dist carries $stray_origin, so this build took the local fake Trakt's origin outside the mock mode." -echo "verify-bundle: dist names no build variable and carries no fake Trakt origin." +echo "verify-bundle: packages/web/dist names no build variable and carries no fake Trakt origin." diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index cb6fb2ea..471b1e5f 100644 --- a/scripts/write-buster.mjs +++ b/scripts/write-buster.mjs @@ -18,9 +18,13 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); * Moving a file between these trees, or into a package, is not a shape change, * so these roots may be repathed without bumping anything. */ -const SHAPE_TREES = ["src/domain", "src/data", "src/ui/runtime"]; +const SHAPE_TREES = [ + "packages/web/src/domain", + "packages/web/src/data", + "packages/web/src/ui/runtime", +]; -const GENERATED = "src/ui/runtime/persist-buster.ts"; +const GENERATED = "packages/web/src/ui/runtime/persist-buster.ts"; /** * Every module specifier in an `import`, `export ... from`, dynamic `import()` diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..aad13085 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "jsx": "react-jsx", + // scripts/mock-trakt runs under plain node (no build step), so its .mjs + // modules are JavaScript; the harness test imports them and infers types. + "allowJs": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": true, + "noEmit": true + } +} diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json new file mode 100644 index 00000000..3300bc34 --- /dev/null +++ b/tsconfig.depcruise.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + // dependency-cruiser reads the web package's path aliases through this file + // and nothing else does. It exists because its tsconfig-paths plugin resolves + // a `paths` entry against `baseUrl`, and dependency-cruiser substitutes the + // process working directory when the tsconfig declares none: from the + // workspace root that turns `./src/ui/*` into `/src/ui/*`, which exists + // nowhere, so every aliased import drops out of the graph and every layering + // rule passes vacuously. Naming the base here fixes that without putting + // `baseUrl`, which TypeScript 6.0 rejects as deprecated, into a tsconfig the + // compiler actually reads. + "extends": "./packages/web/tsconfig.json", + "compilerOptions": { "baseUrl": "./packages/web" } +} diff --git a/tsconfig.json b/tsconfig.json index 53c7bb34..ade773d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,47 +1,14 @@ { "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", "compilerOptions": { - "target": "ES2023", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "jsx": "react-jsx", - "types": ["vite/client", "node"], - "paths": { - "@domain/*": ["./src/domain/*"], - "@data/*": ["./src/data/*"], - "@ui/*": ["./src/ui/*"], - "@app/*": ["./src/app/*"], - "@platform/*": ["./src/platform/*"] - }, - // scripts/mock-trakt runs under plain node (no build step), so its .mjs - // modules are JavaScript; the harness test imports them and infers types. - "allowJs": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "forceConsistentCasingInFileNames": true, - "useDefineForClassFields": true, - "noEmit": true + "lib": ["ES2023"], + "types": ["node"] }, - "include": [ - "src", - "test", - "e2e", - "vite.config.ts", - "vitest.config.ts", - "playwright.config.ts", - "capacitor.config.ts" - ], - "exclude": ["node_modules", "dist", "coverage", "ios", "android"] + // The workspace root ships no product code, but it does carry TypeScript that + // has to compile: the vitest runner's own config and the Capacitor shells'. + // `pnpm -r typecheck` only reaches the packages, so without this program those + // two files would be the only TypeScript in the repository nothing checks. + "include": ["vitest.config.ts", "capacitor.config.ts"], + "exclude": ["node_modules", "packages"] } diff --git a/vitest.config.ts b/vitest.config.ts index 526745ea..651997b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,33 +1,16 @@ -import { fileURLToPath, URL } from "node:url"; -import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "vitest/config"; -const src = (path: string): string => fileURLToPath(new URL(`./src/${path}`, import.meta.url)); - export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - "@domain": src("domain"), - "@data": src("data"), - "@ui": src("ui"), - "@app": src("app"), - "@platform": src("platform"), - }, - }, test: { - environment: "jsdom", - globals: true, - setupFiles: ["./test/setup.ts"], - include: ["test/**/*.test.{ts,tsx}"], + projects: ["packages/*"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], - include: ["src/**"], + include: ["packages/*/src/**"], // The composition root (src/app) and every presentational surface (src/ui) // are gated by the hermetic Playwright suite, not a line threshold // Line coverage targets the logic layers below. - exclude: ["src/**/*.d.ts", "src/app/**", "src/ui/**"], + exclude: ["**/*.d.ts", "packages/web/src/app/**", "packages/web/src/ui/**"], thresholds: { // Global floor = rot tripwire, not the quality bar. Logic layers carry // the real gate below; ui/ behavior is gated by the Playwright suite. @@ -35,8 +18,8 @@ export default defineConfig({ functions: 70, statements: 70, branches: 60, - "src/domain/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, - "src/data/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/web/src/domain/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/web/src/data/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, }, }, }, From 663af596e02723d8160bd8789b1dae97a77fe179 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 19:18:50 -0500 Subject: [PATCH 003/435] Extract @cue/core: the domain, the data layer, format and three ports 45 files move into packages/core and 147 are rewritten to reach them. The core is source only, never built, and reached through one wildcard subpath key, "./*": "./src/*.ts". One key rather than ten because a subpath pattern matches nested directories, and because Node's patterns are a substring substitution with no extension search, which is also why the core can hold no .tsx: such a file would be unreachable from every consumer while compiling cleanly, since lib ES2023 without "dom" catches a DOM global and produces no error at all for DOM JSX. check:core-portable asserts the tree carries neither .tsx nor .css, and runs in the pre-commit hook over the working tree as well as the index, so a file that is not yet committed fails there rather than in CI afterwards. @domain and @data are gone: 310 specifiers now name @cue/core outside the package, and the ones that ended up inside it became relative paths. tsc verifies every one; the three vi.mock string specifiers naming a core target were checked by hand against their subjects' imports. @ui, @app and @platform stay, declared where they always were, in the web package where the Expo prebuild hazard that justifies banning path aliases cannot reach. kv.ts is the one file that splits rather than moves. The KeyValueStore interface is the port; both backends stay in packages/web/src/platform, because this line still ships the Capacitor shells and their branch is live. The rule set is now fifteen. Eleven of the thirteen the architecture calls for, repathed or new, plus two the spec retires that this line still needs: capacitor-only-in-platform, because Capacitor still ships, and ui-no-platform-impl, because the web app's screens are still the whole UI. The two remaining, native-owns-expo and apps-do-not-cross, are written and inert until packages/native exists; both were exercised against a throwaway package, and every other rule against a planted violation. core-stays-portable and its Node half are anchored at the core's src rather than the whole package, because the package's own vitest suite runs on Node and reads git; web-owns-dom and native-owns-expo hold the same line over its test tree. Two guards the architecture's lib line does not give on its own. @types/node has to be in the core's program or fetch, Response, Headers and setTimeout have no declarations, and it brings process, Buffer and Node 22's localStorage with it, none of which exist on a React Native engine and none of which is an import a dependency rule can see. Biome's noRestrictedGlobals closes that over packages/core; tsc still rejects window, document, indexedDB and navigator.onLine outright. The shape witness now hashes each file as the sorted multiset of its lines. Collapsing the module specifier was not enough on its own: it changes the sort key organize-imports uses, so respelling one import reorders the whole block and the bytes move again for the same non-reason. Sorting is also the truer statement, since a persisted value is a set of fields and two declarations that swap places are the same shape. Recomputed from git objects, the new witness is 8ce50d6d2713 on the branch point, on the workspace move and on this tree, so the extraction changed no shape. PERSIST_BUSTER is untouched at 4d3253e9dc61 and the built bundle still carries it. --- .dependency-cruiser.cjs | 124 +++++++++++++++--- biome.jsonc | 32 +++++ knip.json | 4 + lefthook.yml | 6 + package.json | 1 + packages/core/package.json | 21 +++ packages/{web => core}/src/data/auth/oauth.ts | 4 +- packages/{web => core}/src/data/auth/pkce.ts | 0 .../{web => core}/src/data/image-source.ts | 0 .../src/data/query-invalidation.ts | 2 +- packages/{web => core}/src/data/query-keys.ts | 0 .../src/data/trakt/authorized-fetch.ts | 4 +- .../{web => core}/src/data/trakt/calendar.ts | 2 +- .../{web => core}/src/data/trakt/client.ts | 2 +- .../{web => core}/src/data/trakt/endpoints.ts | 6 +- .../src/data/trakt/episode-detail.ts | 4 +- .../{web => core}/src/data/trakt/history.ts | 4 +- .../{web => core}/src/data/trakt/library.ts | 6 +- .../src/data/trakt/movie-library.ts | 2 +- .../src/data/trakt/pooled-endpoints.ts | 0 .../src/data/trakt/read-budget.ts | 2 +- .../src/data/trakt/repositories.ts | 2 +- .../{web => core}/src/data/trakt/schemas.ts | 0 .../{web => core}/src/data/trakt/search.ts | 2 +- .../src/data/trakt/show-detail.ts | 6 +- .../{web => core}/src/data/trakt/transport.ts | 2 +- .../src/data/trakt/user-profile.ts | 0 .../{web => core}/src/domain/auth/token.ts | 0 packages/{web => core}/src/domain/calendar.ts | 0 packages/{web => core}/src/domain/day.ts | 0 packages/{web => core}/src/domain/history.ts | 0 .../src/domain/library-buckets.ts | 0 .../{web => core}/src/domain/model/ids.ts | 0 .../{web => core}/src/domain/model/library.ts | 0 .../{web => core}/src/domain/model/token.ts | 0 .../{web => core}/src/domain/ports/haptics.ts | 0 .../src/domain/ports/reminders.ts | 2 +- .../src/domain/recently-aired.ts | 0 .../{web => core}/src/domain/reminders.ts | 0 packages/{web => core}/src/domain/reversal.ts | 0 .../src/domain/sync-activities.ts | 0 packages/{web => core}/src/domain/time.ts | 0 packages/{web => core}/src/domain/up-next.ts | 0 .../{web => core}/src/domain/watch-status.ts | 0 .../src/domain/write-queue/bulk.ts | 0 .../src/domain/write-queue/classify.ts | 0 .../src/domain/write-queue/coalesce.ts | 0 .../src/domain/write-queue/ops.ts | 0 .../src/domain/write-queue/queue.ts | 0 .../src/domain/write-queue/types.ts | 0 packages/{web/src/ui => core/src}/format.ts | 2 +- .../platform => core/src/ports}/json-store.ts | 2 +- packages/core/src/ports/kv.ts | 12 ++ .../src/ports}/token-store.ts | 6 +- packages/core/test/ci/core-portable.test.ts | 31 +++++ packages/core/tsconfig.json | 14 ++ packages/core/vitest.config.ts | 10 ++ packages/web/package.json | 1 + packages/web/src/app/AuthGate.tsx | 4 +- .../web/src/app/auth/create-auth-store.ts | 8 +- packages/web/src/app/providers.tsx | 2 +- packages/web/src/app/query-client.ts | 2 +- packages/web/src/app/runtime/RuntimeBoot.tsx | 4 +- .../web/src/app/runtime/create-runtime.ts | 50 +++---- packages/web/src/platform/haptics.ts | 2 +- packages/web/src/platform/kv.ts | 14 +- packages/web/src/platform/reminders.ts | 4 +- .../web/src/ui/components/ArtPlaceholder.tsx | 2 +- .../web/src/ui/components/CountdownPanel.tsx | 2 +- .../web/src/ui/components/MarqueeCard.tsx | 12 +- packages/web/src/ui/hooks/library-cache.ts | 8 +- packages/web/src/ui/hooks/mark-store.ts | 2 +- packages/web/src/ui/hooks/queue-order.ts | 4 +- packages/web/src/ui/hooks/resolveUnmark.ts | 2 +- packages/web/src/ui/hooks/useBrowse.ts | 4 +- packages/web/src/ui/hooks/useCalendar.ts | 6 +- packages/web/src/ui/hooks/useEpisode.ts | 4 +- .../web/src/ui/hooks/useEpisodeReminders.ts | 2 +- packages/web/src/ui/hooks/useHideShow.ts | 6 +- packages/web/src/ui/hooks/useHistory.ts | 10 +- .../web/src/ui/hooks/useLibraryBuckets.ts | 8 +- .../web/src/ui/hooks/useLibrarySnapshot.ts | 12 +- packages/web/src/ui/hooks/useMarkSeason.ts | 16 +-- packages/web/src/ui/hooks/useMarkWatched.ts | 13 +- packages/web/src/ui/hooks/useMovieActions.ts | 8 +- packages/web/src/ui/hooks/useMovieDetail.ts | 4 +- packages/web/src/ui/hooks/useMovieLibrary.ts | 6 +- packages/web/src/ui/hooks/useMovieRelated.ts | 4 +- .../web/src/ui/hooks/useOptimisticWrite.ts | 2 +- packages/web/src/ui/hooks/useQueuedWrite.ts | 2 +- packages/web/src/ui/hooks/useRemovalSnacks.ts | 2 +- packages/web/src/ui/hooks/useResumeOnMark.ts | 4 +- packages/web/src/ui/hooks/useSearch.ts | 4 +- packages/web/src/ui/hooks/useSeasons.ts | 4 +- packages/web/src/ui/hooks/useShowArt.ts | 4 +- packages/web/src/ui/hooks/useShowDetail.ts | 4 +- packages/web/src/ui/hooks/useStats.ts | 4 +- .../web/src/ui/hooks/useToggleWatchlist.ts | 6 +- packages/web/src/ui/hooks/useUpNext.ts | 4 +- packages/web/src/ui/hooks/useUserProfile.ts | 4 +- packages/web/src/ui/hooks/useWatchlistAdd.ts | 6 +- packages/web/src/ui/prefs/threshold.ts | 2 +- packages/web/src/ui/runtime/haptics.ts | 2 +- packages/web/src/ui/runtime/persist-buster.ts | 2 +- packages/web/src/ui/runtime/reminders.ts | 2 +- packages/web/src/ui/runtime/runtime.ts | 24 ++-- .../src/ui/screens/calendar/CalendarRow.tsx | 6 +- .../web/src/ui/screens/calendar/agenda.ts | 4 +- .../screens/episode-detail/EpisodeSheet.tsx | 8 +- .../ui/screens/episode-detail/sheet-logic.ts | 6 +- .../web/src/ui/screens/history/History.tsx | 4 +- .../src/ui/screens/history/history-view.ts | 4 +- .../web/src/ui/screens/library/Library.tsx | 10 +- .../web/src/ui/screens/library/MovieTile.tsx | 2 +- .../web/src/ui/screens/library/ShowTile.tsx | 4 +- .../ui/screens/movie-detail/MovieDetail.tsx | 4 +- .../web/src/ui/screens/profile/Profile.tsx | 6 +- packages/web/src/ui/screens/profile/stats.ts | 2 +- .../web/src/ui/screens/search/HitTile.tsx | 2 +- .../web/src/ui/screens/search/ResultRow.tsx | 2 +- packages/web/src/ui/screens/search/Search.tsx | 4 +- .../src/ui/screens/settings/useSyncStatus.ts | 2 +- .../ui/screens/show-detail/ContinueBar.tsx | 10 +- .../ui/screens/show-detail/DetailChrome.tsx | 2 +- .../src/ui/screens/show-detail/SeasonList.tsx | 6 +- .../src/ui/screens/show-detail/ShowDetail.tsx | 6 +- .../ui/screens/show-detail/detail-logic.ts | 4 +- .../web/src/ui/screens/up-next/OnTheWay.tsx | 8 +- .../web/src/ui/screens/up-next/Poster.tsx | 2 +- .../web/src/ui/screens/up-next/Previously.tsx | 4 +- .../web/src/ui/screens/up-next/QueueRow.tsx | 4 +- .../src/ui/screens/up-next/useQueueCheck.ts | 4 +- packages/web/test/data/auth-oauth.test.ts | 4 +- packages/web/test/data/auth-pkce.test.ts | 2 +- .../web/test/data/authorized-fetch.test.ts | 8 +- packages/web/test/data/image-source.test.ts | 2 +- .../web/test/data/query-invalidation.test.ts | 4 +- packages/web/test/data/query-keys.test.ts | 2 +- packages/web/test/data/read-budget.test.ts | 8 +- packages/web/test/data/trakt-calendar.test.ts | 4 +- packages/web/test/data/trakt-client.test.ts | 2 +- .../web/test/data/trakt-endpoints.test.ts | 4 +- .../test/data/trakt-episode-detail.test.ts | 4 +- packages/web/test/data/trakt-history.test.ts | 4 +- packages/web/test/data/trakt-library.test.ts | 6 +- .../web/test/data/trakt-movie-library.test.ts | 4 +- .../test/data/trakt-pooled-endpoints.test.ts | 4 +- .../web/test/data/trakt-repositories.test.ts | 6 +- packages/web/test/data/trakt-search.test.ts | 4 +- .../web/test/data/trakt-show-detail.test.ts | 4 +- .../web/test/data/trakt-transport.test.ts | 8 +- .../web/test/data/trakt-user-profile.test.ts | 2 +- packages/web/test/domain/_helpers.ts | 4 +- packages/web/test/domain/auth-token.test.ts | 4 +- packages/web/test/domain/calendar.test.ts | 2 +- packages/web/test/domain/history.test.ts | 7 +- .../web/test/domain/library-buckets.test.ts | 4 +- .../web/test/domain/recently-aired.test.ts | 4 +- packages/web/test/domain/reminders.test.ts | 4 +- packages/web/test/domain/reversal.test.ts | 2 +- .../web/test/domain/sync-activities.test.ts | 2 +- packages/web/test/domain/time.test.ts | 2 +- packages/web/test/domain/up-next.test.ts | 2 +- packages/web/test/domain/watch-status.test.ts | 6 +- .../web/test/domain/write-queue-bulk.test.ts | 4 +- .../test/domain/write-queue-classify.test.ts | 2 +- .../test/domain/write-queue-coalesce.test.ts | 6 +- .../web/test/domain/write-queue-ops.test.ts | 2 +- .../web/test/domain/write-queue-queue.test.ts | 6 +- packages/web/test/harness/mock-trakt.test.ts | 14 +- packages/web/test/platform/haptics.test.ts | 2 +- packages/web/test/platform/json-store.test.ts | 6 +- packages/web/test/platform/reminders.test.ts | 2 +- packages/web/test/privacy-claims.test.ts | 10 +- .../test/support/composition-root-mocks.tsx | 2 +- packages/web/test/ui/calendar-agenda.test.ts | 2 +- packages/web/test/ui/continue-bar.test.tsx | 4 +- packages/web/test/ui/detail-logic.test.ts | 2 +- .../test/ui/detail-unmark-resolution.test.ts | 2 +- .../web/test/ui/episode-reminders.test.tsx | 4 +- packages/web/test/ui/format.test.ts | 4 +- packages/web/test/ui/history-view.test.ts | 2 +- packages/web/test/ui/library-chips.test.ts | 2 +- .../web/test/ui/library-snapshot.test.tsx | 6 +- packages/web/test/ui/mark-pipeline.test.tsx | 12 +- packages/web/test/ui/mark-store.test.ts | 4 +- packages/web/test/ui/on-the-way.test.ts | 2 +- packages/web/test/ui/optimistic-write.test.ts | 2 +- packages/web/test/ui/profile-stats.test.ts | 2 +- packages/web/test/ui/pull-to-refresh.test.tsx | 2 +- packages/web/test/ui/queue-order.test.ts | 2 +- .../web/test/ui/resolve-movie-unmark.test.ts | 2 +- .../web/test/ui/search-visibility.test.ts | 2 +- packages/web/test/ui/sheet-logic.test.ts | 2 +- packages/web/test/ui/use-calendar.test.tsx | 2 +- packages/web/test/ui/use-sync-status.test.tsx | 2 +- packages/web/test/ui/watchlist-add.test.tsx | 6 +- packages/web/tsconfig.json | 2 - packages/web/vite.config.ts | 2 - packages/web/vitest.config.ts | 2 - pnpm-lock.yaml | 19 +++ scripts/write-buster.mjs | 38 ++++-- vitest.config.ts | 4 +- 203 files changed, 663 insertions(+), 404 deletions(-) create mode 100644 packages/core/package.json rename packages/{web => core}/src/data/auth/oauth.ts (98%) rename packages/{web => core}/src/data/auth/pkce.ts (100%) rename packages/{web => core}/src/data/image-source.ts (100%) rename packages/{web => core}/src/data/query-invalidation.ts (98%) rename packages/{web => core}/src/data/query-keys.ts (100%) rename packages/{web => core}/src/data/trakt/authorized-fetch.ts (98%) rename packages/{web => core}/src/data/trakt/calendar.ts (93%) rename packages/{web => core}/src/data/trakt/client.ts (99%) rename packages/{web => core}/src/data/trakt/endpoints.ts (98%) rename packages/{web => core}/src/data/trakt/episode-detail.ts (97%) rename packages/{web => core}/src/data/trakt/history.ts (96%) rename packages/{web => core}/src/data/trakt/library.ts (98%) rename packages/{web => core}/src/data/trakt/movie-library.ts (98%) rename packages/{web => core}/src/data/trakt/pooled-endpoints.ts (100%) rename packages/{web => core}/src/data/trakt/read-budget.ts (99%) rename packages/{web => core}/src/data/trakt/repositories.ts (97%) rename packages/{web => core}/src/data/trakt/schemas.ts (100%) rename packages/{web => core}/src/data/trakt/search.ts (98%) rename packages/{web => core}/src/data/trakt/show-detail.ts (97%) rename packages/{web => core}/src/data/trakt/transport.ts (87%) rename packages/{web => core}/src/data/trakt/user-profile.ts (100%) rename packages/{web => core}/src/domain/auth/token.ts (100%) rename packages/{web => core}/src/domain/calendar.ts (100%) rename packages/{web => core}/src/domain/day.ts (100%) rename packages/{web => core}/src/domain/history.ts (100%) rename packages/{web => core}/src/domain/library-buckets.ts (100%) rename packages/{web => core}/src/domain/model/ids.ts (100%) rename packages/{web => core}/src/domain/model/library.ts (100%) rename packages/{web => core}/src/domain/model/token.ts (100%) rename packages/{web => core}/src/domain/ports/haptics.ts (100%) rename packages/{web => core}/src/domain/ports/reminders.ts (94%) rename packages/{web => core}/src/domain/recently-aired.ts (100%) rename packages/{web => core}/src/domain/reminders.ts (100%) rename packages/{web => core}/src/domain/reversal.ts (100%) rename packages/{web => core}/src/domain/sync-activities.ts (100%) rename packages/{web => core}/src/domain/time.ts (100%) rename packages/{web => core}/src/domain/up-next.ts (100%) rename packages/{web => core}/src/domain/watch-status.ts (100%) rename packages/{web => core}/src/domain/write-queue/bulk.ts (100%) rename packages/{web => core}/src/domain/write-queue/classify.ts (100%) rename packages/{web => core}/src/domain/write-queue/coalesce.ts (100%) rename packages/{web => core}/src/domain/write-queue/ops.ts (100%) rename packages/{web => core}/src/domain/write-queue/queue.ts (100%) rename packages/{web => core}/src/domain/write-queue/types.ts (100%) rename packages/{web/src/ui => core/src}/format.ts (98%) rename packages/{web/src/platform => core/src/ports}/json-store.ts (94%) create mode 100644 packages/core/src/ports/kv.ts rename packages/{web/src/platform => core/src/ports}/token-store.ts (62%) create mode 100644 packages/core/test/ci/core-portable.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 4ce4adc9..09cabdff 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -3,9 +3,25 @@ // `node_modules/.pnpm/@/node_modules//...`, so npm bans must match // the `(^|/)node_modules//` tail: a bare `^react` / `^@capacitor/` anchor // never fires. Node built-ins carry dependencyType "core", matched separately. +// An UNINSTALLED package is the one exception: nothing resolves it, so its +// module path is the specifier itself, which is why the expo and react-native +// bans below carry both spellings. const RE_REACT = "(^|/)node_modules/react/"; const RE_REACT_DOM = "(^|/)node_modules/react-dom/"; const RE_CAPACITOR = "(^|/)node_modules/@capacitor/"; +const RE_NATIVE = ["(^|/)node_modules/(expo|react-native)([-/]|$)", "^(expo|react-native)([-/]|$)"]; +// The web app's own libraries: the DOM renderer, its storage, its component +// primitives, its router, its virtualiser and its icons. Named one by one on +// purpose rather than as a @tanstack/react-* glob, because @tanstack/react-query +// is a portable dependency of the core that BOTH targets use. +const RE_WEB_ONLY = [ + RE_REACT_DOM, + "(^|/)node_modules/idb-keyval/", + "(^|/)node_modules/radix-ui/", + "(^|/)node_modules/@tanstack/react-router/", + "(^|/)node_modules/@tanstack/react-virtual/", + "(^|/)node_modules/lucide-react/", +]; const RE_DOES_NOT_SHIP_DIRECTORY = "^(docs|\\.github|assets|scripts/mock-trakt|packages/[^/]+/(e2e|test|__tests__))(/|$)"; const RE_DOES_NOT_SHIP_MARKDOWN = "^[^/]*\\.md$"; @@ -24,7 +40,7 @@ module.exports = { from: {}, to: { circular: true }, }, - // These patterns mirror DOES_NOT_SHIP in test/ci/release-paths.test.ts. + // These patterns mirror DOES_NOT_SHIP in packages/web/test/ci/release-paths.test.ts. // Changes to that list require matching updates here. // Known gaps: this rule is static analysis over import specifiers that // dependency-cruiser can resolve. import.meta.glob(...) and @@ -32,9 +48,9 @@ module.exports = { // resolvable import edge that dependency-cruiser follows, so a file matched // by either idiom is never flagged, even if it points at a non-shipping // path. import.meta.glob(...) is already used in - // src/ui/screens/settings/Settings.tsx. Also, from.path "^src/" covers only - // the first edge out of src. It does not cover a transitive hop through a - // non-src root file that imports a non-shipping path. + // packages/web/src/ui/screens/settings/Settings.tsx. Also, the from anchor + // covers only the first edge out of a package's src. It does not cover a + // transitive hop through a non-src root file that imports a non-shipping path. { name: "src-no-non-shipping-imports", severity: "error", @@ -49,33 +65,97 @@ module.exports = { name: "domain-stays-pure", severity: "error", comment: - "src/domain is runtime-agnostic: global fetch + zod only. No data/ui/app/platform, no react, no react-dom, no capacitor.", - from: { path: "^packages/web/src/domain/" }, + "The domain is runtime-agnostic: global fetch + zod only. No data/ports, no app, no react, no react-dom, no capacitor.", + from: { path: "^packages/core/src/domain/" }, to: { - path: ["^packages/web/src/(data|ui|platform|app)/", RE_REACT, RE_REACT_DOM, RE_CAPACITOR], + path: [ + "^packages/core/src/(data|ports)/", + "^packages/[^/]+/src/(ui|platform|app)/", + RE_REACT, + RE_REACT_DOM, + RE_CAPACITOR, + ], }, }, { name: "domain-no-node-builtins", severity: "error", comment: - "domain must not touch Node built-ins (fs/path/crypto/...); it runs in browser + native.", - from: { path: "^packages/web/src/domain/" }, + "The domain must not touch Node built-ins (fs/path/crypto/...); it runs in a browser and on a native engine. Kept beside the other domain rules even though core-stays-portable-node bans them across the package, because the stricter statement belongs where the domain rules are read.", + from: { path: "^packages/core/src/domain/" }, to: { dependencyTypes: ["core"] }, }, { name: "data-stays-headless", severity: "error", comment: - "src/data (clients/repos) may import domain, but never ui/app/platform, react, or react-dom.", - from: { path: "^packages/web/src/data/" }, - to: { path: ["^packages/web/src/(ui|app|platform)/", RE_REACT, RE_REACT_DOM] }, + "The data layer (clients/repos) may import domain, but never ui/app/platform, react, or react-dom.", + from: { path: "^packages/core/src/data/" }, + to: { path: ["^packages/[^/]+/src/(ui|platform|app)/", RE_REACT, RE_REACT_DOM] }, + }, + { + name: "ports-have-no-impls", + severity: "error", + comment: + "A port is a seam the apps fill, so it may take value imports from the domain and from its sibling ports and nothing else. Stated that way rather than as 'types only': token-store.ts imports tokenSchema, a zod value, from domain/model/token, and that is correct code.", + from: { path: "^packages/core/src/ports/" }, + to: { + dependencyTypesNot: ["type-only"], + pathNot: "^packages/core/src/(ports|domain)/", + }, + }, + { + name: "core-stays-portable", + severity: "error", + comment: + "@cue/core executes on both targets, so it takes no library that belongs to one of them: not the DOM renderer or its storage and component primitives, not React Native or Expo, not the bundler. Anchored at src because the package's own vitest suite runs on Node and reads git; web-owns-dom and native-owns-expo hold the same line over its test tree.", + from: { path: "^packages/core/src/" }, + to: { path: [...RE_WEB_ONLY, ...RE_NATIVE, "(^|/)node_modules/vite/"] }, + }, + { + name: "core-stays-portable-node", + severity: "error", + comment: + "The other half of the same ban. It has to be a second rule: a `to` clause is a conjunction, so one rule carrying both the package list and dependencyTypes would read 'a module whose path matches AND which is a Node built-in', which is never true.", + from: { path: "^packages/core/src/" }, + to: { dependencyTypes: ["core"] }, + }, + { + name: "core-imports-no-app", + severity: "error", + comment: + "The shared package imports neither app. Dependencies flow into the core and never back out, or the claim that both targets run the same code is only a claim.", + from: { path: "^packages/core/" }, + to: { path: "^packages/(web|native)/" }, + }, + { + name: "web-owns-dom", + severity: "error", + comment: + "The DOM renderer and the libraries built on it belong to the web app. Named one by one rather than as a @tanstack/react-* glob, because @tanstack/react-query is portable and the core's own layer imports it.", + from: { path: "^packages/", pathNot: "^packages/web/" }, + to: { path: RE_WEB_ONLY }, + }, + { + name: "native-owns-expo", + severity: "error", + comment: "Expo and React Native belong to the native app and to nothing else.", + from: { path: "^packages/", pathNot: "^packages/native/" }, + to: { path: RE_NATIVE }, + }, + { + name: "apps-do-not-cross", + severity: "error", + comment: + "Neither app imports the other; what they share, they share through @cue/core. The $1 backreference into the from group is what keeps this one rule: anchored without it, it forbids a package importing itself.", + from: { path: "^packages/(web|native)/" }, + to: { path: "^packages/(web|native)/", pathNot: "^packages/$1/" }, }, { name: "ui-no-platform-impl", severity: "error", comment: - "src/ui depends on domain/data abstractions; platform impls and the app composition root are injected, not imported directly.", + "The web app's screens depend on core abstractions; platform impls and the composition root are injected, not imported directly.", from: { path: "^packages/web/src/ui/" }, to: { path: "^packages/web/src/(platform|app)/" }, }, @@ -83,7 +163,7 @@ module.exports = { name: "trakt-reads-stay-pooled", severity: "error", comment: - "src/data/trakt/endpoints.ts issues raw, unpooled GETs. Only read-budget.ts " + + "data/trakt/endpoints.ts issues raw, unpooled GETs. Only read-budget.ts " + "(the pool primitive) and pooled-endpoints.ts (its wrapper for every other " + "caller) may import it directly: every other read must go through a pooled " + "wrapper, so a read reachable from the runtime without withReadRateRetry " + @@ -91,15 +171,15 @@ module.exports = { "that a mutation can dodge by pooling one caller and leaving the rest raw.", from: { path: "^packages/[^/]+/src/", - pathNot: "^packages/web/src/data/trakt/(read-budget|pooled-endpoints)\\.ts$", + pathNot: "^packages/core/src/data/trakt/(read-budget|pooled-endpoints)\\.ts$", }, - to: { path: "^packages/web/src/data/trakt/endpoints\\.ts$" }, + to: { path: "^packages/core/src/data/trakt/endpoints\\.ts$" }, }, { name: "capacitor-only-in-platform", severity: "error", comment: - "@capacitor/* is imported ONLY in src/platform, keeping domain/data/ui/app portable and testable without native mocks.", + "@capacitor/* is imported ONLY in the web app's platform directory, keeping the core and every screen portable and testable without native mocks.", from: { path: "^packages/[^/]+/src/", pathNot: "^packages/web/src/platform/" }, to: { path: RE_CAPACITOR }, }, @@ -118,9 +198,13 @@ module.exports = { // directory level too deep. tsConfig: { fileName: join(__dirname, "tsconfig.depcruise.json") }, enhancedResolveOptions: { - // Capacitor ships only `main`/`module` (no `exports`), so an - // `exportsFields`-only resolver drops it from the graph entirely and every - // capacitor ban silently passes. Listing `mainFields` restores resolution. + // exportsFields is what resolves @cue/core/... at all: the package declares + // one wildcard subpath key and no main. Capacitor is the mirror case, it + // ships only main/module and no exports, so dropping mainFields would take + // it out of the graph entirely and every capacitor ban would silently pass. + // preserveSymlinks defaults to false, which resolves the workspace link to + // its realpath under packages/core, which is what makes the anchors above + // match instead of node_modules. exportsFields: ["exports"], conditionNames: ["import", "require", "node", "browser", "default"], mainFields: ["module", "browser", "main"], diff --git a/biome.jsonc b/biome.jsonc index 7db08d8e..0c9b93f4 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -39,6 +39,38 @@ "trailingCommas": "all" } }, + "overrides": [ + { + // The core compiles with lib ES2023 and no "dom", which rejects window, + // document and indexedDB outright. Two families survive that: the Node + // globals @types/node has to declare for fetch, Response and the timers + // the core does use, and the storage globals Node 22 added. Neither + // exists on a React Native engine, and neither is an import, so no + // dependency rule can see them. + "includes": ["packages/core/**"], + "linter": { + "rules": { + "style": { + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": { + "process": "Not on a React Native engine. Take the value as an injected dependency.", + "Buffer": "Not on a React Native engine. Use Uint8Array or TextEncoder.", + "__dirname": "Not on a React Native engine.", + "__filename": "Not on a React Native engine.", + "require": "The core is ESM only.", + "localStorage": "Not on a React Native engine. Take a storage port instead.", + "sessionStorage": "Not on a React Native engine. Take a storage port instead.", + "navigator": "Not on a React Native engine. Take a Network or AppVisibility port instead." + } + } + } + } + } + } + } + ], "linter": { "enabled": true, "domains": { diff --git a/knip.json b/knip.json index 7184385d..bf72b05f 100644 --- a/knip.json +++ b/knip.json @@ -6,6 +6,10 @@ "entry": ["scripts/*.mjs", "scripts/mock-trakt/*.mjs"], "project": ["scripts/**/*.mjs", "*.ts"] }, + "packages/core": { + "entry": ["src/**/*.ts"], + "project": ["src/**/*.ts"] + }, "packages/web": { "project": ["src/**/*.{ts,tsx}"], "ignoreDependencies": ["tailwindcss"] diff --git a/lefthook.yml b/lefthook.yml index eb0c3b2f..53abd529 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -34,6 +34,12 @@ pre-commit: typecheck: glob: "*.{ts,tsx}" run: pnpm typecheck + core-portable: + # The one gate that has to run before the commit exists: it reads the + # working tree as well as the index, so a .tsx added under packages/core + # and not yet committed fails here rather than in CI afterwards. + glob: "packages/core/**" + run: pnpm check:core-portable pre-push: piped: true diff --git a/package.json b/package.json index 620ca02a..fd25699c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "check:arch": "depcruise packages --config .dependency-cruiser.cjs", "check:knip": "knip", "check:dup": "jscpd", + "check:core-portable": "vitest run --project core test/ci/core-portable.test.ts", "check:bundle": "./scripts/verify-bundle.sh", "buster:check": "node scripts/write-buster.mjs --check", "buster:bump": "node scripts/write-buster.mjs --bump", diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..53d35a8e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cue/core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Cue's shared core: the domain, the Trakt data layer and the platform ports, as TypeScript source.", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "5.101.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/web/src/data/auth/oauth.ts b/packages/core/src/data/auth/oauth.ts similarity index 98% rename from packages/web/src/data/auth/oauth.ts rename to packages/core/src/data/auth/oauth.ts index efd1303c..6bd20ce6 100644 --- a/packages/web/src/data/auth/oauth.ts +++ b/packages/core/src/data/auth/oauth.ts @@ -1,6 +1,6 @@ -import { type Token, tokenSchema } from "@domain/model/token"; -import { parseRetryAfterMs } from "@domain/write-queue/classify"; import { z } from "zod"; +import { type Token, tokenSchema } from "../../domain/model/token"; +import { parseRetryAfterMs } from "../../domain/write-queue/classify"; import type { FetchLike } from "../trakt/client"; /** diff --git a/packages/web/src/data/auth/pkce.ts b/packages/core/src/data/auth/pkce.ts similarity index 100% rename from packages/web/src/data/auth/pkce.ts rename to packages/core/src/data/auth/pkce.ts diff --git a/packages/web/src/data/image-source.ts b/packages/core/src/data/image-source.ts similarity index 100% rename from packages/web/src/data/image-source.ts rename to packages/core/src/data/image-source.ts diff --git a/packages/web/src/data/query-invalidation.ts b/packages/core/src/data/query-invalidation.ts similarity index 98% rename from packages/web/src/data/query-invalidation.ts rename to packages/core/src/data/query-invalidation.ts index a6e45f6d..f11a2464 100644 --- a/packages/web/src/data/query-invalidation.ts +++ b/packages/core/src/data/query-invalidation.ts @@ -1,5 +1,5 @@ -import type { InvalidationTarget } from "@domain/sync-activities"; import type { QueryClient } from "@tanstack/react-query"; +import type { InvalidationTarget } from "../domain/sync-activities"; import { queryKeys } from "./query-keys"; /** A composite TanStack query key (a readonly tuple prefix). */ diff --git a/packages/web/src/data/query-keys.ts b/packages/core/src/data/query-keys.ts similarity index 100% rename from packages/web/src/data/query-keys.ts rename to packages/core/src/data/query-keys.ts diff --git a/packages/web/src/data/trakt/authorized-fetch.ts b/packages/core/src/data/trakt/authorized-fetch.ts similarity index 98% rename from packages/web/src/data/trakt/authorized-fetch.ts rename to packages/core/src/data/trakt/authorized-fetch.ts index 195580df..f618a2e0 100644 --- a/packages/web/src/data/trakt/authorized-fetch.ts +++ b/packages/core/src/data/trakt/authorized-fetch.ts @@ -1,5 +1,5 @@ -import { shouldRefresh, TokenRefresher } from "@domain/auth/token"; -import type { Token } from "@domain/model/token"; +import { shouldRefresh, TokenRefresher } from "../../domain/auth/token"; +import type { Token } from "../../domain/model/token"; import { type OAuthConfig, refreshAccessToken, TokenRefreshError } from "../auth/oauth"; import type { FetchLike } from "./client"; diff --git a/packages/web/src/data/trakt/calendar.ts b/packages/core/src/data/trakt/calendar.ts similarity index 93% rename from packages/web/src/data/trakt/calendar.ts rename to packages/core/src/data/trakt/calendar.ts index 204e6aad..4c1fb88f 100644 --- a/packages/web/src/data/trakt/calendar.ts +++ b/packages/core/src/data/trakt/calendar.ts @@ -1,4 +1,4 @@ -import type { CalendarEntry } from "@domain/calendar"; +import type { CalendarEntry } from "../../domain/calendar"; import type { CalendarItem } from "./schemas"; import { toEpisodeIds } from "./show-detail"; diff --git a/packages/web/src/data/trakt/client.ts b/packages/core/src/data/trakt/client.ts similarity index 99% rename from packages/web/src/data/trakt/client.ts rename to packages/core/src/data/trakt/client.ts index 03664e5c..398c8d4e 100644 --- a/packages/web/src/data/trakt/client.ts +++ b/packages/core/src/data/trakt/client.ts @@ -1,4 +1,4 @@ -import { parseRetryAfterMs } from "@domain/write-queue/classify"; +import { parseRetryAfterMs } from "../../domain/write-queue/classify"; export const TRAKT_API_BASE = "https://api.trakt.tv"; const TRAKT_API_VERSION = "2"; diff --git a/packages/web/src/data/trakt/endpoints.ts b/packages/core/src/data/trakt/endpoints.ts similarity index 98% rename from packages/web/src/data/trakt/endpoints.ts rename to packages/core/src/data/trakt/endpoints.ts index a670e5e9..2576f126 100644 --- a/packages/web/src/data/trakt/endpoints.ts +++ b/packages/core/src/data/trakt/endpoints.ts @@ -1,7 +1,7 @@ -import type { HistoryRange } from "@domain/history"; -import type { EpisodeIds, MovieIds, ShowIds } from "@domain/model/ids"; -import type { LastActivities } from "@domain/sync-activities"; import type { z } from "zod"; +import type { HistoryRange } from "../../domain/history"; +import type { EpisodeIds, MovieIds, ShowIds } from "../../domain/model/ids"; +import type { LastActivities } from "../../domain/sync-activities"; import type { RequestOptions, TraktClient, TraktResult } from "./client"; import { type CalendarItem, diff --git a/packages/web/src/data/trakt/episode-detail.ts b/packages/core/src/data/trakt/episode-detail.ts similarity index 97% rename from packages/web/src/data/trakt/episode-detail.ts rename to packages/core/src/data/trakt/episode-detail.ts index bf22d14d..2ff58b4e 100644 --- a/packages/web/src/data/trakt/episode-detail.ts +++ b/packages/core/src/data/trakt/episode-detail.ts @@ -1,5 +1,5 @@ -import type { EpisodeIds } from "@domain/model/ids"; -import { isAired } from "@domain/time"; +import type { EpisodeIds } from "../../domain/model/ids"; +import { isAired } from "../../domain/time"; import type { EpisodeData, Progress } from "./schemas"; import { toEpisodeIds } from "./show-detail"; diff --git a/packages/web/src/data/trakt/history.ts b/packages/core/src/data/trakt/history.ts similarity index 96% rename from packages/web/src/data/trakt/history.ts rename to packages/core/src/data/trakt/history.ts index 64caa464..6a62cbb8 100644 --- a/packages/web/src/data/trakt/history.ts +++ b/packages/core/src/data/trakt/history.ts @@ -1,5 +1,5 @@ -import type { HistoryEntry } from "@domain/history"; -import type { EpisodePlay, MoviePlay } from "@domain/reversal"; +import type { HistoryEntry } from "../../domain/history"; +import type { EpisodePlay, MoviePlay } from "../../domain/reversal"; import { toMovieIds } from "./movie-library"; import type { HistoryItem } from "./schemas"; import { toEpisodeIds } from "./show-detail"; diff --git a/packages/web/src/data/trakt/library.ts b/packages/core/src/data/trakt/library.ts similarity index 98% rename from packages/web/src/data/trakt/library.ts rename to packages/core/src/data/trakt/library.ts index ca48cb62..d4512bc0 100644 --- a/packages/web/src/data/trakt/library.ts +++ b/packages/core/src/data/trakt/library.ts @@ -3,9 +3,9 @@ import { type EpisodeKey, type EpisodeRef, type LibraryShow, -} from "@domain/model/library"; -import { type EpisodePlay, MARK_MATCH_TOLERANCE_MS } from "@domain/reversal"; -import { toMs } from "@domain/time"; +} from "../../domain/model/library"; +import { type EpisodePlay, MARK_MATCH_TOLERANCE_MS } from "../../domain/reversal"; +import { toMs } from "../../domain/time"; import { resolveStill } from "../image-source"; import type { HiddenItem, Progress, WatchedShow, WatchlistItem } from "./schemas"; import { toEpisodeIds } from "./show-detail"; diff --git a/packages/web/src/data/trakt/movie-library.ts b/packages/core/src/data/trakt/movie-library.ts similarity index 98% rename from packages/web/src/data/trakt/movie-library.ts rename to packages/core/src/data/trakt/movie-library.ts index 0531147b..77047e8e 100644 --- a/packages/web/src/data/trakt/movie-library.ts +++ b/packages/core/src/data/trakt/movie-library.ts @@ -1,4 +1,4 @@ -import type { MovieIds } from "@domain/model/ids"; +import type { MovieIds } from "../../domain/model/ids"; import type { MovieDetailData, WatchedMovie, WatchlistItem } from "./schemas"; /** diff --git a/packages/web/src/data/trakt/pooled-endpoints.ts b/packages/core/src/data/trakt/pooled-endpoints.ts similarity index 100% rename from packages/web/src/data/trakt/pooled-endpoints.ts rename to packages/core/src/data/trakt/pooled-endpoints.ts diff --git a/packages/web/src/data/trakt/read-budget.ts b/packages/core/src/data/trakt/read-budget.ts similarity index 99% rename from packages/web/src/data/trakt/read-budget.ts rename to packages/core/src/data/trakt/read-budget.ts index 86560b08..2cae57e6 100644 --- a/packages/web/src/data/trakt/read-budget.ts +++ b/packages/core/src/data/trakt/read-budget.ts @@ -1,4 +1,4 @@ -import { toMs } from "@domain/time"; +import { toMs } from "../../domain/time"; import type { TraktClient, TraktResult } from "./client"; import { getHidden, getShowProgress, getWatchedShows, getWatchlist } from "./endpoints"; import { assembleLibrary, type LibraryEntry, showIdSet, watchedEpisodeCount } from "./library"; diff --git a/packages/web/src/data/trakt/repositories.ts b/packages/core/src/data/trakt/repositories.ts similarity index 97% rename from packages/web/src/data/trakt/repositories.ts rename to packages/core/src/data/trakt/repositories.ts index d77172da..2e06b885 100644 --- a/packages/web/src/data/trakt/repositories.ts +++ b/packages/core/src/data/trakt/repositories.ts @@ -2,7 +2,7 @@ import { diffActivities, type InvalidationTarget, type LastActivities, -} from "@domain/sync-activities"; +} from "../../domain/sync-activities"; import type { TraktClient, TraktFailure } from "./client"; import { getLastActivities } from "./pooled-endpoints"; diff --git a/packages/web/src/data/trakt/schemas.ts b/packages/core/src/data/trakt/schemas.ts similarity index 100% rename from packages/web/src/data/trakt/schemas.ts rename to packages/core/src/data/trakt/schemas.ts diff --git a/packages/web/src/data/trakt/search.ts b/packages/core/src/data/trakt/search.ts similarity index 98% rename from packages/web/src/data/trakt/search.ts rename to packages/core/src/data/trakt/search.ts index 1b6179c6..699976f0 100644 --- a/packages/web/src/data/trakt/search.ts +++ b/packages/core/src/data/trakt/search.ts @@ -1,4 +1,4 @@ -import type { MovieIds, ShowIds } from "@domain/model/ids"; +import type { MovieIds, ShowIds } from "../../domain/model/ids"; import type { MovieSummary, SearchResult, ShowSummary } from "./schemas"; /** A show/movie search hit, flattened for the result row + inline watchlist add. */ diff --git a/packages/web/src/data/trakt/show-detail.ts b/packages/core/src/data/trakt/show-detail.ts similarity index 97% rename from packages/web/src/data/trakt/show-detail.ts rename to packages/core/src/data/trakt/show-detail.ts index f39893f9..099e9248 100644 --- a/packages/web/src/data/trakt/show-detail.ts +++ b/packages/core/src/data/trakt/show-detail.ts @@ -1,6 +1,6 @@ -import type { EpisodeIds, ShowIds } from "@domain/model/ids"; -import type { EpisodeRef } from "@domain/model/library"; -import { isAired } from "@domain/time"; +import type { EpisodeIds, ShowIds } from "../../domain/model/ids"; +import type { EpisodeRef } from "../../domain/model/library"; +import { isAired } from "../../domain/time"; import { resolveStill } from "../image-source"; import type { EpisodeData, Progress, SeasonData, ShowDetailData } from "./schemas"; diff --git a/packages/web/src/data/trakt/transport.ts b/packages/core/src/data/trakt/transport.ts similarity index 87% rename from packages/web/src/data/trakt/transport.ts rename to packages/core/src/data/trakt/transport.ts index 2b6ff2a3..59e90233 100644 --- a/packages/web/src/data/trakt/transport.ts +++ b/packages/core/src/data/trakt/transport.ts @@ -1,4 +1,4 @@ -import type { DispatchResult, RequestDescriptor } from "@domain/write-queue/types"; +import type { DispatchResult, RequestDescriptor } from "../../domain/write-queue/types"; import type { TraktClient } from "./client"; /** diff --git a/packages/web/src/data/trakt/user-profile.ts b/packages/core/src/data/trakt/user-profile.ts similarity index 100% rename from packages/web/src/data/trakt/user-profile.ts rename to packages/core/src/data/trakt/user-profile.ts diff --git a/packages/web/src/domain/auth/token.ts b/packages/core/src/domain/auth/token.ts similarity index 100% rename from packages/web/src/domain/auth/token.ts rename to packages/core/src/domain/auth/token.ts diff --git a/packages/web/src/domain/calendar.ts b/packages/core/src/domain/calendar.ts similarity index 100% rename from packages/web/src/domain/calendar.ts rename to packages/core/src/domain/calendar.ts diff --git a/packages/web/src/domain/day.ts b/packages/core/src/domain/day.ts similarity index 100% rename from packages/web/src/domain/day.ts rename to packages/core/src/domain/day.ts diff --git a/packages/web/src/domain/history.ts b/packages/core/src/domain/history.ts similarity index 100% rename from packages/web/src/domain/history.ts rename to packages/core/src/domain/history.ts diff --git a/packages/web/src/domain/library-buckets.ts b/packages/core/src/domain/library-buckets.ts similarity index 100% rename from packages/web/src/domain/library-buckets.ts rename to packages/core/src/domain/library-buckets.ts diff --git a/packages/web/src/domain/model/ids.ts b/packages/core/src/domain/model/ids.ts similarity index 100% rename from packages/web/src/domain/model/ids.ts rename to packages/core/src/domain/model/ids.ts diff --git a/packages/web/src/domain/model/library.ts b/packages/core/src/domain/model/library.ts similarity index 100% rename from packages/web/src/domain/model/library.ts rename to packages/core/src/domain/model/library.ts diff --git a/packages/web/src/domain/model/token.ts b/packages/core/src/domain/model/token.ts similarity index 100% rename from packages/web/src/domain/model/token.ts rename to packages/core/src/domain/model/token.ts diff --git a/packages/web/src/domain/ports/haptics.ts b/packages/core/src/domain/ports/haptics.ts similarity index 100% rename from packages/web/src/domain/ports/haptics.ts rename to packages/core/src/domain/ports/haptics.ts diff --git a/packages/web/src/domain/ports/reminders.ts b/packages/core/src/domain/ports/reminders.ts similarity index 94% rename from packages/web/src/domain/ports/reminders.ts rename to packages/core/src/domain/ports/reminders.ts index 8358bd4c..f3bb2575 100644 --- a/packages/web/src/domain/ports/reminders.ts +++ b/packages/core/src/domain/ports/reminders.ts @@ -1,4 +1,4 @@ -import type { PlannedReminder } from "@domain/reminders"; +import type { PlannedReminder } from "../reminders"; /** * The notification port, declared where both sides of the seam can read it: diff --git a/packages/web/src/domain/recently-aired.ts b/packages/core/src/domain/recently-aired.ts similarity index 100% rename from packages/web/src/domain/recently-aired.ts rename to packages/core/src/domain/recently-aired.ts diff --git a/packages/web/src/domain/reminders.ts b/packages/core/src/domain/reminders.ts similarity index 100% rename from packages/web/src/domain/reminders.ts rename to packages/core/src/domain/reminders.ts diff --git a/packages/web/src/domain/reversal.ts b/packages/core/src/domain/reversal.ts similarity index 100% rename from packages/web/src/domain/reversal.ts rename to packages/core/src/domain/reversal.ts diff --git a/packages/web/src/domain/sync-activities.ts b/packages/core/src/domain/sync-activities.ts similarity index 100% rename from packages/web/src/domain/sync-activities.ts rename to packages/core/src/domain/sync-activities.ts diff --git a/packages/web/src/domain/time.ts b/packages/core/src/domain/time.ts similarity index 100% rename from packages/web/src/domain/time.ts rename to packages/core/src/domain/time.ts diff --git a/packages/web/src/domain/up-next.ts b/packages/core/src/domain/up-next.ts similarity index 100% rename from packages/web/src/domain/up-next.ts rename to packages/core/src/domain/up-next.ts diff --git a/packages/web/src/domain/watch-status.ts b/packages/core/src/domain/watch-status.ts similarity index 100% rename from packages/web/src/domain/watch-status.ts rename to packages/core/src/domain/watch-status.ts diff --git a/packages/web/src/domain/write-queue/bulk.ts b/packages/core/src/domain/write-queue/bulk.ts similarity index 100% rename from packages/web/src/domain/write-queue/bulk.ts rename to packages/core/src/domain/write-queue/bulk.ts diff --git a/packages/web/src/domain/write-queue/classify.ts b/packages/core/src/domain/write-queue/classify.ts similarity index 100% rename from packages/web/src/domain/write-queue/classify.ts rename to packages/core/src/domain/write-queue/classify.ts diff --git a/packages/web/src/domain/write-queue/coalesce.ts b/packages/core/src/domain/write-queue/coalesce.ts similarity index 100% rename from packages/web/src/domain/write-queue/coalesce.ts rename to packages/core/src/domain/write-queue/coalesce.ts diff --git a/packages/web/src/domain/write-queue/ops.ts b/packages/core/src/domain/write-queue/ops.ts similarity index 100% rename from packages/web/src/domain/write-queue/ops.ts rename to packages/core/src/domain/write-queue/ops.ts diff --git a/packages/web/src/domain/write-queue/queue.ts b/packages/core/src/domain/write-queue/queue.ts similarity index 100% rename from packages/web/src/domain/write-queue/queue.ts rename to packages/core/src/domain/write-queue/queue.ts diff --git a/packages/web/src/domain/write-queue/types.ts b/packages/core/src/domain/write-queue/types.ts similarity index 100% rename from packages/web/src/domain/write-queue/types.ts rename to packages/core/src/domain/write-queue/types.ts diff --git a/packages/web/src/ui/format.ts b/packages/core/src/format.ts similarity index 98% rename from packages/web/src/ui/format.ts rename to packages/core/src/format.ts index e47718f5..613a452e 100644 --- a/packages/web/src/ui/format.ts +++ b/packages/core/src/format.ts @@ -1,6 +1,6 @@ /** Presentation helpers shared across screens (dates, episode codes, progress). */ -import { localTimeZone } from "@domain/time"; +import { localTimeZone } from "./domain/time"; const MONTHS = [ "Jan", diff --git a/packages/web/src/platform/json-store.ts b/packages/core/src/ports/json-store.ts similarity index 94% rename from packages/web/src/platform/json-store.ts rename to packages/core/src/ports/json-store.ts index d620847e..bb701a0a 100644 --- a/packages/web/src/platform/json-store.ts +++ b/packages/core/src/ports/json-store.ts @@ -1,4 +1,4 @@ -import type { KeyValueStore } from "@platform/kv"; +import type { KeyValueStore } from "./kv"; /** * A single JSON value behind the platform key-value abstraction. diff --git a/packages/core/src/ports/kv.ts b/packages/core/src/ports/kv.ts new file mode 100644 index 00000000..2f0565e5 --- /dev/null +++ b/packages/core/src/ports/kv.ts @@ -0,0 +1,12 @@ +/** + * One string key-value abstraction, injected rather than imported: every + * backend the app has had is a promise-returning store of strings, and values + * round-trip byte-identical on all of them. The core writes the op log, the + * last-activities baseline and the persisted query cache through this and knows + * nothing about where they land. + */ +export interface KeyValueStore { + read(key: string): Promise; + write(key: string, value: string): Promise; + remove(key: string): Promise; +} diff --git a/packages/web/src/platform/token-store.ts b/packages/core/src/ports/token-store.ts similarity index 62% rename from packages/web/src/platform/token-store.ts rename to packages/core/src/ports/token-store.ts index d7d9e5af..9981a689 100644 --- a/packages/web/src/platform/token-store.ts +++ b/packages/core/src/ports/token-store.ts @@ -1,6 +1,6 @@ -import { type Token, tokenSchema } from "@domain/model/token"; -import { createJsonStore, type JsonStore } from "@platform/json-store"; -import type { KeyValueStore } from "@platform/kv"; +import { type Token, tokenSchema } from "../domain/model/token"; +import { createJsonStore, type JsonStore } from "./json-store"; +import type { KeyValueStore } from "./kv"; const TOKEN_KEY = "cue.trakt.token"; diff --git a/packages/core/test/ci/core-portable.test.ts b/packages/core/test/ci/core-portable.test.ts new file mode 100644 index 00000000..c34b172f --- /dev/null +++ b/packages/core/test/ci/core-portable.test.ts @@ -0,0 +1,31 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +/** + * The core is reached through one wildcard export key, `"./*": "./src/*.ts"`. + * Node's subpath patterns are a substring substitution with no extension + * search, so a `.tsx` under this tree is unreachable by every consumer, and a + * `.css` is unreachable by a bundler that has no CSS pipeline. The compiler + * cannot see either: with `lib: ["ES2023"]` and no `"dom"`, a DOM global is a + * type error but DOM JSX in a `.tsx` produces none at all. + * + * `--others --exclude-standard` is what makes this fail in the pre-commit hook: + * `git ls-files` alone reads the index, so a newly added file that is not yet + * committed would pass here and fail only in CI, after the commit exists. + */ +const sourceFiles = execFileSync( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "src"], + { cwd: new URL("../..", import.meta.url), encoding: "utf8" }, +) + .split("\0") + .filter(Boolean); + +describe("core portability", () => { + it.each([".tsx", ".css"])("carries no %s file", (extension) => { + expect( + sourceFiles.filter((file) => file.endsWith(extension)), + `@cue/core is reached through one "./*": "./src/*.ts" export key, which resolves neither .tsx nor .css. Keep the file in the app that renders it.`, + ).toEqual([]); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..4a003a33 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + // No "dom": the core runs on a JavaScript engine with no window, no document + // and no navigator, so a reference to one is a compile error here rather than + // a crash on the other target. That guard does not see DOM JSX, which is why + // check:core-portable asserts this tree carries no .tsx and no .css. + "compilerOptions": { + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src", "test"], + "exclude": ["node_modules", "coverage"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..3ea06cdd --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "core", + environment: "node", + globals: true, + include: ["test/**/*.test.ts"], + }, +}); diff --git a/packages/web/package.json b/packages/web/package.json index 516992d4..dd36353c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,6 +19,7 @@ "@capacitor/local-notifications": "^8.3.1", "@capacitor/preferences": "8.0.1", "@capacitor/status-bar": "^8.0.3", + "@cue/core": "workspace:*", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", "@tanstack/query-async-storage-persister": "5.101.2", diff --git a/packages/web/src/app/AuthGate.tsx b/packages/web/src/app/AuthGate.tsx index 8cec682b..b2dab4db 100644 --- a/packages/web/src/app/AuthGate.tsx +++ b/packages/web/src/app/AuthGate.tsx @@ -1,7 +1,7 @@ import { router } from "@app/router"; import { RuntimeBoot } from "@app/runtime/RuntimeBoot"; -import type { KeyValueStore } from "@platform/kv"; -import type { TokenStore } from "@platform/token-store"; +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { TokenStore } from "@cue/core/ports/token-store"; import { RouterProvider } from "@tanstack/react-router"; import { type AuthStore, AuthStoreProvider, useAuth } from "@ui/auth/store"; import { Onboarding } from "@ui/screens/onboarding/Onboarding"; diff --git a/packages/web/src/app/auth/create-auth-store.ts b/packages/web/src/app/auth/create-auth-store.ts index d31e7bca..fd2eb2b5 100644 --- a/packages/web/src/app/auth/create-auth-store.ts +++ b/packages/web/src/app/auth/create-auth-store.ts @@ -6,10 +6,10 @@ import { pollDeviceToken, requestDeviceCode, revokeToken, -} from "@data/auth/oauth"; -import { createPkcePair } from "@data/auth/pkce"; -import type { Token } from "@domain/model/token"; -import type { TokenStore } from "@platform/token-store"; +} from "@cue/core/data/auth/oauth"; +import { createPkcePair } from "@cue/core/data/auth/pkce"; +import type { Token } from "@cue/core/domain/model/token"; +import type { TokenStore } from "@cue/core/ports/token-store"; import type { AuthActions, AuthState, AuthStore } from "@ui/auth/store"; import { createStore } from "zustand/vanilla"; diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 8e3aacd7..4e372bad 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -10,6 +10,7 @@ import { shouldDehydrateQuery, } from "@app/query-client"; import { router } from "@app/router"; +import { createTokenStore } from "@cue/core/ports/token-store"; import { getNativeAppVersion } from "@platform/app-version"; import { bindHardwareBack } from "@platform/back-button"; import { createNativeHaptics } from "@platform/haptics"; @@ -17,7 +18,6 @@ import { createKeyValueStore } from "@platform/kv"; import { isNativePlatform } from "@platform/platform"; import { createNativeReminders } from "@platform/reminders"; import { applyStatusBarTheme } from "@platform/status-bar"; -import { createTokenStore } from "@platform/token-store"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { usePrefs } from "@ui/prefs/prefs-store"; import { AppVersionProvider } from "@ui/runtime/app-version"; diff --git a/packages/web/src/app/query-client.ts b/packages/web/src/app/query-client.ts index 592911c5..6b86a92f 100644 --- a/packages/web/src/app/query-client.ts +++ b/packages/web/src/app/query-client.ts @@ -1,4 +1,4 @@ -import { queryKeys } from "@data/query-keys"; +import { queryKeys } from "@cue/core/data/query-keys"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { type Query, QueryClient } from "@tanstack/react-query"; import { PERSISTED_CACHE } from "@ui/runtime/persist-buster"; diff --git a/packages/web/src/app/runtime/RuntimeBoot.tsx b/packages/web/src/app/runtime/RuntimeBoot.tsx index 7531e783..f2dd4a3a 100644 --- a/packages/web/src/app/runtime/RuntimeBoot.tsx +++ b/packages/web/src/app/runtime/RuntimeBoot.tsx @@ -1,7 +1,7 @@ import { createCueRuntime } from "@app/runtime/create-runtime"; import { sessionTeardown } from "@app/session"; -import type { KeyValueStore } from "@platform/kv"; -import type { TokenStore } from "@platform/token-store"; +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { TokenStore } from "@cue/core/ports/token-store"; import { useAuth } from "@ui/auth/store"; import { useEpisodeReminders } from "@ui/hooks/useEpisodeReminders"; import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; diff --git a/packages/web/src/app/runtime/create-runtime.ts b/packages/web/src/app/runtime/create-runtime.ts index 1453148c..1671e996 100644 --- a/packages/web/src/app/runtime/create-runtime.ts +++ b/packages/web/src/app/runtime/create-runtime.ts @@ -1,18 +1,18 @@ import { TRAKT_BASE_OVERRIDE, TRAKT_CLIENT_ID } from "@app/config"; import { queryClient, queryPersister } from "@app/query-client"; import { PendingWritesError, type TeardownOptions } from "@app/session"; -import { invalidationKeys } from "@data/query-invalidation"; -import { createAuthorizedFetch } from "@data/trakt/authorized-fetch"; -import { assembleCalendarEntries } from "@data/trakt/calendar"; -import { TraktClient } from "@data/trakt/client"; -import { assembleEpisodeDetail } from "@data/trakt/episode-detail"; +import { invalidationKeys } from "@cue/core/data/query-invalidation"; +import { createAuthorizedFetch } from "@cue/core/data/trakt/authorized-fetch"; +import { assembleCalendarEntries } from "@cue/core/data/trakt/calendar"; +import { TraktClient } from "@cue/core/data/trakt/client"; +import { assembleEpisodeDetail } from "@cue/core/data/trakt/episode-detail"; import { assembleEpisodePlays, assembleHistoryEntries, assembleMoviePlays, -} from "@data/trakt/history"; -import { additiveLanded, markLanded, showIdSet } from "@data/trakt/library"; -import { assembleMovieHeader, assembleMovieLibrary } from "@data/trakt/movie-library"; +} from "@cue/core/data/trakt/history"; +import { additiveLanded, markLanded, showIdSet } from "@cue/core/data/trakt/library"; +import { assembleMovieHeader, assembleMovieLibrary } from "@cue/core/data/trakt/movie-library"; import { getEpisode, getHidden, @@ -33,26 +33,30 @@ import { getWatchedMovies, getWatchlist, searchTrakt, -} from "@data/trakt/pooled-endpoints"; -import { loadUpNextEntries } from "@data/trakt/read-budget"; -import { createLastActivitiesRepository } from "@data/trakt/repositories"; -import type { UserStats } from "@data/trakt/schemas"; +} from "@cue/core/data/trakt/pooled-endpoints"; +import { loadUpNextEntries } from "@cue/core/data/trakt/read-budget"; +import { createLastActivitiesRepository } from "@cue/core/data/trakt/repositories"; +import type { UserStats } from "@cue/core/data/trakt/schemas"; import { assembleMovieHits, assembleSearchHits, assembleShowHits, rankSearchHits, -} from "@data/trakt/search"; -import { assembleSeasons, assembleShowInfo, assembleShowProgress } from "@data/trakt/show-detail"; -import { createTraktTransport } from "@data/trakt/transport"; -import { assembleUserProfile, type UserProfile } from "@data/trakt/user-profile"; -import type { Token } from "@domain/model/token"; -import type { LastActivities } from "@domain/sync-activities"; -import { WriteQueue } from "@domain/write-queue/queue"; -import type { QueuedOp } from "@domain/write-queue/types"; -import { createJsonStore } from "@platform/json-store"; -import type { KeyValueStore } from "@platform/kv"; -import type { TokenStore } from "@platform/token-store"; +} from "@cue/core/data/trakt/search"; +import { + assembleSeasons, + assembleShowInfo, + assembleShowProgress, +} from "@cue/core/data/trakt/show-detail"; +import { createTraktTransport } from "@cue/core/data/trakt/transport"; +import { assembleUserProfile, type UserProfile } from "@cue/core/data/trakt/user-profile"; +import type { Token } from "@cue/core/domain/model/token"; +import type { LastActivities } from "@cue/core/domain/sync-activities"; +import { WriteQueue } from "@cue/core/domain/write-queue/queue"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; +import { createJsonStore } from "@cue/core/ports/json-store"; +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { TokenStore } from "@cue/core/ports/token-store"; import type { ActivitiesReconcile, BrowseData, diff --git a/packages/web/src/platform/haptics.ts b/packages/web/src/platform/haptics.ts index b88baae7..b2b81caa 100644 --- a/packages/web/src/platform/haptics.ts +++ b/packages/web/src/platform/haptics.ts @@ -1,5 +1,5 @@ import { registerPlugin } from "@capacitor/core"; -import { type Haptics, SILENT } from "@domain/ports/haptics"; +import { type Haptics, SILENT } from "@cue/core/domain/ports/haptics"; import { isNativePlatform } from "./platform"; /** diff --git a/packages/web/src/platform/kv.ts b/packages/web/src/platform/kv.ts index 79d38149..a1e2b904 100644 --- a/packages/web/src/platform/kv.ts +++ b/packages/web/src/platform/kv.ts @@ -1,18 +1,12 @@ import { Preferences } from "@capacitor/preferences"; +import type { KeyValueStore } from "@cue/core/ports/kv"; import { del, get, set } from "idb-keyval"; /** - * One string key-value abstraction with two interchangeable backends. - * Web persists to IndexedDB (idb-keyval) so a large cache - * never blocks the main thread; native persists to Capacitor Preferences, the - * one store the OS will not evict. Values round-trip byte-identical. + * The two interchangeable backends behind `KeyValueStore`. Web persists to + * IndexedDB (idb-keyval) so a large cache never blocks the main thread; native + * persists to Capacitor Preferences, the one store the OS will not evict. */ -export interface KeyValueStore { - read(key: string): Promise; - write(key: string, value: string): Promise; - remove(key: string): Promise; -} - function webKeyValueStore(): KeyValueStore { return { async read(key) { diff --git a/packages/web/src/platform/reminders.ts b/packages/web/src/platform/reminders.ts index e685e488..c9ed8893 100644 --- a/packages/web/src/platform/reminders.ts +++ b/packages/web/src/platform/reminders.ts @@ -1,7 +1,7 @@ import { Capacitor } from "@capacitor/core"; import { LocalNotifications } from "@capacitor/local-notifications"; -import { type Reminders, SILENT } from "@domain/ports/reminders"; -import { diffReminders, type PendingReminder, type PlannedReminder } from "@domain/reminders"; +import { type Reminders, SILENT } from "@cue/core/domain/ports/reminders"; +import { diffReminders, type PendingReminder, type PlannedReminder } from "@cue/core/domain/reminders"; import { isNativePlatform } from "./platform"; /** diff --git a/packages/web/src/ui/components/ArtPlaceholder.tsx b/packages/web/src/ui/components/ArtPlaceholder.tsx index df2deb14..5b431a70 100644 --- a/packages/web/src/ui/components/ArtPlaceholder.tsx +++ b/packages/web/src/ui/components/ArtPlaceholder.tsx @@ -1,4 +1,4 @@ -import { initialsOf } from "@data/image-source"; +import { initialsOf } from "@cue/core/data/image-source"; import type { ReactElement } from "react"; /** The one "no artwork" mark reused by every placeholder: a quiet media diff --git a/packages/web/src/ui/components/CountdownPanel.tsx b/packages/web/src/ui/components/CountdownPanel.tsx index c1343db3..b0145a3c 100644 --- a/packages/web/src/ui/components/CountdownPanel.tsx +++ b/packages/web/src/ui/components/CountdownPanel.tsx @@ -1,4 +1,4 @@ -import { localTimeZone } from "@domain/time"; +import { localTimeZone } from "@cue/core/domain/time"; import type { ReactElement } from "react"; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/packages/web/src/ui/components/MarqueeCard.tsx b/packages/web/src/ui/components/MarqueeCard.tsx index 2091d297..ca56191b 100644 --- a/packages/web/src/ui/components/MarqueeCard.tsx +++ b/packages/web/src/ui/components/MarqueeCard.tsx @@ -1,13 +1,13 @@ -import { resolveBackdrop } from "@data/image-source"; -import type { LibraryEntry } from "@data/trakt/library"; -import type { EpisodeRef } from "@domain/model/library"; -import { epCode } from "@domain/model/library"; -import { toMs } from "@domain/time"; +import { resolveBackdrop } from "@cue/core/data/image-source"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { EpisodeRef } from "@cue/core/domain/model/library"; +import { epCode } from "@cue/core/domain/model/library"; +import { toMs } from "@cue/core/domain/time"; import { Link } from "@tanstack/react-router"; import { artGradient } from "@ui/components/artGradient"; import { CheckControl, type CheckState } from "@ui/components/CheckControl"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { episodesLeft, watchedPercent } from "@ui/format"; +import { episodesLeft, watchedPercent } from "@cue/core/format"; import { useShowArt } from "@ui/hooks/useShowArt"; import { Poster } from "@ui/screens/up-next/Poster"; import type { ReactElement } from "react"; diff --git a/packages/web/src/ui/hooks/library-cache.ts b/packages/web/src/ui/hooks/library-cache.ts index 57814399..15385ec6 100644 --- a/packages/web/src/ui/hooks/library-cache.ts +++ b/packages/web/src/ui/hooks/library-cache.ts @@ -1,7 +1,7 @@ -import { queryKeys } from "@data/query-keys"; -import type { EpisodeDetail } from "@data/trakt/episode-detail"; -import type { LibraryEntry } from "@data/trakt/library"; -import type { SeasonView } from "@data/trakt/show-detail"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { SeasonView } from "@cue/core/data/trakt/show-detail"; import type { QueryClient } from "@tanstack/react-query"; import type { UpNextData } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/mark-store.ts b/packages/web/src/ui/hooks/mark-store.ts index b4c61e9c..3aeef429 100644 --- a/packages/web/src/ui/hooks/mark-store.ts +++ b/packages/web/src/ui/hooks/mark-store.ts @@ -1,4 +1,4 @@ -import type { LibraryEntry } from "@data/trakt/library"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; import type { CueRuntime } from "@ui/runtime/runtime"; import { create } from "zustand"; diff --git a/packages/web/src/ui/hooks/queue-order.ts b/packages/web/src/ui/hooks/queue-order.ts index b0686a3d..e497f593 100644 --- a/packages/web/src/ui/hooks/queue-order.ts +++ b/packages/web/src/ui/hooks/queue-order.ts @@ -1,5 +1,5 @@ -import { toMs } from "@domain/time"; -import type { UpNextItem } from "@domain/up-next"; +import { toMs } from "@cue/core/domain/time"; +import type { UpNextItem } from "@cue/core/domain/up-next"; import type { LapsedOrder, NextEpisodeOrder } from "@ui/prefs/tracking"; function airMs(item: UpNextItem): number { diff --git a/packages/web/src/ui/hooks/resolveUnmark.ts b/packages/web/src/ui/hooks/resolveUnmark.ts index ac676dd0..7e1f95c9 100644 --- a/packages/web/src/ui/hooks/resolveUnmark.ts +++ b/packages/web/src/ui/hooks/resolveUnmark.ts @@ -4,7 +4,7 @@ import { type MoviePlay, planEpisodeUnmark, type UnmarkPlan, -} from "@domain/reversal"; +} from "@cue/core/domain/reversal"; import type { CueRuntime } from "@ui/runtime/runtime"; /** diff --git a/packages/web/src/ui/hooks/useBrowse.ts b/packages/web/src/ui/hooks/useBrowse.ts index 6674ed06..ff73ba9a 100644 --- a/packages/web/src/ui/hooks/useBrowse.ts +++ b/packages/web/src/ui/hooks/useBrowse.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { SearchHit } from "@data/trakt/search"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { useQuery } from "@tanstack/react-query"; import { BROWSE_STALE_TIME_MS } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useCalendar.ts b/packages/web/src/ui/hooks/useCalendar.ts index c4ed411e..493c8c50 100644 --- a/packages/web/src/ui/hooks/useCalendar.ts +++ b/packages/web/src/ui/hooks/useCalendar.ts @@ -1,6 +1,6 @@ -import { queryKeys } from "@data/query-keys"; -import { type CalendarDay, type CalendarEntry, groupCalendar } from "@domain/calendar"; -import { localTimeZone } from "@domain/time"; +import { queryKeys } from "@cue/core/data/query-keys"; +import { type CalendarDay, type CalendarEntry, groupCalendar } from "@cue/core/domain/calendar"; +import { localTimeZone } from "@cue/core/domain/time"; import { useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS, type QueryStatus, queryStatus } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useEpisode.ts b/packages/web/src/ui/hooks/useEpisode.ts index 625057d4..eed5e513 100644 --- a/packages/web/src/ui/hooks/useEpisode.ts +++ b/packages/web/src/ui/hooks/useEpisode.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { EpisodeDetail } from "@data/trakt/episode-detail"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; import { useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useEpisodeReminders.ts b/packages/web/src/ui/hooks/useEpisodeReminders.ts index 86799886..17abdfd4 100644 --- a/packages/web/src/ui/hooks/useEpisodeReminders.ts +++ b/packages/web/src/ui/hooks/useEpisodeReminders.ts @@ -1,4 +1,4 @@ -import { planReminders, REMINDER_WINDOW_DAYS } from "@domain/reminders"; +import { planReminders, REMINDER_WINDOW_DAYS } from "@cue/core/domain/reminders"; import { useCalendar } from "@ui/hooks/useCalendar"; import { usePrefs } from "@ui/prefs/prefs-store"; import { useReminders } from "@ui/runtime/reminders"; diff --git a/packages/web/src/ui/hooks/useHideShow.ts b/packages/web/src/ui/hooks/useHideShow.ts index 2c6a2311..d0382b8f 100644 --- a/packages/web/src/ui/hooks/useHideShow.ts +++ b/packages/web/src/ui/hooks/useHideShow.ts @@ -1,6 +1,6 @@ -import { queryKeys } from "@data/query-keys"; -import type { ShowIds } from "@domain/model/ids"; -import { buildHideShowOp, buildUnhideShowOp } from "@domain/write-queue/ops"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { ShowIds } from "@cue/core/domain/model/ids"; +import { buildHideShowOp, buildUnhideShowOp } from "@cue/core/domain/write-queue/ops"; import { useQueryClient } from "@tanstack/react-query"; import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; import { useCallback, useState } from "react"; diff --git a/packages/web/src/ui/hooks/useHistory.ts b/packages/web/src/ui/hooks/useHistory.ts index 449a9093..6f800cf7 100644 --- a/packages/web/src/ui/hooks/useHistory.ts +++ b/packages/web/src/ui/hooks/useHistory.ts @@ -1,18 +1,18 @@ -import { invalidateShowProgress } from "@data/query-invalidation"; -import { queryKeys } from "@data/query-keys"; +import { invalidateShowProgress } from "@cue/core/data/query-invalidation"; +import { queryKeys } from "@cue/core/data/query-keys"; import { groupHistory, type HistoryDay, type HistoryEntry, historyRange, historyScopeKey, -} from "@domain/history"; -import { localTimeZone } from "@domain/time"; +} from "@cue/core/domain/history"; +import { localTimeZone } from "@cue/core/domain/time"; import { buildMarkEpisodeOp, buildMarkMovieOp, buildRemoveHistoryPlayOp, -} from "@domain/write-queue/ops"; +} from "@cue/core/domain/write-queue/ops"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { queryStatus, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; diff --git a/packages/web/src/ui/hooks/useLibraryBuckets.ts b/packages/web/src/ui/hooks/useLibraryBuckets.ts index 227c7e34..fae1f532 100644 --- a/packages/web/src/ui/hooks/useLibraryBuckets.ts +++ b/packages/web/src/ui/hooks/useLibraryBuckets.ts @@ -1,7 +1,7 @@ -import type { LibraryEntry } from "@data/trakt/library"; -import { byTitle, type LibrarySort } from "@domain/library-buckets"; -import { toMs } from "@domain/time"; -import { computeWatchStatus } from "@domain/watch-status"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import { byTitle, type LibrarySort } from "@cue/core/domain/library-buckets"; +import { toMs } from "@cue/core/domain/time"; +import { computeWatchStatus } from "@cue/core/domain/watch-status"; import { type QueryStatus, queryStatus } from "@ui/hooks/query-freshness"; import { useLibrarySnapshot } from "@ui/hooks/useLibrarySnapshot"; import { useMemo } from "react"; diff --git a/packages/web/src/ui/hooks/useLibrarySnapshot.ts b/packages/web/src/ui/hooks/useLibrarySnapshot.ts index ab3343a7..bbabf0d0 100644 --- a/packages/web/src/ui/hooks/useLibrarySnapshot.ts +++ b/packages/web/src/ui/hooks/useLibrarySnapshot.ts @@ -1,7 +1,11 @@ -import { queryKeys } from "@data/query-keys"; -import type { LibraryEntry } from "@data/trakt/library"; -import { firstUnwatchedAired, type SeasonView, toEpisodeRef } from "@data/trakt/show-detail"; -import { needsNextEpisode, reconcileRecentlyAired } from "@domain/recently-aired"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import { + firstUnwatchedAired, + type SeasonView, + toEpisodeRef, +} from "@cue/core/data/trakt/show-detail"; +import { needsNextEpisode, reconcileRecentlyAired } from "@cue/core/domain/recently-aired"; import { queryOptions, type UseQueryResult, useQueries, useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { useRecentlyAired } from "@ui/hooks/useCalendar"; diff --git a/packages/web/src/ui/hooks/useMarkSeason.ts b/packages/web/src/ui/hooks/useMarkSeason.ts index c49fc407..7fe75b20 100644 --- a/packages/web/src/ui/hooks/useMarkSeason.ts +++ b/packages/web/src/ui/hooks/useMarkSeason.ts @@ -1,17 +1,17 @@ -import { invalidateShowProgress } from "@data/query-invalidation"; -import { queryKeys } from "@data/query-keys"; -import type { SeasonView, ShowProgress } from "@data/trakt/show-detail"; -import type { EpisodeIds, ShowIds } from "@domain/model/ids"; -import { planSeasonUnmark } from "@domain/reversal"; -import { buildBulkMarkOps, type SeasonTree } from "@domain/write-queue/bulk"; +import { invalidateShowProgress } from "@cue/core/data/query-invalidation"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { SeasonView, ShowProgress } from "@cue/core/data/trakt/show-detail"; +import type { EpisodeIds, ShowIds } from "@cue/core/domain/model/ids"; +import { planSeasonUnmark } from "@cue/core/domain/reversal"; +import { buildBulkMarkOps, type SeasonTree } from "@cue/core/domain/write-queue/bulk"; import { buildAddEpisodePlayOp, buildMarkEpisodeOp, buildRemovePlaysOp, buildUnmarkEpisodeOp, episodeItemKey, -} from "@domain/write-queue/ops"; -import type { QueuedOp } from "@domain/write-queue/types"; +} from "@cue/core/domain/write-queue/ops"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { useQueryClient } from "@tanstack/react-query"; import { type EpisodeMatch, diff --git a/packages/web/src/ui/hooks/useMarkWatched.ts b/packages/web/src/ui/hooks/useMarkWatched.ts index de88a39c..773d073b 100644 --- a/packages/web/src/ui/hooks/useMarkWatched.ts +++ b/packages/web/src/ui/hooks/useMarkWatched.ts @@ -1,16 +1,17 @@ -import { invalidateShowProgress } from "@data/query-invalidation"; -import { queryKeys } from "@data/query-keys"; -import { advancePastNext, type LibraryEntry, type MarkContext } from "@data/trakt/library"; -import { epCode } from "@domain/model/library"; +import { invalidateShowProgress } from "@cue/core/data/query-invalidation"; +import { queryKeys } from "@cue/core/data/query-keys"; +import { advancePastNext, type LibraryEntry, type MarkContext } from "@cue/core/data/trakt/library"; +import { epCode } from "@cue/core/domain/model/library"; import { buildMarkEpisodeOp, buildRemovePlaysOp, buildUnmarkEpisodeOp, episodeItemKey, -} from "@domain/write-queue/ops"; +} from "@cue/core/domain/write-queue/ops"; +import { epCode, middleTruncate } from "@cue/core/format"; import { useQueryClient } from "@tanstack/react-query"; import { dismissSnack, showSnack, useSnackbar } from "@ui/components/snackbar-store"; -import { middleTruncate } from "@ui/format"; +import { middleTruncate } from "@cue/core/format"; import { patchEpisodeDetail, patchLibraryEntry, patchShowSeasons } from "@ui/hooks/library-cache"; import { hasPendingMark, diff --git a/packages/web/src/ui/hooks/useMovieActions.ts b/packages/web/src/ui/hooks/useMovieActions.ts index 068051ba..f1331e2e 100644 --- a/packages/web/src/ui/hooks/useMovieActions.ts +++ b/packages/web/src/ui/hooks/useMovieActions.ts @@ -1,13 +1,13 @@ -import { queryKeys } from "@data/query-keys"; -import type { MovieEntry } from "@data/trakt/movie-library"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; import { buildAddWatchlistOp, buildMarkMovieOp, buildRemoveHistoryPlayOp, buildRemoveWatchlistOp, buildUnmarkMovieOp, -} from "@domain/write-queue/ops"; -import type { QueuedOp } from "@domain/write-queue/types"; +} from "@cue/core/domain/write-queue/ops"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { useQueryClient } from "@tanstack/react-query"; import { resolveMovieUnmark, routeMovieUnmark } from "@ui/hooks/resolveUnmark"; import { invertOp } from "@ui/hooks/useMarkSeason"; diff --git a/packages/web/src/ui/hooks/useMovieDetail.ts b/packages/web/src/ui/hooks/useMovieDetail.ts index c7c72ea7..950fbb3d 100644 --- a/packages/web/src/ui/hooks/useMovieDetail.ts +++ b/packages/web/src/ui/hooks/useMovieDetail.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { MovieHeader } from "@data/trakt/movie-library"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { MovieHeader } from "@cue/core/data/trakt/movie-library"; import { type DetailHeaderView, useDetailHeader } from "@ui/hooks/useDetailHeader"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useMovieLibrary.ts b/packages/web/src/ui/hooks/useMovieLibrary.ts index 9bdb3da1..fcc53493 100644 --- a/packages/web/src/ui/hooks/useMovieLibrary.ts +++ b/packages/web/src/ui/hooks/useMovieLibrary.ts @@ -1,6 +1,6 @@ -import { queryKeys } from "@data/query-keys"; -import type { MovieEntry } from "@data/trakt/movie-library"; -import { byTitle } from "@domain/library-buckets"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; +import { byTitle } from "@cue/core/domain/library-buckets"; import { type QueryClient, useQuery } from "@tanstack/react-query"; import { queryStatus, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { type MovieLibraryData, useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useMovieRelated.ts b/packages/web/src/ui/hooks/useMovieRelated.ts index 8fa40ec2..7bde4e6c 100644 --- a/packages/web/src/ui/hooks/useMovieRelated.ts +++ b/packages/web/src/ui/hooks/useMovieRelated.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { SearchHit } from "@data/trakt/search"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { useQuery } from "@tanstack/react-query"; import { BROWSE_STALE_TIME_MS } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useOptimisticWrite.ts b/packages/web/src/ui/hooks/useOptimisticWrite.ts index a7e1b209..9115361e 100644 --- a/packages/web/src/ui/hooks/useOptimisticWrite.ts +++ b/packages/web/src/ui/hooks/useOptimisticWrite.ts @@ -1,4 +1,4 @@ -import type { QueuedOp } from "@domain/write-queue/types"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { trackWrite } from "@ui/hooks/sync-activity-store"; import { type SubmitOutcome, useRuntime } from "@ui/runtime/runtime"; import { useCallback } from "react"; diff --git a/packages/web/src/ui/hooks/useQueuedWrite.ts b/packages/web/src/ui/hooks/useQueuedWrite.ts index 9c747663..690a7268 100644 --- a/packages/web/src/ui/hooks/useQueuedWrite.ts +++ b/packages/web/src/ui/hooks/useQueuedWrite.ts @@ -1,4 +1,4 @@ -import type { QueuedOp } from "@domain/write-queue/types"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; import { useCallback, useState } from "react"; diff --git a/packages/web/src/ui/hooks/useRemovalSnacks.ts b/packages/web/src/ui/hooks/useRemovalSnacks.ts index 9f917e7a..4ed479d6 100644 --- a/packages/web/src/ui/hooks/useRemovalSnacks.ts +++ b/packages/web/src/ui/hooks/useRemovalSnacks.ts @@ -1,4 +1,4 @@ -import type { HistoryEntry } from "@domain/history"; +import type { HistoryEntry } from "@cue/core/domain/history"; import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; import type { HistoryView } from "@ui/hooks/useHistory"; import { useEffect, useRef } from "react"; diff --git a/packages/web/src/ui/hooks/useResumeOnMark.ts b/packages/web/src/ui/hooks/useResumeOnMark.ts index 787d9fba..c81db909 100644 --- a/packages/web/src/ui/hooks/useResumeOnMark.ts +++ b/packages/web/src/ui/hooks/useResumeOnMark.ts @@ -1,5 +1,5 @@ -import type { ShowIds } from "@domain/model/ids"; -import { buildHideShowOp, buildUnhideShowOp } from "@domain/write-queue/ops"; +import type { ShowIds } from "@cue/core/domain/model/ids"; +import { buildHideShowOp, buildUnhideShowOp } from "@cue/core/domain/write-queue/ops"; import { useQueryClient } from "@tanstack/react-query"; import { useTrackedSubmit } from "@ui/hooks/useOptimisticWrite"; import type { SubmitOutcome } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useSearch.ts b/packages/web/src/ui/hooks/useSearch.ts index 74709bc1..6e4b2897 100644 --- a/packages/web/src/ui/hooks/useSearch.ts +++ b/packages/web/src/ui/hooks/useSearch.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { SearchHit } from "@data/trakt/search"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { useQuery } from "@tanstack/react-query"; import { useRuntime } from "@ui/runtime/runtime"; import { useEffect, useState } from "react"; diff --git a/packages/web/src/ui/hooks/useSeasons.ts b/packages/web/src/ui/hooks/useSeasons.ts index ad8477bf..f0c661e2 100644 --- a/packages/web/src/ui/hooks/useSeasons.ts +++ b/packages/web/src/ui/hooks/useSeasons.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { SeasonView } from "@data/trakt/show-detail"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { SeasonView } from "@cue/core/data/trakt/show-detail"; import { useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useShowArt.ts b/packages/web/src/ui/hooks/useShowArt.ts index d31a0466..e297d950 100644 --- a/packages/web/src/ui/hooks/useShowArt.ts +++ b/packages/web/src/ui/hooks/useShowArt.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { ShowInfo } from "@data/trakt/show-detail"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { ShowInfo } from "@cue/core/data/trakt/show-detail"; import { useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useShowDetail.ts b/packages/web/src/ui/hooks/useShowDetail.ts index dbdd5322..6643d230 100644 --- a/packages/web/src/ui/hooks/useShowDetail.ts +++ b/packages/web/src/ui/hooks/useShowDetail.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { ShowHeader } from "@data/trakt/show-detail"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { ShowHeader } from "@cue/core/data/trakt/show-detail"; import { useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; import type { DetailHeaderView } from "@ui/hooks/useDetailHeader"; diff --git a/packages/web/src/ui/hooks/useStats.ts b/packages/web/src/ui/hooks/useStats.ts index f02315b5..3e547e83 100644 --- a/packages/web/src/ui/hooks/useStats.ts +++ b/packages/web/src/ui/hooks/useStats.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { UserStats } from "@data/trakt/schemas"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { UserStats } from "@cue/core/data/trakt/schemas"; import { useQuery } from "@tanstack/react-query"; import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useToggleWatchlist.ts b/packages/web/src/ui/hooks/useToggleWatchlist.ts index e4663015..26fd3914 100644 --- a/packages/web/src/ui/hooks/useToggleWatchlist.ts +++ b/packages/web/src/ui/hooks/useToggleWatchlist.ts @@ -1,6 +1,6 @@ -import { queryKeys } from "@data/query-keys"; -import type { ShowIds } from "@domain/model/ids"; -import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "@domain/write-queue/ops"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { ShowIds } from "@cue/core/domain/model/ids"; +import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "@cue/core/domain/write-queue/ops"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { patchLibraryEntry } from "@ui/hooks/library-cache"; import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; diff --git a/packages/web/src/ui/hooks/useUpNext.ts b/packages/web/src/ui/hooks/useUpNext.ts index 36a0a652..2844ce25 100644 --- a/packages/web/src/ui/hooks/useUpNext.ts +++ b/packages/web/src/ui/hooks/useUpNext.ts @@ -1,5 +1,5 @@ -import type { LibraryEntry } from "@data/trakt/library"; -import { groupUpNext, type UpNextItem } from "@domain/up-next"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import { groupUpNext, type UpNextItem } from "@cue/core/domain/up-next"; import { type QueryStatus, queryStatus } from "@ui/hooks/query-freshness"; import { sortLapsed, sortQueue, stabilizePendingAdvance } from "@ui/hooks/queue-order"; import { useLibrarySnapshot } from "@ui/hooks/useLibrarySnapshot"; diff --git a/packages/web/src/ui/hooks/useUserProfile.ts b/packages/web/src/ui/hooks/useUserProfile.ts index d37fb5f2..8173d815 100644 --- a/packages/web/src/ui/hooks/useUserProfile.ts +++ b/packages/web/src/ui/hooks/useUserProfile.ts @@ -1,5 +1,5 @@ -import { queryKeys } from "@data/query-keys"; -import type { UserProfile } from "@data/trakt/user-profile"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { UserProfile } from "@cue/core/data/trakt/user-profile"; import { useQuery } from "@tanstack/react-query"; import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { useRuntime } from "@ui/runtime/runtime"; diff --git a/packages/web/src/ui/hooks/useWatchlistAdd.ts b/packages/web/src/ui/hooks/useWatchlistAdd.ts index 059c8ac0..90e78764 100644 --- a/packages/web/src/ui/hooks/useWatchlistAdd.ts +++ b/packages/web/src/ui/hooks/useWatchlistAdd.ts @@ -1,6 +1,6 @@ -import { queryKeys } from "@data/query-keys"; -import type { SearchHit } from "@data/trakt/search"; -import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "@domain/write-queue/ops"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { SearchHit } from "@cue/core/data/trakt/search"; +import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "@cue/core/domain/write-queue/ops"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { useQueuedWrite } from "@ui/hooks/useQueuedWrite"; diff --git a/packages/web/src/ui/prefs/threshold.ts b/packages/web/src/ui/prefs/threshold.ts index fa60397a..5f88433b 100644 --- a/packages/web/src/ui/prefs/threshold.ts +++ b/packages/web/src/ui/prefs/threshold.ts @@ -1,4 +1,4 @@ -import { DEFAULT_STALENESS_THRESHOLD_MS } from "@domain/watch-status"; +import { DEFAULT_STALENESS_THRESHOLD_MS } from "@cue/core/domain/watch-status"; import { choicePref } from "./pref-storage"; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/packages/web/src/ui/runtime/haptics.ts b/packages/web/src/ui/runtime/haptics.ts index 16def066..08f3c154 100644 --- a/packages/web/src/ui/runtime/haptics.ts +++ b/packages/web/src/ui/runtime/haptics.ts @@ -1,4 +1,4 @@ -import { type Haptics, SILENT } from "@domain/ports/haptics"; +import { type Haptics, SILENT } from "@cue/core/domain/ports/haptics"; import { createContext, useContext } from "react"; /** The tactile port as `@ui` reaches it, injected from the composition root so diff --git a/packages/web/src/ui/runtime/persist-buster.ts b/packages/web/src/ui/runtime/persist-buster.ts index 5508b956..8934b058 100644 --- a/packages/web/src/ui/runtime/persist-buster.ts +++ b/packages/web/src/ui/runtime/persist-buster.ts @@ -13,5 +13,5 @@ */ export const PERSISTED_CACHE = { buster: "d0c3d97c58b8", - shape: "457277f8c9b7", + shape: "06ab2c43786e", } as const; diff --git a/packages/web/src/ui/runtime/reminders.ts b/packages/web/src/ui/runtime/reminders.ts index b0a7550f..aa4f13a7 100644 --- a/packages/web/src/ui/runtime/reminders.ts +++ b/packages/web/src/ui/runtime/reminders.ts @@ -1,4 +1,4 @@ -import { type Reminders, SILENT } from "@domain/ports/reminders"; +import { type Reminders, SILENT } from "@cue/core/domain/ports/reminders"; import { createContext, useContext } from "react"; /** The notification port as `@ui` reaches it, injected from the composition root diff --git a/packages/web/src/ui/runtime/runtime.ts b/packages/web/src/ui/runtime/runtime.ts index c84bb02a..54903d99 100644 --- a/packages/web/src/ui/runtime/runtime.ts +++ b/packages/web/src/ui/runtime/runtime.ts @@ -1,15 +1,15 @@ -import type { InvalidationKey } from "@data/query-invalidation"; -import type { EpisodeDetail } from "@data/trakt/episode-detail"; -import type { LibraryEntry } from "@data/trakt/library"; -import type { MovieEntry, MovieHeader } from "@data/trakt/movie-library"; -import type { UserStats } from "@data/trakt/schemas"; -import type { SearchHit } from "@data/trakt/search"; -import type { SeasonView, ShowInfo, ShowProgress } from "@data/trakt/show-detail"; -import type { UserProfile } from "@data/trakt/user-profile"; -import type { CalendarEntry } from "@domain/calendar"; -import type { HistoryEntry, HistoryRange } from "@domain/history"; -import type { EpisodePlay, MoviePlay } from "@domain/reversal"; -import type { QueuedOp } from "@domain/write-queue/types"; +import type { InvalidationKey } from "@cue/core/data/query-invalidation"; +import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { MovieEntry, MovieHeader } from "@cue/core/data/trakt/movie-library"; +import type { UserStats } from "@cue/core/data/trakt/schemas"; +import type { SearchHit } from "@cue/core/data/trakt/search"; +import type { SeasonView, ShowInfo, ShowProgress } from "@cue/core/data/trakt/show-detail"; +import type { UserProfile } from "@cue/core/data/trakt/user-profile"; +import type { CalendarEntry } from "@cue/core/domain/calendar"; +import type { HistoryEntry, HistoryRange } from "@cue/core/domain/history"; +import type { EpisodePlay, MoviePlay } from "@cue/core/domain/reversal"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { createContext, useContext } from "react"; /** The read side of the home surface: the assembled active queue. */ diff --git a/packages/web/src/ui/screens/calendar/CalendarRow.tsx b/packages/web/src/ui/screens/calendar/CalendarRow.tsx index 8439b33a..8a51bc24 100644 --- a/packages/web/src/ui/screens/calendar/CalendarRow.tsx +++ b/packages/web/src/ui/screens/calendar/CalendarRow.tsx @@ -1,6 +1,6 @@ -import type { CalendarRow as CalendarRowModel } from "@domain/calendar"; -import { epCode } from "@domain/model/library"; -import { localTimeZone } from "@domain/time"; +import type { CalendarRow as CalendarRowModel } from "@cue/core/domain/calendar"; +import { epCode } from "@cue/core/domain/model/library"; +import { localTimeZone } from "@cue/core/domain/time"; import { Badge } from "@ui/components/Badge"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/calendar/agenda.ts b/packages/web/src/ui/screens/calendar/agenda.ts index d478e54f..17704f6e 100644 --- a/packages/web/src/ui/screens/calendar/agenda.ts +++ b/packages/web/src/ui/screens/calendar/agenda.ts @@ -1,5 +1,5 @@ -import type { CalendarDay, CalendarRow } from "@domain/calendar"; -import { localTimeZone } from "@domain/time"; +import type { CalendarDay, CalendarRow } from "@cue/core/domain/calendar"; +import { localTimeZone } from "@cue/core/domain/time"; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx index 2fd3087c..2c97697d 100644 --- a/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx +++ b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx @@ -1,6 +1,6 @@ -import { resolveStill } from "@data/image-source"; -import type { EpisodeDetail, EpisodeNav } from "@data/trakt/episode-detail"; -import { epCode } from "@domain/model/library"; +import { resolveStill } from "@cue/core/data/image-source"; +import type { EpisodeDetail, EpisodeNav } from "@cue/core/data/trakt/episode-detail"; +import { epCode } from "@cue/core/domain/model/library"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { CheckControl } from "@ui/components/CheckControl"; @@ -9,7 +9,7 @@ import { ContextMenu } from "@ui/components/ContextMenu"; import { CountdownPanel } from "@ui/components/CountdownPanel"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { Sheet } from "@ui/components/Sheet"; -import { middleTruncate } from "@ui/format"; +import { middleTruncate } from "@cue/core/format"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { useEpisode } from "@ui/hooks/useEpisode"; import { useEpisodePlays } from "@ui/hooks/useEpisodePlays"; diff --git a/packages/web/src/ui/screens/episode-detail/sheet-logic.ts b/packages/web/src/ui/screens/episode-detail/sheet-logic.ts index 581e9770..5c4e9fec 100644 --- a/packages/web/src/ui/screens/episode-detail/sheet-logic.ts +++ b/packages/web/src/ui/screens/episode-detail/sheet-logic.ts @@ -1,6 +1,6 @@ -import type { EpisodeDetail } from "@data/trakt/episode-detail"; -import { epCode } from "@domain/model/library"; -import { formatAirDate, formatWatchedDate } from "@ui/format"; +import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; +import { epCode } from "@cue/core/domain/model/library"; +import { formatAirDate, formatWatchedDate } from "@cue/core/format"; import { metaLine } from "@ui/screens/show-detail/detail-logic"; /** The sheet's quiet meta line: `S1 E5 · Aired Jul 1, 2002 · 60 min`, parts diff --git a/packages/web/src/ui/screens/history/History.tsx b/packages/web/src/ui/screens/history/History.tsx index 748e24f3..69253cc3 100644 --- a/packages/web/src/ui/screens/history/History.tsx +++ b/packages/web/src/ui/screens/history/History.tsx @@ -1,5 +1,5 @@ -import type { HistoryEntry } from "@domain/history"; -import { localTimeZone } from "@domain/time"; +import type { HistoryEntry } from "@cue/core/domain/history"; +import { localTimeZone } from "@cue/core/domain/time"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; diff --git a/packages/web/src/ui/screens/history/history-view.ts b/packages/web/src/ui/screens/history/history-view.ts index 82983fbd..d68dfd16 100644 --- a/packages/web/src/ui/screens/history/history-view.ts +++ b/packages/web/src/ui/screens/history/history-view.ts @@ -1,5 +1,5 @@ -import type { HistoryDay, HistoryEntry } from "@domain/history"; -import { epCode } from "@domain/model/library"; +import type { HistoryDay, HistoryEntry } from "@cue/core/domain/history"; +import { epCode } from "@cue/core/domain/model/library"; import type { EpisodeRowLink } from "@ui/components/EpisodeRow"; /** Where a play's row links: the movie page, or the episode sheet it logged. */ diff --git a/packages/web/src/ui/screens/library/Library.tsx b/packages/web/src/ui/screens/library/Library.tsx index 50d5a9a2..0623420d 100644 --- a/packages/web/src/ui/screens/library/Library.tsx +++ b/packages/web/src/ui/screens/library/Library.tsx @@ -1,8 +1,8 @@ -import type { LibraryEntry } from "@data/trakt/library"; -import type { MovieEntry } from "@data/trakt/movie-library"; -import type { LibrarySort } from "@domain/library-buckets"; -import { epCode } from "@domain/model/library"; -import { isAired } from "@domain/time"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; +import type { LibrarySort } from "@cue/core/domain/library-buckets"; +import { epCode } from "@cue/core/domain/model/library"; +import { isAired } from "@cue/core/domain/time"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; diff --git a/packages/web/src/ui/screens/library/MovieTile.tsx b/packages/web/src/ui/screens/library/MovieTile.tsx index 0ce21360..2d72ed4f 100644 --- a/packages/web/src/ui/screens/library/MovieTile.tsx +++ b/packages/web/src/ui/screens/library/MovieTile.tsx @@ -1,4 +1,4 @@ -import type { MovieEntry } from "@data/trakt/movie-library"; +import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; import { Link } from "@tanstack/react-router"; import { Badge } from "@ui/components/Badge"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/library/ShowTile.tsx b/packages/web/src/ui/screens/library/ShowTile.tsx index 4633848b..d2bcf3a0 100644 --- a/packages/web/src/ui/screens/library/ShowTile.tsx +++ b/packages/web/src/ui/screens/library/ShowTile.tsx @@ -1,8 +1,8 @@ -import type { LibraryEntry } from "@data/trakt/library"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import { episodesLeft, watchedPercent } from "@cue/core/format"; import { Link } from "@tanstack/react-router"; import { Badge } from "@ui/components/Badge"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { episodesLeft, watchedPercent } from "@ui/format"; import type { LibraryChipKey } from "@ui/hooks/useLibraryBuckets"; import { useShowArt } from "@ui/hooks/useShowArt"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx index ece642c5..d9f05a0f 100644 --- a/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx +++ b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx @@ -1,4 +1,5 @@ -import type { MovieEntry, MovieHeader } from "@data/trakt/movie-library"; +import type { MovieEntry, MovieHeader } from "@cue/core/data/trakt/movie-library"; +import { formatWatchedDate, middleTruncate, titleCase } from "@cue/core/format"; import { Link, useNavigate } from "@tanstack/react-router"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { Badge } from "@ui/components/Badge"; @@ -7,7 +8,6 @@ import { DetailHeroSkeleton } from "@ui/components/DetailHeroSkeleton"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { formatWatchedDate, middleTruncate, titleCase } from "@ui/format"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { useMovieActions } from "@ui/hooks/useMovieActions"; import { useMovieDetail } from "@ui/hooks/useMovieDetail"; diff --git a/packages/web/src/ui/screens/profile/Profile.tsx b/packages/web/src/ui/screens/profile/Profile.tsx index 397d66c7..b16fd2ec 100644 --- a/packages/web/src/ui/screens/profile/Profile.tsx +++ b/packages/web/src/ui/screens/profile/Profile.tsx @@ -1,6 +1,6 @@ -import type { UserStats } from "@data/trakt/schemas"; -import type { UserProfile } from "@data/trakt/user-profile"; -import { humanizeWatchMinutes } from "@domain/time"; +import type { UserStats } from "@cue/core/data/trakt/schemas"; +import type { UserProfile } from "@cue/core/data/trakt/user-profile"; +import { humanizeWatchMinutes } from "@cue/core/domain/time"; import { Link } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; diff --git a/packages/web/src/ui/screens/profile/stats.ts b/packages/web/src/ui/screens/profile/stats.ts index 486a7d06..1dec5e65 100644 --- a/packages/web/src/ui/screens/profile/stats.ts +++ b/packages/web/src/ui/screens/profile/stats.ts @@ -1,4 +1,4 @@ -import type { UserStats } from "@data/trakt/schemas"; +import type { UserStats } from "@cue/core/data/trakt/schemas"; import type { MediaVisibility } from "@ui/prefs/media-visibility"; interface CountTile { diff --git a/packages/web/src/ui/screens/search/HitTile.tsx b/packages/web/src/ui/screens/search/HitTile.tsx index b51dccbf..54a1d88e 100644 --- a/packages/web/src/ui/screens/search/HitTile.tsx +++ b/packages/web/src/ui/screens/search/HitTile.tsx @@ -1,4 +1,4 @@ -import type { SearchHit } from "@data/trakt/search"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { Link } from "@tanstack/react-router"; import { Badge } from "@ui/components/Badge"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/search/ResultRow.tsx b/packages/web/src/ui/screens/search/ResultRow.tsx index ad2eef6f..9697580f 100644 --- a/packages/web/src/ui/screens/search/ResultRow.tsx +++ b/packages/web/src/ui/screens/search/ResultRow.tsx @@ -1,4 +1,4 @@ -import type { SearchHit } from "@data/trakt/search"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { Badge } from "@ui/components/Badge"; import { EpisodeRow, type EpisodeRowLink } from "@ui/components/EpisodeRow"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/search/Search.tsx b/packages/web/src/ui/screens/search/Search.tsx index 02ecbee5..f4444a3d 100644 --- a/packages/web/src/ui/screens/search/Search.tsx +++ b/packages/web/src/ui/screens/search/Search.tsx @@ -1,4 +1,5 @@ -import type { SearchHit } from "@data/trakt/search"; +import type { SearchHit } from "@cue/core/data/trakt/search"; +import { middleTruncate } from "@cue/core/format"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; @@ -6,7 +7,6 @@ import { PullToRefresh } from "@ui/components/PullToRefresh"; import { SectionHeader } from "@ui/components/SectionHeader"; import { SkeletonRows } from "@ui/components/Skeletons"; import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { middleTruncate } from "@ui/format"; import { useBrowse } from "@ui/hooks/useBrowse"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { useIsOffline } from "@ui/hooks/useIsOffline"; diff --git a/packages/web/src/ui/screens/settings/useSyncStatus.ts b/packages/web/src/ui/screens/settings/useSyncStatus.ts index bb248e58..6632413c 100644 --- a/packages/web/src/ui/screens/settings/useSyncStatus.ts +++ b/packages/web/src/ui/screens/settings/useSyncStatus.ts @@ -1,4 +1,4 @@ -import { queryKeys } from "@data/query-keys"; +import { queryKeys } from "@cue/core/data/query-keys"; import { useQueryClient } from "@tanstack/react-query"; import { useSyncActivity } from "@ui/hooks/sync-activity-store"; import { useSyncNow } from "@ui/hooks/useSyncNow"; diff --git a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx index 420aa79a..770a1ada 100644 --- a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx +++ b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx @@ -1,17 +1,17 @@ -import type { LibraryEntry } from "@data/trakt/library"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; import { type EpisodeView, firstUnwatchedAired, type SeasonView, type ShowHeader, -} from "@data/trakt/show-detail"; -import { epCode } from "@domain/model/library"; -import { isAired } from "@domain/time"; +} from "@cue/core/data/trakt/show-detail"; +import { epCode } from "@cue/core/domain/model/library"; +import { isAired } from "@cue/core/domain/time"; import { Link } from "@tanstack/react-router"; import { CheckControl } from "@ui/components/CheckControl"; import { CountdownPanel } from "@ui/components/CountdownPanel"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { episodesLeft, watchedPercent } from "@ui/format"; +import { episodesLeft, watchedPercent } from "@cue/core/format"; import type { MarkWatched } from "@ui/hooks/useMarkWatched"; import { useQueueCheck } from "@ui/screens/up-next/useQueueCheck"; import type { ReactElement, ReactNode } from "react"; diff --git a/packages/web/src/ui/screens/show-detail/DetailChrome.tsx b/packages/web/src/ui/screens/show-detail/DetailChrome.tsx index 52a70527..7821028a 100644 --- a/packages/web/src/ui/screens/show-detail/DetailChrome.tsx +++ b/packages/web/src/ui/screens/show-detail/DetailChrome.tsx @@ -1,4 +1,4 @@ -import { resolveBackdrop } from "@data/image-source"; +import { resolveBackdrop } from "@cue/core/data/image-source"; import { Link, useCanGoBack, useRouter } from "@tanstack/react-router"; import { artGradient } from "@ui/components/artGradient"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/show-detail/SeasonList.tsx b/packages/web/src/ui/screens/show-detail/SeasonList.tsx index fc976911..a1a06f57 100644 --- a/packages/web/src/ui/screens/show-detail/SeasonList.tsx +++ b/packages/web/src/ui/screens/show-detail/SeasonList.tsx @@ -1,9 +1,9 @@ -import type { EpisodeView, SeasonView } from "@data/trakt/show-detail"; -import { epCode } from "@domain/model/library"; +import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; +import { epCode } from "@cue/core/domain/model/library"; import { CheckControl } from "@ui/components/CheckControl"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { formatAirDate } from "@ui/format"; +import { formatAirDate } from "@cue/core/format"; import { Check, ChevronDown } from "lucide-react"; import { Accordion } from "radix-ui"; import type { ReactElement } from "react"; diff --git a/packages/web/src/ui/screens/show-detail/ShowDetail.tsx b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx index f00a8eba..ce609a1c 100644 --- a/packages/web/src/ui/screens/show-detail/ShowDetail.tsx +++ b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx @@ -1,5 +1,5 @@ -import type { EpisodeView, SeasonView, ShowHeader } from "@data/trakt/show-detail"; -import { epCode } from "@domain/model/library"; +import type { EpisodeView, SeasonView, ShowHeader } from "@cue/core/data/trakt/show-detail"; +import { epCode } from "@cue/core/domain/model/library"; import { Outlet, useRouterState } from "@tanstack/react-router"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { ConfirmSheet } from "@ui/components/ConfirmSheet"; @@ -8,7 +8,7 @@ import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { SkeletonRows } from "@ui/components/Skeletons"; import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { middleTruncate, titleCase } from "@ui/format"; +import { middleTruncate, titleCase } from "@cue/core/format"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { useHideShow } from "@ui/hooks/useHideShow"; import { useLibraryEntry } from "@ui/hooks/useLibrarySnapshot"; diff --git a/packages/web/src/ui/screens/show-detail/detail-logic.ts b/packages/web/src/ui/screens/show-detail/detail-logic.ts index 078ce768..fb9fd3bd 100644 --- a/packages/web/src/ui/screens/show-detail/detail-logic.ts +++ b/packages/web/src/ui/screens/show-detail/detail-logic.ts @@ -1,5 +1,5 @@ -import type { SeasonView } from "@data/trakt/show-detail"; -import type { MovieIds, ShowIds } from "@domain/model/ids"; +import type { SeasonView } from "@cue/core/data/trakt/show-detail"; +import type { MovieIds, ShowIds } from "@cue/core/domain/model/ids"; import type { EpisodeBound } from "@ui/hooks/useMarkSeason"; /** Presentation + planning helpers for the detail surfaces, kept pure for tests. */ diff --git a/packages/web/src/ui/screens/up-next/OnTheWay.tsx b/packages/web/src/ui/screens/up-next/OnTheWay.tsx index 83c3ec43..150e9f05 100644 --- a/packages/web/src/ui/screens/up-next/OnTheWay.tsx +++ b/packages/web/src/ui/screens/up-next/OnTheWay.tsx @@ -1,7 +1,7 @@ -import type { CalendarDay, CalendarRow } from "@domain/calendar"; -import { dayKeyOf } from "@domain/day"; -import { epCode } from "@domain/model/library"; -import { localTimeZone } from "@domain/time"; +import type { CalendarDay, CalendarRow } from "@cue/core/domain/calendar"; +import { dayKeyOf } from "@cue/core/domain/day"; +import { epCode } from "@cue/core/domain/model/library"; +import { localTimeZone } from "@cue/core/domain/time"; import { Badge } from "@ui/components/Badge"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { SectionHeader } from "@ui/components/SectionHeader"; diff --git a/packages/web/src/ui/screens/up-next/Poster.tsx b/packages/web/src/ui/screens/up-next/Poster.tsx index 9ce3d5a1..5d8d52d5 100644 --- a/packages/web/src/ui/screens/up-next/Poster.tsx +++ b/packages/web/src/ui/screens/up-next/Poster.tsx @@ -1,4 +1,4 @@ -import { resolvePoster } from "@data/image-source"; +import { resolvePoster } from "@cue/core/data/image-source"; import { ArtPlaceholder } from "@ui/components/ArtPlaceholder"; import { artGradient } from "@ui/components/artGradient"; import { type ReactElement, useState } from "react"; diff --git a/packages/web/src/ui/screens/up-next/Previously.tsx b/packages/web/src/ui/screens/up-next/Previously.tsx index c30c7a2f..a3e616fb 100644 --- a/packages/web/src/ui/screens/up-next/Previously.tsx +++ b/packages/web/src/ui/screens/up-next/Previously.tsx @@ -1,5 +1,5 @@ -import type { HistoryEntry } from "@domain/history"; -import { localTimeZone } from "@domain/time"; +import type { HistoryEntry } from "@cue/core/domain/history"; +import { localTimeZone } from "@cue/core/domain/time"; import { CheckControl } from "@ui/components/CheckControl"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { SectionHeader } from "@ui/components/SectionHeader"; diff --git a/packages/web/src/ui/screens/up-next/QueueRow.tsx b/packages/web/src/ui/screens/up-next/QueueRow.tsx index 81409e64..ea1a4a1d 100644 --- a/packages/web/src/ui/screens/up-next/QueueRow.tsx +++ b/packages/web/src/ui/screens/up-next/QueueRow.tsx @@ -1,9 +1,9 @@ -import { epCode } from "@domain/model/library"; +import { epCode } from "@cue/core/domain/model/library"; import { CheckControl } from "@ui/components/CheckControl"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { ProgressBar } from "@ui/components/ProgressBar"; import { SwipeAction } from "@ui/components/SwipeAction"; -import { episodesLeft, lastWatchedPhrase, watchedPercent } from "@ui/format"; +import { episodesLeft, lastWatchedPhrase, watchedPercent } from "@cue/core/format"; import type { MarkWatched } from "@ui/hooks/useMarkWatched"; import { useShowArt } from "@ui/hooks/useShowArt"; import type { UpNextCard } from "@ui/hooks/useUpNext"; diff --git a/packages/web/src/ui/screens/up-next/useQueueCheck.ts b/packages/web/src/ui/screens/up-next/useQueueCheck.ts index 3d99e387..3d705424 100644 --- a/packages/web/src/ui/screens/up-next/useQueueCheck.ts +++ b/packages/web/src/ui/screens/up-next/useQueueCheck.ts @@ -1,5 +1,5 @@ -import type { LibraryEntry } from "@data/trakt/library"; -import { epCode } from "@domain/model/library"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import { epCode } from "@cue/core/domain/model/library"; import type { CheckState } from "@ui/components/CheckControl"; import { reArmDelay } from "@ui/hooks/mark-undo-window"; import type { MarkWatched } from "@ui/hooks/useMarkWatched"; diff --git a/packages/web/test/data/auth-oauth.test.ts b/packages/web/test/data/auth-oauth.test.ts index b248cbb0..a69bba1d 100644 --- a/packages/web/test/data/auth-oauth.test.ts +++ b/packages/web/test/data/auth-oauth.test.ts @@ -7,8 +7,8 @@ import { requestDeviceCode, revokeToken, TRAKT_API_BASE, -} from "@data/auth/oauth"; -import { TokenRefresher } from "@domain/auth/token"; +} from "@cue/core/data/auth/oauth"; +import { TokenRefresher } from "@cue/core/domain/auth/token"; import { delay, HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/auth-pkce.test.ts b/packages/web/test/data/auth-pkce.test.ts index 296c27c3..132087f1 100644 --- a/packages/web/test/data/auth-pkce.test.ts +++ b/packages/web/test/data/auth-pkce.test.ts @@ -1,4 +1,4 @@ -import { createCodeVerifier, createPkcePair, deriveCodeChallenge } from "@data/auth/pkce"; +import { createCodeVerifier, createPkcePair, deriveCodeChallenge } from "@cue/core/data/auth/pkce"; import { describe, expect, it } from "vitest"; const BASE64URL = /^[A-Za-z0-9_-]+$/; diff --git a/packages/web/test/data/authorized-fetch.test.ts b/packages/web/test/data/authorized-fetch.test.ts index 62f70af4..ad4915e9 100644 --- a/packages/web/test/data/authorized-fetch.test.ts +++ b/packages/web/test/data/authorized-fetch.test.ts @@ -1,11 +1,11 @@ -import { type OAuthConfig, TRAKT_API_BASE } from "@data/auth/oauth"; +import { type OAuthConfig, TRAKT_API_BASE } from "@cue/core/data/auth/oauth"; import { type AuthorizedFetchDeps, createAuthorizedFetch, UnauthorizedWriteError, -} from "@data/trakt/authorized-fetch"; -import type { FetchLike } from "@data/trakt/client"; -import type { Token } from "@domain/model/token"; +} from "@cue/core/data/trakt/authorized-fetch"; +import type { FetchLike } from "@cue/core/data/trakt/client"; +import type { Token } from "@cue/core/domain/model/token"; import { delay, HttpResponse, http } from "msw"; import { describe, expect, it, type Mock, vi } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/image-source.test.ts b/packages/web/test/data/image-source.test.ts index 15a0cde8..17f79176 100644 --- a/packages/web/test/data/image-source.test.ts +++ b/packages/web/test/data/image-source.test.ts @@ -1,4 +1,4 @@ -import { resolvePoster } from "@data/image-source"; +import { resolvePoster } from "@cue/core/data/image-source"; import { describe, expect, it } from "vitest"; describe("resolvePoster (Trakt inline → placeholder)", () => { diff --git a/packages/web/test/data/query-invalidation.test.ts b/packages/web/test/data/query-invalidation.test.ts index 9f58867d..63926831 100644 --- a/packages/web/test/data/query-invalidation.test.ts +++ b/packages/web/test/data/query-invalidation.test.ts @@ -1,5 +1,5 @@ -import { invalidationKeys, showProgressKeys } from "@data/query-invalidation"; -import { queryKeys } from "@data/query-keys"; +import { invalidationKeys, showProgressKeys } from "@cue/core/data/query-invalidation"; +import { queryKeys } from "@cue/core/data/query-keys"; import { describe, expect, it } from "vitest"; describe("showProgressKeys: the keys a local mark on show X must refresh", () => { diff --git a/packages/web/test/data/query-keys.test.ts b/packages/web/test/data/query-keys.test.ts index 836b8eb3..df419be2 100644 --- a/packages/web/test/data/query-keys.test.ts +++ b/packages/web/test/data/query-keys.test.ts @@ -1,4 +1,4 @@ -import { queryKeys } from "@data/query-keys"; +import { queryKeys } from "@cue/core/data/query-keys"; import { describe, expect, it } from "vitest"; describe("queryKeys factory", () => { diff --git a/packages/web/test/data/read-budget.test.ts b/packages/web/test/data/read-budget.test.ts index 32c4e4df..66966051 100644 --- a/packages/web/test/data/read-budget.test.ts +++ b/packages/web/test/data/read-budget.test.ts @@ -1,12 +1,12 @@ -import { TRAKT_API_BASE, TraktClient, type TraktResult } from "@data/trakt/client"; +import { TRAKT_API_BASE, TraktClient, type TraktResult } from "@cue/core/data/trakt/client"; import { loadUpNextEntries, READ_CONCURRENCY, WATCHED_PROGRESS_BUDGET, withReadRateRetry, -} from "@data/trakt/read-budget"; -import type { KeyValueStore } from "@platform/kv"; -import type { TokenStore } from "@platform/token-store"; +} from "@cue/core/data/trakt/read-budget"; +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { TokenStore } from "@cue/core/ports/token-store"; import { delay, HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/trakt-calendar.test.ts b/packages/web/test/data/trakt-calendar.test.ts index 64b66421..a46d4265 100644 --- a/packages/web/test/data/trakt-calendar.test.ts +++ b/packages/web/test/data/trakt-calendar.test.ts @@ -1,5 +1,5 @@ -import { assembleCalendarEntries } from "@data/trakt/calendar"; -import type { CalendarItem } from "@data/trakt/schemas"; +import { assembleCalendarEntries } from "@cue/core/data/trakt/calendar"; +import type { CalendarItem } from "@cue/core/data/trakt/schemas"; import { describe, expect, it } from "vitest"; function item(overrides: Partial = {}): CalendarItem { diff --git a/packages/web/test/data/trakt-client.test.ts b/packages/web/test/data/trakt-client.test.ts index 42fa5d54..3c422470 100644 --- a/packages/web/test/data/trakt-client.test.ts +++ b/packages/web/test/data/trakt-client.test.ts @@ -1,4 +1,4 @@ -import { TRAKT_API_BASE, TraktClient } from "@data/trakt/client"; +import { TRAKT_API_BASE, TraktClient } from "@cue/core/data/trakt/client"; import { HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/trakt-endpoints.test.ts b/packages/web/test/data/trakt-endpoints.test.ts index 784c7995..75c37e30 100644 --- a/packages/web/test/data/trakt-endpoints.test.ts +++ b/packages/web/test/data/trakt-endpoints.test.ts @@ -1,4 +1,4 @@ -import { TRAKT_API_BASE, TraktClient } from "@data/trakt/client"; +import { TRAKT_API_BASE, TraktClient } from "@cue/core/data/trakt/client"; import { getEpisode, getHidden, @@ -22,7 +22,7 @@ import { getWatchlist, itemsBody, searchTrakt, -} from "@data/trakt/endpoints"; +} from "@cue/core/data/trakt/endpoints"; import { HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/trakt-episode-detail.test.ts b/packages/web/test/data/trakt-episode-detail.test.ts index 4a5caf9d..c8b89799 100644 --- a/packages/web/test/data/trakt-episode-detail.test.ts +++ b/packages/web/test/data/trakt-episode-detail.test.ts @@ -1,5 +1,5 @@ -import { assembleEpisodeDetail } from "@data/trakt/episode-detail"; -import type { EpisodeData, Progress } from "@data/trakt/schemas"; +import { assembleEpisodeDetail } from "@cue/core/data/trakt/episode-detail"; +import type { EpisodeData, Progress } from "@cue/core/data/trakt/schemas"; import { describe, expect, it } from "vitest"; const NOW = Date.UTC(2026, 6, 5); diff --git a/packages/web/test/data/trakt-history.test.ts b/packages/web/test/data/trakt-history.test.ts index be549acb..a159200e 100644 --- a/packages/web/test/data/trakt-history.test.ts +++ b/packages/web/test/data/trakt-history.test.ts @@ -2,8 +2,8 @@ import { assembleEpisodePlays, assembleHistoryEntries, assembleMoviePlays, -} from "@data/trakt/history"; -import type { HistoryItem } from "@data/trakt/schemas"; +} from "@cue/core/data/trakt/history"; +import type { HistoryItem } from "@cue/core/data/trakt/schemas"; import { describe, expect, it } from "vitest"; const WATCHED_AT = "2026-07-05T21:24:00.000Z"; diff --git a/packages/web/test/data/trakt-library.test.ts b/packages/web/test/data/trakt-library.test.ts index aa0657aa..972391f3 100644 --- a/packages/web/test/data/trakt-library.test.ts +++ b/packages/web/test/data/trakt-library.test.ts @@ -7,9 +7,9 @@ import { markLanded, showIdSet, watchedEpisodeCount, -} from "@data/trakt/library"; -import type { Progress, WatchedShow, WatchlistItem } from "@data/trakt/schemas"; -import type { EpisodePlay } from "@domain/reversal"; +} from "@cue/core/data/trakt/library"; +import type { Progress, WatchedShow, WatchlistItem } from "@cue/core/data/trakt/schemas"; +import type { EpisodePlay } from "@cue/core/domain/reversal"; import { describe, expect, it } from "vitest"; function watchlistItem(overrides: { diff --git a/packages/web/test/data/trakt-movie-library.test.ts b/packages/web/test/data/trakt-movie-library.test.ts index 03f7f367..0d76970e 100644 --- a/packages/web/test/data/trakt-movie-library.test.ts +++ b/packages/web/test/data/trakt-movie-library.test.ts @@ -2,8 +2,8 @@ import { assembleMovieHeader, assembleMovieLibrary, type MovieLibraryInput, -} from "@data/trakt/movie-library"; -import type { MovieDetailData, WatchedMovie, WatchlistItem } from "@data/trakt/schemas"; +} from "@cue/core/data/trakt/movie-library"; +import type { MovieDetailData, WatchedMovie, WatchlistItem } from "@cue/core/data/trakt/schemas"; import { describe, expect, it } from "vitest"; function watchedMovie(overrides: { diff --git a/packages/web/test/data/trakt-pooled-endpoints.test.ts b/packages/web/test/data/trakt-pooled-endpoints.test.ts index 8668ab7e..1fa14a18 100644 --- a/packages/web/test/data/trakt-pooled-endpoints.test.ts +++ b/packages/web/test/data/trakt-pooled-endpoints.test.ts @@ -1,5 +1,5 @@ -import * as raw from "@data/trakt/endpoints"; -import * as pooled from "@data/trakt/pooled-endpoints"; +import * as raw from "@cue/core/data/trakt/endpoints"; +import * as pooled from "@cue/core/data/trakt/pooled-endpoints"; import { describe, expect, it } from "vitest"; /** diff --git a/packages/web/test/data/trakt-repositories.test.ts b/packages/web/test/data/trakt-repositories.test.ts index 76061708..57d75cbf 100644 --- a/packages/web/test/data/trakt-repositories.test.ts +++ b/packages/web/test/data/trakt-repositories.test.ts @@ -1,6 +1,6 @@ -import { TRAKT_API_BASE, TraktClient } from "@data/trakt/client"; -import { createLastActivitiesRepository } from "@data/trakt/repositories"; -import type { LastActivities } from "@domain/sync-activities"; +import { TRAKT_API_BASE, TraktClient } from "@cue/core/data/trakt/client"; +import { createLastActivitiesRepository } from "@cue/core/data/trakt/repositories"; +import type { LastActivities } from "@cue/core/domain/sync-activities"; import { HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/trakt-search.test.ts b/packages/web/test/data/trakt-search.test.ts index a98e70fd..3a4f66f2 100644 --- a/packages/web/test/data/trakt-search.test.ts +++ b/packages/web/test/data/trakt-search.test.ts @@ -1,10 +1,10 @@ -import type { MovieSummary, SearchResult, ShowSummary } from "@data/trakt/schemas"; +import type { MovieSummary, SearchResult, ShowSummary } from "@cue/core/data/trakt/schemas"; import { assembleMovieHits, assembleSearchHits, assembleShowHits, rankSearchHits, -} from "@data/trakt/search"; +} from "@cue/core/data/trakt/search"; import { describe, expect, it } from "vitest"; describe("assembleSearchHits", () => { diff --git a/packages/web/test/data/trakt-show-detail.test.ts b/packages/web/test/data/trakt-show-detail.test.ts index fb09548b..942e04b4 100644 --- a/packages/web/test/data/trakt-show-detail.test.ts +++ b/packages/web/test/data/trakt-show-detail.test.ts @@ -1,4 +1,4 @@ -import type { Progress, SeasonData, ShowDetailData } from "@data/trakt/schemas"; +import type { Progress, SeasonData, ShowDetailData } from "@cue/core/data/trakt/schemas"; import { assembleSeasons, assembleShowInfo, @@ -6,7 +6,7 @@ import { type EpisodeView, firstUnwatchedAired, type SeasonView, -} from "@data/trakt/show-detail"; +} from "@cue/core/data/trakt/show-detail"; import { describe, expect, it } from "vitest"; const NOW = Date.UTC(2026, 6, 5); diff --git a/packages/web/test/data/trakt-transport.test.ts b/packages/web/test/data/trakt-transport.test.ts index 015622df..011a5278 100644 --- a/packages/web/test/data/trakt-transport.test.ts +++ b/packages/web/test/data/trakt-transport.test.ts @@ -1,7 +1,7 @@ -import { TRAKT_API_BASE, TraktClient } from "@data/trakt/client"; -import { createTraktTransport } from "@data/trakt/transport"; -import { buildMarkEpisodeOp } from "@domain/write-queue/ops"; -import { WriteQueue } from "@domain/write-queue/queue"; +import { TRAKT_API_BASE, TraktClient } from "@cue/core/data/trakt/client"; +import { createTraktTransport } from "@cue/core/data/trakt/transport"; +import { buildMarkEpisodeOp } from "@cue/core/domain/write-queue/ops"; +import { WriteQueue } from "@cue/core/domain/write-queue/queue"; import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { mswServer } from "./_msw"; diff --git a/packages/web/test/data/trakt-user-profile.test.ts b/packages/web/test/data/trakt-user-profile.test.ts index e0d930a6..879c1bd1 100644 --- a/packages/web/test/data/trakt-user-profile.test.ts +++ b/packages/web/test/data/trakt-user-profile.test.ts @@ -1,4 +1,4 @@ -import { assembleUserProfile } from "@data/trakt/user-profile"; +import { assembleUserProfile } from "@cue/core/data/trakt/user-profile"; import { describe, expect, it } from "vitest"; describe("assembleUserProfile", () => { diff --git a/packages/web/test/domain/_helpers.ts b/packages/web/test/domain/_helpers.ts index fb2fd7c1..afb08e1b 100644 --- a/packages/web/test/domain/_helpers.ts +++ b/packages/web/test/domain/_helpers.ts @@ -1,5 +1,5 @@ -import type { EpisodeRef, LibraryShow } from "@domain/model/library"; -import type { DispatchResult } from "@domain/write-queue/types"; +import type { EpisodeRef, LibraryShow } from "@cue/core/domain/model/library"; +import type { DispatchResult } from "@cue/core/domain/write-queue/types"; export const DAY = 24 * 60 * 60 * 1000; /** Fixed "now" so aired/unaired fixtures are deterministic: 2026-07-05 UTC. */ diff --git a/packages/web/test/domain/auth-token.test.ts b/packages/web/test/domain/auth-token.test.ts index b27a85f0..b9b92a92 100644 --- a/packages/web/test/domain/auth-token.test.ts +++ b/packages/web/test/domain/auth-token.test.ts @@ -1,5 +1,5 @@ -import { isTokenExpired, shouldRefresh, TokenRefresher } from "@domain/auth/token"; -import type { Token } from "@domain/model/token"; +import { isTokenExpired, shouldRefresh, TokenRefresher } from "@cue/core/domain/auth/token"; +import type { Token } from "@cue/core/domain/model/token"; import { describe, expect, it, vi } from "vitest"; const CREATED_AT = 1_700_000_000; // unix seconds diff --git a/packages/web/test/domain/calendar.test.ts b/packages/web/test/domain/calendar.test.ts index 99bad4c3..c0cdc1f3 100644 --- a/packages/web/test/domain/calendar.test.ts +++ b/packages/web/test/domain/calendar.test.ts @@ -1,4 +1,4 @@ -import { type CalendarEntry, groupCalendar } from "@domain/calendar"; +import { type CalendarEntry, groupCalendar } from "@cue/core/domain/calendar"; import { describe, expect, it } from "vitest"; /** Fixed instant: 2026-07-05T16:00Z = 12:00 in America/New_York (EDT, UTC-4). */ diff --git a/packages/web/test/domain/history.test.ts b/packages/web/test/domain/history.test.ts index dd4459a8..cd09e911 100644 --- a/packages/web/test/domain/history.test.ts +++ b/packages/web/test/domain/history.test.ts @@ -1,4 +1,9 @@ -import { groupHistory, type HistoryEntry, historyRange, historyScopeKey } from "@domain/history"; +import { + groupHistory, + type HistoryEntry, + historyRange, + historyScopeKey, +} from "@cue/core/domain/history"; import { describe, expect, it } from "vitest"; /** Fixed instant: 2026-07-05T16:00Z = 12:00 in America/New_York (EDT, UTC-4). */ diff --git a/packages/web/test/domain/library-buckets.test.ts b/packages/web/test/domain/library-buckets.test.ts index 2fa4a9f0..7fdc57dd 100644 --- a/packages/web/test/domain/library-buckets.test.ts +++ b/packages/web/test/domain/library-buckets.test.ts @@ -1,5 +1,5 @@ -import { groupLibrary } from "@domain/library-buckets"; -import type { WatchStatus } from "@domain/watch-status"; +import { groupLibrary } from "@cue/core/domain/library-buckets"; +import type { WatchStatus } from "@cue/core/domain/watch-status"; import { describe, expect, it } from "vitest"; import { airedNext, DAY, futureNext, iso, makeShow, NOW, THRESHOLD } from "./_helpers"; diff --git a/packages/web/test/domain/recently-aired.test.ts b/packages/web/test/domain/recently-aired.test.ts index 632660a6..76500985 100644 --- a/packages/web/test/domain/recently-aired.test.ts +++ b/packages/web/test/domain/recently-aired.test.ts @@ -1,5 +1,5 @@ -import type { CalendarEntry } from "@domain/calendar"; -import { needsNextEpisode, reconcileRecentlyAired } from "@domain/recently-aired"; +import type { CalendarEntry } from "@cue/core/domain/calendar"; +import { needsNextEpisode, reconcileRecentlyAired } from "@cue/core/domain/recently-aired"; import { describe, expect, it } from "vitest"; import { DAY, iso, makeEpisode, makeShow, NOW } from "./_helpers"; diff --git a/packages/web/test/domain/reminders.test.ts b/packages/web/test/domain/reminders.test.ts index 069cc622..6ecf0b50 100644 --- a/packages/web/test/domain/reminders.test.ts +++ b/packages/web/test/domain/reminders.test.ts @@ -1,11 +1,11 @@ -import type { CalendarDay, CalendarRow } from "@domain/calendar"; +import type { CalendarDay, CalendarRow } from "@cue/core/domain/calendar"; import { diffReminders, type PlannedReminder, planReminders, REMINDER_HOUR, REMINDER_WINDOW_DAYS, -} from "@domain/reminders"; +} from "@cue/core/domain/reminders"; import { afterEach, describe, expect, it, vi } from "vitest"; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/packages/web/test/domain/reversal.test.ts b/packages/web/test/domain/reversal.test.ts index 6ecd75a5..6aee1af0 100644 --- a/packages/web/test/domain/reversal.test.ts +++ b/packages/web/test/domain/reversal.test.ts @@ -1,4 +1,4 @@ -import { type EpisodePlay, planEpisodeUnmark, planSeasonUnmark } from "@domain/reversal"; +import { type EpisodePlay, planEpisodeUnmark, planSeasonUnmark } from "@cue/core/domain/reversal"; import { describe, expect, it } from "vitest"; const WATCHED_AT = "2026-06-01T00:00:00.000Z"; diff --git a/packages/web/test/domain/sync-activities.test.ts b/packages/web/test/domain/sync-activities.test.ts index b743879b..f03f2e3b 100644 --- a/packages/web/test/domain/sync-activities.test.ts +++ b/packages/web/test/domain/sync-activities.test.ts @@ -2,7 +2,7 @@ import { diffActivities, type InvalidationTarget, type LastActivities, -} from "@domain/sync-activities"; +} from "@cue/core/domain/sync-activities"; import { describe, expect, it } from "vitest"; const T0 = "2026-07-01T00:00:00.000Z"; diff --git a/packages/web/test/domain/time.test.ts b/packages/web/test/domain/time.test.ts index 3e6fe943..387f6b26 100644 --- a/packages/web/test/domain/time.test.ts +++ b/packages/web/test/domain/time.test.ts @@ -1,4 +1,4 @@ -import { humanizeWatchMinutes, isAired, toMs } from "@domain/time"; +import { humanizeWatchMinutes, isAired, toMs } from "@cue/core/domain/time"; import { describe, expect, it } from "vitest"; const T = Date.parse("2026-07-05T00:00:00.000Z"); diff --git a/packages/web/test/domain/up-next.test.ts b/packages/web/test/domain/up-next.test.ts index 9b62b65d..96166649 100644 --- a/packages/web/test/domain/up-next.test.ts +++ b/packages/web/test/domain/up-next.test.ts @@ -1,4 +1,4 @@ -import { groupUpNext } from "@domain/up-next"; +import { groupUpNext } from "@cue/core/domain/up-next"; import { describe, expect, it } from "vitest"; import { DAY, iso, makeEpisode, makeShow, NOW, THRESHOLD } from "./_helpers"; diff --git a/packages/web/test/domain/watch-status.test.ts b/packages/web/test/domain/watch-status.test.ts index 2c20310e..fa286da8 100644 --- a/packages/web/test/domain/watch-status.test.ts +++ b/packages/web/test/domain/watch-status.test.ts @@ -1,4 +1,8 @@ -import { computeWatchStatus, isTerminalStatus, type WatchStatus } from "@domain/watch-status"; +import { + computeWatchStatus, + isTerminalStatus, + type WatchStatus, +} from "@cue/core/domain/watch-status"; import { describe, expect, it } from "vitest"; import { airedNext, DAY, futureNext, iso, makeShow, NOW, THRESHOLD } from "./_helpers"; diff --git a/packages/web/test/domain/write-queue-bulk.test.ts b/packages/web/test/domain/write-queue-bulk.test.ts index 2f6a32e7..d586799e 100644 --- a/packages/web/test/domain/write-queue-bulk.test.ts +++ b/packages/web/test/domain/write-queue-bulk.test.ts @@ -4,8 +4,8 @@ import { type EpisodeAir, MAX_EPISODES_PER_CHUNK, type SeasonTree, -} from "@domain/write-queue/bulk"; -import type { QueuedOp } from "@domain/write-queue/types"; +} from "@cue/core/domain/write-queue/bulk"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { describe, expect, it } from "vitest"; import { DAY, iso, NOW } from "./_helpers"; diff --git a/packages/web/test/domain/write-queue-classify.test.ts b/packages/web/test/domain/write-queue-classify.test.ts index 57e363cd..038bf78e 100644 --- a/packages/web/test/domain/write-queue-classify.test.ts +++ b/packages/web/test/domain/write-queue-classify.test.ts @@ -4,7 +4,7 @@ import { computePacingDelay, MIN_WRITE_INTERVAL_MS, parseRetryAfterMs, -} from "@domain/write-queue/classify"; +} from "@cue/core/domain/write-queue/classify"; import { describe, expect, it } from "vitest"; import { dispatchResult } from "./_helpers"; diff --git a/packages/web/test/domain/write-queue-coalesce.test.ts b/packages/web/test/domain/write-queue-coalesce.test.ts index 51267eba..d930e58f 100644 --- a/packages/web/test/domain/write-queue-coalesce.test.ts +++ b/packages/web/test/domain/write-queue-coalesce.test.ts @@ -1,10 +1,10 @@ -import { coalesce } from "@domain/write-queue/coalesce"; +import { coalesce } from "@cue/core/domain/write-queue/coalesce"; import { buildAddEpisodePlayOp, buildMarkEpisodeOp, buildUnmarkEpisodeOp, -} from "@domain/write-queue/ops"; -import type { QueuedOp } from "@domain/write-queue/types"; +} from "@cue/core/domain/write-queue/ops"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { describe, expect, it } from "vitest"; const WATCHED_AT = "2026-07-05T12:00:00.000Z"; diff --git a/packages/web/test/domain/write-queue-ops.test.ts b/packages/web/test/domain/write-queue-ops.test.ts index d5956d76..d42daf72 100644 --- a/packages/web/test/domain/write-queue-ops.test.ts +++ b/packages/web/test/domain/write-queue-ops.test.ts @@ -11,7 +11,7 @@ import { buildUnmarkEpisodeOp, buildUnmarkMovieOp, episodeItemKey, -} from "@domain/write-queue/ops"; +} from "@cue/core/domain/write-queue/ops"; import { describe, expect, it } from "vitest"; const WATCHED_AT = "2026-07-05T12:00:00.000Z"; diff --git a/packages/web/test/domain/write-queue-queue.test.ts b/packages/web/test/domain/write-queue-queue.test.ts index 2034761b..471f141d 100644 --- a/packages/web/test/domain/write-queue-queue.test.ts +++ b/packages/web/test/domain/write-queue-queue.test.ts @@ -2,9 +2,9 @@ import { buildAddEpisodePlayOp, buildMarkEpisodeOp, buildUnmarkEpisodeOp, -} from "@domain/write-queue/ops"; -import { WriteQueue, type WriteQueueDeps } from "@domain/write-queue/queue"; -import type { DispatchResult, QueuedOp } from "@domain/write-queue/types"; +} from "@cue/core/domain/write-queue/ops"; +import { WriteQueue, type WriteQueueDeps } from "@cue/core/domain/write-queue/queue"; +import type { DispatchResult, QueuedOp } from "@cue/core/domain/write-queue/types"; import { describe, expect, it, vi } from "vitest"; import { dispatchResult, fakeClock } from "./_helpers"; diff --git a/packages/web/test/harness/mock-trakt.test.ts b/packages/web/test/harness/mock-trakt.test.ts index 39b583a8..4f1ebcc6 100644 --- a/packages/web/test/harness/mock-trakt.test.ts +++ b/packages/web/test/harness/mock-trakt.test.ts @@ -5,9 +5,9 @@ import { refreshAccessToken, requestDeviceCode, revokeToken, -} from "@data/auth/oauth"; -import { resolvePoster } from "@data/image-source"; -import { TraktClient, type TraktResult } from "@data/trakt/client"; +} from "@cue/core/data/auth/oauth"; +import { resolvePoster } from "@cue/core/data/image-source"; +import { TraktClient, type TraktResult } from "@cue/core/data/trakt/client"; import { getEpisode, getHidden, @@ -28,10 +28,10 @@ import { getWatchedMovies, getWatchedShows, getWatchlist, -} from "@data/trakt/endpoints"; -import { loadUpNextEntries } from "@data/trakt/read-budget"; -import { groupUpNext } from "@domain/up-next"; -import { DEFAULT_STALENESS_THRESHOLD_MS } from "@domain/watch-status"; +} from "@cue/core/data/trakt/endpoints"; +import { loadUpNextEntries } from "@cue/core/data/trakt/read-budget"; +import { groupUpNext } from "@cue/core/domain/up-next"; +import { DEFAULT_STALENESS_THRESHOLD_MS } from "@cue/core/domain/watch-status"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createMockTrakt } from "../../../../scripts/mock-trakt/server.mjs"; diff --git a/packages/web/test/platform/haptics.test.ts b/packages/web/test/platform/haptics.test.ts index 9b356e73..b760c976 100644 --- a/packages/web/test/platform/haptics.test.ts +++ b/packages/web/test/platform/haptics.test.ts @@ -1,4 +1,4 @@ -import type { Haptics } from "@domain/ports/haptics"; +import type { Haptics } from "@cue/core/domain/ports/haptics"; import { createNativeHaptics } from "@platform/haptics"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/platform/json-store.test.ts b/packages/web/test/platform/json-store.test.ts index af99fa00..8ba1d0df 100644 --- a/packages/web/test/platform/json-store.test.ts +++ b/packages/web/test/platform/json-store.test.ts @@ -1,7 +1,7 @@ -import type { Token } from "@domain/model/token"; -import { createJsonStore } from "@platform/json-store"; +import type { Token } from "@cue/core/domain/model/token"; +import { createJsonStore } from "@cue/core/ports/json-store"; +import { createTokenStore } from "@cue/core/ports/token-store"; import { createKeyValueStore } from "@platform/kv"; -import { createTokenStore } from "@platform/token-store"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; diff --git a/packages/web/test/platform/reminders.test.ts b/packages/web/test/platform/reminders.test.ts index ee426635..a6149104 100644 --- a/packages/web/test/platform/reminders.test.ts +++ b/packages/web/test/platform/reminders.test.ts @@ -1,4 +1,4 @@ -import type { PlannedReminder } from "@domain/reminders"; +import type { PlannedReminder } from "@cue/core/domain/reminders"; import { createNativeReminders } from "@platform/reminders"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/privacy-claims.test.ts b/packages/web/test/privacy-claims.test.ts index 4cab9326..2e4818ca 100644 --- a/packages/web/test/privacy-claims.test.ts +++ b/packages/web/test/privacy-claims.test.ts @@ -1,6 +1,6 @@ -import { TRAKT_API_BASE } from "@data/trakt/client"; -import type { Token } from "@domain/model/token"; -import { REMINDER_WINDOW_DAYS } from "@domain/reminders"; +import { TRAKT_API_BASE } from "@cue/core/data/trakt/client"; +import type { Token } from "@cue/core/domain/model/token"; +import { REMINDER_WINDOW_DAYS } from "@cue/core/domain/reminders"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; @@ -302,7 +302,7 @@ describe("privacy copy agreement and storage anchors", () => { }; // Short-circuit the device-code round trip: only the persist step, not // Trakt's network protocol, is what this test is anchoring. - vi.doMock("@data/auth/oauth", () => ({ + vi.doMock("@cue/core/data/auth/oauth", () => ({ requestDeviceCode: vi.fn(async () => ({ userCode: "ABCD1234", verificationUrl: "https://trakt.tv/activate", @@ -314,7 +314,7 @@ describe("privacy copy agreement and storage anchors", () => { exchangeCodeForToken: vi.fn(), revokeToken: vi.fn(), })); - vi.doMock("@data/auth/pkce", () => ({ + vi.doMock("@cue/core/data/auth/pkce", () => ({ createPkcePair: vi.fn(async () => ({ verifier: "verifier", challenge: "challenge" })), })); diff --git a/packages/web/test/support/composition-root-mocks.tsx b/packages/web/test/support/composition-root-mocks.tsx index d7524047..08174657 100644 --- a/packages/web/test/support/composition-root-mocks.tsx +++ b/packages/web/test/support/composition-root-mocks.tsx @@ -45,7 +45,7 @@ export function mockCompositionRoot({ vi.doMock("@platform/kv", () => ({ createKeyValueStore: () => ({}) })); vi.doMock("@platform/reminders", () => ({ createNativeReminders: () => ({}) })); vi.doMock("@platform/status-bar", () => ({ applyStatusBarTheme: vi.fn() })); - vi.doMock("@platform/token-store", () => ({ createTokenStore: () => ({}) })); + vi.doMock("@cue/core/ports/token-store", () => ({ createTokenStore: () => ({}) })); vi.doMock("@ui/app-shell/ScreenHeader", () => ({ ScreenHeader: () => null })); vi.doMock("@ui/screens/settings/SignOutRow", () => ({ SignOutRow: () => null })); vi.doMock("@ui/screens/settings/useSyncStatus", () => ({ diff --git a/packages/web/test/ui/calendar-agenda.test.ts b/packages/web/test/ui/calendar-agenda.test.ts index 6f5742c3..88bfadd8 100644 --- a/packages/web/test/ui/calendar-agenda.test.ts +++ b/packages/web/test/ui/calendar-agenda.test.ts @@ -1,4 +1,4 @@ -import type { CalendarDay, CalendarRow } from "@domain/calendar"; +import type { CalendarDay, CalendarRow } from "@cue/core/domain/calendar"; import { buildAgenda, trailingChip } from "@ui/screens/calendar/agenda"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/continue-bar.test.tsx b/packages/web/test/ui/continue-bar.test.tsx index be4f3065..e73ff25f 100644 --- a/packages/web/test/ui/continue-bar.test.tsx +++ b/packages/web/test/ui/continue-bar.test.tsx @@ -1,5 +1,5 @@ -import type { LibraryEntry } from "@data/trakt/library"; -import type { EpisodeView, SeasonView, ShowHeader } from "@data/trakt/show-detail"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { EpisodeView, SeasonView, ShowHeader } from "@cue/core/data/trakt/show-detail"; import { ContinueBar } from "@ui/screens/show-detail/ContinueBar"; import { act, type ReactElement, type ReactNode, useState } from "react"; import { expect, it, vi } from "vitest"; diff --git a/packages/web/test/ui/detail-logic.test.ts b/packages/web/test/ui/detail-logic.test.ts index 89879cc4..59e8c01c 100644 --- a/packages/web/test/ui/detail-logic.test.ts +++ b/packages/web/test/ui/detail-logic.test.ts @@ -1,4 +1,4 @@ -import type { EpisodeView, SeasonView } from "@data/trakt/show-detail"; +import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; import { airedUnwatchedCount, backfillRangeLabel, diff --git a/packages/web/test/ui/detail-unmark-resolution.test.ts b/packages/web/test/ui/detail-unmark-resolution.test.ts index a56e682f..6c6caaa8 100644 --- a/packages/web/test/ui/detail-unmark-resolution.test.ts +++ b/packages/web/test/ui/detail-unmark-resolution.test.ts @@ -1,4 +1,4 @@ -import type { EpisodePlay } from "@domain/reversal"; +import type { EpisodePlay } from "@cue/core/domain/reversal"; import { findMarkPlay, resolveEpisodeUnmark } from "@ui/hooks/resolveUnmark"; import type { CueRuntime } from "@ui/runtime/runtime"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/episode-reminders.test.tsx b/packages/web/test/ui/episode-reminders.test.tsx index f0369bb7..c82c8f78 100644 --- a/packages/web/test/ui/episode-reminders.test.tsx +++ b/packages/web/test/ui/episode-reminders.test.tsx @@ -3,8 +3,8 @@ * to move, and when it must be left alone. Cancelling is destructive and * silent, so the states that must NOT cancel matter more than the ones that do. */ -import type { CalendarEntry } from "@domain/calendar"; -import type { Reminders } from "@domain/ports/reminders"; +import type { CalendarEntry } from "@cue/core/domain/calendar"; +import type { Reminders } from "@cue/core/domain/ports/reminders"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEpisodeReminders } from "@ui/hooks/useEpisodeReminders"; import { usePrefs } from "@ui/prefs/prefs-store"; diff --git a/packages/web/test/ui/format.test.ts b/packages/web/test/ui/format.test.ts index de57e812..efc498eb 100644 --- a/packages/web/test/ui/format.test.ts +++ b/packages/web/test/ui/format.test.ts @@ -1,5 +1,5 @@ -import { epCode } from "@domain/model/library"; -import { episodesLeft, lastWatchedPhrase, middleTruncate } from "@ui/format"; +import { epCode } from "@cue/core/domain/model/library"; +import { episodesLeft, lastWatchedPhrase, middleTruncate } from "@cue/core/format"; import { describe, expect, it } from "vitest"; describe("epCode", () => { diff --git a/packages/web/test/ui/history-view.test.ts b/packages/web/test/ui/history-view.test.ts index 09581dcd..42dc2685 100644 --- a/packages/web/test/ui/history-view.test.ts +++ b/packages/web/test/ui/history-view.test.ts @@ -1,4 +1,4 @@ -import type { HistoryDay, HistoryEntry, HistoryGroup } from "@domain/history"; +import type { HistoryDay, HistoryEntry, HistoryGroup } from "@cue/core/domain/history"; import { buildBlocks, countItemPlays, diff --git a/packages/web/test/ui/library-chips.test.ts b/packages/web/test/ui/library-chips.test.ts index 3cb1e046..09abad91 100644 --- a/packages/web/test/ui/library-chips.test.ts +++ b/packages/web/test/ui/library-chips.test.ts @@ -1,4 +1,4 @@ -import type { LibraryEntry } from "@data/trakt/library"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; import { chipBuckets } from "@ui/hooks/useLibraryBuckets"; import { describe, expect, it } from "vitest"; import { DAY, iso, makeShow, NOW, THRESHOLD } from "../domain/_helpers"; diff --git a/packages/web/test/ui/library-snapshot.test.tsx b/packages/web/test/ui/library-snapshot.test.tsx index 9ef04d2d..68e01231 100644 --- a/packages/web/test/ui/library-snapshot.test.tsx +++ b/packages/web/test/ui/library-snapshot.test.tsx @@ -1,6 +1,6 @@ -import type { LibraryEntry } from "@data/trakt/library"; -import type { EpisodeView, SeasonView } from "@data/trakt/show-detail"; -import type { CalendarEntry } from "@domain/calendar"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; +import type { CalendarEntry } from "@cue/core/domain/calendar"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type LibrarySnapshot, diff --git a/packages/web/test/ui/mark-pipeline.test.tsx b/packages/web/test/ui/mark-pipeline.test.tsx index 15d8e47e..c324dc3f 100644 --- a/packages/web/test/ui/mark-pipeline.test.tsx +++ b/packages/web/test/ui/mark-pipeline.test.tsx @@ -8,12 +8,12 @@ * exact history id, never remove-by-item, with an honest failure when the play * can't be identified (F14). */ -import { queryKeys } from "@data/query-keys"; -import type { EpisodeDetail } from "@data/trakt/episode-detail"; -import type { LibraryEntry } from "@data/trakt/library"; -import type { EpisodeView, SeasonView } from "@data/trakt/show-detail"; -import type { EpisodePlay } from "@domain/reversal"; -import type { QueuedOp } from "@domain/write-queue/types"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; +import type { EpisodePlay } from "@cue/core/domain/reversal"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { dismissSnack, useSnackbar } from "@ui/components/snackbar-store"; import { resetMarkStore } from "@ui/hooks/mark-store"; diff --git a/packages/web/test/ui/mark-store.test.ts b/packages/web/test/ui/mark-store.test.ts index 98779beb..a28c0a7a 100644 --- a/packages/web/test/ui/mark-store.test.ts +++ b/packages/web/test/ui/mark-store.test.ts @@ -1,5 +1,5 @@ -import { buildAddEpisodePlayOp, buildMarkEpisodeOp } from "@domain/write-queue/ops"; -import type { QueuedOp } from "@domain/write-queue/types"; +import { buildAddEpisodePlayOp, buildMarkEpisodeOp } from "@cue/core/domain/write-queue/ops"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { hasPendingMark, lockShow, diff --git a/packages/web/test/ui/on-the-way.test.ts b/packages/web/test/ui/on-the-way.test.ts index 67b5b3e1..be53d1df 100644 --- a/packages/web/test/ui/on-the-way.test.ts +++ b/packages/web/test/ui/on-the-way.test.ts @@ -1,4 +1,4 @@ -import type { CalendarDay, CalendarRow } from "@domain/calendar"; +import type { CalendarDay, CalendarRow } from "@cue/core/domain/calendar"; import { buildOnTheWay } from "@ui/screens/up-next/OnTheWay"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/optimistic-write.test.ts b/packages/web/test/ui/optimistic-write.test.ts index 4f9bc960..fa4d9ac6 100644 --- a/packages/web/test/ui/optimistic-write.test.ts +++ b/packages/web/test/ui/optimistic-write.test.ts @@ -1,4 +1,4 @@ -import type { QueuedOp } from "@domain/write-queue/types"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { applyOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; import type { SubmitOutcome } from "@ui/runtime/runtime"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/ui/profile-stats.test.ts b/packages/web/test/ui/profile-stats.test.ts index 593b681c..44c4faaf 100644 --- a/packages/web/test/ui/profile-stats.test.ts +++ b/packages/web/test/ui/profile-stats.test.ts @@ -1,4 +1,4 @@ -import type { UserStats } from "@data/trakt/schemas"; +import type { UserStats } from "@cue/core/data/trakt/schemas"; import { countTiles, isAllZero, watchTimeMinutes } from "@ui/screens/profile/stats"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/pull-to-refresh.test.tsx b/packages/web/test/ui/pull-to-refresh.test.tsx index eb248cec..87bdcd94 100644 --- a/packages/web/test/ui/pull-to-refresh.test.tsx +++ b/packages/web/test/ui/pull-to-refresh.test.tsx @@ -5,7 +5,7 @@ * non-passive `touchmove`, which is invisible from the page. */ -import type { Haptics } from "@domain/ports/haptics"; +import type { Haptics } from "@cue/core/domain/ports/haptics"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PullToRefresh } from "@ui/components/PullToRefresh"; import { HapticsProvider } from "@ui/runtime/haptics"; diff --git a/packages/web/test/ui/queue-order.test.ts b/packages/web/test/ui/queue-order.test.ts index f8e0430a..2814a005 100644 --- a/packages/web/test/ui/queue-order.test.ts +++ b/packages/web/test/ui/queue-order.test.ts @@ -1,4 +1,4 @@ -import type { UpNextItem } from "@domain/up-next"; +import type { UpNextItem } from "@cue/core/domain/up-next"; import { sortLapsed, sortQueue, stabilizePendingAdvance } from "@ui/hooks/queue-order"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/resolve-movie-unmark.test.ts b/packages/web/test/ui/resolve-movie-unmark.test.ts index df017a3f..652fb167 100644 --- a/packages/web/test/ui/resolve-movie-unmark.test.ts +++ b/packages/web/test/ui/resolve-movie-unmark.test.ts @@ -1,4 +1,4 @@ -import type { MoviePlay } from "@domain/reversal"; +import type { MoviePlay } from "@cue/core/domain/reversal"; import { resolveMovieUnmark, routeMovieUnmark } from "@ui/hooks/resolveUnmark"; import type { CueRuntime } from "@ui/runtime/runtime"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/search-visibility.test.ts b/packages/web/test/ui/search-visibility.test.ts index 0a388e8b..9316a44c 100644 --- a/packages/web/test/ui/search-visibility.test.ts +++ b/packages/web/test/ui/search-visibility.test.ts @@ -1,4 +1,4 @@ -import type { SearchHit } from "@data/trakt/search"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { visibleSearchHits } from "@ui/hooks/useSearch"; import { describe, expect, it } from "vitest"; diff --git a/packages/web/test/ui/sheet-logic.test.ts b/packages/web/test/ui/sheet-logic.test.ts index 29c561fb..76b7f717 100644 --- a/packages/web/test/ui/sheet-logic.test.ts +++ b/packages/web/test/ui/sheet-logic.test.ts @@ -1,4 +1,4 @@ -import { formatWatchedDate } from "@ui/format"; +import { formatWatchedDate } from "@cue/core/format"; import { removeAllBody, sheetMetaLine, diff --git a/packages/web/test/ui/use-calendar.test.tsx b/packages/web/test/ui/use-calendar.test.tsx index 6a4aad51..787f9248 100644 --- a/packages/web/test/ui/use-calendar.test.tsx +++ b/packages/web/test/ui/use-calendar.test.tsx @@ -3,7 +3,7 @@ * SAME full-window query (one GET serves home's 72h "On the way" slice and the * 28-day Calendar screen), and narrower callers get a client-side day slice. */ -import type { CalendarEntry } from "@domain/calendar"; +import type { CalendarEntry } from "@cue/core/domain/calendar"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CALENDAR_WINDOW_DAYS, diff --git a/packages/web/test/ui/use-sync-status.test.tsx b/packages/web/test/ui/use-sync-status.test.tsx index fa7ca877..d5fe6d8d 100644 --- a/packages/web/test/ui/use-sync-status.test.tsx +++ b/packages/web/test/ui/use-sync-status.test.tsx @@ -1,4 +1,4 @@ -import { queryKeys } from "@data/query-keys"; +import { queryKeys } from "@cue/core/data/query-keys"; import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; import { dismissSnack, useSnackbar } from "@ui/components/snackbar-store"; import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; diff --git a/packages/web/test/ui/watchlist-add.test.tsx b/packages/web/test/ui/watchlist-add.test.tsx index e28915a3..389b1b6a 100644 --- a/packages/web/test/ui/watchlist-add.test.tsx +++ b/packages/web/test/ui/watchlist-add.test.tsx @@ -1,6 +1,6 @@ -import { queryKeys } from "@data/query-keys"; -import type { LibraryEntry } from "@data/trakt/library"; -import type { SearchHit } from "@data/trakt/search"; +import { queryKeys } from "@cue/core/data/query-keys"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { SearchHit } from "@cue/core/data/trakt/search"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useWatchlistAdd, type WatchlistAddView } from "@ui/hooks/useWatchlistAdd"; import { type CueRuntime, RuntimeProvider, type UpNextData } from "@ui/runtime/runtime"; diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index d0fccfb2..6b85d7f4 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -5,8 +5,6 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["vite/client", "node"], "paths": { - "@domain/*": ["./src/domain/*"], - "@data/*": ["./src/data/*"], "@ui/*": ["./src/ui/*"], "@app/*": ["./src/app/*"], "@platform/*": ["./src/platform/*"] diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 04e25dbc..704dc2cc 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -39,8 +39,6 @@ export default defineConfig({ ], resolve: { alias: { - "@domain": src("domain"), - "@data": src("data"), "@ui": src("ui"), "@app": src("app"), "@platform": src("platform"), diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 6f86414f..5bffba11 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -8,8 +8,6 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - "@domain": src("domain"), - "@data": src("data"), "@ui": src("ui"), "@app": src("app"), "@platform": src("platform"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25c25caf..f193ea10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,22 @@ importers: specifier: ^4.1.9 version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.20.0)(typescript@6.0.3))(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/core: + dependencies: + '@tanstack/react-query': + specifier: 5.101.2 + version: 5.101.2(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.20.0)(typescript@6.0.3))(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/web: dependencies: '@capacitor/app': @@ -81,6 +97,9 @@ importers: '@capacitor/status-bar': specifier: ^8.0.3 version: 8.0.3(@capacitor/core@8.5.0) + '@cue/core': + specifier: workspace:* + version: link:../core '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index 471b1e5f..b2f8d788 100644 --- a/scripts/write-buster.mjs +++ b/scripts/write-buster.mjs @@ -19,8 +19,8 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); * so these roots may be repathed without bumping anything. */ const SHAPE_TREES = [ - "packages/web/src/domain", - "packages/web/src/data", + "packages/core/src/domain", + "packages/core/src/data", "packages/web/src/ui/runtime", ]; @@ -28,13 +28,31 @@ const GENERATED = "packages/web/src/ui/runtime/persist-buster.ts"; /** * Every module specifier in an `import`, `export ... from`, dynamic `import()` - * or `require()` clause, collapsed to one constant before hashing. A refactor - * that respells `@domain/up-next` as `@cue/core/domain/up-next` rewrites nearly - * every file in these trees and changes no persisted shape; without this the - * rename alone would drop every shipping user's cache. + * or `require()` clause, collapsed to one constant. A refactor that respells + * `@domain/up-next` as `@cue/core/domain/up-next` rewrites nearly every file in + * these trees and changes no persisted shape; without this the rename alone + * would drop every shipping user's cache. */ const MODULE_SPECIFIER = /(? + source + .replace(MODULE_SPECIFIER, '$1$2$3"$3') + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .sort() + .join("\n"); + function filesUnder(tree) { const root = join(ROOT, tree); return readdirSync(root, { recursive: true, encoding: "utf8" }) @@ -43,15 +61,15 @@ function filesUnder(tree) { } /** - * A digest per file, sorted, then hashed. No path component enters the hash, so - * a file that moves between the trees produces the same witness; a file added, - * deleted or edited does not. + * A digest per normalised file, sorted, then hashed. No path component enters + * the hash, so a file that moves between the trees produces the same witness; a + * file added, deleted or edited does not. */ function shapeWitness() { const digests = SHAPE_TREES.flatMap(filesUnder) .map((path) => createHash("sha256") - .update(readFileSync(join(ROOT, path), "utf8").replace(MODULE_SPECIFIER, '$1$2$3"$3')) + .update(normalise(readFileSync(join(ROOT, path), "utf8"))) .digest("hex"), ) .sort(); diff --git a/vitest.config.ts b/vitest.config.ts index 651997b8..12c7b6dc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,8 +18,8 @@ export default defineConfig({ functions: 70, statements: 70, branches: 60, - "packages/web/src/domain/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, - "packages/web/src/data/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/core/src/domain/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/core/src/data/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, }, }, }, From 5dc3a8736155037fd42ab7f0ae0b23bf5e1c2f8f Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 19:29:09 -0500 Subject: [PATCH 004/435] Lift the preferences onto an injected PreferenceStorage src/ui/prefs closed over localStorage in six places, which is why every preference on the Capacitor build is stuck at its default: the writes throw or no-op and nothing notices. The whole tree moves to packages/core/src/prefs over one port, PreferenceStorage, and the web app supplies the one implementation. The port is synchronous by design, and that is the reason it is a second port rather than a use of KeyValueStore. Preferences are read at import time and the theme is applied before the first render specifically so there is no light or dark flash; an await before first paint would put that flash back on both targets. Every backend this needs answers synchronously. The port also does not throw: localStorage does, outright, in some privacy modes, so the six try/catch pairs collapse into one, in the implementation, where the next target will write its own. prefs-store becomes createPrefsStore(storage) and the web keeps a three-line module that instantiates it, so every @ui/prefs/prefs-store import is unchanged. The implementation lives beside the preferences in src/ui/prefs rather than in src/platform, because ui-no-platform-impl forbids a screen importing a platform impl and this one cannot be injected: it is read before React exists. localStorage is a browser global, not a native bridge, so nothing in src/ui needs a Capacitor mock to run it. The two consumers that bypassed the prefs modules now go through the same port: Library's two chip keys, which were a hand-rolled readChip/persistChip pair, are choicePref, and TutorialCaption's dismissal is booleanPref. One behaviour moves by a hair. TutorialCaption used to read a THROWING localStorage as dismissed, so a private-mode reader never saw the caption at all; through the port a throw is indistinguishable from an absent key, so they see it and it re-shows next visit. That is what the file's own persist path already called harmless, and it is the same answer every other preference gives. The semantics are now pinned in vitest over a map-backed storage rather than only by Playwright: the default for every preference, the coercion of a stale, corrupt or malformed value, the threshold option set, and the last-medium-on invariant, including that the refusal is not persisted. media-visibility's tests move with it; navFor's two, which shared that file for no reason beyond both being about media, become their own. --- packages/core/package.json | 3 +- packages/core/src/ports/preference-storage.ts | 18 +++ .../src/ui => core/src}/prefs/device-prefs.ts | 10 +- .../ui => core/src}/prefs/media-visibility.ts | 18 +-- packages/core/src/prefs/pref-storage.ts | 42 +++++++ packages/core/src/prefs/prefs-store.ts | 114 ++++++++++++++++++ .../src/ui => core/src}/prefs/threshold.ts | 12 +- .../src/ui => core/src}/prefs/tracking.ts | 21 ++-- packages/core/test/prefs/_storage.ts | 11 ++ .../core/test/prefs/media-visibility.test.ts | 61 ++++++++++ packages/core/test/prefs/pref-storage.test.ts | 61 ++++++++++ packages/core/test/prefs/prefs-store.test.ts | 95 +++++++++++++++ .../web/src/ui/components/TutorialCaption.tsx | 20 +-- packages/web/src/ui/hooks/queue-order.ts | 2 +- .../web/src/ui/hooks/useLibrarySnapshot.ts | 2 +- packages/web/src/ui/prefs/pref-storage.ts | 52 -------- .../web/src/ui/prefs/preference-storage.ts | 29 +++++ packages/web/src/ui/prefs/prefs-store.ts | 104 +--------------- .../web/src/ui/screens/library/Library.tsx | 40 ++---- .../web/src/ui/screens/profile/Profile.tsx | 2 +- packages/web/src/ui/screens/profile/stats.ts | 2 +- .../web/src/ui/screens/settings/Settings.tsx | 14 +-- packages/web/test/ui/media-visibility.test.ts | 77 ------------ packages/web/test/ui/nav.test.ts | 17 +++ packages/web/test/ui/pref-storage.test.ts | 72 ----------- pnpm-lock.yaml | 3 + vitest.config.ts | 1 + 27 files changed, 519 insertions(+), 384 deletions(-) create mode 100644 packages/core/src/ports/preference-storage.ts rename packages/{web/src/ui => core/src}/prefs/device-prefs.ts (55%) rename packages/{web/src/ui => core/src}/prefs/media-visibility.ts (57%) create mode 100644 packages/core/src/prefs/pref-storage.ts create mode 100644 packages/core/src/prefs/prefs-store.ts rename packages/{web/src/ui => core/src}/prefs/threshold.ts (67%) rename packages/{web/src/ui => core/src}/prefs/tracking.ts (54%) create mode 100644 packages/core/test/prefs/_storage.ts create mode 100644 packages/core/test/prefs/media-visibility.test.ts create mode 100644 packages/core/test/prefs/pref-storage.test.ts create mode 100644 packages/core/test/prefs/prefs-store.test.ts delete mode 100644 packages/web/src/ui/prefs/pref-storage.ts create mode 100644 packages/web/src/ui/prefs/preference-storage.ts delete mode 100644 packages/web/test/ui/media-visibility.test.ts create mode 100644 packages/web/test/ui/nav.test.ts delete mode 100644 packages/web/test/ui/pref-storage.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 53d35a8e..d9dffc48 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "@tanstack/react-query": "5.101.2", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "5.0.14" }, "devDependencies": { "typescript": "^6.0.3", diff --git a/packages/core/src/ports/preference-storage.ts b/packages/core/src/ports/preference-storage.ts new file mode 100644 index 00000000..451f7894 --- /dev/null +++ b/packages/core/src/ports/preference-storage.ts @@ -0,0 +1,18 @@ +/** + * Device-local preferences, and the one port that is synchronous by design. + * + * `KeyValueStore` is async because IndexedDB and Capacitor Preferences are. + * Preferences cannot be: the prefs store reads every value at import time and + * the theme is applied before the first render specifically so there is no light + * or dark flash. An await before first paint would put that flash back on both + * targets. Every backend this needs can answer synchronously, `localStorage` on + * the web and `expo-sqlite/kv-store`'s `getItemSync` on a device. + * + * Neither method throws. `localStorage` does, outright, in some privacy modes, + * so the degradation lives in the implementation: a preference that cannot be + * remembered still resolves to its principled default. + */ +export interface PreferenceStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} diff --git a/packages/web/src/ui/prefs/device-prefs.ts b/packages/core/src/prefs/device-prefs.ts similarity index 55% rename from packages/web/src/ui/prefs/device-prefs.ts rename to packages/core/src/prefs/device-prefs.ts index 285f601b..2753d9d7 100644 --- a/packages/web/src/ui/prefs/device-prefs.ts +++ b/packages/core/src/prefs/device-prefs.ts @@ -1,4 +1,5 @@ -import { booleanPref } from "./pref-storage"; +import type { PreferenceStorage } from "../ports/preference-storage"; +import { booleanPref, type Pref } from "./pref-storage"; /** * The two preferences that only mean anything inside the phone app, kept @@ -11,5 +12,8 @@ import { booleanPref } from "./pref-storage"; * platforms say to ask for one in context, from a deliberate opt-in, rather * than at launch. The Settings row is that opt-in. */ -export const hapticsPref = booleanPref("cue.haptics-enabled", true); -export const remindersPref = booleanPref("cue.reminders-enabled", false); +export const hapticsPref = (storage: PreferenceStorage): Pref => + booleanPref(storage, "cue.haptics-enabled", true); + +export const remindersPref = (storage: PreferenceStorage): Pref => + booleanPref(storage, "cue.reminders-enabled", false); diff --git a/packages/web/src/ui/prefs/media-visibility.ts b/packages/core/src/prefs/media-visibility.ts similarity index 57% rename from packages/web/src/ui/prefs/media-visibility.ts rename to packages/core/src/prefs/media-visibility.ts index 1be868d1..fbb85727 100644 --- a/packages/web/src/ui/prefs/media-visibility.ts +++ b/packages/core/src/prefs/media-visibility.ts @@ -1,3 +1,4 @@ +import type { PreferenceStorage } from "../ports/preference-storage"; import { booleanPref } from "./pref-storage"; /** Which media a user tracks. Both ON by default; the app is never emptied of @@ -7,8 +8,8 @@ export interface MediaVisibility { readonly moviesEnabled: boolean; } -const showsPref = booleanPref("cue.shows-enabled", true); -const moviesPref = booleanPref("cue.movies-enabled", true); +const showsPref = (storage: PreferenceStorage) => booleanPref(storage, "cue.shows-enabled", true); +const moviesPref = (storage: PreferenceStorage) => booleanPref(storage, "cue.movies-enabled", true); /** * Both media are ON by default. Both-OFF is impossible by construction (the store @@ -25,11 +26,14 @@ export function resolveMediaVisibility( } /** Absent (fresh device / reinstall) reads as ON. */ -export function initialMediaVisibility(): MediaVisibility { - return resolveMediaVisibility(showsPref.initial(), moviesPref.initial()); +export function initialMediaVisibility(storage: PreferenceStorage): MediaVisibility { + return resolveMediaVisibility(showsPref(storage).initial(), moviesPref(storage).initial()); } -export function persistMediaVisibility({ showsEnabled, moviesEnabled }: MediaVisibility): void { - showsPref.persist(showsEnabled); - moviesPref.persist(moviesEnabled); +export function persistMediaVisibility( + storage: PreferenceStorage, + { showsEnabled, moviesEnabled }: MediaVisibility, +): void { + showsPref(storage).persist(showsEnabled); + moviesPref(storage).persist(moviesEnabled); } diff --git a/packages/core/src/prefs/pref-storage.ts b/packages/core/src/prefs/pref-storage.ts new file mode 100644 index 00000000..0ff8aebc --- /dev/null +++ b/packages/core/src/prefs/pref-storage.ts @@ -0,0 +1,42 @@ +import type { PreferenceStorage } from "../ports/preference-storage"; + +/** + * The two shapes every device-local preference takes, so the read/write pair is + * written once rather than per preference. The storage is injected: the same + * definitions run over `localStorage` and over a device's key-value store, and + * a test can hand them a plain map. + */ +export interface Pref { + initial(): T; + persist(value: T): void; +} + +/** A stored "1"/"0" flag; an absent key reads as `fallback`. */ +export function booleanPref( + storage: PreferenceStorage, + key: string, + fallback: boolean, +): Pref { + return { + initial: () => { + const stored = storage.getItem(key); + return stored === null ? fallback : stored === "1"; + }, + persist: (value) => storage.setItem(key, value ? "1" : "0"), + }; +} + +/** A stored member of `options`; anything else (absent, stale, corrupt) reads as + * `fallback`. Numbers store as their decimal text, so an option list can be + * either words or figures. */ +export function choicePref( + storage: PreferenceStorage, + key: string, + options: readonly T[], + fallback: T, +): Pref { + return { + initial: () => options.find((option) => String(option) === storage.getItem(key)) ?? fallback, + persist: (value) => storage.setItem(key, String(value)), + }; +} diff --git a/packages/core/src/prefs/prefs-store.ts b/packages/core/src/prefs/prefs-store.ts new file mode 100644 index 00000000..de2b50b2 --- /dev/null +++ b/packages/core/src/prefs/prefs-store.ts @@ -0,0 +1,114 @@ +import { create } from "zustand"; +import type { PreferenceStorage } from "../ports/preference-storage"; +import { hapticsPref, remindersPref } from "./device-prefs"; +import { + initialMediaVisibility, + type MediaVisibility, + persistMediaVisibility, +} from "./media-visibility"; +import { thresholdPref } from "./threshold"; +import { + hideStillsPref, + type LapsedOrder, + lapsedOrderPref, + type NextEpisodeOrder, + nextEpisodeOrderPref, +} from "./tracking"; + +interface PrefsState { + /** Days of inactivity before a show falls from Watching to Not-watched-in-a-while. */ + thresholdDays: number; + setThresholdDays: (days: number) => void; + /** Media-visibility: a device-local pref so a single-medium user is + * never shown the other. Both ON by default; disabling the last one is refused. */ + showsEnabled: boolean; + moviesEnabled: boolean; + setShowsEnabled: (enabled: boolean) => void; + setMoviesEnabled: (enabled: boolean) => void; + /** The one buzz on a mark/undo/armed pull: ON by default, device-local, + * read by the injected haptics seam at fire time. Silent no-op on web regardless. */ + hapticsEnabled: boolean; + setHapticsEnabled: (enabled: boolean) => void; + /** The daily "airing today" digest: OFF by default, flipped on only after the + * OS notification permission is granted in context. */ + remindersEnabled: boolean; + setRemindersEnabled: (enabled: boolean) => void; + /** Spoiler guard: blur unwatched episode stills until revealed. Default ON. */ + hideStillsUntilWatched: boolean; + setHideStillsUntilWatched: (enabled: boolean) => void; + /** How the Up Next queue orders shows: oldest waiting episode first (default) + * or the user's own last-watched recency. */ + nextEpisodeOrder: NextEpisodeOrder; + setNextEpisodeOrder: (order: NextEpisodeOrder) => void; + /** How the Haven't watched lately drawer orders shows: most recently watched first + * (default) or longest idle first. */ + lapsedOrder: LapsedOrder; + setLapsedOrder: (order: LapsedOrder) => void; +} + +/** + * Local display preferences: the staleness threshold that splits the Watching + * pile (and Up Next) from Not-watched-in-a-while, the TV/Movies visibility + * toggles, and the device-local switches. Never Trakt-synced; every one reverts + * to its principled default (21 days, both media on) on a new device. + * + * A factory rather than a module-level store, because the storage differs per + * app and the reads happen at import time, before anything React could inject. + */ +export function createPrefsStore(storage: PreferenceStorage) { + const haptics = hapticsPref(storage); + const reminders = remindersPref(storage); + const hideStills = hideStillsPref(storage); + const nextEpisodeOrder = nextEpisodeOrderPref(storage); + const lapsedOrder = lapsedOrderPref(storage); + const threshold = thresholdPref(storage); + + return create((set, get) => { + const media = initialMediaVisibility(storage); + // One commit path enforces the single invariant: the app is never emptied of + // both media: so a setter that would turn off the last-enabled medium no-ops. + const commit = (next: MediaVisibility): void => { + if (!next.showsEnabled && !next.moviesEnabled) return; + persistMediaVisibility(storage, next); + set(next); + }; + return { + thresholdDays: threshold.initial(), + setThresholdDays: (thresholdDays) => { + threshold.persist(thresholdDays); + set({ thresholdDays }); + }, + showsEnabled: media.showsEnabled, + moviesEnabled: media.moviesEnabled, + setShowsEnabled: (showsEnabled) => + commit({ showsEnabled, moviesEnabled: get().moviesEnabled }), + setMoviesEnabled: (moviesEnabled) => + commit({ showsEnabled: get().showsEnabled, moviesEnabled }), + hapticsEnabled: haptics.initial(), + setHapticsEnabled: (hapticsEnabled) => { + haptics.persist(hapticsEnabled); + set({ hapticsEnabled }); + }, + remindersEnabled: reminders.initial(), + setRemindersEnabled: (remindersEnabled) => { + reminders.persist(remindersEnabled); + set({ remindersEnabled }); + }, + hideStillsUntilWatched: hideStills.initial(), + setHideStillsUntilWatched: (hideStillsUntilWatched) => { + hideStills.persist(hideStillsUntilWatched); + set({ hideStillsUntilWatched }); + }, + nextEpisodeOrder: nextEpisodeOrder.initial(), + setNextEpisodeOrder: (order) => { + nextEpisodeOrder.persist(order); + set({ nextEpisodeOrder: order }); + }, + lapsedOrder: lapsedOrder.initial(), + setLapsedOrder: (order) => { + lapsedOrder.persist(order); + set({ lapsedOrder: order }); + }, + }; + }); +} diff --git a/packages/web/src/ui/prefs/threshold.ts b/packages/core/src/prefs/threshold.ts similarity index 67% rename from packages/web/src/ui/prefs/threshold.ts rename to packages/core/src/prefs/threshold.ts index 5f88433b..5cd08798 100644 --- a/packages/web/src/ui/prefs/threshold.ts +++ b/packages/core/src/prefs/threshold.ts @@ -1,5 +1,6 @@ -import { DEFAULT_STALENESS_THRESHOLD_MS } from "@cue/core/domain/watch-status"; -import { choicePref } from "./pref-storage"; +import { DEFAULT_STALENESS_THRESHOLD_MS } from "../domain/watch-status"; +import type { PreferenceStorage } from "../ports/preference-storage"; +import { choicePref, type Pref } from "./pref-storage"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -22,8 +23,5 @@ export function thresholdMsFromDays(days: number): number { } /** A stored choice wins; otherwise the principled 21-day default. */ -export const thresholdPref = choicePref( - "cue.staleness-threshold-days", - THRESHOLD_OPTIONS, - DEFAULT_THRESHOLD_DAYS, -); +export const thresholdPref = (storage: PreferenceStorage): Pref => + choicePref(storage, "cue.staleness-threshold-days", THRESHOLD_OPTIONS, DEFAULT_THRESHOLD_DAYS); diff --git a/packages/web/src/ui/prefs/tracking.ts b/packages/core/src/prefs/tracking.ts similarity index 54% rename from packages/web/src/ui/prefs/tracking.ts rename to packages/core/src/prefs/tracking.ts index 871d3b4c..1735e572 100644 --- a/packages/web/src/ui/prefs/tracking.ts +++ b/packages/core/src/prefs/tracking.ts @@ -1,4 +1,5 @@ -import { booleanPref, choicePref } from "./pref-storage"; +import type { PreferenceStorage } from "../ports/preference-storage"; +import { booleanPref, choicePref, type Pref } from "./pref-storage"; /** Which episode order the Up Next queue presents: the show whose oldest * unwatched episode has waited longest first (default), or the user's own @@ -14,15 +15,11 @@ export type LapsedOrder = (typeof LAPSED_ORDER_OPTIONS)[number]; * The spoiler guard for episode stills: ON by default, because an unwatched * episode's still is a spoiler until it is revealed. */ -export const hideStillsPref = booleanPref("cue.hide-stills-until-watched", true); +export const hideStillsPref = (storage: PreferenceStorage): Pref => + booleanPref(storage, "cue.hide-stills-until-watched", true); -export const nextEpisodeOrderPref = choicePref( - "cue.next-episode-order", - NEXT_EPISODE_ORDER_OPTIONS, - "oldest-unwatched", -); -export const lapsedOrderPref = choicePref( - "cue.lapsed-order", - LAPSED_ORDER_OPTIONS, - "recently-watched", -); +export const nextEpisodeOrderPref = (storage: PreferenceStorage): Pref => + choicePref(storage, "cue.next-episode-order", NEXT_EPISODE_ORDER_OPTIONS, "oldest-unwatched"); + +export const lapsedOrderPref = (storage: PreferenceStorage): Pref => + choicePref(storage, "cue.lapsed-order", LAPSED_ORDER_OPTIONS, "recently-watched"); diff --git a/packages/core/test/prefs/_storage.ts b/packages/core/test/prefs/_storage.ts new file mode 100644 index 00000000..940bf48f --- /dev/null +++ b/packages/core/test/prefs/_storage.ts @@ -0,0 +1,11 @@ +import type { PreferenceStorage } from "../../src/ports/preference-storage"; + +/** A `PreferenceStorage` over a plain map, so a preference's semantics can be + * pinned without a browser or a device. */ +export function fakeStorage(seed: Record = {}): PreferenceStorage { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + }; +} diff --git a/packages/core/test/prefs/media-visibility.test.ts b/packages/core/test/prefs/media-visibility.test.ts new file mode 100644 index 00000000..989553cb --- /dev/null +++ b/packages/core/test/prefs/media-visibility.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + initialMediaVisibility, + persistMediaVisibility, + resolveMediaVisibility, +} from "../../src/prefs/media-visibility"; +import { fakeStorage } from "./_storage"; + +describe("resolveMediaVisibility", () => { + it("keeps both media on by default", () => { + expect(resolveMediaVisibility(true, true)).toEqual({ showsEnabled: true, moviesEnabled: true }); + }); + + it("preserves a single-medium choice", () => { + expect(resolveMediaVisibility(true, false)).toEqual({ + showsEnabled: true, + moviesEnabled: false, + }); + expect(resolveMediaVisibility(false, true)).toEqual({ + showsEnabled: false, + moviesEnabled: true, + }); + }); + + it("heals a corrupt both-off pair back to both-on (never an empty app)", () => { + expect(resolveMediaVisibility(false, false)).toEqual({ + showsEnabled: true, + moviesEnabled: true, + }); + }); +}); + +describe("initialMediaVisibility", () => { + it("defaults to both-on on a fresh device (no stored keys)", () => { + expect(initialMediaVisibility(fakeStorage())).toEqual({ + showsEnabled: true, + moviesEnabled: true, + }); + }); + + it("reads a persisted single-medium choice back", () => { + const storage = fakeStorage(); + persistMediaVisibility(storage, { showsEnabled: true, moviesEnabled: false }); + expect(initialMediaVisibility(storage)).toEqual({ showsEnabled: true, moviesEnabled: false }); + + persistMediaVisibility(storage, { showsEnabled: false, moviesEnabled: true }); + expect(initialMediaVisibility(storage)).toEqual({ showsEnabled: false, moviesEnabled: true }); + }); + + it("reads a malformed value as off and an absent one as its default", () => { + // Only "1" has ever meant on, so a present key is an answer however + // malformed, while the absent movies key still reads as its default. + const storage = fakeStorage({ "cue.shows-enabled": "yes" }); + expect(initialMediaVisibility(storage)).toEqual({ showsEnabled: false, moviesEnabled: true }); + }); + + it("heals a both-off store rather than boot empty", () => { + const storage = fakeStorage({ "cue.shows-enabled": "0", "cue.movies-enabled": "0" }); + expect(initialMediaVisibility(storage)).toEqual({ showsEnabled: true, moviesEnabled: true }); + }); +}); diff --git a/packages/core/test/prefs/pref-storage.test.ts b/packages/core/test/prefs/pref-storage.test.ts new file mode 100644 index 00000000..b42ea7aa --- /dev/null +++ b/packages/core/test/prefs/pref-storage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { booleanPref, choicePref } from "../../src/prefs/pref-storage"; +import { fakeStorage } from "./_storage"; + +const FLAG = "cue.flag"; +const CHOICE = "cue.choice"; +const OPTIONS = ["alpha", "beta"] as const; + +describe("booleanPref", () => { + it("reads an absent key as the fallback, either way round", () => { + expect(booleanPref(fakeStorage(), FLAG, true).initial()).toBe(true); + expect(booleanPref(fakeStorage(), FLAG, false).initial()).toBe(false); + }); + + it('stores "1" and "0" and reads them back', () => { + const storage = fakeStorage(); + const pref = booleanPref(storage, FLAG, true); + pref.persist(false); + expect(storage.getItem(FLAG)).toBe("0"); + expect(pref.initial()).toBe(false); + pref.persist(true); + expect(storage.getItem(FLAG)).toBe("1"); + expect(pref.initial()).toBe(true); + }); + + it("reads anything that is not the stored true as false, whatever the fallback", () => { + // A present key is an answer, so a malformed one is off rather than the + // default: the value was written by some version of this app, and only "1" + // has ever meant on. + expect(booleanPref(fakeStorage({ [FLAG]: "true" }), FLAG, true).initial()).toBe(false); + expect(booleanPref(fakeStorage({ [FLAG]: "" }), FLAG, true).initial()).toBe(false); + }); +}); + +describe("choicePref", () => { + it("reads an absent key as the fallback", () => { + expect(choicePref(fakeStorage(), CHOICE, OPTIONS, "alpha").initial()).toBe("alpha"); + }); + + it("reads a stored member back", () => { + const storage = fakeStorage(); + const pref = choicePref(storage, CHOICE, OPTIONS, "alpha"); + pref.persist("beta"); + expect(storage.getItem(CHOICE)).toBe("beta"); + expect(pref.initial()).toBe("beta"); + }); + + it("stores a numeric option as its figure and reads it back as a number", () => { + const storage = fakeStorage(); + const pref = choicePref(storage, CHOICE, [14, 21, 28] as const, 21); + pref.persist(28); + expect(storage.getItem(CHOICE)).toBe("28"); + expect(pref.initial()).toBe(28); + }); + + it("coerces a stale or corrupt value to the fallback rather than trusting it", () => { + expect(choicePref(fakeStorage({ [CHOICE]: "gamma" }), CHOICE, OPTIONS, "alpha").initial()).toBe( + "alpha", + ); + }); +}); diff --git a/packages/core/test/prefs/prefs-store.test.ts b/packages/core/test/prefs/prefs-store.test.ts new file mode 100644 index 00000000..6ed706c1 --- /dev/null +++ b/packages/core/test/prefs/prefs-store.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { createPrefsStore } from "../../src/prefs/prefs-store"; +import { THRESHOLD_OPTIONS } from "../../src/prefs/threshold"; +import { fakeStorage } from "./_storage"; + +describe("createPrefsStore defaults", () => { + it("starts every preference at its principled default on a fresh device", () => { + const state = createPrefsStore(fakeStorage()).getState(); + expect(state.thresholdDays).toBe(21); + expect(state.showsEnabled).toBe(true); + expect(state.moviesEnabled).toBe(true); + expect(state.hapticsEnabled).toBe(true); + expect(state.remindersEnabled).toBe(false); + expect(state.hideStillsUntilWatched).toBe(true); + expect(state.nextEpisodeOrder).toBe("oldest-unwatched"); + expect(state.lapsedOrder).toBe("recently-watched"); + }); + + it("reads a stored choice back and coerces a malformed one", () => { + const stored = createPrefsStore( + fakeStorage({ + "cue.staleness-threshold-days": "42", + "cue.haptics-enabled": "0", + "cue.next-episode-order": "after-last-watched", + }), + ).getState(); + expect(stored.thresholdDays).toBe(42); + expect(stored.hapticsEnabled).toBe(false); + expect(stored.nextEpisodeOrder).toBe("after-last-watched"); + + const corrupt = createPrefsStore( + fakeStorage({ + "cue.staleness-threshold-days": "17", + "cue.next-episode-order": "by-vibes", + "cue.lapsed-order": "", + }), + ).getState(); + expect(corrupt.thresholdDays).toBe(21); + expect(corrupt.nextEpisodeOrder).toBe("oldest-unwatched"); + expect(corrupt.lapsedOrder).toBe("recently-watched"); + }); + + it("only accepts a threshold from the offered option set", () => { + for (const days of THRESHOLD_OPTIONS) { + const storage = fakeStorage(); + createPrefsStore(storage).getState().setThresholdDays(days); + expect(createPrefsStore(storage).getState().thresholdDays).toBe(days); + } + const storage = fakeStorage({ "cue.staleness-threshold-days": "35" }); + expect(createPrefsStore(storage).getState().thresholdDays).toBe(21); + }); +}); + +describe("the last-medium-on invariant", () => { + it("refuses to turn off the last enabled medium, and does not persist the refusal", () => { + const storage = fakeStorage(); + const store = createPrefsStore(storage); + store.getState().setMoviesEnabled(false); + expect(store.getState()).toMatchObject({ showsEnabled: true, moviesEnabled: false }); + + store.getState().setShowsEnabled(false); + expect(store.getState()).toMatchObject({ showsEnabled: true, moviesEnabled: false }); + expect(storage.getItem("cue.shows-enabled")).toBe("1"); + }); + + it("lets either medium come back on", () => { + const store = createPrefsStore(fakeStorage()); + store.getState().setShowsEnabled(false); + expect(store.getState()).toMatchObject({ showsEnabled: false, moviesEnabled: true }); + store.getState().setShowsEnabled(true); + expect(store.getState()).toMatchObject({ showsEnabled: true, moviesEnabled: true }); + }); +}); + +describe("persistence", () => { + it("writes every setter through, so a new store on the same storage restores it", () => { + const storage = fakeStorage(); + const first = createPrefsStore(storage).getState(); + first.setHapticsEnabled(false); + first.setRemindersEnabled(true); + first.setHideStillsUntilWatched(false); + first.setNextEpisodeOrder("after-last-watched"); + first.setLapsedOrder("longest-idle"); + first.setThresholdDays(14); + + expect(createPrefsStore(storage).getState()).toMatchObject({ + hapticsEnabled: false, + remindersEnabled: true, + hideStillsUntilWatched: false, + nextEpisodeOrder: "after-last-watched", + lapsedOrder: "longest-idle", + thresholdDays: 14, + }); + }); +}); diff --git a/packages/web/src/ui/components/TutorialCaption.tsx b/packages/web/src/ui/components/TutorialCaption.tsx index eb5f71d7..8cfbc40a 100644 --- a/packages/web/src/ui/components/TutorialCaption.tsx +++ b/packages/web/src/ui/components/TutorialCaption.tsx @@ -1,23 +1,13 @@ +import { booleanPref } from "@cue/core/prefs/pref-storage"; +import { preferenceStorage } from "@ui/prefs/preference-storage"; import type { ReactElement } from "react"; -const STORAGE_KEY = "cue.tutorial-mark-dismissed"; +const dismissedPref = booleanPref(preferenceStorage, "cue.tutorial-mark-dismissed", false); /** True once the one-time caption has been dismissed (by the first-ever mark). */ -export function initialTutorialDismissed(): boolean { - try { - return localStorage.getItem(STORAGE_KEY) === "1"; - } catch { - return true; - } -} +export const initialTutorialDismissed = dismissedPref.initial; -export function persistTutorialDismissed(): void { - try { - localStorage.setItem(STORAGE_KEY, "1"); - } catch { - // A restricted-storage failure re-shows the caption next visit: harmless. - } -} +export const persistTutorialDismissed = (): void => dismissedPref.persist(true); /** * The entire tutorial: one quiet first-session caption under the first queue diff --git a/packages/web/src/ui/hooks/queue-order.ts b/packages/web/src/ui/hooks/queue-order.ts index e497f593..9b6c7d14 100644 --- a/packages/web/src/ui/hooks/queue-order.ts +++ b/packages/web/src/ui/hooks/queue-order.ts @@ -1,6 +1,6 @@ import { toMs } from "@cue/core/domain/time"; import type { UpNextItem } from "@cue/core/domain/up-next"; -import type { LapsedOrder, NextEpisodeOrder } from "@ui/prefs/tracking"; +import type { LapsedOrder, NextEpisodeOrder } from "@cue/core/prefs/tracking"; function airMs(item: UpNextItem): number { // A provisional post-mark projection has no air date. Treating it as OLDEST diff --git a/packages/web/src/ui/hooks/useLibrarySnapshot.ts b/packages/web/src/ui/hooks/useLibrarySnapshot.ts index bbabf0d0..4fb701eb 100644 --- a/packages/web/src/ui/hooks/useLibrarySnapshot.ts +++ b/packages/web/src/ui/hooks/useLibrarySnapshot.ts @@ -6,11 +6,11 @@ import { toEpisodeRef, } from "@cue/core/data/trakt/show-detail"; import { needsNextEpisode, reconcileRecentlyAired } from "@cue/core/domain/recently-aired"; +import { thresholdMsFromDays } from "@cue/core/prefs/threshold"; import { queryOptions, type UseQueryResult, useQueries, useQuery } from "@tanstack/react-query"; import { CONTENT_STALE_TIME_MS, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; import { useRecentlyAired } from "@ui/hooks/useCalendar"; import { usePrefs } from "@ui/prefs/prefs-store"; -import { thresholdMsFromDays } from "@ui/prefs/threshold"; import { type CueRuntime, type UpNextData, useRuntime } from "@ui/runtime/runtime"; import { useMemo } from "react"; diff --git a/packages/web/src/ui/prefs/pref-storage.ts b/packages/web/src/ui/prefs/pref-storage.ts deleted file mode 100644 index 3c6ab02a..00000000 --- a/packages/web/src/ui/prefs/pref-storage.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * The two shapes every device-local preference takes, so the read/write pair and - * its restricted-storage fallback are written once rather than per preference. - * `localStorage` throws outright in some privacy modes, and a preference that - * cannot be remembered must still resolve to its principled default. - */ - -function write(key: string, value: string): void { - try { - localStorage.setItem(key, value); - } catch { - // A restricted-storage failure just forgets the choice next visit: non-fatal. - } -} - -function read(key: string): string | null { - try { - return localStorage.getItem(key); - } catch { - return null; - } -} - -interface Pref { - initial(): T; - persist(value: T): void; -} - -/** A stored "1"/"0" flag; an absent key reads as `fallback`. */ -export function booleanPref(key: string, fallback: boolean): Pref { - return { - initial: () => { - const stored = read(key); - return stored === null ? fallback : stored === "1"; - }, - persist: (value) => write(key, value ? "1" : "0"), - }; -} - -/** A stored member of `options`; anything else (absent, stale, corrupt) reads as - * `fallback`. Numbers store as their decimal text, so an option list can be - * either words or figures. */ -export function choicePref( - key: string, - options: readonly T[], - fallback: T, -): Pref { - return { - initial: () => options.find((option) => String(option) === read(key)) ?? fallback, - persist: (value) => write(key, String(value)), - }; -} diff --git a/packages/web/src/ui/prefs/preference-storage.ts b/packages/web/src/ui/prefs/preference-storage.ts new file mode 100644 index 00000000..596d945e --- /dev/null +++ b/packages/web/src/ui/prefs/preference-storage.ts @@ -0,0 +1,29 @@ +import type { PreferenceStorage } from "@cue/core/ports/preference-storage"; + +/** + * `PreferenceStorage` over `localStorage`, which throws outright in some privacy + * modes. The port does not throw, so the degradation is absorbed here: a + * preference that cannot be read resolves to its principled default, and one + * that cannot be written is simply forgotten next visit. + * + * It lives beside the preferences rather than in `src/platform` because it is + * read at module scope, before anything React could inject, and because + * `localStorage` is a browser global rather than a native bridge: nothing in + * `src/ui` needs a Capacitor mock to run it. + */ +export const preferenceStorage: PreferenceStorage = { + getItem(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + setItem(key, value) { + try { + localStorage.setItem(key, value); + } catch { + // A restricted-storage failure just forgets the choice next visit: non-fatal. + } + }, +}; diff --git a/packages/web/src/ui/prefs/prefs-store.ts b/packages/web/src/ui/prefs/prefs-store.ts index 64c35c04..d8be2833 100644 --- a/packages/web/src/ui/prefs/prefs-store.ts +++ b/packages/web/src/ui/prefs/prefs-store.ts @@ -1,101 +1,5 @@ -import { create } from "zustand"; -import { hapticsPref, remindersPref } from "./device-prefs"; -import { - initialMediaVisibility, - type MediaVisibility, - persistMediaVisibility, -} from "./media-visibility"; -import { thresholdPref } from "./threshold"; -import { - hideStillsPref, - type LapsedOrder, - lapsedOrderPref, - type NextEpisodeOrder, - nextEpisodeOrderPref, -} from "./tracking"; +import { createPrefsStore } from "@cue/core/prefs/prefs-store"; +import { preferenceStorage } from "./preference-storage"; -interface PrefsState { - /** Days of inactivity before a show falls from Watching to Not-watched-in-a-while. */ - thresholdDays: number; - setThresholdDays: (days: number) => void; - /** Media-visibility: a device-local pref so a single-medium user is - * never shown the other. Both ON by default; disabling the last one is refused. */ - showsEnabled: boolean; - moviesEnabled: boolean; - setShowsEnabled: (enabled: boolean) => void; - setMoviesEnabled: (enabled: boolean) => void; - /** The one buzz on a mark/undo/armed pull: ON by default, device-local, - * read by the injected haptics seam at fire time. Silent no-op on web regardless. */ - hapticsEnabled: boolean; - setHapticsEnabled: (enabled: boolean) => void; - /** The daily "airing today" digest: OFF by default, flipped on only after the - * OS notification permission is granted in context. */ - remindersEnabled: boolean; - setRemindersEnabled: (enabled: boolean) => void; - /** Spoiler guard: blur unwatched episode stills until revealed. Default ON. */ - hideStillsUntilWatched: boolean; - setHideStillsUntilWatched: (enabled: boolean) => void; - /** How the Up Next queue orders shows: oldest waiting episode first (default) - * or the user's own last-watched recency. */ - nextEpisodeOrder: NextEpisodeOrder; - setNextEpisodeOrder: (order: NextEpisodeOrder) => void; - /** How the Haven't watched lately drawer orders shows: most recently watched first - * (default) or longest idle first. */ - lapsedOrder: LapsedOrder; - setLapsedOrder: (order: LapsedOrder) => void; -} - -/** - * Local display preferences: mirrors - * `theme-store`: the staleness threshold that splits the Watching pile (and Up - * Next) from Not-watched-in-a-while, and the TV/Movies visibility toggles. - * Persisted to `localStorage`, never Trakt-synced; both revert to their principled - * defaults (21 days, both media on) on a new device. - */ -export const usePrefs = create((set, get) => { - const media = initialMediaVisibility(); - // One commit path enforces the single invariant: the app is never emptied of - // both media: so a setter that would turn off the last-enabled medium no-ops. - const commit = (next: MediaVisibility): void => { - if (!next.showsEnabled && !next.moviesEnabled) return; - persistMediaVisibility(next); - set(next); - }; - return { - thresholdDays: thresholdPref.initial(), - setThresholdDays: (thresholdDays) => { - thresholdPref.persist(thresholdDays); - set({ thresholdDays }); - }, - showsEnabled: media.showsEnabled, - moviesEnabled: media.moviesEnabled, - setShowsEnabled: (showsEnabled) => commit({ showsEnabled, moviesEnabled: get().moviesEnabled }), - setMoviesEnabled: (moviesEnabled) => - commit({ showsEnabled: get().showsEnabled, moviesEnabled }), - hapticsEnabled: hapticsPref.initial(), - setHapticsEnabled: (hapticsEnabled) => { - hapticsPref.persist(hapticsEnabled); - set({ hapticsEnabled }); - }, - remindersEnabled: remindersPref.initial(), - setRemindersEnabled: (remindersEnabled) => { - remindersPref.persist(remindersEnabled); - set({ remindersEnabled }); - }, - hideStillsUntilWatched: hideStillsPref.initial(), - setHideStillsUntilWatched: (hideStillsUntilWatched) => { - hideStillsPref.persist(hideStillsUntilWatched); - set({ hideStillsUntilWatched }); - }, - nextEpisodeOrder: nextEpisodeOrderPref.initial(), - setNextEpisodeOrder: (nextEpisodeOrder) => { - nextEpisodeOrderPref.persist(nextEpisodeOrder); - set({ nextEpisodeOrder }); - }, - lapsedOrder: lapsedOrderPref.initial(), - setLapsedOrder: (lapsedOrder) => { - lapsedOrderPref.persist(lapsedOrder); - set({ lapsedOrder }); - }, - }; -}); +/** The web app's instance of the shared preferences store. */ +export const usePrefs = createPrefsStore(preferenceStorage); diff --git a/packages/web/src/ui/screens/library/Library.tsx b/packages/web/src/ui/screens/library/Library.tsx index 0623420d..dc77aea2 100644 --- a/packages/web/src/ui/screens/library/Library.tsx +++ b/packages/web/src/ui/screens/library/Library.tsx @@ -3,6 +3,7 @@ import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; import type { LibrarySort } from "@cue/core/domain/library-buckets"; import { epCode } from "@cue/core/domain/model/library"; import { isAired } from "@cue/core/domain/time"; +import { choicePref } from "@cue/core/prefs/pref-storage"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; @@ -19,6 +20,7 @@ import { type LibraryChipKey, useLibraryBuckets } from "@ui/hooks/useLibraryBuck import { useMarkWatched } from "@ui/hooks/useMarkWatched"; import { useMovieActions } from "@ui/hooks/useMovieActions"; import { type MovieSort, useMovieLibrary } from "@ui/hooks/useMovieLibrary"; +import { preferenceStorage } from "@ui/prefs/preference-storage"; import { usePrefs } from "@ui/prefs/prefs-store"; import { ArrowUpDown, Check, Search as SearchIcon } from "lucide-react"; import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; @@ -49,8 +51,13 @@ const SHOW_CHIP_KEYS: readonly LibraryChipKey[] = ["watching", "watchlist", "sto const MOVIE_CHIP_KEYS: readonly MovieChipKey[] = ["watchlist", "watched"]; /** Exactly one chip is active; the last choice is remembered per segment. */ -const SHOW_CHIP_STORAGE = "cue.library-chip"; -const MOVIE_CHIP_STORAGE = "cue.library-movie-chip"; +const showChipPref = choicePref(preferenceStorage, "cue.library-chip", SHOW_CHIP_KEYS, "watching"); +const movieChipPref = choicePref( + preferenceStorage, + "cue.library-movie-chip", + MOVIE_CHIP_KEYS, + "watchlist", +); const SHOW_CHIP_LABEL: Record = { watching: "Watching", @@ -78,23 +85,6 @@ const MOVIE_CHIP_EMPTY: Record = { */ const FILTER_DEBOUNCE_MS = 300; -function readChip(storageKey: string, valid: readonly T[]): T | null { - try { - const raw = localStorage.getItem(storageKey); - return valid.find((key) => key === raw) ?? null; - } catch { - return null; - } -} - -function persistChip(storageKey: string, value: string): void { - try { - localStorage.setItem(storageKey, value); - } catch { - // A private-mode storage failure just forgets the chip next visit: non-fatal. - } -} - /** * Library: one screen frame shared by Shows and Movies. A Shows⇄Movies * segment (URL `?type`), a tap-to-reveal title filter, a per-segment sort @@ -118,12 +108,8 @@ export function Library(): ReactElement { const [showSort, setShowSort] = useState("recently-watched"); const [movieSort, setMovieSort] = useState("recently-watched"); - const [showChip, setShowChip] = useState( - () => readChip(SHOW_CHIP_STORAGE, SHOW_CHIP_KEYS) ?? "watching", - ); - const [movieChip, setMovieChip] = useState( - () => readChip(MOVIE_CHIP_STORAGE, MOVIE_CHIP_KEYS) ?? "watchlist", - ); + const [showChip, setShowChip] = useState(showChipPref.initial); + const [movieChip, setMovieChip] = useState(movieChipPref.initial); const [filterOpen, setFilterOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false); const [query, setQuery] = useState(""); @@ -523,7 +509,7 @@ export function Library(): ReactElement { testId={`chip-${key}`} onPress={() => { setMovieChip(key); - persistChip(MOVIE_CHIP_STORAGE, key); + movieChipPref.persist(key); }} /> )) @@ -537,7 +523,7 @@ export function Library(): ReactElement { testId={`chip-${key}`} onPress={() => { setShowChip(key); - persistChip(SHOW_CHIP_STORAGE, key); + showChipPref.persist(key); }} /> ))} diff --git a/packages/web/src/ui/screens/profile/Profile.tsx b/packages/web/src/ui/screens/profile/Profile.tsx index b16fd2ec..495954a3 100644 --- a/packages/web/src/ui/screens/profile/Profile.tsx +++ b/packages/web/src/ui/screens/profile/Profile.tsx @@ -1,6 +1,7 @@ import type { UserStats } from "@cue/core/data/trakt/schemas"; import type { UserProfile } from "@cue/core/data/trakt/user-profile"; import { humanizeWatchMinutes } from "@cue/core/domain/time"; +import type { MediaVisibility } from "@cue/core/prefs/media-visibility"; import { Link } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; @@ -9,7 +10,6 @@ import { ErrorRetry } from "@ui/components/ErrorStates"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { useStats } from "@ui/hooks/useStats"; import { useUserProfile } from "@ui/hooks/useUserProfile"; -import type { MediaVisibility } from "@ui/prefs/media-visibility"; import { usePrefs } from "@ui/prefs/prefs-store"; import { SignOutRow } from "@ui/screens/settings/SignOutRow"; import { diff --git a/packages/web/src/ui/screens/profile/stats.ts b/packages/web/src/ui/screens/profile/stats.ts index 1dec5e65..f182cff1 100644 --- a/packages/web/src/ui/screens/profile/stats.ts +++ b/packages/web/src/ui/screens/profile/stats.ts @@ -1,5 +1,5 @@ import type { UserStats } from "@cue/core/data/trakt/schemas"; -import type { MediaVisibility } from "@ui/prefs/media-visibility"; +import type { MediaVisibility } from "@cue/core/prefs/media-visibility"; interface CountTile { readonly key: string; diff --git a/packages/web/src/ui/screens/settings/Settings.tsx b/packages/web/src/ui/screens/settings/Settings.tsx index 86297def..5f0b1b43 100644 --- a/packages/web/src/ui/screens/settings/Settings.tsx +++ b/packages/web/src/ui/screens/settings/Settings.tsx @@ -1,15 +1,15 @@ -import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; -import { ActionSheet } from "@ui/components/ActionSheet"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { THRESHOLD_OPTIONS } from "@ui/prefs/threshold"; +import { THRESHOLD_OPTIONS } from "@cue/core/prefs/threshold"; import { LAPSED_ORDER_OPTIONS, type LapsedOrder, NEXT_EPISODE_ORDER_OPTIONS, type NextEpisodeOrder, -} from "@ui/prefs/tracking"; +} from "@cue/core/prefs/tracking"; +import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; +import { ActionSheet } from "@ui/components/ActionSheet"; +import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; +import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; +import { usePrefs } from "@ui/prefs/prefs-store"; import { useAppVersion } from "@ui/runtime/app-version"; import { useReminders } from "@ui/runtime/reminders"; import { ThemeToggle } from "@ui/theme/ThemeToggle"; diff --git a/packages/web/test/ui/media-visibility.test.ts b/packages/web/test/ui/media-visibility.test.ts deleted file mode 100644 index 15a9c98b..00000000 --- a/packages/web/test/ui/media-visibility.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { navFor } from "@ui/app-shell/nav"; -import { - initialMediaVisibility, - persistMediaVisibility, - resolveMediaVisibility, -} from "@ui/prefs/media-visibility"; -import { beforeEach, describe, expect, it } from "vitest"; - -beforeEach(() => { - localStorage.clear(); -}); - -describe("resolveMediaVisibility", () => { - it("keeps both media on by default", () => { - expect(resolveMediaVisibility(true, true)).toEqual({ showsEnabled: true, moviesEnabled: true }); - }); - - it("preserves a single-medium choice", () => { - expect(resolveMediaVisibility(true, false)).toEqual({ - showsEnabled: true, - moviesEnabled: false, - }); - expect(resolveMediaVisibility(false, true)).toEqual({ - showsEnabled: false, - moviesEnabled: true, - }); - }); - - it("heals a corrupt both-off pair back to both-on (never an empty app)", () => { - expect(resolveMediaVisibility(false, false)).toEqual({ - showsEnabled: true, - moviesEnabled: true, - }); - }); -}); - -describe("initialMediaVisibility", () => { - it("defaults to both-on on a fresh device (no stored keys)", () => { - expect(initialMediaVisibility()).toEqual({ showsEnabled: true, moviesEnabled: true }); - }); - - it("reads a persisted single-medium choice back", () => { - persistMediaVisibility({ showsEnabled: true, moviesEnabled: false }); - expect(initialMediaVisibility()).toEqual({ showsEnabled: true, moviesEnabled: false }); - - persistMediaVisibility({ showsEnabled: false, moviesEnabled: true }); - expect(initialMediaVisibility()).toEqual({ showsEnabled: false, moviesEnabled: true }); - }); - - it("keeps a medium whose key was never written on", () => { - // Only one of the pair was ever persisted, e.g. a store written by a build - // that had no movies toggle yet. - localStorage.setItem("cue.shows-enabled", "1"); - expect(initialMediaVisibility()).toEqual({ showsEnabled: true, moviesEnabled: true }); - }); - - it("heals a both-off store rather than boot empty", () => { - localStorage.setItem("cue.shows-enabled", "0"); - localStorage.setItem("cue.movies-enabled", "0"); - expect(initialMediaVisibility()).toEqual({ showsEnabled: true, moviesEnabled: true }); - }); -}); - -describe("navFor", () => { - it("maps the four tabs while TV is enabled (default + TV-only)", () => { - expect(navFor({ showsEnabled: true }).map((d) => d.path)).toEqual([ - "/", - "/library", - "/calendar", - "/search", - ]); - }); - - it("sheds the episodic Up Next AND Calendar for a movies-only app", () => { - expect(navFor({ showsEnabled: false }).map((d) => d.path)).toEqual(["/library", "/search"]); - }); -}); diff --git a/packages/web/test/ui/nav.test.ts b/packages/web/test/ui/nav.test.ts new file mode 100644 index 00000000..e39ba1fb --- /dev/null +++ b/packages/web/test/ui/nav.test.ts @@ -0,0 +1,17 @@ +import { navFor } from "@ui/app-shell/nav"; +import { describe, expect, it } from "vitest"; + +describe("navFor", () => { + it("maps the four tabs while TV is enabled (default + TV-only)", () => { + expect(navFor({ showsEnabled: true }).map((d) => d.path)).toEqual([ + "/", + "/library", + "/calendar", + "/search", + ]); + }); + + it("sheds the episodic Up Next AND Calendar for a movies-only app", () => { + expect(navFor({ showsEnabled: false }).map((d) => d.path)).toEqual(["/library", "/search"]); + }); +}); diff --git a/packages/web/test/ui/pref-storage.test.ts b/packages/web/test/ui/pref-storage.test.ts deleted file mode 100644 index 0627a44a..00000000 --- a/packages/web/test/ui/pref-storage.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * The one read/write pair every device-local preference is built from. What it - * owes each of them: an absent key reads as the principled default rather than - * off/zero, a value that is no longer an option is ignored rather than restored, - * and a store that refuses to answer never takes the app down with it. - */ -import { booleanPref, choicePref } from "@ui/prefs/pref-storage"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -beforeEach(() => { - localStorage.clear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("booleanPref", () => { - const spoilers = booleanPref("cue.test-flag", true); - - it("reads an absent key as its fallback, not as off", () => { - expect(spoilers.initial()).toBe(true); - expect(booleanPref("cue.test-other", false).initial()).toBe(false); - }); - - it("round-trips a choice either way", () => { - spoilers.persist(false); - expect(spoilers.initial()).toBe(false); - spoilers.persist(true); - expect(spoilers.initial()).toBe(true); - }); -}); - -describe("choicePref", () => { - const order = choicePref("cue.test-order", ["oldest", "recent"] as const, "oldest"); - const days = choicePref("cue.test-days", [14, 21, 28] as const, 21); - - it("round-trips a member of its option list", () => { - order.persist("recent"); - expect(order.initial()).toBe("recent"); - }); - - it("stores a numeric option as its figure and reads it back as a number", () => { - days.persist(28); - expect(localStorage.getItem("cue.test-days")).toBe("28"); - expect(days.initial()).toBe(28); - }); - - it("falls back rather than restoring a value the option list no longer holds", () => { - // A build that dropped an option, or a hand-edited store. - localStorage.setItem("cue.test-order", "by-title"); - localStorage.setItem("cue.test-days", "35"); - expect(order.initial()).toBe("oldest"); - expect(days.initial()).toBe(21); - }); -}); - -describe("a store that refuses to answer", () => { - it("resolves to the default and swallows the failed write", () => { - // Some privacy modes throw outright on both rather than storing nothing. - vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { - throw new Error("restricted"); - }); - vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { - throw new Error("restricted"); - }); - const haptics = booleanPref("cue.test-flag", true); - - expect(() => haptics.persist(false)).not.toThrow(); - expect(haptics.initial()).toBe(true); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f193ea10..799b8f43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: zod: specifier: ^4.4.3 version: 4.4.3 + zustand: + specifier: 5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: typescript: specifier: ^6.0.3 diff --git a/vitest.config.ts b/vitest.config.ts index 12c7b6dc..4c3a8036 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ branches: 60, "packages/core/src/domain/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, "packages/core/src/data/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/core/src/prefs/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, }, }, }, From d43bc7281e0e9ea7aeb5dd6c2752cc1c7967c1fa Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 19:52:43 -0500 Subject: [PATCH 005/435] Move the runtime, the auth store and the hook layer into @cue/core 50 more files move and 62 are rewritten. @cue/core is now 11,038 lines, and the web app is a consumer of it: the CueRuntime port and createCueRuntime, the boot effect, session teardown, the query-cache policy, the auth store and its phase selector, three of the four zustand stores, the two URL parsers, and 39 of the 42 hooks. useFlip, useDocumentTitle and useShowArt stay, because each is a browser API wearing a hook. createCueRuntime took three couplings out of the composition root by importing them. All three are dependencies now: - TRAKT_CLIENT_ID and TRAKT_BASE_OVERRIDE from @app/config become clientId and apiBaseUrl, so nothing in the core reads a build environment. - queryClient and queryPersister become one dependency, clearPersistedCaches(). Teardown called exactly one method on each, so handing two library objects across the boundary would have made the core know their types for nothing. - The localStorage walk for `cue.` keys becomes clearLocalPreferences(), over PreferenceStorage, which gains clearNamespace for it. Two more seams the gates found rather than the plan. useActivitiesPoll read document.visibilityState and the window online event, and useIsOffline read navigator.onLine; both now take AppVisibility and Network, injected the way haptics and reminders already were, with the web implementations in src/platform. And createAuthStore stashed the OAuth state nonce and the PKCE verifier in sessionStorage: biome's noRestrictedGlobals over packages/core caught it, and it is a real seam rather than an API difference, because what the authorization-code flow needs is somewhere to survive a full-page navigation. RedirectHandoff names that, and a target with no page navigation has no handoff to make. usePrefs becomes a selector over a provided store, the shape the auth store has always had, because three of the moved hooks read preferences and the store is built per app from that app's storage. Its context default is a store over a storage that forgets, so a component mounted without a composition root reads every preference at its default, which is what the haptics and reminders ports already do. The three callers that are not components, the two router guards and the haptics gate, hold the web instance directly. Two things deliberately left behind. The theme store stays in the web app: it is the fourth zustand store, but every line of theme.ts under it is document, matchMedia and localStorage, and the port that would fix that is not in the architecture's port set. useShowArt keeps its `ref: (node: Element | null)` surface: the change to a `visible` boolean exists so Element stops leaking into consumers that move, and all four of its consumers are web components that stay. The shape witness is 94b50333a5bf, and it moved because the tree it is taken over did: `runtime` now holds the composition root, which used to live in src/app. The four modules that were in it before are byte-identical to the branch point, b10eeeb884bb on both, so no persisted shape changed and PERSIST_BUSTER stays 4d3253e9dc61. The built bundle still carries it. Coverage gains four rows. url, prefs and stores carry the 90/90/90/80 the domain and data layers do. The hook layer gets a ratchet at what it measured here, 53/42/52/54, because 4,500 lines of it have never been inside a coverage number and a threshold it has never met would be a wish rather than a gate. The runtime is excluded, as src/app always was, because Playwright gates it. --- .../src}/auth/create-auth-store.ts | 34 +++---- .../{web/src/ui => core/src}/auth/store.ts | 0 .../ui => core/src}/hooks/apply-reconcile.ts | 2 +- .../ui => core/src}/hooks/library-cache.ts | 10 +- .../ui => core/src}/hooks/mark-undo-window.ts | 0 .../ui => core/src}/hooks/query-freshness.ts | 0 .../src/ui => core/src}/hooks/queue-order.ts | 6 +- .../ui => core/src}/hooks/resolveUnmark.ts | 4 +- .../src}/hooks/useActivitiesPoll.ts | 36 ++++--- .../src/ui => core/src}/hooks/useBrowse.ts | 8 +- .../src/ui => core/src}/hooks/useCalendar.ts | 10 +- .../ui => core/src}/hooks/useDetailHeader.ts | 2 +- .../src/ui => core/src}/hooks/useEpisode.ts | 8 +- .../ui => core/src}/hooks/useEpisodePlays.ts | 4 +- .../src}/hooks/useEpisodeReminders.ts | 8 +- .../src/ui => core/src}/hooks/useHideShow.ts | 8 +- .../src/ui => core/src}/hooks/useHistory.ts | 20 ++-- packages/core/src/hooks/useIsOffline.ts | 8 ++ .../src}/hooks/useLibraryBuckets.ts | 12 +-- .../src}/hooks/useLibrarySnapshot.ts | 22 ++--- .../ui => core/src}/hooks/useMarkSeason.ts | 28 +++--- .../ui => core/src}/hooks/useMarkSnacks.ts | 4 +- .../ui => core/src}/hooks/useMarkWatched.ts | 33 +++---- .../ui => core/src}/hooks/useMovieActions.ts | 26 ++--- .../ui => core/src}/hooks/useMovieDetail.ts | 8 +- .../ui => core/src}/hooks/useMovieLibrary.ts | 10 +- .../ui => core/src}/hooks/useMovieRelated.ts | 8 +- .../src}/hooks/useOptimisticWrite.ts | 6 +- .../ui => core/src}/hooks/useQueuedWrite.ts | 4 +- .../ui => core/src}/hooks/useRemovalSnacks.ts | 6 +- .../ui => core/src}/hooks/useResumeOnMark.ts | 8 +- .../src/ui => core/src}/hooks/useSearch.ts | 6 +- .../src}/hooks/useSeasonReversal.ts | 0 .../src/ui => core/src}/hooks/useSeasons.ts | 8 +- .../ui => core/src}/hooks/useShowDetail.ts | 10 +- .../src/ui => core/src}/hooks/useStats.ts | 8 +- .../src/ui => core/src}/hooks/useSyncNow.ts | 6 +- .../src}/hooks/useToggleWatchlist.ts | 14 +-- .../src/ui => core/src}/hooks/useUpNext.ts | 12 +-- .../ui => core/src}/hooks/useUserProfile.ts | 8 +- .../ui => core/src}/hooks/useWatchlistAdd.ts | 12 +-- packages/core/src/ports/preference-storage.ts | 5 + packages/core/src/ports/redirect-handoff.ts | 24 +++++ packages/core/src/prefs/prefs-store.ts | 39 +++++++- .../ui => core/src}/runtime/app-version.ts | 0 packages/core/src/runtime/app-visibility.ts | 27 +++++ packages/core/src/runtime/boot.ts | 98 +++++++++++++++++++ .../src}/runtime/create-runtime.ts | 91 +++++++++-------- .../src/ui => core/src}/runtime/haptics.ts | 2 +- packages/core/src/runtime/network.ts | 27 +++++ .../ui => core/src}/runtime/persist-buster.ts | 2 +- packages/core/src/runtime/query-cache.ts | 95 ++++++++++++++++++ .../src/ui => core/src}/runtime/reminders.ts | 2 +- .../src/ui => core/src}/runtime/runtime.ts | 24 ++--- .../src/app => core/src/runtime}/session.ts | 0 .../hooks => core/src/stores}/mark-store.ts | 4 +- .../src/stores}/snackbar-store.ts | 0 .../src/stores}/sync-activity-store.ts | 0 packages/core/src/url/search-params.ts | 35 +++++++ packages/core/test/prefs/_storage.ts | 3 + packages/web/src/app/AuthGate.tsx | 2 +- packages/web/src/app/providers.tsx | 39 +++++--- packages/web/src/app/query-client.ts | 88 +++-------------- packages/web/src/app/router.tsx | 30 ++---- .../web/src/app/routes/auth-callback.lazy.tsx | 2 +- packages/web/src/app/runtime/RuntimeBoot.tsx | 79 +++++---------- packages/web/src/platform/app-visibility.ts | 10 ++ packages/web/src/platform/network.ts | 14 +++ packages/web/src/platform/redirect-handoff.ts | 23 +++++ packages/web/src/platform/reminders.ts | 6 +- packages/web/src/ui/app-shell/RootLayout.tsx | 7 +- packages/web/src/ui/app-shell/SyncStrip.tsx | 6 +- .../web/src/ui/components/AppSnackbar.tsx | 2 +- .../web/src/ui/components/ContextMenu.tsx | 2 +- .../web/src/ui/components/MarqueeCard.tsx | 2 +- .../web/src/ui/components/PullToRefresh.tsx | 4 +- packages/web/src/ui/components/Sheet.tsx | 2 +- .../web/src/ui/components/SwipeAction.tsx | 2 +- packages/web/src/ui/hooks/useIsOffline.ts | 15 --- packages/web/src/ui/hooks/useShowArt.ts | 4 +- .../web/src/ui/prefs/preference-storage.ts | 18 ++++ packages/web/src/ui/prefs/prefs-store.ts | 9 +- .../web/src/ui/screens/calendar/Calendar.tsx | 2 +- .../screens/episode-detail/EpisodeSheet.tsx | 26 ++--- .../web/src/ui/screens/history/History.tsx | 6 +- .../web/src/ui/screens/library/Library.tsx | 15 +-- .../web/src/ui/screens/library/ShowTile.tsx | 2 +- .../ui/screens/movie-detail/MovieDetail.tsx | 12 +-- .../src/ui/screens/onboarding/Onboarding.tsx | 2 +- .../web/src/ui/screens/profile/Profile.tsx | 6 +- packages/web/src/ui/screens/search/Search.tsx | 10 +- .../web/src/ui/screens/settings/Settings.tsx | 8 +- .../src/ui/screens/settings/SignOutRow.tsx | 2 +- .../src/ui/screens/settings/useSyncStatus.ts | 6 +- .../ui/screens/show-detail/ContinueBar.tsx | 4 +- .../src/ui/screens/show-detail/SeasonList.tsx | 2 +- .../src/ui/screens/show-detail/ShowDetail.tsx | 24 +++-- .../ui/screens/show-detail/detail-logic.ts | 2 +- .../src/ui/screens/up-next/LapsedDrawer.tsx | 4 +- .../web/src/ui/screens/up-next/Previously.tsx | 6 +- .../web/src/ui/screens/up-next/QueueRow.tsx | 6 +- .../web/src/ui/screens/up-next/UpNext.tsx | 10 +- .../src/ui/screens/up-next/useQueueCheck.ts | 4 +- packages/web/test/data/read-budget.test.ts | 5 +- packages/web/test/privacy-claims.test.ts | 2 +- .../test/support/composition-root-mocks.tsx | 2 +- packages/web/test/ui/activities-poll.test.tsx | 60 ++++++++---- .../test/ui/detail-unmark-resolution.test.ts | 4 +- .../web/test/ui/episode-reminders.test.tsx | 40 +++++--- packages/web/test/ui/library-chips.test.ts | 2 +- .../web/test/ui/library-snapshot.test.tsx | 6 +- packages/web/test/ui/mark-pipeline.test.tsx | 16 +-- packages/web/test/ui/mark-store.test.ts | 4 +- packages/web/test/ui/mark-undo-window.test.ts | 2 +- .../web/test/ui/onboarding-screen.test.tsx | 2 +- packages/web/test/ui/optimistic-write.test.ts | 4 +- packages/web/test/ui/pull-to-refresh.test.tsx | 4 +- packages/web/test/ui/queue-order.test.ts | 2 +- .../web/test/ui/resolve-movie-unmark.test.ts | 4 +- .../web/test/ui/search-visibility.test.ts | 2 +- .../web/test/ui/sync-strip-pending.test.tsx | 3 +- packages/web/test/ui/use-calendar.test.tsx | 6 +- packages/web/test/ui/use-sync-status.test.tsx | 4 +- packages/web/test/ui/watchlist-add.test.tsx | 4 +- packages/web/test/url/search-params.test.ts | 52 ++++++++++ scripts/write-buster.mjs | 4 +- vitest.config.ts | 14 ++- 127 files changed, 1048 insertions(+), 630 deletions(-) rename packages/{web/src/app => core/src}/auth/create-auth-store.ts (89%) rename packages/{web/src/ui => core/src}/auth/store.ts (100%) rename packages/{web/src/ui => core/src}/hooks/apply-reconcile.ts (94%) rename packages/{web/src/ui => core/src}/hooks/library-cache.ts (91%) rename packages/{web/src/ui => core/src}/hooks/mark-undo-window.ts (100%) rename packages/{web/src/ui => core/src}/hooks/query-freshness.ts (100%) rename packages/{web/src/ui => core/src}/hooks/queue-order.ts (94%) rename packages/{web/src/ui => core/src}/hooks/resolveUnmark.ts (98%) rename packages/{web/src/ui => core/src}/hooks/useActivitiesPoll.ts (70%) rename packages/{web/src/ui => core/src}/hooks/useBrowse.ts (87%) rename packages/{web/src/ui => core/src}/hooks/useCalendar.ts (96%) rename packages/{web/src/ui => core/src}/hooks/useDetailHeader.ts (93%) rename packages/{web/src/ui => core/src}/hooks/useEpisode.ts (78%) rename packages/{web/src/ui => core/src}/hooks/useEpisodePlays.ts (93%) rename packages/{web/src/ui => core/src}/hooks/useEpisodeReminders.ts (88%) rename packages/{web/src/ui => core/src}/hooks/useHideShow.ts (94%) rename packages/{web/src/ui => core/src}/hooks/useHistory.ts (96%) create mode 100644 packages/core/src/hooks/useIsOffline.ts rename packages/{web/src/ui => core/src}/hooks/useLibraryBuckets.ts (88%) rename packages/{web/src/ui => core/src}/hooks/useLibrarySnapshot.ts (84%) rename packages/{web/src/ui => core/src}/hooks/useMarkSeason.ts (98%) rename packages/{web/src/ui => core/src}/hooks/useMarkSnacks.ts (95%) rename packages/{web/src/ui => core/src}/hooks/useMarkWatched.ts (95%) rename packages/{web/src/ui => core/src}/hooks/useMovieActions.ts (95%) rename packages/{web/src/ui => core/src}/hooks/useMovieDetail.ts (68%) rename packages/{web/src/ui => core/src}/hooks/useMovieLibrary.ts (94%) rename packages/{web/src/ui => core/src}/hooks/useMovieRelated.ts (80%) rename packages/{web/src/ui => core/src}/hooks/useOptimisticWrite.ts (95%) rename packages/{web/src/ui => core/src}/hooks/useQueuedWrite.ts (91%) rename packages/{web/src/ui => core/src}/hooks/useRemovalSnacks.ts (89%) rename packages/{web/src/ui => core/src}/hooks/useResumeOnMark.ts (90%) rename packages/{web/src/ui => core/src}/hooks/useSearch.ts (95%) rename packages/{web/src/ui => core/src}/hooks/useSeasonReversal.ts (100%) rename packages/{web/src/ui => core/src}/hooks/useSeasons.ts (78%) rename packages/{web/src/ui => core/src}/hooks/useShowDetail.ts (82%) rename packages/{web/src/ui => core/src}/hooks/useStats.ts (81%) rename packages/{web/src/ui => core/src}/hooks/useSyncNow.ts (90%) rename packages/{web/src/ui => core/src}/hooks/useToggleWatchlist.ts (87%) rename packages/{web/src/ui => core/src}/hooks/useUpNext.ts (93%) rename packages/{web/src/ui => core/src}/hooks/useUserProfile.ts (74%) rename packages/{web/src/ui => core/src}/hooks/useWatchlistAdd.ts (95%) create mode 100644 packages/core/src/ports/redirect-handoff.ts rename packages/{web/src/ui => core/src}/runtime/app-version.ts (100%) create mode 100644 packages/core/src/runtime/app-visibility.ts create mode 100644 packages/core/src/runtime/boot.ts rename packages/{web/src/app => core/src}/runtime/create-runtime.ts (87%) rename packages/{web/src/ui => core/src}/runtime/haptics.ts (87%) create mode 100644 packages/core/src/runtime/network.ts rename packages/{web/src/ui => core/src}/runtime/persist-buster.ts (97%) create mode 100644 packages/core/src/runtime/query-cache.ts rename packages/{web/src/ui => core/src}/runtime/reminders.ts (86%) rename packages/{web/src/ui => core/src}/runtime/runtime.ts (91%) rename packages/{web/src/app => core/src/runtime}/session.ts (100%) rename packages/{web/src/ui/hooks => core/src/stores}/mark-store.ts (97%) rename packages/{web/src/ui/components => core/src/stores}/snackbar-store.ts (100%) rename packages/{web/src/ui/hooks => core/src/stores}/sync-activity-store.ts (100%) create mode 100644 packages/core/src/url/search-params.ts create mode 100644 packages/web/src/platform/app-visibility.ts create mode 100644 packages/web/src/platform/network.ts create mode 100644 packages/web/src/platform/redirect-handoff.ts delete mode 100644 packages/web/src/ui/hooks/useIsOffline.ts create mode 100644 packages/web/test/url/search-params.test.ts diff --git a/packages/web/src/app/auth/create-auth-store.ts b/packages/core/src/auth/create-auth-store.ts similarity index 89% rename from packages/web/src/app/auth/create-auth-store.ts rename to packages/core/src/auth/create-auth-store.ts index fd2eb2b5..8b280486 100644 --- a/packages/web/src/app/auth/create-auth-store.ts +++ b/packages/core/src/auth/create-auth-store.ts @@ -1,4 +1,4 @@ -import { PendingWritesError, sessionTeardown } from "@app/session"; +import { createStore } from "zustand/vanilla"; import { buildAuthorizeUrl, exchangeCodeForToken, @@ -6,17 +6,13 @@ import { pollDeviceToken, requestDeviceCode, revokeToken, -} from "@cue/core/data/auth/oauth"; -import { createPkcePair } from "@cue/core/data/auth/pkce"; -import type { Token } from "@cue/core/domain/model/token"; -import type { TokenStore } from "@cue/core/ports/token-store"; -import type { AuthActions, AuthState, AuthStore } from "@ui/auth/store"; -import { createStore } from "zustand/vanilla"; - -const STATE_KEY = "cue.oauth.state"; -// The PKCE verifier is stashed alongside the state nonce so it survives the -// full-page redirect to Trakt and back to `/auth/callback` for the exchange. -const VERIFIER_KEY = "cue.oauth.verifier"; +} from "../data/auth/oauth"; +import { createPkcePair } from "../data/auth/pkce"; +import type { Token } from "../domain/model/token"; +import type { RedirectHandoff } from "../ports/redirect-handoff"; +import type { TokenStore } from "../ports/token-store"; +import { PendingWritesError, sessionTeardown } from "../runtime/session"; +import type { AuthActions, AuthState, AuthStore } from "./store"; export interface AuthDeps { readonly tokenStore: TokenStore; @@ -26,6 +22,8 @@ export interface AuthDeps { readonly redirectUri: string; /** Full-page navigation (injected so tests/native can override). */ readonly redirect: (url: string) => void; + /** Where the state nonce and the PKCE verifier wait out that navigation. */ + readonly redirectHandoff: RedirectHandoff; /** True under Capacitor: device-code is the primary native path (redirect can't return). */ readonly native: boolean; /** Trakt origin override; undefined leaves the flow on the real Trakt hosts. */ @@ -123,8 +121,7 @@ export function createAuthStore(deps: AuthDeps): AuthStore { set({ connectStatus: "connecting", errorMessage: null }); const state = crypto.randomUUID(); const { verifier, challenge } = await createPkcePair(); - sessionStorage.setItem(STATE_KEY, state); - sessionStorage.setItem(VERIFIER_KEY, verifier); + deps.redirectHandoff.write(state, verifier); deps.redirect(buildAuthorizeUrl(config, state, challenge)); }, @@ -149,11 +146,10 @@ export function createAuthStore(deps: AuthDeps): AuthStore { async completeRedirect(code, state) { set({ connectStatus: "connecting", errorMessage: null }); - const expected = sessionStorage.getItem(STATE_KEY); - const verifier = sessionStorage.getItem(VERIFIER_KEY); + const stashed = deps.redirectHandoff.read(); // Validate BEFORE consuming: a stray or tampered callback (bad/absent // state) must not wipe the verifier of an in-progress attempt. - if (state === null || expected === null || state !== expected || verifier === null) { + if (state === null || stashed === null || state !== stashed.state) { set({ connectStatus: "error", errorMessage: "We couldn't verify that sign-in. Please try again.", @@ -161,8 +157,8 @@ export function createAuthStore(deps: AuthDeps): AuthStore { return; } // State accepted: the single-use nonce + verifier are now spent. - sessionStorage.removeItem(STATE_KEY); - sessionStorage.removeItem(VERIFIER_KEY); + const { verifier } = stashed; + deps.redirectHandoff.clear(); if (code === null) { set({ connectStatus: "error", diff --git a/packages/web/src/ui/auth/store.ts b/packages/core/src/auth/store.ts similarity index 100% rename from packages/web/src/ui/auth/store.ts rename to packages/core/src/auth/store.ts diff --git a/packages/web/src/ui/hooks/apply-reconcile.ts b/packages/core/src/hooks/apply-reconcile.ts similarity index 94% rename from packages/web/src/ui/hooks/apply-reconcile.ts rename to packages/core/src/hooks/apply-reconcile.ts index 14f64fe7..d68eab63 100644 --- a/packages/web/src/ui/hooks/apply-reconcile.ts +++ b/packages/core/src/hooks/apply-reconcile.ts @@ -1,5 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { ActivitiesReconcile } from "@ui/runtime/runtime"; +import type { ActivitiesReconcile } from "../runtime/runtime"; /** * Apply one `/sync/last_activities` reconcile, shared by the background poll and diff --git a/packages/web/src/ui/hooks/library-cache.ts b/packages/core/src/hooks/library-cache.ts similarity index 91% rename from packages/web/src/ui/hooks/library-cache.ts rename to packages/core/src/hooks/library-cache.ts index 15385ec6..83297e1b 100644 --- a/packages/web/src/ui/hooks/library-cache.ts +++ b/packages/core/src/hooks/library-cache.ts @@ -1,9 +1,9 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; -import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import type { SeasonView } from "@cue/core/data/trakt/show-detail"; import type { QueryClient } from "@tanstack/react-query"; -import type { UpNextData } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { EpisodeDetail } from "../data/trakt/episode-detail"; +import type { LibraryEntry } from "../data/trakt/library"; +import type { SeasonView } from "../data/trakt/show-detail"; +import type { UpNextData } from "../runtime/runtime"; /** * Optimistically replace one library entry in the shared SWR cache, holding the diff --git a/packages/web/src/ui/hooks/mark-undo-window.ts b/packages/core/src/hooks/mark-undo-window.ts similarity index 100% rename from packages/web/src/ui/hooks/mark-undo-window.ts rename to packages/core/src/hooks/mark-undo-window.ts diff --git a/packages/web/src/ui/hooks/query-freshness.ts b/packages/core/src/hooks/query-freshness.ts similarity index 100% rename from packages/web/src/ui/hooks/query-freshness.ts rename to packages/core/src/hooks/query-freshness.ts diff --git a/packages/web/src/ui/hooks/queue-order.ts b/packages/core/src/hooks/queue-order.ts similarity index 94% rename from packages/web/src/ui/hooks/queue-order.ts rename to packages/core/src/hooks/queue-order.ts index 9b6c7d14..adbe945e 100644 --- a/packages/web/src/ui/hooks/queue-order.ts +++ b/packages/core/src/hooks/queue-order.ts @@ -1,6 +1,6 @@ -import { toMs } from "@cue/core/domain/time"; -import type { UpNextItem } from "@cue/core/domain/up-next"; -import type { LapsedOrder, NextEpisodeOrder } from "@cue/core/prefs/tracking"; +import { toMs } from "../domain/time"; +import type { UpNextItem } from "../domain/up-next"; +import type { LapsedOrder, NextEpisodeOrder } from "../prefs/tracking"; function airMs(item: UpNextItem): number { // A provisional post-mark projection has no air date. Treating it as OLDEST diff --git a/packages/web/src/ui/hooks/resolveUnmark.ts b/packages/core/src/hooks/resolveUnmark.ts similarity index 98% rename from packages/web/src/ui/hooks/resolveUnmark.ts rename to packages/core/src/hooks/resolveUnmark.ts index 7e1f95c9..33d56ba3 100644 --- a/packages/web/src/ui/hooks/resolveUnmark.ts +++ b/packages/core/src/hooks/resolveUnmark.ts @@ -4,8 +4,8 @@ import { type MoviePlay, planEpisodeUnmark, type UnmarkPlan, -} from "@cue/core/domain/reversal"; -import type { CueRuntime } from "@ui/runtime/runtime"; +} from "../domain/reversal"; +import type { CueRuntime } from "../runtime/runtime"; /** * The outcome of resolving a single-episode uncheck against its real Trakt plays diff --git a/packages/web/src/ui/hooks/useActivitiesPoll.ts b/packages/core/src/hooks/useActivitiesPoll.ts similarity index 70% rename from packages/web/src/ui/hooks/useActivitiesPoll.ts rename to packages/core/src/hooks/useActivitiesPoll.ts index 7834ef0c..bf4c1d52 100644 --- a/packages/web/src/ui/hooks/useActivitiesPoll.ts +++ b/packages/core/src/hooks/useActivitiesPoll.ts @@ -1,7 +1,9 @@ import { useQueryClient } from "@tanstack/react-query"; -import { applyReconcile } from "@ui/hooks/apply-reconcile"; -import { useOptionalRuntime } from "@ui/runtime/runtime"; import { useEffect } from "react"; +import { useAppVisibility } from "../runtime/app-visibility"; +import { useNetwork } from "../runtime/network"; +import { useOptionalRuntime } from "../runtime/runtime"; +import { applyReconcile } from "./apply-reconcile"; /** * Foreground poll cadence for the freshness gate. One `/sync/last_activities` GET @@ -15,7 +17,8 @@ const POLL_INTERVAL_MS = 60_000; * The single freshness gate driver. One Page-Visibility-gated poll of * `/sync/last_activities`: on mount (establish the baseline / catch anything that * changed while away), on regaining visibility, on reconnect, and on a 60s - * interval while visible. A diffed change invalidates exactly the affected query + * interval while visible. Visibility and connectivity are injected ports, so + * this is the same poll on a tab and on an app in the background. A diffed change invalidates exactly the affected query * keys and, once they have refetched, advances the persisted baseline. A failed * check stays silent (cached data keeps showing); it never flips the pill. * Navigation triggers none of this, so moving between pages costs zero Trakt @@ -24,6 +27,8 @@ const POLL_INTERVAL_MS = 60_000; export function useActivitiesPoll(): void { const runtime = useOptionalRuntime(); const queryClient = useQueryClient(); + const visibility = useAppVisibility(); + const network = useNetwork(); useEffect(() => { // The shell renders without a runtime during the pre-token /auth/callback @@ -36,7 +41,7 @@ export function useActivitiesPoll(): void { runtime.pendingWrites() > 0 ? runtime.flushWrites() : 0; const runPoll = async (): Promise => { - if (running || document.visibilityState === "hidden") return; + if (running || !visibility.isVisible()) return; running = true; try { // Local ops land BEFORE the freshness check, and a reconcile never @@ -55,25 +60,26 @@ export function useActivitiesPoll(): void { const poll = (): void => void runPoll(); const onVisible = (): void => { - if (document.visibilityState === "visible") poll(); + if (visibility.isVisible()) poll(); }; // Reconnect always attempts to land deferred writes, even from a hidden // tab; the poll itself (and so the reconcile) stays visibility-gated. - const onOnline = (): void => { - if (document.visibilityState === "hidden") void flushPending(); - else poll(); + const onNetwork = (): void => { + if (!network.isOnline()) return; + if (visibility.isVisible()) poll(); + else void flushPending(); }; poll(); - document.addEventListener("visibilitychange", onVisible); - globalThis.addEventListener("online", onOnline); - const interval = globalThis.setInterval(poll, POLL_INTERVAL_MS); + const unsubscribeVisibility = visibility.subscribe(onVisible); + const unsubscribeNetwork = network.subscribe(onNetwork); + const interval = setInterval(poll, POLL_INTERVAL_MS); return () => { cancelled = true; - document.removeEventListener("visibilitychange", onVisible); - globalThis.removeEventListener("online", onOnline); - globalThis.clearInterval(interval); + unsubscribeVisibility(); + unsubscribeNetwork(); + clearInterval(interval); }; - }, [runtime, queryClient]); + }, [runtime, queryClient, visibility, network]); } diff --git a/packages/web/src/ui/hooks/useBrowse.ts b/packages/core/src/hooks/useBrowse.ts similarity index 87% rename from packages/web/src/ui/hooks/useBrowse.ts rename to packages/core/src/hooks/useBrowse.ts index ff73ba9a..f88ae8df 100644 --- a/packages/web/src/ui/hooks/useBrowse.ts +++ b/packages/core/src/hooks/useBrowse.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { SearchHit } from "@cue/core/data/trakt/search"; import { useQuery } from "@tanstack/react-query"; -import { BROWSE_STALE_TIME_MS } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { SearchHit } from "../data/trakt/search"; +import { useRuntime } from "../runtime/runtime"; +import { BROWSE_STALE_TIME_MS } from "./query-freshness"; export interface BrowseView { readonly isLoading: boolean; diff --git a/packages/web/src/ui/hooks/useCalendar.ts b/packages/core/src/hooks/useCalendar.ts similarity index 96% rename from packages/web/src/ui/hooks/useCalendar.ts rename to packages/core/src/hooks/useCalendar.ts index 493c8c50..48b3891c 100644 --- a/packages/web/src/ui/hooks/useCalendar.ts +++ b/packages/core/src/hooks/useCalendar.ts @@ -1,10 +1,10 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import { type CalendarDay, type CalendarEntry, groupCalendar } from "@cue/core/domain/calendar"; -import { localTimeZone } from "@cue/core/domain/time"; import { useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS, type QueryStatus, queryStatus } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; import { useEffect, useMemo, useState } from "react"; +import { queryKeys } from "../data/query-keys"; +import { type CalendarDay, type CalendarEntry, groupCalendar } from "../domain/calendar"; +import { localTimeZone } from "../domain/time"; +import { useRuntime } from "../runtime/runtime"; +import { CONTENT_STALE_TIME_MS, type QueryStatus, queryStatus } from "./query-freshness"; /** * The Calendar screen's agenda depth and the ONE window the query ever diff --git a/packages/web/src/ui/hooks/useDetailHeader.ts b/packages/core/src/hooks/useDetailHeader.ts similarity index 93% rename from packages/web/src/ui/hooks/useDetailHeader.ts rename to packages/core/src/hooks/useDetailHeader.ts index b2d33211..64383ecd 100644 --- a/packages/web/src/ui/hooks/useDetailHeader.ts +++ b/packages/core/src/hooks/useDetailHeader.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; +import { CONTENT_STALE_TIME_MS } from "./query-freshness"; export interface DetailHeaderView { readonly header: T | undefined; diff --git a/packages/web/src/ui/hooks/useEpisode.ts b/packages/core/src/hooks/useEpisode.ts similarity index 78% rename from packages/web/src/ui/hooks/useEpisode.ts rename to packages/core/src/hooks/useEpisode.ts index eed5e513..c6455d84 100644 --- a/packages/web/src/ui/hooks/useEpisode.ts +++ b/packages/core/src/hooks/useEpisode.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; import { useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { EpisodeDetail } from "../data/trakt/episode-detail"; +import { useRuntime } from "../runtime/runtime"; +import { CONTENT_STALE_TIME_MS } from "./query-freshness"; export interface EpisodeView { readonly episode: EpisodeDetail | undefined; diff --git a/packages/web/src/ui/hooks/useEpisodePlays.ts b/packages/core/src/hooks/useEpisodePlays.ts similarity index 93% rename from packages/web/src/ui/hooks/useEpisodePlays.ts rename to packages/core/src/hooks/useEpisodePlays.ts index e3887ae3..e21203ec 100644 --- a/packages/web/src/ui/hooks/useEpisodePlays.ts +++ b/packages/core/src/hooks/useEpisodePlays.ts @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; import { useCallback } from "react"; +import { useRuntime } from "../runtime/runtime"; +import { USER_STATE_STALE_TIME } from "./query-freshness"; const playsKey = (episodeTrakt: number) => ["episode-plays", episodeTrakt] as const; diff --git a/packages/web/src/ui/hooks/useEpisodeReminders.ts b/packages/core/src/hooks/useEpisodeReminders.ts similarity index 88% rename from packages/web/src/ui/hooks/useEpisodeReminders.ts rename to packages/core/src/hooks/useEpisodeReminders.ts index 17abdfd4..90729426 100644 --- a/packages/web/src/ui/hooks/useEpisodeReminders.ts +++ b/packages/core/src/hooks/useEpisodeReminders.ts @@ -1,8 +1,8 @@ -import { planReminders, REMINDER_WINDOW_DAYS } from "@cue/core/domain/reminders"; -import { useCalendar } from "@ui/hooks/useCalendar"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { useReminders } from "@ui/runtime/reminders"; import { useEffect } from "react"; +import { planReminders, REMINDER_WINDOW_DAYS } from "../domain/reminders"; +import { usePrefs } from "../prefs/prefs-store"; +import { useReminders } from "../runtime/reminders"; +import { useCalendar } from "./useCalendar"; /** * Keeps the OS holding exactly the reminders the current calendar implies. diff --git a/packages/web/src/ui/hooks/useHideShow.ts b/packages/core/src/hooks/useHideShow.ts similarity index 94% rename from packages/web/src/ui/hooks/useHideShow.ts rename to packages/core/src/hooks/useHideShow.ts index d0382b8f..7e137691 100644 --- a/packages/web/src/ui/hooks/useHideShow.ts +++ b/packages/core/src/hooks/useHideShow.ts @@ -1,10 +1,10 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { ShowIds } from "@cue/core/domain/model/ids"; -import { buildHideShowOp, buildUnhideShowOp } from "@cue/core/domain/write-queue/ops"; import { useQueryClient } from "@tanstack/react-query"; -import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; import { useCallback, useState } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { ShowIds } from "../domain/model/ids"; +import { buildHideShowOp, buildUnhideShowOp } from "../domain/write-queue/ops"; import { patchLibraryHidden } from "./library-cache"; +import { useOptimisticWrite } from "./useOptimisticWrite"; /** Which direction the last action moved the show: drives the Undo copy + inverse. */ type HideKind = "hide" | "unhide"; diff --git a/packages/web/src/ui/hooks/useHistory.ts b/packages/core/src/hooks/useHistory.ts similarity index 96% rename from packages/web/src/ui/hooks/useHistory.ts rename to packages/core/src/hooks/useHistory.ts index 6f800cf7..fe80a90f 100644 --- a/packages/web/src/ui/hooks/useHistory.ts +++ b/packages/core/src/hooks/useHistory.ts @@ -1,23 +1,23 @@ -import { invalidateShowProgress } from "@cue/core/data/query-invalidation"; -import { queryKeys } from "@cue/core/data/query-keys"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { invalidateShowProgress } from "../data/query-invalidation"; +import { queryKeys } from "../data/query-keys"; import { groupHistory, type HistoryDay, type HistoryEntry, historyRange, historyScopeKey, -} from "@cue/core/domain/history"; -import { localTimeZone } from "@cue/core/domain/time"; +} from "../domain/history"; +import { localTimeZone } from "../domain/time"; import { buildMarkEpisodeOp, buildMarkMovieOp, buildRemoveHistoryPlayOp, -} from "@cue/core/domain/write-queue/ops"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { queryStatus, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; -import { type HistorySection, type SubmitOutcome, useRuntime } from "@ui/runtime/runtime"; -import { useCallback, useMemo, useRef, useState } from "react"; +} from "../domain/write-queue/ops"; +import { type HistorySection, type SubmitOutcome, useRuntime } from "../runtime/runtime"; +import { queryStatus, USER_STATE_STALE_TIME } from "./query-freshness"; +import { useOptimisticWrite } from "./useOptimisticWrite"; /** The history type filter, in user words; mapped to the history endpoint slice. */ export type HistoryFilter = "all" | "tv" | "movies"; diff --git a/packages/core/src/hooks/useIsOffline.ts b/packages/core/src/hooks/useIsOffline.ts new file mode 100644 index 00000000..1e4e8f52 --- /dev/null +++ b/packages/core/src/hooks/useIsOffline.ts @@ -0,0 +1,8 @@ +import { useSyncExternalStore } from "react"; +import { useNetwork } from "../runtime/network"; + +/** Live connectivity as React state (SyncStrip's offline line, Search's offline notice). */ +export function useIsOffline(): boolean { + const network = useNetwork(); + return useSyncExternalStore(network.subscribe, () => !network.isOnline()); +} diff --git a/packages/web/src/ui/hooks/useLibraryBuckets.ts b/packages/core/src/hooks/useLibraryBuckets.ts similarity index 88% rename from packages/web/src/ui/hooks/useLibraryBuckets.ts rename to packages/core/src/hooks/useLibraryBuckets.ts index fae1f532..e181cbe2 100644 --- a/packages/web/src/ui/hooks/useLibraryBuckets.ts +++ b/packages/core/src/hooks/useLibraryBuckets.ts @@ -1,10 +1,10 @@ -import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import { byTitle, type LibrarySort } from "@cue/core/domain/library-buckets"; -import { toMs } from "@cue/core/domain/time"; -import { computeWatchStatus } from "@cue/core/domain/watch-status"; -import { type QueryStatus, queryStatus } from "@ui/hooks/query-freshness"; -import { useLibrarySnapshot } from "@ui/hooks/useLibrarySnapshot"; import { useMemo } from "react"; +import type { LibraryEntry } from "../data/trakt/library"; +import { byTitle, type LibrarySort } from "../domain/library-buckets"; +import { toMs } from "../domain/time"; +import { computeWatchStatus } from "../domain/watch-status"; +import { type QueryStatus, queryStatus } from "./query-freshness"; +import { useLibrarySnapshot } from "./useLibrarySnapshot"; /** * The Library status chips. Statuses map onto them plainly: `caught-up` folds diff --git a/packages/web/src/ui/hooks/useLibrarySnapshot.ts b/packages/core/src/hooks/useLibrarySnapshot.ts similarity index 84% rename from packages/web/src/ui/hooks/useLibrarySnapshot.ts rename to packages/core/src/hooks/useLibrarySnapshot.ts index 4fb701eb..dbcb9900 100644 --- a/packages/web/src/ui/hooks/useLibrarySnapshot.ts +++ b/packages/core/src/hooks/useLibrarySnapshot.ts @@ -1,18 +1,14 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import { - firstUnwatchedAired, - type SeasonView, - toEpisodeRef, -} from "@cue/core/data/trakt/show-detail"; -import { needsNextEpisode, reconcileRecentlyAired } from "@cue/core/domain/recently-aired"; -import { thresholdMsFromDays } from "@cue/core/prefs/threshold"; import { queryOptions, type UseQueryResult, useQueries, useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useRecentlyAired } from "@ui/hooks/useCalendar"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { type CueRuntime, type UpNextData, useRuntime } from "@ui/runtime/runtime"; import { useMemo } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { LibraryEntry } from "../data/trakt/library"; +import { firstUnwatchedAired, type SeasonView, toEpisodeRef } from "../data/trakt/show-detail"; +import { needsNextEpisode, reconcileRecentlyAired } from "../domain/recently-aired"; +import { usePrefs } from "../prefs/prefs-store"; +import { thresholdMsFromDays } from "../prefs/threshold"; +import { type CueRuntime, type UpNextData, useRuntime } from "../runtime/runtime"; +import { CONTENT_STALE_TIME_MS, USER_STATE_STALE_TIME } from "./query-freshness"; +import { useRecentlyAired } from "./useCalendar"; export interface LibrarySnapshot { readonly query: UseQueryResult; diff --git a/packages/web/src/ui/hooks/useMarkSeason.ts b/packages/core/src/hooks/useMarkSeason.ts similarity index 98% rename from packages/web/src/ui/hooks/useMarkSeason.ts rename to packages/core/src/hooks/useMarkSeason.ts index 7fe75b20..04aa20dc 100644 --- a/packages/web/src/ui/hooks/useMarkSeason.ts +++ b/packages/core/src/hooks/useMarkSeason.ts @@ -1,28 +1,28 @@ -import { invalidateShowProgress } from "@cue/core/data/query-invalidation"; -import { queryKeys } from "@cue/core/data/query-keys"; -import type { SeasonView, ShowProgress } from "@cue/core/data/trakt/show-detail"; -import type { EpisodeIds, ShowIds } from "@cue/core/domain/model/ids"; -import { planSeasonUnmark } from "@cue/core/domain/reversal"; -import { buildBulkMarkOps, type SeasonTree } from "@cue/core/domain/write-queue/bulk"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useRef, useState } from "react"; +import { invalidateShowProgress } from "../data/query-invalidation"; +import { queryKeys } from "../data/query-keys"; +import type { SeasonView, ShowProgress } from "../data/trakt/show-detail"; +import type { EpisodeIds, ShowIds } from "../domain/model/ids"; +import { planSeasonUnmark } from "../domain/reversal"; +import { buildBulkMarkOps, type SeasonTree } from "../domain/write-queue/bulk"; import { buildAddEpisodePlayOp, buildMarkEpisodeOp, buildRemovePlaysOp, buildUnmarkEpisodeOp, episodeItemKey, -} from "@cue/core/domain/write-queue/ops"; -import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { useQueryClient } from "@tanstack/react-query"; +} from "../domain/write-queue/ops"; +import type { QueuedOp } from "../domain/write-queue/types"; +import { useRuntime } from "../runtime/runtime"; +import { hasPendingMark, registerPendingMark, releasePendingMark } from "../stores/mark-store"; import { type EpisodeMatch, patchEpisodeDetail as patchEpisodeDetailCache, patchShowSeasons, -} from "@ui/hooks/library-cache"; -import { hasPendingMark, registerPendingMark, releasePendingMark } from "@ui/hooks/mark-store"; -import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; -import { useRuntime } from "@ui/runtime/runtime"; -import { useCallback, useRef, useState } from "react"; +} from "./library-cache"; import { resolveEpisodeUnmark } from "./resolveUnmark"; +import { useOptimisticWrite } from "./useOptimisticWrite"; import { useResumeOnMark } from "./useResumeOnMark"; import { forgetSeasonMark, getSeasonMarkDelta, rememberSeasonMark } from "./useSeasonReversal"; diff --git a/packages/web/src/ui/hooks/useMarkSnacks.ts b/packages/core/src/hooks/useMarkSnacks.ts similarity index 95% rename from packages/web/src/ui/hooks/useMarkSnacks.ts rename to packages/core/src/hooks/useMarkSnacks.ts index 912c4c29..ec3c56db 100644 --- a/packages/web/src/ui/hooks/useMarkSnacks.ts +++ b/packages/core/src/hooks/useMarkSnacks.ts @@ -1,6 +1,6 @@ -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import type { MarkSeasonController } from "@ui/hooks/useMarkSeason"; import { type RefObject, useEffect, useRef } from "react"; +import { dismissSnack, showSnack } from "../stores/snackbar-store"; +import type { MarkSeasonController } from "./useMarkSeason"; /** * A pending "+N earlier" offer: set by the surface at mark time (when the tapped diff --git a/packages/web/src/ui/hooks/useMarkWatched.ts b/packages/core/src/hooks/useMarkWatched.ts similarity index 95% rename from packages/web/src/ui/hooks/useMarkWatched.ts rename to packages/core/src/hooks/useMarkWatched.ts index 773d073b..97fcda97 100644 --- a/packages/web/src/ui/hooks/useMarkWatched.ts +++ b/packages/core/src/hooks/useMarkWatched.ts @@ -1,18 +1,18 @@ -import { invalidateShowProgress } from "@cue/core/data/query-invalidation"; -import { queryKeys } from "@cue/core/data/query-keys"; -import { advancePastNext, type LibraryEntry, type MarkContext } from "@cue/core/data/trakt/library"; -import { epCode } from "@cue/core/domain/model/library"; +import { useQueryClient } from "@tanstack/react-query"; +import { createElement, Fragment, useCallback } from "react"; +import { invalidateShowProgress } from "../data/query-invalidation"; +import { queryKeys } from "../data/query-keys"; +import { advancePastNext, type LibraryEntry, type MarkContext } from "../data/trakt/library"; +import { epCode } from "../domain/model/library"; import { buildMarkEpisodeOp, buildRemovePlaysOp, buildUnmarkEpisodeOp, episodeItemKey, -} from "@cue/core/domain/write-queue/ops"; -import { epCode, middleTruncate } from "@cue/core/format"; -import { useQueryClient } from "@tanstack/react-query"; -import { dismissSnack, showSnack, useSnackbar } from "@ui/components/snackbar-store"; -import { middleTruncate } from "@cue/core/format"; -import { patchEpisodeDetail, patchLibraryEntry, patchShowSeasons } from "@ui/hooks/library-cache"; +} from "../domain/write-queue/ops"; +import { middleTruncate } from "../format"; +import { useHaptics } from "../runtime/haptics"; +import { type CueRuntime, type SubmitOutcome, useRuntime } from "../runtime/runtime"; import { hasPendingMark, isReversalRequested, @@ -26,13 +26,12 @@ import { settleReversal, unlockShow, useMarkStore, -} from "@ui/hooks/mark-store"; -import { appendToBatch } from "@ui/hooks/mark-undo-window"; -import { findMarkPlay } from "@ui/hooks/resolveUnmark"; -import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; -import { useHaptics } from "@ui/runtime/haptics"; -import { type CueRuntime, type SubmitOutcome, useRuntime } from "@ui/runtime/runtime"; -import { createElement, Fragment, useCallback } from "react"; +} from "../stores/mark-store"; +import { dismissSnack, showSnack, useSnackbar } from "../stores/snackbar-store"; +import { patchEpisodeDetail, patchLibraryEntry, patchShowSeasons } from "./library-cache"; +import { appendToBatch } from "./mark-undo-window"; +import { findMarkPlay } from "./resolveUnmark"; +import { useOptimisticWrite } from "./useOptimisticWrite"; export interface MarkWatched { /** Optimistically mark `entry`'s next episode; the op submits at t=0. */ diff --git a/packages/web/src/ui/hooks/useMovieActions.ts b/packages/core/src/hooks/useMovieActions.ts similarity index 95% rename from packages/web/src/ui/hooks/useMovieActions.ts rename to packages/core/src/hooks/useMovieActions.ts index f1331e2e..cee17794 100644 --- a/packages/web/src/ui/hooks/useMovieActions.ts +++ b/packages/core/src/hooks/useMovieActions.ts @@ -1,22 +1,22 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useRef, useState } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { MovieEntry } from "../data/trakt/movie-library"; import { buildAddWatchlistOp, buildMarkMovieOp, buildRemoveHistoryPlayOp, buildRemoveWatchlistOp, buildUnmarkMovieOp, -} from "@cue/core/domain/write-queue/ops"; -import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { useQueryClient } from "@tanstack/react-query"; -import { resolveMovieUnmark, routeMovieUnmark } from "@ui/hooks/resolveUnmark"; -import { invertOp } from "@ui/hooks/useMarkSeason"; -import { writeMovieEntry } from "@ui/hooks/useMovieLibrary"; -import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; -import { useHaptics } from "@ui/runtime/haptics"; -import type { SubmitOutcome } from "@ui/runtime/runtime"; -import { useRuntime } from "@ui/runtime/runtime"; -import { useCallback, useRef, useState } from "react"; +} from "../domain/write-queue/ops"; +import type { QueuedOp } from "../domain/write-queue/types"; +import { useHaptics } from "../runtime/haptics"; +import type { SubmitOutcome } from "../runtime/runtime"; +import { useRuntime } from "../runtime/runtime"; +import { resolveMovieUnmark, routeMovieUnmark } from "./resolveUnmark"; +import { invertOp } from "./useMarkSeason"; +import { writeMovieEntry } from "./useMovieLibrary"; +import { useOptimisticWrite } from "./useOptimisticWrite"; interface MarkUndo { readonly title: string; diff --git a/packages/web/src/ui/hooks/useMovieDetail.ts b/packages/core/src/hooks/useMovieDetail.ts similarity index 68% rename from packages/web/src/ui/hooks/useMovieDetail.ts rename to packages/core/src/hooks/useMovieDetail.ts index 950fbb3d..869bc622 100644 --- a/packages/web/src/ui/hooks/useMovieDetail.ts +++ b/packages/core/src/hooks/useMovieDetail.ts @@ -1,7 +1,7 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { MovieHeader } from "@cue/core/data/trakt/movie-library"; -import { type DetailHeaderView, useDetailHeader } from "@ui/hooks/useDetailHeader"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { MovieHeader } from "../data/trakt/movie-library"; +import { useRuntime } from "../runtime/runtime"; +import { type DetailHeaderView, useDetailHeader } from "./useDetailHeader"; export type MovieDetailView = DetailHeaderView; diff --git a/packages/web/src/ui/hooks/useMovieLibrary.ts b/packages/core/src/hooks/useMovieLibrary.ts similarity index 94% rename from packages/web/src/ui/hooks/useMovieLibrary.ts rename to packages/core/src/hooks/useMovieLibrary.ts index fcc53493..8317f2f9 100644 --- a/packages/web/src/ui/hooks/useMovieLibrary.ts +++ b/packages/core/src/hooks/useMovieLibrary.ts @@ -1,10 +1,10 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; -import { byTitle } from "@cue/core/domain/library-buckets"; import { type QueryClient, useQuery } from "@tanstack/react-query"; -import { queryStatus, USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { type MovieLibraryData, useRuntime } from "@ui/runtime/runtime"; import { useMemo } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { MovieEntry } from "../data/trakt/movie-library"; +import { byTitle } from "../domain/library-buckets"; +import { type MovieLibraryData, useRuntime } from "../runtime/runtime"; +import { queryStatus, USER_STATE_STALE_TIME } from "./query-freshness"; /** Honest movie taxonomy (Rams #6): a film is watched or not: no episode * progress: so the library groups into Watchlist (want to watch) and Watched diff --git a/packages/web/src/ui/hooks/useMovieRelated.ts b/packages/core/src/hooks/useMovieRelated.ts similarity index 80% rename from packages/web/src/ui/hooks/useMovieRelated.ts rename to packages/core/src/hooks/useMovieRelated.ts index 7bde4e6c..ef51c8a3 100644 --- a/packages/web/src/ui/hooks/useMovieRelated.ts +++ b/packages/core/src/hooks/useMovieRelated.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { SearchHit } from "@cue/core/data/trakt/search"; import { useQuery } from "@tanstack/react-query"; -import { BROWSE_STALE_TIME_MS } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { SearchHit } from "../data/trakt/search"; +import { useRuntime } from "../runtime/runtime"; +import { BROWSE_STALE_TIME_MS } from "./query-freshness"; export interface MovieRelatedView { readonly isLoading: boolean; diff --git a/packages/web/src/ui/hooks/useOptimisticWrite.ts b/packages/core/src/hooks/useOptimisticWrite.ts similarity index 95% rename from packages/web/src/ui/hooks/useOptimisticWrite.ts rename to packages/core/src/hooks/useOptimisticWrite.ts index 9115361e..bf2c508e 100644 --- a/packages/web/src/ui/hooks/useOptimisticWrite.ts +++ b/packages/core/src/hooks/useOptimisticWrite.ts @@ -1,7 +1,7 @@ -import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { trackWrite } from "@ui/hooks/sync-activity-store"; -import { type SubmitOutcome, useRuntime } from "@ui/runtime/runtime"; import { useCallback } from "react"; +import type { QueuedOp } from "../domain/write-queue/types"; +import { type SubmitOutcome, useRuntime } from "../runtime/runtime"; +import { trackWrite } from "../stores/sync-activity-store"; /** The cache effects a write can trigger, each run at most once per submit. */ interface OutcomeEffects { diff --git a/packages/web/src/ui/hooks/useQueuedWrite.ts b/packages/core/src/hooks/useQueuedWrite.ts similarity index 91% rename from packages/web/src/ui/hooks/useQueuedWrite.ts rename to packages/core/src/hooks/useQueuedWrite.ts index 690a7268..5a892952 100644 --- a/packages/web/src/ui/hooks/useQueuedWrite.ts +++ b/packages/core/src/hooks/useQueuedWrite.ts @@ -1,6 +1,6 @@ -import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { useOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; import { useCallback, useState } from "react"; +import type { QueuedOp } from "../domain/write-queue/types"; +import { useOptimisticWrite } from "./useOptimisticWrite"; export interface QueuedWrite { /** diff --git a/packages/web/src/ui/hooks/useRemovalSnacks.ts b/packages/core/src/hooks/useRemovalSnacks.ts similarity index 89% rename from packages/web/src/ui/hooks/useRemovalSnacks.ts rename to packages/core/src/hooks/useRemovalSnacks.ts index 4ed479d6..a3b01cc4 100644 --- a/packages/web/src/ui/hooks/useRemovalSnacks.ts +++ b/packages/core/src/hooks/useRemovalSnacks.ts @@ -1,7 +1,7 @@ -import type { HistoryEntry } from "@cue/core/domain/history"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import type { HistoryView } from "@ui/hooks/useHistory"; import { useEffect, useRef } from "react"; +import type { HistoryEntry } from "../domain/history"; +import { dismissSnack, showSnack } from "../stores/snackbar-store"; +import type { HistoryView } from "./useHistory"; type RemovalView = Pick; diff --git a/packages/web/src/ui/hooks/useResumeOnMark.ts b/packages/core/src/hooks/useResumeOnMark.ts similarity index 90% rename from packages/web/src/ui/hooks/useResumeOnMark.ts rename to packages/core/src/hooks/useResumeOnMark.ts index c81db909..47d31a16 100644 --- a/packages/web/src/ui/hooks/useResumeOnMark.ts +++ b/packages/core/src/hooks/useResumeOnMark.ts @@ -1,10 +1,10 @@ -import type { ShowIds } from "@cue/core/domain/model/ids"; -import { buildHideShowOp, buildUnhideShowOp } from "@cue/core/domain/write-queue/ops"; import { useQueryClient } from "@tanstack/react-query"; -import { useTrackedSubmit } from "@ui/hooks/useOptimisticWrite"; -import type { SubmitOutcome } from "@ui/runtime/runtime"; import { useCallback } from "react"; +import type { ShowIds } from "../domain/model/ids"; +import { buildHideShowOp, buildUnhideShowOp } from "../domain/write-queue/ops"; +import type { SubmitOutcome } from "../runtime/runtime"; import { isLibraryHidden, patchLibraryHidden } from "./library-cache"; +import { useTrackedSubmit } from "./useOptimisticWrite"; export interface ResumeOnMark { /** Whether marking this show right now WOULD auto-resume it: i.e. it is currently diff --git a/packages/web/src/ui/hooks/useSearch.ts b/packages/core/src/hooks/useSearch.ts similarity index 95% rename from packages/web/src/ui/hooks/useSearch.ts rename to packages/core/src/hooks/useSearch.ts index 6e4b2897..d8937c11 100644 --- a/packages/web/src/ui/hooks/useSearch.ts +++ b/packages/core/src/hooks/useSearch.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { SearchHit } from "@cue/core/data/trakt/search"; import { useQuery } from "@tanstack/react-query"; -import { useRuntime } from "@ui/runtime/runtime"; import { useEffect, useState } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { SearchHit } from "../data/trakt/search"; +import { useRuntime } from "../runtime/runtime"; import { useWatchlistAdd } from "./useWatchlistAdd"; /** diff --git a/packages/web/src/ui/hooks/useSeasonReversal.ts b/packages/core/src/hooks/useSeasonReversal.ts similarity index 100% rename from packages/web/src/ui/hooks/useSeasonReversal.ts rename to packages/core/src/hooks/useSeasonReversal.ts diff --git a/packages/web/src/ui/hooks/useSeasons.ts b/packages/core/src/hooks/useSeasons.ts similarity index 78% rename from packages/web/src/ui/hooks/useSeasons.ts rename to packages/core/src/hooks/useSeasons.ts index f0c661e2..e9042768 100644 --- a/packages/web/src/ui/hooks/useSeasons.ts +++ b/packages/core/src/hooks/useSeasons.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { SeasonView } from "@cue/core/data/trakt/show-detail"; import { useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { SeasonView } from "../data/trakt/show-detail"; +import { useRuntime } from "../runtime/runtime"; +import { CONTENT_STALE_TIME_MS } from "./query-freshness"; export interface SeasonsView { readonly seasons: readonly SeasonView[]; diff --git a/packages/web/src/ui/hooks/useShowDetail.ts b/packages/core/src/hooks/useShowDetail.ts similarity index 82% rename from packages/web/src/ui/hooks/useShowDetail.ts rename to packages/core/src/hooks/useShowDetail.ts index 6643d230..2f3b3f62 100644 --- a/packages/web/src/ui/hooks/useShowDetail.ts +++ b/packages/core/src/hooks/useShowDetail.ts @@ -1,9 +1,9 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { ShowHeader } from "@cue/core/data/trakt/show-detail"; import { useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; -import type { DetailHeaderView } from "@ui/hooks/useDetailHeader"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { ShowHeader } from "../data/trakt/show-detail"; +import { useRuntime } from "../runtime/runtime"; +import { CONTENT_STALE_TIME_MS } from "./query-freshness"; +import type { DetailHeaderView } from "./useDetailHeader"; export type ShowDetailView = DetailHeaderView; diff --git a/packages/web/src/ui/hooks/useStats.ts b/packages/core/src/hooks/useStats.ts similarity index 81% rename from packages/web/src/ui/hooks/useStats.ts rename to packages/core/src/hooks/useStats.ts index 3e547e83..6f100154 100644 --- a/packages/web/src/ui/hooks/useStats.ts +++ b/packages/core/src/hooks/useStats.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { UserStats } from "@cue/core/data/trakt/schemas"; import { useQuery } from "@tanstack/react-query"; -import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { UserStats } from "../data/trakt/schemas"; +import { useRuntime } from "../runtime/runtime"; +import { USER_STATE_STALE_TIME } from "./query-freshness"; export interface StatsView { readonly stats: UserStats | undefined; diff --git a/packages/web/src/ui/hooks/useSyncNow.ts b/packages/core/src/hooks/useSyncNow.ts similarity index 90% rename from packages/web/src/ui/hooks/useSyncNow.ts rename to packages/core/src/hooks/useSyncNow.ts index 8cb74345..cd69eb71 100644 --- a/packages/web/src/ui/hooks/useSyncNow.ts +++ b/packages/core/src/hooks/useSyncNow.ts @@ -1,8 +1,8 @@ import { useQueryClient } from "@tanstack/react-query"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { applyReconcile } from "@ui/hooks/apply-reconcile"; -import { useRuntime } from "@ui/runtime/runtime"; import { useCallback, useState } from "react"; +import { useRuntime } from "../runtime/runtime"; +import { dismissSnack, showSnack } from "../stores/snackbar-store"; +import { applyReconcile } from "./apply-reconcile"; interface SyncNow { readonly syncing: boolean; diff --git a/packages/web/src/ui/hooks/useToggleWatchlist.ts b/packages/core/src/hooks/useToggleWatchlist.ts similarity index 87% rename from packages/web/src/ui/hooks/useToggleWatchlist.ts rename to packages/core/src/hooks/useToggleWatchlist.ts index 26fd3914..e5be97e7 100644 --- a/packages/web/src/ui/hooks/useToggleWatchlist.ts +++ b/packages/core/src/hooks/useToggleWatchlist.ts @@ -1,12 +1,12 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { ShowIds } from "@cue/core/domain/model/ids"; -import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "@cue/core/domain/write-queue/ops"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { patchLibraryEntry } from "@ui/hooks/library-cache"; -import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useTrackedSubmit } from "@ui/hooks/useOptimisticWrite"; -import { useRuntime } from "@ui/runtime/runtime"; import { useCallback, useState } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { ShowIds } from "../domain/model/ids"; +import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "../domain/write-queue/ops"; +import { useRuntime } from "../runtime/runtime"; +import { patchLibraryEntry } from "./library-cache"; +import { USER_STATE_STALE_TIME } from "./query-freshness"; +import { useTrackedSubmit } from "./useOptimisticWrite"; export interface WatchlistController { isOnWatchlist(showId: number): boolean; diff --git a/packages/web/src/ui/hooks/useUpNext.ts b/packages/core/src/hooks/useUpNext.ts similarity index 93% rename from packages/web/src/ui/hooks/useUpNext.ts rename to packages/core/src/hooks/useUpNext.ts index 2844ce25..b3a9149c 100644 --- a/packages/web/src/ui/hooks/useUpNext.ts +++ b/packages/core/src/hooks/useUpNext.ts @@ -1,10 +1,10 @@ -import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import { groupUpNext, type UpNextItem } from "@cue/core/domain/up-next"; -import { type QueryStatus, queryStatus } from "@ui/hooks/query-freshness"; -import { sortLapsed, sortQueue, stabilizePendingAdvance } from "@ui/hooks/queue-order"; -import { useLibrarySnapshot } from "@ui/hooks/useLibrarySnapshot"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { useEffect, useMemo, useRef } from "react"; +import type { LibraryEntry } from "../data/trakt/library"; +import { groupUpNext, type UpNextItem } from "../domain/up-next"; +import { usePrefs } from "../prefs/prefs-store"; +import { type QueryStatus, queryStatus } from "./query-freshness"; +import { sortLapsed, sortQueue, stabilizePendingAdvance } from "./queue-order"; +import { useLibrarySnapshot } from "./useLibrarySnapshot"; interface EmptyStateCounts { /** Every tracked show, hidden included: 0 only when the library is truly empty. */ diff --git a/packages/web/src/ui/hooks/useUserProfile.ts b/packages/core/src/hooks/useUserProfile.ts similarity index 74% rename from packages/web/src/ui/hooks/useUserProfile.ts rename to packages/core/src/hooks/useUserProfile.ts index 8173d815..9504493d 100644 --- a/packages/web/src/ui/hooks/useUserProfile.ts +++ b/packages/core/src/hooks/useUserProfile.ts @@ -1,8 +1,8 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { UserProfile } from "@cue/core/data/trakt/user-profile"; import { useQuery } from "@tanstack/react-query"; -import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; +import { queryKeys } from "../data/query-keys"; +import type { UserProfile } from "../data/trakt/user-profile"; +import { useRuntime } from "../runtime/runtime"; +import { USER_STATE_STALE_TIME } from "./query-freshness"; /** * The Profile identity read: `/users/settings`, once. Identity changes about diff --git a/packages/web/src/ui/hooks/useWatchlistAdd.ts b/packages/core/src/hooks/useWatchlistAdd.ts similarity index 95% rename from packages/web/src/ui/hooks/useWatchlistAdd.ts rename to packages/core/src/hooks/useWatchlistAdd.ts index 90e78764..d86bcf23 100644 --- a/packages/web/src/ui/hooks/useWatchlistAdd.ts +++ b/packages/core/src/hooks/useWatchlistAdd.ts @@ -1,11 +1,11 @@ -import { queryKeys } from "@cue/core/data/query-keys"; -import type { SearchHit } from "@cue/core/data/trakt/search"; -import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "@cue/core/domain/write-queue/ops"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { USER_STATE_STALE_TIME } from "@ui/hooks/query-freshness"; -import { useQueuedWrite } from "@ui/hooks/useQueuedWrite"; -import { type MovieLibraryData, type UpNextData, useRuntime } from "@ui/runtime/runtime"; import { useCallback, useState } from "react"; +import { queryKeys } from "../data/query-keys"; +import type { SearchHit } from "../data/trakt/search"; +import { buildAddWatchlistOp, buildRemoveWatchlistOp } from "../domain/write-queue/ops"; +import { type MovieLibraryData, type UpNextData, useRuntime } from "../runtime/runtime"; +import { USER_STATE_STALE_TIME } from "./query-freshness"; +import { useQueuedWrite } from "./useQueuedWrite"; export interface WatchlistAddView { /** True once this hit is in the user's library in any sense: optimistically diff --git a/packages/core/src/ports/preference-storage.ts b/packages/core/src/ports/preference-storage.ts index 451f7894..3fc6ecbb 100644 --- a/packages/core/src/ports/preference-storage.ts +++ b/packages/core/src/ports/preference-storage.ts @@ -15,4 +15,9 @@ export interface PreferenceStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; + /** Drop every key under a prefix. Sign-out calls it with the app's own, which + * on a device is a prefix of its own rather than the one the bulk store uses: + * the two share a database there, and a `cue.` clear would take the durable + * write queue and the install marker with the preferences. */ + clearNamespace(prefix: string): void; } diff --git a/packages/core/src/ports/redirect-handoff.ts b/packages/core/src/ports/redirect-handoff.ts new file mode 100644 index 00000000..9a75a342 --- /dev/null +++ b/packages/core/src/ports/redirect-handoff.ts @@ -0,0 +1,24 @@ +/** + * What the authorization-code flow has to carry across a full-page navigation: + * the single-use state nonce and the PKCE verifier, stashed before the redirect + * to Trakt and read back at `/auth/callback`. + * + * A port rather than `sessionStorage` because the semantics, not just the API, + * are the browser's: per tab, and gone when the tab closes, which is exactly + * what a single-use nonce wants and what makes an abandoned attempt expire on + * its own. A target with no page navigation has no handoff to make and no flow + * to make it for, so its implementation is the inert one below. + */ +export interface RedirectHandoff { + read(): { readonly state: string; readonly verifier: string } | null; + write(state: string, verifier: string): void; + /** Called the moment the state is accepted: both values are single-use. */ + clear(): void; +} + +/** Nothing is stashed, so nothing is ever handed back and the flow refuses. */ +export const NO_REDIRECT_HANDOFF: RedirectHandoff = { + read: () => null, + write: () => {}, + clear: () => {}, +}; diff --git a/packages/core/src/prefs/prefs-store.ts b/packages/core/src/prefs/prefs-store.ts index de2b50b2..02c54be7 100644 --- a/packages/core/src/prefs/prefs-store.ts +++ b/packages/core/src/prefs/prefs-store.ts @@ -1,4 +1,6 @@ -import { create } from "zustand"; +import { createContext, useContext } from "react"; +import { type StoreApi, useStore } from "zustand"; +import { createStore } from "zustand/vanilla"; import type { PreferenceStorage } from "../ports/preference-storage"; import { hapticsPref, remindersPref } from "./device-prefs"; import { @@ -46,6 +48,8 @@ interface PrefsState { setLapsedOrder: (order: LapsedOrder) => void; } +export type PrefsStore = StoreApi; + /** * Local display preferences: the staleness threshold that splits the Watching * pile (and Up Next) from Not-watched-in-a-while, the TV/Movies visibility @@ -53,9 +57,10 @@ interface PrefsState { * to its principled default (21 days, both media on) on a new device. * * A factory rather than a module-level store, because the storage differs per - * app and the reads happen at import time, before anything React could inject. + * app and every value is read at import time, before anything React could + * inject. */ -export function createPrefsStore(storage: PreferenceStorage) { +export function createPrefsStore(storage: PreferenceStorage): PrefsStore { const haptics = hapticsPref(storage); const reminders = remindersPref(storage); const hideStills = hideStillsPref(storage); @@ -63,7 +68,7 @@ export function createPrefsStore(storage: PreferenceStorage) { const lapsedOrder = lapsedOrderPref(storage); const threshold = thresholdPref(storage); - return create((set, get) => { + return createStore((set, get) => { const media = initialMediaVisibility(storage); // One commit path enforces the single invariant: the app is never emptied of // both media: so a setter that would turn off the last-enabled medium no-ops. @@ -112,3 +117,29 @@ export function createPrefsStore(storage: PreferenceStorage) { }; }); } + +/** + * A store over a storage that forgets, so a component rendered outside a + * provider reads every preference at its default and its writes go nowhere. + * That is the same inert default the haptics and reminders ports carry, and it + * is what lets an isolated test mount a screen without a composition root. + */ +const inert = (): PrefsStore => { + const values = new Map(); + return createPrefsStore({ + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + clearNamespace: (prefix) => { + for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key); + }, + }); +}; + +const PrefsContext = createContext(inert()); + +export const PrefsProvider = PrefsContext.Provider; + +/** Select from the app's preferences store. */ +export function usePrefs(selector: (state: PrefsState) => T): T { + return useStore(useContext(PrefsContext), selector); +} diff --git a/packages/web/src/ui/runtime/app-version.ts b/packages/core/src/runtime/app-version.ts similarity index 100% rename from packages/web/src/ui/runtime/app-version.ts rename to packages/core/src/runtime/app-version.ts diff --git a/packages/core/src/runtime/app-visibility.ts b/packages/core/src/runtime/app-visibility.ts new file mode 100644 index 00000000..b01edcd5 --- /dev/null +++ b/packages/core/src/runtime/app-visibility.ts @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; + +/** + * Whether the app is in front of the user, and a subscription to that changing, + * injected from the composition root so the shared layer never reads + * `document`. The freshness poll is gated on it: an app in the background spends no + * Trakt budget. The web answers with the Page Visibility API, a device with + * `AppState`. The default is always-visible-never-changing, so the pre-token + * shell and an isolated test need no provider. + */ +export interface AppVisibility { + isVisible(): boolean; + subscribe(listener: () => void): () => void; +} + +const ALWAYS_VISIBLE: AppVisibility = { + isVisible: () => true, + subscribe: () => () => {}, +}; + +const AppVisibilityContext = createContext(ALWAYS_VISIBLE); + +export const AppVisibilityProvider = AppVisibilityContext.Provider; + +export function useAppVisibility(): AppVisibility { + return useContext(AppVisibilityContext); +} diff --git a/packages/core/src/runtime/boot.ts b/packages/core/src/runtime/boot.ts new file mode 100644 index 00000000..8ae07ae8 --- /dev/null +++ b/packages/core/src/runtime/boot.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createCueRuntime, type RuntimeDeps } from "./create-runtime"; +import type { CueRuntime } from "./runtime"; +import { sessionTeardown } from "./session"; + +/** Everything the runtime needs except the token, which boot reads for itself. */ +export type RuntimeBootDeps = Omit; + +export interface RuntimeBootState { + /** Null until the runtime is built; the app paints its loading state meanwhile. */ + readonly runtime: CueRuntime | null; + /** A boot rejection. Must reach a visible retry rather than a stuck spinner. */ + readonly failed: boolean; + readonly retry: () => void; +} + +/** + * Build the authenticated runtime once the session is connected: read the + * persisted token, restore the durable write-queue and replay it, then register + * the live teardown so a disconnect can flush and clear through it. + * + * The three states are returned rather than rendered, because the loading, failed + * and ready surfaces are each app's own; what must not be lost in that split is + * that a failed startup reconcile reaches a retry the user can press. + */ +export function useRuntimeBoot(deps: RuntimeBootDeps): RuntimeBootState { + const [runtime, setRuntime] = useState(null); + const [failed, setFailed] = useState(false); + const alive = useRef(true); + const { + tokenStore, + kv, + redirectUri, + clientId, + apiBaseUrl, + endSession, + clearPersistedCaches, + clearLocalPreferences, + } = deps; + + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + // No runtime is mounted anymore: a disconnect from here on has nothing to + // flush/clear through, so drop the registered teardown. + sessionTeardown.run = () => Promise.resolve(); + }; + }, []); + + const boot = useCallback(() => { + setFailed(false); + void (async () => { + try { + const token = await tokenStore.read(); + if (token === null) return; + const built = await createCueRuntime({ + token, + tokenStore, + kv, + redirectUri, + clientId, + apiBaseUrl, + endSession, + clearPersistedCaches, + clearLocalPreferences, + }); + if (alive.current) { + // Hand disconnect a live teardown: flush pending writes + clear this + // device's caches through the runtime before the token is revoked. + sessionTeardown.run = (options) => built.endLocalSession(options); + setRuntime(built); + } + } catch { + // A boot rejection must never leave the app stuck on the loading spinner; + // surface a retryable error instead. + if (alive.current) setFailed(true); + } + })(); + // The members, not `deps`: the caller builds that object inline on every + // render, so depending on it would re-boot the runtime on every render. + }, [ + tokenStore, + kv, + redirectUri, + clientId, + apiBaseUrl, + endSession, + clearPersistedCaches, + clearLocalPreferences, + ]); + + useEffect(() => { + boot(); + }, [boot]); + + return { runtime, failed, retry: boot }; +} diff --git a/packages/web/src/app/runtime/create-runtime.ts b/packages/core/src/runtime/create-runtime.ts similarity index 87% rename from packages/web/src/app/runtime/create-runtime.ts rename to packages/core/src/runtime/create-runtime.ts index 1671e996..671d10bd 100644 --- a/packages/web/src/app/runtime/create-runtime.ts +++ b/packages/core/src/runtime/create-runtime.ts @@ -1,18 +1,15 @@ -import { TRAKT_BASE_OVERRIDE, TRAKT_CLIENT_ID } from "@app/config"; -import { queryClient, queryPersister } from "@app/query-client"; -import { PendingWritesError, type TeardownOptions } from "@app/session"; -import { invalidationKeys } from "@cue/core/data/query-invalidation"; -import { createAuthorizedFetch } from "@cue/core/data/trakt/authorized-fetch"; -import { assembleCalendarEntries } from "@cue/core/data/trakt/calendar"; -import { TraktClient } from "@cue/core/data/trakt/client"; -import { assembleEpisodeDetail } from "@cue/core/data/trakt/episode-detail"; +import { invalidationKeys } from "../data/query-invalidation"; +import { createAuthorizedFetch } from "../data/trakt/authorized-fetch"; +import { assembleCalendarEntries } from "../data/trakt/calendar"; +import { TraktClient } from "../data/trakt/client"; +import { assembleEpisodeDetail } from "../data/trakt/episode-detail"; import { assembleEpisodePlays, assembleHistoryEntries, assembleMoviePlays, -} from "@cue/core/data/trakt/history"; -import { additiveLanded, markLanded, showIdSet } from "@cue/core/data/trakt/library"; -import { assembleMovieHeader, assembleMovieLibrary } from "@cue/core/data/trakt/movie-library"; +} from "../data/trakt/history"; +import { additiveLanded, markLanded, showIdSet } from "../data/trakt/library"; +import { assembleMovieHeader, assembleMovieLibrary } from "../data/trakt/movie-library"; import { getEpisode, getHidden, @@ -33,30 +30,26 @@ import { getWatchedMovies, getWatchlist, searchTrakt, -} from "@cue/core/data/trakt/pooled-endpoints"; -import { loadUpNextEntries } from "@cue/core/data/trakt/read-budget"; -import { createLastActivitiesRepository } from "@cue/core/data/trakt/repositories"; -import type { UserStats } from "@cue/core/data/trakt/schemas"; +} from "../data/trakt/pooled-endpoints"; +import { loadUpNextEntries } from "../data/trakt/read-budget"; +import { createLastActivitiesRepository } from "../data/trakt/repositories"; +import type { UserStats } from "../data/trakt/schemas"; import { assembleMovieHits, assembleSearchHits, assembleShowHits, rankSearchHits, -} from "@cue/core/data/trakt/search"; -import { - assembleSeasons, - assembleShowInfo, - assembleShowProgress, -} from "@cue/core/data/trakt/show-detail"; -import { createTraktTransport } from "@cue/core/data/trakt/transport"; -import { assembleUserProfile, type UserProfile } from "@cue/core/data/trakt/user-profile"; -import type { Token } from "@cue/core/domain/model/token"; -import type { LastActivities } from "@cue/core/domain/sync-activities"; -import { WriteQueue } from "@cue/core/domain/write-queue/queue"; -import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { createJsonStore } from "@cue/core/ports/json-store"; -import type { KeyValueStore } from "@cue/core/ports/kv"; -import type { TokenStore } from "@cue/core/ports/token-store"; +} from "../data/trakt/search"; +import { assembleSeasons, assembleShowInfo, assembleShowProgress } from "../data/trakt/show-detail"; +import { createTraktTransport } from "../data/trakt/transport"; +import { assembleUserProfile, type UserProfile } from "../data/trakt/user-profile"; +import type { Token } from "../domain/model/token"; +import type { LastActivities } from "../domain/sync-activities"; +import { WriteQueue } from "../domain/write-queue/queue"; +import type { QueuedOp } from "../domain/write-queue/types"; +import { createJsonStore } from "../ports/json-store"; +import type { KeyValueStore } from "../ports/kv"; +import type { TokenStore } from "../ports/token-store"; import type { ActivitiesReconcile, BrowseData, @@ -66,19 +59,13 @@ import type { MovieLibraryData, SubmitOutcome, UpNextData, -} from "@ui/runtime/runtime"; +} from "./runtime"; +import { PendingWritesError, type TeardownOptions } from "./session"; const OP_LOG_KEY = "cue.write-queue"; /** The persisted `/sync/last_activities` baseline the freshness gate diffs against. */ const ACTIVITIES_KEY = "cue.last-activities"; -function clearCueLocalStorage(): void { - for (let index = localStorage.length - 1; index >= 0; index -= 1) { - const key = localStorage.key(index); - if (key?.startsWith("cue.") === true) localStorage.removeItem(key); - } -} - /** * The op's `inversePatch` read as a reconcile anchor: a `mark`/bulk write pivots * on Trakt's `completed` (default `kind`, as the `MarkContext` serializes); a @@ -100,10 +87,23 @@ export interface RuntimeDeps { readonly kv: KeyValueStore; /** Where a rotated token is persisted so it survives reload. */ readonly tokenStore: TokenStore; - /** `${origin}/auth/callback`: the PKCE refresh grant echoes it back. */ + /** `${origin}/auth/callback` on the web, the registered scheme on a device: + * the PKCE refresh grant echoes it back, so it travels on every refresh and + * not only on first sign-in. */ readonly redirectUri: string; + /** Cue's public Trakt client id. Each app reads it from its own build + * environment, so nothing here reads an environment at all. */ + readonly clientId: string; + /** The fake Trakt's origin under `--mode mock`, undefined in every real build. */ + readonly apiBaseUrl?: string | undefined; /** Called when the refresh token is dead: clears the session → onboarding. */ readonly endSession: () => Promise; + /** Drop this device's query cache, live and persisted. One dependency rather + * than a QueryClient and a persister, because teardown calls exactly one + * method on each. */ + readonly clearPersistedCaches: () => Promise; + /** Drop this device's `cue.`-prefixed preferences at sign-out. */ + readonly clearLocalPreferences: () => void; } const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -122,18 +122,18 @@ export async function createCueRuntime(deps: RuntimeDeps): Promise { inner: (input, init) => globalThis.fetch(input, init), token: deps.token, config: { - clientId: TRAKT_CLIENT_ID, + clientId: deps.clientId, redirectUri: deps.redirectUri, - apiBaseUrl: TRAKT_BASE_OVERRIDE, + apiBaseUrl: deps.apiBaseUrl, }, persist: (token) => deps.tokenStore.write(token), endSession: deps.endSession, }); const client = new TraktClient({ - clientId: TRAKT_CLIENT_ID, + clientId: deps.clientId, getToken: () => authorized.accessToken(), fetch: authorized.fetch, - baseUrl: TRAKT_BASE_OVERRIDE, + baseUrl: deps.apiBaseUrl, }); const reconcile = async (op: QueuedOp): Promise => { @@ -453,13 +453,12 @@ export async function createCueRuntime(deps: RuntimeDeps): Promise { // can never be sent, and clearing is what prevents the cross-account // replay. if (options.force !== true && queue.size > 0) throw new PendingWritesError(); - clearCueLocalStorage(); + deps.clearLocalPreferences(); // Clear this device's per-account state so the next account never paints // stale data or dispatches a leftover op. await opLogStore.clear(); await activitiesStore.clear(); - queryClient.clear(); - await queryPersister.removeClient(); + await deps.clearPersistedCaches(); } finally { tearingDown = false; } diff --git a/packages/web/src/ui/runtime/haptics.ts b/packages/core/src/runtime/haptics.ts similarity index 87% rename from packages/web/src/ui/runtime/haptics.ts rename to packages/core/src/runtime/haptics.ts index 08f3c154..942d4d41 100644 --- a/packages/web/src/ui/runtime/haptics.ts +++ b/packages/core/src/runtime/haptics.ts @@ -1,5 +1,5 @@ -import { type Haptics, SILENT } from "@cue/core/domain/ports/haptics"; import { createContext, useContext } from "react"; +import { type Haptics, SILENT } from "../domain/ports/haptics"; /** The tactile port as `@ui` reaches it, injected from the composition root so * `@ui` stays free of `@app`/`@platform`. The default is the silent no-op, so diff --git a/packages/core/src/runtime/network.ts b/packages/core/src/runtime/network.ts new file mode 100644 index 00000000..550f0e1d --- /dev/null +++ b/packages/core/src/runtime/network.ts @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; + +/** + * Whether the device believes it has a network, and a subscription to that + * changing, injected from the composition root so the shared layer never reads + * `navigator`. Both halves are advisory on every platform: a reachable radio is + * not a reachable Trakt, so this drives what the UI says and when a deferred + * write is retried, never whether a request is attempted. The default is + * always-online-never-changing, so an isolated test needs no provider. + */ +export interface Network { + isOnline(): boolean; + subscribe(listener: () => void): () => void; +} + +const ALWAYS_ONLINE: Network = { + isOnline: () => true, + subscribe: () => () => {}, +}; + +const NetworkContext = createContext(ALWAYS_ONLINE); + +export const NetworkProvider = NetworkContext.Provider; + +export function useNetwork(): Network { + return useContext(NetworkContext); +} diff --git a/packages/web/src/ui/runtime/persist-buster.ts b/packages/core/src/runtime/persist-buster.ts similarity index 97% rename from packages/web/src/ui/runtime/persist-buster.ts rename to packages/core/src/runtime/persist-buster.ts index 8934b058..da3ea875 100644 --- a/packages/web/src/ui/runtime/persist-buster.ts +++ b/packages/core/src/runtime/persist-buster.ts @@ -13,5 +13,5 @@ */ export const PERSISTED_CACHE = { buster: "d0c3d97c58b8", - shape: "06ab2c43786e", + shape: "06b3b0833416", } as const; diff --git a/packages/core/src/runtime/query-cache.ts b/packages/core/src/runtime/query-cache.ts new file mode 100644 index 00000000..4bb1f408 --- /dev/null +++ b/packages/core/src/runtime/query-cache.ts @@ -0,0 +1,95 @@ +import { QueryClient as Client, type Query, type QueryClient } from "@tanstack/react-query"; +import { queryKeys } from "../data/query-keys"; +import { PERSISTED_CACHE } from "./persist-buster"; + +export const PERSIST_BUSTER = PERSISTED_CACHE.buster; + +/** + * Query-key heads whose data earns its place in the restored blob: everything a + * home, Library, Diary or Profile screen paints from before the network answers, + * and no more. `library`, `movie-library`, `watchlist`, `users` and `history` are + * `staleTime: Infinity` user state that only the last-activities reconciler ever + * refreshes, so dropping them from the blob strips those screens on a cold or + * offline boot with nothing to restore them. `calendar` carries a finite horizon + * but is what "On the way" paints, so it is persisted for the same reason. + * + * Left out: `search` and `discover` (unbounded key spaces nobody boots into), and + * the per-show/per-movie detail trees, whose count grows with every title ever + * opened and which cost a single GET to re-read on demand. A LIBRARY show's + * `show/info` is the one exception: it is what a restored row paints its poster + * from, and re-reading it costs a GET per card on screen. + */ +const PERSISTED_KEY_HEADS: ReadonlySet = new Set([ + "library", + "movie-library", + "watchlist", + "users", + "history", + "calendar", +]); + +/** + * `maxAge` governs how long a restored cache may be replayed, NOT freshness. + * Freshness is owned by the last-activities reconciler, so an age cap here would + * boot an offline user to an empty screen: the exact failure to avoid. Left + * effectively unbounded; `buster` is the only invalidator. `gcTime` matches so + * restored queries are never collected before they can paint. + */ +export const PERSIST_MAX_AGE = Number.POSITIVE_INFINITY; + +/** The client every target builds, differing only in where its cache is persisted. */ +export function createQueryClient(): QueryClient { + return new Client({ + defaultOptions: { + queries: { + gcTime: PERSIST_MAX_AGE, + // Per-query freshness is explicit: user-state reads (library, movie library, + // stats, watchlist) set `staleTime: Infinity` and revalidate ONLY + // through the last-activities reconciler; content reads (show detail, + // calendar) set a finite content window. The 0 default only covers ephemeral + // reads (search) that are keyed per query and fine to refetch on mount. + staleTime: 0, + // Focus/reconnect no longer trigger a blanket per-screen re-fetch: a single + // visibility-gated `/sync/last_activities` poll is the one freshness + // check on regaining visibility, so navigation costs zero Trakt data calls. + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + }, + }); +} + +/** + * What a given client persists. A factory rather than a free function because + * the membership test reads the client's own library entry, and the client is + * constructed per app: a module-level singleton to close over would not exist + * here. + */ +export function createQueryCachePolicy(queryClient: QueryClient): { + shouldDehydrateQuery(query: Query): boolean; +} { + /** + * Does the restored library hold a card for this show? Show detail and the + * per-card art read share one `showInfo` entry, so anything opened from Search, + * Calendar or the Diary writes one too. Those have no card to paint on a cold + * boot, and with `gcTime` and `PERSIST_MAX_AGE` both unbounded they would + * accumulate in the blob for the life of the install. Gating on library + * membership is what keeps the persisted set bounded by the library. + */ + const paintsALibraryCard = (showId: unknown): boolean => { + const library = queryClient.getQueryData<{ + readonly entries: readonly { readonly showId: number }[]; + }>(queryKeys.library()); + return library?.entries.some((entry) => entry.showId === showId) ?? false; + }; + + return { + shouldDehydrateQuery(query) { + if (query.state.status !== "success") return false; + const [head, section, showId] = query.queryKey; + if (head === "show") return section === "info" && paintsALibraryCard(showId); + return PERSISTED_KEY_HEADS.has(head); + }, + }; +} diff --git a/packages/web/src/ui/runtime/reminders.ts b/packages/core/src/runtime/reminders.ts similarity index 86% rename from packages/web/src/ui/runtime/reminders.ts rename to packages/core/src/runtime/reminders.ts index aa4f13a7..081b5f0f 100644 --- a/packages/web/src/ui/runtime/reminders.ts +++ b/packages/core/src/runtime/reminders.ts @@ -1,5 +1,5 @@ -import { type Reminders, SILENT } from "@cue/core/domain/ports/reminders"; import { createContext, useContext } from "react"; +import { type Reminders, SILENT } from "../domain/ports/reminders"; /** The notification port as `@ui` reaches it, injected from the composition root * so `@ui` stays free of `@app`/`@platform`. The default schedules nothing and diff --git a/packages/web/src/ui/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts similarity index 91% rename from packages/web/src/ui/runtime/runtime.ts rename to packages/core/src/runtime/runtime.ts index 54903d99..6eb88e08 100644 --- a/packages/web/src/ui/runtime/runtime.ts +++ b/packages/core/src/runtime/runtime.ts @@ -1,16 +1,16 @@ -import type { InvalidationKey } from "@cue/core/data/query-invalidation"; -import type { EpisodeDetail } from "@cue/core/data/trakt/episode-detail"; -import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import type { MovieEntry, MovieHeader } from "@cue/core/data/trakt/movie-library"; -import type { UserStats } from "@cue/core/data/trakt/schemas"; -import type { SearchHit } from "@cue/core/data/trakt/search"; -import type { SeasonView, ShowInfo, ShowProgress } from "@cue/core/data/trakt/show-detail"; -import type { UserProfile } from "@cue/core/data/trakt/user-profile"; -import type { CalendarEntry } from "@cue/core/domain/calendar"; -import type { HistoryEntry, HistoryRange } from "@cue/core/domain/history"; -import type { EpisodePlay, MoviePlay } from "@cue/core/domain/reversal"; -import type { QueuedOp } from "@cue/core/domain/write-queue/types"; import { createContext, useContext } from "react"; +import type { InvalidationKey } from "../data/query-invalidation"; +import type { EpisodeDetail } from "../data/trakt/episode-detail"; +import type { LibraryEntry } from "../data/trakt/library"; +import type { MovieEntry, MovieHeader } from "../data/trakt/movie-library"; +import type { UserStats } from "../data/trakt/schemas"; +import type { SearchHit } from "../data/trakt/search"; +import type { SeasonView, ShowInfo, ShowProgress } from "../data/trakt/show-detail"; +import type { UserProfile } from "../data/trakt/user-profile"; +import type { CalendarEntry } from "../domain/calendar"; +import type { HistoryEntry, HistoryRange } from "../domain/history"; +import type { EpisodePlay, MoviePlay } from "../domain/reversal"; +import type { QueuedOp } from "../domain/write-queue/types"; /** The read side of the home surface: the assembled active queue. */ export interface UpNextData { diff --git a/packages/web/src/app/session.ts b/packages/core/src/runtime/session.ts similarity index 100% rename from packages/web/src/app/session.ts rename to packages/core/src/runtime/session.ts diff --git a/packages/web/src/ui/hooks/mark-store.ts b/packages/core/src/stores/mark-store.ts similarity index 97% rename from packages/web/src/ui/hooks/mark-store.ts rename to packages/core/src/stores/mark-store.ts index 3aeef429..48d82ef1 100644 --- a/packages/web/src/ui/hooks/mark-store.ts +++ b/packages/core/src/stores/mark-store.ts @@ -1,6 +1,6 @@ -import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import type { CueRuntime } from "@ui/runtime/runtime"; import { create } from "zustand"; +import type { LibraryEntry } from "../data/trakt/library"; +import type { CueRuntime } from "../runtime/runtime"; /** * One committed mark, held for the two reversal affordances: the live-toggle diff --git a/packages/web/src/ui/components/snackbar-store.ts b/packages/core/src/stores/snackbar-store.ts similarity index 100% rename from packages/web/src/ui/components/snackbar-store.ts rename to packages/core/src/stores/snackbar-store.ts diff --git a/packages/web/src/ui/hooks/sync-activity-store.ts b/packages/core/src/stores/sync-activity-store.ts similarity index 100% rename from packages/web/src/ui/hooks/sync-activity-store.ts rename to packages/core/src/stores/sync-activity-store.ts diff --git a/packages/core/src/url/search-params.ts b/packages/core/src/url/search-params.ts new file mode 100644 index 00000000..ad1e120f --- /dev/null +++ b/packages/core/src/url/search-params.ts @@ -0,0 +1,35 @@ +/** + * The two screens whose state lives in the URL, parsed once and shared. A route + * parameter arrives as untrusted text on both targets, from a deep link or a + * hand-edited address, so each parser drops anything it does not know + * rather than carrying it into a query key. + */ + +/** Library: which medium the segment shows. Absent means Shows. */ +export interface LibrarySearch { + readonly type?: "movies"; +} + +export function parseLibrarySearch(search: Record): LibrarySearch { + return search["type"] === "movies" ? { type: "movies" } : {}; +} + +/** Diary: which medium, and the month it is scrolled to. */ +export interface HistorySearch { + readonly type?: "tv" | "movies"; + readonly year?: number; + readonly month?: number; +} + +export function parseHistorySearch(search: Record): HistorySearch { + const out: { type?: "tv" | "movies"; year?: number; month?: number } = {}; + if (search["type"] === "tv" || search["type"] === "movies") out.type = search["type"]; + const year = Number(search["year"]); + if (Number.isInteger(year) && year >= 1970 && year <= 2100) out.year = year; + const month = Number(search["month"]); + // A month without a year is not a position, so it is dropped with it. + if (out.year !== undefined && Number.isInteger(month) && month >= 1 && month <= 12) { + out.month = month; + } + return out; +} diff --git a/packages/core/test/prefs/_storage.ts b/packages/core/test/prefs/_storage.ts index 940bf48f..e65066e7 100644 --- a/packages/core/test/prefs/_storage.ts +++ b/packages/core/test/prefs/_storage.ts @@ -7,5 +7,8 @@ export function fakeStorage(seed: Record = {}): PreferenceStorag return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => void values.set(key, value), + clearNamespace: (prefix) => { + for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key); + }, }; } diff --git a/packages/web/src/app/AuthGate.tsx b/packages/web/src/app/AuthGate.tsx index b2dab4db..41c291bb 100644 --- a/packages/web/src/app/AuthGate.tsx +++ b/packages/web/src/app/AuthGate.tsx @@ -1,9 +1,9 @@ import { router } from "@app/router"; import { RuntimeBoot } from "@app/runtime/RuntimeBoot"; +import { type AuthStore, AuthStoreProvider, useAuth } from "@cue/core/auth/store"; import type { KeyValueStore } from "@cue/core/ports/kv"; import type { TokenStore } from "@cue/core/ports/token-store"; import { RouterProvider } from "@tanstack/react-router"; -import { type AuthStore, AuthStoreProvider, useAuth } from "@ui/auth/store"; import { Onboarding } from "@ui/screens/onboarding/Onboarding"; import type { ReactElement } from "react"; diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 4e372bad..e697c158 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -1,5 +1,4 @@ import { AuthGate } from "@app/AuthGate"; -import { createAuthStore } from "@app/auth/create-auth-store"; import { TRAKT_BASE_OVERRIDE, TRAKT_CLIENT_ID } from "@app/config"; import { requestPersistentStorage } from "@app/persist"; import { @@ -10,19 +9,26 @@ import { shouldDehydrateQuery, } from "@app/query-client"; import { router } from "@app/router"; +import { createAuthStore } from "@cue/core/auth/create-auth-store"; import { createTokenStore } from "@cue/core/ports/token-store"; +import { PrefsProvider } from "@cue/core/prefs/prefs-store"; +import { AppVersionProvider } from "@cue/core/runtime/app-version"; +import { AppVisibilityProvider } from "@cue/core/runtime/app-visibility"; +import { HapticsProvider } from "@cue/core/runtime/haptics"; +import { NetworkProvider } from "@cue/core/runtime/network"; +import { RemindersProvider } from "@cue/core/runtime/reminders"; import { getNativeAppVersion } from "@platform/app-version"; +import { webAppVisibility } from "@platform/app-visibility"; import { bindHardwareBack } from "@platform/back-button"; import { createNativeHaptics } from "@platform/haptics"; import { createKeyValueStore } from "@platform/kv"; +import { webNetwork } from "@platform/network"; import { isNativePlatform } from "@platform/platform"; +import { sessionRedirectHandoff } from "@platform/redirect-handoff"; import { createNativeReminders } from "@platform/reminders"; import { applyStatusBarTheme } from "@platform/status-bar"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { AppVersionProvider } from "@ui/runtime/app-version"; -import { HapticsProvider } from "@ui/runtime/haptics"; -import { RemindersProvider } from "@ui/runtime/reminders"; +import { prefsStore } from "@ui/prefs/prefs-store"; import { useThemeStore } from "@ui/theme/theme-store"; import { type ReactElement, useEffect, useState } from "react"; import { version } from "../../package.json"; @@ -35,7 +41,7 @@ const tokenStore = createTokenStore(kv); // The tactile seam, built once: silent on web, and on native gated at fire time // on the Settings "Haptics" toggle alone. Both platforms honour their own // system haptics settings underneath, so nothing else here second-guesses them. -const haptics = createNativeHaptics(() => usePrefs.getState().hapticsEnabled); +const haptics = createNativeHaptics(() => prefsStore.getState().hapticsEnabled); // The notification seam, built once: silent on web, and on native the only // caller of the local-notifications plugin. const reminders = createNativeReminders(); @@ -45,6 +51,7 @@ const authStore = createAuthStore({ clientId: TRAKT_CLIENT_ID, redirectUri, redirect: (url) => globalThis.location.assign(url), + redirectHandoff: sessionRedirectHandoff, native, traktBaseUrl: TRAKT_BASE_OVERRIDE, }); @@ -99,13 +106,19 @@ export function AppProviders(): ReactElement { dehydrateOptions: { shouldDehydrateQuery }, }} > - - - - - - - + + + + + + + + + + + + + ); } diff --git a/packages/web/src/app/query-client.ts b/packages/web/src/app/query-client.ts index 6b86a92f..0c716c55 100644 --- a/packages/web/src/app/query-client.ts +++ b/packages/web/src/app/query-client.ts @@ -1,86 +1,14 @@ -import { queryKeys } from "@cue/core/data/query-keys"; +import { createQueryCachePolicy, createQueryClient } from "@cue/core/runtime/query-cache"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; -import { type Query, QueryClient } from "@tanstack/react-query"; -import { PERSISTED_CACHE } from "@ui/runtime/persist-buster"; import { del, get, set } from "idb-keyval"; -export const PERSIST_BUSTER = PERSISTED_CACHE.buster; +export { PERSIST_BUSTER, PERSIST_MAX_AGE } from "@cue/core/runtime/query-cache"; -/** - * Query-key heads whose data earns its place in the restored blob: everything a - * home, Library, Diary or Profile screen paints from before the network answers, - * and no more. `library`, `movie-library`, `watchlist`, `users` and `history` are - * `staleTime: Infinity` user state that only the last-activities reconciler ever - * refreshes, so dropping them from the blob strips those screens on a cold or - * offline boot with nothing to restore them. `calendar` carries a finite horizon - * but is what "On the way" paints, so it is persisted for the same reason. - * - * Left out: `search` and `discover` (unbounded key spaces nobody boots into), and - * the per-show/per-movie detail trees, whose count grows with every title ever - * opened and which cost a single GET to re-read on demand. A LIBRARY show's - * `show/info` is the one exception: it is what a restored row paints its poster - * from, and re-reading it costs a GET per card on screen. - */ -const PERSISTED_KEY_HEADS: ReadonlySet = new Set([ - "library", - "movie-library", - "watchlist", - "users", - "history", - "calendar", -]); +export const queryClient = createQueryClient(); -/** - * `maxAge` governs how long a restored cache may be replayed, NOT freshness. - * Freshness is owned by the last-activities reconciler, so an age cap here would - * boot an offline user to an empty screen: the exact failure to avoid. Left - * effectively unbounded; `buster` is the only invalidator. `gcTime` matches so - * restored queries are never collected before they can paint. - */ -export const PERSIST_MAX_AGE = Number.POSITIVE_INFINITY; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: PERSIST_MAX_AGE, - // Per-query freshness is explicit: user-state reads (library, movie library, - // stats, watchlist) set `staleTime: Infinity` and revalidate ONLY - // through the last-activities reconciler; content reads (show detail, - // calendar) set a finite content window. The 0 default only covers ephemeral - // reads (search) that are keyed per query and fine to refetch on mount. - staleTime: 0, - // Focus/reconnect no longer trigger a blanket per-screen re-fetch: a single - // Page-Visibility-gated `/sync/last_activities` poll is the one freshness - // check on regaining visibility, so navigation costs zero Trakt data calls. - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - }, -}); - -/** - * Does the restored library hold a card for this show? Show detail and the - * per-card art read share one `showInfo` entry, so anything opened from Search, - * Calendar or the Diary writes one too. Those have no card to paint on a cold - * boot, and with `gcTime` and `PERSIST_MAX_AGE` both unbounded they would - * accumulate in the blob for the life of the install. Gating on library - * membership is what keeps the persisted set bounded by the library. - */ -function paintsALibraryCard(showId: unknown): boolean { - const library = queryClient.getQueryData<{ - readonly entries: readonly { readonly showId: number }[]; - }>(queryKeys.library()); - return library?.entries.some((entry) => entry.showId === showId) ?? false; -} - -export function shouldDehydrateQuery(query: Query): boolean { - if (query.state.status !== "success") return false; - const [head, section, showId] = query.queryKey; - if (head === "show") return section === "info" && paintsALibraryCard(showId); - return PERSISTED_KEY_HEADS.has(head); -} +export const { shouldDehydrateQuery } = createQueryCachePolicy(queryClient); +/** IndexedDB, so a large restored blob never blocks the main thread. */ export const queryPersister = createAsyncStoragePersister({ key: "cue.query-cache", storage: { @@ -89,3 +17,9 @@ export const queryPersister = createAsyncStoragePersister({ removeItem: (key) => del(key), }, }); + +/** The one teardown dependency the runtime takes, in place of both objects. */ +export const clearPersistedCaches = async (): Promise => { + queryClient.clear(); + await queryPersister.removeClient(); +}; diff --git a/packages/web/src/app/router.tsx b/packages/web/src/app/router.tsx index defa1d36..e0aeb89e 100644 --- a/packages/web/src/app/router.tsx +++ b/packages/web/src/app/router.tsx @@ -1,6 +1,7 @@ +import { parseHistorySearch, parseLibrarySearch } from "@cue/core/url/search-params"; import { createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router"; import { RootLayout } from "@ui/app-shell/RootLayout"; -import { usePrefs } from "@ui/prefs/prefs-store"; +import { prefsStore } from "@ui/prefs/prefs-store"; const rootRoute = createRootRoute({ component: RootLayout }); @@ -9,7 +10,7 @@ const rootRoute = createRootRoute({ component: RootLayout }); // to the movies Library rather than paint a screen for a hidden medium. The nav // already omits the tabs; this guards the URL: including stale show/episode links. const requireShows = (): void => { - if (!usePrefs.getState().showsEnabled) throw redirect({ to: "/library" }); + if (!prefsStore.getState().showsEnabled) throw redirect({ to: "/library" }); }; const upNextRoute = createRoute({ @@ -27,18 +28,14 @@ const calendarRoute = createRoute({ * returns to Movies (previously it reset to Shows). Shows is the canonical default * carrying no param, so bare `/library`, legacy redirects, and every existing * `to="/library"` link stay valid and clean; only Movies pins `?type=movies`. */ -interface LibrarySearch { - readonly type?: "movies"; -} const libraryRoute = createRoute({ getParentRoute: () => rootRoute, path: "/library", - validateSearch: (search: Record): LibrarySearch => - search["type"] === "movies" ? { type: "movies" } : {}, + validateSearch: parseLibrarySearch, // A `?type=movies` deep link is meaningless once Movies are turned off; drop the // param so Library lands cleanly on Shows. beforeLoad: ({ search }) => { - if (search.type === "movies" && !usePrefs.getState().moviesEnabled) { + if (search.type === "movies" && !prefsStore.getState().moviesEnabled) { throw redirect({ to: "/library" }); } }, @@ -71,25 +68,10 @@ const profileRoute = createRoute({ getParentRoute: () => rootRoute, path: "/prof * inside a `year`, so it is dropped when no valid year is present. The year sanity * range is generous (a deep link to any real year works): the picker's own floor * only shapes which years it offers as chips, never which the URL accepts. */ -interface HistorySearch { - readonly type?: "tv" | "movies"; - readonly year?: number; - readonly month?: number; -} const historyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/history", - validateSearch: (search: Record): HistorySearch => { - const out: { type?: "tv" | "movies"; year?: number; month?: number } = {}; - if (search["type"] === "tv" || search["type"] === "movies") out.type = search["type"]; - const year = Number(search["year"]); - if (Number.isInteger(year) && year >= 1970 && year <= 2100) out.year = year; - const month = Number(search["month"]); - if (out.year !== undefined && Number.isInteger(month) && month >= 1 && month <= 12) { - out.month = month; - } - return out; - }, + validateSearch: parseHistorySearch, }).lazy(() => import("@app/routes/history.lazy").then((module) => module.Route)); const authCallbackRoute = createRoute({ getParentRoute: () => rootRoute, diff --git a/packages/web/src/app/routes/auth-callback.lazy.tsx b/packages/web/src/app/routes/auth-callback.lazy.tsx index 77bd464d..098d3e63 100644 --- a/packages/web/src/app/routes/auth-callback.lazy.tsx +++ b/packages/web/src/app/routes/auth-callback.lazy.tsx @@ -1,5 +1,5 @@ +import { useAuth } from "@cue/core/auth/store"; import { createLazyRoute, useNavigate } from "@tanstack/react-router"; -import { useAuth } from "@ui/auth/store"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { type ReactElement, useEffect, useRef } from "react"; diff --git a/packages/web/src/app/runtime/RuntimeBoot.tsx b/packages/web/src/app/runtime/RuntimeBoot.tsx index f2dd4a3a..5876884b 100644 --- a/packages/web/src/app/runtime/RuntimeBoot.tsx +++ b/packages/web/src/app/runtime/RuntimeBoot.tsx @@ -1,11 +1,13 @@ -import { createCueRuntime } from "@app/runtime/create-runtime"; -import { sessionTeardown } from "@app/session"; +import { TRAKT_BASE_OVERRIDE, TRAKT_CLIENT_ID } from "@app/config"; +import { clearPersistedCaches } from "@app/query-client"; +import { useAuth } from "@cue/core/auth/store"; +import { useEpisodeReminders } from "@cue/core/hooks/useEpisodeReminders"; import type { KeyValueStore } from "@cue/core/ports/kv"; import type { TokenStore } from "@cue/core/ports/token-store"; -import { useAuth } from "@ui/auth/store"; -import { useEpisodeReminders } from "@ui/hooks/useEpisodeReminders"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; -import { type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { useRuntimeBoot } from "@cue/core/runtime/boot"; +import { RuntimeProvider } from "@cue/core/runtime/runtime"; +import { clearLocalPreferences } from "@ui/prefs/preference-storage"; +import type { ReactElement, ReactNode } from "react"; /** The reminder scheduler reads the calendar, so it runs under the runtime and * for exactly as long as the runtime exists. */ @@ -23,10 +25,11 @@ export interface RuntimeBootProps { } /** - * Instantiate the authenticated runtime once the session is connected and hand - * it to `@ui` through context (platform/data wiring lives here, - * not in the UI). Boot reads the persisted token, restores the durable - * write-queue, and replays it; the children mount only once it is ready. + * The web app's three boot surfaces over the shared boot effect: loading, a + * retryable failure, and the runtime handed to the tree through context. The + * effect itself (read the token, restore and replay the durable write-queue, + * register the teardown) is in `@cue/core/runtime/boot` and is the same on both + * targets; only these three renders differ. */ export function RuntimeBoot({ tokenStore, @@ -34,52 +37,18 @@ export function RuntimeBoot({ redirectUri, children, }: RuntimeBootProps): ReactElement { - const [runtime, setRuntime] = useState(null); - const [failed, setFailed] = useState(false); - const alive = useRef(true); // A dead refresh token routes through the auth store's teardown → onboarding. const endSession = useAuth((s) => s.endSession); - - useEffect(() => { - alive.current = true; - return () => { - alive.current = false; - // No runtime is mounted anymore: a disconnect from here on has nothing to - // flush/clear through, so drop the registered teardown. - sessionTeardown.run = () => Promise.resolve(); - }; - }, []); - - const boot = useCallback(() => { - setFailed(false); - void (async () => { - try { - const token = await tokenStore.read(); - if (token === null) return; - const built = await createCueRuntime({ - token, - kv, - tokenStore, - redirectUri, - endSession, - }); - if (alive.current) { - // Hand disconnect a live teardown: flush pending writes + clear this - // device's caches through the runtime before the token is revoked. - sessionTeardown.run = (options) => built.endLocalSession(options); - setRuntime(built); - } - } catch { - // A boot rejection must never leave the app stuck on the loading spinner; - // surface a retryable error instead. - if (alive.current) setFailed(true); - } - })(); - }, [tokenStore, kv, redirectUri, endSession]); - - useEffect(() => { - boot(); - }, [boot]); + const { runtime, failed, retry } = useRuntimeBoot({ + tokenStore, + kv, + redirectUri, + clientId: TRAKT_CLIENT_ID, + apiBaseUrl: TRAKT_BASE_OVERRIDE, + endSession, + clearPersistedCaches, + clearLocalPreferences, + }); if (failed && runtime === null) { return ( @@ -92,7 +61,7 @@ export function RuntimeBoot({ type="button" className="onb__cta" data-testid="runtime-error-retry" - onClick={boot} + onClick={retry} > Retry diff --git a/packages/web/src/platform/app-visibility.ts b/packages/web/src/platform/app-visibility.ts new file mode 100644 index 00000000..74f8d2e1 --- /dev/null +++ b/packages/web/src/platform/app-visibility.ts @@ -0,0 +1,10 @@ +import type { AppVisibility } from "@cue/core/runtime/app-visibility"; + +/** `AppVisibility` over the Page Visibility API. */ +export const webAppVisibility: AppVisibility = { + isVisible: () => document.visibilityState !== "hidden", + subscribe(listener) { + document.addEventListener("visibilitychange", listener); + return () => document.removeEventListener("visibilitychange", listener); + }, +}; diff --git a/packages/web/src/platform/network.ts b/packages/web/src/platform/network.ts new file mode 100644 index 00000000..15450317 --- /dev/null +++ b/packages/web/src/platform/network.ts @@ -0,0 +1,14 @@ +import type { Network } from "@cue/core/runtime/network"; + +/** `Network` over `navigator.onLine` and the two window events that move it. */ +export const webNetwork: Network = { + isOnline: () => navigator.onLine, + subscribe(listener) { + globalThis.addEventListener("online", listener); + globalThis.addEventListener("offline", listener); + return () => { + globalThis.removeEventListener("online", listener); + globalThis.removeEventListener("offline", listener); + }; + }, +}; diff --git a/packages/web/src/platform/redirect-handoff.ts b/packages/web/src/platform/redirect-handoff.ts new file mode 100644 index 00000000..53174e65 --- /dev/null +++ b/packages/web/src/platform/redirect-handoff.ts @@ -0,0 +1,23 @@ +import type { RedirectHandoff } from "@cue/core/ports/redirect-handoff"; + +const STATE_KEY = "cue.oauth.state"; +// The PKCE verifier is stashed alongside the state nonce so it survives the +// full-page redirect to Trakt and back to `/auth/callback` for the exchange. +const VERIFIER_KEY = "cue.oauth.verifier"; + +/** `RedirectHandoff` over `sessionStorage`: per tab, and gone when it closes. */ +export const sessionRedirectHandoff: RedirectHandoff = { + read() { + const state = sessionStorage.getItem(STATE_KEY); + const verifier = sessionStorage.getItem(VERIFIER_KEY); + return state === null || verifier === null ? null : { state, verifier }; + }, + write(state, verifier) { + sessionStorage.setItem(STATE_KEY, state); + sessionStorage.setItem(VERIFIER_KEY, verifier); + }, + clear() { + sessionStorage.removeItem(STATE_KEY); + sessionStorage.removeItem(VERIFIER_KEY); + }, +}; diff --git a/packages/web/src/platform/reminders.ts b/packages/web/src/platform/reminders.ts index c9ed8893..abc583ca 100644 --- a/packages/web/src/platform/reminders.ts +++ b/packages/web/src/platform/reminders.ts @@ -1,7 +1,11 @@ import { Capacitor } from "@capacitor/core"; import { LocalNotifications } from "@capacitor/local-notifications"; import { type Reminders, SILENT } from "@cue/core/domain/ports/reminders"; -import { diffReminders, type PendingReminder, type PlannedReminder } from "@cue/core/domain/reminders"; +import { + diffReminders, + type PendingReminder, + type PlannedReminder, +} from "@cue/core/domain/reminders"; import { isNativePlatform } from "./platform"; /** diff --git a/packages/web/src/ui/app-shell/RootLayout.tsx b/packages/web/src/ui/app-shell/RootLayout.tsx index aeb2f6e9..a6505220 100644 --- a/packages/web/src/ui/app-shell/RootLayout.tsx +++ b/packages/web/src/ui/app-shell/RootLayout.tsx @@ -1,10 +1,11 @@ +import { useActivitiesPoll } from "@cue/core/hooks/useActivitiesPoll"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; +import { useHaptics } from "@cue/core/runtime/haptics"; +import { useOptionalRuntime } from "@cue/core/runtime/runtime"; import { Link, Outlet } from "@tanstack/react-router"; import { ErrorBoundary } from "@ui/app-shell/ErrorBoundary"; import { navFor } from "@ui/app-shell/nav"; import { AppSnackbar } from "@ui/components/AppSnackbar"; -import { useActivitiesPoll } from "@ui/hooks/useActivitiesPoll"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { useHaptics } from "@ui/runtime/haptics"; import { CircleUserRound, Settings } from "lucide-react"; import type { ReactElement, ReactNode } from "react"; diff --git a/packages/web/src/ui/app-shell/SyncStrip.tsx b/packages/web/src/ui/app-shell/SyncStrip.tsx index 41f2d2df..1f035ff4 100644 --- a/packages/web/src/ui/app-shell/SyncStrip.tsx +++ b/packages/web/src/ui/app-shell/SyncStrip.tsx @@ -1,6 +1,6 @@ -import { useSyncActivity } from "@ui/hooks/sync-activity-store"; -import { useIsOffline } from "@ui/hooks/useIsOffline"; -import { useOptionalRuntime } from "@ui/runtime/runtime"; +import { useIsOffline } from "@cue/core/hooks/useIsOffline"; +import { useOptionalRuntime } from "@cue/core/runtime/runtime"; +import { useSyncActivity } from "@cue/core/stores/sync-activity-store"; import { type ReactElement, useEffect, useState } from "react"; interface SyncStripProps { diff --git a/packages/web/src/ui/components/AppSnackbar.tsx b/packages/web/src/ui/components/AppSnackbar.tsx index cf92e8d2..8965b63f 100644 --- a/packages/web/src/ui/components/AppSnackbar.tsx +++ b/packages/web/src/ui/components/AppSnackbar.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_SNACK_TIMEOUT_MS, type Snack, useSnackbar } from "@ui/components/snackbar-store"; +import { DEFAULT_SNACK_TIMEOUT_MS, type Snack, useSnackbar } from "@cue/core/stores/snackbar-store"; import { type ReactElement, useEffect, useRef, useState } from "react"; /** Downward travel past which a release dismisses the snack. */ diff --git a/packages/web/src/ui/components/ContextMenu.tsx b/packages/web/src/ui/components/ContextMenu.tsx index ed3093c6..32b3a9cf 100644 --- a/packages/web/src/ui/components/ContextMenu.tsx +++ b/packages/web/src/ui/components/ContextMenu.tsx @@ -1,6 +1,6 @@ +import { useHaptics } from "@cue/core/runtime/haptics"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { exceedsPressSlop, LONG_PRESS_MS } from "@ui/components/long-press-math"; -import { useHaptics } from "@ui/runtime/haptics"; import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; interface ContextMenuProps { diff --git a/packages/web/src/ui/components/MarqueeCard.tsx b/packages/web/src/ui/components/MarqueeCard.tsx index ca56191b..aaa29303 100644 --- a/packages/web/src/ui/components/MarqueeCard.tsx +++ b/packages/web/src/ui/components/MarqueeCard.tsx @@ -3,11 +3,11 @@ import type { LibraryEntry } from "@cue/core/data/trakt/library"; import type { EpisodeRef } from "@cue/core/domain/model/library"; import { epCode } from "@cue/core/domain/model/library"; import { toMs } from "@cue/core/domain/time"; +import { episodesLeft, watchedPercent } from "@cue/core/format"; import { Link } from "@tanstack/react-router"; import { artGradient } from "@ui/components/artGradient"; import { CheckControl, type CheckState } from "@ui/components/CheckControl"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { episodesLeft, watchedPercent } from "@cue/core/format"; import { useShowArt } from "@ui/hooks/useShowArt"; import { Poster } from "@ui/screens/up-next/Poster"; import type { ReactElement } from "react"; diff --git a/packages/web/src/ui/components/PullToRefresh.tsx b/packages/web/src/ui/components/PullToRefresh.tsx index 0adaa9f2..4ab803c6 100644 --- a/packages/web/src/ui/components/PullToRefresh.tsx +++ b/packages/web/src/ui/components/PullToRefresh.tsx @@ -1,3 +1,5 @@ +import { useSyncNow } from "@cue/core/hooks/useSyncNow"; +import { useHaptics } from "@cue/core/runtime/haptics"; import { resolveIntent } from "@ui/components/gesture-intent"; import { isArmed, @@ -6,8 +8,6 @@ import { pullProgress, settleDelayMs, } from "@ui/components/pull-math"; -import { useSyncNow } from "@ui/hooks/useSyncNow"; -import { useHaptics } from "@ui/runtime/haptics"; import { type CSSProperties, type PointerEvent, diff --git a/packages/web/src/ui/components/Sheet.tsx b/packages/web/src/ui/components/Sheet.tsx index bfe0169b..bee86ee7 100644 --- a/packages/web/src/ui/components/Sheet.tsx +++ b/packages/web/src/ui/components/Sheet.tsx @@ -1,10 +1,10 @@ +import { useHaptics } from "@cue/core/runtime/haptics"; import { DISMISS_FRACTION, type DragSample, releaseVelocity, settleSheet, } from "@ui/components/sheet-math"; -import { useHaptics } from "@ui/runtime/haptics"; import { Dialog } from "radix-ui"; import { type ReactElement, diff --git a/packages/web/src/ui/components/SwipeAction.tsx b/packages/web/src/ui/components/SwipeAction.tsx index 6f3b363a..5275dd9f 100644 --- a/packages/web/src/ui/components/SwipeAction.tsx +++ b/packages/web/src/ui/components/SwipeAction.tsx @@ -1,6 +1,6 @@ +import { useHaptics } from "@cue/core/runtime/haptics"; import { resolveIntent } from "@ui/components/gesture-intent"; import { clampOffset, commitDirection } from "@ui/components/swipe-math"; -import { useHaptics } from "@ui/runtime/haptics"; import { Check, Pause } from "lucide-react"; import { type ReactElement, type ReactNode, useRef, useState } from "react"; diff --git a/packages/web/src/ui/hooks/useIsOffline.ts b/packages/web/src/ui/hooks/useIsOffline.ts deleted file mode 100644 index 1a75a3b4..00000000 --- a/packages/web/src/ui/hooks/useIsOffline.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useSyncExternalStore } from "react"; - -function subscribeOnline(onChange: () => void): () => void { - globalThis.addEventListener("online", onChange); - globalThis.addEventListener("offline", onChange); - return () => { - globalThis.removeEventListener("online", onChange); - globalThis.removeEventListener("offline", onChange); - }; -} - -/** Live `navigator.onLine` as React state (SyncStrip's offline line, Search's offline notice). */ -export function useIsOffline(): boolean { - return useSyncExternalStore(subscribeOnline, () => !navigator.onLine); -} diff --git a/packages/web/src/ui/hooks/useShowArt.ts b/packages/web/src/ui/hooks/useShowArt.ts index e297d950..f74702bf 100644 --- a/packages/web/src/ui/hooks/useShowArt.ts +++ b/packages/web/src/ui/hooks/useShowArt.ts @@ -1,8 +1,8 @@ import { queryKeys } from "@cue/core/data/query-keys"; import type { ShowInfo } from "@cue/core/data/trakt/show-detail"; +import { CONTENT_STALE_TIME_MS } from "@cue/core/hooks/query-freshness"; +import { useRuntime } from "@cue/core/runtime/runtime"; import { useQuery } from "@tanstack/react-query"; -import { CONTENT_STALE_TIME_MS } from "@ui/hooks/query-freshness"; -import { useRuntime } from "@ui/runtime/runtime"; import { useCallback, useState } from "react"; interface Art { diff --git a/packages/web/src/ui/prefs/preference-storage.ts b/packages/web/src/ui/prefs/preference-storage.ts index 596d945e..5ca9c9cf 100644 --- a/packages/web/src/ui/prefs/preference-storage.ts +++ b/packages/web/src/ui/prefs/preference-storage.ts @@ -26,4 +26,22 @@ export const preferenceStorage: PreferenceStorage = { // A restricted-storage failure just forgets the choice next visit: non-fatal. } }, + clearNamespace(prefix) { + try { + for (let index = localStorage.length - 1; index >= 0; index -= 1) { + const key = localStorage.key(index); + if (key?.startsWith(prefix) === true) localStorage.removeItem(key); + } + } catch { + // Nothing was stored to begin with, so nothing is left behind. + } + }, }; + +/** + * What sign-out drops. On the web the namespaces are physically separate: + * `localStorage` holds only preferences, while the op log, the freshness + * baseline and the persisted query cache live in IndexedDB, so clearing `cue.` + * here cannot reach them. + */ +export const clearLocalPreferences = (): void => preferenceStorage.clearNamespace("cue."); diff --git a/packages/web/src/ui/prefs/prefs-store.ts b/packages/web/src/ui/prefs/prefs-store.ts index d8be2833..d2248189 100644 --- a/packages/web/src/ui/prefs/prefs-store.ts +++ b/packages/web/src/ui/prefs/prefs-store.ts @@ -1,5 +1,10 @@ import { createPrefsStore } from "@cue/core/prefs/prefs-store"; import { preferenceStorage } from "./preference-storage"; -/** The web app's instance of the shared preferences store. */ -export const usePrefs = createPrefsStore(preferenceStorage); +/** + * The web app's preferences store. Screens select from it with core's + * `usePrefs`, which reads it through the provider the composition root renders; + * this reference is for the callers that are not components, the router guards + * and the haptics gate. + */ +export const prefsStore = createPrefsStore(preferenceStorage); diff --git a/packages/web/src/ui/screens/calendar/Calendar.tsx b/packages/web/src/ui/screens/calendar/Calendar.tsx index c236027c..5bf94fef 100644 --- a/packages/web/src/ui/screens/calendar/Calendar.tsx +++ b/packages/web/src/ui/screens/calendar/Calendar.tsx @@ -1,9 +1,9 @@ +import { CALENDAR_WINDOW_DAYS, useCalendar } from "@cue/core/hooks/useCalendar"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { PullToRefresh } from "@ui/components/PullToRefresh"; -import { CALENDAR_WINDOW_DAYS, useCalendar } from "@ui/hooks/useCalendar"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { type ReactElement, type ReactNode, useMemo } from "react"; import { buildAgenda } from "./agenda"; diff --git a/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx index 2c97697d..7162be30 100644 --- a/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx +++ b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx @@ -1,6 +1,19 @@ import { resolveStill } from "@cue/core/data/image-source"; import type { EpisodeDetail, EpisodeNav } from "@cue/core/data/trakt/episode-detail"; import { epCode } from "@cue/core/domain/model/library"; +import { middleTruncate } from "@cue/core/format"; +import { useEpisode } from "@cue/core/hooks/useEpisode"; +import { useEpisodePlays } from "@cue/core/hooks/useEpisodePlays"; +import { + type EpisodeBound, + type MarkContextTarget, + type MarkSeasonController, + useMarkSeason, +} from "@cue/core/hooks/useMarkSeason"; +import { type BackfillOffer, useMarkSnacks } from "@cue/core/hooks/useMarkSnacks"; +import { useSeasons } from "@cue/core/hooks/useSeasons"; +import { useShowDetail } from "@cue/core/hooks/useShowDetail"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { CheckControl } from "@ui/components/CheckControl"; @@ -9,20 +22,7 @@ import { ContextMenu } from "@ui/components/ContextMenu"; import { CountdownPanel } from "@ui/components/CountdownPanel"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { Sheet } from "@ui/components/Sheet"; -import { middleTruncate } from "@cue/core/format"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { useEpisode } from "@ui/hooks/useEpisode"; -import { useEpisodePlays } from "@ui/hooks/useEpisodePlays"; -import { - type EpisodeBound, - type MarkContextTarget, - type MarkSeasonController, - useMarkSeason, -} from "@ui/hooks/useMarkSeason"; -import { type BackfillOffer, useMarkSnacks } from "@ui/hooks/useMarkSnacks"; -import { useSeasons } from "@ui/hooks/useSeasons"; -import { useShowDetail } from "@ui/hooks/useShowDetail"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { backfillRangeLabel, earlierUnwatchedCount, diff --git a/packages/web/src/ui/screens/history/History.tsx b/packages/web/src/ui/screens/history/History.tsx index 69253cc3..b6ca3705 100644 --- a/packages/web/src/ui/screens/history/History.tsx +++ b/packages/web/src/ui/screens/history/History.tsx @@ -1,5 +1,8 @@ import type { HistoryEntry } from "@cue/core/domain/history"; import { localTimeZone } from "@cue/core/domain/time"; +import { type HistoryFilter, type HistoryScope, useHistory } from "@cue/core/hooks/useHistory"; +import { useRemovalSnacks } from "@cue/core/hooks/useRemovalSnacks"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; @@ -12,9 +15,6 @@ import { EpisodeRow } from "@ui/components/EpisodeRow"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { SkeletonRows } from "@ui/components/Skeletons"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { type HistoryFilter, type HistoryScope, useHistory } from "@ui/hooks/useHistory"; -import { useRemovalSnacks } from "@ui/hooks/useRemovalSnacks"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { Poster } from "@ui/screens/up-next/Poster"; import { type ReactElement, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { HistorySearch } from "./HistorySearch"; diff --git a/packages/web/src/ui/screens/library/Library.tsx b/packages/web/src/ui/screens/library/Library.tsx index dc77aea2..4281a1ea 100644 --- a/packages/web/src/ui/screens/library/Library.tsx +++ b/packages/web/src/ui/screens/library/Library.tsx @@ -3,7 +3,15 @@ import type { MovieEntry } from "@cue/core/data/trakt/movie-library"; import type { LibrarySort } from "@cue/core/domain/library-buckets"; import { epCode } from "@cue/core/domain/model/library"; import { isAired } from "@cue/core/domain/time"; + +import { useHideShow } from "@cue/core/hooks/useHideShow"; +import { type LibraryChipKey, useLibraryBuckets } from "@cue/core/hooks/useLibraryBuckets"; +import { useMarkWatched } from "@cue/core/hooks/useMarkWatched"; +import { useMovieActions } from "@cue/core/hooks/useMovieActions"; +import { type MovieSort, useMovieLibrary } from "@cue/core/hooks/useMovieLibrary"; import { choicePref } from "@cue/core/prefs/pref-storage"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; +import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; @@ -13,15 +21,8 @@ import { ContextMenu } from "@ui/components/ContextMenu"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { PullToRefresh } from "@ui/components/PullToRefresh"; import { SegmentedControl } from "@ui/components/SegmentedControl"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { useHideShow } from "@ui/hooks/useHideShow"; -import { type LibraryChipKey, useLibraryBuckets } from "@ui/hooks/useLibraryBuckets"; -import { useMarkWatched } from "@ui/hooks/useMarkWatched"; -import { useMovieActions } from "@ui/hooks/useMovieActions"; -import { type MovieSort, useMovieLibrary } from "@ui/hooks/useMovieLibrary"; import { preferenceStorage } from "@ui/prefs/preference-storage"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { ArrowUpDown, Check, Search as SearchIcon } from "lucide-react"; import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; import { LibrarySkeleton } from "./LibrarySkeleton"; diff --git a/packages/web/src/ui/screens/library/ShowTile.tsx b/packages/web/src/ui/screens/library/ShowTile.tsx index d2bcf3a0..8efebad9 100644 --- a/packages/web/src/ui/screens/library/ShowTile.tsx +++ b/packages/web/src/ui/screens/library/ShowTile.tsx @@ -1,9 +1,9 @@ import type { LibraryEntry } from "@cue/core/data/trakt/library"; import { episodesLeft, watchedPercent } from "@cue/core/format"; +import type { LibraryChipKey } from "@cue/core/hooks/useLibraryBuckets"; import { Link } from "@tanstack/react-router"; import { Badge } from "@ui/components/Badge"; import { ProgressBar } from "@ui/components/ProgressBar"; -import type { LibraryChipKey } from "@ui/hooks/useLibraryBuckets"; import { useShowArt } from "@ui/hooks/useShowArt"; import { Poster } from "@ui/screens/up-next/Poster"; import { Check } from "lucide-react"; diff --git a/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx index d9f05a0f..9b031afc 100644 --- a/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx +++ b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx @@ -1,5 +1,11 @@ import type { MovieEntry, MovieHeader } from "@cue/core/data/trakt/movie-library"; import { formatWatchedDate, middleTruncate, titleCase } from "@cue/core/format"; +import { useMovieActions } from "@cue/core/hooks/useMovieActions"; +import { useMovieDetail } from "@cue/core/hooks/useMovieDetail"; +import { useMovieLibrary } from "@cue/core/hooks/useMovieLibrary"; +import { useMovieRelated } from "@cue/core/hooks/useMovieRelated"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; +import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { Link, useNavigate } from "@tanstack/react-router"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { Badge } from "@ui/components/Badge"; @@ -7,13 +13,7 @@ import { CheckControl } from "@ui/components/CheckControl"; import { DetailHeroSkeleton } from "@ui/components/DetailHeroSkeleton"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { useMovieActions } from "@ui/hooks/useMovieActions"; -import { useMovieDetail } from "@ui/hooks/useMovieDetail"; -import { useMovieLibrary } from "@ui/hooks/useMovieLibrary"; -import { useMovieRelated } from "@ui/hooks/useMovieRelated"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { BackDisc, DetailHero } from "@ui/screens/show-detail/DetailChrome"; import { metaLine, openExternal, traktMovieUrl } from "@ui/screens/show-detail/detail-logic"; import { Poster } from "@ui/screens/up-next/Poster"; diff --git a/packages/web/src/ui/screens/onboarding/Onboarding.tsx b/packages/web/src/ui/screens/onboarding/Onboarding.tsx index 2fc990cf..edb235b9 100644 --- a/packages/web/src/ui/screens/onboarding/Onboarding.tsx +++ b/packages/web/src/ui/screens/onboarding/Onboarding.tsx @@ -1,5 +1,5 @@ +import { useAuth } from "@cue/core/auth/store"; import { CueMark } from "@ui/app-shell/CueMark"; -import { useAuth } from "@ui/auth/store"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { type ReactElement, type ReactNode, useState } from "react"; diff --git a/packages/web/src/ui/screens/profile/Profile.tsx b/packages/web/src/ui/screens/profile/Profile.tsx index 495954a3..cc9fe840 100644 --- a/packages/web/src/ui/screens/profile/Profile.tsx +++ b/packages/web/src/ui/screens/profile/Profile.tsx @@ -1,16 +1,16 @@ import type { UserStats } from "@cue/core/data/trakt/schemas"; import type { UserProfile } from "@cue/core/data/trakt/user-profile"; import { humanizeWatchMinutes } from "@cue/core/domain/time"; +import { useStats } from "@cue/core/hooks/useStats"; +import { useUserProfile } from "@cue/core/hooks/useUserProfile"; import type { MediaVisibility } from "@cue/core/prefs/media-visibility"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; import { Link } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { useStats } from "@ui/hooks/useStats"; -import { useUserProfile } from "@ui/hooks/useUserProfile"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { SignOutRow } from "@ui/screens/settings/SignOutRow"; import { ChevronRight, diff --git a/packages/web/src/ui/screens/search/Search.tsx b/packages/web/src/ui/screens/search/Search.tsx index f4444a3d..2cb85c2f 100644 --- a/packages/web/src/ui/screens/search/Search.tsx +++ b/packages/web/src/ui/screens/search/Search.tsx @@ -1,17 +1,17 @@ import type { SearchHit } from "@cue/core/data/trakt/search"; import { middleTruncate } from "@cue/core/format"; +import { useBrowse } from "@cue/core/hooks/useBrowse"; +import { useIsOffline } from "@cue/core/hooks/useIsOffline"; +import { type SearchView, useSearch, visibleSearchHits } from "@cue/core/hooks/useSearch"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; +import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { PullToRefresh } from "@ui/components/PullToRefresh"; import { SectionHeader } from "@ui/components/SectionHeader"; import { SkeletonRows } from "@ui/components/Skeletons"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { useBrowse } from "@ui/hooks/useBrowse"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { useIsOffline } from "@ui/hooks/useIsOffline"; -import { type SearchView, useSearch, visibleSearchHits } from "@ui/hooks/useSearch"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { ArrowUpLeft } from "lucide-react"; import { type ReactElement, type ReactNode, useEffect } from "react"; import { HitTile } from "./HitTile"; diff --git a/packages/web/src/ui/screens/settings/Settings.tsx b/packages/web/src/ui/screens/settings/Settings.tsx index 5f0b1b43..5ba726cf 100644 --- a/packages/web/src/ui/screens/settings/Settings.tsx +++ b/packages/web/src/ui/screens/settings/Settings.tsx @@ -1,3 +1,4 @@ +import { usePrefs } from "@cue/core/prefs/prefs-store"; import { THRESHOLD_OPTIONS } from "@cue/core/prefs/threshold"; import { LAPSED_ORDER_OPTIONS, @@ -5,13 +6,12 @@ import { NEXT_EPISODE_ORDER_OPTIONS, type NextEpisodeOrder, } from "@cue/core/prefs/tracking"; +import { useAppVersion } from "@cue/core/runtime/app-version"; +import { useReminders } from "@cue/core/runtime/reminders"; +import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { ActionSheet } from "@ui/components/ActionSheet"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { useAppVersion } from "@ui/runtime/app-version"; -import { useReminders } from "@ui/runtime/reminders"; import { ThemeToggle } from "@ui/theme/ThemeToggle"; import { Check, RefreshCw } from "lucide-react"; import { type ReactElement, useState } from "react"; diff --git a/packages/web/src/ui/screens/settings/SignOutRow.tsx b/packages/web/src/ui/screens/settings/SignOutRow.tsx index 8384c75d..2c7e5b06 100644 --- a/packages/web/src/ui/screens/settings/SignOutRow.tsx +++ b/packages/web/src/ui/screens/settings/SignOutRow.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@ui/auth/store"; +import { useAuth } from "@cue/core/auth/store"; import { ConfirmSheet } from "@ui/components/ConfirmSheet"; import { type ReactElement, useState } from "react"; diff --git a/packages/web/src/ui/screens/settings/useSyncStatus.ts b/packages/web/src/ui/screens/settings/useSyncStatus.ts index 6632413c..96738b3d 100644 --- a/packages/web/src/ui/screens/settings/useSyncStatus.ts +++ b/packages/web/src/ui/screens/settings/useSyncStatus.ts @@ -1,8 +1,8 @@ import { queryKeys } from "@cue/core/data/query-keys"; +import { useSyncNow } from "@cue/core/hooks/useSyncNow"; +import { useRuntime } from "@cue/core/runtime/runtime"; +import { useSyncActivity } from "@cue/core/stores/sync-activity-store"; import { useQueryClient } from "@tanstack/react-query"; -import { useSyncActivity } from "@ui/hooks/sync-activity-store"; -import { useSyncNow } from "@ui/hooks/useSyncNow"; -import { useRuntime } from "@ui/runtime/runtime"; import { useCallback, useEffect, useState } from "react"; import { newestSyncedAt, syncStatusLine } from "./sync-status"; diff --git a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx index 770a1ada..b9543e9b 100644 --- a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx +++ b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx @@ -7,12 +7,12 @@ import { } from "@cue/core/data/trakt/show-detail"; import { epCode } from "@cue/core/domain/model/library"; import { isAired } from "@cue/core/domain/time"; +import { episodesLeft, watchedPercent } from "@cue/core/format"; +import type { MarkWatched } from "@cue/core/hooks/useMarkWatched"; import { Link } from "@tanstack/react-router"; import { CheckControl } from "@ui/components/CheckControl"; import { CountdownPanel } from "@ui/components/CountdownPanel"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { episodesLeft, watchedPercent } from "@cue/core/format"; -import type { MarkWatched } from "@ui/hooks/useMarkWatched"; import { useQueueCheck } from "@ui/screens/up-next/useQueueCheck"; import type { ReactElement, ReactNode } from "react"; import { continueKind } from "./detail-logic"; diff --git a/packages/web/src/ui/screens/show-detail/SeasonList.tsx b/packages/web/src/ui/screens/show-detail/SeasonList.tsx index a1a06f57..0d436b4c 100644 --- a/packages/web/src/ui/screens/show-detail/SeasonList.tsx +++ b/packages/web/src/ui/screens/show-detail/SeasonList.tsx @@ -1,9 +1,9 @@ import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; import { epCode } from "@cue/core/domain/model/library"; +import { formatAirDate } from "@cue/core/format"; import { CheckControl } from "@ui/components/CheckControl"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { formatAirDate } from "@cue/core/format"; import { Check, ChevronDown } from "lucide-react"; import { Accordion } from "radix-ui"; import type { ReactElement } from "react"; diff --git a/packages/web/src/ui/screens/show-detail/ShowDetail.tsx b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx index ce609a1c..f6dac47a 100644 --- a/packages/web/src/ui/screens/show-detail/ShowDetail.tsx +++ b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx @@ -1,5 +1,19 @@ import type { EpisodeView, SeasonView, ShowHeader } from "@cue/core/data/trakt/show-detail"; import { epCode } from "@cue/core/domain/model/library"; +import { middleTruncate, titleCase } from "@cue/core/format"; +import { useHideShow } from "@cue/core/hooks/useHideShow"; +import { useLibraryEntry } from "@cue/core/hooks/useLibrarySnapshot"; +import { + type EpisodeBound, + type MarkContextTarget, + useMarkSeason, +} from "@cue/core/hooks/useMarkSeason"; +import { type BackfillOffer, useMarkSnacks } from "@cue/core/hooks/useMarkSnacks"; +import { useMarkWatched } from "@cue/core/hooks/useMarkWatched"; +import { useSeasons } from "@cue/core/hooks/useSeasons"; +import { useShowDetail } from "@cue/core/hooks/useShowDetail"; +import { useToggleWatchlist } from "@cue/core/hooks/useToggleWatchlist"; +import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { Outlet, useRouterState } from "@tanstack/react-router"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { ConfirmSheet } from "@ui/components/ConfirmSheet"; @@ -7,17 +21,7 @@ import { DetailHeroSkeleton } from "@ui/components/DetailHeroSkeleton"; import { EmptyState } from "@ui/components/EmptyState"; import { ErrorRetry } from "@ui/components/ErrorStates"; import { SkeletonRows } from "@ui/components/Skeletons"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; -import { middleTruncate, titleCase } from "@cue/core/format"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; -import { useHideShow } from "@ui/hooks/useHideShow"; -import { useLibraryEntry } from "@ui/hooks/useLibrarySnapshot"; -import { type EpisodeBound, type MarkContextTarget, useMarkSeason } from "@ui/hooks/useMarkSeason"; -import { type BackfillOffer, useMarkSnacks } from "@ui/hooks/useMarkSnacks"; -import { useMarkWatched } from "@ui/hooks/useMarkWatched"; -import { useSeasons } from "@ui/hooks/useSeasons"; -import { useShowDetail } from "@ui/hooks/useShowDetail"; -import { useToggleWatchlist } from "@ui/hooks/useToggleWatchlist"; import { SheetReturnContext } from "@ui/screens/episode-detail/sheet-return"; import { ExternalLink } from "lucide-react"; import { type ReactElement, useEffect, useRef, useState } from "react"; diff --git a/packages/web/src/ui/screens/show-detail/detail-logic.ts b/packages/web/src/ui/screens/show-detail/detail-logic.ts index fb9fd3bd..270c859c 100644 --- a/packages/web/src/ui/screens/show-detail/detail-logic.ts +++ b/packages/web/src/ui/screens/show-detail/detail-logic.ts @@ -1,6 +1,6 @@ import type { SeasonView } from "@cue/core/data/trakt/show-detail"; import type { MovieIds, ShowIds } from "@cue/core/domain/model/ids"; -import type { EpisodeBound } from "@ui/hooks/useMarkSeason"; +import type { EpisodeBound } from "@cue/core/hooks/useMarkSeason"; /** Presentation + planning helpers for the detail surfaces, kept pure for tests. */ diff --git a/packages/web/src/ui/screens/up-next/LapsedDrawer.tsx b/packages/web/src/ui/screens/up-next/LapsedDrawer.tsx index 9b44cd3b..60e56df6 100644 --- a/packages/web/src/ui/screens/up-next/LapsedDrawer.tsx +++ b/packages/web/src/ui/screens/up-next/LapsedDrawer.tsx @@ -1,7 +1,7 @@ +import type { MarkWatched } from "@cue/core/hooks/useMarkWatched"; +import type { UpNextCard } from "@cue/core/hooks/useUpNext"; import { Badge } from "@ui/components/Badge"; import { ConfirmSheet } from "@ui/components/ConfirmSheet"; -import type { MarkWatched } from "@ui/hooks/useMarkWatched"; -import type { UpNextCard } from "@ui/hooks/useUpNext"; import { ChevronDown, EllipsisVertical } from "lucide-react"; import { Accordion } from "radix-ui"; import { type ReactElement, useState } from "react"; diff --git a/packages/web/src/ui/screens/up-next/Previously.tsx b/packages/web/src/ui/screens/up-next/Previously.tsx index a3e616fb..23e357f5 100644 --- a/packages/web/src/ui/screens/up-next/Previously.tsx +++ b/packages/web/src/ui/screens/up-next/Previously.tsx @@ -1,11 +1,11 @@ import type { HistoryEntry } from "@cue/core/domain/history"; import { localTimeZone } from "@cue/core/domain/time"; +import { useHistory } from "@cue/core/hooks/useHistory"; +import { useRemovalSnacks } from "@cue/core/hooks/useRemovalSnacks"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; import { CheckControl } from "@ui/components/CheckControl"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { SectionHeader } from "@ui/components/SectionHeader"; -import { useHistory } from "@ui/hooks/useHistory"; -import { useRemovalSnacks } from "@ui/hooks/useRemovalSnacks"; -import { usePrefs } from "@ui/prefs/prefs-store"; import { entryDetail, entryLink } from "@ui/screens/history/history-view"; import { Poster } from "@ui/screens/up-next/Poster"; import { Fragment, type ReactElement } from "react"; diff --git a/packages/web/src/ui/screens/up-next/QueueRow.tsx b/packages/web/src/ui/screens/up-next/QueueRow.tsx index ea1a4a1d..c45ff965 100644 --- a/packages/web/src/ui/screens/up-next/QueueRow.tsx +++ b/packages/web/src/ui/screens/up-next/QueueRow.tsx @@ -1,12 +1,12 @@ import { epCode } from "@cue/core/domain/model/library"; +import { episodesLeft, lastWatchedPhrase, watchedPercent } from "@cue/core/format"; +import type { MarkWatched } from "@cue/core/hooks/useMarkWatched"; +import type { UpNextCard } from "@cue/core/hooks/useUpNext"; import { CheckControl } from "@ui/components/CheckControl"; import { EpisodeRow } from "@ui/components/EpisodeRow"; import { ProgressBar } from "@ui/components/ProgressBar"; import { SwipeAction } from "@ui/components/SwipeAction"; -import { episodesLeft, lastWatchedPhrase, watchedPercent } from "@cue/core/format"; -import type { MarkWatched } from "@ui/hooks/useMarkWatched"; import { useShowArt } from "@ui/hooks/useShowArt"; -import type { UpNextCard } from "@ui/hooks/useUpNext"; import type { ReactElement, ReactNode } from "react"; import { Poster } from "./Poster"; import { useQueueCheck } from "./useQueueCheck"; diff --git a/packages/web/src/ui/screens/up-next/UpNext.tsx b/packages/web/src/ui/screens/up-next/UpNext.tsx index 3f1926e5..05fcde00 100644 --- a/packages/web/src/ui/screens/up-next/UpNext.tsx +++ b/packages/web/src/ui/screens/up-next/UpNext.tsx @@ -1,3 +1,8 @@ +import { useCalendar } from "@cue/core/hooks/useCalendar"; +import { useHideShow } from "@cue/core/hooks/useHideShow"; +import { type MarkWatched, useMarkWatched } from "@cue/core/hooks/useMarkWatched"; +import { type UpNextCard, useUpNext } from "@cue/core/hooks/useUpNext"; +import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { Link } from "@tanstack/react-router"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; @@ -8,18 +13,13 @@ import { PosterTile } from "@ui/components/PosterTile"; import { PullToRefresh } from "@ui/components/PullToRefresh"; import { SectionHeader } from "@ui/components/SectionHeader"; import { SkeletonMarquee, SkeletonRows } from "@ui/components/Skeletons"; -import { dismissSnack, showSnack } from "@ui/components/snackbar-store"; import { initialTutorialDismissed, persistTutorialDismissed, TutorialCaption, } from "@ui/components/TutorialCaption"; -import { useCalendar } from "@ui/hooks/useCalendar"; import { useDocumentTitle } from "@ui/hooks/useDocumentTitle"; import { useFlip } from "@ui/hooks/useFlip"; -import { useHideShow } from "@ui/hooks/useHideShow"; -import { type MarkWatched, useMarkWatched } from "@ui/hooks/useMarkWatched"; -import { type UpNextCard, useUpNext } from "@ui/hooks/useUpNext"; import { type ReactElement, type ReactNode, useEffect, useMemo, useState } from "react"; import { LapsedDrawer } from "./LapsedDrawer"; import { buildOnTheWay, OnTheWay, useOnTheWayClock } from "./OnTheWay"; diff --git a/packages/web/src/ui/screens/up-next/useQueueCheck.ts b/packages/web/src/ui/screens/up-next/useQueueCheck.ts index 3d705424..7bdd3178 100644 --- a/packages/web/src/ui/screens/up-next/useQueueCheck.ts +++ b/packages/web/src/ui/screens/up-next/useQueueCheck.ts @@ -1,8 +1,8 @@ import type { LibraryEntry } from "@cue/core/data/trakt/library"; import { epCode } from "@cue/core/domain/model/library"; +import { reArmDelay } from "@cue/core/hooks/mark-undo-window"; +import type { MarkWatched } from "@cue/core/hooks/useMarkWatched"; import type { CheckState } from "@ui/components/CheckControl"; -import { reArmDelay } from "@ui/hooks/mark-undo-window"; -import type { MarkWatched } from "@ui/hooks/useMarkWatched"; import { useEffect } from "react"; export interface QueueCheck { diff --git a/packages/web/test/data/read-budget.test.ts b/packages/web/test/data/read-budget.test.ts index 66966051..d4f316de 100644 --- a/packages/web/test/data/read-budget.test.ts +++ b/packages/web/test/data/read-budget.test.ts @@ -350,7 +350,7 @@ describe("cold-sync GET budget", () => { }); it("caps concurrent production endpoint reads across independent runtime callers", async () => { - const { createCueRuntime } = await import("@app/runtime/create-runtime"); + const { createCueRuntime } = await import("@cue/core/runtime/create-runtime"); let inFlight = 0; let peak = 0; const browse = async (): Promise => { @@ -387,7 +387,10 @@ describe("cold-sync GET budget", () => { kv, tokenStore, redirectUri: "https://cue.test/auth/callback", + clientId: "test-client", endSession: async () => undefined, + clearPersistedCaches: async () => undefined, + clearLocalPreferences: () => undefined, }); await Promise.all([runtime.loadBrowse(), runtime.loadBrowse()]); diff --git a/packages/web/test/privacy-claims.test.ts b/packages/web/test/privacy-claims.test.ts index 2e4818ca..04f5db3e 100644 --- a/packages/web/test/privacy-claims.test.ts +++ b/packages/web/test/privacy-claims.test.ts @@ -11,7 +11,7 @@ import servedPolicy from "../../../docs/index.html?raw"; import infoPlistSource from "../../../ios/App/App/Info.plist?raw"; import policy from "../../../PRIVACY.md?raw"; import readme from "../../../README.md?raw"; -import runtimeSource from "../src/app/runtime/create-runtime.ts?raw"; +import runtimeSource from "../../core/src/runtime/create-runtime.ts?raw"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/web/test/support/composition-root-mocks.tsx b/packages/web/test/support/composition-root-mocks.tsx index 08174657..9be411ba 100644 --- a/packages/web/test/support/composition-root-mocks.tsx +++ b/packages/web/test/support/composition-root-mocks.tsx @@ -25,7 +25,7 @@ export function mockCompositionRoot({ const { Settings } = await import("@ui/screens/settings/Settings"); return { AuthGate: () => }; }); - vi.doMock("@app/auth/create-auth-store", () => ({ createAuthStore: () => ({}) })); + vi.doMock("@cue/core/auth/create-auth-store", () => ({ createAuthStore: () => ({}) })); vi.doMock("@app/persist", () => ({ requestPersistentStorage: vi.fn() })); vi.doMock("@app/query-client", () => ({ PERSIST_BUSTER: "test", diff --git a/packages/web/test/ui/activities-poll.test.tsx b/packages/web/test/ui/activities-poll.test.tsx index f5c02a8c..580d74cb 100644 --- a/packages/web/test/ui/activities-poll.test.tsx +++ b/packages/web/test/ui/activities-poll.test.tsx @@ -4,11 +4,14 @@ * (it would repaint server state missing the local marks). Reconnect always * attempts a flush, even hidden; the poll itself stays visibility-gated. */ + +import { useActivitiesPoll } from "@cue/core/hooks/useActivitiesPoll"; +import { type AppVisibility, AppVisibilityProvider } from "@cue/core/runtime/app-visibility"; +import { type Network, NetworkProvider } from "@cue/core/runtime/network"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useActivitiesPoll } from "@ui/hooks/useActivitiesPoll"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; import { act } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { mountAsync } from "./_mount"; function Probe(): null { @@ -37,20 +40,31 @@ function stubRuntime(pending: number, afterFlush = 0): Stub { return { runtime, flushWrites, pollActivities }; } -const mountPoll = (runtime: CueRuntime): Promise => - mountAsync( - - - - - , - ); - -function setVisibility(state: DocumentVisibilityState): void { - Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); +/** A port whose listeners the test fires by hand, so no global is patched. */ +function controllable(initial: boolean): { port: AppVisibility & Network; announce: () => void } { + const listeners = new Set<() => void>(); + const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + return { + port: { isVisible: () => initial, isOnline: () => true, subscribe }, + announce: () => { + for (const listener of listeners) listener(); + }, + }; } -afterEach(() => setVisibility("visible")); +const mountPoll = (runtime: CueRuntime, visible = true): Promise => + mountAsync( + visible, subscribe: () => () => {} }}> + + + + + + , + ); describe("useActivitiesPoll write-queue gating", () => { it("polls straight away when the queue is empty, without flushing", async () => { @@ -75,12 +89,22 @@ describe("useActivitiesPoll write-queue gating", () => { }); it("flushes on reconnect even while hidden, without polling", async () => { - setVisibility("hidden"); + const hidden = controllable(false); const stub = stubRuntime(1, 0); - await mountPoll(stub.runtime); + await mountAsync( + + + + + + + + + , + ); expect(stub.flushWrites).not.toHaveBeenCalled(); // mount poll is visibility-gated await act(async () => { - globalThis.dispatchEvent(new Event("online")); + hidden.announce(); }); expect(stub.flushWrites).toHaveBeenCalledTimes(1); expect(stub.pollActivities).not.toHaveBeenCalled(); diff --git a/packages/web/test/ui/detail-unmark-resolution.test.ts b/packages/web/test/ui/detail-unmark-resolution.test.ts index 6c6caaa8..926b78d0 100644 --- a/packages/web/test/ui/detail-unmark-resolution.test.ts +++ b/packages/web/test/ui/detail-unmark-resolution.test.ts @@ -1,6 +1,6 @@ import type { EpisodePlay } from "@cue/core/domain/reversal"; -import { findMarkPlay, resolveEpisodeUnmark } from "@ui/hooks/resolveUnmark"; -import type { CueRuntime } from "@ui/runtime/runtime"; +import { findMarkPlay, resolveEpisodeUnmark } from "@cue/core/hooks/resolveUnmark"; +import type { CueRuntime } from "@cue/core/runtime/runtime"; import { describe, expect, it } from "vitest"; const EP = 501; diff --git a/packages/web/test/ui/episode-reminders.test.tsx b/packages/web/test/ui/episode-reminders.test.tsx index c82c8f78..73dbd1c0 100644 --- a/packages/web/test/ui/episode-reminders.test.tsx +++ b/packages/web/test/ui/episode-reminders.test.tsx @@ -5,11 +5,12 @@ */ import type { CalendarEntry } from "@cue/core/domain/calendar"; import type { Reminders } from "@cue/core/domain/ports/reminders"; +import { useEpisodeReminders } from "@cue/core/hooks/useEpisodeReminders"; +import type { PreferenceStorage } from "@cue/core/ports/preference-storage"; +import { createPrefsStore, PrefsProvider } from "@cue/core/prefs/prefs-store"; +import { RemindersProvider } from "@cue/core/runtime/reminders"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useEpisodeReminders } from "@ui/hooks/useEpisodeReminders"; -import { usePrefs } from "@ui/prefs/prefs-store"; -import { RemindersProvider } from "@ui/runtime/reminders"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -47,18 +48,29 @@ function Probe(): null { return null; } +/** The preferences this suite drives, over a storage that outlives nothing. */ +function memoryStorage(): PreferenceStorage { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + clearNamespace: () => values.clear(), + }; +} + let root: Root | null = null; let queryClient: QueryClient | null = null; +let prefs = createPrefsStore(memoryStorage()); beforeEach(() => { - usePrefs.setState({ remindersEnabled: true }); + prefs = createPrefsStore(memoryStorage()); + prefs.setState({ remindersEnabled: true }); }); afterEach(() => { act(() => root?.unmount()); root = null; queryClient = null; - usePrefs.setState({ remindersEnabled: false }); vi.useRealTimers(); }); @@ -68,11 +80,13 @@ async function mountHook(loadCalendar: CueRuntime["loadCalendar"]): Promise - - - - - + + + + + + + ); root = createRoot(document.createElement("div")); @@ -141,7 +155,7 @@ describe("useEpisodeReminders", () => { Promise.resolve({ entries: [airingAt(Date.now() + DAY_MS)], hiddenShowIds: [] }), ); - await act(async () => usePrefs.setState({ remindersEnabled: false })); + await act(async () => prefs.setState({ remindersEnabled: false })); expect(reminders.cancelAll).toHaveBeenCalledTimes(1); // Nothing is scheduled while the switch is off, so signing out has nothing @@ -165,7 +179,7 @@ describe("useEpisodeReminders", () => { }); it("asks for nothing while the switch is off, cancel included", async () => { - usePrefs.setState({ remindersEnabled: false }); + prefs.setState({ remindersEnabled: false }); const reminders = await mountHook(() => Promise.resolve({ entries: [airingAt(Date.now() + DAY_MS)], hiddenShowIds: [] }), ); diff --git a/packages/web/test/ui/library-chips.test.ts b/packages/web/test/ui/library-chips.test.ts index 09abad91..61671a3b 100644 --- a/packages/web/test/ui/library-chips.test.ts +++ b/packages/web/test/ui/library-chips.test.ts @@ -1,5 +1,5 @@ import type { LibraryEntry } from "@cue/core/data/trakt/library"; -import { chipBuckets } from "@ui/hooks/useLibraryBuckets"; +import { chipBuckets } from "@cue/core/hooks/useLibraryBuckets"; import { describe, expect, it } from "vitest"; import { DAY, iso, makeShow, NOW, THRESHOLD } from "../domain/_helpers"; diff --git a/packages/web/test/ui/library-snapshot.test.tsx b/packages/web/test/ui/library-snapshot.test.tsx index 68e01231..d742268d 100644 --- a/packages/web/test/ui/library-snapshot.test.tsx +++ b/packages/web/test/ui/library-snapshot.test.tsx @@ -1,13 +1,13 @@ import type { LibraryEntry } from "@cue/core/data/trakt/library"; import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; import type { CalendarEntry } from "@cue/core/domain/calendar"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type LibrarySnapshot, useLibraryEntry, useLibrarySnapshot, -} from "@ui/hooks/useLibrarySnapshot"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; +} from "@cue/core/hooks/useLibrarySnapshot"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; import { describe, expect, it, vi } from "vitest"; import { mountAsync } from "./_mount"; diff --git a/packages/web/test/ui/mark-pipeline.test.tsx b/packages/web/test/ui/mark-pipeline.test.tsx index c324dc3f..a78344af 100644 --- a/packages/web/test/ui/mark-pipeline.test.tsx +++ b/packages/web/test/ui/mark-pipeline.test.tsx @@ -14,18 +14,18 @@ import type { LibraryEntry } from "@cue/core/data/trakt/library"; import type { EpisodeView, SeasonView } from "@cue/core/data/trakt/show-detail"; import type { EpisodePlay } from "@cue/core/domain/reversal"; import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { dismissSnack, useSnackbar } from "@ui/components/snackbar-store"; -import { resetMarkStore } from "@ui/hooks/mark-store"; -import { type MarkSeasonController, useMarkSeason } from "@ui/hooks/useMarkSeason"; -import { useMarkSnacks } from "@ui/hooks/useMarkSnacks"; -import { type MarkWatched, useMarkWatched } from "@ui/hooks/useMarkWatched"; +import { type MarkSeasonController, useMarkSeason } from "@cue/core/hooks/useMarkSeason"; +import { useMarkSnacks } from "@cue/core/hooks/useMarkSnacks"; +import { type MarkWatched, useMarkWatched } from "@cue/core/hooks/useMarkWatched"; import { forgetSeasonMark, getSeasonMarkDelta, rememberSeasonMark, -} from "@ui/hooks/useSeasonReversal"; -import { type CueRuntime, RuntimeProvider, type UpNextData } from "@ui/runtime/runtime"; +} from "@cue/core/hooks/useSeasonReversal"; +import { type CueRuntime, RuntimeProvider, type UpNextData } from "@cue/core/runtime/runtime"; +import { resetMarkStore } from "@cue/core/stores/mark-store"; +import { dismissSnack, useSnackbar } from "@cue/core/stores/snackbar-store"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { mount } from "./_mount"; diff --git a/packages/web/test/ui/mark-store.test.ts b/packages/web/test/ui/mark-store.test.ts index a28c0a7a..eb959316 100644 --- a/packages/web/test/ui/mark-store.test.ts +++ b/packages/web/test/ui/mark-store.test.ts @@ -1,5 +1,6 @@ import { buildAddEpisodePlayOp, buildMarkEpisodeOp } from "@cue/core/domain/write-queue/ops"; import type { QueuedOp } from "@cue/core/domain/write-queue/types"; +import type { CueRuntime } from "@cue/core/runtime/runtime"; import { hasPendingMark, lockShow, @@ -9,8 +10,7 @@ import { resetMarkStore, unlockShow, useMarkStore, -} from "@ui/hooks/mark-store"; -import type { CueRuntime } from "@ui/runtime/runtime"; +} from "@cue/core/stores/mark-store"; import { beforeEach, describe, expect, it } from "vitest"; const WATCHED_AT = "2026-07-05T12:00:00.000Z"; diff --git a/packages/web/test/ui/mark-undo-window.test.ts b/packages/web/test/ui/mark-undo-window.test.ts index ccfafd27..8020cd2a 100644 --- a/packages/web/test/ui/mark-undo-window.test.ts +++ b/packages/web/test/ui/mark-undo-window.test.ts @@ -1,4 +1,4 @@ -import { appendToBatch, reArmDelay } from "@ui/hooks/mark-undo-window"; +import { appendToBatch, reArmDelay } from "@cue/core/hooks/mark-undo-window"; import { describe, expect, it } from "vitest"; const at = (ms: number): { at: number; id: string } => ({ at: ms, id: String(ms) }); diff --git a/packages/web/test/ui/onboarding-screen.test.tsx b/packages/web/test/ui/onboarding-screen.test.tsx index a8cc94f1..dc3909e6 100644 --- a/packages/web/test/ui/onboarding-screen.test.tsx +++ b/packages/web/test/ui/onboarding-screen.test.tsx @@ -4,7 +4,7 @@ * footnote link is present (required attribution), and the device-code beat * renders the code + polling state. */ -import { type AuthActions, type AuthState, AuthStoreProvider } from "@ui/auth/store"; +import { type AuthActions, type AuthState, AuthStoreProvider } from "@cue/core/auth/store"; import { Onboarding } from "@ui/screens/onboarding/Onboarding"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; diff --git a/packages/web/test/ui/optimistic-write.test.ts b/packages/web/test/ui/optimistic-write.test.ts index fa4d9ac6..752867a4 100644 --- a/packages/web/test/ui/optimistic-write.test.ts +++ b/packages/web/test/ui/optimistic-write.test.ts @@ -1,6 +1,6 @@ import type { QueuedOp } from "@cue/core/domain/write-queue/types"; -import { applyOptimisticWrite } from "@ui/hooks/useOptimisticWrite"; -import type { SubmitOutcome } from "@ui/runtime/runtime"; +import { applyOptimisticWrite } from "@cue/core/hooks/useOptimisticWrite"; +import type { SubmitOutcome } from "@cue/core/runtime/runtime"; import { describe, expect, it, vi } from "vitest"; /** A minimal op whose `id` doubles as a label so a fake submit can record order. */ diff --git a/packages/web/test/ui/pull-to-refresh.test.tsx b/packages/web/test/ui/pull-to-refresh.test.tsx index 87bdcd94..6b63ac00 100644 --- a/packages/web/test/ui/pull-to-refresh.test.tsx +++ b/packages/web/test/ui/pull-to-refresh.test.tsx @@ -6,10 +6,10 @@ */ import type { Haptics } from "@cue/core/domain/ports/haptics"; +import { HapticsProvider } from "@cue/core/runtime/haptics"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PullToRefresh } from "@ui/components/PullToRefresh"; -import { HapticsProvider } from "@ui/runtime/haptics"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; import { act } from "react"; import { describe, expect, it, vi } from "vitest"; import { mount } from "./_mount"; diff --git a/packages/web/test/ui/queue-order.test.ts b/packages/web/test/ui/queue-order.test.ts index 2814a005..d0d694a4 100644 --- a/packages/web/test/ui/queue-order.test.ts +++ b/packages/web/test/ui/queue-order.test.ts @@ -1,5 +1,5 @@ import type { UpNextItem } from "@cue/core/domain/up-next"; -import { sortLapsed, sortQueue, stabilizePendingAdvance } from "@ui/hooks/queue-order"; +import { sortLapsed, sortQueue, stabilizePendingAdvance } from "@cue/core/hooks/queue-order"; import { describe, expect, it } from "vitest"; function item( diff --git a/packages/web/test/ui/resolve-movie-unmark.test.ts b/packages/web/test/ui/resolve-movie-unmark.test.ts index 652fb167..88489597 100644 --- a/packages/web/test/ui/resolve-movie-unmark.test.ts +++ b/packages/web/test/ui/resolve-movie-unmark.test.ts @@ -1,6 +1,6 @@ import type { MoviePlay } from "@cue/core/domain/reversal"; -import { resolveMovieUnmark, routeMovieUnmark } from "@ui/hooks/resolveUnmark"; -import type { CueRuntime } from "@ui/runtime/runtime"; +import { resolveMovieUnmark, routeMovieUnmark } from "@cue/core/hooks/resolveUnmark"; +import type { CueRuntime } from "@cue/core/runtime/runtime"; import { describe, expect, it } from "vitest"; /** A runtime that answers only `loadMoviePlays`: the sole read the resolver makes. */ diff --git a/packages/web/test/ui/search-visibility.test.ts b/packages/web/test/ui/search-visibility.test.ts index 9316a44c..b2df2f2e 100644 --- a/packages/web/test/ui/search-visibility.test.ts +++ b/packages/web/test/ui/search-visibility.test.ts @@ -1,5 +1,5 @@ import type { SearchHit } from "@cue/core/data/trakt/search"; -import { visibleSearchHits } from "@ui/hooks/useSearch"; +import { visibleSearchHits } from "@cue/core/hooks/useSearch"; import { describe, expect, it } from "vitest"; function hit(type: "show" | "movie", traktId: number): SearchHit { diff --git a/packages/web/test/ui/sync-strip-pending.test.tsx b/packages/web/test/ui/sync-strip-pending.test.tsx index d87e5e1a..7601de47 100644 --- a/packages/web/test/ui/sync-strip-pending.test.tsx +++ b/packages/web/test/ui/sync-strip-pending.test.tsx @@ -4,8 +4,9 @@ * nothing in flight, and the strip must still say so (at least 3 pending * for >5s → "N marks pending · will sync"). */ + +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { SyncStrip } from "@ui/app-shell/SyncStrip"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/ui/use-calendar.test.tsx b/packages/web/test/ui/use-calendar.test.tsx index 787f9248..daa5eea4 100644 --- a/packages/web/test/ui/use-calendar.test.tsx +++ b/packages/web/test/ui/use-calendar.test.tsx @@ -4,14 +4,14 @@ * 28-day Calendar screen), and narrower callers get a client-side day slice. */ import type { CalendarEntry } from "@cue/core/domain/calendar"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CALENDAR_WINDOW_DAYS, recentCalendarStart, sliceCalendarDays, useCalendar, -} from "@ui/hooks/useCalendar"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; +} from "@cue/core/hooks/useCalendar"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/ui/use-sync-status.test.tsx b/packages/web/test/ui/use-sync-status.test.tsx index d5fe6d8d..20a20929 100644 --- a/packages/web/test/ui/use-sync-status.test.tsx +++ b/packages/web/test/ui/use-sync-status.test.tsx @@ -1,7 +1,7 @@ import { queryKeys } from "@cue/core/data/query-keys"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; +import { dismissSnack, useSnackbar } from "@cue/core/stores/snackbar-store"; import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; -import { dismissSnack, useSnackbar } from "@ui/components/snackbar-store"; -import { type CueRuntime, RuntimeProvider } from "@ui/runtime/runtime"; import { useSyncStatus } from "@ui/screens/settings/useSyncStatus"; import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/ui/watchlist-add.test.tsx b/packages/web/test/ui/watchlist-add.test.tsx index 389b1b6a..f32d3649 100644 --- a/packages/web/test/ui/watchlist-add.test.tsx +++ b/packages/web/test/ui/watchlist-add.test.tsx @@ -1,9 +1,9 @@ import { queryKeys } from "@cue/core/data/query-keys"; import type { LibraryEntry } from "@cue/core/data/trakt/library"; import type { SearchHit } from "@cue/core/data/trakt/search"; +import { useWatchlistAdd, type WatchlistAddView } from "@cue/core/hooks/useWatchlistAdd"; +import { type CueRuntime, RuntimeProvider, type UpNextData } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useWatchlistAdd, type WatchlistAddView } from "@ui/hooks/useWatchlistAdd"; -import { type CueRuntime, RuntimeProvider, type UpNextData } from "@ui/runtime/runtime"; import { act } from "react"; import { expect, it } from "vitest"; import { mountAsync } from "./_mount"; diff --git a/packages/web/test/url/search-params.test.ts b/packages/web/test/url/search-params.test.ts new file mode 100644 index 00000000..eedd61e0 --- /dev/null +++ b/packages/web/test/url/search-params.test.ts @@ -0,0 +1,52 @@ +import { parseHistorySearch, parseLibrarySearch } from "@cue/core/url/search-params"; +import { describe, expect, it } from "vitest"; + +describe("parseLibrarySearch", () => { + it("keeps the one param Movies pins", () => { + expect(parseLibrarySearch({ type: "movies" })).toEqual({ type: "movies" }); + }); + + it("drops everything else, so bare /library is Shows", () => { + expect(parseLibrarySearch({})).toEqual({}); + expect(parseLibrarySearch({ type: "tv" })).toEqual({}); + expect(parseLibrarySearch({ type: 7 })).toEqual({}); + expect(parseLibrarySearch({ type: "movies", stray: "1" })).toEqual({ type: "movies" }); + }); +}); + +describe("parseHistorySearch", () => { + it("keeps a known medium and drops anything else", () => { + expect(parseHistorySearch({ type: "tv" })).toEqual({ type: "tv" }); + expect(parseHistorySearch({ type: "movies" })).toEqual({ type: "movies" }); + expect(parseHistorySearch({ type: "books" })).toEqual({}); + }); + + it("accepts any real year in the generous range and rejects the rest", () => { + expect(parseHistorySearch({ year: "1999" })).toEqual({ year: 1999 }); + expect(parseHistorySearch({ year: 2100 })).toEqual({ year: 2100 }); + expect(parseHistorySearch({ year: 1969 })).toEqual({}); + expect(parseHistorySearch({ year: 2101 })).toEqual({}); + expect(parseHistorySearch({ year: "nineteen" })).toEqual({}); + expect(parseHistorySearch({ year: 1999.5 })).toEqual({}); + }); + + it("drops a month with no valid year, because a month alone is not a position", () => { + expect(parseHistorySearch({ month: 3 })).toEqual({}); + expect(parseHistorySearch({ year: 1969, month: 3 })).toEqual({}); + expect(parseHistorySearch({ year: 1999, month: 3 })).toEqual({ year: 1999, month: 3 }); + }); + + it("rejects a month outside 1 to 12 while keeping its year", () => { + expect(parseHistorySearch({ year: 1999, month: 0 })).toEqual({ year: 1999 }); + expect(parseHistorySearch({ year: 1999, month: 13 })).toEqual({ year: 1999 }); + expect(parseHistorySearch({ year: 1999, month: "March" })).toEqual({ year: 1999 }); + }); + + it("carries all three together, and nothing it was not given", () => { + expect(parseHistorySearch({ type: "movies", year: "2024", month: "12", other: "x" })).toEqual({ + type: "movies", + year: 2024, + month: 12, + }); + }); +}); diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index b2f8d788..d057748c 100644 --- a/scripts/write-buster.mjs +++ b/scripts/write-buster.mjs @@ -21,10 +21,10 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const SHAPE_TREES = [ "packages/core/src/domain", "packages/core/src/data", - "packages/web/src/ui/runtime", + "packages/core/src/runtime", ]; -const GENERATED = "packages/web/src/ui/runtime/persist-buster.ts"; +const GENERATED = "packages/core/src/runtime/persist-buster.ts"; /** * Every module specifier in an `import`, `export ... from`, dynamic `import()` diff --git a/vitest.config.ts b/vitest.config.ts index 4c3a8036..1d26bf14 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,13 @@ export default defineConfig({ // The composition root (src/app) and every presentational surface (src/ui) // are gated by the hermetic Playwright suite, not a line threshold // Line coverage targets the logic layers below. - exclude: ["**/*.d.ts", "packages/web/src/app/**", "packages/web/src/ui/**"], + exclude: [ + "**/*.d.ts", + "packages/web/src/app/**", + "packages/web/src/ui/**", + // The composition root, as src/app has always been. + "packages/core/src/runtime/**", + ], thresholds: { // Global floor = rot tripwire, not the quality bar. Logic layers carry // the real gate below; ui/ behavior is gated by the Playwright suite. @@ -21,6 +27,12 @@ export default defineConfig({ "packages/core/src/domain/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, "packages/core/src/data/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, "packages/core/src/prefs/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/core/src/url/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + "packages/core/src/stores/**": { lines: 90, functions: 90, statements: 90, branches: 80 }, + // A ratchet, not a target: the hook layer has never been inside a + // coverage number, so this is what it measured on the commit that + // moved it. It may go up and never down. + "packages/core/src/hooks/**": { lines: 54, functions: 52, statements: 53, branches: 42 }, }, }, }, From ce4e32df2a61dc92d65c854101809ce218e6fce0 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 20:29:59 -0500 Subject: [PATCH 006/435] Make knip bite over @cue/core, and delete its first victim `knip.json` gave the core workspace `entry: ["src/**/*.ts"]`, which read as "every file is an entry point" and switched knip's file and export lanes off over 110 files. Removing that line does not fix it: knip derives entry points from a workspace's manifest as well, and `@cue/core` exports its whole source tree through one wildcard subpath key, which knip expands to `./src/**/*.ts`. Under `exports: { "./*": "./src/*.ts" }` every core module is public API by construction, so no file is ever unreachable and no export is ever unused, and no `entry` list can say otherwise. `includeEntryExports` is knip's answer to exactly that: it reports the exports of entry files, so the core's public surface is measured against what the workspace actually imports rather than against its own export map. The wildcard key stays, because it is the package contract 1.2 chose and 102 of the core's 110 modules are imported by name from the web app. Mutated both ways on this commit. A dead file `packages/core/src/domain/dead-probe.ts` and an unused `UNUSED_PROBE` export in `packages/core/src/format.ts` both pass knip before the change and both fail it after, naming their file and line; both already failed in `packages/web`, which is what the asymmetry was. The first thing it caught is real. `NO_REDIRECT_HANDOFF` in `packages/core/src/ports/redirect-handoff.ts` had no consumer anywhere in the repository: `redirectHandoff` is a required member of `AuthDeps`, so a composition root cannot fall back to an inert default and none does. It is deleted here, with the doc comment that promised it rewritten to say what the required member means instead. `ports/` is not a shape tree, so the buster is untouched. The README's knip bullet claimed a coverage the config did not have. It now says what the shared package's lane rests on. --- README.md | 2 +- knip.json | 4 ++-- packages/core/src/ports/redirect-handoff.ts | 11 ++--------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1eaa6023..d454de31 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Every e2e run builds the app and starts its own preview server on port 4173. Set - **cspell**: spelling across TS/TSX/CSS/MD. - **tsc**: strict TypeScript type-check (`--noEmit`). - **dependency-cruiser**: layering rules (`@capacitor/*` confined to `packages/web/src/platform`), cruised over every package in one pass. -- **knip**: no unused files, dependencies, or exports. +- **knip**: no unused files, dependencies, or exports. `@cue/core` exports every module through one wildcard subpath, which would make each of its files an entry point and switch the export lane off over the shared package, so that workspace sets `includeEntryExports` and its public surface is reported the moment nothing imports it. - **jscpd**: duplicate-code detection. - **Vitest**: unit tests, one project per package, with coverage thresholds on `domain` and `data`. - **Vite build**: a production build must compile. diff --git a/knip.json b/knip.json index bf72b05f..fc6d2d2a 100644 --- a/knip.json +++ b/knip.json @@ -7,8 +7,8 @@ "project": ["scripts/**/*.mjs", "*.ts"] }, "packages/core": { - "entry": ["src/**/*.ts"], - "project": ["src/**/*.ts"] + "project": ["src/**/*.ts"], + "includeEntryExports": true }, "packages/web": { "project": ["src/**/*.{ts,tsx}"], diff --git a/packages/core/src/ports/redirect-handoff.ts b/packages/core/src/ports/redirect-handoff.ts index 9a75a342..fe95f8a2 100644 --- a/packages/core/src/ports/redirect-handoff.ts +++ b/packages/core/src/ports/redirect-handoff.ts @@ -6,8 +6,8 @@ * A port rather than `sessionStorage` because the semantics, not just the API, * are the browser's: per tab, and gone when the tab closes, which is exactly * what a single-use nonce wants and what makes an abandoned attempt expire on - * its own. A target with no page navigation has no handoff to make and no flow - * to make it for, so its implementation is the inert one below. + * its own. It is a required member of `AuthDeps`, so a target with no page + * navigation states what it does instead of inheriting a default. */ export interface RedirectHandoff { read(): { readonly state: string; readonly verifier: string } | null; @@ -15,10 +15,3 @@ export interface RedirectHandoff { /** Called the moment the state is accepted: both values are single-use. */ clear(): void; } - -/** Nothing is stashed, so nothing is ever handed back and the flow refuses. */ -export const NO_REDIRECT_HANDOFF: RedirectHandoff = { - read: () => null, - write: () => {}, - clear: () => {}, -}; From f854270272923d7ac0b92a2efb3b9983acd71c3b Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 20:32:44 -0500 Subject: [PATCH 007/435] State domain-stays-pure and data-stays-headless positively Both rules were blacklists of directory names. Before the extraction the one name `^src/ui/` covered the hooks, the prefs, the auth layer and the stores, so the blacklist was complete by accident. Step 5 moved those trees to `packages/core/src/{hooks,prefs,auth,url,stores}` and `format.ts`, the rules were repathed to `^packages/core/src/(data|ports)/` and `^packages/[^/]+/src/(ui|platform|app)/`, and nothing named the new directories. A domain module could import a React hook and a data module could import the auth store with the arch gate silent; where anything fired at all it was `no-circular` by luck, because the target happened to import the domain back, and a leaf module would have passed. Both are now stated as what the layer MAY reach. In-repo, the domain reaches the domain; the data layer reaches the domain, its own tree and the ports it is filled through. Everything else under `packages/` is a violation, so the next directory added to the core is banned on the day it is created rather than on the day someone remembers to amend a list. The npm half is unchanged and still enumerated: react and react-dom for both, capacitor for the domain. Negative cases run on this commit, each naming its own file, and none of them resolved through `no-circular`: domain -> stores/snackbar-store domain-stays-pure domain -> hooks, prefs, auth, url, format domain-stays-pure (five) domain -> runtime, ports, data domain-stays-pure (three) domain -> web/src/ui, react, @capacitor domain-stays-pure (three) data -> stores/snackbar-store data-stays-headless data -> hooks, prefs, auth, url, format data-stays-headless (five) data -> web/src/platform, react data-stays-headless (two) data -> ports/kv allowed, cruise clean The two the review named, a domain module and a data module importing the snackbar store, are errors again; both passed before this commit. --- .dependency-cruiser.cjs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 09cabdff..4cf1f442 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -65,16 +65,11 @@ module.exports = { name: "domain-stays-pure", severity: "error", comment: - "The domain is runtime-agnostic: global fetch + zod only. No data/ports, no app, no react, no react-dom, no capacitor.", + "The domain is runtime-agnostic: global fetch + zod only. Stated positively, as what it MAY reach rather than as a list of the directories it may not: the ban then covers a directory added to the core tomorrow instead of waiting to be amended. In-repo, the domain may reach the domain and nothing else; from npm it may take no react, no react-dom and no capacitor.", from: { path: "^packages/core/src/domain/" }, to: { - path: [ - "^packages/core/src/(data|ports)/", - "^packages/[^/]+/src/(ui|platform|app)/", - RE_REACT, - RE_REACT_DOM, - RE_CAPACITOR, - ], + path: ["^packages/", RE_REACT, RE_REACT_DOM, RE_CAPACITOR], + pathNot: "^packages/core/src/domain/", }, }, { @@ -89,9 +84,12 @@ module.exports = { name: "data-stays-headless", severity: "error", comment: - "The data layer (clients/repos) may import domain, but never ui/app/platform, react, or react-dom.", + "The data layer (clients/repos) is stated the same way round: in-repo it may reach the domain, its own tree and the ports it is filled through, and nothing else. That is what keeps a repository from reaching a hook, a store or the auth layer, which the directory blacklist this replaces stopped covering the moment those trees moved into the core. From npm it may take no react and no react-dom.", from: { path: "^packages/core/src/data/" }, - to: { path: ["^packages/[^/]+/src/(ui|platform|app)/", RE_REACT, RE_REACT_DOM] }, + to: { + path: ["^packages/", RE_REACT, RE_REACT_DOM], + pathNot: "^packages/core/src/(data|domain|ports)/", + }, }, { name: "ports-have-no-impls", From 5757ea1b8a3ae6f4b60a9e6d7915c94d2d0dead4 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 20:40:56 -0500 Subject: [PATCH 008/435] Close the two shape-witness collisions the sorted line multiset admits The witness now hashes each file's body in source order, and its import statements separately as a sorted multiset with every specifier replaced by the digest of the module it resolves to inside the shape trees. Source order is what closes the field-moved-between-two-interfaces collision, and content-addressing the target is what closes the repointed-import one while keeping a move and a respelling invisible, because the same file has the same digest under any path. Sorting whole files bought invariance to organize-imports reordering the block and paid for it by making the digest blind to where a declaration sits. Moving `readonly still: string | null` from `EpisodeRef`, which every persisted `nextEpisode` is replayed against, to `EpisodeKey` in the same file is the same multiset of lines and a different shape, and it printed `ok`. Lifting only the import statements out of the ordered part buys the same invariance without that cost: nothing in an import line states a shape, so nothing is lost by taking the block out of source order, and the statement text is still hashed, so rebinding a name to a different exported symbol is still visible. Collapsing every specifier to one constant made the witness unable to say which module a type came from. It is now collapsed only for a target outside the shape trees, where the trees' own closure argument says no persisted shape resolves. A target inside them is named by its own content digest, so `git mv` plus the repointing of every importer is still invisible, and so is respelling `@domain/x` as `@cue/core/domain/x` across the tree, while pointing an existing import at a different module in the tree is not. `--reseed` writes the witness while leaving the buster alone, which is what a change to the measurement needs and a change to a shape must not have. The shipped buster is unchanged at 4d3253e9dc61; only `shape` moves, from 94b50333a5bf to d61fcb598933, and this commit touches no file under the shape trees. Mutations, all run on this commit: move `still` from EpisodeRef to EpisodeKey fails (was ok) a5d744050aef repoint ./ids at ./token in the same file fails (was ok) 90469235cc60 git mv domain/time.ts to data/, 11 importers repointed, bytes identical ok, witness unchanged respell 12 @cue/core/... as relative ok, witness unchanged reverse the import block of runtime.ts ok, witness unchanged add one exported type to model/library.ts fails 3dffe35c1440 Checked while writing it: the statement regex extracts all 142 import statements across the 53 files in the trees, including the wrapped ones, and matches nothing else; all 126 in-tree resolutions name a file that exists; the only specifiers that fall outside are react, zod, @tanstack/react-query and the three `ports/` seams. --- packages/core/src/runtime/persist-buster.ts | 2 +- scripts/write-buster.mjs | Bin 5072 -> 7404 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/runtime/persist-buster.ts b/packages/core/src/runtime/persist-buster.ts index da3ea875..6e065297 100644 --- a/packages/core/src/runtime/persist-buster.ts +++ b/packages/core/src/runtime/persist-buster.ts @@ -13,5 +13,5 @@ */ export const PERSISTED_CACHE = { buster: "d0c3d97c58b8", - shape: "06b3b0833416", + shape: "574f011906ba", } as const; diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index d057748ca9f7b56b7f42c51d5c5fcee461464614..1b884ef5b3339063722f8d52ab39b53c202ec75e 100644 GIT binary patch literal 7404 zcmbVRZFAek5$MAed;{JHsB5w9;8_)plb_ zeuZ^K=AebDN;WFZU{zy`JtcXh{z5FYYFW9B%3*AyA;MKGpgSrJs_{lKcR>oWe;z23 zRk zI_=~W+ZA3MOI+E$G)O9S0egR~vhsLgt=*=-bcJ)?U;N{$dWRRsOWP*L%9Erjty350 z=U3{v@{*(Q_h@@0hR20*I>zqdaESerCr?h)6V5@6602!T zmlbBA+^)tJ@inJyO2Y1R-Ws2$l{J19QlQH3j6xn~jmdW^E0HscR6D)k#f!8mbXG1# z%2oiC51uB8*z2kWSt;1aES}W4rcBps?12jsUHW996xmd*&mBqzt(H4y6s18-;@(u8 zBU+Jz)Y7=Ek^CoR$qOTeRAl|Ms&#S;V2^+=XyqVGkSCcrB6-jksfo=^RXM8)y@OCv z5mAGRK_5a7t5fdW1|LY5iI3prtFw2rk5?D7*(CybeWKLBA8aQHOrO{$nTUf1IPx|B(D&*h55wLisP8^ zv>-3qEHz`ZTA9RkOBJEU6vzaCo`458ksrZb+p0lK1?pG@pjIomJkM~Frx*}Uk(Jt! zE3GNAqyl{)E3ZGC-aL7+pg0j*kigs^9K;$)u3N;%*2of)g(W^o=xw20vQaR&B$>5F zI?-bTe#Gfsxt`95w+4^tx<+&f%jwv&N)8Gq1;A%sDoBnFwQH}B3bu0CE~Axdv%Zvk;ce)1uDF_q&!i^GS}i$~YkotP=ks}a^Lajxhfjv{r+9t(`Gd&$_R=3|>116<*wsv!wKhq%%D}r! z(;nO~ymb_D_MhOy0}4^fb#37=gpu6iDo}TPK*0I?tIO9fXOxV;^HL532T^zt)kp}G zz~_He_wa=Hazh0EyvOFz0IQ$<@lONro8qDp$x7Ny0rgU7W8BstAQLSf-_z6myj<|E z#<;dA6)#CM6d)H3Hs%wF+#k~aObEo*sDeTl8S>4iLYFU=*^Nn(VXsVXIn0z7SIDzv zmF|%HrlvA(!6t2!7+@zg%?L!2aO(0`O08gp=b@=8Je!d?L?lO1Crpu#L1Cs@X-I?9 zu>pMma{BNT^@kGOB89~iDxX{g7>`7c-$Nn8ZWG*q`l}WV3Q!7BN97q@8JZYh1E6jo zYSPYgb<9@x{e$vMt|p=I7Cg{U3sSVX%jj?-cvRgH)p!6Er60n$BPdY+1>q7UwS<o*$q&^&2PdW36Y2wz@C@4fTB^5*peupde9W@mWFoAxP zlT}+LE`t`FaFOK6um^>nOHo6>^Z_HBVb6VL#T=5vL=Wj@q@qs3NP)%IP1V*1ys3i) zHm+N{iS7?iU}aE!s$}E!(;M~TMfmdLjoLG|6hgu18UWwQKlG0X*8O9s)F}qYl>g6P zm5Rj!gLyd^hDU|2qlj{p`GG#fuE~mM*sVZ10}j#Os|sme4oNn^ybn#3WYGDFh^F%c zqDRi_S$Dpo1F#n?6J7J^aMfW$%!(K^@vI1aIK1tAFOP%Aj>Jd2cQ}j*1`(@?dOsd> zZHMh~%o^e38~8JnV&Nj7)dPX*Tj+wrF+{Krz{^R?elsp4l z_XWhcDc5k<9ut8DljtE+di#2($Ah7Iq0r{AQ@abIH)Gf%AQ7sjY<=)30p~1G3#vTB zjZjF18i_;m1F7ItQYAJtb3hYPI*K=Q$CEdmXJ-vHdyvntEgDP~u9D_+VNVTRdrP(T4MmcQ-C0*`c)2HJZ z!xu0Zd7uq%=msAY@|+DT4kuhwVPf*JH*%1dFAr}Rm;TW)Z3PB8Izl*z4q(-BG^!C- zyH0;{5d9}M{|1h;@!B}SJ{v=uA5xx|`DI@~i$g7>&q`5863_=CqMJhJ7(OWgSp3|r z1tQU@cn+1OLn*IN;4>-5*A4C4VLtx>#9;fwr zOZ+9HCF@kOFnvAieyimXt zCLP_YSQ6SpI0_w%81F}vWIG(&L*1!gfTqCUB^UvGU}N2^@2;Og#0e=UFHsH9DDk)z zeAjav_wT`P(1!1bwUH|#b2hJ_auW0i939TH4n?$ydCV92lw8P^b}9p2nx&d_N)r4K zl?{O(`0swy$;P8_%s$4QQ~jaK@xfW|7A5=Tl9k5lqAk^;F6+Wiho>@+<%<}f$rOte z$HfU^eZ%O}IM`u6p}}~{5BE4VJcfC=b$j!U?W%53)7U=;OZ<5H@qL%G(LkOJhWnvh zqxXE%RS&~MO8e*>%}gIAGH9CGmf)y^_ar61sW_d12~CBmU(h_rK^QvayJz227!Jj( z{wSfmgrkfK<`eRMi(#_Z5XK-=*-hf+3$%y1xxyrUs$Q3?tjyeQj<%JrZY#uk!$0k; zseH(U0C)fSVq(!8Pe|2|8uNg=%z0+Bo3`fp0xM8*k7iB28gGowx#r77Z6tGWj7!#-tm>La;~Ufjwny0#bG{7?!ZqeP zh%d(dDfLxibf3SNH{rDIS0hVap@`riMJW6{1#LN6Zi|_U|4E83ftrLORDGLa9wz>6 zMw2j5a!XqDLIG#6@iZZmQ4~q#BE@$)=@Ic3V>4=X1woWuU-=PA9e#ldcWOSq+eG?N zo)t!0XhK8pBW-9fha_X8<^J3qjKbd1+`At;5+^amwHaR~Cw}NIMfBb5;_~$+X8Iq0 pI{WDr4O0p00y`A<$M>EXxHPa_e#)Qrc(|vl5sNxK!Np>K@*l|_uRH(% delta 1447 zcmZ8hPj4GV6jz9#$f#9Q^#D?YUX8)Kk>jXML875pX$cU-KT-9REo8j&>>WBgv&_t9 zF^c39cdGpYe23f+7w#O8xN+dbksDut_hy|?>VvgA``*0YznQ<^{%!cfH6lu3AzQ8R@NH9SD34rLI=u#Fzw~%H>V?z3nNOE!?7wW>q3$q_h1XGgJXx^)XE{P zK(0mYaRj;5T2vk-1Pj(6Ef^n!31ip?hcl4}>I{Vl^hWcv_U%uXZ!aA-P6hN}P&mB+ zjJRfsP$|RnLV+G3IHVe3B;6 z8e3}#(%EV?Xz2@8Rm#kPSh%5wT;v6gsdlFq8Ner>z4!3rgRdTZesKTr{lkMV;4bVW zlXq?$jk9L(C{1S0mZzOUrz>63B=cKYGNJ!8IqDCoYI5i3%lYKp?(|mHIw+3;>I0LBJD zW9xZAhQvPGm^<`vpjmlS?q3CLaWwEav{!d z3QMj{wap)oqE!rvS&^*M1$$iDlW2pO3kuqd04pNu@I)5@>fpTn7mO33YeGx=z)C-gxt5U@gvKsGB1PDVpeV02 zsN2prqJm0`oI~d*e(VLKj6oaGSPrNlcC;A?DN&9{4CW{Kgg6X~3MC^u&#Q>8MP0b8 zlT2DgSq@L<%~)3oO>R@r5^rmlV+wcQ?Ex9VFZ2K~b{Nqm&!%$(!@XUlO!nVn9KI}jhipXNVtj!VvI7+CAx$>bKr}i{SGI{*CKO(%0kyQ_@bay|JA>RnZMe`UQU5Pe-xye?<4% z?=S7X-c|9r>YjWW37zl1Li*>WSI@3r|8>3n<>h;4S6=z!xrcO9O))fxCW?^TvZQkr nuQz3aqiZ#uV!nquJ?Q4@j)U_7=4fg Date: Sat, 22 Aug 2026 20:45:38 -0500 Subject: [PATCH 009/435] Move the composition root out of the shape trees, into core/src/app Step 5 put `create-runtime.ts`, `boot.ts` and `session.ts` into `packages/core/src/runtime`, which is one of the three trees the persisted-shape witness is taken over. The tree went from four files and 280 lines to eleven and 1,051, and the largest new arrival is the 471-line composition root: the most edited file in the package, and one that types no persisted value. Left there, every edit to it fails `buster:check`, and the only way past a failing `buster:check` is a bump that drops every shipping user's query cache, priced in d60ef51 at roughly 72 cold GETs against a 1000 GET budget and an empty first screen offline. The mechanism whose purpose is to stop unnecessary busts was pointed at the file most likely to cause them. `runtime/` is now what its name says: the `CueRuntime` port, the query-cache policy that names the persisted key heads, the generated buster, and the four ports that carry a React context. The composition root, the boot effect and the session teardown handoff are `core/src/app/`, the same name both apps already use for their own composition roots. The exemption is a directory boundary rather than a list of exempt filenames in the script, so the next file added to `runtime/` is inside the witness by default rather than by amendment. The witness moves because its membership changed, and no shape did. Proved by recomputing it at 88d610a with exactly those three files filtered out of the trees: 664e3e3eef67, the same value this commit computes. No file that stayed in a tree is touched here. `--reseed` writes it with the buster left at 4d3253e9dc61. Mutations on this commit: append an export to app/create-runtime.ts ok, witness unchanged append an export to app/boot.ts ok, witness unchanged append an export to app/session.ts ok, witness unchanged add a key head to runtime/query-cache.ts fails acda2f0679e9 retype EpisodeRef.still in domain/model fails 9b2ee22093a9 The same split closes the coverage exclusion the review found beside it. `vitest.config.ts` excluded all of `core/src/runtime` "as src/app has always been", which was right for the composition root and wrong for `app-visibility`, `network`, `haptics`, `reminders` and the query-cache policy: library code both targets run. It now excludes `core/src/app` only, and those files carry a real number for the first time. --- packages/core/src/{runtime => app}/boot.ts | 2 +- .../src/{runtime => app}/create-runtime.ts | 2 +- packages/core/src/{runtime => app}/session.ts | 0 packages/core/src/auth/create-auth-store.ts | 2 +- packages/core/src/runtime/persist-buster.ts | 2 +- packages/web/src/app/runtime/RuntimeBoot.tsx | 4 ++-- packages/web/test/data/read-budget.test.ts | 2 +- packages/web/test/privacy-claims.test.ts | 2 +- scripts/write-buster.mjs | Bin 7404 -> 7991 bytes vitest.config.ts | 5 +++-- 10 files changed, 11 insertions(+), 10 deletions(-) rename packages/core/src/{runtime => app}/boot.ts (98%) rename packages/core/src/{runtime => app}/create-runtime.ts (99%) rename packages/core/src/{runtime => app}/session.ts (100%) diff --git a/packages/core/src/runtime/boot.ts b/packages/core/src/app/boot.ts similarity index 98% rename from packages/core/src/runtime/boot.ts rename to packages/core/src/app/boot.ts index 8ae07ae8..96d05a5e 100644 --- a/packages/core/src/runtime/boot.ts +++ b/packages/core/src/app/boot.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import type { CueRuntime } from "../runtime/runtime"; import { createCueRuntime, type RuntimeDeps } from "./create-runtime"; -import type { CueRuntime } from "./runtime"; import { sessionTeardown } from "./session"; /** Everything the runtime needs except the token, which boot reads for itself. */ diff --git a/packages/core/src/runtime/create-runtime.ts b/packages/core/src/app/create-runtime.ts similarity index 99% rename from packages/core/src/runtime/create-runtime.ts rename to packages/core/src/app/create-runtime.ts index 671d10bd..9964f612 100644 --- a/packages/core/src/runtime/create-runtime.ts +++ b/packages/core/src/app/create-runtime.ts @@ -59,7 +59,7 @@ import type { MovieLibraryData, SubmitOutcome, UpNextData, -} from "./runtime"; +} from "../runtime/runtime"; import { PendingWritesError, type TeardownOptions } from "./session"; const OP_LOG_KEY = "cue.write-queue"; diff --git a/packages/core/src/runtime/session.ts b/packages/core/src/app/session.ts similarity index 100% rename from packages/core/src/runtime/session.ts rename to packages/core/src/app/session.ts diff --git a/packages/core/src/auth/create-auth-store.ts b/packages/core/src/auth/create-auth-store.ts index 8b280486..c38014b1 100644 --- a/packages/core/src/auth/create-auth-store.ts +++ b/packages/core/src/auth/create-auth-store.ts @@ -1,4 +1,5 @@ import { createStore } from "zustand/vanilla"; +import { PendingWritesError, sessionTeardown } from "../app/session"; import { buildAuthorizeUrl, exchangeCodeForToken, @@ -11,7 +12,6 @@ import { createPkcePair } from "../data/auth/pkce"; import type { Token } from "../domain/model/token"; import type { RedirectHandoff } from "../ports/redirect-handoff"; import type { TokenStore } from "../ports/token-store"; -import { PendingWritesError, sessionTeardown } from "../runtime/session"; import type { AuthActions, AuthState, AuthStore } from "./store"; export interface AuthDeps { diff --git a/packages/core/src/runtime/persist-buster.ts b/packages/core/src/runtime/persist-buster.ts index 6e065297..b1591894 100644 --- a/packages/core/src/runtime/persist-buster.ts +++ b/packages/core/src/runtime/persist-buster.ts @@ -13,5 +13,5 @@ */ export const PERSISTED_CACHE = { buster: "d0c3d97c58b8", - shape: "574f011906ba", + shape: "280cd07e45e0", } as const; diff --git a/packages/web/src/app/runtime/RuntimeBoot.tsx b/packages/web/src/app/runtime/RuntimeBoot.tsx index 5876884b..28d78645 100644 --- a/packages/web/src/app/runtime/RuntimeBoot.tsx +++ b/packages/web/src/app/runtime/RuntimeBoot.tsx @@ -1,10 +1,10 @@ import { TRAKT_BASE_OVERRIDE, TRAKT_CLIENT_ID } from "@app/config"; import { clearPersistedCaches } from "@app/query-client"; +import { useRuntimeBoot } from "@cue/core/app/boot"; import { useAuth } from "@cue/core/auth/store"; import { useEpisodeReminders } from "@cue/core/hooks/useEpisodeReminders"; import type { KeyValueStore } from "@cue/core/ports/kv"; import type { TokenStore } from "@cue/core/ports/token-store"; -import { useRuntimeBoot } from "@cue/core/runtime/boot"; import { RuntimeProvider } from "@cue/core/runtime/runtime"; import { clearLocalPreferences } from "@ui/prefs/preference-storage"; import type { ReactElement, ReactNode } from "react"; @@ -28,7 +28,7 @@ export interface RuntimeBootProps { * The web app's three boot surfaces over the shared boot effect: loading, a * retryable failure, and the runtime handed to the tree through context. The * effect itself (read the token, restore and replay the durable write-queue, - * register the teardown) is in `@cue/core/runtime/boot` and is the same on both + * register the teardown) is in `@cue/core/app/boot` and is the same on both * targets; only these three renders differ. */ export function RuntimeBoot({ diff --git a/packages/web/test/data/read-budget.test.ts b/packages/web/test/data/read-budget.test.ts index d4f316de..0a8effed 100644 --- a/packages/web/test/data/read-budget.test.ts +++ b/packages/web/test/data/read-budget.test.ts @@ -350,7 +350,7 @@ describe("cold-sync GET budget", () => { }); it("caps concurrent production endpoint reads across independent runtime callers", async () => { - const { createCueRuntime } = await import("@cue/core/runtime/create-runtime"); + const { createCueRuntime } = await import("@cue/core/app/create-runtime"); let inFlight = 0; let peak = 0; const browse = async (): Promise => { diff --git a/packages/web/test/privacy-claims.test.ts b/packages/web/test/privacy-claims.test.ts index 04f5db3e..cba599a6 100644 --- a/packages/web/test/privacy-claims.test.ts +++ b/packages/web/test/privacy-claims.test.ts @@ -11,7 +11,7 @@ import servedPolicy from "../../../docs/index.html?raw"; import infoPlistSource from "../../../ios/App/App/Info.plist?raw"; import policy from "../../../PRIVACY.md?raw"; import readme from "../../../README.md?raw"; -import runtimeSource from "../../core/src/runtime/create-runtime.ts?raw"; +import runtimeSource from "../../core/src/app/create-runtime.ts?raw"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index 1b884ef5b3339063722f8d52ab39b53c202ec75e..7bc621ac68dc1113efb9e09e326ac80ce8be7f9a 100644 GIT binary patch delta 713 zcmYjPv5pfl5H0qqR1_4HqeBpZLav6BkPt!wU4xD*kyvMU5?k?leD-Yiw%T7n+J?_S zqM+q#sQ3tGoQrgwJ>z-r&3pd$`uE$Pi)ymu+YD?G>i0cf_kmr9^#Wc(q{J&2tZOXr z7%>ALFpbcd#$pQx<9l3z32m9a?WOms^kmYzW<1RX(_vOXCE4Q$7EPfv4CZk{h?ug= zESOsH{&Id%omVn3UsX@%+iF?PKdNW*pXzxz|E-quqqF6tsGGivF6>0e$W{((DQf*j z*c$30Wyg*JBq9cvp+)aDm<*#IAxMn^0&P`Yw}wv)-W_CD7oavVUeqgatavi23v`qj zuyrbT+B%Q=QXA1Ud$YTD#PPjlfgY?^EW3oku`P(ACuG;k%+Xne1tuYcfqLJ<);JIK zrWbS48*#nai=30=6eihVFr!fCB9W~aC^}6sMO4YPO%zYbW$R+`s?V5~`IzJyt`JLA zw`g6$hAE9Ia6^4)jYMvayJr^HIt=nH#6OEuQjQ>eUh-2D@eaGlpuS|uImv3GXAmKZ zmszza&GDVavLg*oki|*u1iTXu^ E0iCn-3;+NC delta 102 zcmdmP_r`Jq7qfgxWkG7OLTXuRQKdq1Vsb`m3YUVGLRn%?X{thEUdrUT%u16FF)K3z xMJGRHR-eqzq6%i4u&7OrWl;ySTUgWu6_QGG3o`T4fto5yGJuTDli8L?005uCAP@im diff --git a/vitest.config.ts b/vitest.config.ts index 1d26bf14..1dca720a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,8 +14,9 @@ export default defineConfig({ "**/*.d.ts", "packages/web/src/app/**", "packages/web/src/ui/**", - // The composition root, as src/app has always been. - "packages/core/src/runtime/**", + // The composition root, as src/app has always been. The ports beside it + // in core/src/runtime are library code both targets run, so they are in. + "packages/core/src/app/**", ], thresholds: { // Global floor = rot tripwire, not the quality bar. Logic layers carry From d15009e5b565c88534ec2d11697296d5b95b532a Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 20:49:41 -0500 Subject: [PATCH 010/435] Declare React in @cue/core, and gate the whole category Thirty-three files under `packages/core/src` import `react` directly, and the manifest listed `@tanstack/react-query`, `zod` and `zustand`. It resolved only because `nodeLinker: hoisted` puts one react at the workspace root where any package can reach it. That is exactly the phantom dependency the default pnpm linker exists to prevent, and it matters the moment `packages/native` lands and pins the React its React Native version requires: with the core declaring nothing, it binds to whichever react wins the hoist, and a mismatch surfaces as an invalid-hook-call at runtime rather than as a resolution error at build. React is a peer, because the core must bind to the host app's copy and not a second one, plus a devDependency on the same range so the package's own type program is self-contained rather than borrowing the root's. `@types/react` and `@types/node` join it for the same reason: core's tsconfig sets `types: ["node"]`, and its own suite reads `node:child_process`. knip does not close this class, and I confirmed why rather than assuming: react is a peerDependency of `@tanstack/react-query`, so knip reads the import as satisfied and its dependency lane stays silent through all 33 files. So the gate is dependency-cruiser's own `no-non-package-json`, anchored at `^packages/`: a package may import only what its own manifest declares. Mutation, run on this commit: with `react`, `@types/react` and the peer block taken back out of `packages/core/package.json`, that is with the manifest exactly as 43215c9 had it, the rule reports 33 violations, one per importing file. Put back, the cruise is clean over 395 modules and 1,435 dependencies. Restoring `@types/react` alone silences it, because dependency-cruiser reads an `@types/x` declaration as declaring `x`; both were missing here, which is why it fires. Resolution verified rather than inferred: `pnpm install --frozen-lockfile` is green against the updated lockfile, `pnpm ls --filter @cue/core --depth 0` shows `react@19.2.7` under the core's own devDependencies, and the lockfile's `packages/core` importer now records `react` at `^19.2.7 -> 19.2.7` with `@tanstack/react-query` and `zustand` resolved against that same react. `nodeLinker` is unchanged. --- .dependency-cruiser.cjs | 8 ++++++++ packages/core/package.json | 6 ++++++ pnpm-lock.yaml | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 4cf1f442..1ab0cdb0 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -61,6 +61,14 @@ module.exports = { path: [RE_DOES_NOT_SHIP_DIRECTORY, RE_DOES_NOT_SHIP_MARKDOWN, RE_DOES_NOT_SHIP_FILE], }, }, + { + name: "packages-declare-their-imports", + severity: "error", + comment: + "dependency-cruiser's own no-non-package-json, anchored at the packages. A package may import only what its OWN manifest declares. `nodeLinker: hoisted` (pnpm-workspace.yaml) puts every transitive dependency at the workspace root where any package can reach it undeclared, which is the strictness the default linker exists to provide and the price the native package's resolver charges for it. knip's dependency lane does not close this: react is a peerDependency of @tanstack/react-query, so it read 33 undeclared react imports in @cue/core as satisfied.", + from: { path: "^packages/" }, + to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"] }, + }, { name: "domain-stays-pure", severity: "error", diff --git a/packages/core/package.json b/packages/core/package.json index d9dffc48..b7a2640d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,13 @@ "zod": "^4.4.3", "zustand": "5.0.14" }, + "peerDependencies": { + "react": "^19.2.7" + }, "devDependencies": { + "@types/node": "^22.12.0", + "@types/react": "^19.2.17", + "react": "^19.2.7", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 799b8f43..4a1d4e6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,15 @@ importers: specifier: 5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.20.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + react: + specifier: ^19.2.7 + version: 19.2.7 typescript: specifier: ^6.0.3 version: 6.0.3 From d2875a66b21c325e13652dab9778c23875fa5518 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:00:08 -0500 Subject: [PATCH 011/435] Cover the platform adapters, and the seam they were built for The four adapters the extraction exists to create were the least tested code in the repository: `web/src/platform/network.ts` at 0 percent functions, `app-visibility.ts` at 0, `redirect-handoff.ts` at 0, and the web `PreferenceStorage` outside the coverage gate entirely. No test named `webNetwork`, `webAppVisibility` or `sessionRedirectHandoff`. What each new suite uniquely owns, rather than repeating a layer above it: - `test/platform/network`, `app-visibility`: the binding from the events the browser actually fires to the port. Every layer above injects a port, so a missing `offline` registration is invisible to all of them. - `test/platform/redirect-handoff`: that the OAuth nonce and the PKCE verifier survive the full-page redirect, that a half-written handoff refuses rather than returning a partial pair, and that they are stashed per tab. Playwright's callback leg never leaves the fixture. - `test/platform/preference-storage`: the restricted-storage degradation. `localStorage` throws outright in some privacy modes, the port promises never to throw, and Playwright cannot make a real browser's storage throw, so this is the only layer that can assert it. - `test/ui/poll-browser-events`: the swiss-cheese layer that was lost when `activities-poll.test.tsx` moved onto an injected port. Real `visibilitychange`, `online` and `offline` through the real adapters into `useActivitiesPoll`, including the false branch of the new `if (!network.isOnline()) return`, which nothing exercised because the poll test's port hardcodes `isOnline: () => true`. - `test/ui/port-defaults`: that a tree with no composition root above it still works, silently. Four suites already depend on that without saying so. - `core/test/runtime/query-cache`: what a cold boot restores. The persisted set is unbounded in age, so it is a product decision: too little and an offline user boots to an empty Up Next, too much and the blob grows with every title ever opened. `endLocalSession` changed, and the test is what forced it. It cleared preferences first and the op log, the freshness baseline and the persisted cache after, so a preference clear that threw skipped all three and left the durable write queue on the device to replay under the next account: the cross-account replay the comment beside it exists to prevent. Preferences are device-local and account-agnostic, so they go last. Mutation: with the old order restored, "still clears the account state when the preference clear fails" fails on the op log key. Two more mutations run on this commit: dropping the `offline` registration from `webNetwork.subscribe` fails the disconnection case, and removing the `try`/`catch` from `preferenceStorage.getItem` fails the refused-storage case. `vitest.config.ts` coverage is now stated as what the line gate covers rather than as what it lets through. `web/src/ui/prefs` is in it: it is the preferences adapter, not a screen, and it sits under `ui` only because it is read at module scope before React exists, which `ui-no-platform-impl` makes the price of. It reads 100 percent on every axis now and was in no coverage number before. `web/src/platform` goes 82.05 to 96.58 statements and 86.15 to 100 functions, `core/src/runtime` 69.38 to 98.14 statements and 10 to 80 branches, and the aggregate 74.43 to 75.76. The `core/src/hooks` ratchet is left where it is: the poll test raises `useActivitiesPoll` from 86.66 to 91.11, and the directory's floor is unchanged at the same integers. `test/data/_runtime.ts` is a shared composition-root harness rather than a second copy of the one `read-budget.test.ts` had; jscpd caught the duplicate at threshold 0, which is the gate doing its job. --- packages/core/src/app/create-runtime.ts | 8 +- .../core/test/runtime/query-cache.test.ts | 71 +++++++++++ packages/web/test/data/_runtime.ts | 46 +++++++ packages/web/test/data/read-budget.test.ts | 31 +---- .../web/test/data/session-teardown.test.ts | 60 +++++++++ .../web/test/platform/app-visibility.test.ts | 45 +++++++ packages/web/test/platform/network.test.ts | 51 ++++++++ .../test/platform/preference-storage.test.ts | 76 +++++++++++ .../test/platform/redirect-handoff.test.ts | 50 ++++++++ .../web/test/ui/poll-browser-events.test.tsx | 119 ++++++++++++++++++ packages/web/test/ui/port-defaults.test.tsx | 53 ++++++++ vitest.config.ts | 22 ++-- 12 files changed, 590 insertions(+), 42 deletions(-) create mode 100644 packages/core/test/runtime/query-cache.test.ts create mode 100644 packages/web/test/data/_runtime.ts create mode 100644 packages/web/test/data/session-teardown.test.ts create mode 100644 packages/web/test/platform/app-visibility.test.ts create mode 100644 packages/web/test/platform/network.test.ts create mode 100644 packages/web/test/platform/preference-storage.test.ts create mode 100644 packages/web/test/platform/redirect-handoff.test.ts create mode 100644 packages/web/test/ui/poll-browser-events.test.tsx create mode 100644 packages/web/test/ui/port-defaults.test.tsx diff --git a/packages/core/src/app/create-runtime.ts b/packages/core/src/app/create-runtime.ts index 9964f612..47cec7d9 100644 --- a/packages/core/src/app/create-runtime.ts +++ b/packages/core/src/app/create-runtime.ts @@ -453,12 +453,16 @@ export async function createCueRuntime(deps: RuntimeDeps): Promise { // can never be sent, and clearing is what prevents the cross-account // replay. if (options.force !== true && queue.size > 0) throw new PendingWritesError(); - deps.clearLocalPreferences(); // Clear this device's per-account state so the next account never paints - // stale data or dispatches a leftover op. + // stale data or dispatches a leftover op. Preferences go last, and the + // order is the point: they are device-local rather than account-scoped, + // so a storage that refuses the preference clear (a locked-down browser, + // a full device) leaves a theme behind rather than the op log that would + // replay under the next account. await opLogStore.clear(); await activitiesStore.clear(); await deps.clearPersistedCaches(); + deps.clearLocalPreferences(); } finally { tearingDown = false; } diff --git a/packages/core/test/runtime/query-cache.test.ts b/packages/core/test/runtime/query-cache.test.ts new file mode 100644 index 00000000..d8a4fda7 --- /dev/null +++ b/packages/core/test/runtime/query-cache.test.ts @@ -0,0 +1,71 @@ +/** + * What survives a cold boot. The persisted blob is what the first screen paints + * before the network answers, and it is unbounded in age, so what goes into it + * is a product decision rather than a caching detail: too little and an offline + * user boots to an empty Up Next, too much and the blob grows with every title + * ever opened. + */ + +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { queryKeys } from "../../src/data/query-keys"; +import { createQueryCachePolicy } from "../../src/runtime/query-cache"; + +const LIBRARY_SHOW = 1; +const UNSEEN_SHOW = 2; + +function policyOver(entries: readonly { readonly showId: number }[]): { + persists(key: readonly unknown[], status?: "success" | "error"): boolean; +} { + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.library(), { entries }); + const { shouldDehydrateQuery } = createQueryCachePolicy(queryClient); + return { + persists(key, status = "success") { + return shouldDehydrateQuery({ queryKey: key, state: { status } } as Parameters< + typeof shouldDehydrateQuery + >[0]); + }, + }; +} + +const policy = policyOver([{ showId: LIBRARY_SHOW }]); + +describe("what a cold boot restores", () => { + it("keeps the screens a user lands on", () => { + expect(policy.persists(queryKeys.library())).toBe(true); + expect(policy.persists(queryKeys.movieLibrary())).toBe(true); + expect(policy.persists(queryKeys.watchlist("shows"))).toBe(true); + expect(policy.persists(queryKeys.history("all"))).toBe(true); + expect(policy.persists(queryKeys.userStats())).toBe(true); + expect(policy.persists(queryKeys.calendar("2026-01-01", 7))).toBe(true); + }); + + it("drops the unbounded key spaces nobody boots into", () => { + expect(policy.persists(queryKeys.search("dune", "movie"))).toBe(false); + expect(policy.persists(queryKeys.browse())).toBe(false); + }); + + it("drops the per-title detail trees, which cost one read to re-open", () => { + expect(policy.persists(queryKeys.showProgress(LIBRARY_SHOW))).toBe(false); + expect(policy.persists(queryKeys.showSeasons(LIBRARY_SHOW))).toBe(false); + expect(policy.persists(queryKeys.episode(LIBRARY_SHOW, 1, 1))).toBe(false); + expect(policy.persists(queryKeys.movieHeader(LIBRARY_SHOW))).toBe(false); + }); + + it("keeps the artwork a restored library card paints, and only that", () => { + expect(policy.persists(queryKeys.showInfo(LIBRARY_SHOW))).toBe(true); + expect(policy.persists(queryKeys.showInfo(UNSEEN_SHOW))).toBe(false); + }); + + it("keeps nothing at all when there is no library to paint cards from", () => { + const empty = policyOver([]); + expect(empty.persists(queryKeys.showInfo(LIBRARY_SHOW))).toBe(false); + expect(empty.persists(queryKeys.library())).toBe(true); + }); + + it("never restores a query that failed", () => { + expect(policy.persists(queryKeys.library(), "error")).toBe(false); + expect(policy.persists(queryKeys.showInfo(LIBRARY_SHOW), "error")).toBe(false); + }); +}); diff --git a/packages/web/test/data/_runtime.ts b/packages/web/test/data/_runtime.ts new file mode 100644 index 00000000..7e82175b --- /dev/null +++ b/packages/web/test/data/_runtime.ts @@ -0,0 +1,46 @@ +import { createCueRuntime, type RuntimeDeps } from "@cue/core/app/create-runtime"; +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { TokenStore } from "@cue/core/ports/token-store"; +import type { CueRuntime } from "@cue/core/runtime/runtime"; + +/** A `KeyValueStore` over a Map, so a suite can seed it and read it back. */ +export function memoryKv( + seed: Record = {}, +): KeyValueStore & { readonly values: Map } { + const values = new Map(Object.entries(seed)); + return { + values, + read: async (key) => values.get(key) ?? null, + write: async (key, value) => void values.set(key, value), + remove: async (key) => void values.delete(key), + }; +} + +const noopTokenStore: TokenStore = { + read: async () => null, + write: async () => undefined, + clear: async () => undefined, +}; + +/** + * A real composition root over inert dependencies: everything a suite about the + * runtime's own behavior needs, with only the parts it is testing supplied. + */ +export function buildRuntime(overrides: Partial = {}): Promise { + return createCueRuntime({ + token: { + access_token: "access", + refresh_token: "refresh", + created_at: Math.floor(Date.now() / 1000), + expires_in: 604_800, + }, + kv: memoryKv(), + tokenStore: noopTokenStore, + redirectUri: "https://cue.test/auth/callback", + clientId: "test-client", + endSession: async () => undefined, + clearPersistedCaches: async () => undefined, + clearLocalPreferences: () => undefined, + ...overrides, + }); +} diff --git a/packages/web/test/data/read-budget.test.ts b/packages/web/test/data/read-budget.test.ts index 0a8effed..0e10777b 100644 --- a/packages/web/test/data/read-budget.test.ts +++ b/packages/web/test/data/read-budget.test.ts @@ -5,11 +5,10 @@ import { WATCHED_PROGRESS_BUDGET, withReadRateRetry, } from "@cue/core/data/trakt/read-budget"; -import type { KeyValueStore } from "@cue/core/ports/kv"; -import type { TokenStore } from "@cue/core/ports/token-store"; import { delay, HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { mswServer } from "./_msw"; +import { buildRuntime } from "./_runtime"; const server = mswServer(); const client = new TraktClient({ clientId: "cid" }); @@ -350,7 +349,6 @@ describe("cold-sync GET budget", () => { }); it("caps concurrent production endpoint reads across independent runtime callers", async () => { - const { createCueRuntime } = await import("@cue/core/app/create-runtime"); let inFlight = 0; let peak = 0; const browse = async (): Promise => { @@ -366,32 +364,7 @@ describe("cold-sync GET budget", () => { http.get(`${TRAKT_API_BASE}/movies/trending`, browse), http.get(`${TRAKT_API_BASE}/movies/popular`, browse), ); - const values = new Map(); - const kv: KeyValueStore = { - read: async (key) => values.get(key) ?? null, - write: async (key, value) => void values.set(key, value), - remove: async (key) => void values.delete(key), - }; - const tokenStore: TokenStore = { - read: async () => null, - write: async () => undefined, - clear: async () => undefined, - }; - const runtime = await createCueRuntime({ - token: { - access_token: "access", - refresh_token: "refresh", - created_at: Math.floor(Date.now() / 1000), - expires_in: 604_800, - }, - kv, - tokenStore, - redirectUri: "https://cue.test/auth/callback", - clientId: "test-client", - endSession: async () => undefined, - clearPersistedCaches: async () => undefined, - clearLocalPreferences: () => undefined, - }); + const runtime = await buildRuntime(); await Promise.all([runtime.loadBrowse(), runtime.loadBrowse()]); diff --git a/packages/web/test/data/session-teardown.test.ts b/packages/web/test/data/session-teardown.test.ts new file mode 100644 index 00000000..78f264fa --- /dev/null +++ b/packages/web/test/data/session-teardown.test.ts @@ -0,0 +1,60 @@ +/** + * What sign-out has to leave behind, and what it must not. + * + * The op log, the freshness baseline and the persisted query cache are + * account-scoped: anything of them surviving a sign-out can replay or repaint + * under the next account on the same device. Preferences are device-local and + * carry no account state. So a teardown step that fails must not be able to skip + * the three that matter, which is what pins their order here. + */ + +import { describe, expect, it } from "vitest"; +import { buildRuntime, memoryKv } from "./_runtime"; + +const OP_LOG_KEY = "cue.write-queue"; +const ACTIVITIES_KEY = "cue.last-activities"; +const SEEDED_BASELINE = '{"episodes":{"watched_at":"2026-01-01T00:00:00Z"}}'; + +describe("endLocalSession", () => { + it("leaves nothing of the account on the device", async () => { + const kv = memoryKv({ [ACTIVITIES_KEY]: SEEDED_BASELINE }); + let cachesCleared = false; + let preferencesCleared = false; + const runtime = await buildRuntime({ + kv, + clearPersistedCaches: async () => { + cachesCleared = true; + }, + clearLocalPreferences: () => { + preferencesCleared = true; + }, + }); + + await runtime.endLocalSession(); + + expect(kv.values.has(OP_LOG_KEY)).toBe(false); + expect(kv.values.has(ACTIVITIES_KEY)).toBe(false); + expect(cachesCleared).toBe(true); + expect(preferencesCleared).toBe(true); + }); + + it("still clears the account state when the preference clear fails", async () => { + const kv = memoryKv({ [ACTIVITIES_KEY]: SEEDED_BASELINE }); + let cachesCleared = false; + const runtime = await buildRuntime({ + kv, + clearPersistedCaches: async () => { + cachesCleared = true; + }, + clearLocalPreferences: () => { + throw new DOMException("The operation is insecure.", "SecurityError"); + }, + }); + + await expect(runtime.endLocalSession()).rejects.toThrow(DOMException); + + expect(kv.values.has(OP_LOG_KEY)).toBe(false); + expect(kv.values.has(ACTIVITIES_KEY)).toBe(false); + expect(cachesCleared).toBe(true); + }); +}); diff --git a/packages/web/test/platform/app-visibility.test.ts b/packages/web/test/platform/app-visibility.test.ts new file mode 100644 index 00000000..7d67ab92 --- /dev/null +++ b/packages/web/test/platform/app-visibility.test.ts @@ -0,0 +1,45 @@ +/** + * The `AppVisibility` adapter, against the real Page Visibility API. What it + * uniquely owns: that a tab in the background reads as hidden, and that the + * event the browser fires is the one this port listens to, which is what stops + * the freshness poll spending Trakt budget while nobody is looking. + */ + +import { webAppVisibility } from "@platform/app-visibility"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** jsdom's `visibilityState` is a getter on the document, so it is stubbed. */ +function setVisibility(state: DocumentVisibilityState): void { + vi.spyOn(document, "visibilityState", "get").mockReturnValue(state); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("webAppVisibility", () => { + it("reads a foreground tab as visible", () => { + setVisibility("visible"); + expect(webAppVisibility.isVisible()).toBe(true); + }); + + it("reads a tab in the background as hidden", () => { + setVisibility("hidden"); + expect(webAppVisibility.isVisible()).toBe(false); + }); + + it("announces the tab changing state", () => { + const listener = vi.fn(); + const unsubscribe = webAppVisibility.subscribe(listener); + document.dispatchEvent(new Event("visibilitychange")); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("goes quiet once unsubscribed", () => { + const listener = vi.fn(); + webAppVisibility.subscribe(listener)(); + document.dispatchEvent(new Event("visibilitychange")); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/test/platform/network.test.ts b/packages/web/test/platform/network.test.ts new file mode 100644 index 00000000..c83ea597 --- /dev/null +++ b/packages/web/test/platform/network.test.ts @@ -0,0 +1,51 @@ +/** + * The `Network` adapter, against the real browser events rather than a stub. + * What it uniquely owns: that the two window events the browser actually fires + * are the two this port listens to. Nothing above it can catch a missing + * `offline` registration, because every layer above injects a port. + */ + +import { webNetwork } from "@platform/network"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** jsdom's `navigator.onLine` is a getter, so it is stubbed rather than assigned. */ +function setOnline(value: boolean): void { + vi.spyOn(navigator, "onLine", "get").mockReturnValue(value); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("webNetwork", () => { + it("reports what the browser believes about the connection", () => { + setOnline(true); + expect(webNetwork.isOnline()).toBe(true); + setOnline(false); + expect(webNetwork.isOnline()).toBe(false); + }); + + it("announces a reconnection", () => { + const listener = vi.fn(); + const unsubscribe = webNetwork.subscribe(listener); + window.dispatchEvent(new Event("online")); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("announces a disconnection too, so a listener can stop retrying", () => { + const listener = vi.fn(); + const unsubscribe = webNetwork.subscribe(listener); + window.dispatchEvent(new Event("offline")); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("goes quiet once unsubscribed, on both events", () => { + const listener = vi.fn(); + webNetwork.subscribe(listener)(); + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("offline")); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/test/platform/preference-storage.test.ts b/packages/web/test/platform/preference-storage.test.ts new file mode 100644 index 00000000..2bfeabc7 --- /dev/null +++ b/packages/web/test/platform/preference-storage.test.ts @@ -0,0 +1,76 @@ +/** + * The web `PreferenceStorage`. What it uniquely owns: the restricted-storage + * degradation. `localStorage` throws outright in some privacy modes, the port + * promises never to throw, and Playwright cannot make a real browser's + * `localStorage` throw, so this is the only layer that can assert a preference + * which cannot be remembered still resolves to its principled default. + */ + +import { clearLocalPreferences, preferenceStorage } from "@ui/prefs/preference-storage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** Every `localStorage` access rejected, the way a locked-down browser does it. */ +function denyStorage(): void { + for (const method of ["getItem", "setItem", "removeItem", "key"] as const) { + vi.spyOn(Storage.prototype, method).mockImplementation(() => { + throw new DOMException("The operation is insecure.", "SecurityError"); + }); + } +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("preferenceStorage", () => { + it("reads back what it was asked to remember", () => { + preferenceStorage.setItem("cue.theme", "dark"); + expect(preferenceStorage.getItem("cue.theme")).toBe("dark"); + }); + + it("reports an unset preference as absent, so the caller takes its default", () => { + expect(preferenceStorage.getItem("cue.never-set")).toBeNull(); + }); + + it("drops only the namespace it is given", () => { + preferenceStorage.setItem("cue.theme", "dark"); + preferenceStorage.setItem("cue.persist-requested", "1"); + preferenceStorage.setItem("other.key", "kept"); + preferenceStorage.clearNamespace("cue."); + expect(preferenceStorage.getItem("cue.theme")).toBeNull(); + expect(preferenceStorage.getItem("cue.persist-requested")).toBeNull(); + expect(preferenceStorage.getItem("other.key")).toBe("kept"); + }); + + it("clears every key under the namespace, not every other one", () => { + for (let index = 0; index < 10; index += 1) preferenceStorage.setItem(`cue.p${index}`, "on"); + clearLocalPreferences(); + expect(localStorage.length).toBe(0); + }); + + describe("when the browser refuses storage outright", () => { + it("resolves a read to absent instead of throwing", () => { + denyStorage(); + expect(preferenceStorage.getItem("cue.theme")).toBeNull(); + }); + + it("forgets a write instead of throwing", () => { + denyStorage(); + expect(() => { + preferenceStorage.setItem("cue.theme", "dark"); + }).not.toThrow(); + }); + + it("lets sign-out finish instead of throwing", () => { + preferenceStorage.setItem("cue.theme", "dark"); + denyStorage(); + expect(() => { + clearLocalPreferences(); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/web/test/platform/redirect-handoff.test.ts b/packages/web/test/platform/redirect-handoff.test.ts new file mode 100644 index 00000000..1bd75248 --- /dev/null +++ b/packages/web/test/platform/redirect-handoff.test.ts @@ -0,0 +1,50 @@ +/** + * The `RedirectHandoff` adapter. What it uniquely owns: that the OAuth state + * nonce and the PKCE verifier survive the full-page navigation to Trakt and + * back, that a half-written handoff refuses rather than returning a partial + * pair, and that both are single-use. Playwright cannot own this: the callback + * leg it exercises never leaves the fixture. + */ + +import { sessionRedirectHandoff } from "@platform/redirect-handoff"; +import { beforeEach, describe, expect, it } from "vitest"; + +beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); +}); + +describe("sessionRedirectHandoff", () => { + it("hands back what was stashed before the redirect", () => { + sessionRedirectHandoff.write("state-nonce", "pkce-verifier"); + expect(sessionRedirectHandoff.read()).toEqual({ + state: "state-nonce", + verifier: "pkce-verifier", + }); + }); + + it("refuses when nothing was stashed", () => { + expect(sessionRedirectHandoff.read()).toBeNull(); + }); + + it("refuses a half-written handoff rather than returning a partial pair", () => { + sessionRedirectHandoff.write("state-nonce", "pkce-verifier"); + for (const key of Object.keys(sessionStorage)) { + if (sessionStorage.getItem(key) === "pkce-verifier") sessionStorage.removeItem(key); + } + expect(sessionRedirectHandoff.read()).toBeNull(); + }); + + it("leaves nothing behind once the state is accepted", () => { + sessionRedirectHandoff.write("state-nonce", "pkce-verifier"); + sessionRedirectHandoff.clear(); + expect(sessionRedirectHandoff.read()).toBeNull(); + expect(sessionStorage.length).toBe(0); + }); + + it("stashes per tab, so a closed tab abandons the attempt", () => { + sessionRedirectHandoff.write("state-nonce", "pkce-verifier"); + expect(sessionStorage.length).toBe(2); + expect(localStorage.length).toBe(0); + }); +}); diff --git a/packages/web/test/ui/poll-browser-events.test.tsx b/packages/web/test/ui/poll-browser-events.test.tsx new file mode 100644 index 00000000..002eaae6 --- /dev/null +++ b/packages/web/test/ui/poll-browser-events.test.tsx @@ -0,0 +1,119 @@ +/** + * The web adapters wired into the poll, driven by real browser events. + * + * Every other test of `useActivitiesPoll` fires an injected port by hand, which + * is the right shape for the gating rules but leaves the binding from the + * events the browser actually fires to the poll asserted at no layer. This suite + * owns exactly that binding: `visibilitychange`, `online` and `offline` through + * `webAppVisibility` and `webNetwork`, with nothing stubbed between them. + */ + +import { useActivitiesPoll } from "@cue/core/hooks/useActivitiesPoll"; +import { AppVisibilityProvider } from "@cue/core/runtime/app-visibility"; +import { NetworkProvider } from "@cue/core/runtime/network"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; +import { webAppVisibility } from "@platform/app-visibility"; +import { webNetwork } from "@platform/network"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mountAsync } from "./_mount"; + +function Probe(): null { + useActivitiesPoll(); + return null; +} + +function stubRuntime(pending: number): { + runtime: CueRuntime; + flushWrites: ReturnType; + pollActivities: ReturnType; +} { + const flushWrites = vi.fn(() => Promise.resolve(0)); + const pollActivities = vi.fn(() => Promise.resolve(null)); + return { + runtime: { + pendingWrites: () => pending, + flushWrites, + pollActivities, + } as unknown as CueRuntime, + flushWrites, + pollActivities, + }; +} + +const setVisibility = (state: DocumentVisibilityState): void => { + vi.spyOn(document, "visibilityState", "get").mockReturnValue(state); +}; +const setOnline = (value: boolean): void => { + vi.spyOn(navigator, "onLine", "get").mockReturnValue(value); +}; + +const mountPoll = (runtime: CueRuntime): Promise => + mountAsync( + + + + + + + + + , + ); + +const fire = async (target: EventTarget, type: string): Promise => { + await act(async () => { + target.dispatchEvent(new Event(type)); + }); +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("the browser's own events reaching the freshness poll", () => { + it("does not poll while the tab is hidden", async () => { + setVisibility("hidden"); + setOnline(true); + const stub = stubRuntime(0); + await mountPoll(stub.runtime); + expect(stub.pollActivities).not.toHaveBeenCalled(); + }); + + it("polls when the tab comes back to the front", async () => { + setVisibility("hidden"); + setOnline(true); + const stub = stubRuntime(0); + await mountPoll(stub.runtime); + + setVisibility("visible"); + await fire(document, "visibilitychange"); + expect(stub.pollActivities).toHaveBeenCalledTimes(1); + }); + + it("lands deferred writes on a real reconnect, even from a hidden tab", async () => { + setVisibility("hidden"); + setOnline(false); + const stub = stubRuntime(1); + await mountPoll(stub.runtime); + expect(stub.flushWrites).not.toHaveBeenCalled(); + + setOnline(true); + await fire(window, "online"); + expect(stub.flushWrites).toHaveBeenCalledTimes(1); + expect(stub.pollActivities).not.toHaveBeenCalled(); + }); + + it("attempts nothing when the same subscription hears the tab go offline", async () => { + setVisibility("visible"); + setOnline(true); + const stub = stubRuntime(1); + await mountPoll(stub.runtime); + stub.flushWrites.mockClear(); + + setOnline(false); + await fire(window, "offline"); + expect(stub.flushWrites).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/test/ui/port-defaults.test.tsx b/packages/web/test/ui/port-defaults.test.tsx new file mode 100644 index 00000000..5285bc98 --- /dev/null +++ b/packages/web/test/ui/port-defaults.test.tsx @@ -0,0 +1,53 @@ +/** + * The four context-carrying ports with no provider above them. + * + * Each one documents the same promise: a tree mounted without a composition + * root still works, silently. It is what lets the pre-token shell render before + * a runtime exists, what makes the Settings reminder switch a plain preference + * on a build with no notifications, and what every suite that mounts a screen + * without wiring four providers depends on without saying so. + */ + +import { useAppVisibility } from "@cue/core/runtime/app-visibility"; +import { useHaptics } from "@cue/core/runtime/haptics"; +import { useNetwork } from "@cue/core/runtime/network"; +import { useReminders } from "@cue/core/runtime/reminders"; +import { describe, expect, it } from "vitest"; +import { mount } from "./_mount"; + +function readPort(usePort: () => T): T { + let value: T | null = null; + function Probe(): null { + value = usePort(); + return null; + } + mount(); + if (value === null) throw new Error("the probe never rendered"); + return value; +} + +describe("a tree with no composition root above it", () => { + it("believes it is in front of the user, and never hears otherwise", () => { + const visibility = readPort(useAppVisibility); + expect(visibility.isVisible()).toBe(true); + expect(() => visibility.subscribe(() => {})()).not.toThrow(); + }); + + it("believes it is online, and never hears otherwise", () => { + const network = readPort(useNetwork); + expect(network.isOnline()).toBe(true); + expect(() => network.subscribe(() => {})()).not.toThrow(); + }); + + it("stays silent rather than throwing when the UI fires a haptic", () => { + const haptics = readPort(useHaptics); + for (const verb of Object.values(haptics)) expect(() => verb()).not.toThrow(); + }); + + it("grants the reminder permission and schedules nothing", async () => { + const reminders = readPort(useReminders); + await expect(reminders.requestPermission()).resolves.toBe(true); + await expect(reminders.reconcile([])).resolves.toBeUndefined(); + await expect(reminders.cancelAll()).resolves.toBeUndefined(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1dca720a..8e53ccc2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,18 +6,18 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "html", "lcov"], - include: ["packages/*/src/**"], - // The composition root (src/app) and every presentational surface (src/ui) - // are gated by the hermetic Playwright suite, not a line threshold - // Line coverage targets the logic layers below. - exclude: [ - "**/*.d.ts", - "packages/web/src/app/**", - "packages/web/src/ui/**", - // The composition root, as src/app has always been. The ports beside it - // in core/src/runtime are library code both targets run, so they are in. - "packages/core/src/app/**", + // Stated as what the line gate covers rather than as what it lets through. + // Every composition root (both `src/app` directories) and every + // presentational surface (`web/src/ui`) is gated by the hermetic Playwright + // suite instead; `web/src/ui/prefs` is in because it is the preferences + // adapter rather than a screen, and sits under `ui` only because it is read + // at module scope, before React exists (ui-no-platform-impl). + include: [ + "packages/core/src/**", + "packages/web/src/platform/**", + "packages/web/src/ui/prefs/**", ], + exclude: ["**/*.d.ts", "packages/core/src/app/**"], thresholds: { // Global floor = rot tripwire, not the quality bar. Logic layers carry // the real gate below; ui/ behavior is gated by the Playwright suite. From 0bbf432d6730fdaefaf439218fb08b679c0d6110 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:04:01 -0500 Subject: [PATCH 012/435] Describe the workspace the repository actually is The README's architecture bullet described `packages/web` as "layered under src/domain, src/data, src/ui, src/app, and src/platform". That was true at 54abe84 and stopped being true two commits later, when the domain and the data layer left for `packages/core`. `@cue/core` itself appeared in no document: not the README, not PRIVACY.md, not docs/index.html. The branch's headline artifact was undocumented, which is the "understandable from the repository alone" bar. It now names both packages, what each owns, that the core is source with no build step reached through one wildcard subpath, and that the one-way dependency flow is enforced rather than agreed. The gate list picks up the same drift: the dependency-cruiser bullet said one rule where there are sixteen, and the Vitest bullet named thresholds on `domain` and `data` when there are five directories, a ratchet and a floor. `check:core-portable` and `buster:check` were in the harness and in no list. Two stale paths that survived the move, one line each: the auth store's comment pointed at `@ui/auth/store`, an alias that resolves into `packages/web/src` where the file no longer is, and `verify-bundle.sh`'s failure message told the reader to look in `src/app/config.ts`, a path that has not existed since 54abe84. Tailwind's content detection is pinned with `@source "../"`. v4 scans automatically from the directory its config sits in, which made the generated utility set a function of where the config happened to sit: before the workspace move it scanned the repository root and emitted `.invisible` and `.lowercase` out of prose in `.dependency-cruiser.cjs` and the Markdown, and the move silently dropped both. Nothing regressed, because no markup has ever used either, but the next move would have shifted the stylesheet again for the same non-reason. Proved rather than assumed. The built CSS is byte-identical with the pin, `index-CwPq4kL5.css` before and after. With it, a utility name added to `.dependency-cruiser.cjs` does not reach the stylesheet and the hash does not move; the same name added under `packages/web/src` does both. --- README.md | 11 ++++++++--- packages/core/src/auth/create-auth-store.ts | 2 +- packages/web/src/ui/styles.css | 10 ++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d454de31..192d9d42 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,12 @@ Every e2e run builds the app and starts its own preview server on port 4173. Set - **dprint**: Markdown formatting. - **cspell**: spelling across TS/TSX/CSS/MD. - **tsc**: strict TypeScript type-check (`--noEmit`). -- **dependency-cruiser**: layering rules (`@capacitor/*` confined to `packages/web/src/platform`), cruised over every package in one pass. +- **dependency-cruiser**: sixteen layering rules cruised over every package in one pass. They keep `@cue/core` free of both apps and of either one's libraries, hold the domain and the data layer to what they may reach, confine `@capacitor/*` to `packages/web/src/platform`, keep every Trakt read behind the pooled wrapper that spends the read budget, and require each package to declare what it imports. - **knip**: no unused files, dependencies, or exports. `@cue/core` exports every module through one wildcard subpath, which would make each of its files an entry point and switch the export lane off over the shared package, so that workspace sets `includeEntryExports` and its public surface is reported the moment nothing imports it. - **jscpd**: duplicate-code detection. -- **Vitest**: unit tests, one project per package, with coverage thresholds on `domain` and `data`. +- **Vitest**: unit tests, one project per package. Coverage covers the shared core, the web app's platform adapters and its preferences adapter, with 90/90/90/80 on `domain`, `data`, `prefs`, `url` and `stores`, a ratchet on `hooks`, and a global floor everywhere else. Both composition roots and every screen are gated by the Playwright suite instead. +- **`check:core-portable`**: `@cue/core` carries no `.tsx` and no `.css`, asserted over the index and the working tree so a new file fails before the commit exists. +- **`buster:check`**: the committed persisted-shape witness still matches the shapes, so a cache that would be replayed against a changed type is dropped rather than trusted. - **Vite build**: a production build must compile. `pnpm e2e` runs the Playwright suite (chromium). `pnpm audit` (high/critical production advisories) is deliberately kept out of `pnpm check` because it reads live advisory state; it runs as its own CI job on every push and on a weekly schedule. @@ -121,7 +123,10 @@ Build numbers come from that workflow's run counter, which every branch shares a - **TanStack Query** (with persistence) and **TanStack Router** for data and routing. - **TanStack Virtual** for large lists, **Zustand** for local state, **Zod** for runtime boundary validation, **Radix UI** for primitives. - **Capacitor 8** thin shell for iOS/Android: all `@capacitor/*` imports confined to `packages/web/src/platform`. -- The repository is a pnpm workspace. `packages/web` is the Vite app, layered under `src/domain`, `src/data`, `src/ui`, `src/app`, and `src/platform`; the root carries the gate runner, the native shells and the release lanes. +- The repository is a pnpm workspace of two packages, and the root carries only the gate runner, the native shells and the release lanes. + - **`@cue/core`** (`packages/core`) is everything that does not know what it is rendered on: the domain, the Trakt data layer, the durable write queue, the hooks, the stores, the preferences and the URL parsers, plus the ports each app fills (key-value storage, preference storage, haptics, reminders, connectivity, app visibility, the OAuth redirect handoff). It is TypeScript source with no build step, published to the workspace through one wildcard subpath (`@cue/core/domain/up-next`), and it contains no `.tsx` and no `.css`: its `tsconfig` omits the DOM lib and `pnpm check:core-portable` asserts the file types, because the two catch different halves of the same rule. `src/app` inside it is the composition root the apps call, and the only part of it excluded from the line-coverage gate. + - **`@cue/web`** (`packages/web`) is the Vite app: `src/ui` for screens and components, `src/app` for the composition root, `src/platform` for the browser side of every port, plus `test/`, `e2e/` and `public/`. + - Dependencies flow one way, and dependency-cruiser is what keeps that true rather than convention: the core imports neither app, the domain reaches only the domain, and the data layer reaches only the domain, its own tree and the ports. ## Attribution diff --git a/packages/core/src/auth/create-auth-store.ts b/packages/core/src/auth/create-auth-store.ts index c38014b1..f908ef96 100644 --- a/packages/core/src/auth/create-auth-store.ts +++ b/packages/core/src/auth/create-auth-store.ts @@ -34,7 +34,7 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout /** * The concrete auth store injected into the UI (composition - * root): pure UI state in `@ui/auth/store`, side effects (persist token, OAuth + * root): pure UI state in `auth/store.ts`, side effects (persist token, OAuth * network, full-page redirect) wired here where platform + data meet. The public * client id is embedded once by the app author, not entered per user. */ diff --git a/packages/web/src/ui/styles.css b/packages/web/src/ui/styles.css index 849d614f..e8293573 100644 --- a/packages/web/src/ui/styles.css +++ b/packages/web/src/ui/styles.css @@ -1,4 +1,14 @@ @import "tailwindcss"; +/* + * Where the utilities come from. Tailwind v4 detects content automatically from + * the directory its config sits in, which made the generated set a function of + * the repository layout rather than of the source: before the workspace move it + * scanned from the repository root and emitted `.invisible` and `.lowercase` + * from prose in .cjs and .md files, neither of which any markup has ever used. + * Naming the source explicitly is what makes moving a directory generate exactly + * the same utilities as before. + */ +@source "../"; @import "./styles/base.css"; @import "./styles/layout.css"; @import "./styles/components.css"; From 1803109a56bbb8d94634b124581a6d16b1d1e942 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:08:40 -0500 Subject: [PATCH 013/435] Move the device ports out of the shape trees, into ports/ Five modules in `core/src/runtime` are ports that carry a React context and type no persisted value: haptics, reminders, app-version, app-visibility and network. Inside the shape trees, an edit to any of them fails `buster:check`, and the only way past that is a bump that drops every shipping user's query cache. The next commit adds the seventh haptic verb, and 8.1 says no commit in this phase changes the buster, so this is the commit that has to make that true. They go to `ports/`, which is where 3.1 puts them and where the four contextless seams already are. D10 deferred that as churn Phase 0 does not need, and it was right until the buster started charging for it. `runtime/` is now the `CueRuntime` port, the query-cache policy and the generated witness: three files that between them name every persisted value and the key heads that are persisted at all, which is what the shape tree is supposed to mean. `ports-have-no-impls` gains react to its allowed set, and only react. The rule guards against a port growing an implementation and stopping being injectable; a `createContext` plus a `useContext` is how a component reaches the injected instance, not an implementation of one. Everything an implementation would actually need is still banned there by `core-stays-portable`, by `web-owns-dom` and by biome's globals override over this package, and all three still fire: `ports/kv.ts` importing `idb-keyval` reports four violations, `ports/haptics.ts` importing a data module or a hook reports `ports-have-no-impls` by name. The witness moves because membership changed, and no shape did. Recomputed at e062377 with exactly those five files filtered out of `runtime/`: fae27b70d3bb, the value this commit computes. Reseeded with the buster left at 4d3253e9dc61. edit ports/haptics.ts ok, witness unchanged edit runtime/runtime.ts fails c19f649b400b --- .dependency-cruiser.cjs | 4 ++-- packages/core/src/hooks/useActivitiesPoll.ts | 4 ++-- .../core/src/hooks/useEpisodeReminders.ts | 2 +- packages/core/src/hooks/useIsOffline.ts | 2 +- packages/core/src/hooks/useMarkWatched.ts | 2 +- packages/core/src/hooks/useMovieActions.ts | 2 +- .../src/{runtime => ports}/app-version.ts | 0 .../src/{runtime => ports}/app-visibility.ts | 0 .../core/src/{domain => }/ports/haptics.ts | 13 +++++++++++++ .../core/src/{runtime => ports}/network.ts | 0 .../core/src/{domain => }/ports/reminders.ts | 14 +++++++++++++- packages/core/src/runtime/haptics.ts | 13 ------------- packages/core/src/runtime/persist-buster.ts | 2 +- packages/core/src/runtime/reminders.ts | 13 ------------- packages/web/src/app/providers.tsx | 10 +++++----- packages/web/src/platform/app-visibility.ts | 2 +- packages/web/src/platform/haptics.ts | 2 +- packages/web/src/platform/network.ts | 2 +- packages/web/src/platform/reminders.ts | 2 +- packages/web/src/ui/app-shell/RootLayout.tsx | 3 +-- .../web/src/ui/components/ContextMenu.tsx | 2 +- .../web/src/ui/components/PullToRefresh.tsx | 2 +- packages/web/src/ui/components/Sheet.tsx | 2 +- .../web/src/ui/components/SwipeAction.tsx | 2 +- .../web/src/ui/screens/settings/Settings.tsx | 4 ++-- packages/web/test/platform/haptics.test.ts | 2 +- packages/web/test/ui/activities-poll.test.tsx | 4 ++-- .../web/test/ui/episode-reminders.test.tsx | 3 +-- .../web/test/ui/poll-browser-events.test.tsx | 4 ++-- packages/web/test/ui/port-defaults.test.tsx | 8 ++++---- packages/web/test/ui/pull-to-refresh.test.tsx | 3 +-- scripts/write-buster.mjs | Bin 7991 -> 8070 bytes 32 files changed, 62 insertions(+), 66 deletions(-) rename packages/core/src/{runtime => ports}/app-version.ts (100%) rename packages/core/src/{runtime => ports}/app-visibility.ts (100%) rename packages/core/src/{domain => }/ports/haptics.ts (73%) rename packages/core/src/{runtime => ports}/network.ts (100%) rename packages/core/src/{domain => }/ports/reminders.ts (66%) delete mode 100644 packages/core/src/runtime/haptics.ts delete mode 100644 packages/core/src/runtime/reminders.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 1ab0cdb0..9389fbaf 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -103,11 +103,11 @@ module.exports = { name: "ports-have-no-impls", severity: "error", comment: - "A port is a seam the apps fill, so it may take value imports from the domain and from its sibling ports and nothing else. Stated that way rather than as 'types only': token-store.ts imports tokenSchema, a zod value, from domain/model/token, and that is correct code.", + "A port is a seam the apps fill, so it may take value imports from the domain, from its sibling ports and from react, and nothing else. Stated that way rather than as 'types only': token-store.ts imports tokenSchema, a zod value, from domain/model/token, and five of the ports publish a React context and hook beside their interface, which is how a component reaches the injected instance. React is the injection mechanism rather than an implementation, and everything an implementation would actually need (the DOM, idb-keyval, @capacitor/*, expo) is still banned here by core-stays-portable and by biome's globals override over this package.", from: { path: "^packages/core/src/ports/" }, to: { dependencyTypesNot: ["type-only"], - pathNot: "^packages/core/src/(ports|domain)/", + pathNot: ["^packages/core/src/(ports|domain)/", RE_REACT], }, }, { diff --git a/packages/core/src/hooks/useActivitiesPoll.ts b/packages/core/src/hooks/useActivitiesPoll.ts index bf4c1d52..769962f5 100644 --- a/packages/core/src/hooks/useActivitiesPoll.ts +++ b/packages/core/src/hooks/useActivitiesPoll.ts @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { useAppVisibility } from "../runtime/app-visibility"; -import { useNetwork } from "../runtime/network"; +import { useAppVisibility } from "../ports/app-visibility"; +import { useNetwork } from "../ports/network"; import { useOptionalRuntime } from "../runtime/runtime"; import { applyReconcile } from "./apply-reconcile"; diff --git a/packages/core/src/hooks/useEpisodeReminders.ts b/packages/core/src/hooks/useEpisodeReminders.ts index 90729426..4dcf7a92 100644 --- a/packages/core/src/hooks/useEpisodeReminders.ts +++ b/packages/core/src/hooks/useEpisodeReminders.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { planReminders, REMINDER_WINDOW_DAYS } from "../domain/reminders"; +import { useReminders } from "../ports/reminders"; import { usePrefs } from "../prefs/prefs-store"; -import { useReminders } from "../runtime/reminders"; import { useCalendar } from "./useCalendar"; /** diff --git a/packages/core/src/hooks/useIsOffline.ts b/packages/core/src/hooks/useIsOffline.ts index 1e4e8f52..cbf18a88 100644 --- a/packages/core/src/hooks/useIsOffline.ts +++ b/packages/core/src/hooks/useIsOffline.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from "react"; -import { useNetwork } from "../runtime/network"; +import { useNetwork } from "../ports/network"; /** Live connectivity as React state (SyncStrip's offline line, Search's offline notice). */ export function useIsOffline(): boolean { diff --git a/packages/core/src/hooks/useMarkWatched.ts b/packages/core/src/hooks/useMarkWatched.ts index 97fcda97..41707c78 100644 --- a/packages/core/src/hooks/useMarkWatched.ts +++ b/packages/core/src/hooks/useMarkWatched.ts @@ -11,7 +11,7 @@ import { episodeItemKey, } from "../domain/write-queue/ops"; import { middleTruncate } from "../format"; -import { useHaptics } from "../runtime/haptics"; +import { useHaptics } from "../ports/haptics"; import { type CueRuntime, type SubmitOutcome, useRuntime } from "../runtime/runtime"; import { hasPendingMark, diff --git a/packages/core/src/hooks/useMovieActions.ts b/packages/core/src/hooks/useMovieActions.ts index cee17794..88314ee8 100644 --- a/packages/core/src/hooks/useMovieActions.ts +++ b/packages/core/src/hooks/useMovieActions.ts @@ -10,7 +10,7 @@ import { buildUnmarkMovieOp, } from "../domain/write-queue/ops"; import type { QueuedOp } from "../domain/write-queue/types"; -import { useHaptics } from "../runtime/haptics"; +import { useHaptics } from "../ports/haptics"; import type { SubmitOutcome } from "../runtime/runtime"; import { useRuntime } from "../runtime/runtime"; import { resolveMovieUnmark, routeMovieUnmark } from "./resolveUnmark"; diff --git a/packages/core/src/runtime/app-version.ts b/packages/core/src/ports/app-version.ts similarity index 100% rename from packages/core/src/runtime/app-version.ts rename to packages/core/src/ports/app-version.ts diff --git a/packages/core/src/runtime/app-visibility.ts b/packages/core/src/ports/app-visibility.ts similarity index 100% rename from packages/core/src/runtime/app-visibility.ts rename to packages/core/src/ports/app-visibility.ts diff --git a/packages/core/src/domain/ports/haptics.ts b/packages/core/src/ports/haptics.ts similarity index 73% rename from packages/core/src/domain/ports/haptics.ts rename to packages/core/src/ports/haptics.ts index 59df0092..25ff8678 100644 --- a/packages/core/src/domain/ports/haptics.ts +++ b/packages/core/src/ports/haptics.ts @@ -1,3 +1,5 @@ +import { createContext, useContext } from "react"; + /** * The tactile port, declared where both sides of the seam can read it: `@ui` * fires through it, `@platform` implements it, and the composition root hands @@ -30,3 +32,14 @@ export const SILENT: Haptics = { contextClick() {}, prepare() {}, }; + +/** The port as `@ui` reaches it, injected from the composition root so `@ui` + * stays free of `@app`/`@platform`. The default is `SILENT`, so the browser + * build, the pre-token shell and tests need no provider. */ +const HapticsContext = createContext(SILENT); + +export const HapticsProvider = HapticsContext.Provider; + +export function useHaptics(): Haptics { + return useContext(HapticsContext); +} diff --git a/packages/core/src/runtime/network.ts b/packages/core/src/ports/network.ts similarity index 100% rename from packages/core/src/runtime/network.ts rename to packages/core/src/ports/network.ts diff --git a/packages/core/src/domain/ports/reminders.ts b/packages/core/src/ports/reminders.ts similarity index 66% rename from packages/core/src/domain/ports/reminders.ts rename to packages/core/src/ports/reminders.ts index f3bb2575..f084d8a9 100644 --- a/packages/core/src/domain/ports/reminders.ts +++ b/packages/core/src/ports/reminders.ts @@ -1,4 +1,5 @@ -import type { PlannedReminder } from "../reminders"; +import { createContext, useContext } from "react"; +import type { PlannedReminder } from "../domain/reminders"; /** * The notification port, declared where both sides of the seam can read it: @@ -22,3 +23,14 @@ export const SILENT: Reminders = { reconcile: () => Promise.resolve(), cancelAll: () => Promise.resolve(), }; + +/** The port as `@ui` reaches it, injected from the composition root so `@ui` + * stays free of `@app`/`@platform`. The default is `SILENT`, so no provider is + * needed off native. */ +const RemindersContext = createContext(SILENT); + +export const RemindersProvider = RemindersContext.Provider; + +export function useReminders(): Reminders { + return useContext(RemindersContext); +} diff --git a/packages/core/src/runtime/haptics.ts b/packages/core/src/runtime/haptics.ts deleted file mode 100644 index 942d4d41..00000000 --- a/packages/core/src/runtime/haptics.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from "react"; -import { type Haptics, SILENT } from "../domain/ports/haptics"; - -/** The tactile port as `@ui` reaches it, injected from the composition root so - * `@ui` stays free of `@app`/`@platform`. The default is the silent no-op, so - * the browser build, the pre-token shell and tests need no provider. */ -const HapticsContext = createContext(SILENT); - -export const HapticsProvider = HapticsContext.Provider; - -export function useHaptics(): Haptics { - return useContext(HapticsContext); -} diff --git a/packages/core/src/runtime/persist-buster.ts b/packages/core/src/runtime/persist-buster.ts index b1591894..04c35132 100644 --- a/packages/core/src/runtime/persist-buster.ts +++ b/packages/core/src/runtime/persist-buster.ts @@ -13,5 +13,5 @@ */ export const PERSISTED_CACHE = { buster: "d0c3d97c58b8", - shape: "280cd07e45e0", + shape: "b76d12c06a3e", } as const; diff --git a/packages/core/src/runtime/reminders.ts b/packages/core/src/runtime/reminders.ts deleted file mode 100644 index 081b5f0f..00000000 --- a/packages/core/src/runtime/reminders.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from "react"; -import { type Reminders, SILENT } from "../domain/ports/reminders"; - -/** The notification port as `@ui` reaches it, injected from the composition root - * so `@ui` stays free of `@app`/`@platform`. The default schedules nothing and - * grants everything, so no provider is needed off native. */ -const RemindersContext = createContext(SILENT); - -export const RemindersProvider = RemindersContext.Provider; - -export function useReminders(): Reminders { - return useContext(RemindersContext); -} diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index e697c158..7c16a77b 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -10,13 +10,13 @@ import { } from "@app/query-client"; import { router } from "@app/router"; import { createAuthStore } from "@cue/core/auth/create-auth-store"; +import { AppVersionProvider } from "@cue/core/ports/app-version"; +import { AppVisibilityProvider } from "@cue/core/ports/app-visibility"; +import { HapticsProvider } from "@cue/core/ports/haptics"; +import { NetworkProvider } from "@cue/core/ports/network"; +import { RemindersProvider } from "@cue/core/ports/reminders"; import { createTokenStore } from "@cue/core/ports/token-store"; import { PrefsProvider } from "@cue/core/prefs/prefs-store"; -import { AppVersionProvider } from "@cue/core/runtime/app-version"; -import { AppVisibilityProvider } from "@cue/core/runtime/app-visibility"; -import { HapticsProvider } from "@cue/core/runtime/haptics"; -import { NetworkProvider } from "@cue/core/runtime/network"; -import { RemindersProvider } from "@cue/core/runtime/reminders"; import { getNativeAppVersion } from "@platform/app-version"; import { webAppVisibility } from "@platform/app-visibility"; import { bindHardwareBack } from "@platform/back-button"; diff --git a/packages/web/src/platform/app-visibility.ts b/packages/web/src/platform/app-visibility.ts index 74f8d2e1..095168f9 100644 --- a/packages/web/src/platform/app-visibility.ts +++ b/packages/web/src/platform/app-visibility.ts @@ -1,4 +1,4 @@ -import type { AppVisibility } from "@cue/core/runtime/app-visibility"; +import type { AppVisibility } from "@cue/core/ports/app-visibility"; /** `AppVisibility` over the Page Visibility API. */ export const webAppVisibility: AppVisibility = { diff --git a/packages/web/src/platform/haptics.ts b/packages/web/src/platform/haptics.ts index b2b81caa..f95e76fe 100644 --- a/packages/web/src/platform/haptics.ts +++ b/packages/web/src/platform/haptics.ts @@ -1,5 +1,5 @@ import { registerPlugin } from "@capacitor/core"; -import { type Haptics, SILENT } from "@cue/core/domain/ports/haptics"; +import { type Haptics, SILENT } from "@cue/core/ports/haptics"; import { isNativePlatform } from "./platform"; /** diff --git a/packages/web/src/platform/network.ts b/packages/web/src/platform/network.ts index 15450317..35e8b85d 100644 --- a/packages/web/src/platform/network.ts +++ b/packages/web/src/platform/network.ts @@ -1,4 +1,4 @@ -import type { Network } from "@cue/core/runtime/network"; +import type { Network } from "@cue/core/ports/network"; /** `Network` over `navigator.onLine` and the two window events that move it. */ export const webNetwork: Network = { diff --git a/packages/web/src/platform/reminders.ts b/packages/web/src/platform/reminders.ts index abc583ca..fe51f3d5 100644 --- a/packages/web/src/platform/reminders.ts +++ b/packages/web/src/platform/reminders.ts @@ -1,11 +1,11 @@ import { Capacitor } from "@capacitor/core"; import { LocalNotifications } from "@capacitor/local-notifications"; -import { type Reminders, SILENT } from "@cue/core/domain/ports/reminders"; import { diffReminders, type PendingReminder, type PlannedReminder, } from "@cue/core/domain/reminders"; +import { type Reminders, SILENT } from "@cue/core/ports/reminders"; import { isNativePlatform } from "./platform"; /** diff --git a/packages/web/src/ui/app-shell/RootLayout.tsx b/packages/web/src/ui/app-shell/RootLayout.tsx index a6505220..a0edc355 100644 --- a/packages/web/src/ui/app-shell/RootLayout.tsx +++ b/packages/web/src/ui/app-shell/RootLayout.tsx @@ -1,7 +1,6 @@ import { useActivitiesPoll } from "@cue/core/hooks/useActivitiesPoll"; +import { useHaptics } from "@cue/core/ports/haptics"; import { usePrefs } from "@cue/core/prefs/prefs-store"; -import { useHaptics } from "@cue/core/runtime/haptics"; -import { useOptionalRuntime } from "@cue/core/runtime/runtime"; import { Link, Outlet } from "@tanstack/react-router"; import { ErrorBoundary } from "@ui/app-shell/ErrorBoundary"; import { navFor } from "@ui/app-shell/nav"; diff --git a/packages/web/src/ui/components/ContextMenu.tsx b/packages/web/src/ui/components/ContextMenu.tsx index 32b3a9cf..9e0f2ea7 100644 --- a/packages/web/src/ui/components/ContextMenu.tsx +++ b/packages/web/src/ui/components/ContextMenu.tsx @@ -1,4 +1,4 @@ -import { useHaptics } from "@cue/core/runtime/haptics"; +import { useHaptics } from "@cue/core/ports/haptics"; import { ActionSheet, type ActionSheetRow } from "@ui/components/ActionSheet"; import { exceedsPressSlop, LONG_PRESS_MS } from "@ui/components/long-press-math"; import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; diff --git a/packages/web/src/ui/components/PullToRefresh.tsx b/packages/web/src/ui/components/PullToRefresh.tsx index 4ab803c6..881946f5 100644 --- a/packages/web/src/ui/components/PullToRefresh.tsx +++ b/packages/web/src/ui/components/PullToRefresh.tsx @@ -1,5 +1,5 @@ import { useSyncNow } from "@cue/core/hooks/useSyncNow"; -import { useHaptics } from "@cue/core/runtime/haptics"; +import { useHaptics } from "@cue/core/ports/haptics"; import { resolveIntent } from "@ui/components/gesture-intent"; import { isArmed, diff --git a/packages/web/src/ui/components/Sheet.tsx b/packages/web/src/ui/components/Sheet.tsx index bee86ee7..34fa8395 100644 --- a/packages/web/src/ui/components/Sheet.tsx +++ b/packages/web/src/ui/components/Sheet.tsx @@ -1,4 +1,4 @@ -import { useHaptics } from "@cue/core/runtime/haptics"; +import { useHaptics } from "@cue/core/ports/haptics"; import { DISMISS_FRACTION, type DragSample, diff --git a/packages/web/src/ui/components/SwipeAction.tsx b/packages/web/src/ui/components/SwipeAction.tsx index 5275dd9f..911a1b60 100644 --- a/packages/web/src/ui/components/SwipeAction.tsx +++ b/packages/web/src/ui/components/SwipeAction.tsx @@ -1,4 +1,4 @@ -import { useHaptics } from "@cue/core/runtime/haptics"; +import { useHaptics } from "@cue/core/ports/haptics"; import { resolveIntent } from "@ui/components/gesture-intent"; import { clampOffset, commitDirection } from "@ui/components/swipe-math"; import { Check, Pause } from "lucide-react"; diff --git a/packages/web/src/ui/screens/settings/Settings.tsx b/packages/web/src/ui/screens/settings/Settings.tsx index 5ba726cf..ce9ea410 100644 --- a/packages/web/src/ui/screens/settings/Settings.tsx +++ b/packages/web/src/ui/screens/settings/Settings.tsx @@ -1,3 +1,5 @@ +import { useAppVersion } from "@cue/core/ports/app-version"; +import { useReminders } from "@cue/core/ports/reminders"; import { usePrefs } from "@cue/core/prefs/prefs-store"; import { THRESHOLD_OPTIONS } from "@cue/core/prefs/threshold"; import { @@ -6,8 +8,6 @@ import { NEXT_EPISODE_ORDER_OPTIONS, type NextEpisodeOrder, } from "@cue/core/prefs/tracking"; -import { useAppVersion } from "@cue/core/runtime/app-version"; -import { useReminders } from "@cue/core/runtime/reminders"; import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; import { ScreenHeader } from "@ui/app-shell/ScreenHeader"; import { ActionSheet } from "@ui/components/ActionSheet"; diff --git a/packages/web/test/platform/haptics.test.ts b/packages/web/test/platform/haptics.test.ts index b760c976..2857dd8a 100644 --- a/packages/web/test/platform/haptics.test.ts +++ b/packages/web/test/platform/haptics.test.ts @@ -1,4 +1,4 @@ -import type { Haptics } from "@cue/core/domain/ports/haptics"; +import type { Haptics } from "@cue/core/ports/haptics"; import { createNativeHaptics } from "@platform/haptics"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/web/test/ui/activities-poll.test.tsx b/packages/web/test/ui/activities-poll.test.tsx index 580d74cb..bdd6ce15 100644 --- a/packages/web/test/ui/activities-poll.test.tsx +++ b/packages/web/test/ui/activities-poll.test.tsx @@ -6,8 +6,8 @@ */ import { useActivitiesPoll } from "@cue/core/hooks/useActivitiesPoll"; -import { type AppVisibility, AppVisibilityProvider } from "@cue/core/runtime/app-visibility"; -import { type Network, NetworkProvider } from "@cue/core/runtime/network"; +import { type AppVisibility, AppVisibilityProvider } from "@cue/core/ports/app-visibility"; +import { type Network, NetworkProvider } from "@cue/core/ports/network"; import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; diff --git a/packages/web/test/ui/episode-reminders.test.tsx b/packages/web/test/ui/episode-reminders.test.tsx index 73dbd1c0..3a536fe1 100644 --- a/packages/web/test/ui/episode-reminders.test.tsx +++ b/packages/web/test/ui/episode-reminders.test.tsx @@ -4,11 +4,10 @@ * silent, so the states that must NOT cancel matter more than the ones that do. */ import type { CalendarEntry } from "@cue/core/domain/calendar"; -import type { Reminders } from "@cue/core/domain/ports/reminders"; import { useEpisodeReminders } from "@cue/core/hooks/useEpisodeReminders"; import type { PreferenceStorage } from "@cue/core/ports/preference-storage"; +import { type Reminders, RemindersProvider } from "@cue/core/ports/reminders"; import { createPrefsStore, PrefsProvider } from "@cue/core/prefs/prefs-store"; -import { RemindersProvider } from "@cue/core/runtime/reminders"; import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, type ReactElement } from "react"; diff --git a/packages/web/test/ui/poll-browser-events.test.tsx b/packages/web/test/ui/poll-browser-events.test.tsx index 002eaae6..c48ae26a 100644 --- a/packages/web/test/ui/poll-browser-events.test.tsx +++ b/packages/web/test/ui/poll-browser-events.test.tsx @@ -9,8 +9,8 @@ */ import { useActivitiesPoll } from "@cue/core/hooks/useActivitiesPoll"; -import { AppVisibilityProvider } from "@cue/core/runtime/app-visibility"; -import { NetworkProvider } from "@cue/core/runtime/network"; +import { AppVisibilityProvider } from "@cue/core/ports/app-visibility"; +import { NetworkProvider } from "@cue/core/ports/network"; import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { webAppVisibility } from "@platform/app-visibility"; import { webNetwork } from "@platform/network"; diff --git a/packages/web/test/ui/port-defaults.test.tsx b/packages/web/test/ui/port-defaults.test.tsx index 5285bc98..b8d328da 100644 --- a/packages/web/test/ui/port-defaults.test.tsx +++ b/packages/web/test/ui/port-defaults.test.tsx @@ -8,10 +8,10 @@ * without wiring four providers depends on without saying so. */ -import { useAppVisibility } from "@cue/core/runtime/app-visibility"; -import { useHaptics } from "@cue/core/runtime/haptics"; -import { useNetwork } from "@cue/core/runtime/network"; -import { useReminders } from "@cue/core/runtime/reminders"; +import { useAppVisibility } from "@cue/core/ports/app-visibility"; +import { useHaptics } from "@cue/core/ports/haptics"; +import { useNetwork } from "@cue/core/ports/network"; +import { useReminders } from "@cue/core/ports/reminders"; import { describe, expect, it } from "vitest"; import { mount } from "./_mount"; diff --git a/packages/web/test/ui/pull-to-refresh.test.tsx b/packages/web/test/ui/pull-to-refresh.test.tsx index 6b63ac00..9b382a87 100644 --- a/packages/web/test/ui/pull-to-refresh.test.tsx +++ b/packages/web/test/ui/pull-to-refresh.test.tsx @@ -5,8 +5,7 @@ * non-passive `touchmove`, which is invisible from the page. */ -import type { Haptics } from "@cue/core/domain/ports/haptics"; -import { HapticsProvider } from "@cue/core/runtime/haptics"; +import { type Haptics, HapticsProvider } from "@cue/core/ports/haptics"; import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PullToRefresh } from "@ui/components/PullToRefresh"; diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index 7bc621ac68dc1113efb9e09e326ac80ce8be7f9a..87b4c20189d236844efd3b95eeb783c7e243adb1 100644 GIT binary patch delta 551 zcmY*WJ5B>Z3{^l-AVft$=^6p)K>~3H4v@)?mmRSgug2aiTczUyxdI13N`W{AH{y8- z1*M;!-^c#Ee|z{iIh>{4+2Pl0^GHJ@EmKT|E0Z!(3D$5SYeJ?#ZBiqtsZK5H6{!GQ zN$jx_V&q6BB$+`up-U|Bijq>wnj-s1L$b`Ov>YjN9s1U83CgnOa=q*~P_INyAzMis zsc?u_Ah=qe<3yS`rSu(WiX7S65tK-^6o$?XJvG7HH?#;iG~?uKgV0OLQTO5Yu~7`3 zp`dD(4Ru@;9lgoeG5W4irnkTR%2Y&P{O>I4@%WWjoh8XiI2d~hMu}A?njD@MJ=G#~ z-F)G#U;<9$b*h*;DYil8!mhUPQRj<0T8cG}6367Z>QHcU%u*X?C`^yhCLpfYrwYik vf5#0R8C~%AE1(NHxRlZ$Jx&aHE<*xZ$${GT`{?}Cb0sK+s^Q)C`2FGs33$SD delta 466 zcmXX?y-EW?5Jn;?B3P%KPLL2wA%fQ8OJwhMayMjeciEZC<*IbPfUn>K2r0zZ@kyMy zSnlli|Nip*{I!@}w<-AZ&Ftsu<95D0c??uxae`rlm;_QpNIQ@gyEVKD1UeuCMcBHW zAGRwXS-?FI8w9$<0_aK5!;S(P(C*pQd)MF!T=dES8MCUy1Q`nx8~orxkEbUXNZLtJ zl+5Inga<6+Bny&^KBPXVfujK1s#kj1)GgL~jbjGpn4%UK+^E((C6v~wB7=jf@3La_ z=}XG|FV8LIe4^Mu&sgplYNwiOctgx~HBnCmYe{9afK}?FcN)xHy0hqN9Y$G!?$=^5 zkEnh@3)+Tw#4gLU3xob^GtDz-5R+rkERU1T1xP7ElgwI`bxI2m@u=fbcX>%cgr=l{ J&%g7} Date: Sat, 22 Aug 2026 21:13:42 -0500 Subject: [PATCH 014/435] Add failure(), the seventh haptic verb, and fire it 3.3 asks for seven verbs and the app has shipped six. The missing one is what P3 A7 requires: a mark or a write that fails should be reported by touch the way one that lands already is. Without it a failed mark is silent on a phone that is not being looked at, which is the case the tactile vocabulary exists for. iOS answers it with `notificationOccurred(.error)` on the notification generator already built and kept warm for `success()`. Android answers it with `HapticFeedbackConstants.REJECT`, "a haptic effect to signal the rejection or failure of a user interaction", added in API 30, with `LONG_PRESS` below that. Both facts read off androidx's own `HapticFeedbackConstantsCompat` reference, which documents REJECT as 17 and its pre-30 compatibility as "Same feedback as LONG_PRESS", rather than inferred from the sibling CONFIRM. It is wired, not just declared. A verb with no caller is the speculative code `NO_REDIRECT_HANDOFF` already cost this branch once. It fires exactly where its counterpart `success()` does, at the mark and take-back failures the user is already told about: `showUndoFailed`, which is the one place the app says "Couldn't undo", the "Couldn't mark ... watched" snack, and the three movie mark/undo failures. Hooks that carry no haptics today, the watchlist, hide/show and history, are left alone; those are inline errors on a different affordance and widening them is a design change rather than this step. The seam test's table now asserts that it covers every verb the port declares, so the eighth verb cannot arrive untested. Mutation: with `failure` removed from that table, the new case fails and names itself. Verified on both shells: `cap sync android` then `assembleDebug` is BUILD SUCCESSFUL and `verify-apk.sh` reports 9.9.9 (42) with the merged permission set still exactly five. `prefers-reduced-motion` needed nothing. The gate is already gone and `test/platform/haptics.test.ts` already pins its absence with "still fires under prefers-reduced-motion"; the I0 report listing it as outstanding was wrong. --- .../main/java/app/cuetracker/CueHapticsPlugin.kt | 10 ++++++++++ ios/App/App/CueHaptics.swift | 5 +++++ packages/core/src/hooks/useMarkWatched.ts | 16 +++++++++------- packages/core/src/hooks/useMovieActions.ts | 7 ++++++- packages/core/src/ports/haptics.ts | 3 +++ packages/web/src/platform/haptics.ts | 2 ++ packages/web/test/platform/haptics.test.ts | 7 +++++++ packages/web/test/ui/pull-to-refresh.test.tsx | 1 + 8 files changed, 43 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/app/cuetracker/CueHapticsPlugin.kt b/android/app/src/main/java/app/cuetracker/CueHapticsPlugin.kt index 97c8dea0..477cbe37 100644 --- a/android/app/src/main/java/app/cuetracker/CueHapticsPlugin.kt +++ b/android/app/src/main/java/app/cuetracker/CueHapticsPlugin.kt @@ -32,6 +32,16 @@ class CueHapticsPlugin : Plugin() { else HapticFeedbackConstants.VIRTUAL_KEY, ) + /** REJECT is API 30, and it is the constant that signals "the interaction + * did not take". Below it, LONG_PRESS is the longest of the pre-30 effects + * and the one androidx's HapticFeedbackConstantsCompat falls back to. */ + @PluginMethod + fun failure(call: PluginCall) = perform( + call, + if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.REJECT + else HapticFeedbackConstants.LONG_PRESS, + ) + /** The gesture threshold pair is API 34. */ @PluginMethod fun thresholdActivate(call: PluginCall) = perform( diff --git a/ios/App/App/CueHaptics.swift b/ios/App/App/CueHaptics.swift index acd71110..01d1f4a8 100644 --- a/ios/App/App/CueHaptics.swift +++ b/ios/App/App/CueHaptics.swift @@ -18,6 +18,7 @@ public class CueHapticsPlugin: CAPPlugin, CAPBridgedPlugin { public let jsName = "CueHaptics" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "success", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "failure", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "thresholdActivate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "thresholdDeactivate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "selection", returnType: CAPPluginReturnPromise), @@ -37,6 +38,10 @@ public class CueHapticsPlugin: CAPPlugin, CAPBridgedPlugin { fire(call, notification) { $0.notificationOccurred(.success) } } + @objc func failure(_ call: CAPPluginCall) { + fire(call, notification) { $0.notificationOccurred(.error) } + } + @objc func thresholdActivate(_ call: CAPPluginCall) { fire(call, activate) { $0.impactOccurred() } } diff --git a/packages/core/src/hooks/useMarkWatched.ts b/packages/core/src/hooks/useMarkWatched.ts index 41707c78..6f3127e2 100644 --- a/packages/core/src/hooks/useMarkWatched.ts +++ b/packages/core/src/hooks/useMarkWatched.ts @@ -11,7 +11,7 @@ import { episodeItemKey, } from "../domain/write-queue/ops"; import { middleTruncate } from "../format"; -import { useHaptics } from "../ports/haptics"; +import { type Haptics, useHaptics } from "../ports/haptics"; import { type CueRuntime, type SubmitOutcome, useRuntime } from "../runtime/runtime"; import { hasPendingMark, @@ -65,7 +65,8 @@ async function waitWhileInFlight(runtime: CueRuntime, opId: string): Promise op.id === record.opId)) { @@ -217,7 +218,7 @@ export function useMarkWatched(): MarkWatched { plays = await runtime.loadEpisodePlays(record.episodeIds.trakt); } catch { reapplyMark(record); - showUndoFailed(record.title); + showUndoFailed(haptics, record.title); return null; } const target = findMarkPlay(plays, record.episodeIds.trakt, record.watchedAt); @@ -238,7 +239,7 @@ export function useMarkWatched(): MarkWatched { effects, ); }, - [runtime, submit, reapplyMark, revalidate], + [runtime, submit, reapplyMark, revalidate, haptics], ); const submitReversal = useCallback( @@ -252,9 +253,9 @@ export function useMarkWatched(): MarkWatched { // the suppression entry is spent and must not accumulate. settleReversal(record.opId); } - if (outcome === "failed") showUndoFailed(record.title); + if (outcome === "failed") showUndoFailed(haptics, record.title); }, - [runReversal], + [runReversal, haptics], ); const undoBatch = useCallback(async () => { @@ -379,6 +380,7 @@ export function useMarkWatched(): MarkWatched { const inBatch = after.batch.some((r) => r.opId === opId); after.setBatch(after.batch.filter((r) => r.opId !== opId)); if (ownedWindow || inBatch) { + haptics.failure(); showSnack({ message: `Couldn't mark ${entry.title} watched. Please try again.`, actions: [{ label: "Dismiss", onPress: dismissSnack }], diff --git a/packages/core/src/hooks/useMovieActions.ts b/packages/core/src/hooks/useMovieActions.ts index 88314ee8..6e83596c 100644 --- a/packages/core/src/hooks/useMovieActions.ts +++ b/packages/core/src/hooks/useMovieActions.ts @@ -176,6 +176,7 @@ export function useMovieActions(): MovieActions { if (outcome === "failed") { pendingMark.current = null; setUndo(null); + haptics.failure(); setError(`Couldn't update ${entry.title}. Please try again.`); return; } @@ -289,7 +290,10 @@ export function useMovieActions(): MovieActions { // The take-back is a completed action too, so it reports the same way. haptics.success(); const outcome = await reverseSessionMark(pending); - if (outcome === "failed") setError(`Couldn't undo ${pending.title}. Please try again.`); + if (outcome === "failed") { + haptics.failure(); + setError(`Couldn't undo ${pending.title}. Please try again.`); + } }, [undo, reverseSessionMark, haptics]); // Re-add the removed play: restore the watched entry and re-send the removal @@ -306,6 +310,7 @@ export function useMovieActions(): MovieActions { revalidate, }); if (outcome === "failed") { + haptics.failure(); setError(`Couldn't undo ${pending.before.title}. Please try again.`); } }, [removed, haptics, queryClient, submit, revalidate]); diff --git a/packages/core/src/ports/haptics.ts b/packages/core/src/ports/haptics.ts index 25ff8678..91e73e61 100644 --- a/packages/core/src/ports/haptics.ts +++ b/packages/core/src/ports/haptics.ts @@ -10,6 +10,8 @@ import { createContext, useContext } from "react"; export interface Haptics { /** A task completed: a watch mark landed, or was taken back. */ success(): void; + /** A task did not complete: a mark or a take-back the user was told failed. */ + failure(): void; /** A drag just crossed the threshold that arms its action on release. */ thresholdActivate(): void; /** The drag retreated back under that threshold, disarming it. */ @@ -26,6 +28,7 @@ export interface Haptics { /** Fires nothing: the browser build, the pre-token shell, and tests. */ export const SILENT: Haptics = { success() {}, + failure() {}, thresholdActivate() {}, thresholdDeactivate() {}, selection() {}, diff --git a/packages/web/src/platform/haptics.ts b/packages/web/src/platform/haptics.ts index f95e76fe..5aaee5dd 100644 --- a/packages/web/src/platform/haptics.ts +++ b/packages/web/src/platform/haptics.ts @@ -11,6 +11,7 @@ import { isNativePlatform } from "./platform"; */ interface CueHapticsPlugin { success(): Promise; + failure(): Promise; thresholdActivate(): Promise; thresholdDeactivate(): Promise; selection(): Promise; @@ -37,6 +38,7 @@ export function createNativeHaptics(isEnabled: () => boolean): Haptics { }; return { success: () => fire(() => CueHaptics.success()), + failure: () => fire(() => CueHaptics.failure()), thresholdActivate: () => fire(() => CueHaptics.thresholdActivate()), thresholdDeactivate: () => fire(() => CueHaptics.thresholdDeactivate()), selection: () => fire(() => CueHaptics.selection()), diff --git a/packages/web/test/platform/haptics.test.ts b/packages/web/test/platform/haptics.test.ts index 2857dd8a..ce144ad3 100644 --- a/packages/web/test/platform/haptics.test.ts +++ b/packages/web/test/platform/haptics.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const plugin = vi.hoisted(() => ({ success: vi.fn(async () => {}), + failure: vi.fn(async () => {}), thresholdActivate: vi.fn(async () => {}), thresholdDeactivate: vi.fn(async () => {}), selection: vi.fn(async () => {}), @@ -20,6 +21,7 @@ vi.mock("@capacitor/core", () => ({ * which system effect each one plays; this seam only owns the routing. */ const vocabulary = [ ["success", plugin.success], + ["failure", plugin.failure], ["thresholdActivate", plugin.thresholdActivate], ["thresholdDeactivate", plugin.thresholdDeactivate], ["selection", plugin.selection], @@ -39,6 +41,11 @@ describe("the native haptics seam", () => { } }); + it("covers every verb the port declares, so a new one cannot arrive untested", () => { + const declared = Object.keys(createNativeHaptics(() => true)).sort(); + expect(vocabulary.map(([method]) => method).sort()).toEqual(declared); + }); + it("fires nothing while the Settings toggle is off", () => { const haptics = createNativeHaptics(() => false); for (const [method] of vocabulary) haptics[method](); diff --git a/packages/web/test/ui/pull-to-refresh.test.tsx b/packages/web/test/ui/pull-to-refresh.test.tsx index 9b382a87..39654170 100644 --- a/packages/web/test/ui/pull-to-refresh.test.tsx +++ b/packages/web/test/ui/pull-to-refresh.test.tsx @@ -120,6 +120,7 @@ describe("the pull's haptics", () => { it("warms the engine as the gesture locks vertical, before any tick is due", () => { const haptics = { success: vi.fn(), + failure: vi.fn(), thresholdActivate: vi.fn(), thresholdDeactivate: vi.fn(), selection: vi.fn(), From 12e02401780618433a23a84a510d8a2189a91e3c Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:17:51 -0500 Subject: [PATCH 015/435] Move the tests that need nothing but node into @cue/core `packages/core` held 110 source files and five test files while 85 test files in `packages/web/test` tested core code, so `pnpm --filter @cue/core test` proved almost nothing and there was no script to run it with. When `packages/native` lands, its CI lane has to be able to run the shared layer's suite; today it could not. D14 defers the move because the tests need msw or React and because the core's vitest project is `environment: node`. That holds for the hook tests and for `read-budget.test.ts`, whose last case builds a real `createCueRuntime`. It does not hold for the half that moves here: all 17 files in `test/domain` and 13 of the 23 in `test/data` import nothing but `@cue/core/...` and `vitest`, touch no `document` and no `window`, and need no msw. Checked by grep rather than by reading the two the deferral names. `test/ui/library-chips.test.ts` comes with them. It tests `chipBuckets`, a pure function in a core hook module, and it was reaching into `test/domain/_helpers` across what is now a package boundary, so it lands as `test/hooks/`. What stays, and the standing reason: the eight `test/data` files that boot msw, `read-budget.test.ts` and `session-teardown.test.ts` because they build a real runtime, and everything under `test/ui`, which renders React that `environment: node` cannot and that `web-owns-dom` correctly keeps out of this package. `@cue/core` gains a `test` script, which is the point of the exercise: `pnpm --filter @cue/core test` now runs 35 files and 342 tests against the shared layer alone, where it ran five files and 27. One measurement artifact worth recording, because the numbers move and the code does not. Coverage aggregates across the two vitest projects, and a module both projects load is instrumented twice; moving these tests puts 199 more function entries into the denominator. `core/src/domain` functions read 99.04 rather than 100 and `core/src/data` 91.48 rather than 96.96, with no test and no source changed. Every threshold still passes, the aggregate rises from 75.68 to 76.34 statements, and the `core/src/hooks` ratchet is left where it is at 54 lines against 54.07 measured. This is the same effect the I0 report saw on the URL parsers from the other direction. --- packages/core/package.json | 3 ++- packages/{web => core}/test/data/auth-pkce.test.ts | 0 packages/{web => core}/test/data/image-source.test.ts | 0 packages/{web => core}/test/data/query-invalidation.test.ts | 0 packages/{web => core}/test/data/query-keys.test.ts | 0 packages/{web => core}/test/data/trakt-calendar.test.ts | 0 packages/{web => core}/test/data/trakt-episode-detail.test.ts | 0 packages/{web => core}/test/data/trakt-history.test.ts | 0 packages/{web => core}/test/data/trakt-library.test.ts | 0 packages/{web => core}/test/data/trakt-movie-library.test.ts | 0 .../{web => core}/test/data/trakt-pooled-endpoints.test.ts | 0 packages/{web => core}/test/data/trakt-search.test.ts | 0 packages/{web => core}/test/data/trakt-show-detail.test.ts | 0 packages/{web => core}/test/data/trakt-user-profile.test.ts | 0 packages/{web => core}/test/domain/_helpers.ts | 0 packages/{web => core}/test/domain/auth-token.test.ts | 0 packages/{web => core}/test/domain/calendar.test.ts | 0 packages/{web => core}/test/domain/history.test.ts | 0 packages/{web => core}/test/domain/library-buckets.test.ts | 0 packages/{web => core}/test/domain/recently-aired.test.ts | 0 packages/{web => core}/test/domain/reminders.test.ts | 0 packages/{web => core}/test/domain/reversal.test.ts | 0 packages/{web => core}/test/domain/sync-activities.test.ts | 0 packages/{web => core}/test/domain/time.test.ts | 0 packages/{web => core}/test/domain/up-next.test.ts | 0 packages/{web => core}/test/domain/watch-status.test.ts | 0 packages/{web => core}/test/domain/write-queue-bulk.test.ts | 0 .../{web => core}/test/domain/write-queue-classify.test.ts | 0 .../{web => core}/test/domain/write-queue-coalesce.test.ts | 0 packages/{web => core}/test/domain/write-queue-ops.test.ts | 0 packages/{web => core}/test/domain/write-queue-queue.test.ts | 0 .../{web/test/ui => core/test/hooks}/library-chips.test.ts | 0 32 files changed, 2 insertions(+), 1 deletion(-) rename packages/{web => core}/test/data/auth-pkce.test.ts (100%) rename packages/{web => core}/test/data/image-source.test.ts (100%) rename packages/{web => core}/test/data/query-invalidation.test.ts (100%) rename packages/{web => core}/test/data/query-keys.test.ts (100%) rename packages/{web => core}/test/data/trakt-calendar.test.ts (100%) rename packages/{web => core}/test/data/trakt-episode-detail.test.ts (100%) rename packages/{web => core}/test/data/trakt-history.test.ts (100%) rename packages/{web => core}/test/data/trakt-library.test.ts (100%) rename packages/{web => core}/test/data/trakt-movie-library.test.ts (100%) rename packages/{web => core}/test/data/trakt-pooled-endpoints.test.ts (100%) rename packages/{web => core}/test/data/trakt-search.test.ts (100%) rename packages/{web => core}/test/data/trakt-show-detail.test.ts (100%) rename packages/{web => core}/test/data/trakt-user-profile.test.ts (100%) rename packages/{web => core}/test/domain/_helpers.ts (100%) rename packages/{web => core}/test/domain/auth-token.test.ts (100%) rename packages/{web => core}/test/domain/calendar.test.ts (100%) rename packages/{web => core}/test/domain/history.test.ts (100%) rename packages/{web => core}/test/domain/library-buckets.test.ts (100%) rename packages/{web => core}/test/domain/recently-aired.test.ts (100%) rename packages/{web => core}/test/domain/reminders.test.ts (100%) rename packages/{web => core}/test/domain/reversal.test.ts (100%) rename packages/{web => core}/test/domain/sync-activities.test.ts (100%) rename packages/{web => core}/test/domain/time.test.ts (100%) rename packages/{web => core}/test/domain/up-next.test.ts (100%) rename packages/{web => core}/test/domain/watch-status.test.ts (100%) rename packages/{web => core}/test/domain/write-queue-bulk.test.ts (100%) rename packages/{web => core}/test/domain/write-queue-classify.test.ts (100%) rename packages/{web => core}/test/domain/write-queue-coalesce.test.ts (100%) rename packages/{web => core}/test/domain/write-queue-ops.test.ts (100%) rename packages/{web => core}/test/domain/write-queue-queue.test.ts (100%) rename packages/{web/test/ui => core/test/hooks}/library-chips.test.ts (100%) diff --git a/packages/core/package.json b/packages/core/package.json index b7a2640d..8c58b527 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,8 @@ "./*": "./src/*.ts" }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@tanstack/react-query": "5.101.2", diff --git a/packages/web/test/data/auth-pkce.test.ts b/packages/core/test/data/auth-pkce.test.ts similarity index 100% rename from packages/web/test/data/auth-pkce.test.ts rename to packages/core/test/data/auth-pkce.test.ts diff --git a/packages/web/test/data/image-source.test.ts b/packages/core/test/data/image-source.test.ts similarity index 100% rename from packages/web/test/data/image-source.test.ts rename to packages/core/test/data/image-source.test.ts diff --git a/packages/web/test/data/query-invalidation.test.ts b/packages/core/test/data/query-invalidation.test.ts similarity index 100% rename from packages/web/test/data/query-invalidation.test.ts rename to packages/core/test/data/query-invalidation.test.ts diff --git a/packages/web/test/data/query-keys.test.ts b/packages/core/test/data/query-keys.test.ts similarity index 100% rename from packages/web/test/data/query-keys.test.ts rename to packages/core/test/data/query-keys.test.ts diff --git a/packages/web/test/data/trakt-calendar.test.ts b/packages/core/test/data/trakt-calendar.test.ts similarity index 100% rename from packages/web/test/data/trakt-calendar.test.ts rename to packages/core/test/data/trakt-calendar.test.ts diff --git a/packages/web/test/data/trakt-episode-detail.test.ts b/packages/core/test/data/trakt-episode-detail.test.ts similarity index 100% rename from packages/web/test/data/trakt-episode-detail.test.ts rename to packages/core/test/data/trakt-episode-detail.test.ts diff --git a/packages/web/test/data/trakt-history.test.ts b/packages/core/test/data/trakt-history.test.ts similarity index 100% rename from packages/web/test/data/trakt-history.test.ts rename to packages/core/test/data/trakt-history.test.ts diff --git a/packages/web/test/data/trakt-library.test.ts b/packages/core/test/data/trakt-library.test.ts similarity index 100% rename from packages/web/test/data/trakt-library.test.ts rename to packages/core/test/data/trakt-library.test.ts diff --git a/packages/web/test/data/trakt-movie-library.test.ts b/packages/core/test/data/trakt-movie-library.test.ts similarity index 100% rename from packages/web/test/data/trakt-movie-library.test.ts rename to packages/core/test/data/trakt-movie-library.test.ts diff --git a/packages/web/test/data/trakt-pooled-endpoints.test.ts b/packages/core/test/data/trakt-pooled-endpoints.test.ts similarity index 100% rename from packages/web/test/data/trakt-pooled-endpoints.test.ts rename to packages/core/test/data/trakt-pooled-endpoints.test.ts diff --git a/packages/web/test/data/trakt-search.test.ts b/packages/core/test/data/trakt-search.test.ts similarity index 100% rename from packages/web/test/data/trakt-search.test.ts rename to packages/core/test/data/trakt-search.test.ts diff --git a/packages/web/test/data/trakt-show-detail.test.ts b/packages/core/test/data/trakt-show-detail.test.ts similarity index 100% rename from packages/web/test/data/trakt-show-detail.test.ts rename to packages/core/test/data/trakt-show-detail.test.ts diff --git a/packages/web/test/data/trakt-user-profile.test.ts b/packages/core/test/data/trakt-user-profile.test.ts similarity index 100% rename from packages/web/test/data/trakt-user-profile.test.ts rename to packages/core/test/data/trakt-user-profile.test.ts diff --git a/packages/web/test/domain/_helpers.ts b/packages/core/test/domain/_helpers.ts similarity index 100% rename from packages/web/test/domain/_helpers.ts rename to packages/core/test/domain/_helpers.ts diff --git a/packages/web/test/domain/auth-token.test.ts b/packages/core/test/domain/auth-token.test.ts similarity index 100% rename from packages/web/test/domain/auth-token.test.ts rename to packages/core/test/domain/auth-token.test.ts diff --git a/packages/web/test/domain/calendar.test.ts b/packages/core/test/domain/calendar.test.ts similarity index 100% rename from packages/web/test/domain/calendar.test.ts rename to packages/core/test/domain/calendar.test.ts diff --git a/packages/web/test/domain/history.test.ts b/packages/core/test/domain/history.test.ts similarity index 100% rename from packages/web/test/domain/history.test.ts rename to packages/core/test/domain/history.test.ts diff --git a/packages/web/test/domain/library-buckets.test.ts b/packages/core/test/domain/library-buckets.test.ts similarity index 100% rename from packages/web/test/domain/library-buckets.test.ts rename to packages/core/test/domain/library-buckets.test.ts diff --git a/packages/web/test/domain/recently-aired.test.ts b/packages/core/test/domain/recently-aired.test.ts similarity index 100% rename from packages/web/test/domain/recently-aired.test.ts rename to packages/core/test/domain/recently-aired.test.ts diff --git a/packages/web/test/domain/reminders.test.ts b/packages/core/test/domain/reminders.test.ts similarity index 100% rename from packages/web/test/domain/reminders.test.ts rename to packages/core/test/domain/reminders.test.ts diff --git a/packages/web/test/domain/reversal.test.ts b/packages/core/test/domain/reversal.test.ts similarity index 100% rename from packages/web/test/domain/reversal.test.ts rename to packages/core/test/domain/reversal.test.ts diff --git a/packages/web/test/domain/sync-activities.test.ts b/packages/core/test/domain/sync-activities.test.ts similarity index 100% rename from packages/web/test/domain/sync-activities.test.ts rename to packages/core/test/domain/sync-activities.test.ts diff --git a/packages/web/test/domain/time.test.ts b/packages/core/test/domain/time.test.ts similarity index 100% rename from packages/web/test/domain/time.test.ts rename to packages/core/test/domain/time.test.ts diff --git a/packages/web/test/domain/up-next.test.ts b/packages/core/test/domain/up-next.test.ts similarity index 100% rename from packages/web/test/domain/up-next.test.ts rename to packages/core/test/domain/up-next.test.ts diff --git a/packages/web/test/domain/watch-status.test.ts b/packages/core/test/domain/watch-status.test.ts similarity index 100% rename from packages/web/test/domain/watch-status.test.ts rename to packages/core/test/domain/watch-status.test.ts diff --git a/packages/web/test/domain/write-queue-bulk.test.ts b/packages/core/test/domain/write-queue-bulk.test.ts similarity index 100% rename from packages/web/test/domain/write-queue-bulk.test.ts rename to packages/core/test/domain/write-queue-bulk.test.ts diff --git a/packages/web/test/domain/write-queue-classify.test.ts b/packages/core/test/domain/write-queue-classify.test.ts similarity index 100% rename from packages/web/test/domain/write-queue-classify.test.ts rename to packages/core/test/domain/write-queue-classify.test.ts diff --git a/packages/web/test/domain/write-queue-coalesce.test.ts b/packages/core/test/domain/write-queue-coalesce.test.ts similarity index 100% rename from packages/web/test/domain/write-queue-coalesce.test.ts rename to packages/core/test/domain/write-queue-coalesce.test.ts diff --git a/packages/web/test/domain/write-queue-ops.test.ts b/packages/core/test/domain/write-queue-ops.test.ts similarity index 100% rename from packages/web/test/domain/write-queue-ops.test.ts rename to packages/core/test/domain/write-queue-ops.test.ts diff --git a/packages/web/test/domain/write-queue-queue.test.ts b/packages/core/test/domain/write-queue-queue.test.ts similarity index 100% rename from packages/web/test/domain/write-queue-queue.test.ts rename to packages/core/test/domain/write-queue-queue.test.ts diff --git a/packages/web/test/ui/library-chips.test.ts b/packages/core/test/hooks/library-chips.test.ts similarity index 100% rename from packages/web/test/ui/library-chips.test.ts rename to packages/core/test/hooks/library-chips.test.ts From 3a0143c65ba9b287429a60030ca54658cf86903d Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:20:33 -0500 Subject: [PATCH 016/435] Gate the plugin set the shells build `capacitor.config.ts` names `includePlugins` by hand, because moving `package.json` into `packages/web` left Capacitor discovering plugins from a root manifest that no longer had the code importing them. Its comment is honest about the price: "adding a plugin to the web package without adding it here leaves it out of the binaries". That is a silent, release-shaped failure, and nothing checked it. `verify-apk.sh` pins the merged permission set, so it would catch a plugin that asks for a permission and miss one that does not, and iOS has no equivalent at all. Three lists now have to agree: `includePlugins`, the `@capacitor/*` runtime dependencies of `packages/web`, and those of the root. Mutation-tested both ways: dropping `@capacitor/status-bar` from `includePlugins` fails the first case, dropping it from the root manifest fails the second, each naming itself. The root's five `@capacitor/*` dependencies stay. I checked whether they are still load-bearing now that `includePlugins` names them explicitly, and they are not today: with the whole `dependencies` block deleted, `cap sync android` regenerates `capacitor.settings.gradle` and `capacitor.build.gradle` with the same four plugin projects, because the hoisted linker leaves every package reachable from the root. That is exactly why they should stay declared. The root project is the one that owns `android/` and `ios/`, it genuinely consumes those packages, and leaning on the hoist would make `cap sync` break the day the workspace stops hoisting for the native package. Duplication that a check keeps true is not the kind worth deleting. --- .../web/test/ci/capacitor-plugins.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/web/test/ci/capacitor-plugins.test.ts diff --git a/packages/web/test/ci/capacitor-plugins.test.ts b/packages/web/test/ci/capacitor-plugins.test.ts new file mode 100644 index 00000000..268b77e2 --- /dev/null +++ b/packages/web/test/ci/capacitor-plugins.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import capacitorConfig from "../../../../capacitor.config"; + +const REPOSITORY_ROOT = path.join(import.meta.dirname, "../../../.."); + +const capacitorNames = (record: Record | undefined): readonly string[] => + Object.keys(record ?? {}) + .filter((name) => name.startsWith("@capacitor/")) + .sort(); + +const manifest = (file: string): { dependencies?: Record } => + JSON.parse(readFileSync(path.join(REPOSITORY_ROOT, file), "utf8")); + +/** + * Capacitor discovers plugins from the manifest beside `capacitor.config.ts`, + * which is the root's, while the code that imports them lives in the web + * package and declares them there. `includePlugins` is what bridges the two, + * and its own comment concedes the failure it buys: "adding a plugin to the web + * package without adding it here leaves it out of the binaries", silently, in a + * release build. `verify-apk.sh` catches only a plugin that adds a permission, + * and iOS has no equivalent at all. + * + * So the three lists are pinned to each other. The shells and the web app must + * want exactly the same plugins, and the root must declare them itself rather + * than reach them through the hoisted linker, so `cap sync` keeps working the + * day the workspace stops hoisting. + */ +describe("the plugin set the shells build", () => { + const web = capacitorNames(manifest("packages/web/package.json").dependencies); + const root = capacitorNames(manifest("package.json").dependencies); + + // `?? []` rather than an assertion: deleting the key is one of the ways the + // shells lose a plugin, so it has to fail this comparison, not throw before it. + it("is exactly what the web app imports", () => { + expect([...(capacitorConfig.includePlugins ?? [])].sort()).toEqual( + web.filter((name) => name !== "@capacitor/core"), + ); + }); + + it("is declared by the root, which is the manifest Capacitor reads", () => { + expect(root).toEqual(web); + }); +}); From 384348047252d190a4b6ab851e64817f8ce6e332 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:25:06 -0500 Subject: [PATCH 017/435] Run one KeyValueStore contract over every backend that exists 8.1 step 7's first half. The seam that carries the OAuth token and the durable write queue had one suite over `JsonStore` and nothing asserting the store underneath it, so a backend that truncated a long value, mangled a non-latin1 one, threw instead of answering null, or forgot across a restart would lose a user's writes with no test able to say so. The assertions live in `@cue/core`'s test tree and take a backing, because `packages/native`'s two stores have to join the same list rather than get their own copy of the same expectations. Eleven cases from 6.4, restricted to what applies before the Expo backends exist: exact round-trip, a non-latin1 value byte for byte, a 200KB value whole, an absent key as null, keys independent, a second removal idempotent, an overwrite in place, a value surviving the store being reopened, corrupt JSON as null through `JsonStore`, and the token round- tripping while a wrong-shaped one reads back null through `tokenSchema`. The web backend runs on a real IndexedDB, not a Map standing in for idb-keyval: the properties under test belong to the database rather than to the wrapper, so `fake-indexeddb` joins the web package's devDependencies and the suite drives `createKeyValueStore(false)` through it end to end. The Capacitor store keeps its Map, because there is no in-process implementation of the bridge to run and what is under test there is the shape of the calls. Two mutations, run on this commit. Truncating a write in the web store at 1KB fails "keeps a long value whole" on the web backend and nothing else. Making the native store throw on an absent key instead of answering null fails three cases on the native backend and nothing on the web one, which is the parameterisation doing its job. The suite also pins 3.2's namespace split in the only form the web has one: sign-out clears `cue.`-prefixed preferences out of `localStorage` and cannot reach the `cue.write-queue` entry in IndexedDB, because on this target the two namespaces are physically separate stores. --- cspell.json | 5 +- packages/core/test/support/kv-contract.ts | 114 ++++++++++++++++++ packages/web/package.json | 1 + .../web/test/platform/kv-contract.test.ts | 60 +++++++++ pnpm-lock.yaml | 9 ++ 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/support/kv-contract.ts create mode 100644 packages/web/test/platform/kv-contract.test.ts diff --git a/cspell.json b/cspell.json index 66d7d796..5280bb54 100644 --- a/cspell.json +++ b/cspell.json @@ -15,6 +15,7 @@ "contenteditable", "cspell", "cuetracker", + "Cœur", "depcruise", "dprint", "fanart", @@ -22,6 +23,7 @@ "fontsource", "idb", "imdb", + "indexeddb", "jank", "jscpd", "jsdom", @@ -83,7 +85,8 @@ "wordmark", "Workbox", "Zod", - "Zustand" + "Zustand", + "Привет" ], "ignorePaths": [ "node_modules/**", diff --git a/packages/core/test/support/kv-contract.ts b/packages/core/test/support/kv-contract.ts new file mode 100644 index 00000000..3bb7aab1 --- /dev/null +++ b/packages/core/test/support/kv-contract.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createJsonStore } from "../../src/ports/json-store"; +import type { KeyValueStore } from "../../src/ports/kv"; +import { createTokenStore } from "../../src/ports/token-store"; + +/** + * One backend under test. `open` returns a store over the SAME durable backing + * every time it is called, which is what lets the suite assert that a value + * outlives the store object the way it has to outlive an app launch. + */ +export interface KeyValueBacking { + readonly name: string; + open(): KeyValueStore; + reset(): Promise; +} + +const LONG = "x".repeat(200_000); +/** + * One value per way a store can lose text. In order: Cyrillic, a Latin-1 + * Supplement ligature inside the quotes JSON has to escape, Japanese, one code + * point outside the BMP (a surrogate pair in UTF-16), and the backslash and + * whitespace a naive serializer eats. A backend that stores its values as + * latin1, or that round-trips them through a lossy encoder, loses these + * silently, and what it is losing is a show title or an OAuth token. + */ +const UNICODE = 'Привет "Cœur" 日本語 \u{1f3ac} \\ \n\t'; + +const TOKEN = { + access_token: "a", + refresh_token: "r", + created_at: 1_700_000_000, + expires_in: 7_776_000, +} as const; + +/** + * The contract every `KeyValueStore` has to satisfy, run against each backend + * rather than asserted once against the one that happens to be in front of us. + * This seam carries the OAuth token and the durable write queue, so a backend + * that truncates a long value, mangles a non-latin1 one, throws instead of + * answering `null`, or forgets across a restart loses a user's writes silently. + */ +export function describeKeyValueStore(backing: KeyValueBacking): void { + describe(`the KeyValueStore contract on ${backing.name}`, () => { + beforeEach(async () => { + await backing.reset(); + }); + + it("reads back exactly what was written", async () => { + const store = backing.open(); + await store.write("cue.plain", "value"); + expect(await store.read("cue.plain")).toBe("value"); + }); + + it("keeps a non-latin1 value byte for byte", async () => { + const store = backing.open(); + await store.write("cue.unicode", UNICODE); + expect(await store.read("cue.unicode")).toBe(UNICODE); + }); + + it("keeps a long value whole", async () => { + const store = backing.open(); + await store.write("cue.long", LONG); + expect(await store.read("cue.long")).toBe(LONG); + }); + + it("answers null for a key nothing wrote, rather than throwing", async () => { + expect(await backing.open().read("cue.absent")).toBeNull(); + }); + + it("keeps its keys apart", async () => { + const store = backing.open(); + await store.write("cue.one", "1"); + await store.write("cue.two", "2"); + await store.remove("cue.one"); + expect(await store.read("cue.one")).toBeNull(); + expect(await store.read("cue.two")).toBe("2"); + }); + + it("treats a second removal as done rather than as an error", async () => { + const store = backing.open(); + await store.write("cue.gone", "value"); + await store.remove("cue.gone"); + await expect(store.remove("cue.gone")).resolves.toBeUndefined(); + expect(await store.read("cue.gone")).toBeNull(); + }); + + it("overwrites in place", async () => { + const store = backing.open(); + await store.write("cue.same", "first"); + await store.write("cue.same", "second"); + expect(await store.read("cue.same")).toBe("second"); + }); + + it("survives the store being reopened, which is what an app launch is", async () => { + await backing.open().write("cue.durable", UNICODE); + expect(await backing.open().read("cue.durable")).toBe(UNICODE); + }); + + it("reads a corrupt JSON entry back as null through JsonStore", async () => { + const store = backing.open(); + await store.write("cue.json", "{ not json"); + expect(await createJsonStore(store, "cue.json").read()).toBeNull(); + }); + + it("round-trips the token and refuses a wrong-shaped one", async () => { + const store = backing.open(); + await createTokenStore(store).write(TOKEN); + expect(await createTokenStore(store).read()).toEqual(TOKEN); + + await store.write("cue.trakt.token", JSON.stringify({ access_token: "only" })); + expect(await createTokenStore(store).read()).toBeNull(); + }); + }); +} diff --git a/packages/web/package.json b/packages/web/package.json index dd36353c..96fcc68a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -44,6 +44,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.3.1", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", "msw": "^2.14.6", "picomatch": "^4.0.5", diff --git a/packages/web/test/platform/kv-contract.test.ts b/packages/web/test/platform/kv-contract.test.ts new file mode 100644 index 00000000..382c1967 --- /dev/null +++ b/packages/web/test/platform/kv-contract.test.ts @@ -0,0 +1,60 @@ +/** + * Every `KeyValueStore` backend that exists today, against the one contract. + * + * The web store runs on a real IndexedDB implementation rather than a Map + * standing in for idb-keyval, because the properties the contract cares about + * (a 200KB value kept whole, an emoji kept as itself, a key gone after a + * removal) belong to the database, not to the wrapper. The Capacitor store is + * the one backend with no in-process implementation to run, so it keeps its + * Map: what is under test there is the shape of the bridge calls. + * + * `packages/native`'s backends join this list by handing the same suite their + * own `KeyValueBacking`, which is why the assertions live in `@cue/core`'s test + * tree rather than here. + */ + +import "fake-indexeddb/auto"; +import { createKeyValueStore } from "@platform/kv"; +import { clearLocalPreferences, preferenceStorage } from "@ui/prefs/preference-storage"; +import { clear } from "idb-keyval"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describeKeyValueStore } from "../../../core/test/support/kv-contract"; + +const nativeBacking = vi.hoisted(() => new Map()); + +vi.mock("@capacitor/preferences", async () => { + const { createPreferencesMock } = await import("../support/capacitor-preferences-mock"); + return createPreferencesMock(nativeBacking); +}); + +describeKeyValueStore({ + name: "the web store (idb-keyval over IndexedDB)", + open: () => createKeyValueStore(false), + reset: () => clear(), +}); + +describeKeyValueStore({ + name: "the native store (Capacitor Preferences)", + open: () => createKeyValueStore(true), + reset: async () => { + nativeBacking.clear(); + }, +}); + +describe("what sign-out's preference clear can reach", () => { + beforeEach(async () => { + await clear(); + localStorage.clear(); + }); + + it("cannot reach the durable write queue, which lives in a different store", async () => { + const kv = createKeyValueStore(false); + await kv.write("cue.write-queue", '[{"id":"op-1"}]'); + preferenceStorage.setItem("cue.theme", "dark"); + + clearLocalPreferences(); + + expect(preferenceStorage.getItem("cue.theme")).toBeNull(); + expect(await kv.read("cue.write-queue")).toBe('[{"id":"op-1"}]'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a1d4e6c..eb48592b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.3.1 version: 4.3.1(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -3402,6 +3405,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -8279,6 +8286,8 @@ snapshots: expect-type@1.4.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-equals@6.0.0: {} From 574a1aeb036595550d3dd639fc42507054c195c1 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:47:32 -0500 Subject: [PATCH 018/435] Add the request journal and the equivalence lane that captures it 8.1 step 7's second half. The migration's signature failure is that both apps look right and behave differently against Trakt: a mark sent with a different payload, an extra read, two marks coalesced that used to be separate, a write not enqueued while offline. Nothing in the other layers catches it, because each app is otherwise only ever compared with its own expectations. The fake Trakt wrote one line per request to stdout: method, path and status, no body. It now also writes a journal when `MOCK_TRAKT_JOURNAL` names a file: method, path, query and body, in order, one JSON object per line, truncated at start because a journal is one run against one fresh account. A request no route answers is journalled too, so a hole is visible on both sides of a comparison rather than only on the side that has it. The driver is a separate Playwright project, not a rewrite. The 21 existing specs answer Trakt themselves through `context.route` with no origin for a server to stand at, and moving them onto one would be the largest single change this repository could make for the least reason. `pnpm e2e:mock` builds `--mode mock`, boots the fake Trakt and a second preview beside the fixture one, and runs 15 flows as scripted user actions with no interception at all. It is off unless `E2E_MOCK=1`, because it costs a second build and two more processes that the fixture lane has no use for. `journal.mjs` owns what two runs may be compared on, and that policy is itself tested, because a normalizer that drops too much would let the very difference this layer exists to catch through in silence. Artwork goes, because its order is a function of scroll. The visibility-gated freshness poll goes, because its count measures how long a tab stayed in front. The sign-in leg goes: the web app redirects and a device polls, by design, and its payloads carry a fresh nonce and PKCE pair every run, so it is not comparable even against another run of the same app. `/oauth/token` stays with its secrets redacted and its `grant_type` kept, because a refresh that fires twice or never is a real difference. Every date-shaped value is replaced, in the path as well as the body, because the calendar carries its window there. Journal A captured: 493 entries over the 15 flows, and all six of the app's write paths appear in it, `/sync/history`, `/sync/history/remove`, `/sync/watchlist`, `/sync/watchlist/remove`, `/users/hidden/progress_watched` and its `/remove`. Getting the last of them there was itself a finding: a take-back while the write is still queued coalesce-cancels the pair and sends Trakt nothing, which is correct and now has its own flow, so the flows that want the reversal as a separate write wait for the durable queue to drain first. The journal file is gitignored. It is one run's artifact and both sides of a comparison are captured fresh, so carrying it in the tree would only let it go stale. --- .gitignore | 3 + README.md | 8 +- cspell.json | 2 + package.json | 1 + .../web/e2e/mock/01-connect-and-read.spec.ts | 30 ++++ .../web/e2e/mock/02-mark-and-undo.spec.ts | 43 ++++++ .../web/e2e/mock/03-show-and-episode.spec.ts | 38 +++++ .../e2e/mock/04-search-and-watchlist.spec.ts | 33 ++++ .../web/e2e/mock/05-every-write-path.spec.ts | 80 ++++++++++ packages/web/e2e/mock/_flow.ts | 72 +++++++++ packages/web/package.json | 3 +- packages/web/playwright.config.ts | 71 +++++++-- packages/web/test/harness/journal.test.ts | 143 ++++++++++++++++++ scripts/mock-trakt/journal.mjs | 108 +++++++++++++ scripts/mock-trakt/server.mjs | 23 ++- 15 files changed, 640 insertions(+), 18 deletions(-) create mode 100644 packages/web/e2e/mock/01-connect-and-read.spec.ts create mode 100644 packages/web/e2e/mock/02-mark-and-undo.spec.ts create mode 100644 packages/web/e2e/mock/03-show-and-episode.spec.ts create mode 100644 packages/web/e2e/mock/04-search-and-watchlist.spec.ts create mode 100644 packages/web/e2e/mock/05-every-write-path.spec.ts create mode 100644 packages/web/e2e/mock/_flow.ts create mode 100644 packages/web/test/harness/journal.test.ts create mode 100644 scripts/mock-trakt/journal.mjs diff --git a/.gitignore b/.gitignore index 1d62ccea..7665c031 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ android/keystore.properties coverage/ playwright-report/ test-results/ +# The equivalence lane's recording: one run's artifact, captured fresh on each +# side of a comparison rather than carried in the tree to go stale. +journal/ .dprint-cache # Local design-review output (screenshots + notes). Never push. diff --git a/README.md b/README.md index 192d9d42..ac083a60 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Every e2e run builds the app and starts its own preview server on port 4173. Set - **`buster:check`**: the committed persisted-shape witness still matches the shapes, so a cache that would be replayed against a changed type is dropped rather than trusted. - **Vite build**: a production build must compile. -`pnpm e2e` runs the Playwright suite (chromium). `pnpm audit` (high/critical production advisories) is deliberately kept out of `pnpm check` because it reads live advisory state; it runs as its own CI job on every push and on a weekly schedule. +`pnpm e2e` runs the Playwright suite (chromium); `pnpm e2e:mock` runs the equivalence lane (below). `pnpm audit` (high/critical production advisories) is deliberately kept out of `pnpm check` because it reads live advisory state; it runs as its own CI job on every push and on a weekly schedule. Git hooks are wired with [lefthook](https://lefthook.dev) (`pnpm install` runs `lefthook install`): pre-commit runs the fast gates, pre-push runs `pnpm check`. The full Playwright e2e suite runs in CI (`.github/workflows/ci.yml`, Node 22) on every pull request, and branch protection ruleset 18841630 requires the `e2e` context, so it still gates every merge. @@ -84,6 +84,12 @@ Every write the app makes moves the mock's in-memory account: history marks and The Trakt wire shapes are built twice, here and in `packages/web/e2e/helpers.ts`. Both run in Node and either could import the other, so what keeps them apart is the fixtures, not the module boundary: the mock seeds an account (a `library` with a linear `completed` counter, images served from its own origin, per-play watch stamps), while each Playwright spec seeds its own `shows` array with `hidden` and `inWatchlist` flags, serves no images at all, and gates every field on the `extended` level so a request that drops one breaks the suite. Unifying them means reconciling those two seed models, not moving a function. The duplication detector does not report the overlap either way: it reads each package's `src` and `test`. +### The equivalence lane + +`pnpm e2e:mock` is a separate Playwright project that drives the built app against the fake Trakt with no route interception at all, and records what the app asked Trakt for to `packages/web/journal/journal-a.ndjson`: method, path, query and body, in order. It exists for the native rewrite, and it catches the one failure nothing else can, that both apps look right while one of them sends a different payload, an extra read, or no write at all, because each is otherwise only ever compared with its own expectations. The 21 fixture specs are untouched and keep running against their own routes. + +The lane is off unless `E2E_MOCK=1` is set, because it costs a second build and two more processes on every run. `scripts/mock-trakt/journal.mjs` also owns what two runs may be compared on: artwork and the visibility-gated freshness poll are dropped, the sign-in leg is dropped because the two targets differ on it by design and its payloads are fresh every run, a token exchange keeps its `grant_type` and loses its secrets, and every date-shaped value is replaced so the same run yesterday equals the same run today. `packages/web/test/harness/journal.test.ts` is what stops that policy quietly dropping a real difference. + Reaching it from the iOS simulator needs one thing this branch deliberately does not do: a Debug-only `NSAppTransportSecurity` dictionary in `ios/App/App/Info.plist`, either `NSAllowsLocalNetworking` or an `NSExceptionDomains` entry for `127.0.0.1`, because App Transport Security blocks plaintext HTTP. It must never reach a release build, and `packages/web/test/privacy-claims.test.ts` fails if `NSAppTransportSecurity` appears in the committed `Info.plist` at all. ## Mobile diff --git a/cspell.json b/cspell.json index 5280bb54..9c57bdc8 100644 --- a/cspell.json +++ b/cspell.json @@ -33,7 +33,9 @@ "logomark", "macrotask", "Markable", + "ndjson", "neighbours", + "networkidle", "noopener", "noreferrer", "nums", diff --git a/package.json b/package.json index fd25699c..9afa6a17 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "vitest run --coverage", "e2e": "pnpm --filter @cue/web e2e", "e2e:mobile": "pnpm --filter @cue/web e2e:mobile", + "e2e:mock": "pnpm --filter @cue/web e2e:mock", "check:md": "dprint check", "check:spell": "cspell --no-progress --no-summary \"**/*.{ts,tsx,css,md}\"", "check:arch": "depcruise packages --config .dependency-cruiser.cjs", diff --git a/packages/web/e2e/mock/01-connect-and-read.spec.ts b/packages/web/e2e/mock/01-connect-and-read.spec.ts new file mode 100644 index 00000000..85f4e4a8 --- /dev/null +++ b/packages/web/e2e/mock/01-connect-and-read.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import { connect, settle } from "./_flow"; + +/** + * SHELL and the cold read. What the native app has to issue identically: the + * OAuth exchange, then the library reads that paint the first screen, and no + * more of them than this. + */ +test("connects and paints Up Next from a cold start", async ({ page }) => { + await connect(page); + await settle(page); + + await expect(page.getByTestId("up-next-list")).toBeVisible(); + await expect(page.getByTestId("up-next-card").first()).toBeVisible(); +}); + +test("walks the four tabs", async ({ page }) => { + await connect(page); + + for (const [label, screen] of [ + ["Library", "screen-library"], + ["Calendar", "screen-calendar"], + ["Search", "screen-search"], + ["Up Next", "screen-up-next"], + ] as const) { + await page.getByRole("link", { name: label, exact: true }).first().click(); + await expect(page.getByTestId(screen)).toBeVisible(); + await settle(page); + } +}); diff --git a/packages/web/e2e/mock/02-mark-and-undo.spec.ts b/packages/web/e2e/mock/02-mark-and-undo.spec.ts new file mode 100644 index 00000000..d6df59a3 --- /dev/null +++ b/packages/web/e2e/mock/02-mark-and-undo.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import { connect, landed, settle } from "./_flow"; + +/** The write path the whole app is built around: mark the queue's lead episode, + * take it back through the snackbar, then mark it and let it stand. */ +test("marks an episode, takes it back, then marks it for good", async ({ page }) => { + await connect(page); + const lead = page.getByTestId("up-next-card").first(); + await expect(lead).toBeVisible(); + + // Let the mark land before taking it back, so the reversal is the separate + // per-play removal it becomes then rather than a coalesced pair that sends + // nothing. Both are real behavior; the flow after this one records the other. + await lead.getByTestId("mark-watched").click(); + await expect(page.getByTestId("snackbar")).toBeVisible(); + await landed(page); + await page.getByTestId("snackbar-undo").click(); + await landed(page); + + await page.getByTestId("up-next-card").first().getByTestId("mark-watched").click(); + await expect(page.getByTestId("snackbar")).toBeVisible(); + await landed(page); + await settle(page); +}); + +/** The other half of the same behavior: a take-back before the write leaves the + * durable queue cancels the pair, so Trakt is never told anything happened. */ +test("takes a mark back before it leaves the queue", async ({ page }) => { + await connect(page); + await page.getByTestId("up-next-card").first().getByTestId("mark-watched").click(); + await page.getByTestId("snackbar-undo").click(); + await settle(page); +}); + +/** Sync now: the freshness gate driven deliberately rather than by the poll. */ +test("asks for a sync from Settings", async ({ page }) => { + await connect(page); + await page.getByTestId("avatar-link").click(); + await page.getByTestId("link-settings").click(); + await expect(page.getByTestId("screen-settings")).toBeVisible(); + await page.getByTestId("sync-now").click(); + await settle(page); +}); diff --git a/packages/web/e2e/mock/03-show-and-episode.spec.ts b/packages/web/e2e/mock/03-show-and-episode.spec.ts new file mode 100644 index 00000000..d3d21ae9 --- /dev/null +++ b/packages/web/e2e/mock/03-show-and-episode.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { connect, settle } from "./_flow"; + +/** Show detail: the header read, the season shelves, and one episode's sheet. */ +test("opens a show, its seasons, and one episode's sheet", async ({ page }) => { + await connect(page); + await page.getByRole("link", { name: "Library", exact: true }).first().click(); + await page.getByTestId("library-card").first().click(); + await expect(page.getByTestId("screen-show-detail")).toBeVisible(); + await settle(page); + + await expect(page.getByTestId("season-list")).toBeVisible(); + await page.getByTestId("season-trigger").first().click(); + await settle(page); + + await page.getByTestId("episode-row").first().click(); + await expect(page.getByTestId("episode-sheet")).toBeVisible(); + await expect(page.getByTestId("episode-mark-row")).toBeVisible(); + await settle(page); +}); + +/** Library: the chips, the reveal filter and the sort, over the virtualized grid. */ +test("filters and sorts the library", async ({ page }) => { + await connect(page); + await page.getByRole("link", { name: "Library", exact: true }).first().click(); + await expect(page.getByTestId("screen-library")).toBeVisible(); + await settle(page); + + await expect(page.getByTestId("library-card").first()).toBeVisible(); + await page.getByTestId("library-filter-toggle").click(); + await page.getByTestId("library-filter").fill("Harbor"); + await expect(page.getByTestId("library-card")).toHaveCount(1); + + await page.getByTestId("library-filter-toggle").click(); + await page.getByTestId("library-sort").click(); + await page.getByTestId("sort-alphabetical").click(); + await settle(page); +}); diff --git a/packages/web/e2e/mock/04-search-and-watchlist.spec.ts b/packages/web/e2e/mock/04-search-and-watchlist.spec.ts new file mode 100644 index 00000000..19781859 --- /dev/null +++ b/packages/web/e2e/mock/04-search-and-watchlist.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { connect, settle } from "./_flow"; + +/** Search, and the watchlist write it offers inline. */ +test("searches, then adds and removes a watchlist entry", async ({ page }) => { + await connect(page); + await page.getByRole("link", { name: "Search", exact: true }).first().click(); + await expect(page.getByTestId("screen-search")).toBeVisible(); + await settle(page); + + await page.getByTestId("search-input").fill("Coastal"); + await settle(page); +}); + +/** Calendar: the agenda the app reads a window of. */ +test("reads the calendar agenda", async ({ page }) => { + await connect(page); + await page.getByRole("link", { name: "Calendar", exact: true }).first().click(); + await expect(page.getByTestId("screen-calendar")).toBeVisible(); + await settle(page); +}); + +/** Profile and History: the stats tiles and the watch log. */ +test("reads the profile and the history log", async ({ page }) => { + await connect(page); + await page.getByTestId("avatar-link").click(); + await expect(page.getByTestId("screen-profile")).toBeVisible(); + await settle(page); + + await page.getByTestId("link-history").click(); + await expect(page.getByTestId("screen-history")).toBeVisible(); + await settle(page); +}); diff --git a/packages/web/e2e/mock/05-every-write-path.spec.ts b/packages/web/e2e/mock/05-every-write-path.spec.ts new file mode 100644 index 00000000..1eeb37c3 --- /dev/null +++ b/packages/web/e2e/mock/05-every-write-path.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; +import { connect, landed, settle } from "./_flow"; + +/** + * The six write paths, each once. These are the entries the equivalence + * comparison is really for: a payload that differs, a write coalesced that used + * to be separate, or a write not enqueued at all is invisible to every other + * layer, because each app is otherwise only ever compared with its own + * expectations. + */ + +test("marks a whole season in one batched write", async ({ page }) => { + await connect(page); + await page.goto("/show/8803"); + await expect(page.getByTestId("screen-show-detail")).toBeVisible(); + await settle(page); + + await page.locator('[data-season="2"]').getByTestId("season-check").click(); + await page.getByTestId("confirm-sheet-primary").click(); + await expect(page.getByTestId("snackbar")).toBeVisible(); + await settle(page); +}); + +test("stops a show, then resumes it", async ({ page }) => { + await connect(page); + await page.goto("/show/8805"); + await expect(page.getByTestId("screen-show-detail")).toBeVisible(); + await settle(page); + + await page.getByTestId("detail-overflow").click(); + await page.getByTestId("overflow-stop").click(); + await landed(page); + + await page.getByTestId("detail-overflow").click(); + await page.getByTestId("overflow-stop").click(); + await landed(page); + await settle(page); +}); + +// The seeded account has no show that offers an add (the one never started is +// already watchlisted), so the pair is driven from the movie that is: the remove +// write, then the add its Undo sends. +test("takes a movie off the watchlist and puts it back", async ({ page }) => { + await connect(page); + await page.goto("/movie/5503"); + await expect(page.getByTestId("screen-movie-detail")).toBeVisible(); + await settle(page); + + await page.getByTestId("movie-overflow").click(); + await page.getByTestId("overflow-watchlist").click(); + await expect(page.getByTestId("snackbar")).toBeVisible(); + await landed(page); + + await page.getByTestId("snackbar-undo").click(); + await landed(page); + await settle(page); +}); + +test("marks a movie watched, then unmarks it", async ({ page }) => { + await connect(page); + await page.goto("/movie/5503"); + await expect(page.getByTestId("screen-movie-detail")).toBeVisible(); + await settle(page); + + await page.getByTestId("movie-check").click(); + await settle(page); + await page.getByTestId("movie-check").click(); + await settle(page); +}); + +test("removes one play from the history log", async ({ page }) => { + await connect(page); + await page.goto("/history"); + await expect(page.getByTestId("screen-history")).toBeVisible(); + await settle(page); + + await page.getByTestId("history-row").first().getByTestId("mark-watched").click(); + await expect(page.getByTestId("snackbar")).toBeVisible(); + await settle(page); +}); diff --git a/packages/web/e2e/mock/_flow.ts b/packages/web/e2e/mock/_flow.ts new file mode 100644 index 00000000..eab7870c --- /dev/null +++ b/packages/web/e2e/mock/_flow.ts @@ -0,0 +1,72 @@ +import { expect, type Page, test } from "@playwright/test"; + +/** + * The equivalence lane's shared driving. + * + * These flows script user actions and nothing else: no `context.route`, no + * fixture, no assertion about a request. What Cue asked Trakt for is recorded by + * the fake Trakt itself, in `journal/journal-a.ndjson`, and the native app will + * be driven through the same actions and compared against that recording. So an + * assertion here exists only to know that the action landed before the next one + * starts; the journal is the artifact. + * + * One account, one mock process, one ordered run: the files are numbered because + * the account state each flow leaves behind is the state the next one starts + * from, exactly as it will be on the other side of the comparison. + */ + +/** Sign in through the redirect flow the mock answers, and land on Up Next. */ +export async function connect(page: Page): Promise { + await page.goto("/"); + await page.getByTestId("button-connect").click(); + await expect(page.getByTestId("screen-up-next")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("up-next-skeleton")).toHaveCount(0); +} + +/** Every flow starts signed in; the token is per browser context, not per run. */ +export function signedIn(): void { + test.beforeEach(async ({ page }) => { + await connect(page); + }); +} + +/** Wait for the app to go quiet, so the journal's order is the flow's order and + * not a race between a click and the read it triggers. */ +export async function settle(page: Page): Promise { + await page.waitForLoadState("networkidle"); +} + +/** + * Wait for the durable queue to drain. A take-back while the write is still + * queued coalesce-cancels the pair and sends nothing, which is correct and is + * what the journal should record; this is for the flows that want the write to + * have landed first, so the reversal is the separate write it becomes then. + */ +export async function landed(page: Page): Promise { + await expect + .poll( + () => + page.evaluate( + () => + new Promise((resolve, reject) => { + const open = indexedDB.open("keyval-store"); + open.onupgradeneeded = () => open.result.createObjectStore("keyval"); + open.onsuccess = () => { + const db = open.result; + const request = db + .transaction("keyval", "readonly") + .objectStore("keyval") + .get("cue.write-queue"); + request.onsuccess = () => { + db.close(); + resolve((request.result as string | undefined) ?? null); + }; + request.onerror = () => reject(request.error); + }; + open.onerror = () => reject(open.error); + }), + ), + { timeout: 15_000 }, + ) + .toBe("[]"); +} diff --git a/packages/web/package.json b/packages/web/package.json index 96fcc68a..3d7f39b3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,7 +11,8 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "e2e": "playwright test", - "e2e:mobile": "playwright test --project=mobile-chromium --project=mobile-webkit" + "e2e:mobile": "playwright test --project=mobile-chromium --project=mobile-webkit", + "e2e:mock": "E2E_MOCK=1 playwright test --project=mock" }, "dependencies": { "@capacitor/app": "^8.1.1", diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index 66c2881e..1f19aae9 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -16,21 +16,61 @@ const MOBILE_EXPERIENCE_SPECS = [ "viewport-zoom.spec.ts", ]; +/** + * The equivalence lane, off by default. It is a separate project rather than a + * rewrite of the 21 specs: those answer Trakt themselves through + * `context.route`, with no origin for a server to stand at, and moving them onto + * one would be the largest single change this repository could make for the + * least reason. This lane instead drives the fake Trakt with no interception at + * all and journals what the app asked for, so the native app can be compared + * against the same recording rather than against its own expectations. + * + * Off by default because it costs a second build and two more processes on every + * run, and the lane it exists for does not exist yet. `pnpm e2e:mock` turns it on. + */ +const MOCK_LANE = process.env["E2E_MOCK"] === "1"; +// `.env.mock` is committed with this origin in it, so the mock answers here or +// the build does not reach it. +const MOCK_TRAKT_URL = "http://127.0.0.1:8787"; +const MOCK_PREVIEW_PORT = PREVIEW_PORT + 1; +const MOCK_PREVIEW_URL = `http://127.0.0.1:${MOCK_PREVIEW_PORT}`; +const JOURNAL = process.env["MOCK_TRAKT_JOURNAL"] ?? "journal/journal-a.ndjson"; + +const mockServers = [ + { + command: `MOCK_TRAKT_JOURNAL=${JOURNAL} node ../../scripts/mock-trakt/server.mjs`, + url: `${MOCK_TRAKT_URL}/users/settings`, + reuseExistingServer: false, + timeout: 30_000, + }, + { + command: `pnpm exec vite build --mode mock && pnpm exec vite preview --host 127.0.0.1 --port ${MOCK_PREVIEW_PORT} --strictPort`, + url: MOCK_PREVIEW_URL, + reuseExistingServer: false, + timeout: 120_000, + }, +]; + export default defineConfig({ testDir: "./e2e", globalSetup: "./e2e/global-setup.ts", fullyParallel: true, workers: 1, - webServer: { - // `--mode test` loads the committed .env.test so the build boots with a dummy - // public client id in CI (where the real, gitignored .env is absent). - command: `pnpm exec vite build --mode test && pnpm exec vite preview --host 127.0.0.1 --port ${PREVIEW_PORT} --strictPort`, - url: PREVIEW_URL, - // Always spawn a fresh build+preview so the gate can never pass against a - // stale or unrelated server already listening on that port. - reuseExistingServer: false, - timeout: 120_000, - }, + // An array, so the documented caveat applies: with more than one server + // Playwright needs an explicit baseURL, which `use` below already sets. + webServer: [ + { + // `--mode test` loads the committed .env.test so the build boots with a dummy + // public client id in CI (where the real, gitignored .env is absent). + command: `pnpm exec vite build --mode test && pnpm exec vite preview --host 127.0.0.1 --port ${PREVIEW_PORT} --strictPort`, + url: PREVIEW_URL, + // Always spawn a fresh build+preview so the gate can never pass against a + // stale or unrelated server already listening on that port. + reuseExistingServer: false, + timeout: 120_000, + }, + ...(MOCK_LANE ? mockServers : []), + ], use: { baseURL: PREVIEW_URL, trace: "on-first-retry", @@ -40,7 +80,7 @@ export default defineConfig({ timezoneId: "America/New_York", }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "chromium", testIgnore: "mock/**", use: { ...devices["Desktop Chrome"] } }, { name: "mobile-chromium", testMatch: MOBILE_EXPERIENCE_SPECS, @@ -51,5 +91,14 @@ export default defineConfig({ testMatch: MOBILE_EXPERIENCE_SPECS, use: { ...devices["iPhone 15"] }, }, + ...(MOCK_LANE + ? [ + { + name: "mock", + testMatch: "mock/**/*.spec.ts", + use: { ...devices["Desktop Chrome"], baseURL: MOCK_PREVIEW_URL }, + }, + ] + : []), ], }); diff --git a/packages/web/test/harness/journal.test.ts b/packages/web/test/harness/journal.test.ts new file mode 100644 index 00000000..c83af209 --- /dev/null +++ b/packages/web/test/harness/journal.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { normalizeJournal, readJournal } from "../../../../scripts/mock-trakt/journal.mjs"; +import { createMockTrakt } from "../../../../scripts/mock-trakt/server.mjs"; + +/** + * The write-journal harness, at the layer where it can be asserted at all. + * + * What it uniquely owns is the equivalence comparison's own correctness: a + * normalizer that drops too much would let a genuine behavioral difference + * through silently, and that is the one failure the whole equivalence layer + * exists to catch, so it cannot be the thing nobody checks. + */ + +const directory = mkdtempSync(join(tmpdir(), "cue-journal-")); +const file = join(directory, "nested", "journal.ndjson"); +const mock = createMockTrakt({ port: 0, log: false, journalFile: file }); +let baseUrl = ""; + +beforeAll(async () => { + baseUrl = await mock.listen(); +}); + +afterAll(async () => { + await mock.close(); + rmSync(directory, { recursive: true, force: true }); +}); + +const call = (path: string, init?: RequestInit): Promise => + fetch(`${baseUrl}${path}`, init); + +describe("the mock's request journal", () => { + it("records method, path, query and body, in order", async () => { + await call("/users/settings"); + await call("/sync/history", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + episodes: [{ ids: { trakt: 1 }, watched_at: "2026-08-22T10:00:00Z" }], + }), + }); + + expect(readJournal(file)).toEqual([ + { method: "GET", path: "/users/settings", search: "", body: {} }, + { + method: "POST", + path: "/sync/history", + search: "", + body: { episodes: [{ ids: { trakt: 1 }, watched_at: "2026-08-22T10:00:00Z" }] }, + }, + ]); + }); + + it("records a request no route answers, so a hole is visible on both sides", async () => { + await call("/no/such/endpoint"); + expect(readJournal(file).at(-1)).toEqual({ + method: "GET", + path: "/no/such/endpoint", + search: "", + body: {}, + }); + }); +}); + +describe("what two runs are compared on", () => { + const entry = (path: string, extra: Record = {}) => ({ + method: "GET", + path, + search: "", + body: null, + ...extra, + }); + + it("drops the requests whose count measures timing or layout, not behavior", () => { + expect( + normalizeJournal([ + entry("/shows/1/images/poster.jpg"), + entry("/oauth/authorize"), + entry("/oauth/device/code", { method: "POST" }), + entry("/oauth/device/token", { method: "POST" }), + entry("/sync/last_activities"), + entry("/users/settings"), + ]), + ).toEqual([entry("/users/settings")]); + }); + + it("keeps a token exchange, and compares it on what it was rather than on its secrets", () => { + const exchange = (grant: string, secret: string) => [ + entry("/oauth/token", { + method: "POST", + body: { grant_type: grant, code_verifier: secret, redirect_uri: `cue://${secret}` }, + }), + ]; + expect(normalizeJournal(exchange("authorization_code", "one"))).toEqual( + normalizeJournal(exchange("authorization_code", "two")), + ); + expect(normalizeJournal(exchange("refresh_token", "one"))).not.toEqual( + normalizeJournal(exchange("authorization_code", "one")), + ); + }); + + it("makes the same run yesterday equal to the same run today", () => { + const monday = normalizeJournal([ + entry("/calendars/my/shows/2026-08-17/7", { search: "?extended=full" }), + entry("/sync/history", { + method: "POST", + body: { episodes: [{ watched_at: "2026-08-17T09:30:00.000Z" }] }, + }), + ]); + const tuesday = normalizeJournal([ + entry("/calendars/my/shows/2026-08-18/7", { search: "?extended=full" }), + entry("/sync/history", { + method: "POST", + body: { episodes: [{ watched_at: "2026-08-18T21:04:11.000Z" }] }, + }), + ]); + expect(monday).toEqual(tuesday); + }); + + it("still tells two different requests apart", () => { + expect(normalizeJournal([entry("/shows/1/progress/watched")])).not.toEqual( + normalizeJournal([entry("/shows/2/progress/watched")]), + ); + }); + + it("still tells two different payloads apart", () => { + const mark = (trakt: number) => [ + entry("/sync/history", { + method: "POST", + body: { episodes: [{ ids: { trakt }, watched_at: "2026-08-18T21:04:11.000Z" }] }, + }), + ]; + expect(normalizeJournal(mark(1))).not.toEqual(normalizeJournal(mark(2))); + }); + + it("still tells a missing date apart from a present one", () => { + expect( + normalizeJournal([entry("/sync/history", { body: { watched_at: "2026-08-18" } })]), + ).not.toEqual(normalizeJournal([entry("/sync/history", { body: {} })])); + }); +}); diff --git a/scripts/mock-trakt/journal.mjs b/scripts/mock-trakt/journal.mjs new file mode 100644 index 00000000..3d97d83d --- /dev/null +++ b/scripts/mock-trakt/journal.mjs @@ -0,0 +1,108 @@ +/** + * The request journal: what the app asked Trakt for, in order, in a form two + * different apps can be compared through. + * + * The migration's signature failure is that both apps look right and behave + * differently against Trakt: a mark sent with a different payload, an extra + * read, two marks coalesced that used to be separate, a write not enqueued + * while offline. Nothing but this catches it, because each side is otherwise + * only ever compared with its own expectations. + * + * The writer records faithfully and the normalizer decides what is comparable, + * so a journal stays readable as a debugging artifact and the comparison policy + * lives in exactly one place. + */ + +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +/** Artwork the app fetches lazily as cards settle on screen: ordering is a + * function of scroll and layout, not of behavior. */ +const ARTWORK = /\/(images|posters)\//; +/** + * The sign-in leg, which the two targets differ on by design: the web app runs + * the authorization-code flow through a full-page redirect, a device runs the + * device-code flow and polls until a human approves. Its request count measures + * how long that human took, and its payloads carry a fresh nonce and PKCE pair + * every run, so it is not comparable even against another run of the same app. + * What IS comparable is what happens to the token afterwards, so `/oauth/token` + * stays: a refresh that fires twice, or never, is a real difference. + */ +const SIGN_IN = new Set(["/oauth/authorize", "/oauth/device/code", "/oauth/device/token"]); +const TOKEN = "/oauth/token"; +/** Per-run or per-target values in a token exchange. `grant_type` is the field + * that says what the exchange was, and it survives. */ +const TOKEN_SECRETS = ["code", "code_verifier", "refresh_token", "redirect_uri", "client_id"]; +/** Visibility-gated, so its count measures how long the tab stayed in front. */ +const ACTIVITIES = "/sync/last_activities"; +/** Anything derived from `Date.now()`: the calendar's window start, and the + * `watched_at` a mark stamps. Replaced rather than dropped, so a missing one is + * still a difference. */ +const DATE = /\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?/g; +const WINDOW = ""; + +/** + * Append one entry per request. With no file, the mock journals nothing and + * costs nothing, which is what every run but the equivalence lane's wants. + */ +export function createJournal(file) { + if (file === undefined || file === "") return { record: () => {}, file: null }; + mkdirSync(dirname(file), { recursive: true }); + // Truncated, not appended to: a journal is one run against one fresh account, + // and two runs concatenated would compare as a behavior nobody performed. + writeFileSync(file, "", "utf8"); + return { + file, + record(method, path, search, body) { + const entry = { method, path, search, body }; + appendFileSync(file, `${JSON.stringify(entry)}\n`, "utf8"); + }, + }; +} + +/** Read a journal file back as entries. */ +export const readJournal = (file) => + readFileSync(file, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + +const stripDates = (value) => { + if (typeof value === "string") return value.replace(DATE, WINDOW); + if (Array.isArray(value)) return value.map(stripDates); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stripDates(v)])); + } + return value; +}; + +/** + * What two runs are allowed to be compared on. Drops the requests whose count + * is a function of timing or layout rather than of behavior, and replaces every + * date-shaped value so a run yesterday equals the same run today. + */ +export function normalizeJournal(entries) { + return entries + .filter( + (entry) => !ARTWORK.test(entry.path) && !SIGN_IN.has(entry.path) && entry.path !== ACTIVITIES, + ) + .map((entry) => ({ + method: entry.method, + // The calendar carries its window start in the path, so the path is + // stripped too. Trakt's own path segments are numeric ids, never dates. + path: stripDates(entry.path), + search: stripDates(entry.search ?? ""), + body: stripDates(redactTokenExchange(entry)), + })); +} + +function redactTokenExchange(entry) { + const body = entry.body ?? null; + if (entry.path !== TOKEN || body === null || typeof body !== "object") return body; + return Object.fromEntries( + Object.entries(body).map(([key, value]) => [ + key, + TOKEN_SECRETS.includes(key) ? "" : value, + ]), + ); +} diff --git a/scripts/mock-trakt/server.mjs b/scripts/mock-trakt/server.mjs index b67b6fbc..4472c7ec 100644 --- a/scripts/mock-trakt/server.mjs +++ b/scripts/mock-trakt/server.mjs @@ -14,6 +14,7 @@ import { createServer } from "node:http"; import { pathToFileURL } from "node:url"; +import { createJournal } from "./journal.mjs"; import { applyHiddenWrite, applyHistoryWrite, @@ -347,17 +348,29 @@ function resolve(library, method, url, origin, body) { * A mock instance: `listen()` resolves with the URL it bound, and `library` is * the live account state, so a caller can assert a write landed. */ -export function createMockTrakt({ port = DEFAULT_PORT, host = "127.0.0.1", log = true } = {}) { +export function createMockTrakt({ + port = DEFAULT_PORT, + host = "127.0.0.1", + log = true, + journalFile = process.env["MOCK_TRAKT_JOURNAL"], +} = {}) { const library = createLibrary(); + const journal = createJournal(journalFile); const server = createServer((request, response) => { void (async () => { const origin = `http://${request.headers.host ?? `${host}:${port}`}`; const url = new URL(request.url ?? "/", origin); const method = request.method ?? "GET"; - const result = - method === "OPTIONS" - ? { status: 204, headers: {}, body: "" } - : resolve(library, method, url, origin, await readBody(request)); + if (method === "OPTIONS") { + response.writeHead(204, CORS); + response.end(""); + return; + } + const body = await readBody(request); + // Journalled before the route runs, so a request with no route is still + // in the record: a hole has to be visible on both sides of a comparison. + journal.record(method, url.pathname, url.search, body); + const result = resolve(library, method, url, origin, body); if (log) { process.stdout.write( `mock-trakt ${method} ${url.pathname}${url.search} ${result.status}\n`, From a9a7b3e68b96dd900b7710d6c96000e300784840 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 21:50:47 -0500 Subject: [PATCH 019/435] Declare @types/node at the root, and prove the hoist is no longer load-bearing `nodeLinker: hoisted` is spec-conformant and it is a repository-wide strictness loss taken now for a package that arrives later: every transitive dependency becomes importable from every package without being declared. The review offered two ways out, and this is the second: keep the setting, and pay for it by making each manifest complete and verifying once that the tree still installs and typechecks without the hoist. Doing that check found the last phantom. The root type program sets `types: ["node"]` for `vitest.config.ts` and `capacitor.config.ts`, and the root manifest declared no `@types/node`; it resolved only because the web package's copy sat at the workspace root. Under `nodeLinker: isolated` the root program fails outright with `TS2688: Cannot find type definition file for 'node'`. It is declared here. dependency-cruiser's `packages-declare-their-imports` could not have caught this one: it is a `types` entry in a tsconfig rather than an import, and it is at the root rather than in a package. Verified in a scratch clone of this commit with `nodeLinker: isolated`, which is the only way to see it: `pnpm install --frozen-lockfile` is clean, there is no `react` at the workspace root and `packages/core/node_modules` holds its own, `pnpm typecheck` passes over all three programs, and `pnpm check` is green end to end, including the build. Nothing in the tree leans on the hoist any more, so the setting is now only what Expo's guidance asks for rather than something the repository has come to depend on. --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index 9afa6a17..73d524a5 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@capacitor/android": "^8.5.0", "@capacitor/cli": "^8.5.0", "@capacitor/ios": "^8.5.0", + "@types/node": "^22.12.0", "@vitest/coverage-v8": "^4.1.9", "cspell": "^9.8.0", "dependency-cruiser": "^18.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb48592b..53c9bc4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@capacitor/ios': specifier: ^8.5.0 version: 8.5.0(@capacitor/core@8.5.0) + '@types/node': + specifier: ^22.12.0 + version: 22.20.0 '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) From 8b1aebe606114a3b5b42dd4c41df600059cf9ae1 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 22:47:26 -0500 Subject: [PATCH 020/435] Write the shape witness's outside token as an escape, not a raw NUL scripts/write-buster.mjs carried the sentinel as a literal NUL byte, so git classified the one script whose failure mode is silent permanent cache corruption as binary: the three commits that changed it have no reviewable diff. The two-character escape produces the identical runtime string, so the witness does not move and nothing reseeds. Proof the value is unchanged and the check is not vacuous: `buster:check` reads `ok (shape fae27b70d3bb, buster 4d3253e9dc61)` before and after, and changing the token's text to "\0OUTSIDE-PROBE" moves the shape to 881915530c57, so the string this commit rewrites is one the digest actually consumes. --- scripts/write-buster.mjs | Bin 8070 -> 8071 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/scripts/write-buster.mjs b/scripts/write-buster.mjs index 87b4c20189d236844efd3b95eeb783c7e243adb1..90daa02cecf351c17eaf0c8b32e7dda5eb1491bb 100644 GIT binary patch delta 15 WcmZp(Z@1s@o0BQVVDlf&E>Qq8Kn5iM delta 14 VcmZp-Z?oU Date: Sat, 22 Aug 2026 22:52:47 -0500 Subject: [PATCH 021/435] Pin the Tailwind scan on the import, where it actually takes effect `@source "../"` was a no-op. Tailwind v4 treats `@source` as additive: it registers a path and leaves automatic content detection running, and automatic detection starts at the working directory the build runs in, which for this app is the whole web package. So the shipped stylesheet stayed a function of prose in unit tests and Playwright specs. `source()` on the import is the only thing that moves that starting point, and this moves it to packages/web/src. Measured before, with a bare word planted in a Playwright spec comment and another in a unit test: index-C3P0mvO4.css 75053 bytes becomes index-DnuPyKSv.css 75159 bytes, with `.capitalize` from the spec and `.truncate` from the test in it. After, both builds are index-Crrafkpd.css at 74333 bytes, byte identical. The six utilities the pin drops are `blur`, `border`, `container`, `contents`, `invisible` and `table`; every one came from outside src, and no class attribute under src carries any of them, which is why nothing is added back. The gate is a test rather than an eye: the previous attempt could not tell a working pin from a no-op because both produce an unchanged stylesheet. tailwind-scan.test.ts fails on the exact content this replaces. --- packages/web/src/ui/styles.css | 17 +++++++-------- packages/web/test/ci/tailwind-scan.test.ts | 25 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 packages/web/test/ci/tailwind-scan.test.ts diff --git a/packages/web/src/ui/styles.css b/packages/web/src/ui/styles.css index e8293573..e3f6dc93 100644 --- a/packages/web/src/ui/styles.css +++ b/packages/web/src/ui/styles.css @@ -1,14 +1,13 @@ -@import "tailwindcss"; /* - * Where the utilities come from. Tailwind v4 detects content automatically from - * the directory its config sits in, which made the generated set a function of - * the repository layout rather than of the source: before the workspace move it - * scanned from the repository root and emitted `.invisible` and `.lowercase` - * from prose in .cjs and .md files, neither of which any markup has ever used. - * Naming the source explicitly is what makes moving a directory generate exactly - * the same utilities as before. + * Where the utilities come from. Tailwind v4's automatic content detection + * starts at the working directory the build runs in, which made the generated + * set a function of everything beside the app rather than of the app: prose in + * a unit test or a Playwright spec emitted utilities no markup has ever used. + * `source()` on the import moves that starting point to the `src` beside this + * file, which is the only tree that can contain markup. A bare `@source` rule + * cannot do this: it adds a path and leaves automatic detection running. */ -@source "../"; +@import "tailwindcss" source("../"); @import "./styles/base.css"; @import "./styles/layout.css"; @import "./styles/components.css"; diff --git a/packages/web/test/ci/tailwind-scan.test.ts b/packages/web/test/ci/tailwind-scan.test.ts new file mode 100644 index 00000000..02c9104c --- /dev/null +++ b/packages/web/test/ci/tailwind-scan.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const STYLES = path.join(import.meta.dirname, "../../src/ui/styles.css"); +const APP_SOURCE = path.join(import.meta.dirname, "../../src"); + +/** + * Tailwind v4 starts its automatic content detection at the working directory + * the build runs in, which for this app is the whole web package: the utility + * set was a function of prose in unit tests and Playwright specs, and the + * shipped stylesheet moved when a comment did. Only `source()` on the import + * moves that starting point; a bare `@source` rule adds a path and leaves + * automatic detection running, which is what made the first attempt at this a + * no-op that nothing could see. + */ +describe("the Tailwind scan", () => { + it("starts at the app's own source root", () => { + const pinned = /@import\s+"tailwindcss"\s+source\("([^"]+)"\)\s*;/.exec( + readFileSync(STYLES, "utf8"), + ); + expect(pinned, "the tailwindcss import must pin its scan base with source(...)").not.toBeNull(); + expect(path.resolve(path.dirname(STYLES), pinned?.[1] ?? "")).toBe(APP_SOURCE); + }); +}); From faa81405cf9592ad3f568f6e33890cfc4b1d790a Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 22 Aug 2026 22:57:06 -0500 Subject: [PATCH 022/435] Fail the equivalence lane when the app stops sending a write The lane recorded the journal and nothing read it, so a write path removed at the dispatch site passed: every screen still behaves, and each app is otherwise only ever compared against its own expectations, which is the migration defect the lane exists to catch. A teardown project on `mock` reads the recording once, after every flow, and fails naming the write paths the run never sent. The six are pinned as literals rather than derived from the write queue's own constants: derived, a path deleted from the queue would shrink the expectation and the lane would still pass. Proved by planting `if (op.request.path === "/sync/history/remove") return "done";` at the queue's dispatch site. Before: 16 passed. With the mutation: 15 flow tests still pass and mock-journal fails with `+ "/sync/history/remove"`. --- packages/web/e2e/mock/journal.teardown.ts | 39 +++++++++++++++++++++++ packages/web/playwright.config.ts | 13 ++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/web/e2e/mock/journal.teardown.ts diff --git a/packages/web/e2e/mock/journal.teardown.ts b/packages/web/e2e/mock/journal.teardown.ts new file mode 100644 index 00000000..45ee3010 --- /dev/null +++ b/packages/web/e2e/mock/journal.teardown.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { expect, test } from "@playwright/test"; +import { JOURNAL_FILE } from "../../playwright.config"; + +/** + * Every write Cue can send, pinned as literals rather than derived from + * `domain/write-queue/ops.ts`. Derived, a path deleted from the queue would + * shrink the expectation and the lane would still pass, which is the migration + * defect it exists to catch: the app quietly stops telling Trakt something while + * every screen still behaves. These are Trakt's endpoints, so this list moves + * only when Trakt's API does. + */ +const WRITE_PATHS = [ + "/sync/history", + "/sync/history/remove", + "/sync/watchlist", + "/sync/watchlist/remove", + "/users/hidden/progress_watched", + "/users/hidden/progress_watched/remove", +]; + +/** + * The journal is a whole-run artifact: one account, one mock process, one + * ordered run of all five flows, which is what makes it comparable against the + * native app's recording of the same actions. So this covers the lane, not a + * file, and running a single spec through `--project=mock` fails it by design. + */ +test("the journal records every write path the flows drove", () => { + const sent = new Set( + readFileSync(JOURNAL_FILE, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { method: string; path: string }) + .filter((entry) => entry.method === "POST") + .map((entry) => entry.path), + ); + + expect(WRITE_PATHS.filter((path) => !sent.has(path))).toEqual([]); +}); diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index 1f19aae9..05ab9dca 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; // 4173 is Vite's preview default. Override with E2E_PREVIEW_PORT so two checkouts can @@ -35,6 +36,12 @@ const MOCK_TRAKT_URL = "http://127.0.0.1:8787"; const MOCK_PREVIEW_PORT = PREVIEW_PORT + 1; const MOCK_PREVIEW_URL = `http://127.0.0.1:${MOCK_PREVIEW_PORT}`; const JOURNAL = process.env["MOCK_TRAKT_JOURNAL"] ?? "journal/journal-a.ndjson"; +/** + * The same file, absolute. The mock resolves the relative name from its own cwd, + * which is this directory, and the teardown project reads the journal back from + * wherever `playwright test` was invoked. + */ +export const JOURNAL_FILE = path.resolve(import.meta.dirname, JOURNAL); const mockServers = [ { @@ -96,8 +103,14 @@ export default defineConfig({ { name: "mock", testMatch: "mock/**/*.spec.ts", + teardown: "mock-journal", use: { ...devices["Desktop Chrome"], baseURL: MOCK_PREVIEW_URL }, }, + // Reads the recording the flows just produced, so the lane fails on a + // write the app stopped sending. A teardown project rather than a + // sixth spec: it must run once, after every flow, whatever order the + // files ran in. + { name: "mock-journal", testMatch: "mock/journal.teardown.ts" }, ] : []), ], From 02d75d61eb6b4240fd7e5f0d2eefcec0545add2f Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sun, 23 Aug 2026 00:59:06 -0500 Subject: [PATCH 023/435] Scaffold packages/native, in the gate on the commit that creates it An Expo SDK 57 app under Continuous Native Generation: `ios/` and `android/` are prebuild output and are not committed, so every native fact lives in `app.config.ts` or in one of the three config plugins. `expo prebuild` in SDK 57 clears and regenerates both directories by default, which makes a committed native tree a second copy that can silently disagree with the plugin that is supposed to own it. Four facts in the app config are release blockers, and each is pinned by a test because none of them is visible until a build is in somebody's hands: the bundle id is `app.cuetracker` on both platforms, because on Play a different `applicationId` is a different app rather than an upgrade; the orientation set and tablet support are the shipping app's; and the colour scheme follows the system, because the app has three themes. `BUILD_NUMBER` and `APP_VERSION` come from the environment, which collapses the two mechanisms the Capacitor line uses into the one prebuild already applies to both projects. `with-ios-scene-lifecycle` adopts the UIKit scene life cycle, without which an app linked against the iOS 27 SDK installs and then refuses to launch. It is the spike's plugin with the two corrections its review named: the scene cold launch reads `connectionOptions.urlContexts` and `.userActivities`, and it reconstructs real launch options for `startReactNative`, without which `Linking.getInitialURL()` answers null and every cold-start deep link is discarded. `with-android-privacy` re-establishes `allowBackup="false"` and the app's own data-extraction rules. Android's default is `true`, so a prebuilt app silently opts back in and turns a claim PRIVACY.md, docs/index.html and README.md all make into a false one. `verify-apk.sh` now reads the result out of the built APK rather than trusting the plugin: backup off, the rules resolved through the resource table and their contents checked, no ``, and the merged permission set pinned exactly per line. The Expo line's set is measured off a release prebuild and differs from the Capacitor line's, which is why the script takes the line as an argument instead of pretending one list covers both. `with-android-build-memory` raises the Gradle JVM heap from the template's `-Xmx2048m` to `-Xmx4g`. D8 runs out of heap merging this app's dex archives at the template's size, and the failure is an `OutOfMemoryError` inside `:app:mergeDexRelease` rather than anything the app's own code answers for. It is a plugin rather than a checked-in `gradle.properties` for the same reason as the other two: `android/` is prebuild output, so a hand-written value does not survive the next `expo prebuild --clean`. The release build is the assertion: without it, `assembleRelease` fails. Twenty-five permissions the dependency tree adds are blocked in the app config and each group is explained there: Expo's four optional template permissions, expo-secure-store's biometric pair, and expo-notifications' push receive, Play install-referrer binding and per-OEM launcher badge set. A TV tracker asking eight launcher vendors for shortcut access is exactly what the permission gate exists to stop. Three pnpm overrides come with the package. `expo` is a runtime dependency, so `pnpm audit --prod` walks its build tooling too, and `@expo/cli` and `@expo/metro-config` pull transitive `brace-expansion`, `nanoid` and `postcss` versions with high-severity advisories against them. Each override is written as a version range rather than a bare name, so it rewrites only the versions the advisory names and leaves every other copy in the tree alone. `pnpm audit --prod --audit-level=high` is clean with them and reports five highs without them. The package joins every gate in this commit rather than later: biome, dprint, cspell, `pnpm -r typecheck`, knip, jscpd, the sixteen dependency-cruiser rules, and jest-expo under both the ios and the android preset. `pnpm check` runs the native suite, and ci.yml gains a job per platform that generates the projects and compiles them, so a config plugin that no longer matches the Expo template fails in CI rather than on somebody's machine. The release gate's required list and its deadline move with them. One gate found its own bug immediately: `react` was declared at two versions across the workspace, pnpm hoisted one and nested the other, and a Radix package's peer bound the web app's component library to a different React instance than its renderer. `workspace-versions.test.ts` pins one range per package across every manifest. --- .dependency-cruiser.cjs | 2 +- .github/workflows/ci.yml | 56 + .github/workflows/mobile-release.yml | 11 +- .gitignore | 12 + .jscpd.json | 2 +- README.md | 10 +- cspell.json | 15 + knip.json | 5 + package.json | 10 +- packages/native/.gitignore | 6 + packages/native/__tests__/app-config.test.ts | 49 + packages/native/app.config.ts | 147 + packages/native/app/_layout.tsx | 7 + packages/native/app/index.tsx | 12 + packages/native/babel.config.js | 4 + packages/native/jest.config.js | 19 + packages/native/metro.config.js | 7 + packages/native/package.json | 40 + .../plugins/with-android-build-memory.js | 27 + .../native/plugins/with-android-privacy.js | 77 + .../plugins/with-ios-scene-lifecycle.js | 119 + packages/native/tsconfig.json | 30 + packages/web/test/ci/release-paths.test.ts | 26 +- .../web/test/ci/workspace-versions.test.ts | 62 + pnpm-lock.yaml | 5902 ++++++++++++++++- scripts/verify-apk.sh | 116 +- vitest.config.ts | 5 +- 27 files changed, 6588 insertions(+), 190 deletions(-) create mode 100644 packages/native/.gitignore create mode 100644 packages/native/__tests__/app-config.test.ts create mode 100644 packages/native/app.config.ts create mode 100644 packages/native/app/_layout.tsx create mode 100644 packages/native/app/index.tsx create mode 100644 packages/native/babel.config.js create mode 100644 packages/native/jest.config.js create mode 100644 packages/native/metro.config.js create mode 100644 packages/native/package.json create mode 100644 packages/native/plugins/with-android-build-memory.js create mode 100644 packages/native/plugins/with-android-privacy.js create mode 100644 packages/native/plugins/with-ios-scene-lifecycle.js create mode 100644 packages/native/tsconfig.json create mode 100644 packages/web/test/ci/workspace-versions.test.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 9389fbaf..8e86a13b 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -26,7 +26,7 @@ const RE_DOES_NOT_SHIP_DIRECTORY = "^(docs|\\.github|assets|scripts/mock-trakt|packages/[^/]+/(e2e|test|__tests__))(/|$)"; const RE_DOES_NOT_SHIP_MARKDOWN = "^[^/]*\\.md$"; const RE_DOES_NOT_SHIP_FILE = - "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|scripts/write-buster\\.mjs|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|\\.env\\.(example|test|mock)))$"; + "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|scripts/write-buster\\.mjs|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|jest\\.config\\.js|\\.gitignore|\\.env\\.(example|test|mock)))$"; const { join } = require("node:path"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5404467b..2f6ae682 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,62 @@ jobs: xcodebuild -project ios/App/App.xcodeproj -scheme App \ -configuration Release -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO build + native-ios: + # The same runner and Xcode pin as the Capacitor iOS job, deliberately: the + # two lines have to stay on one toolchain until one of them stops shipping. + runs-on: macos-26 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.6.app + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: ".nvmrc" + cache: pnpm + - run: pnpm install --frozen-lockfile + # The native projects are generated, so the first thing this job proves is + # that they generate: a config plugin that no longer matches the template + # fails here rather than on somebody's machine. + - run: pnpm --filter @cue/native exec expo prebuild --platform ios --clean + env: + EXPO_PUBLIC_TRAKT_CLIENT_ID: ci + - name: Build the app for the simulator + working-directory: packages/native/ios + run: | + xcodebuild -workspace Cue.xcworkspace -scheme Cue \ + -configuration Debug -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO build + native-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "21" + cache: gradle + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: ".nvmrc" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @cue/native exec expo prebuild --platform android --clean + env: + EXPO_PUBLIC_TRAKT_CLIENT_ID: ci + APP_VERSION: 9.9.9 + BUILD_NUMBER: "42" + # A release variant, because the permission set and the two backup + # properties are what testers install, and the debug flavour adds + # SYSTEM_ALERT_WINDOW for the development menu. Expo's template signs + # release with the debug keystore, so this needs no secret. + - name: Build the release APK and check what it declares + working-directory: packages/native/android + run: | + ./gradlew assembleRelease + ../../../scripts/verify-apk.sh \ + app/build/outputs/apk/release/app-release.apk 42 9.9.9 expo e2e: needs: check runs-on: ubuntu-latest diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 8866165c..688ff1db 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -26,6 +26,9 @@ on: "packages/*/test/**", "packages/*/playwright.config.ts", "packages/*/vitest.config.ts", + "packages/*/jest.config.js", + "packages/*/__tests__/**", + "packages/*/.gitignore", "packages/*/.env.example", "packages/*/.env.test", "packages/*/.env.mock", @@ -145,7 +148,9 @@ jobs: # ruleset's contexts rather than a read of it: a gate that asks the thing it # guards whether that thing is happy is not a gate. Keep the two in step. runs-on: ubuntu-latest - timeout-minutes: 35 + # Raised with the two native jobs: they generate both projects from scratch + # and then compile them, so the gate now waits on a slower set than it did. + timeout-minutes: 50 permissions: actions: read checks: read @@ -158,10 +163,10 @@ jobs: # Job names that exist in ci.yml today. Never list one that does not: # a check that can never report turns the wait below into a guaranteed # timeout on every release. - REQUIRED: '["check","audit","actionlint","standardrb","android","ios","e2e"]' + REQUIRED: '["check","audit","actionlint","standardrb","android","ios","native-android","native-ios","e2e"]' # GitHub Actions, so no other check provider can satisfy a required name. APP_ID: "15368" - DEADLINE_MINUTES: "30" + DEADLINE_MINUTES: "45" POLL_SECONDS: "20" run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 7665c031..87b4473a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,18 @@ dev-dist/ # via the patterns below. .wrangler/ +# The Expo app is generated, not committed: `expo prebuild` clears and +# regenerates both native directories, so a hand edit has to live in a config +# plugin whether or not the output is tracked, and tracking it only creates a +# second copy that can disagree with the plugin. The typed-route declarations are +# generated as well, and the native typecheck regenerates them before it +# compiles, so a clean checkout compiles the program the author sees. +# packages/native/.gitignore is expo-cli's own and covers expo-env.d.ts; it is +# tracked because the CLI rewrites it on every `expo customize`. +packages/native/ios/ +packages/native/android/ +packages/native/.expo/ + # Env: the real .env (your Trakt app's PUBLIC client id) is local-only. # Committed: .env.example (placeholders), .env.test (CI/e2e dummy id) and # .env.mock (the `--mode mock` harness pointing at the local fake Trakt). diff --git a/.jscpd.json b/.jscpd.json index 52483e9f..75af3aff 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -1,6 +1,6 @@ { "path": ["packages"], - "pattern": "*/{src,test}/**/*.{ts,tsx}", + "pattern": "*/{src,test,app,__tests__,modules}/**/*.{ts,tsx}", "minLines": 8, "threshold": 0 } diff --git a/README.md b/README.md index ac083a60..fab16175 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,11 @@ Every e2e run builds the app and starts its own preview server on port 4173. Set - **dprint**: Markdown formatting. - **cspell**: spelling across TS/TSX/CSS/MD. - **tsc**: strict TypeScript type-check (`--noEmit`). -- **dependency-cruiser**: sixteen layering rules cruised over every package in one pass. They keep `@cue/core` free of both apps and of either one's libraries, hold the domain and the data layer to what they may reach, confine `@capacitor/*` to `packages/web/src/platform`, keep every Trakt read behind the pooled wrapper that spends the read budget, and require each package to declare what it imports. +- **dependency-cruiser**: sixteen layering rules cruised over every package in one pass. They keep `@cue/core` free of all three apps' libraries, keep Expo and React Native inside `packages/native` and the DOM inside `packages/web`, stop either app importing the other, hold the domain and the data layer to what they may reach, confine `@capacitor/*` to `packages/web/src/platform`, keep every Trakt read behind the pooled wrapper that spends the read budget, and require each package to declare what it imports. - **knip**: no unused files, dependencies, or exports. `@cue/core` exports every module through one wildcard subpath, which would make each of its files an entry point and switch the export lane off over the shared package, so that workspace sets `includeEntryExports` and its public surface is reported the moment nothing imports it. - **jscpd**: duplicate-code detection. -- **Vitest**: unit tests, one project per package. Coverage covers the shared core, the web app's platform adapters and its preferences adapter, with 90/90/90/80 on `domain`, `data`, `prefs`, `url` and `stores`, a ratchet on `hooks`, and a global floor everywhere else. Both composition roots and every screen are gated by the Playwright suite instead. +- **Vitest**: unit tests for the two web-side packages. Coverage covers the shared core, the web app's platform adapters and its preferences adapter, with 90/90/90/80 on `domain`, `data`, `prefs`, `url` and `stores`, a ratchet on `hooks`, and a global floor everywhere else. Both composition roots and every screen are gated by the Playwright suite instead. +- **jest-expo**: the native package's own suite, run under both the `ios` and the `android` preset, because that is what catches a platform-only divergence. It carries the composition root, the first-launch migration and the `KeyValueStore` contract, the last of which is the same suite `@cue/core` hands the web backends. - **`check:core-portable`**: `@cue/core` carries no `.tsx` and no `.css`, asserted over the index and the working tree so a new file fails before the commit exists. - **`buster:check`**: the committed persisted-shape witness still matches the shapes, so a cache that would be replayed against a changed type is dropped rather than trusted. - **Vite build**: a production build must compile. @@ -129,10 +130,11 @@ Build numbers come from that workflow's run counter, which every branch shares a - **TanStack Query** (with persistence) and **TanStack Router** for data and routing. - **TanStack Virtual** for large lists, **Zustand** for local state, **Zod** for runtime boundary validation, **Radix UI** for primitives. - **Capacitor 8** thin shell for iOS/Android: all `@capacitor/*` imports confined to `packages/web/src/platform`. -- The repository is a pnpm workspace of two packages, and the root carries only the gate runner, the native shells and the release lanes. +- The repository is a pnpm workspace of three packages, and the root carries only the gate runner, the Capacitor shells and the release lanes. - **`@cue/core`** (`packages/core`) is everything that does not know what it is rendered on: the domain, the Trakt data layer, the durable write queue, the hooks, the stores, the preferences and the URL parsers, plus the ports each app fills (key-value storage, preference storage, haptics, reminders, connectivity, app visibility, the OAuth redirect handoff). It is TypeScript source with no build step, published to the workspace through one wildcard subpath (`@cue/core/domain/up-next`), and it contains no `.tsx` and no `.css`: its `tsconfig` omits the DOM lib and `pnpm check:core-portable` asserts the file types, because the two catch different halves of the same rule. `src/app` inside it is the composition root the apps call, and the only part of it excluded from the line-coverage gate. - **`@cue/web`** (`packages/web`) is the Vite app: `src/ui` for screens and components, `src/app` for the composition root, `src/platform` for the browser side of every port, plus `test/`, `e2e/` and `public/`. - - Dependencies flow one way, and dependency-cruiser is what keeps that true rather than convention: the core imports neither app, the domain reaches only the domain, and the data layer reaches only the domain, its own tree and the ports. + - **`@cue/native`** (`packages/native`) is the Expo app: `app/` for the expo-router tree, `src/` for the composition root, the screens and the native side of every port, `modules/cue-native` for the one local Expo module (the haptic vocabulary and the Capacitor preference reader), `plugins/` for the two config plugins, and `__tests__/` for the jest-expo suite. Its `ios/` and `android/` projects are generated by `expo prebuild` and are not committed, so every native fact lives in `app.config.ts` or in a config plugin. + - Dependencies flow one way, and dependency-cruiser is what keeps that true rather than convention: the core imports no app, neither app imports the other, the domain reaches only the domain, and the data layer reaches only the domain, its own tree and the ports. ## Attribution diff --git a/cspell.json b/cspell.json index 9c57bdc8..edc7ac6d 100644 --- a/cspell.json +++ b/cspell.json @@ -7,6 +7,7 @@ "affordance", "affordances", "airdates", + "anddoes", "Biome", "Braciole", "browsable", @@ -18,9 +19,12 @@ "Cœur", "depcruise", "dprint", + "Expo", "fanart", "favorited", + "finsky", "fontsource", + "Hermes", "idb", "imdb", "indexeddb", @@ -32,7 +36,11 @@ "lefthook", "logomark", "macrotask", + "magnifyingglass", + "majeur", "Markable", + "Metro", + "MMKV", "ndjson", "neighbours", "networkidle", @@ -40,8 +48,12 @@ "noreferrer", "nums", "OAuth", + "oppo", "PKCE", + "podspec", + "prebuild", "prefs", + "Pressable", "Radix", "rarr", "refetch", @@ -53,9 +65,12 @@ "rewatches", "ringless", "Rolldown", + "Schedulable", "scrobble", "sharedpref", "Shneiderman", + "sonyericsson", + "sonymobile", "SWR", "Tailwind", "tailwindcss", diff --git a/knip.json b/knip.json index fc6d2d2a..3875c135 100644 --- a/knip.json +++ b/knip.json @@ -13,6 +13,11 @@ "packages/web": { "project": ["src/**/*.{ts,tsx}"], "ignoreDependencies": ["tailwindcss"] + }, + "packages/native": { + "entry": ["plugins/*.js"], + "project": ["app/**/*.{ts,tsx}", "plugins/*.js"], + "babel": ["babel.config.js"] } } } diff --git a/package.json b/package.json index 73d524a5..6247a3d8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "biome check .", "format": "biome check --write . && dprint fmt", "test": "vitest run", + "test:native": "pnpm --filter @cue/native test", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "e2e": "pnpm --filter @cue/web e2e", @@ -33,7 +34,7 @@ "check:bundle": "./scripts/verify-bundle.sh", "buster:check": "node scripts/write-buster.mjs --check", "buster:bump": "node scripts/write-buster.mjs --bump", - "check": "biome check . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage", + "check": "biome check . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage && pnpm test:native", "audit": "pnpm audit --prod --audit-level=high", "prepare": "lefthook install" }, @@ -59,5 +60,12 @@ "lefthook": "^2.1.9", "typescript": "^6.0.3", "vitest": "^4.1.9" + }, + "pnpm": { + "overrides": { + "brace-expansion@>=4.0.0 <5.0.9": ">=5.0.9", + "nanoid@<3.3.18": ">=3.3.18", + "postcss@<=8.5.17": ">=8.5.18" + } } } diff --git a/packages/native/.gitignore b/packages/native/.gitignore new file mode 100644 index 00000000..5873d9ab --- /dev/null +++ b/packages/native/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/packages/native/__tests__/app-config.test.ts b/packages/native/__tests__/app-config.test.ts new file mode 100644 index 00000000..6bf26313 --- /dev/null +++ b/packages/native/__tests__/app-config.test.ts @@ -0,0 +1,49 @@ +import { nativeAppConfig } from "../app.config"; + +/** + * The app config is the whole native identity under CNG, and four of the facts + * in it are release blockers: a different bundle id is a different app on Play + * rather than an upgrade, and a pinned orientation, a tablet opt-out or a + * hard-coded color scheme each silently narrow what the shipping app already + * does. None of them is visible until a build is in somebody's hands. + */ +describe("the native app config", () => { + const config = nativeAppConfig({}); + + it("is the same app on both stores", () => { + expect(config.ios?.bundleIdentifier).toBe("app.cuetracker"); + expect(config.android?.package).toBe("app.cuetracker"); + }); + + it("keeps the orientations, the tablet support and the system color scheme the shipping app has", () => { + expect(config.orientation).toBe("default"); + expect(config.ios?.supportsTablet).toBe(true); + expect(config.userInterfaceStyle).toBe("automatic"); + }); + + // The whole merged set is pinned by `verify-apk.sh` out of the built APK, + // which is the only place it can honestly be checked: any dependency's + // manifest can add to it. This is the one entry worth naming here, because its + // presence would put an "Alarms & reminders" screen in front of a user for a + // permission the app never uses. + it("never asks for an exact alarm", () => { + expect(config.android?.blockedPermissions).toContain("android.permission.SCHEDULE_EXACT_ALARM"); + }); + + it("takes the version and the build number from the environment", () => { + const released = nativeAppConfig({ APP_VERSION: "2.1.0", BUILD_NUMBER: "4207" }); + expect(released.version).toBe("2.1.0"); + expect(released.ios?.buildNumber).toBe("4207"); + expect(released.android?.versionCode).toBe(4207); + }); + + it("carries no transport-security exception unless the harness asks for one", () => { + expect(config.ios?.infoPlist).toBeUndefined(); + expect(nativeAppConfig({ EXPO_PUBLIC_TRAKT_API_BASE: "" }).ios?.infoPlist).toBeUndefined(); + + const harness = nativeAppConfig({ EXPO_PUBLIC_TRAKT_API_BASE: "http://127.0.0.1:8787" }); + expect(harness.ios?.infoPlist?.["NSAppTransportSecurity"]).toEqual({ + NSExceptionDomains: { "127.0.0.1": { NSExceptionAllowsInsecureHTTPLoads: true } }, + }); + }); +}); diff --git a/packages/native/app.config.ts b/packages/native/app.config.ts new file mode 100644 index 00000000..7092aff9 --- /dev/null +++ b/packages/native/app.config.ts @@ -0,0 +1,147 @@ +import type { ExpoConfig } from "expo/config"; + +/** + * Every permission somebody else's manifest declares and Cue does not want, in + * the one place Android documents for dropping them. `verify-apk.sh` pins the + * set that survives, out of the built APK, so a new arrival fails a build rather + * than shipping unannounced. + * + * Four groups, and each is a decision rather than tidiness: + * + * - **The exact alarm.** Every reminder is scheduled inexactly, so the + * declaration would put an "Alarms & reminders" entry in system settings for a + * permission the app never uses, and the auto-granted alternative is + * restricted by Play policy to alarm, timer and calendar apps. + * - **Expo's prebuild template**, whose own comment calls these optional. + * Haptics go through `performHapticFeedback`, which Android documents as not + * requiring VIBRATE; nothing reads or writes shared storage; nothing draws + * over other apps. SYSTEM_ALERT_WINDOW is re-declared by the debug flavour for + * the development menu and is absent from a release build either way. + * - **The biometric pair**, from expo-secure-store's optional authenticated + * reads. Cue never passes `requireAuthentication`, which is also why the Face + * ID usage description is turned off below. + * - **Push and badges**, from expo-notifications: the FCM receive permission, + * the Play install-referrer binding, and ShortcutBadger's per-OEM launcher + * set. Cue schedules local notifications only and sets no badge count, so a TV + * tracker asking eight launcher vendors for shortcut access is exactly the + * kind of thing the permission gate exists to stop. + */ +const BLOCKED_PERMISSIONS = [ + "android.permission.SCHEDULE_EXACT_ALARM", + "android.permission.SYSTEM_ALERT_WINDOW", + "android.permission.VIBRATE", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.USE_BIOMETRIC", + "android.permission.USE_FINGERPRINT", + "android.permission.READ_APP_BADGE", + "com.google.android.c2dm.permission.RECEIVE", + "com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE", + "com.sec.android.provider.badge.permission.READ", + "com.sec.android.provider.badge.permission.WRITE", + "com.htc.launcher.permission.READ_SETTINGS", + "com.htc.launcher.permission.UPDATE_SHORTCUT", + "com.sonyericsson.home.permission.BROADCAST_BADGE", + "com.sonymobile.home.permission.PROVIDER_INSERT_BADGE", + "com.anddoes.launcher.permission.UPDATE_COUNT", + "com.majeur.launcher.permission.UPDATE_BADGE", + "com.huawei.android.launcher.permission.CHANGE_BADGE", + "com.huawei.android.launcher.permission.READ_SETTINGS", + "com.huawei.android.launcher.permission.WRITE_SETTINGS", + "com.oppo.launcher.permission.READ_SETTINGS", + "com.oppo.launcher.permission.WRITE_SETTINGS", + "me.everything.badger.permission.BADGE_COUNT_READ", + "me.everything.badger.permission.BADGE_COUNT_WRITE", +]; + +/** + * The native app's identity and its generated projects, under Continuous Native + * Generation: `ios/` and `android/` are prebuild output and are not committed, + * so every native fact the app depends on is stated here or in a config plugin. + * + * A function of the environment rather than `app.json`, because two of those + * facts come from it. `BUILD_NUMBER` and `APP_VERSION` are what the release lane + * computes; under CNG prebuild writes them into both projects, which is one + * mechanism in place of the two the Capacitor line used (a build-setting + * override on iOS, Gradle properties on Android). + * + * The environment is a parameter rather than a global read because that is what + * makes the four release blockers below testable: `babel-preset-expo` replaces + * every literal `process.env.EXPO_PUBLIC_*` with its value at transform time, + * which is what the app bundle wants and what would otherwise freeze this file's + * harness branch to whatever the test runner started with. + */ +export function nativeAppConfig(env: Readonly>): ExpoConfig { + const buildNumber = env["BUILD_NUMBER"] ?? "1"; + + /** + * The fake Trakt's origin, and the one reason this app would ever load plain + * HTTP. Read here so a build that is not pointed at the harness carries no + * `NSAppTransportSecurity` key at all rather than a disabled exception: the + * privacy gate asserts that key's absence out of the built `Info.plist`. + */ + const mockTrakt = env["EXPO_PUBLIC_TRAKT_API_BASE"]; + const mockTraktHost = + mockTrakt === undefined || mockTrakt === "" ? null : new URL(mockTrakt).hostname; + + return { + name: "Cue", + slug: "cue", + scheme: "cue", + version: env["APP_VERSION"] ?? "1.0.0", + // The shipping app allows portrait and both landscapes on iPhone and all + // four on iPad; "default" preserves that instead of narrowing it. + orientation: "default", + // The app has a light theme, a dark theme and a System option that tracks + // live OS changes, so the native shell follows the system too. + userInterfaceStyle: "automatic", + ios: { + bundleIdentifier: "app.cuetracker", + supportsTablet: true, + buildNumber, + ...(mockTraktHost === null + ? {} + : { + infoPlist: { + NSAppTransportSecurity: { + NSExceptionDomains: { + [mockTraktHost]: { NSExceptionAllowsInsecureHTTPLoads: true }, + }, + }, + }, + }), + }, + android: { + package: "app.cuetracker", + versionCode: Number(buildNumber), + predictiveBackGestureEnabled: true, + blockedPermissions: BLOCKED_PERMISSIONS, + }, + plugins: [ + "expo-router", + // Cue writes `allowBackup="false"` and its own data-extraction rules, and + // this plugin writes both itself by default; told to stand down, it leaves + // them to with-android-privacy, whose `sharedpref path="."` exclusion + // covers the SecureStore file the plugin would have named on its own. + // `faceIDPermission: false` drops the Face ID usage description it also + // writes by default: Cue never passes `requireAuthentication`, and a usage + // description for a capability the app does not use is a false claim on + // the App Store privacy surface. + ["expo-secure-store", { configureAndroidBackup: false, faceIDPermission: false }], + "expo-sqlite", + "expo-status-bar", + "expo-splash-screen", + "expo-notifications", + "./plugins/with-android-build-memory", + "./plugins/with-android-privacy", + "./plugins/with-ios-scene-lifecycle", + ], + // No OTA updates: the app ships through the stores, and an updates client + // that is enabled by default is a runtime and a permission surface for a + // service this app does not use. + updates: { enabled: false }, + experiments: { typedRoutes: true }, + }; +} + +export default (): ExpoConfig => nativeAppConfig(process.env); diff --git a/packages/native/app/_layout.tsx b/packages/native/app/_layout.tsx new file mode 100644 index 00000000..288635ed --- /dev/null +++ b/packages/native/app/_layout.tsx @@ -0,0 +1,7 @@ +import { Stack } from "expo-router"; +import type { ReactElement } from "react"; + +/** The root navigator. The composition root mounts here once it exists. */ +export default function RootLayout(): ReactElement { + return ; +} diff --git a/packages/native/app/index.tsx b/packages/native/app/index.tsx new file mode 100644 index 00000000..4cb50617 --- /dev/null +++ b/packages/native/app/index.tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; + +/** The scaffold's one screen, so the router has a route. The composition root + * and the tab tree replace it. */ +export default function Index(): ReactElement { + return ( + + Cue + + ); +} diff --git a/packages/native/babel.config.js b/packages/native/babel.config.js new file mode 100644 index 00000000..646dc15b --- /dev/null +++ b/packages/native/babel.config.js @@ -0,0 +1,4 @@ +// Metro does not need this file; the jest lane does. Without it `babel-jest` +// finds no config, does not strip the Flow types out of `@react-native/jest-preset`'s +// own setup file, and every suite dies before a test runs. +module.exports = { presets: ["babel-preset-expo"] }; diff --git a/packages/native/jest.config.js b/packages/native/jest.config.js new file mode 100644 index 00000000..4e8e9347 --- /dev/null +++ b/packages/native/jest.config.js @@ -0,0 +1,19 @@ +/** + * `projects` over `jest-expo/ios` and `jest-expo/android` rather than the + * universal preset: the universal one also runs every test under web and node, + * and this app has no web target, so those two runs would assert nothing while + * doubling the suite. Running both of the two that do ship is the point, because + * it is what catches an Android-only divergence in the legacy-preferences key + * shape or the haptics bridge. + * + * `testMatch` names the test files rather than the directory, so a support + * module beside them is not itself run as a suite with no tests in it. + */ +const project = { testMatch: ["/__tests__/**/*.test.{ts,tsx}"] }; + +module.exports = { + projects: [ + { ...project, preset: "jest-expo/ios", displayName: "ios" }, + { ...project, preset: "jest-expo/android", displayName: "android" }, + ], +}; diff --git a/packages/native/metro.config.js b/packages/native/metro.config.js new file mode 100644 index 00000000..3c9ac383 --- /dev/null +++ b/packages/native/metro.config.js @@ -0,0 +1,7 @@ +const { getDefaultConfig } = require("expo/metro-config"); + +// Expo's Metro config has built-in monorepo support, and its own guide says not +// to configure it by hand: `watchFolders`, `resolver.nodeModulesPaths`, +// `resolver.extraNodeModules` and `resolver.disableHierarchicalLookup` are the +// four settings a manual config sets and this one must not. +module.exports = getDefaultConfig(__dirname); diff --git a/packages/native/package.json b/packages/native/package.json new file mode 100644 index 00000000..020ef41d --- /dev/null +++ b/packages/native/package.json @@ -0,0 +1,40 @@ +{ + "name": "@cue/native", + "version": "0.0.0", + "private": true, + "main": "expo-router/entry", + "description": "Cue's native app: the Expo / React Native client over @cue/core.", + "scripts": { + "start": "expo start", + "prebuild": "expo prebuild", + "typecheck": "expo customize tsconfig.json && tsc --noEmit", + "test": "jest" + }, + "dependencies": { + "expo": "57.0.15", + "expo-constants": "~57.0.13", + "expo-linking": "~57.0.7", + "expo-notifications": "~57.0.13", + "expo-router": "~57.0.15", + "expo-secure-store": "~57.0.1", + "expo-splash-screen": "~57.0.7", + "expo-sqlite": "~57.0.1", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.2", + "react": "^19.2.7", + "react-native": "0.86.2", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.2", + "react-native-worklets": "0.10.1" + }, + "devDependencies": { + "@types/jest": "29.5.14", + "@types/react": "^19.2.17", + "babel-preset-expo": "~57.0.7", + "jest": "~29.7.0", + "jest-expo": "~57.0.4", + "typescript": "^6.0.3" + } +} diff --git a/packages/native/plugins/with-android-build-memory.js b/packages/native/plugins/with-android-build-memory.js new file mode 100644 index 00000000..4f94ae73 --- /dev/null +++ b/packages/native/plugins/with-android-build-memory.js @@ -0,0 +1,27 @@ +const { withGradleProperties } = require("expo/config-plugins"); + +/** + * The Gradle JVM heap the release build needs. Expo's Android template writes + * `-Xmx2048m`, and D8 runs out of heap merging this app's dex archives at that + * size: React Native, Hermes, reanimated, screens and the Expo modules together + * are past what 2 GB holds, and the failure is an `OutOfMemoryError` inside + * `:app:mergeDexRelease` rather than anything the app's own code can answer for. + * It is set here rather than in a checked-in `gradle.properties` because + * `android/` is prebuild output, so a value written by hand does not survive the + * next `expo prebuild --clean`. + */ +const JVM_ARGS = "-Xmx4g -XX:MaxMetaspaceSize=1g"; + +module.exports = function withAndroidBuildMemory(config) { + return withGradleProperties(config, (mod) => { + const property = mod.modResults.find( + (entry) => entry.type === "property" && entry.key === "org.gradle.jvmargs", + ); + if (property === undefined) { + mod.modResults.push({ type: "property", key: "org.gradle.jvmargs", value: JVM_ARGS }); + } else { + property.value = JVM_ARGS; + } + return mod; + }); +}; diff --git a/packages/native/plugins/with-android-privacy.js b/packages/native/plugins/with-android-privacy.js new file mode 100644 index 00000000..e2c17d19 --- /dev/null +++ b/packages/native/plugins/with-android-privacy.js @@ -0,0 +1,77 @@ +const { mkdirSync, writeFileSync } = require("node:fs"); +const { dirname, join } = require("node:path"); +const { withAndroidManifest, withDangerousMod } = require("expo/config-plugins"); + +const RULES_RESOURCE = "cue_data_extraction_rules"; +const RULES_PATH = join("app", "src", "main", "res", "xml", `${RULES_RESOURCE}.xml`); + +/** + * Every storage domain the platform can name, excluded from both channels. + * Carried over verbatim from the Capacitor shell's committed rules, because the + * claim they support is the one PRIVACY.md, docs/index.html and README.md all + * make to users. `path="."` rather than a narrower path is what makes the + * exclusion whole, which is also what covers the SecureStore shared-preferences + * file; there is no `` anywhere, because an include flips a section + * back to being an allowlist. + */ +const RULES = ` + + + + + + + + + + + + + + + + + + + + + + + + +`; + +/** + * Cue's app storage holds a live Trakt refresh token and a cache that one sync + * rebuilds, so there is nothing worth restoring and one thing worth never + * copying. Android's default for `allowBackup` is `true`, so a prebuilt app + * silently opts back in without this and turns a documented privacy property + * into a false statement. `allowBackup="false"` stops Google Drive backup and + * `adb backup`; it does not stop device-to-device transfer on Android 12 and + * later, which is what the rules above are for. + * + * There is no app-config property for either field, so the manifest is edited + * directly, which is what Expo's own `withSecureStore` does. `verify-apk.sh` + * asserts the result out of the built APK rather than trusting this plugin. + */ +module.exports = function withAndroidPrivacy(config) { + const withRules = withDangerousMod(config, [ + "android", + (cfg) => { + const file = join(cfg.modRequest.platformProjectRoot, RULES_PATH); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, RULES, "utf8"); + return cfg; + }, + ]); + + return withAndroidManifest(withRules, (cfg) => { + const application = cfg.modResults.manifest.application?.[0]; + if (application === undefined) { + throw new Error("with-android-privacy: the generated manifest has no element."); + } + application.$["android:allowBackup"] = "false"; + application.$["android:dataExtractionRules"] = `@xml/${RULES_RESOURCE}`; + return cfg; + }); +}; diff --git a/packages/native/plugins/with-ios-scene-lifecycle.js b/packages/native/plugins/with-ios-scene-lifecycle.js new file mode 100644 index 00000000..ce0e81bc --- /dev/null +++ b/packages/native/plugins/with-ios-scene-lifecycle.js @@ -0,0 +1,119 @@ +const { withAppDelegate, withInfoPlist } = require("expo/config-plugins"); + +/** + * Adopt the UIKit scene-based life cycle so the app launches on the iOS 27 SDK. + * + * Apps linked against that SDK must adopt scenes; Expo's prebuild template still + * starts React Native from `application(_:didFinishLaunchingWithOptions:)` and + * ships no `UIApplicationSceneManifest`, so a stock SDK 57 prebuild installs and + * then refuses to launch with "UIScene life cycle is required for apps built + * with this SDK" (expo/expo#46663, facebook/react-native#54739, Apple TN3187). + * + * The delegate is appended to `AppDelegate.swift` rather than added as its own + * file, so the plugin never edits the Xcode project's build phases: the scene + * manifest names `$(PRODUCT_MODULE_NAME).SceneDelegate`, which resolves to the + * same module either way. + * + * Delete this plugin when expo/expo#46733 and its follow-up expo/expo#47628 both + * reach a published SDK, and re-verify cold-start URL delivery on the way out: + * that is the half upstream took two attempts to get right. + */ +const SCENE_DELEGATE = ` +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard + let windowScene = scene as? UIWindowScene, + let appDelegate = UIApplication.shared.delegate as? AppDelegate, + let factory = appDelegate.reactNativeFactory + else { return } + + let window = UIWindow(windowScene: windowScene) + self.window = window + appDelegate.window = window + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: Self.launchOptions(from: connectionOptions) + ) + } + + // On a scene cold launch the launch URL and the handed-off user activity + // arrive in the connection options, not through the app delegate (TN3187), and + // \`RCTLinkingManager\` answers \`Linking.getInitialURL()\` out of the launch + // options the React root was started with. Passing nil here is what silently + // discards every cold-launch deep link. + private static func launchOptions( + from connectionOptions: UIScene.ConnectionOptions + ) -> [UIApplication.LaunchOptionsKey: Any] { + var options: [UIApplication.LaunchOptionsKey: Any] = [:] + if let url = connectionOptions.urlContexts.first?.url { + options[.url] = url + } + if let activity = connectionOptions.userActivities.first { + options[.userActivityDictionary] = [ + "UIApplicationLaunchOptionsUserActivityKey": activity, + "UIApplicationLaunchOptionsUserActivityTypeKey": activity.activityType, + ] + options[.userActivityType] = activity.activityType + } + return options + } + + // The scene life cycle also takes over URL delivery to a running app, so the + // warm path has to be re-published here or expo-linking never sees it. + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + RCTLinkingManager.application(UIApplication.shared, open: context.url, options: [:]) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + RCTLinkingManager.application( + UIApplication.shared, continue: userActivity, restorationHandler: { _ in }) + } +} +`; + +function adoptScenes(contents) { + if (contents.includes("class SceneDelegate")) return contents; + + // The window is created by the scene, not the app delegate: leaving the + // template's `startReactNative` call in `didFinishLaunchingWithOptions` would + // mount a second React root into a window that is never attached to a scene. + const startBlock = + /#if os\(iOS\) \|\| os\(tvOS\)\s*\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\s*\n\s*factory\.startReactNative\([\s\S]*?\)\s*\n#endif\n/; + if (!startBlock.test(contents)) { + throw new Error( + "with-ios-scene-lifecycle: AppDelegate.swift no longer starts React Native the way this plugin expects. Re-check it against the Expo template before trusting the patch.", + ); + } + return `${contents.replace(startBlock, "")}${SCENE_DELEGATE}`; +} + +module.exports = function withIosSceneLifecycle(config) { + const withManifest = withInfoPlist(config, (cfg) => { + cfg.modResults["UIApplicationSceneManifest"] = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: "Default Configuration", + UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate", + }, + ], + }, + }; + return cfg; + }); + + return withAppDelegate(withManifest, (cfg) => { + cfg.modResults.contents = adoptScenes(cfg.modResults.contents); + return cfg; + }); +}; diff --git a/packages/native/tsconfig.json b/packages/native/tsconfig.json new file mode 100644 index 00000000..7880af1b --- /dev/null +++ b/packages/native/tsconfig.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + // Expo's base first for the React Native program (the `react-native` export + // condition, the JSX transform, the lib set), then the workspace's own strict + // options on top, so the native package compiles under the same strictness as + // the other two rather than under whatever Expo happens to default to. + // + // No `paths`. `expo prebuild` rewrites this file, and a path alias declared + // here is what it would silently discard; the core is reached by package name + // through `@cue/core`'s export map instead. + "extends": ["expo/tsconfig.base", "../../tsconfig.base.json"], + "compilerOptions": { + "types": ["node", "jest"] + }, + // `expo-env.d.ts` and `.expo/types` are both generated and both gitignored, so + // `pnpm --filter @cue/native typecheck` regenerates them before it compiles. + // Without that the typed-route unions exist on the machine that last ran the + // dev server and nowhere else, and the program CI compiles is weaker than the + // one the author sees. + "include": [ + "app", + "src", + "modules", + "__tests__", + "app.config.ts", + "expo-env.d.ts", + ".expo/types/**/*.ts" + ], + "exclude": ["node_modules", "android", "ios"] +} diff --git a/packages/web/test/ci/release-paths.test.ts b/packages/web/test/ci/release-paths.test.ts index bba8a664..a0faba2b 100644 --- a/packages/web/test/ci/release-paths.test.ts +++ b/packages/web/test/ci/release-paths.test.ts @@ -23,6 +23,16 @@ const SHIPS = [ "packages/*/vite.config.ts", "packages/*/package.json", "packages/*/tsconfig.json", + // The Expo app. `app/**` is its route tree, `modules/**` is native source that + // is compiled into the binary, `plugins/**` writes the generated projects, and + // the app config is the whole native identity. The two bundler configs decide + // what the shipped JavaScript is, so they ship too. + "packages/*/app/**", + "packages/*/app.config.ts", + "packages/*/modules/**", + "packages/*/plugins/**", + "packages/*/babel.config.js", + "packages/*/metro.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", @@ -49,6 +59,9 @@ const DOES_NOT_SHIP = [ "packages/*/test/**", "packages/*/playwright.config.ts", "packages/*/vitest.config.ts", + "packages/*/jest.config.js", + "packages/*/__tests__/**", + "packages/*/.gitignore", "packages/*/.env.example", "packages/*/.env.test", "packages/*/.env.mock", @@ -217,14 +230,17 @@ describe("the iOS toolchain pin", () => { (match) => match[1] ?? [], ); - it("is the same Xcode in the CI build and the release archive", () => { - // ci.yml's ios job exists to compile what mobile-release.yml archives. Two - // toolchains would make it a green check for a build nobody ships, and the - // pin is deliberate: the runner image's default Xcode moves on its own. + it("is the same Xcode in every CI build and in the release archive", () => { + // ci.yml's iOS jobs exist to compile what mobile-release.yml archives. A + // second toolchain would make one of them a green check for a build nobody + // ships, and the pin is deliberate: the runner image's default Xcode moves + // on its own. const release = selectedXcode(MOBILE_RELEASE_WORKFLOW); + const ci = selectedXcode(CI_WORKFLOW); expect(release).toHaveLength(1); - expect(selectedXcode(CI_WORKFLOW)).toEqual(release); + expect(ci.length).toBeGreaterThan(0); + expect([...new Set(ci)]).toEqual(release); }); }); diff --git a/packages/web/test/ci/workspace-versions.test.ts b/packages/web/test/ci/workspace-versions.test.ts new file mode 100644 index 00000000..ba200c48 --- /dev/null +++ b/packages/web/test/ci/workspace-versions.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPOSITORY_ROOT = path.join(import.meta.dirname, "../../../.."); + +const MANIFESTS = [ + "package.json", + "packages/core/package.json", + "packages/web/package.json", + "packages/native/package.json", +]; + +type Manifest = Record< + "dependencies" | "devDependencies" | "peerDependencies", + Record | undefined +>; + +const declarations = (file: string): [string, string][] => { + const manifest = JSON.parse(readFileSync(path.join(REPOSITORY_ROOT, file), "utf8")) as Manifest; + return [ + ...Object.entries(manifest.dependencies ?? {}), + ...Object.entries(manifest.devDependencies ?? {}), + ...Object.entries(manifest.peerDependencies ?? {}), + ]; +}; + +/** + * One range per package across the whole workspace, compared as the exact + * string. `nodeLinker: hoisted` puts one copy of each resolved version at the + * workspace root and nests the rest, and which copy a third-party peer + * dependency binds to is then pnpm's choice rather than anyone's: two ranges for + * `react` that a Radix package's peer both satisfy is enough to give the web + * app's renderer a different React instance than its component library, which + * surfaces as `Cannot read properties of null (reading 'useId')` in a suite that + * has nothing to do with either declaration. + * + * The exact string rather than range intersection, because two ranges that + * overlap today can resolve apart tomorrow, and because the fix is always the + * same one line. + */ +describe("a dependency two packages share", () => { + it("is declared at the same version by every one of them", () => { + const ranges = new Map>(); + for (const file of MANIFESTS) { + for (const [name, range] of declarations(file)) { + const byRange = ranges.get(name) ?? new Map(); + byRange.set(range, [...new Set([...(byRange.get(range) ?? []), file])]); + ranges.set(name, byRange); + } + } + + const disagreements = [...ranges] + .filter(([, byRange]) => byRange.size > 1) + .map( + ([name, byRange]) => + `${name}: ${[...byRange].map(([range, files]) => `${range} (${files.join(", ")})`).join(" vs ")}`, + ); + + expect(disagreements).toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53c9bc4f..e9fc0fe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + brace-expansion@>=4.0.0 <5.0.9: '>=5.0.9' + nanoid@<3.3.18: '>=3.3.18' + postcss@<=8.5.17: '>=8.5.18' + importers: .: @@ -95,6 +100,79 @@ importers: specifier: ^4.1.9 version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.20.0)(typescript@6.0.3))(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/native: + dependencies: + expo: + specifier: 57.0.15 + version: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-constants: + specifier: ~57.0.13 + version: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + expo-linking: + specifier: ~57.0.7 + version: 57.0.7(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-notifications: + specifier: ~57.0.13 + version: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-router: + specifier: ~57.0.15 + version: 57.0.15(cf60fc1f6a5dd37a3570b41921065fd9) + expo-secure-store: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.15) + expo-splash-screen: + specifier: ~57.0.7 + version: 57.0.7(expo@57.0.15)(typescript@6.0.3) + expo-sqlite: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-status-bar: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-system-ui: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-native: + specifier: 0.86.2 + version: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + react-native-gesture-handler: + specifier: ~2.32.0 + version: 2.32.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-reanimated: + specifier: 4.5.1 + version: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-safe-area-context: + specifier: ~5.7.0 + version: 5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-screens: + specifier: ~4.26.2 + version: 4.26.2(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-worklets: + specifier: 0.10.1 + version: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + devDependencies: + '@types/jest': + specifier: 29.5.14 + version: 29.5.14 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + babel-preset-expo: + specifier: ~57.0.7 + version: 57.0.7(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.15)(react-refresh@0.14.2) + jest: + specifier: ~29.7.0 + version: 29.7.0(@types/node@22.20.0) + jest-expo: + specifier: ~57.0.4 + version: 57.0.4(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(expo@57.0.15)(jest@29.7.0(@types/node@22.20.0))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/web: dependencies: '@capacitor/app': @@ -205,7 +283,7 @@ importers: version: 8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) vite-plugin-pwa: specifier: ^1.3.0 - version: 1.3.0(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1) + version: 1.3.0(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1) vitest: specifier: ^4.1.9 version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.20.0)(typescript@6.0.3))(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) @@ -380,12 +458,68 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.29.7': + resolution: {integrity: sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.29.7': + resolution: {integrity: sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-import-assertions@7.29.7': resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} engines: {node: '>=6.9.0'} @@ -398,6 +532,70 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} @@ -506,6 +704,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-flow-strip-types@7.29.7': + resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-for-of@7.29.7': resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} engines: {node: '>=6.9.0'} @@ -638,6 +842,42 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-display-name@7.29.7': + resolution: {integrity: sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.29.7': + resolution: {integrity: sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.29.7': + resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.29.7': + resolution: {integrity: sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-regenerator@7.29.7': resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} engines: {node: '>=6.9.0'} @@ -656,6 +896,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-runtime@7.29.7': + resolution: {integrity: sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-shorthand-properties@7.29.7': resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} engines: {node: '>=6.9.0'} @@ -686,6 +932,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-unicode-escapes@7.29.7': resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} engines: {node: '>=6.9.0'} @@ -721,6 +973,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -737,6 +995,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1194,6 +1455,10 @@ packages: cpu: [x64] os: [win32] + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} @@ -1218,6 +1483,194 @@ packages: '@noble/hashes': optional: true + '@expo-google-fonts/material-symbols@0.4.44': + resolution: {integrity: sha512-36JP9Chcy/QEVZ9ZGY4i6zInlyFPbQjkIm6gRNuWWltScIj0WR8rddcD57EIjSC5YKCe2ZKLO6eO/N5r8Jit0A==} + + '@expo/cli@57.0.17': + resolution: {integrity: sha512-PQc7if117dNh2qe+CHdlmSWpgwhFiP9CJ7XbwNF0w4KSKxFpJ2qUjDPoAcCMW1VZg1x30mEJ907bT8G1iDIZBQ==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@57.0.8': + resolution: {integrity: sha512-x6lx4s/19i39/+1dMPwb9tFCc4WR843dG5Yi+C64lhKL3YHX+6oKrT2Kv72PCEalnUY+usQDkB3NrLSrYOWtDg==} + + '@expo/config-types@57.0.2': + resolution: {integrity: sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==} + + '@expo/config@57.0.8': + resolution: {integrity: sha512-7VpAu2ZMXNZI0+Kn+nD3vQBIyST89LATNkZpnp/cXh8/aj7zUXO8d/T+hOxsvhOFwR8eQO+jKXEw8tQbidiMxA==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@57.0.1': + resolution: {integrity: sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@57.0.1': + resolution: {integrity: sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.4.2': + resolution: {integrity: sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==} + engines: {node: '>=20.12.0'} + + '@expo/expo-modules-macros-plugin@0.6.1': + resolution: {integrity: sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==} + + '@expo/fingerprint@0.20.9': + resolution: {integrity: sha512-h+YvPyNmeAUCCqaXvftiXklA2zGyRcIPv1B8fFS0YSIgNhwcxFGh1EyLOMsvJ2aWsBViNtIJ1HIFLzNu43/r4w==} + hasBin: true + + '@expo/image-utils@0.11.4': + resolution: {integrity: sha512-pn/4770DIEOcYZr484uazuwg20FX/qaDkeMRF6J+oxejynDmEmO8wLsCudaNShFE0BhyKGQTYrs2rsRhqrqESw==} + + '@expo/inline-modules@0.1.6': + resolution: {integrity: sha512-5f6EiOIKsFj9zlrCBet4ZIQRPEa9dBUdQgTzpjYwdZ8Z/M8W5lqMVL9dEs4HYKvEmDnqv0dQuDkFLyRSwu/DAQ==} + + '@expo/json-file@11.0.1': + resolution: {integrity: sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==} + + '@expo/local-build-cache-provider@57.0.7': + resolution: {integrity: sha512-Hq5xWhXJWuyH3CWh3uqDWoHtxM0XYqLs9h2OzRWImBJahCPfePOo4kmw2uTEB6qHT5T47eqO4jtDG3MRGGZCpw==} + + '@expo/log-box@57.0.3': + resolution: {integrity: sha512-qv/cMliNax6es07Un/4IGJIcs4PUuhTKKTrJZX/0X2YRcOT5cqm/QicibZwTIbiZWi1i8P4YBRQTfFT2B17giw==} + peerDependencies: + '@expo/dom-webview': ^57.0.1 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@57.0.9': + resolution: {integrity: sha512-+lalXZdoMaKTG1uKXsi2KWO0f7H3KiWrhZzAla4EmNSkcUMRx6K1rhZuBYoaJaKIp9BHZ333KbqRPron7slKQA==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-file-map@57.0.1': + resolution: {integrity: sha512-8JXfVstZN7QnP4NianZZnlTVboOWR0sG8trUDNajOjnbGlPln29vponXM84tY+3tAHapz5/TxE53L0ixUwqPtA==} + + '@expo/metro-runtime@57.0.12': + resolution: {integrity: sha512-WpgjfRFh88B5tKJrbsypvyxx0MKuXMo+ru9Gbjq4Qwj7OboJmrQ+g555YCgiQD3uk3J1ThzgEU06yZOV43suGA==} + peerDependencies: + '@expo/log-box': ^57.0.3 + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@56.0.2': + resolution: {integrity: sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==} + + '@expo/osascript@2.7.1': + resolution: {integrity: sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==} + engines: {node: '>=12'} + + '@expo/package-manager@1.13.1': + resolution: {integrity: sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==} + + '@expo/plist@0.8.1': + resolution: {integrity: sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==} + + '@expo/prebuild-config@57.0.13': + resolution: {integrity: sha512-VhSySuXqOwK4fIc+9rgms7zN+c1Khj2xpuIgpVLPNyWRtpS8wB+eyV5CSohjSUBa3h+NsFdPk/VG6p0ZwlMDrg==} + + '@expo/require-utils@57.0.4': + resolution: {integrity: sha512-e7xbg/9BTQcsZE/oErafZXtI7kh5IgfasLJ97J5sFSzX2cA74pDvdlhW1KHVSaDkQyQv6h1LSLhsY7dEeOk7hw==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@57.0.7': + resolution: {integrity: sha512-QFHwt6V7UovmYA7+8BoMeKj3fh6WtkM9zksKjNAlZdcEI/hUhcW4uged6So5yc4tq+eA60kA7gIVZPKgBIZdrQ==} + peerDependencies: + '@expo/metro-runtime': ^57.0.12 + expo: '*' + expo-constants: ^57.0.13 + expo-font: ^57.0.1 + expo-router: '*' + expo-server: ^57.0.3 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@57.0.2': + resolution: {integrity: sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.8.0': + resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/ui@57.0.12': + resolution: {integrity: sha512-ZkrECV6xZe0+Drq6C0hrBKvz1534/VyARNp9ziKihC6Db69W29+jBDZm8k9cyRyS1Hh23q6LP8J+2wDyUM8eAA==} + peerDependencies: + '@babel/core': '*' + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-worklets: '*' + peerDependenciesMeta: + '@babel/core': + optional: true + react-dom: + optional: true + react-native-worklets: + optional: true + + '@expo/ws-tunnel@2.0.0': + resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==} + peerDependencies: + ws: ^8.0.0 + + '@expo/xcpretty@4.4.4': + resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} + hasBin: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1314,6 +1767,100 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2286,29 +2833,117 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] + '@react-native-masked-view/masked-view@0.3.2': + resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} + peerDependencies: + react: '>=16' + react-native: '>=0.57' - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] + '@react-native/assets-registry@0.86.2': + resolution: {integrity: sha512-vcX/mBjWAVnWofu7KecotquI2unZ/tITwA7OGdq/mdY/zmGXIEvYhfEYyOQij/LRqi9WAL+iizInTBWnxDhK/Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] + '@react-native/babel-plugin-codegen@0.86.2': + resolution: {integrity: sha512-NNDZqOlNbH5SzgPks1jFDYH3234Rpa5e/nhZymxhIiBH3NcE3uD+rGj/HWXhH7nHF2ToGK6XbUpqy7nmJPeh+g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] + '@react-native/babel-preset@0.86.2': + resolution: {integrity: sha512-4XKEJ6jKW9lXMB1O5o47gBoGgolde1fbX13gLW/erlcn+1ky+MHHo5UjuM3RWGdPJHIvIzekDDumSUHhB9x5iQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.86.2': + resolution: {integrity: sha512-xKkudsahUJ1n//55g4fXk5BStVqqmZlz8HQveL45ZxcfDnwvhuYe2GymksQANFsSN+slvrarjrfq8kIxJzbceA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.86.2': + resolution: {integrity: sha512-YHXNKoM6Y/HjREySZ5arET2xgiHgg67r1MdwJB//MPJAJ0Xc5g0u6UHxY9VzsHO3Y07dre6s0BinYwjt1SEWvQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': 0.86.2 + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.86.2': + resolution: {integrity: sha512-KGS1aV5F6cIqpnoIUhLBXyVzy1oAj8jBFGau6vX4Vy0HXRJN7p+68RU7x6NuyraHvQcR14ccMGT5TkFuNjQ4gA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/debugger-shell@0.86.2': + resolution: {integrity: sha512-/TaVJ2+gGajZPJGrFaObUQmHmlaxAlfmOPZicl6pNKDUjzSgFMpcLkdTOExvb+USYTVdGX1XwxXyvjQdUO2bvg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/dev-middleware@0.86.2': + resolution: {integrity: sha512-B7L0vKvg+IcEElT7Vpqh1xj5yJAqWUegjbP+bQRaorJMAYnv11GkliTnZV2AdTDfZQJWgOEx8i8LGkHkUg7bnA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/gradle-plugin@0.86.2': + resolution: {integrity: sha512-2F6x14NcHMpVmfTTFKfMkpV5dZedZrLiv6PE+c3vgnesV2bjleUBydr4U+NI8VkI7OwW71L0A5qQ76I9LCrfoQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/jest-preset@0.86.2': + resolution: {integrity: sha512-wneoqwKWdv6wIcWotVgp6NMlMWcJDjxDnp7fVYym2Pn6JLgAKgkbmuHGmxW0cLCGoa9wtcO680uciv+Kzx0G7w==} + engines: {node: '>= 20.19.4'} + peerDependencies: + react: ^19.2.3 + + '@react-native/js-polyfills@0.86.2': + resolution: {integrity: sha512-bIwNcGBaQ74shB5z1mRkxOpjikimuwsnOCEkZSzL67Z1FTyK1ObpENfyd2QvcvVW9Cjl+tHuw9ynpBnb2jPoJQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.86.2': + resolution: {integrity: sha512-mX1wgLErdb2hDgXJr9zM9SWLe+ZteZTFTwRWGOQ53yEJwc9DVSnxdTlAtVdMeOj2ntycKh0R8jYdat2Am43cwQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.86.2': + resolution: {integrity: sha512-hJno256j+MS0b3JD1aD3ouTGZVacKNVBuXL2atMQQ8BZ060vl1ptnZ83y569aDW+/rgFSOcqn6ydKeSz4uUKQQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.86.2': + resolution: {integrity: sha512-EzPFc9Y6lzYOWeso2almwXI7f8+qReHxWvT+algsOczb2UhWXIWXDoSvkdwoSfiwwmGt/ijJgKJoeHlzPkLwRg==} + + '@react-native/virtualized-lists@0.86.2': + resolution: {integrity: sha512-uO0J72gh3EvE+1/GHRk18QRyBDTRHRB0AraAfojsRjbT7VMuJwKrZYaKGshavoaEud6aw00ZB9/8mTMIKjjcAw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: 0.86.2 + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] '@rolldown/binding-linux-arm-gnueabihf@1.1.4': resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} @@ -2571,6 +3206,18 @@ packages: cpu: [x64] os: [win32] + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2818,6 +3465,22 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react-native@14.0.1': + resolution: {integrity: sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA==} + engines: {node: ^22.13.0 || >=24} + peerDependencies: + jest: '>=29.0.0' + react: '>=19.0.0' + react-native: '>=0.78' + test-renderer: ^1.0.0 + peerDependenciesMeta: + jest: + optional: true + + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} engines: {node: '>=12'} @@ -2825,6 +3488,18 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2837,6 +3512,27 @@ packages: '@types/fs-extra@8.1.5': resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==} + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} @@ -2848,6 +3544,14 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.33.0': + resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==} + peerDependencies: + '@types/react': '*' + + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} @@ -2860,12 +3564,27 @@ packages: '@types/slice-ansi@4.0.0': resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@vitejs/plugin-react-swc@4.3.1': resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2910,11 +3629,34 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} deprecated: this version has critical issues, please update to the latest version + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + acorn-jsx-walk@2.0.0: resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} @@ -2936,9 +3678,37 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agent-cli-detector@0.1.6: + resolution: {integrity: sha512-vKrPeEVN3upDF3GjWxsWBbwQgMtNJ8VB1cduPvK3svmmz4ENpS7yaPxbogsbe3w+xp9xp2Cu+0Ar41rjAR4+lA==} + engines: {node: '>=18.18'} + hasBin: true + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2947,10 +3717,31 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2970,6 +3761,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2988,6 +3782,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} @@ -2996,11 +3793,33 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-polyfill-corejs3@0.14.2: resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} peerDependencies: @@ -3011,6 +3830,50 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.36.0: + resolution: {integrity: sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==} + + babel-plugin-syntax-hermes-parser@0.36.1: + resolution: {integrity: sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@57.0.7: + resolution: {integrity: sha512-/1RLnZTJVoTNo6nCdSv27BSA2LBzM/qEkNLznwWvztg64DhsAz7ByZIiAqdbau9FK3YqF+IV5a2ZsPXFclwq4A==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^57.0.10 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3044,24 +3907,38 @@ packages: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3078,6 +3955,14 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001800: resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} @@ -3089,6 +3974,14 @@ packages: resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==} engines: {node: '>=14.16'} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3097,29 +3990,94 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + char-regex@2.0.2: + resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} + engines: {node: '>=12.20'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.3.0: + resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + clear-module@4.1.3: resolution: {integrity: sha512-XdLrg7BnbXKntyrbs2dNjDN9CVoTQ+WV0i7jT5/r9ahzAaSDSzC9e2OVZB/QVwbxBb1/1AeObzjlxsYk5HFvww==} engines: {node: '>=8'} + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -3135,6 +4093,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + comment-json@4.6.2: resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} engines: {node: '>= 6'} @@ -3143,6 +4105,25 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3189,6 +4170,11 @@ packages: cpu: [x64] os: [win32] + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3245,9 +4231,23 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3264,6 +4264,22 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3276,10 +4292,25 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -3292,21 +4323,49 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dependency-cruiser@18.0.0: resolution: {integrity: sha512-51Q7wbHoP3/GZrENpINnvKE/xfBsj41NVKarqu5Fff/kmqB/bg0GpiD5bOBwj0+CVunxPGzO80uTdYoy/d4Rsw==} engines: {node: ^22||^24||>=26} hasBin: true + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dnssd-advertise@1.1.6: + resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} + dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + dprint@0.55.1: resolution: {integrity: sha512-tgUCT9gAM7veMvLX5NmRoYVLnKGKrIYRXpNpJDJj5U7/YIIJef5rLJhPZaJXwB7ad2Vo0Kv//nQM1xkrn5j8kQ==} hasBin: true @@ -3315,6 +4374,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -3327,9 +4389,21 @@ packages: resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==} engines: {node: '>= 0.4.0'} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -3338,6 +4412,10 @@ packages: resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -3350,6 +4428,12 @@ packages: resolution: {integrity: sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==} engines: {node: '>=20'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -3385,11 +4469,35 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -3404,22 +4512,222 @@ packages: resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} engines: {node: '>=20'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - fake-indexeddb@6.2.5: - resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} - engines: {node: '>=18'} + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + expo-application@57.0.2: + resolution: {integrity: sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==} + peerDependencies: + expo: '*' - fast-equals@6.0.0: - resolution: {integrity: sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==} - engines: {node: '>=6.0.0'} + expo-asset@57.0.13: + resolution: {integrity: sha512-RPjMcmPXRMb6UbQdTIkmSDEnQL5LKGTu7ZXatq3U67V6ldlSdQeQ9pQV7zlmk0DrbnaMEh3HYiEh2YxWLNyCzA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' - fast-json-stable-stringify@2.1.0: + expo-constants@57.0.13: + resolution: {integrity: sha512-eB5AHp7kKxsVIBjTetUgj5WQSw8joI2ekzgTlcUv+Hc0x2t0jZU6IiL4DpyOT2yZQ71n0WJZCkmOKLgxlajIzg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-file-system@57.0.5: + resolution: {integrity: sha512-XjGrCClF0935y5wLB9qNQQUVptNEy+0OloBstXlCd+IeNXLo3uNOod6cX4N0hofIhNIrN1Al8fwfay7R0Z1a2A==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@57.0.1: + resolution: {integrity: sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@57.0.1: + resolution: {integrity: sha512-m/n8maxqNcHk6ZDhuqXBfD5Kt1Iz3M8xykVgdB0iSCIXvF70IqWXmQhX8Psswhrp8eZ+3r0mAD0Jh/2gFA3QaA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-keep-awake@57.0.1: + resolution: {integrity: sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@57.0.7: + resolution: {integrity: sha512-Ujn4kY0bd1MoJFLYuvIUWamlZH61pkahrUpug3hBtOeS89LDbPyKNjjmWef6+CPTGQfed2PlfuMZuPO1BCrg5w==} + peerDependencies: + react: '*' + react-native: '*' + + expo-modules-autolinking@57.0.10: + resolution: {integrity: sha512-jNqLswMx8QHN8VXRgud+xT+9q+J9U63CEGlx+k4V/IE9Qyt9HrKbWp+pIDma0fOquta5HBfSfsShcI+NkEpTiA==} + hasBin: true + + expo-modules-core@57.0.12: + resolution: {integrity: sha512-hKqCdu8+78oNKWCgM4xSeblfmD/audgNVCn6uUCJuNBS/hc4DyC0Ia1X96SSZF9w9bGTLjNS8j2b7DshiC3WJA==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-modules-jsi@57.0.5: + resolution: {integrity: sha512-NQF7MUF0j3rQHV8KJqETYA1SrJr3USvQ/AWEhODo+unMRFBDP9BKpn8+aNGGmx6fa2XLnqC2qulVw4DT6Gp13Q==} + peerDependencies: + react-native: '*' + + expo-notifications@57.0.13: + resolution: {integrity: sha512-4kTLfvCbpr92RWWmYGO1BP4pJ5ZhxOdhB/6zdtNs44wV7h6zKrbweccwovgZTLo4Ef/KPL3SD02Vam4Tl/xv9A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@57.0.15: + resolution: {integrity: sha512-vwr2HL2U7hqv3QTkuz0e8hQc5hkgpXztisy30VwhdXJVDPdP0/m8wSiYUm6GKyZAbcXfD4fn44ZYcJsSESY6cA==} + peerDependencies: + '@expo/log-box': ^57.0.3 + '@expo/metro-runtime': ^57.0.12 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^57.0.13 + expo-linking: ^57.0.7 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: ^4.26.0 + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-secure-store@57.0.1: + resolution: {integrity: sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA==} + peerDependencies: + expo: '*' + + expo-server@57.0.3: + resolution: {integrity: sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@57.0.7: + resolution: {integrity: sha512-QxqfjOCTnTGnqK2BxfEtxUJOcnsJbUXO873Q342r3VkgHAG877eozsNzgYWQXcCFj/asMwx2uy9OXxBShxc5rQ==} + peerDependencies: + expo: '*' + + expo-sqlite@57.0.1: + resolution: {integrity: sha512-I6KoUvfGIiROTKxr5D3H+jRIGA/iEvWEtqHK4XMukAA7tVTinz/YdS8zOz6/DdG6vgrNmvF7gyOcbhLinlfxzQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-status-bar@57.0.1: + resolution: {integrity: sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-symbols@57.0.2: + resolution: {integrity: sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo-system-ui@57.0.2: + resolution: {integrity: sha512-zABCRqFSioDBAo/RtmS0dQiGgDtDPZUVk01Y3Ti4ducobNM4HTM2sNAtq+YPpEUhaVW3hccPVsEUH4LK/ADVhA==} + peerDependencies: + expo: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo@57.0.15: + resolution: {integrity: sha512-9UooISw8S8Gxo9wpsPcGkyRlf2UzNr+F26LSU2+M8iaiZ1WAjPR0I75NKFX8ETbrSgthZ2lQHgy5IU2KE/mpBA==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-web: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-dom: + optional: true + react-native-web: + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@6.0.0: + resolution: {integrity: sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==} + engines: {node: '>=6.0.0'} + + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-string-truncated-width@3.0.3: @@ -3434,6 +4742,14 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -3449,12 +4765,37 @@ packages: picomatch: optional: true + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3463,11 +4804,19 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} hasBin: true + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -3476,6 +4825,9 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3523,10 +4875,18 @@ packages: get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -3534,6 +4894,10 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -3544,6 +4908,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -3571,6 +4939,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3597,6 +4969,38 @@ packages: headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + hermes-compiler@250829098.0.16: + resolution: {integrity: sha512-xsgzk+mUyvt9t1nUbF8USBlYxajTUtPJhVZ86q85s/SEoMKCF+52YZcudb0ENSnV3T3lV9mgB3s6R7+pH90zgw==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-estree@0.36.0: + resolution: {integrity: sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==} + + hermes-estree@0.36.1: + resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hermes-parser@0.36.0: + resolution: {integrity: sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==} + + hermes-parser@0.36.1: + resolution: {integrity: sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3604,12 +5008,40 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb-keyval@6.2.6: resolution: {integrity: sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==} idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -3618,13 +5050,26 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3648,10 +5093,19 @@ packages: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -3697,6 +5151,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3723,6 +5181,10 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-obj@1.0.1: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} @@ -3800,10 +5262,22 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -3817,6 +5291,178 @@ packages: engines: {node: '>=10'} hasBin: true + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-expo@57.0.4: + resolution: {integrity: sha512-EpTc8zizYWepKXHdHYuYBdEP0mZLjZfB1ldb2dyLKHug0HYFGuBnQ3Qp3gmBWCj3rSRaJYP90Lq/qPozzx2Khg==} + hasBin: true + peerDependencies: + '@react-native/jest-preset': ^0.86.2 + expo: '*' + react-native: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + expo: + optional: true + react-server-dom-webpack: + optional: true + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watch-select-projects@2.0.0: + resolution: {integrity: sha512-j00nW4dXc2NiCW6znXgFLF9g8PJ0zP25cpQ1xRro/HU2GBfZQFZD0SoXnAlaoKkIY4MlfTMkKGbNXFpvCdjl1w==} + + jest-watch-typeahead@2.2.1: + resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 || ^29.0.0 + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3827,11 +5473,31 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + hasBin: true + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jscpd@5.0.11: resolution: {integrity: sha512-NfLrFJHRM6rIf3oVcdZ4sfhMVop1qxi5r8aC99lpj55YC8hiWaN4VmzU2wcXTwoAo+NS4npXPs9EVEQJ6jyRlg==} engines: {node: '>=18'} hasBin: true + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -3846,6 +5512,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -3874,6 +5543,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -3932,6 +5605,9 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -4006,12 +5682,36 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -4034,6 +5734,12 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4041,6 +5747,103 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -4049,6 +5852,9 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} @@ -4064,6 +5870,14 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4077,13 +5891,16 @@ packages: typescript: optional: true + multitars@1.0.2: + resolution: {integrity: sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} + engines: {node: ^22 || ^24 || >=26} hasBin: true native-run@2.0.3: @@ -4091,10 +5908,54 @@ packages: engines: {node: '>=16.0.0'} hasBin: true - node-releases@2.0.50: + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -4111,10 +5972,41 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -4129,6 +6021,22 @@ packages: oxc-resolver@11.21.3: resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -4140,9 +6048,32 @@ packages: resolution: {integrity: sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==} engines: {node: '>=8'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4166,6 +6097,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -4174,6 +6109,14 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + playwright-core@1.61.1: resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} @@ -4188,12 +6131,16 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} pretty-bytes@5.6.0: @@ -4204,14 +6151,46 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + radix-ui@1.6.1: resolution: {integrity: sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw==} peerDependencies: @@ -4225,11 +6204,107 @@ packages: '@types/react-dom': optional: true + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: react: ^19.2.7 + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + react-native-drawer-layout@4.2.10: + resolution: {integrity: sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + react-native-gesture-handler: '>= 2.0.0' + react-native-reanimated: '>= 2.0.0' + + react-native-gesture-handler@2.32.0: + resolution: {integrity: sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.5.1: + resolution: {integrity: sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==} + peerDependencies: + react: '*' + react-native: 0.83 - 0.86 + react-native-worklets: 0.10.x + + react-native-safe-area-context@5.7.0: + resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.26.2: + resolution: {integrity: sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-worklets@0.10.1: + resolution: {integrity: sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.83 - 0.86 + + react-native@0.86.2: + resolution: {integrity: sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + peerDependencies: + '@react-native/jest-preset': 0.86.2 + '@types/react': ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + '@react-native/jest-preset': + optional: true + '@types/react': + optional: true + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4260,6 +6335,11 @@ packages: '@types/react': optional: true + react-test-renderer@19.2.3: + resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} + peerDependencies: + react: ^19.2.3 + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -4287,6 +6367,9 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -4314,6 +6397,13 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4325,11 +6415,22 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} hasBin: true + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + rettime@0.11.11: resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} @@ -4366,6 +6467,14 @@ packages: safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sandbox-cli-detector@0.2.0: + resolution: {integrity: sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==} + engines: {node: '>=18.18'} + hasBin: true + sax@1.1.4: resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} @@ -4389,6 +6498,14 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + serialize-javascript@7.0.7: resolution: {integrity: sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==} engines: {node: '>=20.0.0'} @@ -4403,6 +6520,13 @@ packages: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@3.1.1: resolution: {integrity: sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==} @@ -4418,6 +6542,16 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4426,6 +6560,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -4455,13 +6593,28 @@ packages: simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + slice-ansi@4.0.0: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + smob@1.6.2: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} @@ -4474,9 +6627,20 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -4486,13 +6650,47 @@ packages: engines: {node: '>= 8'} deprecated: The work that was done in this beta branch won't be included in future versions + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + + stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + standard-navigation@0.0.5: + resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4511,6 +6709,18 @@ packages: strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-length@5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4538,30 +6748,65 @@ packages: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + strip-comments@2.0.1: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -4592,11 +6837,27 @@ packages: resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} engines: {node: '>=10'} + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + terser@5.48.0: resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + test-renderer@1.2.0: + resolution: {integrity: sha512-JYiEGbgBGtmHAWX8Kf99gGRL1HtjcRVHLrmJNTZL4vFs9XrnWcuL45Iszw/pO4094+JR7havSrN+ds6YTOpSQA==} + peerDependencies: + react: ^19.0.0 + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} @@ -4622,6 +6883,24 @@ packages: resolution: {integrity: sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==} hasBin: true + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4629,6 +6908,10 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -4648,10 +6931,22 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + type-fest@5.8.0: resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} @@ -4712,10 +7007,18 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -4733,6 +7036,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -4743,6 +7049,11 @@ packages: '@types/react': optional: true + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -4761,11 +7072,33 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite-plugin-pwa@1.3.0: resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==} engines: {node: '>=16.0.0'} @@ -4862,12 +7195,19 @@ packages: jsdom: optional: true + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + vscode-languageserver-textdocument@1.0.12: resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4876,22 +7216,54 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + watskeburt@6.0.0: resolution: {integrity: sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==} engines: {node: ^22.13||^24||>=26} hasBin: true + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} + whatwg-url-minimum@0.1.2: + resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + whatwg-url@16.0.1: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4978,6 +7350,37 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -4986,10 +7389,18 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -5032,6 +7443,13 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -5284,11 +7702,65 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color - '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 @@ -5298,6 +7770,66 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5419,6 +7951,12 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5570,6 +8108,45 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5586,6 +8163,18 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5614,6 +8203,17 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5721,6 +8321,17 @@ snapshots: '@babel/types': 7.29.7 esutils: 2.0.3 + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -5746,6 +8357,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} '@biomejs/biome@2.5.2': @@ -6129,6 +8742,10 @@ snapshots: '@dprint/win32-x64@0.55.1': optional: true + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -6158,97 +8775,479 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 + '@expo-google-fonts/material-symbols@0.4.44': {} + + '@expo/cli@57.0.17(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-constants@57.0.13)(expo-font@57.0.1)(expo-router@57.0.15)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 57.0.8(typescript@6.0.3) + '@expo/config-plugins': 57.0.8(typescript@6.0.3) + '@expo/devcert': 1.2.1 + '@expo/env': 2.4.2 + '@expo/image-utils': 0.11.4(typescript@6.0.3) + '@expo/inline-modules': 0.1.6(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@expo/log-box': 57.0.3(@expo/dom-webview@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@expo/metro': 56.0.2 + '@expo/metro-config': 57.0.9(expo@57.0.15)(typescript@6.0.3) + '@expo/metro-file-map': 57.0.1 + '@expo/osascript': 2.7.1 + '@expo/package-manager': 1.13.1 + '@expo/plist': 0.8.1 + '@expo/prebuild-config': 57.0.13(typescript@6.0.3) + '@expo/require-utils': 57.0.4(typescript@6.0.3) + '@expo/router-server': 57.0.7(@expo/metro-runtime@57.0.12)(expo-constants@57.0.13)(expo-font@57.0.1)(expo-router@57.0.15)(expo-server@57.0.3)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@expo/schema-utils': 57.0.2 + '@expo/spawn-async': 1.8.0 + '@expo/ws-tunnel': 2.0.0(ws@8.21.3) + '@expo/xcpretty': 4.4.4 + '@react-native/dev-middleware': 0.86.2 + accepts: 1.3.8 + agent-cli-detector: 0.1.6 + arg: 5.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.6 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-server: 57.0.3 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.2 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.5 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + sandbox-cli-detector: 0.2.0 + semver: 7.8.5 + send: 0.19.2 + slugify: 1.6.9 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.21.3 + zod: 3.25.76 + optionalDependencies: + expo-router: 57.0.15(cf60fc1f6a5dd37a3570b41921065fd9) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate - '@floating-ui/dom@1.7.6': + '@expo/code-signing-certificates@0.0.6': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + node-forge: 1.4.0 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@expo/config-plugins@57.0.8(typescript@6.0.3)': dependencies: - '@floating-ui/dom': 1.7.6 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - - '@floating-ui/utils@0.2.11': {} - - '@fontsource-variable/inter@5.2.8': {} + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/plist': 0.8.1 + '@expo/require-utils': 57.0.4(typescript@6.0.3) + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + semver: 7.8.5 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript - '@fontsource-variable/space-grotesk@5.2.10': {} + '@expo/config-types@57.0.2': {} - '@inquirer/ansi@2.0.7': {} + '@expo/config@57.0.8(typescript@6.0.3)': + dependencies: + '@expo/config-plugins': 57.0.8(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/require-utils': 57.0.4(typescript@6.0.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.8.5 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript - '@inquirer/confirm@6.1.1(@types/node@22.20.0)': + '@expo/devcert@1.2.1': dependencies: - '@inquirer/core': 11.2.1(@types/node@22.20.0) - '@inquirer/type': 4.0.7(@types/node@22.20.0) - optionalDependencies: - '@types/node': 22.20.0 + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color - '@inquirer/core@11.2.1(@types/node@22.20.0)': + '@expo/devtools@57.0.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@22.20.0) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.2 - mute-stream: 3.0.0 - signal-exit: 4.1.0 + chalk: 4.1.2 optionalDependencies: - '@types/node': 22.20.0 - - '@inquirer/figures@2.0.7': {} + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) - '@inquirer/type@4.0.7(@types/node@22.20.0)': - optionalDependencies: - '@types/node': 22.20.0 + '@expo/dom-webview@57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) - '@ionic/cli-framework-output@2.2.8': + '@expo/env@2.4.2': dependencies: - '@ionic/utils-terminal': 2.3.5 + chalk: 4.1.2 debug: 4.4.3 - tslib: 2.8.1 + getenv: 2.0.0 transitivePeerDependencies: - supports-color - '@ionic/utils-array@2.1.6': + '@expo/expo-modules-macros-plugin@0.6.1': {} + + '@expo/fingerprint@0.20.9': dependencies: + '@expo/env': 2.4.2 + '@expo/spawn-async': 1.8.0 + arg: 5.0.2 + chalk: 4.1.2 debug: 4.4.3 - tslib: 2.8.1 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.5 + resolve-from: 5.0.0 + semver: 7.8.5 transitivePeerDependencies: - supports-color - '@ionic/utils-fs@3.1.7': + '@expo/image-utils@0.11.4(typescript@6.0.3)': dependencies: - '@types/fs-extra': 8.1.5 - debug: 4.4.3 - fs-extra: 9.1.0 - tslib: 2.8.1 + '@expo/require-utils': 57.0.4(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.8.5 transitivePeerDependencies: - supports-color + - typescript - '@ionic/utils-object@2.1.6': + '@expo/inline-modules@0.1.6(typescript@6.0.3)': dependencies: - debug: 4.4.3 - tslib: 2.8.1 + '@expo/config-plugins': 57.0.8(typescript@6.0.3) transitivePeerDependencies: - supports-color + - typescript - '@ionic/utils-process@2.1.12': + '@expo/json-file@11.0.1': dependencies: - '@ionic/utils-object': 2.1.6 - '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 - signal-exit: 3.0.7 - tree-kill: 1.2.2 - tslib: 2.8.1 + '@babel/code-frame': 7.29.7 + json5: 2.2.3 + + '@expo/local-build-cache-provider@57.0.7(typescript@6.0.3)': + dependencies: + '@expo/config': 57.0.8(typescript@6.0.3) + chalk: 4.1.2 transitivePeerDependencies: - supports-color + - typescript - '@ionic/utils-stream@3.1.7': + '@expo/log-box@57.0.3(@expo/dom-webview@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + '@expo/dom-webview': 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + anser: 1.4.10 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@57.0.9(expo@57.0.15)(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@expo/config': 57.0.8(typescript@6.0.3) + '@expo/env': 2.4.2 + '@expo/json-file': 11.0.1 + '@expo/metro': 56.0.2 + '@expo/require-utils': 57.0.4(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + browserslist: 4.28.4 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.36.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.26 + resolve-from: 5.0.0 + optionalDependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-file-map@57.0.1': + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + '@expo/metro-runtime@57.0.12(@expo/log-box@57.0.3)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + '@expo/log-box': 57.0.3(@expo/dom-webview@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + anser: 1.4.10 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + pretty-format: 29.7.0 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + '@expo/metro@56.0.2': + dependencies: + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-minify-terser: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/osascript@2.7.1': + dependencies: + '@expo/spawn-async': 1.8.0 + + '@expo/package-manager@1.13.1': + dependencies: + '@expo/json-file': 11.0.1 + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.8.1': + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@57.0.13(typescript@6.0.3)': + dependencies: + '@expo/config': 57.0.8(typescript@6.0.3) + '@expo/config-plugins': 57.0.8(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/image-utils': 0.11.4(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@react-native/normalize-colors': 0.86.2 + debug: 4.4.3 + expo-modules-autolinking: 57.0.10(typescript@6.0.3) + resolve-from: 5.0.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@57.0.4(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@57.0.7(@expo/metro-runtime@57.0.12)(expo-constants@57.0.13)(expo-font@57.0.1)(expo-router@57.0.15)(expo-server@57.0.3)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + debug: 4.4.3 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-constants: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + expo-font: 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-server: 57.0.3 + react: 19.2.7 + optionalDependencies: + '@expo/metro-runtime': 57.0.12(@expo/log-box@57.0.3)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-router: 57.0.15(cf60fc1f6a5dd37a3570b41921065fd9) + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@57.0.2': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.8.0': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/ui@57.0.12(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + sf-symbols-typescript: 2.2.0 + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + optionalDependencies: + '@babel/core': 7.29.7 + react-dom: 19.2.7(react@19.2.7) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@expo/ws-tunnel@2.0.0(ws@8.21.3)': + dependencies: + ws: 8.21.3 + + '@expo/xcpretty@4.4.4': + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + js-yaml: 4.3.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@fontsource-variable/inter@5.2.8': {} + + '@fontsource-variable/space-grotesk@5.2.10': {} + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/confirm@6.1.1(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/core@11.2.1(@types/node@22.20.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/type@4.0.7(@types/node@22.20.0)': + optionalDependencies: + '@types/node': 22.20.0 + + '@ionic/cli-framework-output@2.2.8': + dependencies: + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-array@2.1.6': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-fs@3.1.7': + dependencies: + '@types/fs-extra': 8.1.5 + debug: 4.4.3 + fs-extra: 9.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-object@2.1.6': + dependencies: + debug: 4.4.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-process@2.1.12': + dependencies: + '@ionic/utils-object': 2.1.6 + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3 + signal-exit: 3.0.7 + tree-kill: 1.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-stream@3.1.7': dependencies: debug: 4.4.3 tslib: 2.8.1 @@ -6288,6 +9287,195 @@ snapshots: dependencies: minipass: 7.1.3 + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.20.0) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.4.0': + optional: true + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.20.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.1.0': + optional: true + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.20.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.52 + optional: true + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.20.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7223,6 +10411,160 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@react-native-masked-view/masked-view@0.3.2(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + '@react-native/assets-registry@0.86.2': {} + + '@react-native/babel-plugin-codegen@0.86.2(@babel/core@7.29.7)': + dependencies: + '@babel/traverse': 7.29.7 + '@react-native/codegen': 0.86.2(@babel/core@7.29.7) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.86.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.86.2(@babel/core@7.29.7) + babel-plugin-syntax-hermes-parser: 0.36.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.86.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + hermes-parser: 0.36.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.17 + yargs: 17.7.3 + + '@react-native/community-cli-plugin@0.86.2(@react-native/metro-config@0.86.2(@babel/core@7.29.7))': + dependencies: + '@react-native/dev-middleware': 0.86.2 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + semver: 7.8.5 + optionalDependencies: + '@react-native/metro-config': 0.86.2(@babel/core@7.29.7) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.86.2': {} + + '@react-native/debugger-shell@0.86.2': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3 + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color + + '@react-native/dev-middleware@0.86.2': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.86.2 + '@react-native/debugger-shell': 0.86.2 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.3.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.86.2': {} + + '@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7)': + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/js-polyfills': 0.86.2 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-environment-node: 29.7.0 + react: 19.2.7 + regenerator-runtime: 0.13.11 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/js-polyfills@0.86.2': {} + + '@react-native/metro-babel-transformer@0.86.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@react-native/babel-preset': 0.86.2(@babel/core@7.29.7) + hermes-parser: 0.36.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + '@react-native/metro-config@0.86.2(@babel/core@7.29.7)': + dependencies: + '@react-native/js-polyfills': 0.86.2 + '@react-native/metro-babel-transformer': 0.86.2(@babel/core@7.29.7) + metro-config: 0.84.5 + metro-runtime: 0.84.5 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/normalize-colors@0.86.2': {} + + '@react-native/virtualized-lists@0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@rolldown/binding-android-arm64@1.1.4': optional: true @@ -7274,12 +10616,13 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.2)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.2)': dependencies: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@rollup/pluginutils': 5.4.0(rollup@4.62.2) optionalDependencies: + '@types/babel__core': 7.20.5 rollup: 4.62.2 transitivePeerDependencies: - supports-color @@ -7392,6 +10735,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sinclair/typebox@0.27.12': {} + + '@sinclair/typebox@0.34.52': + optional: true + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.43': @@ -7588,6 +10944,21 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react-native@14.0.1(jest@29.7.0(@types/node@22.20.0))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(test-renderer@1.2.0(@types/react@19.2.17)(react@19.2.7))': + dependencies: + jest-matcher-utils: 30.4.1 + picocolors: 1.1.1 + pretty-format: 30.4.1 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + redent: 3.0.0 + test-renderer: 1.2.0(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + jest: 29.7.0(@types/node@22.20.0) + optional: true + + '@tootallnate/once@2.0.1': {} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': dependencies: ejs: 3.1.10 @@ -7600,6 +10971,27 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -7613,6 +11005,33 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.20.0 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 22.20.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -7623,6 +11042,15 @@ snapshots: dependencies: '@types/react': 19.2.17 + '@types/react-reconciler@0.33.0(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + optional: true + + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.17 + '@types/react@19.2.17': dependencies: csstype: 3.2.3 @@ -7635,10 +11063,22 @@ snapshots: '@types/slice-ansi@4.0.0': {} + '@types/stack-utils@2.0.3': {} + '@types/statuses@2.0.6': {} + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.3.3': {} + '@vitejs/plugin-react-swc@4.3.1(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -7703,8 +11143,31 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@xmldom/xmldom@0.8.15': {} + '@xmldom/xmldom@0.9.10': {} + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.17.0 + acorn-walk: 8.3.5 + acorn-jsx-walk@2.0.0: {} acorn-jsx@5.3.2(acorn@8.17.0): @@ -7721,6 +11184,16 @@ snapshots: acorn@8.17.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agent-cli-detector@0.1.6: {} + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -7728,14 +11201,43 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@6.2.1: {} + + ansi-regex@4.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -7759,6 +11261,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asap@2.0.6: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.4: @@ -7773,18 +11277,60 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + at-least-node@1.0.0: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + await-lock@2.2.2: {} + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): dependencies: - '@babel/compat-data': 7.29.7 '@babel/core': 7.29.7 '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) - semver: 6.3.1 + core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color @@ -7803,6 +11349,105 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.7 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.36.0: + dependencies: + hermes-parser: 0.36.0 + + babel-plugin-syntax-hermes-parser@0.36.1: + dependencies: + hermes-parser: 0.36.1 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): + dependencies: + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-expo@57.0.7(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.15)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.86.2(@babel/core@7.29.7) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.36.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + debug: 4.4.3 + react-refresh: 0.14.2 + optionalDependencies: + '@babel/runtime': 7.29.7 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + badgin@1.2.3: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -7829,14 +11474,23 @@ snapshots: dependencies: big-integer: 1.6.52 + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.4: dependencies: baseline-browser-mapping: 2.10.42 @@ -7845,10 +11499,16 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + buffer-crc32@0.2.13: {} buffer-from@1.1.2: {} + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -7868,6 +11528,10 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + caniuse-lite@1.0.30001800: {} chai@6.2.2: {} @@ -7876,6 +11540,17 @@ snapshots: dependencies: chalk: 5.6.2 + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7883,27 +11558,90 @@ snapshots: chalk@5.6.2: {} + char-regex@1.0.2: {} + + char-regex@2.0.2: {} + chownr@3.0.0: {} + chrome-launcher@0.15.2: + dependencies: + '@types/node': 22.20.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.3.0: + dependencies: + '@types/node': 22.20.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + clear-module@4.1.3: dependencies: parent-module: 2.0.0 resolve-from: 5.0.0 + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} commander@14.0.3: {} @@ -7912,6 +11650,8 @@ snapshots: commander@2.20.3: {} + commander@7.2.0: {} + comment-json@4.6.2: dependencies: array-timsort: 1.0.3 @@ -7919,6 +11659,35 @@ snapshots: common-tags@1.8.2: {} + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cookie-es@3.1.1: {} @@ -7947,6 +11716,21 @@ snapshots: cpd-windows-x64-msvc@5.0.11: optional: true + create-jest@29.7.0(@types/node@22.20.0): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.20.0) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8053,8 +11837,22 @@ snapshots: css.escape@1.5.1: {} + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + csstype@3.2.3: {} + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -8080,14 +11878,30 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 decimal.js@10.6.0: {} + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + deepmerge@4.3.1: {} + defaults@1.0.4: + dependencies: + clone: 1.0.4 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -8102,6 +11916,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + dependency-cruiser@18.0.0: dependencies: acorn: 8.17.0 @@ -8123,12 +11941,24 @@ snapshots: tsconfig-paths-webpack-plugin: 4.2.0 watskeburt: 6.0.0 + destroy@1.2.0: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} + diff-sequences@29.6.3: {} + + dnssd-advertise@1.1.6: {} + dom-accessibility-api@0.6.3: {} + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + dprint@0.55.1: optionalDependencies: '@dprint/android-arm64': 0.55.1 @@ -8153,6 +11983,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -8163,8 +11995,14 @@ snapshots: dependencies: sax: 1.1.4 + emittery@0.13.1: {} + emoji-regex@8.0.0: {} + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -8175,6 +12013,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + entities@8.0.0: {} env-paths@2.2.1: {} @@ -8183,6 +12023,14 @@ snapshots: dependencies: is-safe-filename: 0.1.1 + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -8264,30 +12112,306 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es-to-primitive@1.3.4: + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eta@4.6.0: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect-type@1.4.0: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expo-application@57.0.2(expo@57.0.15): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + + expo-asset@57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.11.4(typescript@6.0.3) + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-constants: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - supports-color + - typescript + + expo-constants@57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)): + dependencies: + '@expo/env': 2.4.2 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - supports-color + + expo-file-system@57.0.5(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + expo-font@57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + fontfaceobserver: 2.3.0 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + expo-glass-effect@57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + expo-keep-awake@57.0.1(expo@57.0.15)(react@19.2.7): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + + expo-linking@57.0.7(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + expo-constants: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + invariant: 2.2.4 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - expo + - supports-color + + expo-modules-autolinking@57.0.10(typescript@6.0.3): + dependencies: + '@expo/require-utils': 57.0.4(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@57.0.12(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@expo/expo-modules-macros-plugin': 0.6.1 + expo-modules-jsi: 57.0.5(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + invariant: 2.2.4 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + + expo-modules-jsi@57.0.5(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)): + dependencies: + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + expo-notifications@57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.11.4(typescript@6.0.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-application: 57.0.2(expo@57.0.15) + expo-constants: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - supports-color + - typescript + + expo-router@57.0.15(cf60fc1f6a5dd37a3570b41921065fd9): + dependencies: + '@expo/log-box': 57.0.3(@expo/dom-webview@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@expo/metro-runtime': 57.0.12(@expo/log-box@57.0.3)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@expo/schema-utils': 57.0.2 + '@expo/ui': 57.0.12(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-tabs': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + client-only: 0.0.1 + color: 4.2.3 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-constants: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + expo-glass-effect: 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-linking: 57.0.7(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-server: 57.0.3 + expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 6.0.1 + query-string: 7.1.3 + react: 19.2.7 + react-fast-compare: 3.2.2 + react-is: 19.2.8 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + react-native-drawer-layout: 4.2.10(f738ba1b5e361b97dd113db23df44616) + react-native-safe-area-context: 5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-screens: 4.26.2(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + standard-navigation: 0.0.5 + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + optionalDependencies: + '@testing-library/react-native': 14.0.1(jest@29.7.0(@types/node@22.20.0))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(test-renderer@1.2.0(@types/react@19.2.17)(react@19.2.7)) + react-dom: 19.2.7(react@19.2.7) + react-native-gesture-handler: 2.32.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + transitivePeerDependencies: + - '@babel/core' + - '@types/react' + - '@types/react-dom' + - expo-font + - react-native-worklets + - supports-color + + expo-secure-store@57.0.1(expo@57.0.15): dependencies: - es-abstract-get: 1.0.0 - es-define-property: 1.0.1 - es-errors: 1.3.0 - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) - escalade@3.2.0: {} + expo-server@57.0.3: {} - esprima@4.0.1: {} + expo-splash-screen@57.0.7(expo@57.0.15)(typescript@6.0.3): + dependencies: + '@expo/config-plugins': 57.0.8(typescript@6.0.3) + '@expo/image-utils': 0.11.4(typescript@6.0.3) + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript - estree-walker@2.0.2: {} + expo-sqlite@57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + await-lock: 2.2.2 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) - estree-walker@3.0.3: + expo-status-bar@57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): dependencies: - '@types/estree': 1.0.9 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) - esutils@2.0.3: {} + expo-symbols@57.0.2(expo-font@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.44 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-font: 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + sf-symbols-typescript: 2.2.0 - eta@4.6.0: {} + expo-system-ui@57.0.2(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)): + dependencies: + '@react-native/normalize-colors': 0.86.2 + debug: 4.4.3 + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - supports-color - expect-type@1.4.0: {} + expo@57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + '@expo/cli': 57.0.17(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-constants@57.0.13)(expo-font@57.0.1)(expo-router@57.0.15)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + '@expo/config': 57.0.8(typescript@6.0.3) + '@expo/config-plugins': 57.0.8(typescript@6.0.3) + '@expo/devtools': 57.0.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@expo/fingerprint': 0.20.9 + '@expo/local-build-cache-provider': 57.0.7(typescript@6.0.3) + '@expo/log-box': 57.0.3(@expo/dom-webview@57.0.1)(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@expo/metro': 56.0.2 + '@expo/metro-config': 57.0.9(expo@57.0.15)(typescript@6.0.3) + '@ungap/structured-clone': 1.3.3 + babel-preset-expo: 57.0.7(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.15)(react-refresh@0.14.2) + expo-asset: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-constants: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + expo-file-system: 57.0.5(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + expo-font: 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-keep-awake: 57.0.1(expo@57.0.15)(react@19.2.7) + expo-modules-autolinking: 57.0.10(typescript@6.0.3) + expo-modules-core: 57.0.12(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + pretty-format: 29.7.0 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.2 + optionalDependencies: + '@expo/dom-webview': 57.0.1(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + '@expo/metro-runtime': 57.0.12(@expo/log-box@57.0.3)(expo@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} fake-indexeddb@6.2.5: {} @@ -8309,6 +12433,12 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -8321,12 +12451,41 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fetch-nodeshim@0.4.10: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + flatted@3.4.2: {} + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -8336,10 +12495,20 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 + fresh@0.5.2: {} + fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 @@ -8353,6 +12522,8 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -8400,11 +12571,15 @@ snapshots: get-own-enumerable-property-symbols@3.0.2: {} + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -8415,6 +12590,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + getenv@2.0.0: {} + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -8430,6 +12607,15 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -8451,6 +12637,8 @@ snapshots: has-bigints@1.1.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -8476,6 +12664,38 @@ snapshots: '@types/set-cookie-parser': 2.4.10 set-cookie-parser: 3.1.1 + hermes-compiler@250829098.0.16: {} + + hermes-estree@0.35.0: {} + + hermes-estree@0.36.0: {} + + hermes-estree@0.36.1: {} + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hermes-parser@0.36.0: + dependencies: + hermes-estree: 0.36.0 + + hermes-parser@0.36.1: + dependencies: + hermes-estree: 0.36.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -8484,10 +12704,48 @@ snapshots: html-escaper@2.0.2: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb-keyval@6.2.6: {} idb@7.1.1: {} + ignore@5.3.2: {} + ignore@7.0.5: {} import-fresh@3.3.1: @@ -8495,10 +12753,22 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@4.1.1: {} @@ -8515,12 +12785,20 @@ snapshots: interpret@3.1.1: {} + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -8567,6 +12845,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-generator-fn@2.1.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -8593,6 +12873,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-obj@1.0.1: {} is-path-inside@4.0.0: {} @@ -8612,70 +12894,483 @@ snapshots: is-set@2.0.3: {} - is-shared-array-buffer@1.0.4: + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isbot@5.1.44: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@22.20.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.20.0) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.20.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@22.20.0): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.20.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + optional: true + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 22.20.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-expo@57.0.4(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(expo@57.0.15)(jest@29.7.0(@types/node@22.20.0))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@jest/globals': 29.7.0 + '@react-native/jest-preset': 0.86.2(@babel/core@7.29.7)(react@19.2.7) + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-environment-jsdom: 29.7.0 + jest-snapshot: 29.7.0 + jest-watch-select-projects: 2.0.0 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@22.20.0)) + json5: 2.2.3 + lodash: 4.18.1 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + react-test-renderer: 19.2.3(react@19.2.7) + server-only: 0.0.1 + stacktrace-js: 2.0.2 + optionalDependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - canvas + - jest + - react + - supports-color + - utf-8-validate + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.20.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: dependencies: - call-bound: 1.0.4 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 - is-stream@2.0.1: {} + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 - is-string@1.1.1: + jest-matcher-utils@30.4.1: dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + optional: true - is-symbol@1.1.1: + jest-message-util@29.7.0: dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 - is-typed-array@1.1.15: + jest-mock@29.7.0: dependencies: - which-typed-array: 1.1.22 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + jest-util: 29.7.0 - is-weakmap@2.0.2: {} + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 - is-weakref@1.1.1: + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: dependencies: - call-bound: 1.0.4 + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color - is-weakset@2.0.4: + jest-resolve@29.7.0: dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 - is-wsl@2.2.0: + jest-runner@29.7.0: dependencies: - is-docker: 2.2.1 + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color - isarray@2.0.5: {} + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color - isbot@5.1.44: {} + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color - isexe@2.0.0: {} + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 - istanbul-lib-coverage@3.2.2: {} + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 - istanbul-lib-report@3.0.1: + jest-watch-select-projects@2.0.0: dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 + ansi-escapes: 4.3.2 + chalk: 3.0.0 + prompts: 2.4.2 - istanbul-reports@3.2.0: + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@22.20.0)): dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 + ansi-escapes: 6.2.1 + chalk: 4.1.2 + jest: 29.7.0(@types/node@22.20.0) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 - jackspeak@4.2.3: + jest-watcher@29.7.0: dependencies: - '@isaacs/cliui': 9.0.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.20.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 - jake@10.9.4: + jest-worker@29.7.0: dependencies: - async: 3.2.6 - filelist: 1.0.6 - picocolors: 1.1.1 + '@types/node': 22.20.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@22.20.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.20.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jimp-compact@0.16.1: {} jiti@2.7.0: {} @@ -8683,6 +13378,17 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.15.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + jscpd@5.0.11: optionalDependencies: cpd-darwin-arm64: 5.0.11 @@ -8692,6 +13398,39 @@ snapshots: cpd-linux-x64-musl: 5.0.11 cpd-windows-x64-msvc: 5.0.11 + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.17.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.6 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.21.3 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -8720,6 +13459,8 @@ snapshots: jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@1.0.0: {} json5@2.2.3: {} @@ -8752,6 +13493,8 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + lan-network@0.2.1: {} + lefthook-darwin-arm64@2.1.9: optional: true @@ -8797,6 +13540,13 @@ snapshots: leven@3.1.0: {} + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + lightningcss-android-arm64@1.32.0: optional: true @@ -8846,10 +13596,30 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.debounce@4.0.8: {} lodash.sortby@4.7.0: {} + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + lru-cache@11.5.1: {} lru-cache@5.1.1: @@ -8874,15 +13644,225 @@ snapshots: dependencies: semver: 7.8.5 + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + math-intrinsics@1.1.0: {} mdn-data@2.27.1: {} + memoize-one@5.2.1: {} + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.84.5: + dependencies: + '@babel/core': 7.29.7 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.84.5: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.5 + transitivePeerDependencies: + - supports-color + + metro-config@0.84.5: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.5 + metro-cache: 0.84.5 + metro-core: 0.84.5 + metro-runtime: 0.84.5 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.5 + + metro-file-map@0.84.5: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.48.0 + + metro-resolver@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.5: + dependencies: + '@babel/runtime': 7.29.7 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.84.5: + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.5 + nullthrows: 1.1.1 + ob1: 0.84.5 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.5 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.5: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.84.5: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + flow-enums-runtime: 0.0.6 + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5 + metro-transform-plugins: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.5: + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.13 + yargs: 17.7.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + min-indent@1.0.1: {} minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 minimatch@5.1.9: dependencies: @@ -8896,6 +13876,10 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp@1.0.4: {} + + ms@2.0.0: {} + ms@2.1.3: {} msw@2.14.6(@types/node@22.20.0)(typescript@6.0.3): @@ -8923,9 +13907,11 @@ snapshots: transitivePeerDependencies: - '@types/node' + multitars@1.0.2: {} + mute-stream@3.0.0: {} - nanoid@3.3.15: {} + nanoid@6.0.1: {} native-run@2.0.3: dependencies: @@ -8943,8 +13929,43 @@ snapshots: transitivePeerDependencies: - supports-color + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + node-releases@2.0.50: {} + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + validate-npm-package-name: 5.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nullthrows@1.1.1: {} + + nwsapi@2.2.24: {} + + ob1@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -8960,12 +13981,48 @@ snapshots: obug@2.1.3: {} + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 is-docker: 2.2.1 is-wsl: 2.2.0 + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + outvariant@1.4.3: {} own-keys@1.0.1: @@ -9021,6 +14078,20 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.21.3 '@oxc-resolver/binding-win32-x64-msvc': 11.21.3 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -9031,10 +14102,31 @@ snapshots: dependencies: callsites: 3.1.0 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -9052,10 +14144,18 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} picomatch@4.0.5: {} + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + playwright-core@1.61.1: {} playwright@1.61.1: @@ -9070,11 +14170,13 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@3.4.0: {} + possible-typed-array-names@1.1.0: {} - postcss@8.5.16: + postcss@8.5.26: dependencies: - nanoid: 3.3.15 + nanoid: 6.0.1 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9082,13 +14184,50 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.8 + optional: true + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@8.3.0: + dependencies: + asap: 2.0.6 + prompts@2.4.2: dependencies: kleur: 3.0.3 sisteransi: 1.0.5 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + querystringify@2.2.0: {} + radix-ui@1.6.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@radix-ui/primitive': 1.1.4 @@ -9152,11 +14291,151 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + range-parser@1.2.1: {} + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.10.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 scheduler: 0.27.0 + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.8: {} + + react-native-drawer-layout@4.2.10(f738ba1b5e361b97dd113db23df44616): + dependencies: + color: 4.2.3 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + react-native-gesture-handler: 2.32.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + use-latest-callback: 0.2.6(react@19.2.7) + + react-native-gesture-handler@2.32.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@egjs/hammerjs': 2.0.17 + '@types/react-test-renderer': 19.1.0 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + react-native-is-edge-to-edge@1.3.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + semver: 7.8.5 + + react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + + react-native-screens@4.26.2(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-freeze: 1.0.4(react@19.2.7) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + warn-once: 0.1.1 + + react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@react-native/metro-config': 0.86.2(@babel/core@7.29.7) + convert-source-map: 2.0.0 + react: 19.2.7 + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@react-native/assets-registry': 0.86.2 + '@react-native/codegen': 0.86.2(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.86.2(@react-native/metro-config@0.86.2(@babel/core@7.29.7)) + '@react-native/gradle-plugin': 0.86.2 + '@react-native/js-polyfills': 0.86.2 + '@react-native/normalize-colors': 0.86.2 + '@react-native/virtualized-lists': 0.86.2(@types/react@19.2.17)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.36.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.16 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.7 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.8.5 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 + whatwg-fetch: 3.6.20 + ws: 7.5.13 + yargs: 17.7.3 + optionalDependencies: + '@react-native/jest-preset': 0.86.2(@babel/core@7.29.7)(react@19.2.7) + '@types/react': 19.2.17 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-reconciler@0.33.0(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + optional: true + + react-refresh@0.14.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -9184,6 +14463,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-test-renderer@19.2.3(react@19.2.7): + dependencies: + react: 19.2.7 + react-is: 19.2.8 + scheduler: 0.27.0 + react@19.2.7: {} readable-stream@3.6.2: @@ -9218,6 +14503,8 @@ snapshots: regenerate@1.4.2: {} + regenerator-runtime@0.13.11: {} + regexp-tree@0.1.27: {} regexp.prototype.flags@1.5.4: @@ -9248,12 +14535,22 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -9261,6 +14558,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + rettime@0.11.11: {} rimraf@6.1.3: @@ -9345,6 +14647,10 @@ snapshots: dependencies: regexp-tree: 0.1.27 + safer-buffer@2.1.2: {} + + sandbox-cli-detector@0.2.0: {} + sax@1.1.4: {} sax@1.6.0: {} @@ -9359,6 +14665,26 @@ snapshots: semver@7.8.5: {} + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + serialize-javascript@7.0.7: {} seroval-plugins@1.5.4(seroval@1.5.4): @@ -9367,6 +14693,17 @@ snapshots: seroval@1.5.4: {} + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + set-cookie-parser@3.1.1: {} set-function-length@1.2.2: @@ -9391,12 +14728,20 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + shell-quote@1.10.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -9437,35 +14782,87 @@ snapshots: bplist-parser: 0.3.1 plist: 3.1.1 + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + sisteransi@1.0.5: {} + slash@3.0.0: {} + + slash@5.1.0: {} + slice-ansi@4.0.0: dependencies: ansi-styles: 4.3.0 astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slugify@1.6.9: {} + smob@1.6.2: {} smol-toml@1.7.0: {} source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 + source-map@0.5.6: {} + + source-map@0.5.7: {} + source-map@0.6.1: {} source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 + split-on-first@1.1.0: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + stackframe@1.3.4: {} + + stacktrace-gps@3.1.2: + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + + stacktrace-js@2.0.2: + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + standard-navigation@0.0.5: {} + + statuses@1.5.0: {} + statuses@2.0.2: {} std-env@4.1.0: {} @@ -9479,6 +14876,18 @@ snapshots: strict-event-emitter@0.5.1: {} + strict-uri-encode@2.0.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-length@5.0.1: + dependencies: + char-regex: 2.0.2 + strip-ansi: 7.2.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -9535,24 +14944,53 @@ snapshots: is-obj: 1.0.1 is-regexp: 1.0.0 + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + strip-comments@2.0.1: {} + strip-final-newline@2.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 + strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + structured-headers@0.4.1: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} symbol-tree@3.2.4: {} @@ -9580,6 +15018,11 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -9587,6 +15030,23 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + test-renderer@1.2.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@types/react-reconciler': 0.33.0(@types/react@19.2.17) + react: 19.2.7 + react-reconciler: 0.33.0(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + optional: true + + throat@5.0.0: {} + through2@4.0.2: dependencies: readable-stream: 3.6.2 @@ -9608,6 +15068,23 @@ snapshots: dependencies: tldts-core: 7.4.6 + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tough-cookie@6.0.1: dependencies: tldts: 7.4.6 @@ -9616,6 +15093,10 @@ snapshots: dependencies: punycode: 2.3.1 + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -9637,8 +15118,14 @@ snapshots: tslib@2.8.1: {} + type-detect@4.0.8: {} + type-fest@0.16.0: {} + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + type-fest@5.8.0: dependencies: tagged-tag: 1.0.0 @@ -9706,8 +15193,12 @@ snapshots: dependencies: crypto-random-string: 2.0.0 + universalify@0.2.0: {} + universalify@2.0.1: {} + unpipe@1.0.0: {} + until-async@3.0.2: {} untildify@4.0.0: {} @@ -9720,6 +15211,11 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -9727,6 +15223,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + use-latest-callback@0.2.6(react@19.2.7): + dependencies: + react: 19.2.7 + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): dependencies: detect-node-es: 1.1.0 @@ -9741,15 +15241,36 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + uuid@7.0.3: {} - vite-plugin-pwa@1.3.0(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1): + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vite-plugin-pwa@1.3.0(vite@8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.17 vite: 8.1.3(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - workbox-build: 7.4.1 + workbox-build: 7.4.1(@types/babel__core@7.20.5) workbox-window: 7.4.1 transitivePeerDependencies: - supports-color @@ -9758,7 +15279,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.16 + postcss: 8.5.26 rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: @@ -9797,24 +15318,57 @@ snapshots: transitivePeerDependencies: - msw + vlq@1.0.1: {} + vscode-languageserver-textdocument@1.0.12: {} vscode-uri@3.1.0: {} + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 walk-up-path@4.0.0: {} + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + watskeburt@6.0.0: {} + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + webidl-conversions@4.0.2: {} + webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url-minimum@0.1.2: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + whatwg-url@16.0.1: dependencies: '@exodus/bytes': 1.15.1 @@ -9888,13 +15442,13 @@ snapshots: dependencies: workbox-core: 7.4.1 - workbox-build@7.4.1: + workbox-build@7.4.1(@types/babel__core@7.20.5): dependencies: '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) '@babel/core': 7.29.7 '@babel/preset-env': 7.29.7(@babel/core@7.29.7) '@babel/runtime': 7.29.7 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.2) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.2) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2) '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) '@rollup/plugin-terser': 1.0.0(rollup@4.62.2) @@ -9998,6 +15552,17 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@7.5.13: {} + + ws@8.21.3: {} + xcode@3.0.1: dependencies: simple-plist: 1.3.1 @@ -10005,8 +15570,15 @@ snapshots: xdg-basedir@5.1.0: {} + xml-name-validator@4.0.0: {} + xml-name-validator@5.0.0: {} + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + xml2js@0.6.2: dependencies: sax: 1.6.0 @@ -10043,6 +15615,10 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + zod@4.4.3: {} zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): diff --git a/scripts/verify-apk.sh b/scripts/verify-apk.sh index 4f88e8fe..33507e27 100755 --- a/scripts/verify-apk.sh +++ b/scripts/verify-apk.sh @@ -1,35 +1,78 @@ #!/usr/bin/env bash -# Usage: verify-apk.sh +# Usage: verify-apk.sh [line] # -# What the packaged APK actually says about itself: the version testers see, and -# every permission the phone will show them. AGP's output-metadata.json and the -# merged-manifest report are the build's own account of what it configured, so -# they agree with a build that resolved either one wrong; the APK is what gets -# installed. +# What the packaged APK actually says about itself: the version testers see, +# every permission the phone will show them, and the two backup properties +# PRIVACY.md, docs/index.html and README.md all promise. AGP's +# output-metadata.json and the merged-manifest report are the build's own account +# of what it configured, so they agree with a build that resolved any of it +# wrong; the APK is what gets installed. # # The permission set is a merge result, not a repo file: any dependency's # manifest can add to it, and a `tools:node="remove"` can silently stop applying. -# PRIVACY.md's claim is about the merged set, so the merged set is pinned here, -# exactly, and a new one fails the build rather than shipping unannounced. +# So the merged set is pinned here, exactly, and a new one fails the build rather +# than shipping unannounced. +# +# `line` is `capacitor` (the default, and the line that ships today) or `expo`. +# The two build genuinely different sets and there is no honest way to write one +# list that covers both: see the comments on each. set -euo pipefail apk=$1 expected_code=$2 expected_name=$3 +line=${4:-capacitor} -# Every permission Cue's own manifest declares or accepts from a plugin. +# Every permission the Capacitor line's own manifest declares or accepts from a +# plugin. # INTERNET Trakt, declared by hand # POST_NOTIFICATIONS episode reminders (API 33+ runtime ask) # RECEIVE_BOOT_COMPLETED, WAKE_LOCK the notification plugin's alarm receiver # DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION AGP's own, injected for targetSdk >= 33 # SCHEDULE_EXACT_ALARM is absent deliberately: android/app/src/main/AndroidManifest.xml # removes it, and its absence here is what proves the removal still applies. -expected_permissions="android.permission.INTERNET +capacitor_permissions="android.permission.INTERNET android.permission.POST_NOTIFICATIONS android.permission.RECEIVE_BOOT_COMPLETED android.permission.WAKE_LOCK app.cuetracker.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" +# The Expo line's set, measured off its first release prebuild rather than +# assumed. All five above survive for the same reasons, and two are new: +# ACCESS_NETWORK_STATE firebase-messaging, which expo-notifications depends on +# whether or not an app uses push. Cue never asks for a +# push token, so nothing in that library runs; the +# permission is normal-level and auto-granted, and +# blocking it would make the FCM code path throw if it +# ever did run. Recorded rather than removed. +# ACCESS_WIFI_STATE expo-network, which fills the connectivity port. This +# one the app does use, through getNetworkStateAsync, so +# it is kept rather than blocked: the library reads the +# Wi-Fi state to answer, and a blocked permission it +# actually holds is a crash rather than a smaller set. +# Twenty-five other permissions arrive from the same dependency tree and are +# dropped in app.config.ts, which explains each one: the Expo template's four +# optional ones, expo-secure-store's biometric pair, and expo-notifications' +# push receive, install-referrer binding and per-OEM launcher badge set. +# SYSTEM_ALERT_WINDOW is re-declared by the debug flavour for the development +# menu, so a debug APK of this line carries it and a release APK does not. +expo_permissions="android.permission.ACCESS_NETWORK_STATE +android.permission.ACCESS_WIFI_STATE +android.permission.INTERNET +android.permission.POST_NOTIFICATIONS +android.permission.RECEIVE_BOOT_COMPLETED +android.permission.WAKE_LOCK +app.cuetracker.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + +case "$line" in + capacitor) expected_permissions=$capacitor_permissions ;; + expo) expected_permissions=$expo_permissions ;; + *) + echo "verify-apk: unknown line '$line'; expected 'capacitor' or 'expo'." >&2 + exit 1 + ;; +esac + sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} aapt2=$(printf '%s\n' "$sdk/build-tools"/*/aapt2 | sort -V | tail -n 1) if [ ! -x "$aapt2" ]; then @@ -60,4 +103,55 @@ if [ "$permissions" != "$(sort <<<"$expected_permissions")" ]; then exit 1 fi -echo "verify-apk: $apk is $name ($code), asking for $(wc -l <<<"$permissions" | tr -d ' ') permissions." +manifest=$("$aapt2" dump xmltree --file AndroidManifest.xml "$apk") + +if ! grep -q 'android:allowBackup([^)]*)=false' <<<"$manifest"; then + echo "verify-apk: $apk does not set android:allowBackup=false, so Google Drive backup and adb backup are on." >&2 + exit 1 +fi + +# Presence of the attribute says nothing about the file it points at, so the +# rules are read out of the APK too. AGP shortens resource file paths in a +# release build, which is why the id is resolved through the resource table +# rather than guessed from the source file name. +rules_id=$(sed -n 's/.*android:dataExtractionRules([^)]*)=@\(0x[0-9a-f]*\).*/\1/p' <<<"$manifest") +if [ -z "$rules_id" ]; then + echo "verify-apk: $apk names no dataExtractionRules, so device-to-device transfer copies everything." >&2 + exit 1 +fi + +rules_path=$("$aapt2" dump resources "$apk" | + grep -A1 "resource $rules_id " | + sed -n 's/.*(file) \(res\/[^ ]*\) type=XML.*/\1/p' | + head -n 1) +if [ -z "$rules_path" ]; then + echo "verify-apk: $apk names dataExtractionRules $rules_id, which resolves to no XML file." >&2 + exit 1 +fi + +rules=$("$aapt2" dump xmltree --file "$rules_path" "$apk") + +if grep -q '^ *E: include' <<<"$rules"; then + echo "verify-apk: $rules_path carries an , which turns its section back into an allowlist." >&2 + exit 1 +fi + +# Every storage domain the platform can name, excluded whole from both channels. +# Counted rather than pattern-matched per section, because a rule that excludes +# nine domains from one channel and eight from the other is exactly the shape +# this is here to catch. +for domain in root file database sharedpref external device_root device_file device_database device_sharedpref; do + excluded=$(grep -c "A: domain=\"$domain\"" <<<"$rules" || true) + if [ "$excluded" != "2" ]; then + echo "verify-apk: $rules_path excludes domain '$domain' from $excluded of the two backup channels, expected both." >&2 + exit 1 + fi +done + +whole=$(grep -c 'A: path="\."' <<<"$rules" || true) +if [ "$whole" != "18" ]; then + echo "verify-apk: $rules_path has $whole whole-domain exclusions, expected 18 (nine domains, two channels)." >&2 + exit 1 +fi + +echo "verify-apk: $apk is $name ($code) on the $line line, asking for $(wc -l <<<"$permissions" | tr -d ' ') permissions, with backup off and every storage domain excluded from both channels." diff --git a/vitest.config.ts b/vitest.config.ts index 8e53ccc2..c0904ea0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/*"], + // Named rather than globbed: `packages/native` is a jest-expo package, and a + // glob would hand its `__tests__` to vitest, which has no React Native + // runtime and reports the suite as a failure rather than as not its job. + projects: ["packages/core", "packages/web"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], From 45afc61ec0d5f06e4cfecfda1fa8607d5357d0d5 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sun, 23 Aug 2026 01:01:39 -0500 Subject: [PATCH 024/435] The local Expo module and the native composition root Every member of `CueRuntime` on this target, and almost no new logic: the write queue, the op-log restore, the startup reconcile, the activities freshness gate, the persisted query cache, the sign-out teardown and the dead-token exit all arrive with `@cue/core` and are wired here rather than rewritten. The spike's parallel context, six members of the port under a different name, is not carried forward in any form. The module carries two native classes because both platforms need both, and an Expo local module is one native compilation unit. `CueHaptics` is the seven-verb vocabulary the Capacitor shells already ship, ported to Swift and Kotlin: generators built once and kept warm on iOS, action-oriented `HapticFeedbackConstants` with their per-API-level fallbacks on Android. `expo-haptics` cannot be the answer here: it exposes no `prepare()`, so a threshold tick arrives late enough to read as belonging to some other gesture, and its Android side funnels everything into a raw `Vibrator` waveform, which is what Android's own haptics guidance tells you not to use for touch feedback. `CueLegacyPreferences` reads Capacitor Preferences through each platform's own key shape, which are not the same shape: iOS prefixes the storage group onto the key inside `UserDefaults`, Android makes the group the shared-preferences file name and leaves the key alone, and a migration that assumes one reads nothing on the other platform. Two key-value stores behind one interface. The Keychain holds the token and nothing else, at `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, which is what finally closes the iOS half of the storage caveat: the token no longer rides in a store every device backup includes. `expo-sqlite/kv-store` holds everything durable, and because it and the preferences now share one database, preferences are written under `pref.` rather than `cue.`: a sign-out that cleared `cue.` would take the durable write queue, the freshness baseline and the install marker with it, and losing the install marker makes the next launch look like a fresh install to the purge, which then clears the token. Two things run before the runtime is built, in that order, because both change what the token store holds. The reinstall purge clears the Keychain when the bulk store carries no install marker: Expo documents that SecureStore items survive an uninstall on iOS, so without this a user who deletes Cue and reinstalls it comes back apparently signed in with a stale token and none of their local state. Then the Capacitor migration, which is pure over a port and therefore a unit test rather than a device session: it adopts the legacy token on every boot and leaves it in place while a Capacitor build is still a rollback target, takes the legacy op log away on read because a replayed op is a duplicate play, parses that op log through a schema because it was written by another build at an unknown version, and seeds the first-mark caption as seen. The Web Crypto surface the shared OAuth code is written against is installed from a plain function rather than from an import side effect, so organize-imports cannot move it, and rather than from a React component, so it is not a global write at a moment nothing controls. `TextEncoder` is deliberately not shimmed: a hand-written UTF-8 encoder is right until it meets a non-latin1 input, and a crash at first sign-in is better than a challenge that hashes the wrong bytes. The `KeyValueStore` contract suite is `@cue/core`'s, run over both native backends rather than transcribed, which is what the `vitest` shim under `__tests__/support` is for. The composition root is rendered under RNTL for the one property nothing else can check: which of the two branches the gate takes after the purge and the migration have had their say. `neverRejects` moves the "none of these ever rejects" half of the reminders port into the port itself, so the two adapters state the policy once between them instead of once each. --- .gitignore | 3 + knip.json | 2 +- .../core/src/migration/legacy-capacitor.ts | 141 ++++++++++++++ packages/core/src/ports/legacy-store.ts | 17 ++ packages/core/src/ports/reminders.ts | 14 ++ .../test/migration/legacy-capacitor.test.ts | 162 ++++++++++++++++ packages/core/test/support/stores.ts | 44 +++++ packages/native/__tests__/boot.test.ts | 87 +++++++++ .../__tests__/composition-root.test.tsx | 124 ++++++++++++ packages/native/__tests__/kv-contract.test.ts | 72 +++++++ .../native/__tests__/support/native-stores.ts | 90 +++++++++ packages/native/__tests__/support/vitest.ts | 19 ++ packages/native/app/_layout.tsx | 177 +++++++++++++++++- packages/native/app/index.tsx | 12 +- packages/native/jest.config.js | 9 +- .../modules/cue-native/android/build.gradle | 18 ++ .../android/src/main/AndroidManifest.xml | 2 + .../cuetracker/cuenative/CueHapticsModule.kt | 89 +++++++++ .../cuenative/CueLegacyPreferencesModule.kt | 38 ++++ .../cue-native/expo-module.config.json | 12 ++ .../cue-native/ios/CueHapticsModule.swift | 80 ++++++++ .../ios/CueLegacyPreferencesModule.swift | 29 +++ .../modules/cue-native/ios/CueNative.podspec | 23 +++ .../native/modules/cue-native/src/index.ts | 32 ++++ packages/native/package.json | 11 +- packages/native/src/boot.ts | 63 +++++++ packages/native/src/config.ts | 45 +++++ packages/native/src/crypto.ts | 40 ++++ packages/native/src/platform/app-version.ts | 15 ++ .../native/src/platform/app-visibility.ts | 16 ++ packages/native/src/platform/haptics.ts | 34 ++++ packages/native/src/platform/legacy-store.ts | 9 + packages/native/src/platform/network.ts | 39 ++++ .../native/src/platform/query-persister.ts | 24 +++ packages/native/src/platform/reminders.ts | 106 +++++++++++ packages/native/src/platform/stores.ts | 80 ++++++++ packages/native/src/screens/Onboarding.tsx | 60 ++++++ packages/native/src/screens/RuntimeBoot.tsx | 54 ++++++ packages/native/src/screens/UpNext.tsx | 45 +++++ packages/web/src/platform/reminders.ts | 16 +- pnpm-lock.yaml | 64 +++++-- 41 files changed, 1978 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/migration/legacy-capacitor.ts create mode 100644 packages/core/src/ports/legacy-store.ts create mode 100644 packages/core/test/migration/legacy-capacitor.test.ts create mode 100644 packages/core/test/support/stores.ts create mode 100644 packages/native/__tests__/boot.test.ts create mode 100644 packages/native/__tests__/composition-root.test.tsx create mode 100644 packages/native/__tests__/kv-contract.test.ts create mode 100644 packages/native/__tests__/support/native-stores.ts create mode 100644 packages/native/__tests__/support/vitest.ts create mode 100644 packages/native/modules/cue-native/android/build.gradle create mode 100644 packages/native/modules/cue-native/android/src/main/AndroidManifest.xml create mode 100644 packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueHapticsModule.kt create mode 100644 packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueLegacyPreferencesModule.kt create mode 100644 packages/native/modules/cue-native/expo-module.config.json create mode 100644 packages/native/modules/cue-native/ios/CueHapticsModule.swift create mode 100644 packages/native/modules/cue-native/ios/CueLegacyPreferencesModule.swift create mode 100644 packages/native/modules/cue-native/ios/CueNative.podspec create mode 100644 packages/native/modules/cue-native/src/index.ts create mode 100644 packages/native/src/boot.ts create mode 100644 packages/native/src/config.ts create mode 100644 packages/native/src/crypto.ts create mode 100644 packages/native/src/platform/app-version.ts create mode 100644 packages/native/src/platform/app-visibility.ts create mode 100644 packages/native/src/platform/haptics.ts create mode 100644 packages/native/src/platform/legacy-store.ts create mode 100644 packages/native/src/platform/network.ts create mode 100644 packages/native/src/platform/query-persister.ts create mode 100644 packages/native/src/platform/reminders.ts create mode 100644 packages/native/src/platform/stores.ts create mode 100644 packages/native/src/screens/Onboarding.tsx create mode 100644 packages/native/src/screens/RuntimeBoot.tsx create mode 100644 packages/native/src/screens/UpNext.tsx diff --git a/.gitignore b/.gitignore index 87b4473a..36a2bd54 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ dev-dist/ packages/native/ios/ packages/native/android/ packages/native/.expo/ +# The local Expo module is source; what Gradle leaves beside it is not. +packages/native/modules/*/android/build/ +packages/native/modules/*/android/.cxx/ # Env: the real .env (your Trakt app's PUBLIC client id) is local-only. # Committed: .env.example (placeholders), .env.test (CI/e2e dummy id) and diff --git a/knip.json b/knip.json index 3875c135..a4c8803b 100644 --- a/knip.json +++ b/knip.json @@ -16,7 +16,7 @@ }, "packages/native": { "entry": ["plugins/*.js"], - "project": ["app/**/*.{ts,tsx}", "plugins/*.js"], + "project": ["app/**/*.{ts,tsx}", "src/**/*.{ts,tsx}", "modules/**/*.ts", "plugins/*.js"], "babel": ["babel.config.js"] } } diff --git a/packages/core/src/migration/legacy-capacitor.ts b/packages/core/src/migration/legacy-capacitor.ts new file mode 100644 index 00000000..106a8689 --- /dev/null +++ b/packages/core/src/migration/legacy-capacitor.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; +import { tokenSchema } from "../domain/model/token"; +import type { QueuedOp } from "../domain/write-queue/types"; +import { createJsonStore } from "../ports/json-store"; +import type { KeyValueStore } from "../ports/kv"; +import type { LegacyStore } from "../ports/legacy-store"; +import type { PreferenceStorage } from "../ports/preference-storage"; +import type { TokenStore } from "../ports/token-store"; + +const LEGACY_TOKEN_KEY = "cue.trakt.token"; +const LEGACY_OP_LOG_KEY = "cue.write-queue"; +const OP_LOG_KEY = "cue.write-queue"; +/** The one preference worth seeding: without it an established user is offered + * the first-mark tutorial again, which is the only loss that reads as a bug + * rather than as a reset. */ +const TUTORIAL_KEY = "cue.tutorial-mark-dismissed"; + +const requestSchema = z.object({ + method: z.literal("POST"), + path: z.string(), + body: z.unknown(), +}); + +/** + * The op-log the Capacitor build persisted, validated on the way in. + * + * The live op-log store validates nothing beyond `Array.isArray`, which is + * right for a value this app wrote itself an instant ago and wrong for one + * another build wrote at an unknown version: a malformed op replayed is a play + * sent to Trakt that the user never made, and a dropped op is a play that does + * not reach it. Between those two, dropping is the one that is visible. + * + * The parser is here rather than in `domain/write-queue/` because the shape + * witness is taken over that tree, and this file states no persisted shape of + * its own: it re-states one, and `parseOpLog`'s return type is what keeps the + * two from drifting. + */ +const queuedOpSchema = z.object({ + id: z.string(), + itemKey: z.string(), + request: requestSchema, + inverse: requestSchema, + inversePatch: z.unknown(), + watchedAt: z.string().nullable(), + fromState: z.enum(["present", "absent"]), + toState: z.enum(["present", "absent"]), + reconcileKeys: z.array(z.string()), +}); + +function parseOpLog(raw: string): QueuedOp[] { + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(decoded)) return []; + const ops: QueuedOp[] = []; + for (const entry of decoded) { + const op = queuedOpSchema.safeParse(entry); + if (op.success) ops.push(op.data); + } + return ops; +} + +export interface LegacyMigrationDeps { + readonly legacy: LegacyStore; + /** The secure store the token lands in. */ + readonly tokenStore: TokenStore; + /** The bulk store the op log lands in. */ + readonly bulk: KeyValueStore; + readonly preferences: PreferenceStorage; +} + +export interface LegacyMigrationResult { + /** A legacy token was present and is now the session's. */ + readonly adoptedToken: boolean; + /** How many ops were carried across. */ + readonly adoptedOps: number; +} + +/** + * Take what the Capacitor build left behind, on every boot. + * + * Not "migrate once when the secure store is empty". While a Capacitor build is + * still a shippable rollback target, both apps can write a token, and the only + * rule that matches what a user who rolled back, signed in again and rolled + * forward expects is last-writer-wins with the legacy store preferred. So the + * legacy token is read every boot and left where it is; it is removed in the + * commit that removes the rollback target. + * + * The op log is the opposite case and is removed on read: it is the one store + * whose loss is silent data loss, an op in it is a play the user marked that + * has not reached Trakt, and a replayed op-log is a duplicate play. + * + * The last-activities baseline is deliberately not migrated. With no baseline + * the first poll establishes one without invalidating anything, and the query + * cache is unreadable from here anyway, so there is nothing stale to protect. + */ +export async function migrateLegacyCapacitorData( + deps: LegacyMigrationDeps, +): Promise { + const rawToken = await deps.legacy.read(LEGACY_TOKEN_KEY); + let adoptedToken = false; + if (rawToken !== null) { + const token = tokenSchema.safeParse(tryParse(rawToken)); + if (token.success) { + await deps.tokenStore.write(token.data); + adoptedToken = true; + // An install that has a token is an install that has been used, so the + // one-time caption has been seen whether or not its own key survived. + deps.preferences.setItem(TUTORIAL_KEY, "1"); + } + } + + const rawOpLog = await deps.legacy.read(LEGACY_OP_LOG_KEY); + let adoptedOps = 0; + if (rawOpLog !== null) { + const migrated = parseOpLog(rawOpLog); + if (migrated.length > 0) { + const opLog = createJsonStore(deps.bulk, OP_LOG_KEY, (value) => + Array.isArray(value) ? (value as QueuedOp[]) : [], + ); + // Ahead of anything this install queued: the legacy ops are older, and + // the queue replays in order. + await opLog.write([...migrated, ...((await opLog.read()) ?? [])]); + adoptedOps = migrated.length; + } + await deps.legacy.remove(LEGACY_OP_LOG_KEY); + } + + return { adoptedToken, adoptedOps }; +} + +function tryParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} diff --git a/packages/core/src/ports/legacy-store.ts b/packages/core/src/ports/legacy-store.ts new file mode 100644 index 00000000..598700b5 --- /dev/null +++ b/packages/core/src/ports/legacy-store.ts @@ -0,0 +1,17 @@ +/** + * The Capacitor build's own key-value store, read-only apart from the one key + * the migration is allowed to take away. + * + * A port rather than a direct read because the two platforms do not agree on + * the key shape: Capacitor Preferences prefixes its group onto the key on iOS + * (`CapacitorStorage.cue.trakt.token` in `UserDefaults`) and uses the group as + * the file name on Android (`cue.trakt.token` inside `CapacitorStorage` + * shared preferences). The implementation owns that difference so the migration + * can be a pure function of what it reads back. + * + * Only the native app provides one. The web app is the Capacitor build. + */ +export interface LegacyStore { + read(key: string): Promise; + remove(key: string): Promise; +} diff --git a/packages/core/src/ports/reminders.ts b/packages/core/src/ports/reminders.ts index f084d8a9..b7ded8b1 100644 --- a/packages/core/src/ports/reminders.ts +++ b/packages/core/src/ports/reminders.ts @@ -16,6 +16,20 @@ export interface Reminders { cancelAll(): Promise; } +/** + * The port's promise not to reject, applied rather than restated by each + * implementation. A permission revoked mid-call, a missing plugin or an OS + * refusal cannot be recovered from at this seam, and an unhandled rejection in a + * shell is worse than the honest fallback: nothing granted, nothing scheduled. + */ +export function neverRejects(reminders: Reminders): Reminders { + return { + requestPermission: () => reminders.requestPermission().catch(() => false), + reconcile: (planned) => reminders.reconcile(planned).catch(() => {}), + cancelAll: () => reminders.cancelAll().catch(() => {}), + }; +} + /** Schedules nothing and grants everything, which keeps the Settings switch a * plain preference on the web build exactly as the haptics one is. */ export const SILENT: Reminders = { diff --git a/packages/core/test/migration/legacy-capacitor.test.ts b/packages/core/test/migration/legacy-capacitor.test.ts new file mode 100644 index 00000000..2e71945f --- /dev/null +++ b/packages/core/test/migration/legacy-capacitor.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + type LegacyMigrationDeps, + migrateLegacyCapacitorData, +} from "../../src/migration/legacy-capacitor"; +import { createTokenStore } from "../../src/ports/token-store"; +import { + type MemoryKeyValueStore, + memoryKeyValueStore, + memoryPreferenceStorage, +} from "../support/stores"; + +const TOKEN = { + access_token: "access", + refresh_token: "refresh", + created_at: 1_700_000_000, + expires_in: 604_800, +} as const; + +const OP = { + id: "op-1", + itemKey: "episode:8802191", + request: { method: "POST", path: "/sync/history", body: { episodes: [] } }, + inverse: { method: "POST", path: "/sync/history/remove", body: { episodes: [] } }, + inversePatch: { kind: "mark", showId: 8803, preCompleted: 3 }, + watchedAt: "2026-08-01T20:00:00.000Z", + fromState: "absent", + toState: "present", + reconcileKeys: ["progress/watched", "watched/shows"], +} as const; + +function deps(legacySeed: Record = {}): LegacyMigrationDeps & { + readonly legacy: MemoryKeyValueStore; + readonly bulk: MemoryKeyValueStore; + readonly preferences: ReturnType; +} { + const bulk = memoryKeyValueStore(); + return { + legacy: memoryKeyValueStore(legacySeed), + tokenStore: createTokenStore(memoryKeyValueStore()), + bulk, + preferences: memoryPreferenceStorage(), + }; +} + +/** + * The migration is a pure function of what the Capacitor build left in its own + * store, which is what makes every branch below a test rather than a device + * session. The two that matter most are the ones with no visible symptom: a + * token not adopted is a forced device-code re-login for every installed user, + * and an op log not adopted is a play the user marked that never reaches Trakt. + */ +describe("the Capacitor migration", () => { + it("does nothing on a fresh install", async () => { + const fresh = deps(); + const result = await migrateLegacyCapacitorData(fresh); + + expect(result).toEqual({ adoptedToken: false, adoptedOps: 0 }); + expect(await fresh.tokenStore.read()).toBeNull(); + expect(fresh.bulk.values.size).toBe(0); + expect(fresh.preferences.values.size).toBe(0); + }); + + it("adopts the legacy token and leaves it where it is", async () => { + const upgrade = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); + const result = await migrateLegacyCapacitorData(upgrade); + + expect(result.adoptedToken).toBe(true); + expect(await upgrade.tokenStore.read()).toEqual(TOKEN); + // Left in place on purpose: a Capacitor build is still a rollback target, + // and the rule that matches what a user expects is last writer wins. + expect(upgrade.legacy.values.get("cue.trakt.token")).toBe(JSON.stringify(TOKEN)); + }); + + it("prefers the legacy token over one this install already holds", async () => { + const rolledBackAndForward = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); + await rolledBackAndForward.tokenStore.write({ ...TOKEN, access_token: "stale" }); + + await migrateLegacyCapacitorData(rolledBackAndForward); + + expect((await rolledBackAndForward.tokenStore.read())?.access_token).toBe("access"); + }); + + it("seeds the first-mark caption as seen, because an install with a token has been used", async () => { + const upgrade = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); + await migrateLegacyCapacitorData(upgrade); + + expect(upgrade.preferences.getItem("cue.tutorial-mark-dismissed")).toBe("1"); + }); + + it("ignores a token the schema rejects rather than adopting half of one", async () => { + const corrupt = deps({ "cue.trakt.token": JSON.stringify({ access_token: "only" }) }); + const result = await migrateLegacyCapacitorData(corrupt); + + expect(result.adoptedToken).toBe(false); + expect(await corrupt.tokenStore.read()).toBeNull(); + expect(corrupt.preferences.values.size).toBe(0); + }); + + it("carries the op log across and removes it, so it can never replay twice", async () => { + const pending = deps({ + "cue.trakt.token": JSON.stringify(TOKEN), + "cue.write-queue": JSON.stringify([OP]), + }); + const result = await migrateLegacyCapacitorData(pending); + + expect(result.adoptedOps).toBe(1); + expect(JSON.parse(pending.bulk.values.get("cue.write-queue") ?? "null")).toEqual([OP]); + expect(pending.legacy.values.has("cue.write-queue")).toBe(false); + }); + + it("drops an unparseable op rather than replaying it as a play nobody made", async () => { + const mixed = deps({ + "cue.write-queue": JSON.stringify([OP, { id: "op-2" }, { ...OP, id: "op-3" }]), + }); + const result = await migrateLegacyCapacitorData(mixed); + + expect(result.adoptedOps).toBe(2); + expect(JSON.parse(mixed.bulk.values.get("cue.write-queue") ?? "null")).toEqual([ + OP, + { ...OP, id: "op-3" }, + ]); + }); + + it("treats a corrupt op log as empty and still clears it", async () => { + const corrupt = deps({ "cue.write-queue": "{ not json" }); + const result = await migrateLegacyCapacitorData(corrupt); + + expect(result.adoptedOps).toBe(0); + expect(corrupt.bulk.values.size).toBe(0); + expect(corrupt.legacy.values.has("cue.write-queue")).toBe(false); + }); + + it("puts migrated ops ahead of anything this install already queued", async () => { + const both = deps({ "cue.write-queue": JSON.stringify([OP]) }); + const mine = { ...OP, id: "op-native" }; + await both.bulk.write("cue.write-queue", JSON.stringify([mine])); + + await migrateLegacyCapacitorData(both); + + expect(JSON.parse(both.bulk.values.get("cue.write-queue") ?? "null")).toEqual([OP, mine]); + }); + + it("is a no-op on the second launch, because the op log is gone and the token is idempotent", async () => { + const upgrade = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); + await migrateLegacyCapacitorData({ ...upgrade, legacy: upgrade.legacy }); + const second = await migrateLegacyCapacitorData(upgrade); + + expect(second).toEqual({ adoptedToken: true, adoptedOps: 0 }); + expect(await upgrade.tokenStore.read()).toEqual(TOKEN); + }); + + it("does not migrate the freshness baseline, so the first poll establishes one", async () => { + const upgrade = deps({ + "cue.trakt.token": JSON.stringify(TOKEN), + "cue.last-activities": JSON.stringify({ all: "2026-08-01T00:00:00.000Z" }), + }); + await migrateLegacyCapacitorData(upgrade); + + expect(upgrade.bulk.values.has("cue.last-activities")).toBe(false); + }); +}); diff --git a/packages/core/test/support/stores.ts b/packages/core/test/support/stores.ts new file mode 100644 index 00000000..691a88ff --- /dev/null +++ b/packages/core/test/support/stores.ts @@ -0,0 +1,44 @@ +import type { KeyValueStore } from "../../src/ports/kv"; +import type { PreferenceStorage } from "../../src/ports/preference-storage"; + +/** + * The two storage ports as plain maps, with the map exposed so a test can assert + * on what is durable rather than on what a spy was called with. Shared, because + * the migration is tested here as a pure function and again in the native + * package as part of its boot, and two copies of the same fake are two things + * that can drift from the port they stand for. + */ + +export type MemoryKeyValueStore = KeyValueStore & { readonly values: Map }; + +export function memoryKeyValueStore(seed: Record = {}): MemoryKeyValueStore { + const values = new Map(Object.entries(seed)); + return { + values, + read: (key) => Promise.resolve(values.get(key) ?? null), + write: (key, value) => { + values.set(key, value); + return Promise.resolve(); + }, + remove: (key) => { + values.delete(key); + return Promise.resolve(); + }, + }; +} + +export type MemoryPreferenceStorage = PreferenceStorage & { readonly values: Map }; + +export function memoryPreferenceStorage(): MemoryPreferenceStorage { + const values = new Map(); + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + clearNamespace: (prefix) => { + for (const key of [...values.keys()]) if (key.startsWith(prefix)) values.delete(key); + }, + }; +} diff --git a/packages/native/__tests__/boot.test.ts b/packages/native/__tests__/boot.test.ts new file mode 100644 index 00000000..33f3fe5a --- /dev/null +++ b/packages/native/__tests__/boot.test.ts @@ -0,0 +1,87 @@ +import type { LegacyStore } from "@cue/core/ports/legacy-store"; +import { createTokenStore } from "@cue/core/ports/token-store"; +import { + type MemoryKeyValueStore, + memoryKeyValueStore, + memoryPreferenceStorage, +} from "../../core/test/support/stores"; +import { bootNativeStores } from "../src/boot"; + +const TOKEN = { + access_token: "access", + refresh_token: "refresh", + created_at: 1_700_000_000, + expires_in: 604_800, +} as const; + +function deps(options: { + secure?: Record; + bulk?: Record; + legacy?: Record; +}) { + return { + secure: memoryKeyValueStore(options.secure), + bulk: memoryKeyValueStore(options.bulk), + legacy: memoryKeyValueStore(options.legacy) as LegacyStore & MemoryKeyValueStore, + preferences: memoryPreferenceStorage(), + newInstallId: () => "an-install", + }; +} + +/** + * The two things that happen before the runtime is built, and the reason they + * happen in this order: both can change what the token store contains, and + * getting either wrong is invisible until a user is either signed in as somebody + * they are not or signed out for no reason they can see. + */ +describe("the native boot", () => { + it("purges the Keychain on the first launch of a fresh install", async () => { + // The Keychain outlives an uninstall on iOS, so a reinstalled app can find a + // token its own install never wrote. An empty bulk store is what a fresh + // install looks like, which is why the marker has to live there. + const reinstall = deps({ secure: { "cue.trakt.token": JSON.stringify(TOKEN) } }); + + const result = await bootNativeStores(reinstall); + + expect(result.purged).toBe(true); + expect(await createTokenStore(reinstall.secure).read()).toBeNull(); + expect(reinstall.bulk.values.get("cue.install-id")).toBe("an-install"); + }); + + it("leaves a signed-in session alone on every launch after the first", async () => { + const returning = deps({ + secure: { "cue.trakt.token": JSON.stringify(TOKEN) }, + bulk: { "cue.install-id": "an-earlier-install" }, + }); + + const result = await bootNativeStores(returning); + + expect(result.purged).toBe(false); + expect(await createTokenStore(returning.secure).read()).toEqual(TOKEN); + expect(returning.bulk.values.get("cue.install-id")).toBe("an-earlier-install"); + }); + + it("adopts the Capacitor build's token, after the purge rather than before it", async () => { + // Both run on the same launch: the purge clears a Keychain item this install + // never wrote, and the migration then puts back the one the Capacitor build + // did write. Reversed, an upgrading user is signed out. + const upgrade = deps({ + secure: { "cue.trakt.token": JSON.stringify({ ...TOKEN, access_token: "stale" }) }, + legacy: { "cue.trakt.token": JSON.stringify(TOKEN) }, + }); + + const result = await bootNativeStores(upgrade); + + expect(result.purged).toBe(true); + expect(result.migration.adoptedToken).toBe(true); + expect(await createTokenStore(upgrade.secure).read()).toEqual(TOKEN); + }); + + it("installs the Web Crypto surface the shared OAuth code is written against", async () => { + await bootNativeStores(deps({})); + + expect(typeof globalThis.btoa).toBe("function"); + expect(typeof globalThis.crypto.getRandomValues).toBe("function"); + expect(typeof globalThis.crypto.subtle.digest).toBe("function"); + }); +}); diff --git a/packages/native/__tests__/composition-root.test.tsx b/packages/native/__tests__/composition-root.test.tsx new file mode 100644 index 00000000..a201a69f --- /dev/null +++ b/packages/native/__tests__/composition-root.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from "@testing-library/react-native"; +import { bulkBacking, legacyBacking, secureBacking } from "./support/native-stores"; + +// Every jest factory below is hoisted above the imports, so each one reaches its +// fake through `require` rather than through a binding that does not exist yet. +jest.mock("expo-secure-store", () => require("./support/native-stores").secureStoreModule); +jest.mock("expo-sqlite/kv-store", () => ({ + __esModule: true, + default: require("./support/native-stores").bulkBacking, +})); +jest.mock("../modules/cue-native/src", () => require("./support/native-stores").cueNativeModule); +jest.mock( + "react-native-safe-area-context", + () => require("react-native-safe-area-context/jest/mock").default, +); +// Imported for its side effects by the reminders adapter, and on Android its +// push-registration module throws under this runner. The seam it fills is not +// what this file is about. +jest.mock("expo-notifications", () => ({ + AndroidImportance: { DEFAULT: 3 }, + SchedulableTriggerInputTypes: { DATE: "date" }, + setNotificationChannelAsync: () => Promise.resolve(null), + requestPermissionsAsync: () => Promise.resolve({ granted: false }), + getPermissionsAsync: () => Promise.resolve({ granted: false }), + getAllScheduledNotificationsAsync: () => Promise.resolve([]), + scheduleNotificationAsync: () => Promise.resolve(""), + cancelScheduledNotificationAsync: () => Promise.resolve(), + cancelAllScheduledNotificationsAsync: () => Promise.resolve(), +})); +jest.mock("expo-splash-screen", () => ({ + preventAutoHideAsync: () => Promise.resolve(true), + hideAsync: () => Promise.resolve(true), +})); +jest.mock("expo-application", () => ({ + nativeApplicationVersion: "1.2.3", + nativeBuildVersion: "45", +})); +jest.mock("expo-network", () => ({ + getNetworkStateAsync: () => Promise.resolve({ isConnected: true }), + addNetworkStateListener: () => ({ remove: () => {} }), +})); +jest.mock("expo-crypto", () => ({ + CryptoDigestAlgorithm: { SHA256: "SHA-256" }, + digest: () => Promise.resolve(new ArrayBuffer(32)), + getRandomValues: (array: Uint8Array) => array, + randomUUID: () => "an-install", +})); +// The navigator stands in for the whole route tree: what this file is about is +// which of the two branches the gate takes, not what the router then draws. +jest.mock("expo-router", () => { + const { createElement } = require("react"); + const { Text } = require("react-native"); + const Stack = () => createElement(Text, { testID: "router-stack" }, "routed"); + Stack.Screen = () => null; + return { Stack, Link: Text, Redirect: () => null }; +}); + +const TOKEN = JSON.stringify({ + access_token: "access", + refresh_token: "refresh", + created_at: 1_700_000_000, + expires_in: 604_800, +}); + +// Required rather than imported: the module reads its stores at import time, so +// it has to be loaded after the mocks above are in place. +const RootLayout = require("../app/_layout").default as () => React.JSX.Element; + +/** + * The composition root, rendered. + * + * What it has to get right is an ordering nothing else can check: the reinstall + * purge and the Capacitor migration both change what the token store contains, + * and the auth store reads that store the instant it is built. Build it too + * early and a reinstalling user is signed in with a Keychain item their install + * never wrote, or an upgrading user is dropped onto a sign-in screen with a + * perfectly good token sitting beside them. + */ +describe("the native composition root", () => { + beforeEach(() => { + bulkBacking.values.clear(); + secureBacking.clear(); + legacyBacking.clear(); + }); + + it("shows sign-in on a fresh install, even with a Keychain item that outlived an uninstall", async () => { + secureBacking.set("cue.trakt.token", TOKEN); + + await render(); + + expect(await screen.findByTestId("screen-onboarding")).toBeOnTheScreen(); + expect(screen.queryByTestId("router-stack")).toBeNull(); + }); + + it("boots straight into the app when the Capacitor build left a token", async () => { + legacyBacking.set("cue.trakt.token", TOKEN); + + await render(); + + expect(await screen.findByTestId("router-stack")).toBeOnTheScreen(); + expect(screen.queryByTestId("screen-onboarding")).toBeNull(); + }); + + it("leaves the legacy token where it is, and takes the legacy op log away", async () => { + legacyBacking.set("cue.trakt.token", TOKEN); + legacyBacking.set("cue.write-queue", "[]"); + + await render(); + await screen.findByTestId("router-stack"); + + expect(legacyBacking.get("cue.trakt.token")).toBe(TOKEN); + expect(legacyBacking.has("cue.write-queue")).toBe(false); + }); + + it("keeps the token out of the bulk store", async () => { + legacyBacking.set("cue.trakt.token", TOKEN); + + await render(); + await screen.findByTestId("router-stack"); + + expect([...bulkBacking.values.keys()]).not.toContain("cue.trakt.token"); + expect(secureBacking.get("cue.trakt.token")).toBe(TOKEN); + }); +}); diff --git a/packages/native/__tests__/kv-contract.test.ts b/packages/native/__tests__/kv-contract.test.ts new file mode 100644 index 00000000..1a2dd02a --- /dev/null +++ b/packages/native/__tests__/kv-contract.test.ts @@ -0,0 +1,72 @@ +import { describeKeyValueStore } from "../../core/test/support/kv-contract"; +import { bulkBacking, secureBacking } from "./support/native-stores"; + +// Both factories are hoisted above the imports, so each reaches its fake +// through `require` rather than through a binding that does not exist yet, and +// the adapters are loaded the same way, after the mocks are in place. +jest.mock("expo-sqlite/kv-store", () => ({ + __esModule: true, + default: require("./support/native-stores").bulkBacking, +})); +jest.mock("expo-secure-store", () => require("./support/native-stores").secureStoreModule); + +const { bulkStore, clearLocalPreferences, preferenceStorage, secureStore } = + require("../src/platform/stores") as typeof import("../src/platform/stores"); + +/** + * The two native backends against the one contract every `KeyValueStore` has to + * satisfy, the same suite the web store and the Capacitor store already run. + * This seam carries the OAuth token and the durable write queue, so a backend + * that truncates a long value, mangles a non-latin1 one, throws instead of + * answering null or forgets across a restart loses a user's writes silently. + */ +describeKeyValueStore({ + name: "the native bulk store (expo-sqlite/kv-store)", + open: () => bulkStore, + reset: () => { + bulkBacking.values.clear(); + return Promise.resolve(); + }, +}); + +describeKeyValueStore({ + name: "the native token store (expo-secure-store)", + open: () => secureStore, + reset: () => { + secureBacking.clear(); + return Promise.resolve(); + }, +}); + +/** + * The namespace split, which exists only on this target. On the web, + * preferences and the durable state are in physically different stores; here + * they share one database, and every durable key is `cue.`-prefixed, so a + * sign-out that cleared `cue.` would take the write queue, the freshness + * baseline and the install marker with it. Losing the install marker is the + * worst of those: the next launch would look like a fresh install to the + * reinstall purge, which would then clear the token. + */ +describe("what sign-out's preference clear can reach", () => { + beforeEach(() => { + bulkBacking.values.clear(); + }); + + it("cannot reach the durable write queue or the install marker, which share its store", async () => { + await bulkStore.write("cue.write-queue", '[{"id":"op-1"}]'); + await bulkStore.write("cue.install-id", "an-install"); + preferenceStorage.setItem("cue.theme", "dark"); + + clearLocalPreferences(); + + expect(preferenceStorage.getItem("cue.theme")).toBeNull(); + expect(await bulkStore.read("cue.write-queue")).toBe('[{"id":"op-1"}]'); + expect(await bulkStore.read("cue.install-id")).toBe("an-install"); + }); + + it("keeps a preference out of a `cue.` prefix scan of the bulk store", () => { + preferenceStorage.setItem("cue.theme", "dark"); + + expect([...bulkBacking.values.keys()]).toEqual(["pref.cue.theme"]); + }); +}); diff --git a/packages/native/__tests__/support/native-stores.ts b/packages/native/__tests__/support/native-stores.ts new file mode 100644 index 00000000..9ea45a95 --- /dev/null +++ b/packages/native/__tests__/support/native-stores.ts @@ -0,0 +1,90 @@ +/** + * In-memory stand-ins for the two native backends, at the module boundary the + * adapters call. + * + * jest-expo mocks the native modules but not functionally: `expo-sqlite`'s + * `NativeDatabase` is not a constructor under it, and `expo-secure-store`'s + * getter answers nothing it was given. So what runs here is the shape of the + * calls each adapter makes and the key mapping it applies, which is the same + * bargain the web lane already strikes for the Capacitor store. The backends + * themselves are proved on a simulator, where the SQLite file and the Keychain + * are real. + */ + +export interface MemoryStorage { + readonly values: Map; + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + getItemSync(key: string): string | null; + setItemSync(key: string, value: string): void; + removeItemSync(key: string): boolean; + getAllKeysSync(): string[]; +} + +function createMemoryStorage(): MemoryStorage { + const values = new Map(); + return { + values, + getItem: (key) => Promise.resolve(values.get(key) ?? null), + setItem: (key, value) => { + values.set(key, value); + return Promise.resolve(); + }, + removeItem: (key) => { + values.delete(key); + return Promise.resolve(); + }, + getItemSync: (key) => values.get(key) ?? null, + setItemSync: (key, value) => { + values.set(key, value); + }, + removeItemSync: (key) => values.delete(key), + getAllKeysSync: () => [...values.keys()], + }; +} + +/** The bulk store's backing: the op log, the freshness baseline, the persisted + * query cache, the preferences and the install marker. */ +export const bulkBacking = createMemoryStorage(); + +/** The Keychain's backing: the token, and nothing else. */ +export const secureBacking = new Map(); + +export const secureStoreModule = { + WHEN_UNLOCKED_THIS_DEVICE_ONLY: "whenUnlockedThisDeviceOnly", + getItemAsync: (key: string): Promise => + Promise.resolve(secureBacking.get(key) ?? null), + setItemAsync: (key: string, value: string): Promise => { + secureBacking.set(key, value); + return Promise.resolve(); + }, + deleteItemAsync: (key: string): Promise => { + secureBacking.delete(key); + return Promise.resolve(); + }, +}; + +/** What the Capacitor build left behind, as the local module would read it. */ +export const legacyBacking = new Map(); + +/** The local module's whole JavaScript surface: the seven silent verbs and the + * legacy reader over the map above. */ +export const cueNativeModule = { + CueHaptics: { + success: () => {}, + failure: () => {}, + thresholdActivate: () => {}, + thresholdDeactivate: () => {}, + selection: () => {}, + contextClick: () => {}, + prepare: () => {}, + }, + CueLegacyPreferences: { + read: (key: string): Promise => Promise.resolve(legacyBacking.get(key) ?? null), + remove: (key: string): Promise => { + legacyBacking.delete(key); + return Promise.resolve(); + }, + }, +}; diff --git a/packages/native/__tests__/support/vitest.ts b/packages/native/__tests__/support/vitest.ts new file mode 100644 index 00000000..fa498c9c --- /dev/null +++ b/packages/native/__tests__/support/vitest.ts @@ -0,0 +1,19 @@ +/** + * The shared assertions live in `@cue/core`'s test tree and import their test + * API from `vitest`, because the two packages that ran them first are vitest + * projects. Jest declares the same four names as globals, so this module stands + * in for `vitest` under the jest lane (`moduleNameMapper` in `jest.config.js`) + * and the contract suite runs unchanged in both runners rather than being + * transcribed into a second copy that can drift. + */ +const jestDescribe = describe; +const jestIt = it; +const jestExpect = expect; +const jestBeforeEach = beforeEach; + +export { + jestBeforeEach as beforeEach, + jestDescribe as describe, + jestExpect as expect, + jestIt as it, +}; diff --git a/packages/native/app/_layout.tsx b/packages/native/app/_layout.tsx index 288635ed..018b76f5 100644 --- a/packages/native/app/_layout.tsx +++ b/packages/native/app/_layout.tsx @@ -1,7 +1,178 @@ +import { createAuthStore } from "@cue/core/auth/create-auth-store"; +import { type AuthStore, AuthStoreProvider, useAuth } from "@cue/core/auth/store"; +import { AppVersionProvider } from "@cue/core/ports/app-version"; +import { AppVisibilityProvider } from "@cue/core/ports/app-visibility"; +import { HapticsProvider } from "@cue/core/ports/haptics"; +import { NetworkProvider } from "@cue/core/ports/network"; +import { RemindersProvider } from "@cue/core/ports/reminders"; +import { createTokenStore } from "@cue/core/ports/token-store"; +import { createPrefsStore, PrefsProvider } from "@cue/core/prefs/prefs-store"; +import { PERSIST_BUSTER, PERSIST_MAX_AGE } from "@cue/core/runtime/query-cache"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import { randomUUID } from "expo-crypto"; import { Stack } from "expo-router"; -import type { ReactElement } from "react"; +import * as SplashScreen from "expo-splash-screen"; +import { StatusBar } from "expo-status-bar"; +import { type ReactElement, useEffect, useState } from "react"; +import { View } from "react-native"; +import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context"; +import { bootNativeStores } from "../src/boot"; +import { NATIVE_REDIRECT_URI, TRAKT_BASE_OVERRIDE, TRAKT_CLIENT_ID } from "../src/config"; +import { nativeAppVersion } from "../src/platform/app-version"; +import { nativeAppVisibility } from "../src/platform/app-visibility"; +import { createNativeHaptics } from "../src/platform/haptics"; +import { legacyCapacitorStore } from "../src/platform/legacy-store"; +import { createNativeNetwork } from "../src/platform/network"; +import { + clearPersistedCaches, + queryClient, + queryPersister, + shouldDehydrateQuery, +} from "../src/platform/query-persister"; +import { createNativeReminders } from "../src/platform/reminders"; +import { + bulkStore, + clearLocalPreferences, + preferenceStorage, + secureStore, +} from "../src/platform/stores"; +import { Onboarding } from "../src/screens/Onboarding"; +import { RuntimeBoot } from "../src/screens/RuntimeBoot"; + +/** + * The native composition root. It is the only file that knows both which + * implementation fills each port and which app is being built; everything below + * it is `@cue/core`, unchanged, and the screens. + * + * Held from the first frame, because the boot below can change what the token + * store contains and painting onboarding before that resolves would show a + * signed-in user a sign-in screen. A rejection is swallowed: the splash module + * throws when there is no splash to hold, which is not a reason to fail a launch. + */ +void SplashScreen.preventAutoHideAsync().catch(() => {}); + +const prefsStore = createPrefsStore(preferenceStorage); +const tokenStore = createTokenStore(secureStore); +// Read at fire time rather than captured, exactly as the web build reads it. +const haptics = createNativeHaptics(() => prefsStore.getState().hapticsEnabled); +const reminders = createNativeReminders(); +const network = createNativeNetwork(); + +/** + * The purge, the migration, and only then the auth store. + * + * The order is the point: `createAuthStore` reads the persisted token the + * instant it is built, so building it before the migration has adopted a legacy + * token would drop an upgrading user onto onboarding, and building it before the + * reinstall purge would sign a reinstalling user in with a Keychain item their + * new install never wrote. + */ +function useNativeSession(): AuthStore | null { + const [authStore, setAuthStore] = useState(null); + + useEffect(() => { + let alive = true; + void bootNativeStores({ + secure: secureStore, + bulk: bulkStore, + legacy: legacyCapacitorStore, + preferences: preferenceStorage, + newInstallId: randomUUID, + }) + .catch(() => {}) + .then(() => { + if (!alive) return; + setAuthStore( + createAuthStore({ + tokenStore, + clientId: TRAKT_CLIENT_ID, + redirectUri: NATIVE_REDIRECT_URI, + // A device has no page navigation, so the two members that exist for + // one are stated rather than inherited: nothing redirects, and there + // is no handoff to stash across a navigation that never happens. + redirect: () => {}, + redirectHandoff: { read: () => null, write: () => {}, clear: () => {} }, + native: true, + traktBaseUrl: TRAKT_BASE_OVERRIDE, + }), + ); + void SplashScreen.hideAsync().catch(() => {}); + }); + return () => { + alive = false; + }; + }, []); + + return authStore; +} + +/** Everything the runtime takes that this build decides, assembled once: the + * boot component then knows only how to draw its three states. */ +const runtimeDeps = { + tokenStore, + kv: bulkStore, + redirectUri: NATIVE_REDIRECT_URI, + clientId: TRAKT_CLIENT_ID, + apiBaseUrl: TRAKT_BASE_OVERRIDE, + clearPersistedCaches, + clearLocalPreferences, +}; + +/** Until a token is stored the app is onboarding; once connected it is the + * routed shell wrapped in the authenticated runtime. */ +function Gate(): ReactElement { + const phase = useAuth((s) => s.phase); + + if (phase === "connected") { + return ( + + + + ); + } + if (phase === "loading") return ; + return ; +} -/** The root navigator. The composition root mounts here once it exists. */ export default function RootLayout(): ReactElement { - return ; + const authStore = useNativeSession(); + + // Nothing to paint until the stores are settled, and the splash is still up. + if (authStore === null) return ; + + return ( + // The metrics the native side already knows, so the first frame is the app + // rather than nothing. + + + + + + + + + + {/* Declarative, and "auto" follows the system appearance the + app config already declares. The theme store drives it + once that store has a port of its own. */} + + + + + + + + + + + + ); } diff --git a/packages/native/app/index.tsx b/packages/native/app/index.tsx index 4cb50617..e2f64e6b 100644 --- a/packages/native/app/index.tsx +++ b/packages/native/app/index.tsx @@ -1,12 +1,6 @@ import type { ReactElement } from "react"; -import { Text, View } from "react-native"; +import { UpNext } from "../src/screens/UpNext"; -/** The scaffold's one screen, so the router has a route. The composition root - * and the tab tree replace it. */ -export default function Index(): ReactElement { - return ( - - Cue - - ); +export default function UpNextRoute(): ReactElement { + return ; } diff --git a/packages/native/jest.config.js b/packages/native/jest.config.js index 4e8e9347..a8d8b92a 100644 --- a/packages/native/jest.config.js +++ b/packages/native/jest.config.js @@ -8,8 +8,15 @@ * * `testMatch` names the test files rather than the directory, so a support * module beside them is not itself run as a suite with no tests in it. + * + * `moduleNameMapper` points `vitest` at a shim over jest's own globals: the + * shared `KeyValueStore` contract lives in `@cue/core`'s test tree and is run by + * both runners rather than transcribed into a second copy. */ -const project = { testMatch: ["/__tests__/**/*.test.{ts,tsx}"] }; +const project = { + testMatch: ["/__tests__/**/*.test.{ts,tsx}"], + moduleNameMapper: { "^vitest$": "/__tests__/support/vitest.ts" }, +}; module.exports = { projects: [ diff --git a/packages/native/modules/cue-native/android/build.gradle b/packages/native/modules/cue-native/android/build.gradle new file mode 100644 index 00000000..91a0c877 --- /dev/null +++ b/packages/native/modules/cue-native/android/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'app.cuetracker.cuenative' +version = '0.1.0' + +android { + namespace "app.cuetracker.cuenative" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} diff --git a/packages/native/modules/cue-native/android/src/main/AndroidManifest.xml b/packages/native/modules/cue-native/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..bdae66c8 --- /dev/null +++ b/packages/native/modules/cue-native/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueHapticsModule.kt b/packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueHapticsModule.kt new file mode 100644 index 00000000..66fa58ab --- /dev/null +++ b/packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueHapticsModule.kt @@ -0,0 +1,89 @@ +package app.cuetracker.cuenative + +import android.os.Build +import android.view.HapticFeedbackConstants +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +/** + * Cue's haptic vocabulary on Android. + * + * The app names interactions; this answers with the action-oriented + * [HapticFeedbackConstants] for each, played through the activity's own view. + * That path is the one Android's haptics guidance recommends first: the OEM + * tunes each constant for the actuator in that phone, it honours the system + * Touch feedback setting by contract, and it needs no VIBRATE permission. Raw + * `Vibrator` waveforms are what the same guidance tells you not to use for touch + * feedback, and they are what `expo-haptics` uses on this platform. + * + * Several constants arrived after this app's minSdk, so each one below names the + * release it landed in and the older effect that stands in for it. + */ +class CueHapticsModule : Module() { + override fun definition() = ModuleDefinition { + Name("CueHaptics") + + /** CONFIRM is API 30. Below it, VIRTUAL_KEY is the platform's baseline + * "that registered" tap. */ + Function("success") { + perform( + if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM + else HapticFeedbackConstants.VIRTUAL_KEY, + ) + } + + /** REJECT is API 30, and it is the constant that signals "the + * interaction did not take". Below it, LONG_PRESS is what androidx's + * HapticFeedbackConstantsCompat documents as the same feedback. */ + Function("failure") { + perform( + if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.REJECT + else HapticFeedbackConstants.LONG_PRESS, + ) + } + + /** The gesture threshold pair is API 34. */ + Function("thresholdActivate") { + perform( + if (Build.VERSION.SDK_INT >= 34) HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE + else HapticFeedbackConstants.VIRTUAL_KEY, + ) + } + + /** Backing off the threshold should read softer than crossing it, so the + * pre-34 stand-in is CLOCK_TICK rather than VIRTUAL_KEY. */ + Function("thresholdDeactivate") { + perform( + if (Build.VERSION.SDK_INT >= 34) + HapticFeedbackConstants.GESTURE_THRESHOLD_DEACTIVATE + else HapticFeedbackConstants.CLOCK_TICK, + ) + } + + /** SEGMENT_TICK is API 34; CLOCK_TICK is the older discrete-step tick. */ + Function("selection") { + perform( + if (Build.VERSION.SDK_INT >= 34) HapticFeedbackConstants.SEGMENT_TICK + else HapticFeedbackConstants.CLOCK_TICK, + ) + } + + /** CONTEXT_CLICK is API 23, so it needs no fallback. */ + Function("contextClick") { + perform(HapticFeedbackConstants.CONTEXT_CLICK) + } + + /** iOS warms its Taptic Engine ahead of a gesture. Android's constants + * play through a per-device effect the platform already has loaded, so + * there is no latency to hide and nothing to do. */ + Function("prepare") {} + } + + /** View feedback has to be asked for on the UI thread. A haptic with no + * window to play through is a no-op rather than a crash: the app is in the + * background, which is the one case where nobody is holding the phone. */ + private fun perform(constant: Int) { + val activity = appContext.currentActivity ?: return + activity.runOnUiThread { activity.window.decorView.performHapticFeedback(constant) } + } +} diff --git a/packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueLegacyPreferencesModule.kt b/packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueLegacyPreferencesModule.kt new file mode 100644 index 00000000..27dfbaf9 --- /dev/null +++ b/packages/native/modules/cue-native/android/src/main/java/app/cuetracker/cuenative/CueLegacyPreferencesModule.kt @@ -0,0 +1,38 @@ +package app.cuetracker.cuenative + +import android.content.Context +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +/** + * Reads what the Capacitor build left in shared preferences. + * + * Capacitor Preferences namespaces by a group that defaults to + * `CapacitorStorage`, and on Android that group is the file name while the key + * is stored unprefixed. iOS prefixes the group onto the key inside + * `UserDefaults` instead. The two shapes are not the same, and a migration that + * assumes one reads nothing on the other platform, so the difference is + * expressed here rather than in shared code. + */ +class CueLegacyPreferencesModule : Module() { + private companion object { + const val GROUP = "CapacitorStorage" + } + + override fun definition() = ModuleDefinition { + Name("CueLegacyPreferences") + + AsyncFunction("read") { key: String -> + preferences().getString(key, null) + } + + AsyncFunction("remove") { key: String -> + preferences().edit().remove(key).apply() + } + } + + private fun preferences() = + (appContext.reactContext ?: throw Exceptions.ReactContextLost()) + .getSharedPreferences(GROUP, Context.MODE_PRIVATE) +} diff --git a/packages/native/modules/cue-native/expo-module.config.json b/packages/native/modules/cue-native/expo-module.config.json new file mode 100644 index 00000000..f88d3542 --- /dev/null +++ b/packages/native/modules/cue-native/expo-module.config.json @@ -0,0 +1,12 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["CueHapticsModule", "CueLegacyPreferencesModule"] + }, + "android": { + "modules": [ + "app.cuetracker.cuenative.CueHapticsModule", + "app.cuetracker.cuenative.CueLegacyPreferencesModule" + ] + } +} diff --git a/packages/native/modules/cue-native/ios/CueHapticsModule.swift b/packages/native/modules/cue-native/ios/CueHapticsModule.swift new file mode 100644 index 00000000..bcd0339d --- /dev/null +++ b/packages/native/modules/cue-native/ios/CueHapticsModule.swift @@ -0,0 +1,80 @@ +import ExpoModulesCore +import UIKit + +/// Cue's haptic vocabulary on iOS. +/// +/// The app names interactions; this picks the documented UIKit generator for +/// each, per the Human Interface Guidelines' "Playing haptics": notification +/// feedback reports the outcome of a task, impact feedback is the physical +/// metaphor for something snapping into place, and selection feedback marks +/// movement through a series of discrete values. +/// +/// Generators are built once and kept warm, which is the reason this is a local +/// module rather than `expo-haptics`: that package exposes no `prepare()`, and a +/// generator built at the instant of the event fires late enough to read as +/// belonging to some other action. +public class CueHapticsModule: Module { + private let notification = UINotificationFeedbackGenerator() + private let activate = UIImpactFeedbackGenerator(style: .medium) + private let deactivate = UIImpactFeedbackGenerator(style: .light) + private let selectionGenerator = UISelectionFeedbackGenerator() + private lazy var generators: [UIFeedbackGenerator] = [ + notification, activate, deactivate, selectionGenerator, + ] + + public func definition() -> ModuleDefinition { + Name("CueHaptics") + + Function("success") { + self.fire(self.notification) { $0.notificationOccurred(.success) } + } + + Function("failure") { + self.fire(self.notification) { $0.notificationOccurred(.error) } + } + + Function("thresholdActivate") { + self.fire(self.activate) { $0.impactOccurred() } + } + + Function("thresholdDeactivate") { + self.fire(self.deactivate) { $0.impactOccurred() } + } + + Function("selection") { + self.fire(self.selectionGenerator) { $0.selectionChanged() } + } + + // iOS has no context-click pattern of its own; Apple's own long-press + // sample fires selection feedback, so a menu opening is one of these. + Function("contextClick") { + self.fire(self.selectionGenerator) { $0.selectionChanged() } + } + + // Warm all four: a gesture that ticks at its threshold is the one that + // ends in a mark, so the notification generator needs the head start too. + Function("prepare") { + DispatchQueue.main.async { + for generator in self.generators { generator.prepare() } + } + } + } + + /// Re-prepare the generator that just fired: the engine idles again within a + /// few seconds, and a threshold pair or a run of marks usually wants a + /// second tap right after the first. + /// + /// Dispatched from a synchronous `Function` rather than declared as an + /// `AsyncFunction`: UIKit feedback has to be asked for on the main thread, + /// and a haptic is fire and forget, so the caller should not be handed a + /// promise it would only ever discard. + private func fire( + _ generator: Generator, + _ play: @escaping (Generator) -> Void + ) { + DispatchQueue.main.async { + play(generator) + generator.prepare() + } + } +} diff --git a/packages/native/modules/cue-native/ios/CueLegacyPreferencesModule.swift b/packages/native/modules/cue-native/ios/CueLegacyPreferencesModule.swift new file mode 100644 index 00000000..09c58555 --- /dev/null +++ b/packages/native/modules/cue-native/ios/CueLegacyPreferencesModule.swift @@ -0,0 +1,29 @@ +import ExpoModulesCore + +/// Reads what the Capacitor build left in `UserDefaults`. +/// +/// Capacitor Preferences namespaces by a group that defaults to +/// `CapacitorStorage`, and on iOS that group plus a dot is prefixed onto every +/// key, so the stored name is `CapacitorStorage.cue.trakt.token`. Android puts +/// the group in the file name and leaves the key alone. The two shapes are not +/// the same, and a migration that assumes one reads nothing on the other +/// platform, so the prefix is applied here rather than in shared code. +/// +/// React Native's own `Settings` module wraps `NSUserDefaults` and is documented +/// as iOS-only, which would cover half the job; Android needs a native module +/// regardless, so both platforms go through this one. +public class CueLegacyPreferencesModule: Module { + private static let prefix = "CapacitorStorage." + + public func definition() -> ModuleDefinition { + Name("CueLegacyPreferences") + + AsyncFunction("read") { (key: String) -> String? in + UserDefaults.standard.string(forKey: Self.prefix + key) + } + + AsyncFunction("remove") { (key: String) in + UserDefaults.standard.removeObject(forKey: Self.prefix + key) + } + } +} diff --git a/packages/native/modules/cue-native/ios/CueNative.podspec b/packages/native/modules/cue-native/ios/CueNative.podspec new file mode 100644 index 00000000..98e8c6a2 --- /dev/null +++ b/packages/native/modules/cue-native/ios/CueNative.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'CueNative' + s.version = '1.0.0' + s.summary = "Cue's native seams" + s.description = 'The seven-verb haptic vocabulary and the Capacitor preference reader the first-launch migration needs.' + s.author = 'Cue' + s.homepage = 'https://github.com/arun279/cue' + s.platforms = { + :ios => '16.4', + :tvos => '16.4' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/packages/native/modules/cue-native/src/index.ts b/packages/native/modules/cue-native/src/index.ts new file mode 100644 index 00000000..5b405dd7 --- /dev/null +++ b/packages/native/modules/cue-native/src/index.ts @@ -0,0 +1,32 @@ +import { NativeModule, requireNativeModule } from "expo"; + +/** + * The two native classes this module ships, and the whole of its JavaScript + * surface. One module package rather than two, because an Expo local module is a + * native compilation unit: splitting it would double the config, the podspec and + * the Gradle file to separate two Swift classes. + */ + +declare class CueHapticsNativeModule extends NativeModule> { + success(): void; + failure(): void; + thresholdActivate(): void; + thresholdDeactivate(): void; + selection(): void; + contextClick(): void; + prepare(): void; +} + +declare class CueLegacyPreferencesNativeModule extends NativeModule> { + read(key: string): Promise; + remove(key: string): Promise; +} + +/** The seven verbs, fired and forgotten: every one dispatches to the platform's + * UI thread and returns nothing, so no caller ever has a promise to discard. */ +export const CueHaptics = requireNativeModule("CueHaptics"); + +/** Capacitor Preferences, read through the key shape each platform actually + * used. Migration only. */ +export const CueLegacyPreferences = + requireNativeModule("CueLegacyPreferences"); diff --git a/packages/native/package.json b/packages/native/package.json index 020ef41d..5e529124 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -8,12 +8,20 @@ "start": "expo start", "prebuild": "expo prebuild", "typecheck": "expo customize tsconfig.json && tsc --noEmit", - "test": "jest" + "test": "EXPO_PUBLIC_TRAKT_CLIENT_ID=test-client jest" }, "dependencies": { + "@cue/core": "workspace:*", + "@tanstack/query-async-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", + "base64-js": "^1.5.1", "expo": "57.0.15", + "expo-application": "~57.0.2", "expo-constants": "~57.0.13", + "expo-crypto": "~57.0.1", "expo-linking": "~57.0.7", + "expo-network": "~57.0.1", "expo-notifications": "~57.0.13", "expo-router": "~57.0.15", "expo-secure-store": "~57.0.1", @@ -30,6 +38,7 @@ "react-native-worklets": "0.10.1" }, "devDependencies": { + "@testing-library/react-native": "^14.0.1", "@types/jest": "29.5.14", "@types/react": "^19.2.17", "babel-preset-expo": "~57.0.7", diff --git a/packages/native/src/boot.ts b/packages/native/src/boot.ts new file mode 100644 index 00000000..0f6f88a2 --- /dev/null +++ b/packages/native/src/boot.ts @@ -0,0 +1,63 @@ +import { + type LegacyMigrationResult, + migrateLegacyCapacitorData, +} from "@cue/core/migration/legacy-capacitor"; +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { LegacyStore } from "@cue/core/ports/legacy-store"; +import type { PreferenceStorage } from "@cue/core/ports/preference-storage"; +import { createTokenStore } from "@cue/core/ports/token-store"; +import { installWebCrypto } from "./crypto"; + +/** The marker that says this install has launched before. It lives in the bulk + * store, which is the one an uninstall clears, and under `cue.` rather than + * `pref.` so a sign-out's preference clear cannot reach it. */ +const INSTALL_MARKER_KEY = "cue.install-id"; + +export interface NativeBootDeps { + readonly secure: KeyValueStore; + readonly bulk: KeyValueStore; + readonly legacy: LegacyStore; + readonly preferences: PreferenceStorage; + readonly newInstallId: () => string; +} + +export interface NativeBootResult { + /** True when this launch was the first of a fresh install and the Keychain was + * purged. */ + readonly purged: boolean; + readonly migration: LegacyMigrationResult; +} + +/** + * Everything that has to happen before the runtime is built, in order, because + * both steps can change what the token store contains. + * + * 1. **The reinstall purge.** Expo documents that data stored with + * `expo-secure-store` survives an uninstall when the app is reinstalled with + * the same bundle id on iOS, while on Android it does not. Without this, a + * user who deletes Cue and reinstalls it comes back apparently signed in, + * with a stale token and none of their local state. An empty bulk store is + * what a fresh install looks like, so the absence of the marker is the + * signal, and the marker has to live there rather than in the Keychain for + * exactly the same reason. + * 2. **The Capacitor migration.** Pure, over the legacy port, so every branch of + * it is a unit test rather than a device session. + */ +export async function bootNativeStores(deps: NativeBootDeps): Promise { + installWebCrypto(globalThis as unknown as Record); + + const purged = (await deps.bulk.read(INSTALL_MARKER_KEY)) === null; + if (purged) { + await createTokenStore(deps.secure).clear(); + await deps.bulk.write(INSTALL_MARKER_KEY, deps.newInstallId()); + } + + const migration = await migrateLegacyCapacitorData({ + legacy: deps.legacy, + tokenStore: createTokenStore(deps.secure), + bulk: deps.bulk, + preferences: deps.preferences, + }); + + return { purged, migration }; +} diff --git a/packages/native/src/config.ts b/packages/native/src/config.ts new file mode 100644 index 00000000..7d2e96b4 --- /dev/null +++ b/packages/native/src/config.ts @@ -0,0 +1,45 @@ +/** + * Build-time app configuration. Cue is a public OAuth client: its Trakt + * `client_id` is embedded once by the app author and is PUBLIC, it ships in the + * bundle and travels in plaintext in every device-code request, and it carries + * no secret because PKCE proves possession per attempt. Each user then + * authorizes their OWN Trakt account; they never supply a client id. + * + * `babel-preset-expo` replaces each `process.env.EXPO_PUBLIC_*` read below with + * its value at bundle time, so these are literals in the shipped JavaScript and + * nothing reads an environment at runtime. + */ + +const readEnv = (value: string | undefined): string => value?.trim() ?? ""; + +export const TRAKT_CLIENT_ID: string = readEnv(process.env["EXPO_PUBLIC_TRAKT_CLIENT_ID"]); + +if (TRAKT_CLIENT_ID === "") { + throw new Error( + "EXPO_PUBLIC_TRAKT_CLIENT_ID is not set. Set it to your Trakt app's public client id " + + "(register one at https://trakt.tv/oauth/applications) before building.", + ); +} + +/** + * Optional Trakt origin override, for pointing a build at the local fake Trakt + * instead of a real account. Read only in a development build, which is the + * native counterpart of the web app's `--mode mock` gate and holds the same + * invariant: a stray env file or an exported shell variable cannot redirect a + * build meant for a real account, because a release bundle compiles this branch + * out entirely. + */ +const traktBase = __DEV__ ? readEnv(process.env["EXPO_PUBLIC_TRAKT_API_BASE"]) : ""; +export const TRAKT_BASE_OVERRIDE: string | undefined = traktBase === "" ? undefined : traktBase; + +/** + * The redirect URI the token grants echo back. + * + * The device-code grant does not send it, so first sign-in works without it; + * the refresh grant does, on every refresh, and Trakt requires it to match the + * registration exactly. Access tokens last seven days, so a wrong value here is + * discovered by every user at once a week after a release rather than by CI. + * It must be registered on the Trakt application, and verified by refreshing a + * real token from a real build, before the first TestFlight group. + */ +export const NATIVE_REDIRECT_URI = "cue://auth/callback"; diff --git a/packages/native/src/crypto.ts b/packages/native/src/crypto.ts new file mode 100644 index 00000000..940d1580 --- /dev/null +++ b/packages/native/src/crypto.ts @@ -0,0 +1,40 @@ +import { fromByteArray } from "base64-js"; +import { type CryptoDigestAlgorithm, digest, getRandomValues, randomUUID } from "expo-crypto"; + +/** + * The Web Crypto surface `@cue/core` expects, on an engine that has none of it. + * + * `data/auth/pkce.ts` reads `crypto.getRandomValues`, `crypto.subtle.digest` and + * `btoa`, and `auth/create-auth-store.ts` reads `crypto.randomUUID`. All four + * are browser globals with no Hermes equivalent. Rather than fork the core for + * one platform, the platform is given the globals the core was written against: + * that is what keeps the OAuth implementation one file both targets run. + * + * A dedicated module rather than a React component, because installing globals + * from a render is a side effect at a moment nothing controls; and a plain + * function rather than an import-order side effect, because organize-imports is + * free to move a bare import and nothing would notice until first sign-in. + * + * Nothing already present is replaced. Hermes and Expo's own runtime keep + * gaining standard globals, and the day one of these arrives for real the + * platform's implementation is the one that should win. + * + * `TextEncoder` is deliberately not shimmed. `pkce.ts` uses it too, but a + * hand-written UTF-8 encoder is exactly the kind of code that is right until it + * meets a non-latin1 input, and there is nothing here worth being wrong about + * quietly: if an engine ever lacks it, the failure should be a crash at the + * first sign-in rather than a challenge that hashes the wrong bytes. + */ +export function installWebCrypto(target: Record): void { + target["btoa"] ??= (binary: string): string => + fromByteArray(Uint8Array.from(binary, (character) => character.charCodeAt(0))); + + const crypto = (target["crypto"] ?? {}) as Record; + crypto["getRandomValues"] ??= getRandomValues; + crypto["randomUUID"] ??= randomUUID; + crypto["subtle"] ??= {}; + const subtle = crypto["subtle"] as Record; + subtle["digest"] ??= (algorithm: string, data: BufferSource): Promise => + digest(algorithm as CryptoDigestAlgorithm, data); + target["crypto"] ??= crypto; +} diff --git a/packages/native/src/platform/app-version.ts b/packages/native/src/platform/app-version.ts new file mode 100644 index 00000000..bc982dd7 --- /dev/null +++ b/packages/native/src/platform/app-version.ts @@ -0,0 +1,15 @@ +import { nativeApplicationVersion, nativeBuildVersion } from "expo-application"; + +/** + * The app-store-facing identity Settings renders: the marketing version and the + * build number stores show. iOS sources both from `Info.plist`, Android from + * `BuildConfig`, and prebuild writes both from `app.config.ts`, so this is the + * far end of the same two environment variables the release lane sets. + * + * Constants rather than a call, and null-tolerant because they are only null + * where there is no native shell to read. + */ +export const nativeAppVersion = + nativeApplicationVersion === null || nativeBuildVersion === null + ? "Unknown" + : `${nativeApplicationVersion} (${nativeBuildVersion})`; diff --git a/packages/native/src/platform/app-visibility.ts b/packages/native/src/platform/app-visibility.ts new file mode 100644 index 00000000..01506bce --- /dev/null +++ b/packages/native/src/platform/app-visibility.ts @@ -0,0 +1,16 @@ +import type { AppVisibility } from "@cue/core/ports/app-visibility"; +import { AppState } from "react-native"; + +/** + * `AppState` is the native answer to the Page Visibility API the web app reads. + * Only "active" counts as in front of the user: "inactive" is the iOS state + * during a call banner, the app switcher or a system prompt, where nothing is + * being read and the freshness poll should not spend Trakt's budget. + */ +export const nativeAppVisibility: AppVisibility = { + isVisible: () => AppState.currentState === "active", + subscribe: (listener) => { + const subscription = AppState.addEventListener("change", listener); + return () => subscription.remove(); + }, +}; diff --git a/packages/native/src/platform/haptics.ts b/packages/native/src/platform/haptics.ts new file mode 100644 index 00000000..162d8f70 --- /dev/null +++ b/packages/native/src/platform/haptics.ts @@ -0,0 +1,34 @@ +import type { Haptics } from "@cue/core/ports/haptics"; +import { CueHaptics } from "../../modules/cue-native/src"; + +/** + * The seven verbs, over the local module. + * + * `expo-haptics` is not a dependency of this app and cannot be: it exposes no + * `prepare()`, so every generator is cold on first fire and a swipe threshold + * tick lands late enough to read as unrelated to the gesture; and its Android + * side funnels everything into a raw `Vibrator` waveform, which is what + * Android's own haptics guidance tells you not to use for touch feedback. + * + * The Settings toggle is read at fire time rather than captured, exactly as the + * web build reads it, so turning haptics off takes effect on the next tap + * instead of the next launch. Both platforms honour their own system haptics + * setting underneath, and nothing here second-guesses that. + */ +export function createNativeHaptics(isEnabled: () => boolean): Haptics { + const fire = + (verb: () => void): (() => void) => + () => { + if (isEnabled()) verb(); + }; + + return { + success: fire(() => CueHaptics.success()), + failure: fire(() => CueHaptics.failure()), + thresholdActivate: fire(() => CueHaptics.thresholdActivate()), + thresholdDeactivate: fire(() => CueHaptics.thresholdDeactivate()), + selection: fire(() => CueHaptics.selection()), + contextClick: fire(() => CueHaptics.contextClick()), + prepare: fire(() => CueHaptics.prepare()), + }; +} diff --git a/packages/native/src/platform/legacy-store.ts b/packages/native/src/platform/legacy-store.ts new file mode 100644 index 00000000..23a2639f --- /dev/null +++ b/packages/native/src/platform/legacy-store.ts @@ -0,0 +1,9 @@ +import type { LegacyStore } from "@cue/core/ports/legacy-store"; +import { CueLegacyPreferences } from "../../modules/cue-native/src"; + +/** Capacitor Preferences, through the local module that knows each platform's + * key shape. Migration only, and it never writes. */ +export const legacyCapacitorStore: LegacyStore = { + read: (key) => CueLegacyPreferences.read(key), + remove: (key) => CueLegacyPreferences.remove(key), +}; diff --git a/packages/native/src/platform/network.ts b/packages/native/src/platform/network.ts new file mode 100644 index 00000000..1e3752e8 --- /dev/null +++ b/packages/native/src/platform/network.ts @@ -0,0 +1,39 @@ +import type { Network } from "@cue/core/ports/network"; +import { addNetworkStateListener, getNetworkStateAsync } from "expo-network"; + +/** + * `expo-network`'s state is asynchronous and the port is not, because the port's + * consumers ask during a render. So the last state the OS reported is kept here + * and the subscription keeps it current, with the optimistic default the web + * side also takes: both halves are advisory on every platform, a reachable radio + * is not a reachable Trakt, and this drives what the UI says and when a deferred + * write is retried, never whether a request is attempted. + */ +export function createNativeNetwork(): Network { + let online = true; + const listeners = new Set<() => void>(); + + const apply = (connected: boolean | undefined): void => { + const next = connected !== false; + if (next === online) return; + online = next; + for (const listener of listeners) listener(); + }; + + // A failed first read leaves the optimistic default in place, which is the + // same answer this port gives before the OS has said anything. + void getNetworkStateAsync() + .then((state) => apply(state.isConnected)) + .catch(() => {}); + addNetworkStateListener((state) => apply(state.isConnected)); + + return { + isOnline: () => online, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/native/src/platform/query-persister.ts b/packages/native/src/platform/query-persister.ts new file mode 100644 index 00000000..2e2e3b3c --- /dev/null +++ b/packages/native/src/platform/query-persister.ts @@ -0,0 +1,24 @@ +import { createQueryCachePolicy, createQueryClient } from "@cue/core/runtime/query-cache"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; +import Storage from "expo-sqlite/kv-store"; + +export const queryClient = createQueryClient(); + +export const { shouldDehydrateQuery } = createQueryCachePolicy(queryClient); + +/** + * The same persister the web build uses, over the bulk store instead of + * idb-keyval. Its `storage` argument wants `getItem`, `setItem` and `removeItem` + * returning a value or a promise, which is exactly what `expo-sqlite/kv-store` + * is. + */ +export const queryPersister = createAsyncStoragePersister({ + key: "cue.query-cache", + storage: Storage, +}); + +/** The one teardown dependency the runtime takes, in place of both objects. */ +export const clearPersistedCaches = async (): Promise => { + queryClient.clear(); + await queryPersister.removeClient(); +}; diff --git a/packages/native/src/platform/reminders.ts b/packages/native/src/platform/reminders.ts new file mode 100644 index 00000000..db9d0d3f --- /dev/null +++ b/packages/native/src/platform/reminders.ts @@ -0,0 +1,106 @@ +import { + diffReminders, + type PendingReminder, + type PlannedReminder, +} from "@cue/core/domain/reminders"; +import { neverRejects, type Reminders } from "@cue/core/ports/reminders"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +/** + * Android 8+ drops any notification posted without a channel, and a channel's + * behavior is fixed at creation, so this one exists to be the single thing the + * user can silence without turning Cue's notifications off wholesale. It also + * has to exist before the runtime permission is asked for, or the OS prompt + * never appears. + */ +const CHANNEL_ID = "cue-airing-today"; + +/** Collects the digests under one heading in iOS Notification Center. Each one + * still arrives as its own banner: threading groups what is already delivered. */ +const THREAD_ID = CHANNEL_ID; + +function toPending(request: Notifications.NotificationRequest): PendingReminder { + const fingerprint = request.content.data?.["fingerprint"]; + return { + id: Number(request.identifier), + fingerprint: typeof fingerprint === "string" ? fingerprint : null, + }; +} + +/** + * The notification seam on a device: the permission ask, the Android channel, + * and the reconcile that makes the OS hold exactly the plan the domain produced. + * + * Every reminder is scheduled inexactly, and that is a property of the app's + * manifest rather than of this call. `expo-notifications` asks + * `AlarmManager.canScheduleExactAlarms()` and takes `setExactAndAllowWhileIdle` + * when it can and `setAndAllowWhileIdle` when it cannot + * (`ExpoSchedulingDelegate.kt:106-120`), and `app.config.ts` blocks + * `SCHEDULE_EXACT_ALARM`, so the second branch is the only one this app takes. + * That is the answer Android's own guidance prescribes for a user-specified time + * that may fall during Doze, it keeps the "Alarms & reminders" screen out of the + * first-run experience, and a morning digest does not care about a quarter hour. + * + * The identifier is the planner's own day id as a string, so a replan addresses + * the same notification the last one scheduled rather than cancelling the world. + */ +export function createNativeReminders(): Reminders { + let channelReady = false; + + const ensureChannel = async (): Promise => { + if (Platform.OS !== "android" || channelReady) return; + await Notifications.setNotificationChannelAsync(CHANNEL_ID, { + name: "Airing today", + description: "One notification each morning naming what airs that day.", + // Makes a sound; does not barge in as a heads-up. A digest about the + // evening is not urgent. + importance: Notifications.AndroidImportance.DEFAULT, + }); + channelReady = true; + }; + + const grant = async (): Promise => { + // The channel first: on Android 13 and later the POST_NOTIFICATIONS prompt + // does not appear at all until one exists. + await ensureChannel(); + const { granted } = await Notifications.requestPermissionsAsync(); + return granted; + }; + + const apply = async (planned: readonly PlannedReminder[]): Promise => { + // Checked rather than requested, so the prompt can only ever come from the + // Settings toggle, and a later revoke in system settings simply stops the + // scheduling. + const { granted } = await Notifications.getPermissionsAsync(); + if (!granted) return; + await ensureChannel(); + const pending = await Notifications.getAllScheduledNotificationsAsync(); + const { cancel, schedule } = diffReminders(planned, pending.map(toPending)); + for (const id of cancel) { + await Notifications.cancelScheduledNotificationAsync(String(id)); + } + for (const reminder of schedule) { + await Notifications.scheduleNotificationAsync({ + identifier: String(reminder.id), + content: { + title: reminder.title, + body: reminder.body, + data: { fingerprint: reminder.fingerprint }, + ...(Platform.OS === "ios" ? { threadIdentifier: THREAD_ID } : {}), + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.DATE, + date: new Date(reminder.atMs), + channelId: CHANNEL_ID, + }, + }); + } + }; + + return neverRejects({ + requestPermission: grant, + reconcile: apply, + cancelAll: () => Notifications.cancelAllScheduledNotificationsAsync(), + }); +} diff --git a/packages/native/src/platform/stores.ts b/packages/native/src/platform/stores.ts new file mode 100644 index 00000000..51300267 --- /dev/null +++ b/packages/native/src/platform/stores.ts @@ -0,0 +1,80 @@ +import type { KeyValueStore } from "@cue/core/ports/kv"; +import type { PreferenceStorage } from "@cue/core/ports/preference-storage"; +import * as SecureStore from "expo-secure-store"; +import Storage from "expo-sqlite/kv-store"; + +/** + * The token, and nothing else. + * + * `WHEN_UNLOCKED_THIS_DEVICE_ONLY` maps to + * `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, which is what closes the iOS + * half of the standing storage caveat: the item never enters an iCloud or + * iTunes backup, where Capacitor Preferences (UserDefaults) always did. Keys are + * restricted to alphanumerics, `.`, `-` and `_`, so the app's dotted names pass + * through unchanged. + * + * The Keychain must not become the store for anything else. It is not a bulk + * store, and its survival across an uninstall on iOS is precisely the property a + * cached op log must not have, which is what the reinstall purge in `boot.ts` is + * for. + */ +const SECURE: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, +}; + +export const secureStore: KeyValueStore = { + read: (key) => SecureStore.getItemAsync(key, SECURE), + write: (key, value) => SecureStore.setItemAsync(key, value, SECURE), + remove: (key) => SecureStore.deleteItemAsync(key, SECURE), +}; + +/** + * Everything durable that is not the token: the op log, the freshness baseline, + * the persisted query cache, the preferences and the install marker. + * + * `expo-sqlite/kv-store` rather than AsyncStorage or MMKV. Expo ships it and + * documents it as a drop-in replacement for AsyncStorage, whose current major + * does not work on this SDK; MMKV is a third-party native module its own + * maintainer marks unsupported in Expo Go, and it buys speed a query cache + * throttled at one write per second does not need. What it uniquely gives is + * genuinely synchronous reads, which is what `PreferenceStorage` below is for. + */ +export const bulkStore: KeyValueStore = { + read: (key) => Storage.getItem(key), + write: (key, value) => Storage.setItem(key, value), + remove: (key) => Storage.removeItem(key), +}; + +/** + * Preferences are read at module scope, before the first render, so the theme is + * applied without a flash. `getItemSync` and `setItemSync` are backed by + * `db.getFirstSync` and `db.runSync` rather than by a promise wrapped around an + * async call, which is the specific reason this store wins the port. + * + * The `pref.` prefix is load-bearing. On the web the namespaces are physically + * separate, preferences in `localStorage` and the durable state in IndexedDB; + * here they share one database, and every durable key is `cue.`-prefixed, so a + * `cue.` clear at sign-out would take the write queue, the freshness baseline + * and the install marker with the preferences. Losing the install marker is the + * worst of those: the next launch would look like a fresh install to the + * reinstall purge, which would then clear the token. + */ +const PREFIX = "pref."; + +export const preferenceStorage: PreferenceStorage = { + getItem: (key) => Storage.getItemSync(PREFIX + key), + setItem: (key, value) => { + Storage.setItemSync(PREFIX + key, value); + }, + clearNamespace: (prefix) => { + for (const key of Storage.getAllKeysSync()) { + if (key.startsWith(PREFIX + prefix)) Storage.removeItemSync(key); + } + }, +}; + +/** What sign-out drops: this device's preferences, and nothing account-agnostic + * that lives beside them. */ +export const clearLocalPreferences = (): void => { + preferenceStorage.clearNamespace("cue."); +}; diff --git a/packages/native/src/screens/Onboarding.tsx b/packages/native/src/screens/Onboarding.tsx new file mode 100644 index 00000000..9eee8e68 --- /dev/null +++ b/packages/native/src/screens/Onboarding.tsx @@ -0,0 +1,60 @@ +import { useAuth } from "@cue/core/auth/store"; +import type { ReactElement } from "react"; +import { Linking, Pressable, Text } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +/** + * The device-code sign-in. Unstyled: the visual layer is its own piece of work, + * and what this has to be right about first is the flow. + * + * The activation URL is rendered beside the code, which the spike did not do. + * `verificationUrl` is already computed by the auth store and was thrown away; + * a device flow that shows a code with no destination is not usable against + * real Trakt. + */ +export function Onboarding(): ReactElement { + const status = useAuth((s) => s.connectStatus); + const deviceCode = useAuth((s) => s.deviceCode); + const errorMessage = useAuth((s) => s.errorMessage); + const connect = useAuth((s) => s.connectWithDeviceCode); + const cancel = useAuth((s) => s.cancelConnect); + + if (deviceCode !== null) { + return ( + + Connect to Trakt + Open this page and enter the code: + void Linking.openURL(deviceCode.verificationUrl)} + > + {deviceCode.verificationUrl} + + + {deviceCode.userCode} + + + Cancel + + + ); + } + + return ( + + Cue + Your Up Next queue, from your Trakt account. + {errorMessage !== null && {errorMessage}} + void connect()} + > + {status === "connecting" ? "Connecting…" : "Connect to Trakt"} + + + ); +} diff --git a/packages/native/src/screens/RuntimeBoot.tsx b/packages/native/src/screens/RuntimeBoot.tsx new file mode 100644 index 00000000..8be1af9d --- /dev/null +++ b/packages/native/src/screens/RuntimeBoot.tsx @@ -0,0 +1,54 @@ +import { type RuntimeBootDeps, useRuntimeBoot } from "@cue/core/app/boot"; +import { useAuth } from "@cue/core/auth/store"; +import { RuntimeProvider } from "@cue/core/runtime/runtime"; +import type { ReactElement, ReactNode } from "react"; +import { Pressable, Text } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export interface RuntimeBootProps { + /** Everything the runtime needs except the token, which boot reads for itself, + * and the dead-token exit, which belongs to the auth store and is read here. */ + readonly deps: Omit; + readonly children: ReactNode; +} + +/** + * The native app's three boot surfaces over the shared boot effect: loading, a + * retryable failure, and the runtime handed to the tree through context. + * + * The effect itself, which reads the token, restores and replays the durable + * write queue and registers the teardown, is `@cue/core/app/boot` and is the + * same on both targets; only these three renders differ. What must not be lost + * in that split is the behavior behind it: a failed startup reconcile has to + * reach a visible retry rather than a stuck spinner. + * + * The dependencies arrive assembled, from the composition root that already + * knows which build this is and where the cache lives, so this file knows only + * how to draw three states. + */ +export function RuntimeBoot({ deps, children }: RuntimeBootProps): ReactElement { + // A dead refresh token routes through the auth store's teardown to onboarding. + const endSession = useAuth((s) => s.endSession); + const { runtime, failed, retry } = useRuntimeBoot({ ...deps, endSession }); + + if (failed && runtime === null) { + return ( + + Couldn't start Cue. + + Retry + + + ); + } + + if (runtime === null) { + return ( + + Loading your queue… + + ); + } + + return {children}; +} diff --git a/packages/native/src/screens/UpNext.tsx b/packages/native/src/screens/UpNext.tsx new file mode 100644 index 00000000..270ba17c --- /dev/null +++ b/packages/native/src/screens/UpNext.tsx @@ -0,0 +1,45 @@ +import { epCode } from "@cue/core/domain/model/library"; +import { useMarkWatched } from "@cue/core/hooks/useMarkWatched"; +import { useUpNext } from "@cue/core/hooks/useUpNext"; +import type { ReactElement } from "react"; +import { FlatList, Pressable, Text, View } from "react-native"; + +/** + * Up Next, over the shared hook and nothing else. Unstyled on purpose: the + * visual layer is its own piece of work, and what this has to prove first is + * that the whole read path, the persisted cache, the write queue and the mark + * surfaces already work on this target without a line of new logic. + */ +export function UpNext(): ReactElement { + const { queue, isLoading, isError, hasData } = useUpNext(); + const marking = useMarkWatched(); + + if (isLoading) return Loading your queue…; + if (isError && !hasData) return Couldn't load your queue.; + + return ( + + Up Next + String(card.entry.showId)} + ListEmptyComponent={Nothing queued.} + renderItem={({ item: card }) => ( + + {card.entry.title} + {epCode(card.item.episode.season, card.item.episode.number)} + void marking.mark(card.entry)} + > + Mark watched + + + )} + /> + + ); +} diff --git a/packages/web/src/platform/reminders.ts b/packages/web/src/platform/reminders.ts index fe51f3d5..306362bb 100644 --- a/packages/web/src/platform/reminders.ts +++ b/packages/web/src/platform/reminders.ts @@ -5,7 +5,7 @@ import { type PendingReminder, type PlannedReminder, } from "@cue/core/domain/reminders"; -import { type Reminders, SILENT } from "@cue/core/ports/reminders"; +import { neverRejects, type Reminders, SILENT } from "@cue/core/ports/reminders"; import { isNativePlatform } from "./platform"; /** @@ -97,13 +97,9 @@ export function createNativeReminders(): Reminders { } }; - // A plugin rejection is swallowed here, the way the haptics seam swallows one: - // a missing plugin, a permission revoked mid-call or an OS refusal cannot be - // recovered from at this seam, and an unhandled rejection in the shell is - // worse than the honest fallback (nothing granted, nothing scheduled). - return { - requestPermission: () => grant().catch(() => false), - reconcile: (planned) => apply(planned).catch(() => {}), - cancelAll: () => LocalNotifications.cancelAll().catch(() => {}), - }; + return neverRejects({ + requestPermission: grant, + reconcile: apply, + cancelAll: () => LocalNotifications.cancelAll(), + }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9fc0fe5..102475c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,15 +102,39 @@ importers: packages/native: dependencies: + '@cue/core': + specifier: workspace:* + version: link:../core + '@tanstack/query-async-storage-persister': + specifier: 5.101.2 + version: 5.101.2 + '@tanstack/react-query': + specifier: 5.101.2 + version: 5.101.2(react@19.2.7) + '@tanstack/react-query-persist-client': + specifier: 5.101.2 + version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) + base64-js: + specifier: ^1.5.1 + version: 1.5.1 expo: specifier: 57.0.15 version: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-application: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.15) expo-constants: specifier: ~57.0.13 version: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)) + expo-crypto: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.15) expo-linking: specifier: ~57.0.7 version: 57.0.7(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + expo-network: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.15)(react@19.2.7) expo-notifications: specifier: ~57.0.13 version: 57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) @@ -154,6 +178,9 @@ importers: specifier: 0.10.1 version: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) devDependencies: + '@testing-library/react-native': + specifier: ^14.0.1 + version: 14.0.1(jest@29.7.0(@types/node@22.20.0))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(test-renderer@1.2.0(@types/react@19.2.17)(react@19.2.7)) '@types/jest': specifier: 29.5.14 version: 29.5.14 @@ -4554,6 +4581,11 @@ packages: expo: '*' react-native: '*' + expo-crypto@57.0.1: + resolution: {integrity: sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==} + peerDependencies: + expo: '*' + expo-file-system@57.0.5: resolution: {integrity: sha512-XjGrCClF0935y5wLB9qNQQUVptNEy+0OloBstXlCd+IeNXLo3uNOod6cX4N0hofIhNIrN1Al8fwfay7R0Z1a2A==} peerDependencies: @@ -4605,6 +4637,12 @@ packages: peerDependencies: react-native: '*' + expo-network@57.0.1: + resolution: {integrity: sha512-ndg+FbDDlz6XTpQ6aVuVgyvrYQwMkpcUAvZIXbwrGBbLTSWNzYC/gawYu1BAeDN7O6JTGY98GBVJBov/JH7LgQ==} + peerDependencies: + expo: '*' + react: '*' + expo-notifications@57.0.13: resolution: {integrity: sha512-4kTLfvCbpr92RWWmYGO1BP4pJ5ZhxOdhB/6zdtNs44wV7h6zKrbweccwovgZTLo4Ef/KPL3SD02Vam4Tl/xv9A==} peerDependencies: @@ -9347,8 +9385,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 - '@jest/diff-sequences@30.4.0': - optional: true + '@jest/diff-sequences@30.4.0': {} '@jest/environment@29.7.0': dependencies: @@ -9377,8 +9414,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - '@jest/get-type@30.1.0': - optional: true + '@jest/get-type@30.1.0': {} '@jest/globals@29.7.0': dependencies: @@ -9425,7 +9461,6 @@ snapshots: '@jest/schemas@30.4.1': dependencies: '@sinclair/typebox': 0.34.52 - optional: true '@jest/source-map@29.6.3': dependencies: @@ -10737,8 +10772,7 @@ snapshots: '@sinclair/typebox@0.27.12': {} - '@sinclair/typebox@0.34.52': - optional: true + '@sinclair/typebox@0.34.52': {} '@sinonjs/commons@3.0.1': dependencies: @@ -10955,7 +10989,6 @@ snapshots: test-renderer: 1.2.0(@types/react@19.2.17)(react@19.2.7) optionalDependencies: jest: 29.7.0(@types/node@22.20.0) - optional: true '@tootallnate/once@2.0.1': {} @@ -11045,7 +11078,6 @@ snapshots: '@types/react-reconciler@0.33.0(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 - optional: true '@types/react-test-renderer@19.1.0': dependencies: @@ -12204,6 +12236,10 @@ snapshots: transitivePeerDependencies: - supports-color + expo-crypto@57.0.1(expo@57.0.15): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + expo-file-system@57.0.5(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7)): dependencies: expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) @@ -12261,6 +12297,11 @@ snapshots: dependencies: react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7) + expo-network@57.0.1(expo@57.0.15)(react@19.2.7): + dependencies: + expo: 57.0.15(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.12)(expo-router@57.0.15)(react-dom@19.2.7(react@19.2.7))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + expo-notifications@57.0.13(expo@57.0.15)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.7))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.11.4(typescript@6.0.3) @@ -13081,7 +13122,6 @@ snapshots: '@jest/get-type': 30.1.0 chalk: 4.1.2 pretty-format: 30.4.1 - optional: true jest-docblock@29.7.0: dependencies: @@ -13182,7 +13222,6 @@ snapshots: chalk: 4.1.2 jest-diff: 30.4.1 pretty-format: 30.4.1 - optional: true jest-message-util@29.7.0: dependencies: @@ -14196,7 +14235,6 @@ snapshots: ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 react-is-19: react-is@19.2.8 - optional: true proc-log@4.2.0: {} @@ -14432,7 +14470,6 @@ snapshots: dependencies: react: 19.2.7 scheduler: 0.27.0 - optional: true react-refresh@0.14.2: {} @@ -15043,7 +15080,6 @@ snapshots: react-reconciler: 0.33.0(react@19.2.7) transitivePeerDependencies: - '@types/react' - optional: true throat@5.0.0: {} From 5b9f03084da0b928bb4a8a6fadb46a4f25ba2d6a Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sun, 23 Aug 2026 01:03:03 -0500 Subject: [PATCH 025/435] The expo-router tree: four native tabs, a stack per tab, the account modal Four tabs, fixed at four for every user, on four distinct paths. Fixed, because media visibility is a reversible preference two screens away in Settings and a navigation bar that changes item count when a switch flips is the least predictable thing a shell can do: Apple's tab-bar guidance names the case outright and Material's navigation bar says destinations do not change, so a movies-only user keeps four tabs and the two with nothing in them say why. Distinct paths, because the native-tabs navigator does not forward `initialRouteName`, so four groups all serving `/` land on whichever is alphabetically first; a path per tab works around that and makes every tab deep-linkable besides. Show detail, movie detail and the episode sheet are written once, in an array-group directory that expo-router duplicates into each of the four groups. The tab bar stays visible on push and each tab keeps its own back stack, which is what a `UINavigationController` inside a tab does natively and what the per-tab duplication would otherwise cost. Two facts about that shape were found by running it rather than by reading about it, and both are recorded where they bite. `unstable_settings`' per-group keys carry no parentheses, because `matchLastGroupName` strips them before the lookup, and with a parenthesised key every group silently falls back to the first route in the tree: four tab stacks opening on `show/[showId]` with no id, four GETs for `/shows/NaN` on every launch, and nothing on screen to show for it. And even with the key right, `unstable_settings` had no effect on a tab press, so the layout that ships declares each tab's own root first and relies on declaration order, which is what the navigator actually falls back to. The account area is one full-screen modal stack over the tabs rather than a route inside whichever tab happened to be selected: a modal always has a real parent and always dismisses back to where the user was, and full-screen rather than the default because `"modal"` resolves to a page sheet on iOS and this is a task area with three screens and its own back stack. The episode route stays a child of the show route and presents as a `formSheet` at the web app's own two detents, so a cold deep link paints the show underneath and dismissing is one pop, with UIKit owning the physics. The URL state the web app validates is validated here by the same two parsers, because a route parameter is untrusted text on both targets. Path ids get the same treatment in `route-params.ts`: `Number("")` is `NaN` rather than an error, so a link that goes nowhere reaches the not-found screen instead of becoming a request for `/shows/NaN`. The screens are placeholders that render real data through the shared hooks, and carry no styling: what they have to prove first is that the read path, the persisted cache and the write queue already work here. --- packages/native/app/(account)/_layout.tsx | 28 ++++++++++ packages/native/app/(account)/history.tsx | 6 +++ packages/native/app/(account)/profile.tsx | 6 +++ packages/native/app/(account)/settings.tsx | 6 +++ .../native/app/(tabs)/(calendar)/_layout.tsx | 6 +++ .../native/app/(tabs)/(calendar)/calendar.tsx | 6 +++ .../native/app/(tabs)/(library)/_layout.tsx | 6 +++ .../native/app/(tabs)/(library)/library.tsx | 6 +++ .../native/app/(tabs)/(search)/_layout.tsx | 6 +++ .../native/app/(tabs)/(search)/search.tsx | 6 +++ .../native/app/(tabs)/(up-next)/_layout.tsx | 6 +++ .../app/{ => (tabs)/(up-next)}/index.tsx | 2 +- .../movie/[movieId].tsx | 10 ++++ .../show/[showId].tsx | 10 ++++ .../[showId]/episode/[season]/[episode].tsx | 15 ++++++ packages/native/app/(tabs)/_layout.tsx | 47 ++++++++++++++++ packages/native/app/+not-found.tsx | 18 +++++++ packages/native/app/_layout.tsx | 8 ++- packages/native/src/TabStack.tsx | 52 ++++++++++++++++++ packages/native/src/route-params.ts | 15 ++++++ packages/native/src/screens/Calendar.tsx | 29 ++++++++++ packages/native/src/screens/EpisodeSheet.tsx | 28 ++++++++++ packages/native/src/screens/History.tsx | 38 +++++++++++++ packages/native/src/screens/Library.tsx | 54 +++++++++++++++++++ packages/native/src/screens/MovieDetail.tsx | 19 +++++++ packages/native/src/screens/Profile.tsx | 26 +++++++++ packages/native/src/screens/Search.tsx | 41 ++++++++++++++ packages/native/src/screens/Settings.tsx | 36 +++++++++++++ packages/native/src/screens/ShowDetail.tsx | 42 +++++++++++++++ packages/native/src/screens/UpNext.tsx | 6 +++ 30 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 packages/native/app/(account)/_layout.tsx create mode 100644 packages/native/app/(account)/history.tsx create mode 100644 packages/native/app/(account)/profile.tsx create mode 100644 packages/native/app/(account)/settings.tsx create mode 100644 packages/native/app/(tabs)/(calendar)/_layout.tsx create mode 100644 packages/native/app/(tabs)/(calendar)/calendar.tsx create mode 100644 packages/native/app/(tabs)/(library)/_layout.tsx create mode 100644 packages/native/app/(tabs)/(library)/library.tsx create mode 100644 packages/native/app/(tabs)/(search)/_layout.tsx create mode 100644 packages/native/app/(tabs)/(search)/search.tsx create mode 100644 packages/native/app/(tabs)/(up-next)/_layout.tsx rename packages/native/app/{ => (tabs)/(up-next)}/index.tsx (69%) create mode 100644 packages/native/app/(tabs)/(up-next,library,calendar,search)/movie/[movieId].tsx create mode 100644 packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId].tsx create mode 100644 packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId]/episode/[season]/[episode].tsx create mode 100644 packages/native/app/(tabs)/_layout.tsx create mode 100644 packages/native/app/+not-found.tsx create mode 100644 packages/native/src/TabStack.tsx create mode 100644 packages/native/src/route-params.ts create mode 100644 packages/native/src/screens/Calendar.tsx create mode 100644 packages/native/src/screens/EpisodeSheet.tsx create mode 100644 packages/native/src/screens/History.tsx create mode 100644 packages/native/src/screens/Library.tsx create mode 100644 packages/native/src/screens/MovieDetail.tsx create mode 100644 packages/native/src/screens/Profile.tsx create mode 100644 packages/native/src/screens/Search.tsx create mode 100644 packages/native/src/screens/Settings.tsx create mode 100644 packages/native/src/screens/ShowDetail.tsx diff --git a/packages/native/app/(account)/_layout.tsx b/packages/native/app/(account)/_layout.tsx new file mode 100644 index 00000000..954e1798 --- /dev/null +++ b/packages/native/app/(account)/_layout.tsx @@ -0,0 +1,28 @@ +import { Stack } from "expo-router"; +import type { ReactElement } from "react"; + +/** + * Profile, Settings and History as one full-screen modal stack over the tabs. + * + * The presentation is declared where the group is presented, in the root stack. + * A modal rather than a route inside whichever tab happened to be selected, + * because a modal always has a real parent and always dismisses back to exactly + * where the user was. Full-screen rather than the default, because expo-router's + * `"modal"` resolves to a page sheet on iOS, and this is a task area with three + * screens and its own back stack rather than a scoped peek at the parent; it + * would also make the month-jump sheet inside History a sheet over a sheet. + * + * `initialRouteName` is what builds Profile under a cold deep link into Settings + * or History, which is what deletes the web app's per-route back fallbacks. + */ +export const unstable_settings = { initialRouteName: "profile" }; + +export default function AccountLayout(): ReactElement { + return ( + + + + + + ); +} diff --git a/packages/native/app/(account)/history.tsx b/packages/native/app/(account)/history.tsx new file mode 100644 index 00000000..63622ed7 --- /dev/null +++ b/packages/native/app/(account)/history.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { History } from "../../src/screens/History"; + +export default function HistoryRoute(): ReactElement { + return ; +} diff --git a/packages/native/app/(account)/profile.tsx b/packages/native/app/(account)/profile.tsx new file mode 100644 index 00000000..20720015 --- /dev/null +++ b/packages/native/app/(account)/profile.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { Profile } from "../../src/screens/Profile"; + +export default function ProfileRoute(): ReactElement { + return ; +} diff --git a/packages/native/app/(account)/settings.tsx b/packages/native/app/(account)/settings.tsx new file mode 100644 index 00000000..f7d07702 --- /dev/null +++ b/packages/native/app/(account)/settings.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { Settings } from "../../src/screens/Settings"; + +export default function SettingsRoute(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(calendar)/_layout.tsx b/packages/native/app/(tabs)/(calendar)/_layout.tsx new file mode 100644 index 00000000..1f2cc317 --- /dev/null +++ b/packages/native/app/(tabs)/(calendar)/_layout.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { TabStack } from "../../../src/TabStack"; + +export default function CalendarStack(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(calendar)/calendar.tsx b/packages/native/app/(tabs)/(calendar)/calendar.tsx new file mode 100644 index 00000000..9876eb2d --- /dev/null +++ b/packages/native/app/(tabs)/(calendar)/calendar.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { Calendar } from "../../../src/screens/Calendar"; + +export default function CalendarRoute(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(library)/_layout.tsx b/packages/native/app/(tabs)/(library)/_layout.tsx new file mode 100644 index 00000000..58f60326 --- /dev/null +++ b/packages/native/app/(tabs)/(library)/_layout.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { TabStack } from "../../../src/TabStack"; + +export default function LibraryStack(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(library)/library.tsx b/packages/native/app/(tabs)/(library)/library.tsx new file mode 100644 index 00000000..7ffae422 --- /dev/null +++ b/packages/native/app/(tabs)/(library)/library.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { Library } from "../../../src/screens/Library"; + +export default function LibraryRoute(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(search)/_layout.tsx b/packages/native/app/(tabs)/(search)/_layout.tsx new file mode 100644 index 00000000..cfe53041 --- /dev/null +++ b/packages/native/app/(tabs)/(search)/_layout.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { TabStack } from "../../../src/TabStack"; + +export default function SearchStack(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(search)/search.tsx b/packages/native/app/(tabs)/(search)/search.tsx new file mode 100644 index 00000000..7ad5f9f9 --- /dev/null +++ b/packages/native/app/(tabs)/(search)/search.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { Search } from "../../../src/screens/Search"; + +export default function SearchRoute(): ReactElement { + return ; +} diff --git a/packages/native/app/(tabs)/(up-next)/_layout.tsx b/packages/native/app/(tabs)/(up-next)/_layout.tsx new file mode 100644 index 00000000..1172a1ef --- /dev/null +++ b/packages/native/app/(tabs)/(up-next)/_layout.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from "react"; +import { TabStack } from "../../../src/TabStack"; + +export default function UpNextStack(): ReactElement { + return ; +} diff --git a/packages/native/app/index.tsx b/packages/native/app/(tabs)/(up-next)/index.tsx similarity index 69% rename from packages/native/app/index.tsx rename to packages/native/app/(tabs)/(up-next)/index.tsx index e2f64e6b..69f5df56 100644 --- a/packages/native/app/index.tsx +++ b/packages/native/app/(tabs)/(up-next)/index.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from "react"; -import { UpNext } from "../src/screens/UpNext"; +import { UpNext } from "../../../src/screens/UpNext"; export default function UpNextRoute(): ReactElement { return ; diff --git a/packages/native/app/(tabs)/(up-next,library,calendar,search)/movie/[movieId].tsx b/packages/native/app/(tabs)/(up-next,library,calendar,search)/movie/[movieId].tsx new file mode 100644 index 00000000..dd297378 --- /dev/null +++ b/packages/native/app/(tabs)/(up-next,library,calendar,search)/movie/[movieId].tsx @@ -0,0 +1,10 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; +import type { ReactElement } from "react"; +import { parseId } from "../../../../src/route-params"; +import { MovieDetail } from "../../../../src/screens/MovieDetail"; + +export default function MovieRoute(): ReactElement { + const movieId = parseId(useLocalSearchParams<{ movieId: string }>().movieId); + if (movieId === null) return ; + return ; +} diff --git a/packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId].tsx b/packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId].tsx new file mode 100644 index 00000000..11eb2f2e --- /dev/null +++ b/packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId].tsx @@ -0,0 +1,10 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; +import type { ReactElement } from "react"; +import { parseId } from "../../../../src/route-params"; +import { ShowDetail } from "../../../../src/screens/ShowDetail"; + +export default function ShowRoute(): ReactElement { + const showId = parseId(useLocalSearchParams<{ showId: string }>().showId); + if (showId === null) return ; + return ; +} diff --git a/packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId]/episode/[season]/[episode].tsx b/packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId]/episode/[season]/[episode].tsx new file mode 100644 index 00000000..dbf4cabd --- /dev/null +++ b/packages/native/app/(tabs)/(up-next,library,calendar,search)/show/[showId]/episode/[season]/[episode].tsx @@ -0,0 +1,15 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; +import type { ReactElement } from "react"; +import { parseId } from "../../../../../../../src/route-params"; +import { EpisodeSheet } from "../../../../../../../src/screens/EpisodeSheet"; + +export default function EpisodeRoute(): ReactElement { + const params = useLocalSearchParams<{ showId: string; season: string; episode: string }>(); + const showId = parseId(params.showId); + const season = parseId(params.season); + const episode = parseId(params.episode); + if (showId === null || season === null || episode === null) { + return ; + } + return ; +} diff --git a/packages/native/app/(tabs)/_layout.tsx b/packages/native/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..8d80c9d0 --- /dev/null +++ b/packages/native/app/(tabs)/_layout.tsx @@ -0,0 +1,47 @@ +import { NativeTabs } from "expo-router/unstable-native-tabs"; +import type { ReactElement } from "react"; + +/** + * Four tabs, fixed, never pruned, on four distinct paths. + * + * **Fixed**, because media visibility is a reversible preference two screens + * away in Settings, and a navigation structure that changes item count when a + * switch flips is the least predictable thing a shell can do. Apple's tab-bar + * guidance names the case directly ("Don't disable or hide tab bar buttons, even + * when their content is unavailable ... If a section is empty, explain why") and + * Material's navigation bar says destinations do not change. So a movies-only + * user keeps four tabs, and the two with nothing in them say why. + * + * **Distinct paths**, because `NativeTabsNavigator` calls `useNavigationBuilder` + * without forwarding `initialRouteName`: with four groups all serving `/` the + * alphabetically first group becomes the landing screen and nothing changes it. + * A path per tab works around that and is better anyway, because every tab is + * then deep-linkable. + * + * `role="search"` is what makes the last tab the platform's search destination: + * the dedicated search tab on iOS 26 and later, the trailing item of the + * Material navigation bar on Android. Two open issues touch it, so if it + * misbehaves the fallback is a plain trigger and nothing else changes. + */ +export default function TabsLayout(): ReactElement { + return ( + + + + Up Next + + + + Library + + + + Calendar + + + + Search + + + ); +} diff --git a/packages/native/app/+not-found.tsx b/packages/native/app/+not-found.tsx new file mode 100644 index 00000000..5af85c22 --- /dev/null +++ b/packages/native/app/+not-found.tsx @@ -0,0 +1,18 @@ +import { Link, Stack } from "expo-router"; +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; + +/** Where a deep link nothing matches lands, with a way back into the app. */ +export default function NotFound(): ReactElement { + return ( + <> + + + That link doesn't go anywhere in Cue. + + Go to Up Next + + + + ); +} diff --git a/packages/native/app/_layout.tsx b/packages/native/app/_layout.tsx index 018b76f5..ad4293b7 100644 --- a/packages/native/app/_layout.tsx +++ b/packages/native/app/_layout.tsx @@ -126,7 +126,13 @@ function Gate(): ReactElement { if (phase === "connected") { return ( - + + + {/* Presented from the root, over the tab bar, so it always dismisses + back to exactly where the user was rather than into whichever tab + happened to be selected. */} + + ); } diff --git a/packages/native/src/TabStack.tsx b/packages/native/src/TabStack.tsx new file mode 100644 index 00000000..5d45b217 --- /dev/null +++ b/packages/native/src/TabStack.tsx @@ -0,0 +1,52 @@ +import { Stack } from "expo-router"; +import type { ReactElement } from "react"; + +export interface TabStackProps { + /** The tab's own root route, declared first so it is the stack's initial one. */ + readonly root: string; + readonly title: string; +} + +/** + * One native stack per tab, written once and rendered by each tab group's own + * `_layout.tsx` with its own root screen. + * + * The detail routes it hosts are written once too, in the sibling + * `(up-next,library,calendar,search)` directory: expo-router's array-group + * syntax duplicates that directory's files into each named group, so show + * detail, movie detail and the episode sheet are reachable from every tab, the + * tab bar stays visible on push, and each tab keeps its own back stack. That is + * what a `UINavigationController` inside a tab does natively, and it is the + * per-tab duplication this avoids. + * + * The root screen is declared first, and that ordering is load-bearing rather + * than tidy. `unstable_settings`, the documented way to name a group's initial + * route, has no effect here: with the shared routes declared first, pressing a + * tab opened `show/[showId]` with no id in every tab. Declaration order is what + * the navigator actually falls back to, so the tab's own root is declared first + * and the shared routes after it. + */ +export function TabStack({ root, title }: TabStackProps): ReactElement { + return ( + + + + + {/* The episode is a child of the show route so a cold deep link paints the + show underneath and dismissing is one pop. UIKit owns the detents, the + grabber and the physics; the numbers are the web app's own 65 and 92 + per cent. */} + + + ); +} diff --git a/packages/native/src/route-params.ts b/packages/native/src/route-params.ts new file mode 100644 index 00000000..8285b5c9 --- /dev/null +++ b/packages/native/src/route-params.ts @@ -0,0 +1,15 @@ +/** + * A path parameter is untrusted text: it arrives from a deep link, a + * notification payload or a hand-typed URL, and `Number("")` is `NaN` rather + * than an error. Every id in this app is a Trakt id, so the one shape worth + * accepting is a positive integer; anything else is a link that goes nowhere and + * must not become a request for `/shows/NaN`. + * + * The query-string half of the same rule lives in `@cue/core/url/search-params` + * and is shared with the web app. This half is not: expo-router is the only + * router that puts ids in the path. + */ +export function parseId(raw: string | undefined): number | null { + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : null; +} diff --git a/packages/native/src/screens/Calendar.tsx b/packages/native/src/screens/Calendar.tsx new file mode 100644 index 00000000..8ef09e21 --- /dev/null +++ b/packages/native/src/screens/Calendar.tsx @@ -0,0 +1,29 @@ +import { epCode } from "@cue/core/domain/model/library"; +import { useCalendar } from "@cue/core/hooks/useCalendar"; +import type { ReactElement } from "react"; +import { SectionList, Text, View } from "react-native"; + +/** The calendar agenda, read-only, over the shared window hook. */ +export function Calendar(): ReactElement { + const { days, isLoading } = useCalendar(); + + if (isLoading) return Loading the calendar…; + + return ( + + Calendar + ({ title: day.label, data: [...day.rows] }))} + keyExtractor={(row) => String(row.ids.trakt)} + renderSectionHeader={({ section }) => {section.title}} + renderItem={({ item }) => ( + + {item.showTitle} {epCode(item.season, item.number)} + + )} + ListEmptyComponent={Nothing on the way.} + /> + + ); +} diff --git a/packages/native/src/screens/EpisodeSheet.tsx b/packages/native/src/screens/EpisodeSheet.tsx new file mode 100644 index 00000000..be4b1b81 --- /dev/null +++ b/packages/native/src/screens/EpisodeSheet.tsx @@ -0,0 +1,28 @@ +import { epCode } from "@cue/core/domain/model/library"; +import { useEpisode } from "@cue/core/hooks/useEpisode"; +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; + +export interface EpisodeSheetProps { + readonly showId: number; + readonly season: number; + readonly episode: number; +} + +/** The episode sheet's content. The presentation, the detents and the physics + * are the stack's, declared once in the shared tab layout. */ +export function EpisodeSheet({ showId, season, episode }: EpisodeSheetProps): ReactElement { + const { episode: detail, isLoading } = useEpisode(showId, season, episode); + + if (isLoading || detail === undefined) { + return Loading…; + } + + return ( + + {detail.title ?? epCode(season, episode)} + {epCode(detail.season, detail.number)} + {detail.watched ? "Watched" : "Not watched"} + + ); +} diff --git a/packages/native/src/screens/History.tsx b/packages/native/src/screens/History.tsx new file mode 100644 index 00000000..ae501187 --- /dev/null +++ b/packages/native/src/screens/History.tsx @@ -0,0 +1,38 @@ +import { useHistory } from "@cue/core/hooks/useHistory"; +import { parseHistorySearch } from "@cue/core/url/search-params"; +import { useLocalSearchParams } from "expo-router"; +import type { ReactElement } from "react"; +import { SectionList, Text, View } from "react-native"; + +/** + * The diary, over the shared paged read. The medium and the month it is + * scrolled to are the web app's own URL state, parsed by the same function: a + * month without a valid year is not a position, so it is dropped with it. + */ +export function History(): ReactElement { + const params = useLocalSearchParams<{ type?: string; year?: string; month?: string }>(); + const search = parseHistorySearch(params); + const history = useHistory({ + filter: search.type ?? "all", + ...(search.year === undefined ? {} : { year: search.year }), + ...(search.month === undefined ? {} : { month: search.month }), + }); + + return ( + + History + ({ + title: day.label, + data: day.groups.flatMap((group) => group.entries), + }))} + keyExtractor={(entry) => String(entry.historyId)} + renderSectionHeader={({ section }) => {section.title}} + renderItem={({ item }) => {item.title}} + ListEmptyComponent={Nothing watched yet.} + onEndReached={() => history.loadEarlier()} + /> + + ); +} diff --git a/packages/native/src/screens/Library.tsx b/packages/native/src/screens/Library.tsx new file mode 100644 index 00000000..0a654545 --- /dev/null +++ b/packages/native/src/screens/Library.tsx @@ -0,0 +1,54 @@ +import { useLibraryBuckets } from "@cue/core/hooks/useLibraryBuckets"; +import { useMovieLibrary } from "@cue/core/hooks/useMovieLibrary"; +import { parseLibrarySearch } from "@cue/core/url/search-params"; +import { Link, useLocalSearchParams } from "expo-router"; +import type { ReactElement } from "react"; +import { FlatList, Text, View } from "react-native"; + +/** + * Library, over the shared bucket hooks, with the segment read through the same + * parser the web app validates its query string with. A route parameter is + * untrusted text on both targets, from a deep link or a hand-edited address, so + * the one that decides which medium is shown is parsed rather than trusted, and + * the medium that is not shown leaves its query idle rather than reading a + * section nobody is looking at. + */ +export function Library(): ReactElement { + const params = useLocalSearchParams<{ type?: string }>(); + const { type } = parseLibrarySearch(params); + const movies = type === "movies"; + const shows = useLibraryBuckets("alphabetical", !movies); + const movieLibrary = useMovieLibrary("alphabetical", movies); + + return ( + + {movies ? "Library, movies" : "Library, shows"} + + {movies ? "Shows" : "Movies"} + + {movies ? ( + segment.entries)} + keyExtractor={(entry) => String(entry.movieId)} + renderItem={({ item }) => ( + + {item.title} + + )} + /> + ) : ( + String(entry.showId)} + renderItem={({ item }) => ( + + {item.title} + + )} + /> + )} + + ); +} diff --git a/packages/native/src/screens/MovieDetail.tsx b/packages/native/src/screens/MovieDetail.tsx new file mode 100644 index 00000000..db2b6ee2 --- /dev/null +++ b/packages/native/src/screens/MovieDetail.tsx @@ -0,0 +1,19 @@ +import { useMovieDetail } from "@cue/core/hooks/useMovieDetail"; +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; + +/** Movie detail: the hero facts, over the shared read. */ +export function MovieDetail({ movieId }: { readonly movieId: number }): ReactElement { + const { header, isLoading } = useMovieDetail(movieId); + + if (isLoading || header === undefined) { + return Loading…; + } + + return ( + + {header.title} + {header.year !== null && {header.year}} + + ); +} diff --git a/packages/native/src/screens/Profile.tsx b/packages/native/src/screens/Profile.tsx new file mode 100644 index 00000000..2ea50e6e --- /dev/null +++ b/packages/native/src/screens/Profile.tsx @@ -0,0 +1,26 @@ +import { useStats } from "@cue/core/hooks/useStats"; +import { useUserProfile } from "@cue/core/hooks/useUserProfile"; +import { Link } from "expo-router"; +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; + +/** Profile: the account the session belongs to, and the two ways out of it. */ +export function Profile(): ReactElement { + const profile = useUserProfile(); + const { stats } = useStats(); + + return ( + + {profile?.displayName ?? "Profile"} + {stats !== undefined && ( + {`${stats.episodes.watched} episodes watched`} + )} + + History + + + Settings + + + ); +} diff --git a/packages/native/src/screens/Search.tsx b/packages/native/src/screens/Search.tsx new file mode 100644 index 00000000..5bc61126 --- /dev/null +++ b/packages/native/src/screens/Search.tsx @@ -0,0 +1,41 @@ +import { useSearch } from "@cue/core/hooks/useSearch"; +import { Link } from "expo-router"; +import type { ReactElement } from "react"; +import { FlatList, Text, TextInput, View } from "react-native"; + +/** + * Search, over the shared debounced hook. The field is a plain `TextInput` for + * now; the platform's own search affordance is `headerSearchBarOptions`, which + * is `UISearchController`, and adopting it belongs with the visual layer rather + * than with the wiring. + */ +export function Search(): ReactElement { + const search = useSearch(); + + return ( + + Search + + hit.key} + renderItem={({ item }) => ( + + {item.title} + + )} + /> + + ); +} diff --git a/packages/native/src/screens/Settings.tsx b/packages/native/src/screens/Settings.tsx new file mode 100644 index 00000000..2ab756d4 --- /dev/null +++ b/packages/native/src/screens/Settings.tsx @@ -0,0 +1,36 @@ +import { useAuth } from "@cue/core/auth/store"; +import { useAppVersion } from "@cue/core/ports/app-version"; +import { usePrefs } from "@cue/core/prefs/prefs-store"; +import type { ReactElement } from "react"; +import { Pressable, Switch, Text, View } from "react-native"; + +/** Settings: the device-local preferences, the app identity, and sign-out. */ +export function Settings(): ReactElement { + const hapticsEnabled = usePrefs((s) => s.hapticsEnabled); + const setHapticsEnabled = usePrefs((s) => s.setHapticsEnabled); + const version = useAppVersion(); + const disconnect = useAuth((s) => s.disconnect); + + return ( + + Settings + + Haptics + + + {version} + void disconnect()} + > + Disconnect + + + ); +} diff --git a/packages/native/src/screens/ShowDetail.tsx b/packages/native/src/screens/ShowDetail.tsx new file mode 100644 index 00000000..b7a4858f --- /dev/null +++ b/packages/native/src/screens/ShowDetail.tsx @@ -0,0 +1,42 @@ +import { epCode } from "@cue/core/domain/model/library"; +import { useSeasons } from "@cue/core/hooks/useSeasons"; +import { useShowDetail } from "@cue/core/hooks/useShowDetail"; +import { Link } from "expo-router"; +import type { ReactElement } from "react"; +import { FlatList, Text, View } from "react-native"; + +/** Show detail: the hero facts, the viewer's progress, and the season stream. */ +export function ShowDetail({ showId }: { readonly showId: number }): ReactElement { + const { header, isLoading } = useShowDetail(showId); + const seasons = useSeasons(showId); + + if (isLoading || header === undefined) { + return Loading…; + } + + return ( + + {header.title} + {`${header.completed} of ${header.aired} watched`} + String(season.number)} + renderItem={({ item: season }) => ( + + {`Season ${season.number}`} + {season.episodes.map((episode) => ( + + {epCode(season.number, episode.number)} + + ))} + + )} + /> + + ); +} diff --git a/packages/native/src/screens/UpNext.tsx b/packages/native/src/screens/UpNext.tsx index 270ba17c..99a587d2 100644 --- a/packages/native/src/screens/UpNext.tsx +++ b/packages/native/src/screens/UpNext.tsx @@ -1,6 +1,7 @@ import { epCode } from "@cue/core/domain/model/library"; import { useMarkWatched } from "@cue/core/hooks/useMarkWatched"; import { useUpNext } from "@cue/core/hooks/useUpNext"; +import { Link } from "expo-router"; import type { ReactElement } from "react"; import { FlatList, Pressable, Text, View } from "react-native"; @@ -20,6 +21,11 @@ export function UpNext(): ReactElement { return ( Up Next + {/* The account area is behind the header avatar on every tab root; this is + that entry point before the header exists. */} + + Profile + Date: Sat, 5 Sep 2026 01:30:16 -0500 Subject: [PATCH 026/435] Fail checks on Biome warnings Run repository lint and the aggregate check with error-on-warnings so stale suppression diagnostics block the gate. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6247a3d8..93402902 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "preview": "pnpm --filter @cue/web preview", "sync": "cap sync", "typecheck": "tsc --noEmit && pnpm -r typecheck", - "lint": "biome check .", + "lint": "biome check --error-on-warnings .", "format": "biome check --write . && dprint fmt", "test": "vitest run", "test:native": "pnpm --filter @cue/native test", @@ -34,7 +34,7 @@ "check:bundle": "./scripts/verify-bundle.sh", "buster:check": "node scripts/write-buster.mjs --check", "buster:bump": "node scripts/write-buster.mjs --bump", - "check": "biome check . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage && pnpm test:native", + "check": "biome check --error-on-warnings . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage && pnpm test:native", "audit": "pnpm audit --prod --audit-level=high", "prepare": "lefthook install" }, From a5a0ba2fed906cf4cd7dcbf9d0b5d2e2b21724af Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 01:33:11 -0500 Subject: [PATCH 027/435] Gate cognitive complexity at fifteen Enable Biome cognitive complexity errors at the default threshold and document each existing exception at the function that owns its inherent branching. --- biome.jsonc | 4 ++++ packages/core/src/app/create-runtime.ts | 1 + packages/core/src/auth/create-auth-store.ts | 1 + packages/core/src/data/trakt/authorized-fetch.ts | 1 + packages/core/src/data/trakt/episode-detail.ts | 1 + packages/core/src/data/trakt/library.ts | 1 + packages/core/src/data/trakt/movie-library.ts | 1 + packages/core/src/domain/up-next.ts | 1 + packages/core/src/hooks/useMarkSeason.ts | 2 ++ packages/web/e2e/helpers.ts | 4 ++++ packages/web/src/ui/components/PullToRefresh.tsx | 1 + packages/web/src/ui/components/Sheet.tsx | 2 ++ packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx | 1 + packages/web/src/ui/screens/history/History.tsx | 1 + packages/web/src/ui/screens/library/Library.tsx | 1 + packages/web/src/ui/screens/movie-detail/MovieDetail.tsx | 1 + packages/web/src/ui/screens/search/Search.tsx | 1 + packages/web/src/ui/screens/show-detail/ContinueBar.tsx | 1 + packages/web/src/ui/screens/show-detail/ShowDetail.tsx | 1 + packages/web/src/ui/screens/show-detail/detail-logic.ts | 1 + packages/web/src/ui/screens/up-next/UpNext.tsx | 1 + scripts/mock-trakt/seed.mjs | 1 + 22 files changed, 30 insertions(+) diff --git a/biome.jsonc b/biome.jsonc index 0c9b93f4..fc7424a4 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -82,6 +82,10 @@ "noConsole": { "level": "error", "options": { "allow": ["error", "warn"] } } }, "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 15 } + }, // tsconfig `noPropertyAccessFromIndexSignature` forces bracket access on // index-signature types like `import.meta.env` / `process.env`; leaving // `useLiteralKeys` on would rewrite `env["X"]` back to `env.X`, which TS rejects. diff --git a/packages/core/src/app/create-runtime.ts b/packages/core/src/app/create-runtime.ts index 47cec7d9..9e3d92ca 100644 --- a/packages/core/src/app/create-runtime.ts +++ b/packages/core/src/app/create-runtime.ts @@ -136,6 +136,7 @@ export async function createCueRuntime(deps: RuntimeDeps): Promise { baseUrl: deps.apiBaseUrl, }); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Reconciles each queued operation kind against its distinct authoritative Trakt read and landing condition. const reconcile = async (op: QueuedOp): Promise => { const context = op.inversePatch as ReconcileContext | null; if (context === null || typeof context !== "object") return false; diff --git a/packages/core/src/auth/create-auth-store.ts b/packages/core/src/auth/create-auth-store.ts index f908ef96..9670a5e4 100644 --- a/packages/core/src/auth/create-auth-store.ts +++ b/packages/core/src/auth/create-auth-store.ts @@ -74,6 +74,7 @@ export function createAuthStore(deps: AuthDeps): AuthStore { }); } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Models cancellation and every terminal or retryable device authorization polling outcome in one state transition loop. async function pollLoop( deviceCode: string, intervalMs: number, diff --git a/packages/core/src/data/trakt/authorized-fetch.ts b/packages/core/src/data/trakt/authorized-fetch.ts index f618a2e0..3ba3e2a5 100644 --- a/packages/core/src/data/trakt/authorized-fetch.ts +++ b/packages/core/src/data/trakt/authorized-fetch.ts @@ -98,6 +98,7 @@ export function createAuthorizedFetch(deps: AuthorizedFetchDeps): AuthorizedFetc return { ...init, headers }; } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Coordinates proactive refresh, concurrent token rotation, and separate safe retry policies for reads and writes. const fetch: FetchLike = async (input, init) => { const isWrite = isMutating(init?.method); let sessionEnded = false; diff --git a/packages/core/src/data/trakt/episode-detail.ts b/packages/core/src/data/trakt/episode-detail.ts index 2ff58b4e..d1ac2333 100644 --- a/packages/core/src/data/trakt/episode-detail.ts +++ b/packages/core/src/data/trakt/episode-detail.ts @@ -41,6 +41,7 @@ const key = (season: number, number: number): string => `${season}:${number}`; * can't reach an unaired episode that isn't the target. Derive the ordering from * the full `/shows/:id/seasons` list and keep watched state from progress. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Merges nested progress into watched state and a specially ordered previous and next episode navigation model. export function assembleEpisodeDetail( showId: number, episode: EpisodeData, diff --git a/packages/core/src/data/trakt/library.ts b/packages/core/src/data/trakt/library.ts index d4512bc0..54628025 100644 --- a/packages/core/src/data/trakt/library.ts +++ b/packages/core/src/data/trakt/library.ts @@ -66,6 +66,7 @@ function toEpisodeRef(ep: SchemaEpisode): EpisodeRef { * `/sync/watched/shows` row, so it is materialized here as a zero-progress * `to-watch` entry: otherwise it would vanish from "To watch" after a refetch. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Merges watched, progress, hidden, and watchlist sources while preserving watchlist-only shows. export function assembleLibrary(input: LibraryInput): LibraryEntry[] { const watchlistShowIds = new Set(); for (const item of input.watchlistShows) { diff --git a/packages/core/src/data/trakt/movie-library.ts b/packages/core/src/data/trakt/movie-library.ts index 77047e8e..eeebf8a4 100644 --- a/packages/core/src/data/trakt/movie-library.ts +++ b/packages/core/src/data/trakt/movie-library.ts @@ -69,6 +69,7 @@ export function toMovieIds(ids: { * library's watchlist-only handling): otherwise it would vanish from the * Watchlist shelf after a refetch. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Merges watched and watchlist movie sources while preserving ordering and watchlist-only entries. export function assembleMovieLibrary(input: MovieLibraryInput): MovieEntry[] { // trakt id → its watchlist `listed_at` (the add time), so a watched movie that // is also watchlisted still carries the queue order, and a watchlist-only movie diff --git a/packages/core/src/domain/up-next.ts b/packages/core/src/domain/up-next.ts index 5d3d2ee5..6034cd67 100644 --- a/packages/core/src/domain/up-next.ts +++ b/packages/core/src/domain/up-next.ts @@ -31,6 +31,7 @@ export interface UpNextGroups { * post-mark projection (`ids.trakt === 0`, air date unknown) stays in the queue, * visible and locked, until the authoritative refetch lands. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Partitions shows using status, provisional advance, air time, and lapse rules that jointly define queue membership. export function groupUpNext( shows: readonly LibraryShow[], now: number, diff --git a/packages/core/src/hooks/useMarkSeason.ts b/packages/core/src/hooks/useMarkSeason.ts index 04aa20dc..3346c460 100644 --- a/packages/core/src/hooks/useMarkSeason.ts +++ b/packages/core/src/hooks/useMarkSeason.ts @@ -424,6 +424,7 @@ export function useMarkSeason(): MarkSeasonController { remembered ?? new Set(season.episodes.filter((e) => e.aired).map((episode) => episode.number)); if (delta.size === 0) return; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Unmarks a season across remembered deltas, play resolution, optimistic cache changes, rollback, and rewatch preservation. await withSeasonLock(season.number, async () => { setError(null); setNotice(null); @@ -578,6 +579,7 @@ export function useMarkSeason(): MarkSeasonController { const toggleEpisode = useCallback( async (target: MarkContextTarget, episode: MarkableEpisode, options?: ToggleEpisodeOptions) => { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Toggles queued and live episode plays while preserving rewatches and defining rollback for every resolution outcome. await withEpisodeLock(target, episode, async () => { const matchEpisode: EpisodeMatch = (s, n) => s === episode.season && n === episode.number; const bound: EpisodeBound = { season: episode.season, number: episode.number }; diff --git a/packages/web/e2e/helpers.ts b/packages/web/e2e/helpers.ts index b1b6be6c..0c703584 100644 --- a/packages/web/e2e/helpers.ts +++ b/packages/web/e2e/helpers.ts @@ -96,6 +96,7 @@ export async function installIntersectionObserverPolyfill(page: Page): Promise this.tick(), POLL_MS); } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Emulates viewport intersection by evaluating every rectangle edge and reporting only state transitions. private tick(): void { const entries: Entry[] = []; for (const [target, wasIntersecting] of this.states) { @@ -1064,6 +1065,7 @@ export async function installLibraryRoutes( }), ); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simulates episode and bulk show history writes, per-play removals, rewatches, and configured transport failures. const handleHistory = (remove: boolean) => async (route: import("@playwright/test").Route) => { const body = (route.request().postDataJSON() ?? {}) as HistoryBody; const episodeIds = (body.episodes ?? []).map((e) => e.ids?.trakt ?? -1); @@ -1108,6 +1110,7 @@ export async function installLibraryRoutes( } } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Applies episode, show, primary-play, and rewatch removal effects to the mutable test fixture. const apply = (): void => { applyWrite(shows, episodeIds, remove); applyWrite(shows, primaryEpisodeIds, remove); @@ -1849,6 +1852,7 @@ export async function installMovieRoutes( }); }); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simulates movie history add and removal requests across item and per-play identifier shapes. const handleHistory = (remove: boolean) => (route: import("@playwright/test").Route) => { const body = (route.request().postDataJSON() ?? {}) as { movies?: { ids?: { trakt?: number }; watched_at?: string }[]; diff --git a/packages/web/src/ui/components/PullToRefresh.tsx b/packages/web/src/ui/components/PullToRefresh.tsx index 881946f5..38ac9a13 100644 --- a/packages/web/src/ui/components/PullToRefresh.tsx +++ b/packages/web/src/ui/components/PullToRefresh.tsx @@ -129,6 +129,7 @@ export function PullToRefresh({ children }: { readonly children: ReactNode }): R }; event.currentTarget.addEventListener("touchmove", claimScroll, { passive: false }); }} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Resolves pointer ownership, gesture direction, pull threshold transitions, and haptic feedback in one event handler. onPointerMove={(event) => { const g = gesture.current; if (g === null || g.pointerId !== event.pointerId || g.intent === "abandoned") return; diff --git a/packages/web/src/ui/components/Sheet.tsx b/packages/web/src/ui/components/Sheet.tsx index 34fa8395..3bdccdf1 100644 --- a/packages/web/src/ui/components/Sheet.tsx +++ b/packages/web/src/ui/components/Sheet.tsx @@ -96,6 +96,7 @@ export function Sheet({ return detent === "open" ? height * OPEN_DETENT_FRACTION : 0; }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Settles cancelled and completed drags across dismissal and every available sheet detent. const endDrag = (e: ReactPointerEvent, cancelled: boolean): void => { const state = drag.current; drag.current = null; @@ -162,6 +163,7 @@ export function Sheet({ samples: [], }; }} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Arbitrates horizontal child gestures, body scrolling, and sheet detent dragging after pointer intent is known. onPointerMove={(e) => { const state = drag.current; if (state === null) return; diff --git a/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx index 7162be30..7943d04e 100644 --- a/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx +++ b/packages/web/src/ui/screens/episode-detail/EpisodeSheet.tsx @@ -165,6 +165,7 @@ function PagerButton({ * mark path runs through the same show-surface controller the season list uses, * so both surfaces tick together and share one snackbar grammar. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Coordinates episode loading, navigation, spoiler, play, marking, and modal states within the route-backed sheet. export function EpisodeSheet({ showId, season, diff --git a/packages/web/src/ui/screens/history/History.tsx b/packages/web/src/ui/screens/history/History.tsx index b6ca3705..9f91ddd0 100644 --- a/packages/web/src/ui/screens/history/History.tsx +++ b/packages/web/src/ui/screens/history/History.tsx @@ -131,6 +131,7 @@ function scopeLabel(year: number | undefined, month: number | undefined): string * navigation. The scope lives in the URL (`?type`/`?year`/`?month`), so every * window is deep-linkable. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Renders history across media filters, scoped loading states, grouped results, pagination, and per-play removal feedback. export function History(): ReactElement { useDocumentTitle("History · Cue"); const { type, year, month } = useSearch({ from: "/history" }); diff --git a/packages/web/src/ui/screens/library/Library.tsx b/packages/web/src/ui/screens/library/Library.tsx index 4281a1ea..b772cd49 100644 --- a/packages/web/src/ui/screens/library/Library.tsx +++ b/packages/web/src/ui/screens/library/Library.tsx @@ -96,6 +96,7 @@ const FILTER_DEBOUNCE_MS = 300; * (mark next episode, stop/resume, details); there is deliberately no * "remove from library": no single Trakt op backs it. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Coordinates show and movie segments with their distinct filters, sorting, empty states, and quick actions. export function Library(): ReactElement { useDocumentTitle("Library · Cue"); const { type } = useSearch({ from: "/library" }); diff --git a/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx index 9b031afc..7efa9de1 100644 --- a/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx +++ b/packages/web/src/ui/screens/movie-detail/MovieDetail.tsx @@ -46,6 +46,7 @@ function toEntry(header: MovieHeader, existing: MovieEntry | undefined): MovieEn * multi-play movie is refused and routed to History), the clamped overview, and * a related grid. Watchlist and the Trakt hand-off live in the overflow disc. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Coordinates movie loading, watch and watchlist actions, undo feedback, overflow controls, and related content states. function MovieDetailContent({ movieId }: { readonly movieId: number }): ReactElement { const detail = useMovieDetail(movieId); const library = useMovieLibrary(); diff --git a/packages/web/src/ui/screens/search/Search.tsx b/packages/web/src/ui/screens/search/Search.tsx index 2cb85c2f..a1fb0759 100644 --- a/packages/web/src/ui/screens/search/Search.tsx +++ b/packages/web/src/ui/screens/search/Search.tsx @@ -125,6 +125,7 @@ function Browse({ * blames the spelling rather than the user, inline retry on failure, and an * offline panel. Search is the one surface that genuinely needs a connection. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Renders idle and queried search across connectivity, media preferences, result states, and watchlist actions. export function Search(): ReactElement { useDocumentTitle("Search · Cue"); const view = useSearch(); diff --git a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx index b9543e9b..3171b50f 100644 --- a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx +++ b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx @@ -147,6 +147,7 @@ function EntryCheck({ * loads. The check uses the fallback mark path. The bar body (not the check) * opens the episode sheet. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Selects tracked or fallback progress and renders next, caught-up, countdown, and finished continuation states. export function ContinueBar({ showId, header, diff --git a/packages/web/src/ui/screens/show-detail/ShowDetail.tsx b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx index f6dac47a..4136acd8 100644 --- a/packages/web/src/ui/screens/show-detail/ShowDetail.tsx +++ b/packages/web/src/ui/screens/show-detail/ShowDetail.tsx @@ -88,6 +88,7 @@ function About({ header }: { readonly header: ShowHeader }): ReactElement | null * and the Trakt hand-off. Every state is designed: hero skeleton, hero error * retry, season skeletons, season error retry, and an announced-only empty tree. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Coordinates detail, season, marking, hiding, watchlist, confirmation, overflow, and nested episode route states. export function ShowDetail({ showId }: { readonly showId: number }): ReactElement { const detail = useShowDetail(showId); const seasonsView = useSeasons(showId); diff --git a/packages/web/src/ui/screens/show-detail/detail-logic.ts b/packages/web/src/ui/screens/show-detail/detail-logic.ts index 270c859c..2f5f7722 100644 --- a/packages/web/src/ui/screens/show-detail/detail-logic.ts +++ b/packages/web/src/ui/screens/show-detail/detail-logic.ts @@ -121,6 +121,7 @@ export function earlierUnwatchedCount(seasons: readonly SeasonView[], bound: Epi /** The post-backfill snackbar message: `S2 E1-E5 marked` when the gap sits * inside the bound's season, else the honest coalesced count. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Scans nested seasons to distinguish same-season episode ranges from cross-season backfills. export function backfillRangeLabel( seasons: readonly SeasonView[], bound: EpisodeBound, diff --git a/packages/web/src/ui/screens/up-next/UpNext.tsx b/packages/web/src/ui/screens/up-next/UpNext.tsx index 05fcde00..292d5e54 100644 --- a/packages/web/src/ui/screens/up-next/UpNext.tsx +++ b/packages/web/src/ui/screens/up-next/UpNext.tsx @@ -56,6 +56,7 @@ function MarqueeSlot({ * branches on real library composition, and error = SyncStrip over cached * content, never a blank screen. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Coordinates queue, calendar, stop and mark feedback, tutorial state, and every timeline loading or empty branch. export function UpNext(): ReactElement { useDocumentTitle("Up Next · Cue"); const view = useUpNext(); diff --git a/scripts/mock-trakt/seed.mjs b/scripts/mock-trakt/seed.mjs index f7980e3e..2a7ee68c 100644 --- a/scripts/mock-trakt/seed.mjs +++ b/scripts/mock-trakt/seed.mjs @@ -674,6 +674,7 @@ function targetedEpisodes(show, body) { * have comes back in `not_found`: a write that matched nothing must not read as * a success the account never took. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Applies episode, bulk show, movie, and play-id history bodies for both additions and removals. export function applyHistoryWrite(library, body, remove) { const stamped = Date.parse( (body.episodes ?? [])[0]?.watched_at ?? From 02b15237a17d1232b9eda81e48bb838ec9244bd8 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 01:38:16 -0500 Subject: [PATCH 028/435] Gate the web bundle size Measure the initial load and all JavaScript and CSS with the size-limit file plugin after a clean web build. Keep both budgets at the measured baseline plus twenty-five percent. --- .size-limit.json | 18 ++++++++++++++++ package.json | 5 ++++- pnpm-lock.yaml | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .size-limit.json diff --git a/.size-limit.json b/.size-limit.json new file mode 100644 index 00000000..c884f7c2 --- /dev/null +++ b/.size-limit.json @@ -0,0 +1,18 @@ +[ + { + "name": "web initial load", + "path": [ + "packages/web/dist/assets/index-*.js", + "packages/web/dist/assets/useDocumentTitle-*.js", + "packages/web/dist/assets/query-keys-*.js", + "packages/web/dist/assets/query-freshness-*.js", + "packages/web/dist/assets/index-*.css" + ], + "limit": "170 kB" + }, + { + "name": "web all JavaScript and CSS", + "path": "packages/web/dist/**/*.{js,css}", + "limit": "285 kB" + } +] diff --git a/package.json b/package.json index 93402902..69a1f838 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,11 @@ "check:knip": "knip", "check:dup": "jscpd", "check:core-portable": "vitest run --project core test/ci/core-portable.test.ts", + "check:size": "pnpm build && size-limit", "check:bundle": "./scripts/verify-bundle.sh", "buster:check": "node scripts/write-buster.mjs --check", "buster:bump": "node scripts/write-buster.mjs --bump", - "check": "biome check --error-on-warnings . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:bundle && vitest run --coverage && pnpm test:native", + "check": "biome check --error-on-warnings . && dprint check && pnpm check:spell && pnpm typecheck && pnpm check:arch && knip && jscpd && pnpm buster:check && pnpm check:size && pnpm check:bundle && vitest run --coverage && pnpm test:native", "audit": "pnpm audit --prod --audit-level=high", "prepare": "lefthook install" }, @@ -50,6 +51,7 @@ "@capacitor/android": "^8.5.0", "@capacitor/cli": "^8.5.0", "@capacitor/ios": "^8.5.0", + "@size-limit/file": "^12.1.0", "@types/node": "^22.12.0", "@vitest/coverage-v8": "^4.1.9", "cspell": "^9.8.0", @@ -58,6 +60,7 @@ "jscpd": "^5.0.11", "knip": "^6.24.0", "lefthook": "^2.1.9", + "size-limit": "^12.1.0", "typescript": "^6.0.3", "vitest": "^4.1.9" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 102475c3..baed0aa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@capacitor/ios': specifier: ^8.5.0 version: 8.5.0(@capacitor/core@8.5.0) + '@size-limit/file': + specifier: ^12.1.0 + version: 12.1.0(size-limit@12.1.0(jiti@2.7.0)) '@types/node': specifier: ^22.12.0 version: 22.20.0 @@ -65,6 +68,9 @@ importers: lefthook: specifier: ^2.1.9 version: 2.1.9 + size-limit: + specifier: ^12.1.0 + version: 12.1.0(jiti@2.7.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3245,6 +3251,12 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@size-limit/file@12.1.0': + resolution: {integrity: sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 12.1.0 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3962,6 +3974,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes-iec@3.1.1: + resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} + engines: {node: '>= 0.8'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -5720,6 +5736,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -5941,6 +5961,9 @@ packages: engines: {node: ^22 || ^24 || >=26} hasBin: true + nanospinner@1.2.2: + resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + native-run@2.0.3: resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==} engines: {node: '>=16.0.0'} @@ -6637,6 +6660,16 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + size-limit@12.1.0: + resolution: {integrity: sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + jiti: ^2.0.0 + peerDependenciesMeta: + jiti: + optional: true + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -10782,6 +10815,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@size-limit/file@12.1.0(size-limit@12.1.0(jiti@2.7.0))': + dependencies: + size-limit: 12.1.0(jiti@2.7.0) + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.43': @@ -11539,6 +11576,8 @@ snapshots: buffer-from@1.1.2: {} + bytes-iec@3.1.1: {} + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -13635,6 +13674,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -13952,6 +13993,10 @@ snapshots: nanoid@6.0.1: {} + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + native-run@2.0.3: dependencies: '@ionic/utils-fs': 3.1.7 @@ -14825,6 +14870,16 @@ snapshots: sisteransi@1.0.5: {} + size-limit@12.1.0(jiti@2.7.0): + dependencies: + bytes-iec: 3.1.1 + lilconfig: 3.1.3 + nanospinner: 1.2.2 + picocolors: 1.1.1 + tinyglobby: 0.2.17 + optionalDependencies: + jiti: 2.7.0 + slash@3.0.0: {} slash@5.1.0: {} From 64a2b729a8720614e6cd4c7ae788d3b5ff3d72f5 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 01:42:58 -0500 Subject: [PATCH 029/435] Fix repeat legacy token adoption Persist the adopted token digest in bulk storage so each legacy token is migrated once. Cover relaunch after sign-out, changed legacy credentials, and rollback key preservation. --- .../core/src/migration/legacy-capacitor.ts | 26 +++++++++++++------ .../test/migration/legacy-capacitor.test.ts | 21 ++++++++++++--- packages/native/__tests__/boot.test.ts | 12 +++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/core/src/migration/legacy-capacitor.ts b/packages/core/src/migration/legacy-capacitor.ts index 106a8689..77df50f2 100644 --- a/packages/core/src/migration/legacy-capacitor.ts +++ b/packages/core/src/migration/legacy-capacitor.ts @@ -8,6 +8,7 @@ import type { PreferenceStorage } from "../ports/preference-storage"; import type { TokenStore } from "../ports/token-store"; const LEGACY_TOKEN_KEY = "cue.trakt.token"; +const ADOPTED_TOKEN_KEY = "cue.legacy-token-adopted"; const LEGACY_OP_LOG_KEY = "cue.write-queue"; const OP_LOG_KEY = "cue.write-queue"; /** The one preference worth seeding: without it an established user is offered @@ -85,9 +86,9 @@ export interface LegacyMigrationResult { * Not "migrate once when the secure store is empty". While a Capacitor build is * still a shippable rollback target, both apps can write a token, and the only * rule that matches what a user who rolled back, signed in again and rolled - * forward expects is last-writer-wins with the legacy store preferred. So the - * legacy token is read every boot and left where it is; it is removed in the - * commit that removes the rollback target. + * forward expects is to adopt each distinct legacy token once. Its digest is + * recorded in the bulk store, while the token itself is left where it is for + * the rollback target. Both are removed with that target at cut-over. * * The op log is the opposite case and is removed on read: it is the one store * whose loss is silent data loss, an op in it is a play the user marked that @@ -105,11 +106,15 @@ export async function migrateLegacyCapacitorData( if (rawToken !== null) { const token = tokenSchema.safeParse(tryParse(rawToken)); if (token.success) { - await deps.tokenStore.write(token.data); - adoptedToken = true; - // An install that has a token is an install that has been used, so the - // one-time caption has been seen whether or not its own key survived. - deps.preferences.setItem(TUTORIAL_KEY, "1"); + const digest = await digestToken(rawToken); + if (digest !== (await deps.bulk.read(ADOPTED_TOKEN_KEY))) { + await deps.tokenStore.write(token.data); + await deps.bulk.write(ADOPTED_TOKEN_KEY, digest); + adoptedToken = true; + // An install that has a token is an install that has been used, so the + // one-time caption has been seen whether or not its own key survived. + deps.preferences.setItem(TUTORIAL_KEY, "1"); + } } } @@ -132,6 +137,11 @@ export async function migrateLegacyCapacitorData( return { adoptedToken, adoptedOps }; } +async function digestToken(raw: string): Promise { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)); + return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + function tryParse(raw: string): unknown { try { return JSON.parse(raw); diff --git a/packages/core/test/migration/legacy-capacitor.test.ts b/packages/core/test/migration/legacy-capacitor.test.ts index 2e71945f..0e01a1be 100644 --- a/packages/core/test/migration/legacy-capacitor.test.ts +++ b/packages/core/test/migration/legacy-capacitor.test.ts @@ -61,17 +61,32 @@ describe("the Capacitor migration", () => { expect(fresh.preferences.values.size).toBe(0); }); - it("adopts the legacy token and leaves it where it is", async () => { + it("adopts the legacy token once and leaves it where it is", async () => { const upgrade = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); const result = await migrateLegacyCapacitorData(upgrade); + const second = await migrateLegacyCapacitorData(upgrade); expect(result.adoptedToken).toBe(true); + expect(second.adoptedToken).toBe(false); expect(await upgrade.tokenStore.read()).toEqual(TOKEN); // Left in place on purpose: a Capacitor build is still a rollback target, // and the rule that matches what a user expects is last writer wins. expect(upgrade.legacy.values.get("cue.trakt.token")).toBe(JSON.stringify(TOKEN)); }); + it("adopts a changed legacy token", async () => { + const upgrade = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); + await migrateLegacyCapacitorData(upgrade); + const changed = JSON.stringify({ ...TOKEN, access_token: "changed" }); + upgrade.legacy.values.set("cue.trakt.token", changed); + + const result = await migrateLegacyCapacitorData(upgrade); + + expect(result.adoptedToken).toBe(true); + expect(await upgrade.tokenStore.read()).toEqual({ ...TOKEN, access_token: "changed" }); + expect(upgrade.legacy.values.get("cue.trakt.token")).toBe(changed); + }); + it("prefers the legacy token over one this install already holds", async () => { const rolledBackAndForward = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); await rolledBackAndForward.tokenStore.write({ ...TOKEN, access_token: "stale" }); @@ -141,12 +156,12 @@ describe("the Capacitor migration", () => { expect(JSON.parse(both.bulk.values.get("cue.write-queue") ?? "null")).toEqual([OP, mine]); }); - it("is a no-op on the second launch, because the op log is gone and the token is idempotent", async () => { + it("is a no-op on the second launch", async () => { const upgrade = deps({ "cue.trakt.token": JSON.stringify(TOKEN) }); await migrateLegacyCapacitorData({ ...upgrade, legacy: upgrade.legacy }); const second = await migrateLegacyCapacitorData(upgrade); - expect(second).toEqual({ adoptedToken: true, adoptedOps: 0 }); + expect(second).toEqual({ adoptedToken: false, adoptedOps: 0 }); expect(await upgrade.tokenStore.read()).toEqual(TOKEN); }); diff --git a/packages/native/__tests__/boot.test.ts b/packages/native/__tests__/boot.test.ts index 33f3fe5a..bcb22426 100644 --- a/packages/native/__tests__/boot.test.ts +++ b/packages/native/__tests__/boot.test.ts @@ -77,6 +77,18 @@ describe("the native boot", () => { expect(await createTokenStore(upgrade.secure).read()).toEqual(TOKEN); }); + it("stays signed out after relaunching with an adopted legacy token", async () => { + const upgrade = deps({ legacy: { "cue.trakt.token": JSON.stringify(TOKEN) } }); + await bootNativeStores(upgrade); + await createTokenStore(upgrade.secure).clear(); + + const result = await bootNativeStores(upgrade); + + expect(result.migration.adoptedToken).toBe(false); + expect(await createTokenStore(upgrade.secure).read()).toBeNull(); + expect(upgrade.legacy.values.get("cue.trakt.token")).toBe(JSON.stringify(TOKEN)); + }); + it("installs the Web Crypto surface the shared OAuth code is written against", async () => { await bootNativeStores(deps({})); From 4906d8d65c15481dc2af5d54c2a68ae2e8e17c30 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 01:45:17 -0500 Subject: [PATCH 030/435] Add account modal dismissal Render a Done action in the profile header and dismiss the full-screen account stack through Expo Router. Cover the initial route and dismissal behavior on iOS and Android. --- .../native/__tests__/account-layout.test.tsx | 32 +++++++++++++++++++ packages/native/app/(account)/_layout.tsx | 20 ++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 packages/native/__tests__/account-layout.test.tsx diff --git a/packages/native/__tests__/account-layout.test.tsx b/packages/native/__tests__/account-layout.test.tsx new file mode 100644 index 00000000..04303957 --- /dev/null +++ b/packages/native/__tests__/account-layout.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +const mockDismissAll = jest.fn(); + +jest.mock("expo-router", () => { + const { createElement, Fragment } = require("react"); + const { View } = require("react-native"); + const Stack = ({ children }: { children: React.ReactNode }) => + createElement(Fragment, null, children); + Stack.Screen = ({ + name, + options, + }: { + name: string; + options: { headerRight?: () => React.ReactNode }; + }) => createElement(View, { testID: `account-route-${name}` }, options.headerRight?.()); + return { Stack, useRouter: () => ({ dismissAll: mockDismissAll }) }; +}); + +const AccountLayout = require("../app/(account)/_layout").default as () => React.JSX.Element; + +describe("the account stack", () => { + beforeEach(() => mockDismissAll.mockClear()); + + it("lets the initial profile route dismiss the modal", async () => { + const account = await render(); + + expect(account.getByTestId("account-route-profile")).toBeOnTheScreen(); + fireEvent.press(account.getByRole("button", { name: "Done" })); + expect(mockDismissAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/native/app/(account)/_layout.tsx b/packages/native/app/(account)/_layout.tsx index 954e1798..d18307ed 100644 --- a/packages/native/app/(account)/_layout.tsx +++ b/packages/native/app/(account)/_layout.tsx @@ -1,5 +1,6 @@ -import { Stack } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import type { ReactElement } from "react"; +import { Button } from "react-native"; /** * Profile, Settings and History as one full-screen modal stack over the tabs. @@ -18,9 +19,24 @@ import type { ReactElement } from "react"; export const unstable_settings = { initialRouteName: "profile" }; export default function AccountLayout(): ReactElement { + const router = useRouter(); + return ( - + ( + diff --git a/packages/web/src/ui/components/CheckControl.tsx b/packages/web/src/ui/components/CheckControl.tsx index d81c2e5f..e6f2941a 100644 --- a/packages/web/src/ui/components/CheckControl.tsx +++ b/packages/web/src/ui/components/CheckControl.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from "react"; -export type CheckState = "unwatched" | "just-marked" | "watched" | "unaired"; +export type CheckState = "unwatched" | "just-marked" | "advancing" | "watched" | "unaired"; interface CheckControlProps { readonly state: CheckState; @@ -8,7 +8,7 @@ interface CheckControlProps { readonly size?: 44 | 48 | 56; /** * `advance` = queue grammar: after a mark the filled check is a live undo - * toggle until it re-arms for the next episode. `toggle` = everywhere else: + * toggle for the undo window, then the row advances. `toggle` = everywhere else: * filled means watched, a tap removes the latest play. Purely semantic here * (the owner wires the tap); exposed as `data-mode` for tests. */ @@ -22,6 +22,9 @@ interface CheckControlProps { readonly plays?: number; /** Season-bulk partial state: hollow ring with a center dot. */ readonly partial?: boolean; + /** The mark behind an `advancing` row has not reached Trakt yet: a quiet dot, + * never a spinner and never green. */ + readonly pending?: boolean; /** The micro date chip that replaces the control while unaired, e.g. "Jul 16". */ readonly unairedDate?: string; onPress?(): void; @@ -56,8 +59,9 @@ function Glyph({ layer }: { readonly layer: "rest" | "fill" }): ReactElement { * a check, always carrying the glyph ("a check waiting to happen", never bare * decoration). Its look is a pure function of watched STATE (green disc = done, * quiet ring = waiting), never of the surface it sits on. There is deliberately - * NO busy state: marks are optimistic and instant, and write-queue trouble - * belongs to the SyncStrip, never to this control. + * no spinner: marks are optimistic and instant. `pending` is the one nod to the + * write queue, a quiet dot rather than a busy state, because the mark is saved + * either way and there is nothing for the user to wait on. */ export function CheckControl({ state, @@ -67,6 +71,7 @@ export function CheckControl({ testId = "mark-watched", plays, partial = false, + pending = false, unairedDate, onPress, }: CheckControlProps): ReactElement { @@ -77,19 +82,24 @@ export function CheckControl({ ); } - const filled = state !== "unwatched"; + // `advancing` is the row that has already moved on while Trakt names its next + // episode: watched, so the switch reads checked, but not green and not armed. + const filled = state === "just-marked" || state === "watched"; + const advancing = state === "advancing"; return ( diff --git a/packages/web/src/ui/components/MarqueeCard.tsx b/packages/web/src/ui/components/MarqueeCard.tsx index aaa29303..5c863e56 100644 --- a/packages/web/src/ui/components/MarqueeCard.tsx +++ b/packages/web/src/ui/components/MarqueeCard.tsx @@ -19,6 +19,8 @@ interface MarqueeCardProps { readonly episode: EpisodeRef; readonly checkState: CheckState; readonly checkLabel: string; + /** The mark behind an advancing card is still on its way to Trakt. */ + readonly checkPending?: boolean; onCheck(): void; } @@ -34,6 +36,7 @@ export function MarqueeCard({ episode, checkState, checkLabel, + checkPending, onCheck, }: MarqueeCardProps): ReactElement { // Art is deferred out of the cold-sync budget: the card reads its own backdrop @@ -92,6 +95,7 @@ export function MarqueeCard({ - + {body} ); diff --git a/packages/web/src/ui/screens/history/History.tsx b/packages/web/src/ui/screens/history/History.tsx index b6ca3705..8147c0e4 100644 --- a/packages/web/src/ui/screens/history/History.tsx +++ b/packages/web/src/ui/screens/history/History.tsx @@ -248,6 +248,7 @@ export function History(): ReactElement { body = ( } /> - +
{lockedFilter === undefined && ( diff --git a/packages/web/src/ui/screens/library/Library.tsx b/packages/web/src/ui/screens/library/Library.tsx index 4281a1ea..c606b6b0 100644 --- a/packages/web/src/ui/screens/library/Library.tsx +++ b/packages/web/src/ui/screens/library/Library.tsx @@ -403,6 +403,7 @@ export function Library(): ReactElement { body = ( - +
diff --git a/packages/web/src/ui/screens/profile/Profile.tsx b/packages/web/src/ui/screens/profile/Profile.tsx index cc9fe840..846f9c7f 100644 --- a/packages/web/src/ui/screens/profile/Profile.tsx +++ b/packages/web/src/ui/screens/profile/Profile.tsx @@ -119,6 +119,7 @@ export function Profile(): ReactElement { body = ( - + diff --git a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx index b9543e9b..c47ed7f0 100644 --- a/packages/web/src/ui/screens/show-detail/ContinueBar.tsx +++ b/packages/web/src/ui/screens/show-detail/ContinueBar.tsx @@ -8,12 +8,12 @@ import { import { epCode } from "@cue/core/domain/model/library"; import { isAired } from "@cue/core/domain/time"; import { episodesLeft, watchedPercent } from "@cue/core/format"; +import { useMarkControl } from "@cue/core/hooks/useMarkControl"; import type { MarkWatched } from "@cue/core/hooks/useMarkWatched"; import { Link } from "@tanstack/react-router"; import { CheckControl } from "@ui/components/CheckControl"; import { CountdownPanel } from "@ui/components/CountdownPanel"; import { ProgressBar } from "@ui/components/ProgressBar"; -import { useQueueCheck } from "@ui/screens/up-next/useQueueCheck"; import type { ReactElement, ReactNode } from "react"; import { continueKind } from "./detail-logic"; @@ -116,8 +116,8 @@ function NextBody({ } /** The tracked-show check: the identical advance-mode pipeline the Up Next queue - * runs (optimistic advance, live reverse window, re-arm on the authoritative - * next episode). Its own component so the hook has a stable home. */ + * runs (optimistic advance, the undo window, then the advanced row). Its own + * component so the hook has a stable home. */ function EntryCheck({ entry, mark, @@ -125,7 +125,7 @@ function EntryCheck({ readonly entry: LibraryEntry; readonly mark: MarkWatched; }): ReactElement { - const check = useQueueCheck(entry, mark); + const check = useMarkControl(entry, mark); return ( ); diff --git a/packages/web/src/ui/screens/up-next/QueueRow.tsx b/packages/web/src/ui/screens/up-next/QueueRow.tsx index c45ff965..73814ff7 100644 --- a/packages/web/src/ui/screens/up-next/QueueRow.tsx +++ b/packages/web/src/ui/screens/up-next/QueueRow.tsx @@ -1,5 +1,6 @@ import { epCode } from "@cue/core/domain/model/library"; import { episodesLeft, lastWatchedPhrase, watchedPercent } from "@cue/core/format"; +import { useMarkControl } from "@cue/core/hooks/useMarkControl"; import type { MarkWatched } from "@cue/core/hooks/useMarkWatched"; import type { UpNextCard } from "@cue/core/hooks/useUpNext"; import { CheckControl } from "@ui/components/CheckControl"; @@ -9,7 +10,6 @@ import { SwipeAction } from "@ui/components/SwipeAction"; import { useShowArt } from "@ui/hooks/useShowArt"; import type { ReactElement, ReactNode } from "react"; import { Poster } from "./Poster"; -import { useQueueCheck } from "./useQueueCheck"; interface QueueRowProps { readonly card: UpNextCard; @@ -36,7 +36,7 @@ export function QueueRow({ trailingExtra, }: QueueRowProps): ReactElement { const { entry, item } = card; - const check = useQueueCheck(entry, mark); + const check = useMarkControl(entry, mark); // Art is deferred out of the cold-sync budget: a row that settles on screen // reads its own poster. The queue is not virtualized, so every row mounts at // once; only the ones actually on screen spend a read. @@ -76,6 +76,7 @@ export function QueueRow({ size={48} mode="advance" label={check.label} + pending={check.pending} onPress={check.onPress} /> {trailingExtra} diff --git a/packages/web/src/ui/screens/up-next/UpNext.tsx b/packages/web/src/ui/screens/up-next/UpNext.tsx index 05fcde00..c4078c59 100644 --- a/packages/web/src/ui/screens/up-next/UpNext.tsx +++ b/packages/web/src/ui/screens/up-next/UpNext.tsx @@ -1,5 +1,6 @@ import { useCalendar } from "@cue/core/hooks/useCalendar"; import { useHideShow } from "@cue/core/hooks/useHideShow"; +import { useMarkControl } from "@cue/core/hooks/useMarkControl"; import { type MarkWatched, useMarkWatched } from "@cue/core/hooks/useMarkWatched"; import { type UpNextCard, useUpNext } from "@cue/core/hooks/useUpNext"; import { dismissSnack, showSnack } from "@cue/core/stores/snackbar-store"; @@ -25,7 +26,6 @@ import { LapsedDrawer } from "./LapsedDrawer"; import { buildOnTheWay, OnTheWay, useOnTheWayClock } from "./OnTheWay"; import { Previously } from "./Previously"; import { QueueRow } from "./QueueRow"; -import { useQueueCheck } from "./useQueueCheck"; /** The marquee's check shares the queue grammar; a thin slot component so the * check-state hook has a stable home per card. */ @@ -36,13 +36,14 @@ function MarqueeSlot({ readonly card: UpNextCard; readonly mark: MarkWatched; }): ReactElement { - const check = useQueueCheck(card.entry, mark); + const check = useMarkControl(card.entry, mark); return ( ); @@ -171,7 +172,7 @@ export function UpNext(): ReactElement { return (
- + {view.isLoading && ( @@ -184,6 +185,7 @@ export function UpNext(): ReactElement { {!view.isLoading && view.isError && !view.hasData && ( { - if (markedAt === null) return; - const delay = reArmDelay(markedAt, pendingAdvance, Date.now()); - if (delay === null) return; - const timer = setTimeout(() => reArm(showId), delay); - return () => clearTimeout(timer); - }, [markedAt, pendingAdvance, showId, reArm]); - - if (markedAt !== null) { - return { - state: "just-marked", - label: "Watched. Tap to remove.", - onPress: () => void mark.reverse(entry.showId), - }; - } - if (entry.pendingAdvance) { - return { state: "watched", label: "Watched", onPress: () => {} }; - } - const episode = entry.nextEpisode; - const code = episode === null ? "" : epCode(episode.season, episode.number); - return { - state: "unwatched", - label: `Mark ${entry.title} ${code} watched`, - onPress: () => void mark.mark(entry), - }; -} diff --git a/packages/web/src/ui/styles/components.css b/packages/web/src/ui/styles/components.css index a60b3aea..a5773625 100644 --- a/packages/web/src/ui/styles/components.css +++ b/packages/web/src/ui/styles/components.css @@ -96,6 +96,25 @@ stroke-dashoffset: 0; transition: stroke-dashoffset 160ms var(--ease-standard) 60ms; } +/* Advancing: the row has moved on and Trakt has yet to name its next episode. + * A dimmed ring, no fill: green would say take-back-able, which it no longer is, + * and an armed ring would offer a mark of a coordinate nobody knows yet. */ +.check[data-state="advancing"] { + cursor: default; + opacity: 0.55; +} +.check[data-state="advancing"] .check__rest { + opacity: 0.4; +} +.check[data-state="advancing"]:active .check__disc { + transform: none; + border-color: var(--color-border-strong); +} +/* The mark is still on its way to Trakt: the same quiet dot the partial state + * uses, in place of the glyph, and never a spinner. It is saved either way. */ +.check[data-pending="true"] .check__rest { + opacity: 0; +} /* Season-bulk partial: hollow ring with a quiet center dot. The rest glyph * yields entirely. Overlapped strokes read as a smudge at 44px. */ .check__dot { diff --git a/packages/web/test/ui/mark-control.test.tsx b/packages/web/test/ui/mark-control.test.tsx new file mode 100644 index 00000000..e5086447 --- /dev/null +++ b/packages/web/test/ui/mark-control.test.tsx @@ -0,0 +1,162 @@ +/** + * The queue mark control over the real mark pipeline: the defect the owner saw + * was a row that stayed green for as long as the write stayed undelivered, so + * several rows could sit green at once with no way to tell whether anything was + * wrong. These pin the fix: green is the undo window and nothing longer, the row + * advances the instant it is tapped, and outstanding delivery is a quiet + * indicator rather than a green check. + */ + +import { queryKeys } from "@cue/core/data/query-keys"; +import type { LibraryEntry } from "@cue/core/data/trakt/library"; +import type { QueuedOp } from "@cue/core/domain/write-queue/types"; +import { useMarkControl } from "@cue/core/hooks/useMarkControl"; +import { useMarkWatched } from "@cue/core/hooks/useMarkWatched"; +import { createQueryClient } from "@cue/core/runtime/query-cache"; +import { type CueRuntime, RuntimeProvider, type UpNextData } from "@cue/core/runtime/runtime"; +import { resetMarkStore } from "@cue/core/stores/mark-store"; +import { UNDO_WINDOW_MS } from "@cue/core/sync-contract"; +import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mount } from "./_mount"; + +const SHOW = 1; + +function entry(overrides: Partial = {}): LibraryEntry { + return { + showId: SHOW, + title: "Harbor Lights", + status: "returning series", + hidden: false, + inWatchlist: false, + lastWatchedAt: "2026-07-01T00:00:00.000Z", + aired: 10, + completed: 4, + nextEpisode: { + season: 3, + number: 5, + title: "Salt Air", + firstAired: "2026-01-01T00:00:00.000Z", + still: null, + ids: { trakt: 305 }, + }, + lastAired: null, + tmdbId: null, + pendingAdvance: false, + ...overrides, + }; +} + +/** A runtime whose writes never settle: the deferred queue, held open. */ +function heldRuntime(): CueRuntime { + const queued: QueuedOp[] = []; + return { + submit: (op: QueuedOp) => { + queued.push(op); + return new Promise(() => {}); + }, + pendingOps: () => [...queued], + inFlightOpId: () => null, + } as unknown as CueRuntime; +} + +interface Slot { + state: string; + pending: boolean; + label: string; + press(): void; +} + +function Row({ qc, slot }: { qc: QueryClient; slot: Slot[] }) { + const mark = useMarkWatched(); + const current = qc.getQueryData(queryKeys.library())?.entries[0] ?? entry(); + const control = useMarkControl(current, mark); + slot[0] = { ...control, press: control.onPress }; + return null; +} + +function mountRow(runtime: CueRuntime): { slot: Slot[]; qc: QueryClient } { + // The app's own client, so an hours-long case measures the fix rather than + // the default 5-minute gcTime collecting the seeded library out from under it. + const qc = createQueryClient(); + qc.setQueryData(queryKeys.library(), { entries: [entry()] }); + const slot: Slot[] = []; + mount( + + + + + , + ); + return { slot, qc }; +} + +const currentEntry = (qc: QueryClient): LibraryEntry | undefined => + qc.getQueryData(queryKeys.library())?.entries[0]; + +beforeEach(() => { + resetMarkStore(); + vi.useFakeTimers(); +}); + +describe("the queue mark control", () => { + it("advances the row and goes green in the same frame as the tap", () => { + const { slot, qc } = mountRow(heldRuntime()); + expect(slot[0]?.state).toBe("unwatched"); + + act(() => slot[0]?.press()); + + expect(currentEntry(qc)?.nextEpisode?.number).toBe(6); + expect(currentEntry(qc)?.completed).toBe(5); + expect(slot[0]?.state).toBe("just-marked"); + }); + + it("leaves green on the clock, not on the write: a held write cannot pin it", () => { + const { slot } = mountRow(heldRuntime()); + act(() => slot[0]?.press()); + expect(slot[0]?.state).toBe("just-marked"); + + // The write never settles. The row must still stop being green on schedule, + // and say plainly that the mark has not reached Trakt. + act(() => vi.advanceTimersByTime(UNDO_WINDOW_MS + 10)); + + expect(slot[0]?.state).toBe("advancing"); + expect(slot[0]?.pending).toBe(true); + expect(slot[0]?.label).toBe("Watched. Not synced yet."); + }); + + it("stays advanced rather than reverting, hours into an undelivered write", () => { + const { slot, qc } = mountRow(heldRuntime()); + act(() => slot[0]?.press()); + act(() => vi.advanceTimersByTime(3_600_000)); + + expect(slot[0]?.state).toBe("advancing"); + expect(currentEntry(qc)?.nextEpisode?.number).toBe(6); + }); + + it("reverses the mark while it is green, restoring the row exactly", () => { + const { slot, qc } = mountRow(heldRuntime()); + act(() => slot[0]?.press()); + act(() => slot[0]?.press()); + + expect(currentEntry(qc)?.nextEpisode?.number).toBe(5); + expect(currentEntry(qc)?.completed).toBe(4); + expect(slot[0]?.state).toBe("unwatched"); + }); + + it("re-arms once the authoritative next episode lands", () => { + const { slot, qc } = mountRow(heldRuntime()); + act(() => slot[0]?.press()); + // The revalidated read names the real next episode: pendingAdvance clears. + act(() => { + qc.setQueryData(queryKeys.library(), { + entries: [entry({ completed: 5, nextEpisode: entry().nextEpisode })], + }); + }); + act(() => vi.advanceTimersByTime(UNDO_WINDOW_MS + 10)); + + expect(slot[0]?.state).toBe("unwatched"); + expect(slot[0]?.label).toBe("Mark Harbor Lights S3 E5 watched"); + }); +}); diff --git a/packages/web/test/ui/sync-strip-pending.test.tsx b/packages/web/test/ui/sync-strip-pending.test.tsx deleted file mode 100644 index 7601de47..00000000 --- a/packages/web/test/ui/sync-strip-pending.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * The SyncStrip's pending signal reads the DURABLE queue depth, not only the - * in-flight flush counter: a mark deferred offline sits in the op-log with - * nothing in flight, and the strip must still say so (at least 3 pending - * for >5s → "N marks pending · will sync"). - */ - -import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; -import { SyncStrip } from "@ui/app-shell/SyncStrip"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -let root: Root | null = null; -let host: HTMLElement | null = null; -let queueDepth = 0; - -const runtime = { pendingWrites: () => queueDepth } as unknown as CueRuntime; - -function mountStrip(): void { - host = document.createElement("div"); - document.body.appendChild(host); - root = createRoot(host); - act(() => - root?.render( - - - , - ), - ); -} - -const strip = (): HTMLElement | null => document.querySelector("[data-testid='sync-strip']"); - -beforeEach(() => { - vi.useFakeTimers(); - queueDepth = 0; -}); - -afterEach(() => { - act(() => root?.unmount()); - host?.remove(); - vi.useRealTimers(); -}); - -describe("SyncStrip durable pending", () => { - it("surfaces ≥3 durable ops after the 5s grace, with the will-sync copy", () => { - queueDepth = 3; - mountStrip(); - // Inside the grace window a burst stays silent. - expect(strip()).toBeNull(); - act(() => vi.advanceTimersByTime(5100)); - expect(strip()?.getAttribute("data-state")).toBe("pending"); - expect(strip()?.textContent).toContain("3 marks pending · will sync"); - }); - - it("stays silent below the threshold", () => { - queueDepth = 2; - mountStrip(); - act(() => vi.advanceTimersByTime(6000)); - expect(strip()).toBeNull(); - }); - - it("clears once a background flush drains the queue, with no in-flight signal", () => { - queueDepth = 4; - mountStrip(); - act(() => vi.advanceTimersByTime(5100)); - expect(strip()).not.toBeNull(); - // A poll/reconnect flush drains the log without any submit bracketing; - // the strip's own coarse re-sample must notice. - queueDepth = 0; - act(() => vi.advanceTimersByTime(1100)); - expect(strip()).toBeNull(); - }); -}); diff --git a/packages/web/test/ui/sync-strip.test.tsx b/packages/web/test/ui/sync-strip.test.tsx new file mode 100644 index 00000000..a79d51af --- /dev/null +++ b/packages/web/test/ui/sync-strip.test.tsx @@ -0,0 +1,140 @@ +/** + * The strip renders the contract and decides nothing. What it has to prove here + * is the wiring the contract cannot: the durable queue depth (a mark deferred + * offline sits in the op-log with nothing in flight and is still pending), the + * shared rate-limit pause published by the read pool, and the Retry button + * appearing only when there is something for the user to retry. + */ + +import type { TraktResult } from "@cue/core/data/trakt/client"; +import { + readsPausedUntil, + resetReadPause, + withReadRateRetry, +} from "@cue/core/data/trakt/read-budget"; +import type { QueryStatus } from "@cue/core/hooks/query-freshness"; +import { type CueRuntime, RuntimeProvider } from "@cue/core/runtime/runtime"; +import { SyncStrip } from "@ui/app-shell/SyncStrip"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mount } from "./_mount"; + +let queueDepth = 0; +const runtime = { pendingWrites: () => queueDepth } as unknown as CueRuntime; + +const healthy: QueryStatus = { + isLoading: false, + isFetching: false, + isError: false, + hasData: true, + syncedAt: 0, + failure: null, + retrying: false, +}; + +function mountStrip(status: QueryStatus = healthy, onRetry?: () => void): void { + mount( + + + , + ); +} + +const strip = (): HTMLElement | null => document.querySelector("[data-testid='sync-strip']"); + +/** Open the shared read pause the way the pool does: one rate-limited read. Its + * own retries sleep on the fake clock, which is the state under test. */ +async function rateLimitReads(retryAfterMs: number): Promise { + await act(async () => { + void withReadRateRetry( + () => + Promise.resolve({ + ok: false, + error: { kind: "rate-limited", retryAfterMs }, + }) as Promise>, + ); + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + queueDepth = 0; + resetReadPause(); +}); + +afterEach(() => { + vi.useRealTimers(); + resetReadPause(); +}); + +describe("SyncStrip", () => { + it("is silent while sync is healthy", () => { + mountStrip(); + expect(strip()).toBeNull(); + }); + + it("surfaces ≥3 durable ops after the grace window, with the will-sync copy", () => { + queueDepth = 3; + mountStrip(); + // Inside the grace window a burst stays silent. + expect(strip()).toBeNull(); + act(() => vi.advanceTimersByTime(5100)); + expect(strip()?.getAttribute("data-state")).toBe("pending"); + expect(strip()?.textContent).toContain("3 marks pending · will sync"); + }); + + it("stays silent below the threshold", () => { + queueDepth = 2; + mountStrip(); + act(() => vi.advanceTimersByTime(6000)); + expect(strip()).toBeNull(); + }); + + it("clears once a background flush drains the queue, with no in-flight signal", () => { + queueDepth = 4; + mountStrip(); + act(() => vi.advanceTimersByTime(5100)); + expect(strip()).not.toBeNull(); + // A poll/reconnect flush drains the log without any submit bracketing; + // the strip's own coarse re-sample must notice. + queueDepth = 0; + act(() => vi.advanceTimersByTime(1100)); + expect(strip()).toBeNull(); + }); + + it("reads the shared rate-limit pause, names it, and offers no Retry", async () => { + await rateLimitReads(3000); + const retry = vi.fn(); + mountStrip({ ...healthy, failure: { kind: "rate-limited", retryAfterMs: 3000 } }, retry); + + expect(strip()?.getAttribute("data-state")).toBe("rate-limited"); + expect(strip()?.textContent).toContain("Trakt is limiting requests."); + expect(strip()?.textContent).not.toContain("unreachable"); + // Nothing for the user to do: the pool is holding every read until it lifts. + expect(strip()?.querySelector(".sync-strip__retry")).toBeNull(); + }); + + it("retracts on its own once the pause lifts", async () => { + await rateLimitReads(3000); + mountStrip(); + expect(strip()).not.toBeNull(); + act(() => vi.advanceTimersByTime(3100)); + expect(strip()).toBeNull(); + }); + + it("offers Retry only for a settled failure over cached content", () => { + const retry = vi.fn(); + mountStrip({ ...healthy, isError: true, failure: { kind: "network" } }, retry); + expect(strip()?.getAttribute("data-state")).toBe("unreachable"); + expect(strip()?.textContent).toContain("Can't reach Trakt. Showing your cached data."); + strip()?.querySelector(".sync-strip__retry")?.click(); + expect(retry).toHaveBeenCalledOnce(); + }); + + it("leaves the pause at zero when nothing rate limited anything", () => { + mountStrip(); + expect(readsPausedUntil()).toBe(0); + expect(strip()).toBeNull(); + }); +}); From a527aaaf98e17f225f66763001114711f6146717 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:13:12 -0500 Subject: [PATCH 048/435] Read the sync contract from the native queue The one native screen that marks anything takes its check grammar and its ambient line from the same module the web screens do, so the contract is proved consumable on this target before the visual layer exists. --- packages/native/src/screens/UpNext.tsx | 71 +++++++++++++++++--------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/native/src/screens/UpNext.tsx b/packages/native/src/screens/UpNext.tsx index 99a587d2..65374f2b 100644 --- a/packages/native/src/screens/UpNext.tsx +++ b/packages/native/src/screens/UpNext.tsx @@ -1,26 +1,64 @@ import { epCode } from "@cue/core/domain/model/library"; +import { useMarkControl } from "@cue/core/hooks/useMarkControl"; import { useMarkWatched } from "@cue/core/hooks/useMarkWatched"; -import { useUpNext } from "@cue/core/hooks/useUpNext"; +import { useSyncBanner } from "@cue/core/hooks/useSyncBanner"; +import { type UpNextCard, useUpNext } from "@cue/core/hooks/useUpNext"; +import { readFailureBody } from "@cue/core/sync-contract"; import { Link } from "expo-router"; import type { ReactElement } from "react"; import { FlatList, Pressable, Text, View } from "react-native"; +/** One row's check, over the shared grammar: green only through the undo + * window, then the advanced row, with a quiet note while the mark is still on + * its way to Trakt. Its own component so the hook has a stable home per card. */ +function Row({ card }: { readonly card: UpNextCard }): ReactElement { + const control = useMarkControl(card.entry, useMarkWatched()); + return ( + + {card.entry.title} + {epCode(card.item.episode.season, card.item.episode.number)} + + {control.state} + + {control.pending && Not synced yet} + + ); +} + /** - * Up Next, over the shared hook and nothing else. Unstyled on purpose: the + * Up Next, over the shared hooks and nothing else. Unstyled on purpose: the * visual layer is its own piece of work, and what this has to prove first is - * that the whole read path, the persisted cache, the write queue and the mark - * surfaces already work on this target without a line of new logic. + * that the whole read path, the persisted cache, the write queue and the sync + * contract already work on this target without a line of new logic. */ export function UpNext(): ReactElement { - const { queue, isLoading, isError, hasData } = useUpNext(); - const marking = useMarkWatched(); + const view = useUpNext(); + const banner = useSyncBanner(view); - if (isLoading) return Loading your queue…; - if (isError && !hasData) return Couldn't load your queue.; + if (view.isLoading) return Loading your queue…; + if (view.isError && !view.hasData) { + return ( + + Couldn't load your queue. + {readFailureBody(view.failure)} + + ); + } return ( Up Next + {banner !== null && ( + + {banner.message} + + )} {/* The account area is behind the header avatar on every tab root; this is that entry point before the header exists. */} @@ -28,23 +66,10 @@ export function UpNext(): ReactElement { String(card.entry.showId)} ListEmptyComponent={Nothing queued.} - renderItem={({ item: card }) => ( - - {card.entry.title} - {epCode(card.item.episode.season, card.item.episode.number)} - void marking.mark(card.entry)} - > - Mark watched - - - )} + renderItem={({ item: card }) => } /> ); From 4440e21def5d86f895532463b5edbe24d248fc45 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:13:21 -0500 Subject: [PATCH 049/435] Cover both defects end to end against a misbehaving Trakt The mock lane gains the two the owner reported: a 429 burst that must not read as an outage and must clear itself, and a mark that must advance its row in the frame of the tap and clear its pending note when the write lands. Both are driven by arming the fake Trakt rather than by intercepting requests, because both are properties of Trakt's answers and the native app has to survive the same ones. The hermetic specs move with the behaviour. Two of them failed a single read and expected an error state; a single failed read is now absorbed by the retry, so one becomes the case that proves the blip never surfaces and the others fail for as long as it takes the budget to run out. Retry appears only once the app has stopped trying on its own. --- packages/web/e2e/history.spec.ts | 11 +-- packages/web/e2e/mock/06-sync-truth.spec.ts | 88 +++++++++++++++++++++ packages/web/e2e/mock/_flow.ts | 21 +++++ packages/web/e2e/up-next.spec.ts | 24 ++++-- packages/web/e2e/upcoming.spec.ts | 28 ++++++- 5 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 packages/web/e2e/mock/06-sync-truth.spec.ts diff --git a/packages/web/e2e/history.spec.ts b/packages/web/e2e/history.spec.ts index 1032ea33..11b5c593 100644 --- a/packages/web/e2e/history.spec.ts +++ b/packages/web/e2e/history.spec.ts @@ -168,12 +168,12 @@ test("same-item plays within a day collapse to one ×N row whose check removes t test("surfaces a load-earlier failure inline and recovers on Retry", async ({ page }) => { await installHistoryRoutes(page.context(), recentRows()); - // Fail the FIRST second-page fetch, then let later ones through. - let failedPageTwo = false; + // Fail every second-page fetch until the test relents: a 500 is retried on its + // own now, so only an outage that outlasts the budget reaches the screen. + let failPageTwo = true; await page.context().route(/\/users\/me\/history(\/episodes|\/movies)?(\?|$)/, async (route) => { const isPageTwo = new URL(route.request().url()).searchParams.get("page") === "2"; - if (isPageTwo && !failedPageTwo) { - failedPageTwo = true; + if (isPageTwo && failPageTwo) { return route.fulfill({ status: 500, contentType: "application/json", body: "{}" }); } return route.fallback(); @@ -183,9 +183,10 @@ test("surfaces a load-earlier failure inline and recovers on Retry", async ({ pa // The auto-scroll pull fails: the failure surfaces inline, first-page data // stays put, and the control becomes an explicit Retry (the observer disarms // so an outage can't be hammered on every scroll twitch). - await expect(page.getByTestId("history-load-earlier-error")).toBeVisible(); + await expect(page.getByTestId("history-load-earlier-error")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("history-day-heading")).toHaveCount(1); + failPageTwo = false; await page.getByTestId("history-load-earlier").click(); await expect(page.getByTestId("history-day-heading")).toHaveCount(2); await expect(page.getByTestId("history-day-heading").nth(1)).toContainText("Yesterday"); diff --git a/packages/web/e2e/mock/06-sync-truth.spec.ts b/packages/web/e2e/mock/06-sync-truth.spec.ts new file mode 100644 index 00000000..abe6f23f --- /dev/null +++ b/packages/web/e2e/mock/06-sync-truth.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; +import { armFault, clearFaults, connect, landed, settle } from "./_flow"; + +/** + * The two defects the owner reported, driven against a Trakt that misbehaves on + * purpose. Both were the app describing its own state wrongly: a rate limit + * reported as an outage over data that was on the screen and fine, and a row + * that stayed green for as long as the write stayed undelivered. + * + * They belong in this lane rather than the hermetic one because both are + * properties of Trakt's ANSWERS, and the native app has to survive the same + * ones. Faults are cleared after each, so the account the earlier flows left + * behind is the account this one leaves behind. + */ + +test.afterEach(async () => { + await clearFaults(); +}); + +test("a 429 burst never says Trakt is unreachable, and clears itself", async ({ page }) => { + await connect(page); + await expect(page.getByTestId("up-next-card").first()).toBeVisible(); + await settle(page); + await expect(page.getByTestId("sync-strip")).toHaveCount(0); + + // Trakt closes the window on the library read twice, then serves it. The queue + // on screen is real and current; the only thing in trouble is a refresh. + await armFault({ + match: "reads", + path: "^/sync/watched/shows", + status: 429, + retryAfter: 3, + count: 2, + }); + await page.getByTestId("up-next-card").first().getByTestId("mark-watched").click(); + + const strip = page.getByTestId("sync-strip"); + await expect(strip).toHaveAttribute("data-state", "rate-limited"); + await expect(strip).toContainText("Trakt is limiting requests."); + await expect(strip).toContainText("Retrying"); + await expect(strip).not.toContainText("unreachable"); + // Nothing for the user to do: every read is held until the window reopens. + await expect(strip.getByRole("button", { name: "Retry" })).toHaveCount(0); + // The queue it was showing is still there, never wiped into an error screen. + await expect(page.getByTestId("up-next-card").first()).toBeVisible(); + await expect(page.getByTestId("up-next-error")).toHaveCount(0); + + // The window reopens, the held read goes, and the strip retracts on its own. + await expect(strip).toHaveCount(0, { timeout: 20_000 }); +}); + +test("a mark advances the row at once and clears its pending note when the write lands", async ({ + page, +}) => { + await connect(page); + const lead = page.getByTestId("up-next-card").first(); + await expect(lead).toBeVisible(); + const code = await lead.locator(".ep-row__code").textContent(); + + // Trakt refuses the write outright for a stretch, so the durable queue defers + // it. The row must advance anyway: the queue guarantees delivery. + await armFault({ match: "writes", status: 429, retryAfter: 1, forMs: 12_000 }); + await lead.getByTestId("mark-watched").click(); + + // In the frame of the tap: the episode line has already moved on. + await expect(lead.locator(".ep-row__code")).not.toHaveText(code ?? ""); + const check = lead.getByTestId("mark-watched"); + await expect(check).toHaveAttribute("data-state", "just-marked"); + + // Green is the undo window and nothing longer. Past it the row reads as + // advanced, with a quiet note that the mark has not reached Trakt yet. + await expect(check).toHaveAttribute("data-state", "advancing", { timeout: 15_000 }); + await expect(check).toHaveAttribute("data-pending", "true"); + + // Trakt starts accepting writes again. The user asks for a sync rather than + // waiting out the poll; the POST lands, and the note goes with it. + await clearFaults(); + await page.getByTestId("avatar-link").click(); + await page.getByTestId("link-settings").click(); + await page.getByTestId("sync-now").click(); + await landed(page); + + await page.getByRole("link", { name: "Up Next", exact: true }).first().click(); + const settled = page.getByTestId("up-next-card").first().getByTestId("mark-watched"); + await expect(settled).toHaveAttribute("data-state", "unwatched"); + await expect(settled).not.toHaveAttribute("data-pending", "true"); + await settle(page); +}); diff --git a/packages/web/e2e/mock/_flow.ts b/packages/web/e2e/mock/_flow.ts index eab7870c..2fc2c481 100644 --- a/packages/web/e2e/mock/_flow.ts +++ b/packages/web/e2e/mock/_flow.ts @@ -70,3 +70,24 @@ export async function landed(page: Page): Promise { ) .toBe("[]"); } + +/** The fake Trakt's origin, where its fault control plane lives. */ +const MOCK_TRAKT = "http://127.0.0.1:8787"; + +/** + * Arm the fake Trakt's fault modes for the next few requests. This is the one + * place a flow configures the SERVER rather than intercepting the app: what a + * rate limit or an outage does to Cue is a property of Trakt's answers, and the + * native app has to survive the same ones. + */ +export async function armFault(rule: Record): Promise { + const response = await fetch(`${MOCK_TRAKT}/__fault`, { + method: "POST", + body: JSON.stringify(rule), + }); + expect(response.ok).toBe(true); +} + +export async function clearFaults(): Promise { + await fetch(`${MOCK_TRAKT}/__fault`, { method: "DELETE" }); +} diff --git a/packages/web/e2e/up-next.spec.ts b/packages/web/e2e/up-next.spec.ts index 882a1b31..51eaa8f4 100644 --- a/packages/web/e2e/up-next.spec.ts +++ b/packages/web/e2e/up-next.spec.ts @@ -511,12 +511,16 @@ test("a read error over a warm cache keeps the queue under the SyncStrip error v controls.setReadMode("abort"); await page.getByTestId("mark-watched").click(); // triggers a revalidate that will fail const strip = page.getByTestId("sync-strip"); - await expect(strip).toHaveAttribute("data-state", "error"); - await expect(strip).toContainText("Trakt unreachable. Showing your cached data."); + await expect(strip).toHaveAttribute("data-state", "unreachable"); + await expect(strip).toContainText("Can't reach Trakt. Showing your cached data."); await expect(page.getByTestId("up-next-card")).toHaveCount(1); + // Retry appears only once the read has spent its own attempts: while the app + // is still trying there is nothing for the user to do. + const retry = strip.getByRole("button", { name: "Retry" }); + await expect(retry).toBeVisible({ timeout: 15_000 }); controls.setReadMode("ok"); - await strip.getByRole("button", { name: "Retry" }).click(); + await retry.click(); await expect(page.getByTestId("sync-strip")).toHaveCount(0); }); @@ -529,12 +533,14 @@ test("one show's progress outage keeps the warm queue instead of erasing it", as // survive under the strip, never silently collapse to "all caught up". controls.failProgressFor([1]); await page.getByTestId("mark-watched").click(); - await expect(page.getByTestId("sync-strip")).toHaveAttribute("data-state", "error"); + await expect(page.getByTestId("sync-strip")).toHaveAttribute("data-state", "unreachable"); await expect(page.getByTestId("up-next-card")).toHaveCount(1); await expect(page.getByTestId("empty-all-caught-up")).toHaveCount(0); + const retry = page.getByTestId("sync-strip").getByRole("button", { name: "Retry" }); + await expect(retry).toBeVisible({ timeout: 15_000 }); controls.failProgressFor([]); - await page.getByTestId("sync-strip").getByRole("button", { name: "Retry" }).click(); + await retry.click(); await expect(page.getByTestId("sync-strip")).toHaveCount(0); }); @@ -554,8 +560,14 @@ test("boot survives a startup-reconcile outage: the app mounts instead of hangin await expect(page.getByTestId("screen-up-next")).toBeVisible(); await expect(page.getByTestId("runtime-loading")).toHaveCount(0); + // With nothing cached the screen's own error carries the failure, honestly: + // a strip claiming to be showing cached data over an empty screen would be a + // second message and a false one. await expect(page.getByTestId("up-next-error")).toBeVisible(); - await expect(page.getByTestId("sync-strip")).toHaveAttribute("data-state", "error"); + await expect(page.getByTestId("up-next-error")).toContainText( + "Check your connection and try again.", + ); + await expect(page.getByTestId("sync-strip")).toHaveCount(0); const logRaw = await readStored(page, "cue.write-queue"); const log = JSON.parse(logRaw ?? "[]") as unknown[]; diff --git a/packages/web/e2e/upcoming.spec.ts b/packages/web/e2e/upcoming.spec.ts index 8f89545a..326eb1c5 100644 --- a/packages/web/e2e/upcoming.spec.ts +++ b/packages/web/e2e/upcoming.spec.ts @@ -182,11 +182,10 @@ test("a long agenda stays virtualized: bounded window, yet scrolling reaches lat expect(await page.getByTestId("virtual-row").count()).toBeLessThan(60); }); -test("a calendar outage without cache shows the retry state, and recovery fills the agenda", async ({ +test("a single failed read is absorbed by the retry, never shown as an outage", async ({ page, }) => { await installCalendarRoutes(page.context(), spreadFixture()); - // First read fails hard; the screen must offer Retry rather than a blank. let failed = false; await page.context().route("**/api.trakt.tv/calendars/my/shows/*/*", (route) => { if (!failed) { @@ -197,7 +196,30 @@ test("a calendar outage without cache shows the retry state, and recovery fills }); await page.goto("/calendar"); - await expect(page.getByTestId("upcoming-error")).toBeVisible(); + // The retry lands and the agenda fills; the user is never told about a blip + // the app recovered from on its own. + await expect(page.getByTestId("calendar-row")).toHaveCount(4); + await expect(page.getByTestId("upcoming-error")).toHaveCount(0); + await expect(page.getByTestId("sync-strip")).toHaveCount(0); +}); + +test("a sustained calendar outage shows the retry state, and recovery fills the agenda", async ({ + page, +}) => { + await installCalendarRoutes(page.context(), spreadFixture()); + // Every attempt fails until the test relents, so the read spends its budget + // and the screen has something honest to say. + let failing = true; + await page.context().route("**/api.trakt.tv/calendars/my/shows/*/*", (route) => { + return failing ? route.abort() : route.fallback(); + }); + await page.goto("/calendar"); + + await expect(page.getByTestId("upcoming-error")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("upcoming-error")).toContainText( + "Check your connection and try again.", + ); + failing = false; await page.getByTestId("upcoming-error-retry").click(); await expect(page.getByTestId("calendar-row")).toHaveCount(4); }); From ad5918897d6991a915b8ecd7a8e468b4ae93155a Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 01:48:30 -0500 Subject: [PATCH 050/435] Gate generated iOS privacy settings Verify transport security, scene lifecycle, Face ID usage, and the entitlement set after native iOS prebuild. Run the artifact check before the simulator build and correct the app configuration claim. --- .dependency-cruiser.cjs | 2 +- .github/workflows/ci.yml | 2 + .github/workflows/mobile-release.yml | 1 + packages/native/app.config.ts | 6 +-- packages/web/test/ci/release-paths.test.ts | 1 + scripts/verify-ios-privacy.sh | 43 ++++++++++++++++++++++ 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100755 scripts/verify-ios-privacy.sh diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 8e86a13b..ed5569ed 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -26,7 +26,7 @@ const RE_DOES_NOT_SHIP_DIRECTORY = "^(docs|\\.github|assets|scripts/mock-trakt|packages/[^/]+/(e2e|test|__tests__))(/|$)"; const RE_DOES_NOT_SHIP_MARKDOWN = "^[^/]*\\.md$"; const RE_DOES_NOT_SHIP_FILE = - "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|scripts/write-buster\\.mjs|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|jest\\.config\\.js|\\.gitignore|\\.env\\.(example|test|mock)))$"; + "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|scripts/(verify-ios-privacy\\.sh|write-buster\\.mjs)|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|jest\\.config\\.js|\\.gitignore|\\.env\\.(example|test|mock)))$"; const { join } = require("node:path"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f6ae682..bf1707d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,8 @@ jobs: - run: pnpm --filter @cue/native exec expo prebuild --platform ios --clean env: EXPO_PUBLIC_TRAKT_CLIENT_ID: ci + - name: Check the generated iOS privacy configuration + run: scripts/verify-ios-privacy.sh packages/native/ios/Cue/Info.plist packages/native/ios/Cue/Cue.entitlements - name: Build the app for the simulator working-directory: packages/native/ios run: | diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 688ff1db..4ff47d53 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -41,6 +41,7 @@ on: ".jscpd.json", ".dependency-cruiser.cjs", "scripts/diff-footprint.sh", + "scripts/verify-ios-privacy.sh", "scripts/mock-trakt/**", "scripts/write-buster.mjs", "tsconfig.depcruise.json", diff --git a/packages/native/app.config.ts b/packages/native/app.config.ts index 7092aff9..b73dc4c4 100644 --- a/packages/native/app.config.ts +++ b/packages/native/app.config.ts @@ -76,9 +76,9 @@ export function nativeAppConfig(env: Readonly /** * The fake Trakt's origin, and the one reason this app would ever load plain - * HTTP. Read here so a build that is not pointed at the harness carries no - * `NSAppTransportSecurity` key at all rather than a disabled exception: the - * privacy gate asserts that key's absence out of the built `Info.plist`. + * HTTP. Read here so a build that is not pointed at the harness adds no ATS + * exception domains. The generated-project privacy gate requires arbitrary + * loads to stay disabled and exception domains to stay absent. */ const mockTrakt = env["EXPO_PUBLIC_TRAKT_API_BASE"]; const mockTraktHost = diff --git a/packages/web/test/ci/release-paths.test.ts b/packages/web/test/ci/release-paths.test.ts index a0faba2b..04ee2c78 100644 --- a/packages/web/test/ci/release-paths.test.ts +++ b/packages/web/test/ci/release-paths.test.ts @@ -74,6 +74,7 @@ const DOES_NOT_SHIP = [ ".jscpd.json", ".dependency-cruiser.cjs", "scripts/diff-footprint.sh", + "scripts/verify-ios-privacy.sh", "scripts/mock-trakt/**", "scripts/write-buster.mjs", "tsconfig.depcruise.json", diff --git a/scripts/verify-ios-privacy.sh b/scripts/verify-ios-privacy.sh new file mode 100755 index 00000000..30cb62f1 --- /dev/null +++ b/scripts/verify-ios-privacy.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Usage: verify-ios-privacy.sh +set -euo pipefail + +info_plist=$1 +entitlements=$2 +plist_buddy=/usr/libexec/PlistBuddy + +if [ ! -f "$info_plist" ] || [ ! -f "$entitlements" ]; then + echo "verify-ios-privacy: generated Info.plist or entitlements file is missing." >&2 + exit 1 +fi + +if [ "$("$plist_buddy" -c 'Print :NSAppTransportSecurity:NSAllowsArbitraryLoads' "$info_plist")" != "false" ]; then + echo "verify-ios-privacy: NSAllowsArbitraryLoads must be false." >&2 + exit 1 +fi + +if "$plist_buddy" -c 'Print :NSAppTransportSecurity:NSExceptionDomains' "$info_plist" >/dev/null 2>&1; then + echo "verify-ios-privacy: NSExceptionDomains must be absent." >&2 + exit 1 +fi + +delegate=$( + "$plist_buddy" -c 'Print :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneDelegateClassName' "$info_plist" +) +if [ "$delegate" != '$(PRODUCT_MODULE_NAME).SceneDelegate' ]; then + echo "verify-ios-privacy: the application scene has no generated SceneDelegate." >&2 + exit 1 +fi + +if "$plist_buddy" -c 'Print :NSFaceIDUsageDescription' "$info_plist" >/dev/null 2>&1; then + echo "verify-ios-privacy: NSFaceIDUsageDescription must be absent." >&2 + exit 1 +fi + +if [ "$(grep -c '' "$entitlements")" != "1" ] || + [ "$("$plist_buddy" -c 'Print :aps-environment' "$entitlements")" != "development" ]; then + echo "verify-ios-privacy: generated entitlements differ from the expected baseline." >&2 + exit 1 +fi + +echo "verify-ios-privacy: transport security, scene lifecycle, Face ID usage, and entitlements are valid." From 7ca45e4ea5263e3bb98530f713997e0c63506d65 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 01:50:08 -0500 Subject: [PATCH 051/435] Reject unresolved package imports Treat dependency-cruiser's unknown edges as undeclared package imports. Exclude the Vite ambient type reference so the architecture baseline remains clean. --- .dependency-cruiser.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index ed5569ed..ff2bc1ac 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -66,8 +66,8 @@ module.exports = { severity: "error", comment: "dependency-cruiser's own no-non-package-json, anchored at the packages. A package may import only what its OWN manifest declares. `nodeLinker: hoisted` (pnpm-workspace.yaml) puts every transitive dependency at the workspace root where any package can reach it undeclared, which is the strictness the default linker exists to provide and the price the native package's resolver charges for it. knip's dependency lane does not close this: react is a peerDependency of @tanstack/react-query, so it read 33 undeclared react imports in @cue/core as satisfied.", - from: { path: "^packages/" }, - to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"] }, + from: { path: "^packages/", pathNot: "^packages/web/src/vite-env\\.d\\.ts$" }, + to: { dependencyTypes: ["npm-no-pkg", "npm-unknown", "unknown"] }, }, { name: "domain-stays-pure", From 52996af0365792e9d4417b39cf5c5c8c8a7b4f32 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:32:39 -0500 Subject: [PATCH 052/435] Measure every tree at the measurement threshold Keep the unsuppressed shadow inside the head checkout, because Biome resolves a nested config only for paths under the working directory's project and silently applied the root threshold of fifteen to a base worktree. Read the threshold out of the diagnostic so any future fallback parses as zero functions and fails. --- scripts/measure-complexity.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/measure-complexity.mjs b/scripts/measure-complexity.mjs index f98a92d7..c5259892 100644 --- a/scripts/measure-complexity.mjs +++ b/scripts/measure-complexity.mjs @@ -11,7 +11,7 @@ if (treeArgument === undefined || outputArgument === undefined) { const headRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const tree = path.resolve(treeArgument); const output = path.resolve(outputArgument); -const shadow = path.join(tree, ".complexity-measure"); +const shadow = path.join(headRoot, ".complexity-measure"); const files = execFileSync( "git", [ @@ -59,7 +59,7 @@ rmSync(shadow, { force: true, recursive: true }); const report = JSON.parse(run.stdout); const parsed = report.diagnostics.flatMap(({ location, message }) => { - const match = /Excessive complexity of (\d+)/.exec(message); + const match = /Excessive complexity of (\d+) detected \(max: 1\)/.exec(message); const productPath = /packages\/[^/]+\/(?:src|app|modules)\/.+/.exec(location.path)?.[0]; return match === null || productPath === undefined ? [] From c8ed3ccc528a4ccc5aff2ee81ad0377bd8f2b452 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:33:02 -0500 Subject: [PATCH 053/435] Drop the redundant CodeQL path filter The .github tree is already listed in both the release path filter and the non-shipping partition, so naming the CodeQL workflow again adds a pattern that can never match first. --- .github/workflows/mobile-release.yml | 1 - packages/web/test/ci/release-paths.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 6a9ff664..c65f6a06 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -41,7 +41,6 @@ on: ".jscpd.json", ".dependency-cruiser.cjs", ".size-limit.json", - ".github/workflows/codeql.yml", "scripts/assert-file-size.mjs", "scripts/assert-ipa-size.mjs", "scripts/check-size.mjs", diff --git a/packages/web/test/ci/release-paths.test.ts b/packages/web/test/ci/release-paths.test.ts index 533833f4..622d7cec 100644 --- a/packages/web/test/ci/release-paths.test.ts +++ b/packages/web/test/ci/release-paths.test.ts @@ -75,7 +75,6 @@ const DOES_NOT_SHIP = [ ".jscpd.json", ".dependency-cruiser.cjs", ".size-limit.json", - ".github/workflows/codeql.yml", "scripts/assert-file-size.mjs", "scripts/assert-ipa-size.mjs", "scripts/check-size.mjs", From e08a94127402e099378518279408380c69edbbc8 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:33:23 -0500 Subject: [PATCH 054/435] Keep the shipping verification scripts out of the non-shipping list verify-apk.sh and verify-bundle.sh are classified as shipping in release-paths.test.ts, which this regex is documented to mirror, so listing them here put the two authorities in conflict. --- .dependency-cruiser.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 6b15ef4e..9141e523 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -26,7 +26,7 @@ const RE_DOES_NOT_SHIP_DIRECTORY = "^(docs|\\.github|assets|scripts/(complexity|mock-trakt)|packages/[^/]+/(e2e|test|__tests__))(/|$)"; const RE_DOES_NOT_SHIP_MARKDOWN = "^[^/]*\\.md$"; const RE_DOES_NOT_SHIP_FILE = - "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|\\.size-limit\\.json|scripts/(assert-file-size|assert-ipa-size|check-size|measure-comments|measure-complexity)\\.mjs|scripts/(diff-footprint|measure-sizes|verify-apk|verify-bundle)\\.sh|scripts/write-buster\\.mjs|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|jest\\.config\\.js|\\.gitignore|\\.env\\.(example|test|mock)))$"; + "^(LICENSE|vitest\\.config\\.ts|lefthook\\.yml|cspell\\.json|dprint\\.json|biome\\.jsonc|knip\\.json|\\.jscpd\\.json|\\.dependency-cruiser\\.cjs|\\.gitignore|\\.size-limit\\.json|scripts/(assert-file-size|assert-ipa-size|check-size|measure-comments|measure-complexity)\\.mjs|scripts/(diff-footprint|measure-sizes)\\.sh|scripts/write-buster\\.mjs|tsconfig\\.depcruise\\.json|packages/[^/]+/(playwright\\.config\\.ts|vitest\\.config\\.ts|jest\\.config\\.js|\\.gitignore|\\.env\\.(example|test|mock)))$"; const { join } = require("node:path"); From c3f4959b160b209c07c3c4ba690091d951174a7c Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:38:16 -0500 Subject: [PATCH 055/435] The native token map: the palette as DynamicColorIOS pairs, and the metrics Every color is a light/dark pair transcribed from packages/web/src/ui/styles.css. On iOS the pair becomes a DynamicColorIOS value the system resolves, so Appearance.setColorScheme drives the palette, useColorScheme and the status bar together and the three-way Theme setting needs no per-token plumbing; on Android useColors resolves the pair against the scheme. --color-border-strong is retired with its five web users: the check ring and the Library filter stroke become --color-muted, and the sheet grabber, the History search field and the Settings switch are drawn by the platform. --color-focus goes with the CSS focus ring. Separators are the platform separator on iOS and --color-border on Android, which is what HorizontalDivider's outlineVariant resolves to. Amber fills carry a stroke token whose dark half is transparent, so a fill draws it unconditionally instead of branching on the theme. Beside the palette: the 8-step spacing scale, the radii, the per-row minimum heights, the 44 pt and 48 dp target floors, the two check sizes, the hairline and the font scale at which a row's trailing controls move below its text. --- packages/native/__tests__/ui/tokens.test.ts | 56 ++++++++ packages/native/src/ui/tokens.ts | 134 ++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/native/__tests__/ui/tokens.test.ts create mode 100644 packages/native/src/ui/tokens.ts diff --git a/packages/native/__tests__/ui/tokens.test.ts b/packages/native/__tests__/ui/tokens.test.ts new file mode 100644 index 00000000..89a11fed --- /dev/null +++ b/packages/native/__tests__/ui/tokens.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from "@testing-library/react-native"; +import { Platform, processColor, useColorScheme } from "react-native"; +import { useColors } from "../../src/ui/tokens"; + +/** + * The React Native jest preset already replaces `useColorScheme` with a mock + * pinned to "light", which is the seam this file drives: the palette's whole + * Android half is a function of that value, and its iOS half deliberately is + * not. Both projects run the file, because a single-platform run would miss + * exactly the two divergences below. + */ +const scheme = jest.mocked(useColorScheme); + +async function colorsUnder(value: "light" | "dark") { + scheme.mockReturnValue(value); + const { result } = await renderHook(() => useColors()); + return result.current; +} + +afterEach(() => scheme.mockReturnValue("light")); + +it("resolves the scheme on Android and leaves it to the system on iOS", async () => { + const dark = await colorsUnder("dark"); + const light = await colorsUnder("light"); + + if (Platform.OS === "ios") { + // One map of dynamic values, so nothing here changes when the scheme does. + expect(light.bg).toBe(dark.bg); + } else { + expect(dark.bg).toBe("#0e0c0a"); + expect(light.bg).toBe("#fbfaf7"); + } +}); + +it("takes the separator from the platform on iOS and from --color-border on Android", async () => { + const colors = await colorsUnder("dark"); + + if (Platform.OS === "ios") { + expect(colors.separator).not.toBe(colors.border); + } else { + expect(colors.separator).toBe(colors.border); + } +}); + +it("carries a light-only stroke for amber fills, so a fill can draw it unconditionally", async () => { + if (Platform.OS === "ios") { + // A dynamic value cannot be read back, so what is checkable here is that the + // token exists and is not the raw light hex the Android branch resolves to. + expect((await colorsUnder("dark")).accentFillStroke).not.toBe("#935800"); + return; + } + expect(processColor((await colorsUnder("dark")).accentFillStroke)).toBe( + processColor("transparent"), + ); + expect((await colorsUnder("light")).accentFillStroke).toBe("#935800"); +}); diff --git a/packages/native/src/ui/tokens.ts b/packages/native/src/ui/tokens.ts new file mode 100644 index 00000000..3e531d5b --- /dev/null +++ b/packages/native/src/ui/tokens.ts @@ -0,0 +1,134 @@ +import { + type ColorValue, + DynamicColorIOS, + Platform, + PlatformColor, + StyleSheet, + useColorScheme, +} from "react-native"; + +/** + * The design tokens, resolved for React Native. + * + * Every color below is a light/dark pair transcribed from the web app's + * `src/ui/styles.css`, which is the one place the palette is defined. The pairs + * are what iOS wants: a `DynamicColorIOS` value resolves itself, including under + * `Appearance.setColorScheme`, so the three-way Theme setting drives the whole + * palette, `useColorScheme` and `expo-status-bar` together and no token needs + * plumbing of its own. Android has no such value, so `useColors` resolves the + * pair against `useColorScheme` there. + * + * Two tokens do not survive the port. `--color-border-strong` is retired: its + * five web users are the check ring, the sheet grabber, the History search + * input, the Settings switch and the Library filter stroke, and on native the + * first and last become `muted` while the middle three are drawn by the + * platform. `--color-focus` goes with the CSS focus ring it exists for. + */ +const PALETTE = { + bg: { light: "#fbfaf7", dark: "#0e0c0a" }, + surface: { light: "#ffffff", dark: "#17140f" }, + elevated: { light: "#f4f1ea", dark: "#221d16" }, + overlay: { light: "#ffffff", dark: "#2b241b" }, + border: { light: "#e7e2d8", dark: "#33291e" }, + fg: { light: "#1a1712", dark: "#f4efe7" }, + ink2: { light: "#514a3e", dark: "#c6baa8" }, + muted: { light: "#6e6656", dark: "#978a78" }, + accent: { light: "#f0a62a", dark: "#f5b841" }, + accentStrong: { light: "#d98f13", dark: "#ffc966" }, + accentFg: { light: "#1a1206", dark: "#140e04" }, + accentInk: { light: "#935800", dark: "#f5b841" }, + progress: { light: "#b26a00", dark: "#f5b841" }, + track: { light: "#e7e2d8", dark: "#2a241c" }, + watched: { light: "#1e7a54", dark: "#57c996" }, + watchedFg: { light: "#ffffff", dark: "#08130d" }, + ok: { light: "#1f7a54", dark: "#5fbe97" }, + danger: { light: "#b4231b", dark: "#f0857a" }, + // Theme-invariant: text over artwork always sits on a dark scrim. + scrim: { light: "rgba(10,8,6,0.86)", dark: "rgba(10,8,6,0.86)" }, + onImage: { light: "#ffffff", dark: "#ffffff" }, + onImage2: { light: "#d9cfc0", dark: "#d9cfc0" }, + /** + * The stroke every amber fill carries so a selected state is not identified by + * a fill alone: `#f0a62a` reads 1.97:1 against the light page and 1.83:1 + * against the elevated fill beside it, which fails WCAG 1.4.11. On dark the + * same fill is 10.98:1 and needs nothing, so the dark half is transparent and + * a fill can draw the stroke unconditionally. + */ + accentFillStroke: { light: "#935800", dark: "transparent" }, + /** + * List separators. On iOS this is the system color, so it tracks Increase + * Contrast, which a Cue token cannot. Android's `HorizontalDivider` takes + * `outlineVariant`, which is `--color-border`; there is no framework constant + * behind it and no setting that repaints it, so the token stands. + */ + separator: { light: "#e7e2d8", dark: "#33291e" }, +} as const; + +type Pair = (typeof PALETTE)[keyof typeof PALETTE]; +export type ColorToken = keyof typeof PALETTE; +export type Colors = Readonly>; + +function mapPalette(pick: (pair: Pair) => ColorValue): Colors { + return Object.fromEntries( + Object.entries(PALETTE).map(([token, pair]) => [token, pick(pair)]), + ) as Colors; +} + +const LIGHT = mapPalette((pair) => pair.light); +const DARK = mapPalette((pair) => pair.dark); + +/** Built only on iOS: `DynamicColorIOS` throws everywhere else. */ +const DYNAMIC: Colors | null = + Platform.OS === "ios" + ? { ...mapPalette(DynamicColorIOS), separator: PlatformColor("separator") } + : null; + +/** + * The palette this render should draw with. On iOS it is one constant map of + * dynamic values the system resolves; on Android it is the resolved map for the + * scheme, which is what makes the Theme setting repaint without per-token work. + */ +export function useColors(): Colors { + const scheme = useColorScheme(); + return DYNAMIC ?? (scheme === "light" ? LIGHT : DARK); +} + +/** The 8-step scale, in points and density-independent pixels, never scalable units. */ +export const SPACE = { s1: 4, s2: 8, s3: 12, s4: 16, s5: 24, s6: 32, s7: 48, s8: 64 } as const; + +export const RADIUS = { poster: 8, control: 12, card: 16, sheet: 20, pill: 999 } as const; + +/** + * No row has a height. Every row has a minimum and grows, so a title that wraps + * at the largest content sizes takes the room it needs instead of truncating. + */ +export const ROW_MIN_HEIGHT = { + marquee: 140, + queue: 72, + lapsed: 72, + onTheWay: 64, + calendar: 64, + history: 56, + search: 56, + settings: 56, + seasonEpisode: 48, + footer: 48, +} as const; + +/** Apple's default control size is 44 by 44 pt; Material's is 48 dp. */ +export const TARGET_MIN = Platform.OS === "ios" ? 44 : 48; + +/** The check's two sizes. The 44 pt one went with the "Previously" strip. */ +export const CHECK_SIZE = { marquee: 56, row: 48 } as const; + +/** `.sep` is a half-point hairline on iOS; `DividerDefaults.Thickness` is 1 dp. */ +export const HAIRLINE = Platform.OS === "ios" ? StyleSheet.hairlineWidth : 1; + +/** + * Where a row's trailing controls stop fitting beside its text and move below + * it, and where a footer's rail and count stop sharing a line. Derived from + * iOS's AX5 measurement; Android's curve and its independent Display size + * setting give a different answer, which is measured on an Android device + * rather than inherited from this one. + */ +export const REFLOW_FONT_SCALE = 1.6; From 1e048e8e2c8030b3257a088908e3290ff425f154 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 5 Sep 2026 02:38:21 -0500 Subject: [PATCH 056/435] Verify the account modal dismissal through the router Mount the account group over a root stack with expo-router's own test harness, press Done, and assert the modal is gone. The previous test mocked useRouter, so it asserted only that a method named dismissAll was called and would have passed for one that dismisses nothing. Drop the accessibilityLabel that restates the button title, and add nanoid to jest-expo's transform allow-list: the harness loads the real router, whose vendored React Navigation imports an ESM-only nanoid entry point. --- .../native/__tests__/account-layout.test.tsx | 62 +++++++++++-------- packages/native/app/(account)/_layout.tsx | 7 +-- packages/native/jest.config.js | 10 +++ 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/packages/native/__tests__/account-layout.test.tsx b/packages/native/__tests__/account-layout.test.tsx index 04303957..62792de5 100644 --- a/packages/native/__tests__/account-layout.test.tsx +++ b/packages/native/__tests__/account-layout.test.tsx @@ -1,32 +1,44 @@ -import { fireEvent, render } from "@testing-library/react-native"; +import { router, Stack } from "expo-router"; +import { act, fireEvent, renderRouter, screen } from "expo-router/testing-library"; +import type { ReactElement } from "react"; +import { View } from "react-native"; +import * as accountLayout from "../app/(account)/_layout"; -const mockDismissAll = jest.fn(); - -jest.mock("expo-router", () => { - const { createElement, Fragment } = require("react"); - const { View } = require("react-native"); - const Stack = ({ children }: { children: React.ReactNode }) => - createElement(Fragment, null, children); - Stack.Screen = ({ - name, - options, - }: { - name: string; - options: { headerRight?: () => React.ReactNode }; - }) => createElement(View, { testID: `account-route-${name}` }, options.headerRight?.()); - return { Stack, useRouter: () => ({ dismissAll: mockDismissAll }) }; -}); - -const AccountLayout = require("../app/(account)/_layout").default as () => React.JSX.Element; +/** + * Done dismisses the modal, asserted by dismissing it. + * + * `dismissAll()` dispatches `POP_TO_TOP`, which the account stack declines + * because Profile is its initial route, and the root stack then pops the whole + * group. A mocked `useRouter` would only assert that a method with that name + * was called, which passes just as happily for one that dismisses nothing, so + * the tree below is the smallest one that reproduces what the composition root + * presents: the account group as a full-screen modal over the tabs. + */ +const routes = { + _layout: (): ReactElement => ( + + + + + ), + index: (): ReactElement => , + "(account)/_layout": accountLayout, + "(account)/profile": (): ReactElement => , + "(account)/settings": (): ReactElement => , + "(account)/history": (): ReactElement => , +}; describe("the account stack", () => { - beforeEach(() => mockDismissAll.mockClear()); + it("dismisses the modal back to where the user was", async () => { + await renderRouter(routes, { initialUrl: "/" }); + + await act(() => router.push("/profile")); + expect(screen.getByTestId("screen-profile")).toBeOnTheScreen(); - it("lets the initial profile route dismiss the modal", async () => { - const account = await render(); + // Case-insensitive: React Native renders an Android button title in capitals. + await fireEvent.press(screen.getByRole("button", { name: /^done$/i })); - expect(account.getByTestId("account-route-profile")).toBeOnTheScreen(); - fireEvent.press(account.getByRole("button", { name: "Done" })); - expect(mockDismissAll).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("screen-profile")).toBeNull(); + expect(screen.getByTestId("screen-tabs")).toBeOnTheScreen(); }); }); diff --git a/packages/native/app/(account)/_layout.tsx b/packages/native/app/(account)/_layout.tsx index d18307ed..dacf8e3e 100644 --- a/packages/native/app/(account)/_layout.tsx +++ b/packages/native/app/(account)/_layout.tsx @@ -28,12 +28,7 @@ export default function AccountLayout(): ReactElement { options={{ title: "Profile", headerRight: () => ( -
+ ); +} diff --git a/packages/native/src/screens/account/Rows.tsx b/packages/native/src/screens/account/Rows.tsx new file mode 100644 index 00000000..d2ded3da --- /dev/null +++ b/packages/native/src/screens/account/Rows.tsx @@ -0,0 +1,197 @@ +import type { ReactElement, ReactNode } from "react"; +import { ScrollView, StyleSheet, Switch, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Chevron } from "../../ui/Chevron"; +import { Row, Separator } from "../../ui/Row"; +import { RowMenu } from "../../ui/RowMenu"; +import { ROW_MIN_HEIGHT, SPACE, useColors } from "../../ui/tokens"; +import { CueText } from "../../ui/type"; + +export function AccountScreen({ + testID, + children, +}: { + readonly testID: string; + readonly children: ReactNode; +}): ReactElement { + const colors = useColors(); + const insets = useSafeAreaInsets(); + return ( + + {children} + + ); +} + +export function Section({ + title, + children, +}: { + readonly title: string; + readonly children: ReactNode; +}): ReactElement { + const colors = useColors(); + return ( + + + {title} + + {children} + + ); +} + +export function Note({ children }: { readonly children: ReactNode }): ReactElement { + const colors = useColors(); + return ( + + {children} + + ); +} + +interface SettingRowProps { + readonly title: string; + readonly hint?: string; + readonly trailing?: ReactNode; + readonly testID?: string; + readonly onPress?: () => void; +} + +export function SettingRow({ + title, + hint, + trailing, + testID, + onPress, +}: SettingRowProps): ReactElement { + const colors = useColors(); + return ( + <> + + + {title} + + {hint ? ( + + {hint} + + ) : null} + + + + ); +} + +export function Toggle({ + value, + onChange, + disabled = false, + ...row +}: Omit & { + readonly value: boolean; + readonly disabled?: boolean; + readonly onChange: (value: boolean) => void; +}): ReactElement { + return ( + + } + /> + ); +} + +export function Picker({ + title, + hint, + value, + options, + onChange, + testID, +}: { + readonly title: string; + readonly hint?: string; + readonly value: T; + readonly options: readonly { value: T; label: string }[]; + readonly onChange: (value: T) => void; + readonly testID: string; +}): ReactElement { + const colors = useColors(); + const label = options.find((option) => option.value === value)?.label; + return ( + ({ + id: String(option.value), + label: option.label, + selected: option.value === value, + onPress: () => onChange(option.value), + }))} + > + + + {label} + + + + + } + /> + ); +} + +const styles = StyleSheet.create({ + picker: { + minHeight: ROW_MIN_HEIGHT.settings, + flexDirection: "row", + alignItems: "center", + maxWidth: 190, + }, +}); diff --git a/packages/native/src/screens/account/SignOut.tsx b/packages/native/src/screens/account/SignOut.tsx new file mode 100644 index 00000000..f5974308 --- /dev/null +++ b/packages/native/src/screens/account/SignOut.tsx @@ -0,0 +1,68 @@ +import { PendingWritesError } from "@cue/core/app/session"; +import { useAuth } from "@cue/core/auth/store"; +import { type ReactElement, useState } from "react"; +import { Alert, Platform, Pressable, View } from "react-native"; +import { useLiveRegion } from "../../ui/live-region"; +import { TEST_IDS } from "../../ui/test-ids"; +import { ROW_MIN_HEIGHT, useColors } from "../../ui/tokens"; +import { CueText } from "../../ui/type"; + +export function SignOut(): ReactElement { + const disconnect = useAuth((state) => state.disconnect); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const colors = useColors(); + const liveRegion = useLiveRegion(error, "assertive"); + async function signOut(): Promise { + setBusy(true); + setError(null); + try { + await disconnect(); + } catch (failure) { + setError( + failure instanceof PendingWritesError + ? "Some changes haven't synced yet. Reconnect to the internet, then try signing out again." + : "Couldn't finish signing out. Please try again.", + ); + } finally { + setBusy(false); + } + } + const label = busy ? "Signing out…" : "Sign out"; + return ( + + {error ? ( + + {error} + + ) : null} + + Alert.alert("Sign out of Cue?", "Your Trakt history stays on Trakt.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign out", + onPress: () => void signOut(), + ...(Platform.OS === "ios" ? { isPreferred: true } : {}), + }, + ]) + } + > + + {label} + + + + ); +} diff --git a/packages/native/src/screens/account/ThemeControl.tsx b/packages/native/src/screens/account/ThemeControl.tsx new file mode 100644 index 00000000..db0f71bc --- /dev/null +++ b/packages/native/src/screens/account/ThemeControl.tsx @@ -0,0 +1,65 @@ +import { useHaptics } from "@cue/core/ports/haptics"; +import { type PrefsStore, type Theme, usePrefs } from "@cue/core/prefs/prefs-store"; +import { type ReactElement, useLayoutEffect, useSyncExternalStore } from "react"; +import { Appearance, Pressable, View } from "react-native"; +import { TEST_IDS } from "../../ui/test-ids"; +import { HAIRLINE, RADIUS, SPACE, TARGET_MIN, useColors } from "../../ui/tokens"; +import { CueText } from "../../ui/type"; + +const OPTIONS: readonly { value: Theme; label: string; testID: string }[] = [ + { value: "system", label: "System", testID: TEST_IDS.themeSystem }, + { value: "dark", label: "Dark", testID: TEST_IDS.themeDark }, + { value: "light", label: "Light", testID: TEST_IDS.themeLight }, +]; + +export function useAppearance(store: PrefsStore): void { + const theme = useSyncExternalStore(store.subscribe, () => store.getState().theme); + useLayoutEffect( + () => Appearance.setColorScheme(theme === "system" ? "unspecified" : theme), + [theme], + ); +} + +export function ThemeControl(): ReactElement { + const theme = usePrefs((state) => state.theme); + const setTheme = usePrefs((state) => state.setTheme); + const colors = useColors(); + const haptics = useHaptics(); + return ( + + {OPTIONS.map((option) => ( + { + haptics.selection(); + setTheme(option.value); + }} + style={{ + minHeight: TARGET_MIN, + paddingHorizontal: SPACE.s2, + justifyContent: "center", + borderRadius: RADIUS.control, + borderWidth: HAIRLINE, + borderColor: theme === option.value ? colors.muted : "transparent", + backgroundColor: theme === option.value ? colors.overlay : "transparent", + }} + > + + {option.label} + + + ))} + + ); +} diff --git a/packages/native/src/screens/account/trakt.png b/packages/native/src/screens/account/trakt.png new file mode 100644 index 0000000000000000000000000000000000000000..cf6a79a6ed7a492efb1bf4301c4061c6bc0580a2 GIT binary patch literal 2911 zcmZ`*do&Xc8=h|ZQG_Iv=qoBIOfFN&+$z^ZY$}9dt}zwKrP8k=A=HR1X=L$NS2`&T7Aek^}$%*njP+ zx#Kot|5bZ;Zli?U>NWyYy{E_O^bLea39zAPp!C37a{w);)?^|6Il)Lxjg4;@N5qNWa;o3%D z5$&5#?>AU)fANfPeS^zbFL5S(vS?%UbGA0O$XG_PGvTvcC*?bn(Yq{K-)MSH$-mZ) zOB}-`jcsmjwZ0x}O&V)TposVz#GI+p+uedy5jLQ2Wr2@NrhbJGSLXTH!2YjLBH9c; zT02+&WCU&AR$$#R{EabOIZMVcK3TR=YM8lT+=g(Ils{b^)L$Lg*H=jENS|nVIog&w zPW(8*C$S}k?!#wAh9?(`Jj>}pG)E>c%e;I{nh?Hi)anJ)I`#^zAP>>u1BpCeqR zt?`)ae6FxIV(>@!VEemqLiW_psNpW;; zXmFcIuz>>OQ0DkAknY|BT4&}&d-_C2#ss;Vi85;gKoC#2uTboswUzk}m}Io~b|a3s zW@{lnw@3Maw(cWZc;vQp5@F$r@b*ObfV{$dwh5rCt*fu4t*@nP;H0Au0$u=V>uYLj zgS55h6Zqo)We5uK_J>9Oe?!)Y>CbJ$ssAJ({DXYL5uQPz|Krg!1YOYncL>N3_t*BE z!`IABogPtG)K`xO%nyrCx=U4GvEChjXxAr)>qZA_;nqgb^4AWForfT0uBY>k|9!kz z^`M36E_n+n`49_JoUj2mU7?Fy=ws=(eMFY7KF&=essp);3$)R|wEb5?c=*^e`=|gF zOo`iXuX7i%Ugr$40q6AebQBI#fC`5|Ugu6BhHhVFbkTQ!A0j2ibG*JI+@gpvo{&s6 zqk$}~QKtjn!5Af-3!n)b?&e|9snZMWQEadz8$HuY4v z!}&QCrHe3@d)sqDLVwSl`4b_^XI(Mpt+{c@dcr#10|b`5Pcy>_bBr)V?$v{tV7rqG zb+emh5D2{-BZ%L1j5I~+cG|)4G|~X<7!)QpBcpkgPD3jaF4gF3hF%LJW|8i}F8P^a z4q=NS;?R*&F|FX_V_Hpw*M*md^ZN)d_01<_Ps8-FYM>O_2PEkQW9hv&%vACiJjDlg z#SQCx#vpt}4+YId`r2?yPG?9|-V5m8 zHu2xhvr~d$bN&sMv}_1o&mAa1xZ!7pRgL-(iK#-B+i)D6 zaa3KZ8VnZaiPv>=}}MJmbjAvL-^LkFAu&Gvr#Bs#y_IsHAR|kKAh3G($lq;W}p75Spxk zz*|EoFiM^0_zm_|@a5`0q}XXu&pz6H;tjL`!mA=Orqv_}&BIxaN2RYRf$-)UGG%63 zQ;xKC4#pf2&4;K%?P3albv)C45YZreR*I0V0~6DJv1DR z3)XZn-(>aIxZl6u*l;pBqVmd#G09JjT$iYoxOg(GLQ(I>KqP#xLe`vCjUqnQBRxa< z!cQE@b6EG5=MsANo`~Kh8Q9w&!QL_oS)J=&O9sjMxYxX(@3PLs9Ilwa&^%Y9C=qJL4VbB`3vs%9E?3v z>M2Vy2wK{oa%l3FV_+||pfKw~nd@vf^~I27_!Ff7h17(aoe#|iy)h5V&NHnl;Zhic#m40fL zH*u-pst(Ma95}l6=W@2W++q!jNOW5QKz_-OsQ+Y%(gqCv3Tj%2swxT6@QSoLs=|X8 zUlFMJR>_rl1Is34Z&`OAtw7!kUSuVdxG#BB30khQ!%U>&&s2zlCuMv&eoFZrB1+1c zF#AeO7B~2{P0NE9JoTzDaTTuM^h{7D%uP~hjH$A4lKZwFfmwPLOi>zo=e*|GXv}Pl z51hJnamp^Y)l^sL6!$||PfD;?e3uJ6ZXdf(RrB(jstmg$V7QOEqan?%GxS5LcJXW` zBaR!~t0gc?T3kgx+G7YFI;?Pe+OVowM#E<|lNVq)`B<*2ex9{3`zgWqEvPr%%L=%B zB=@1lc@Jqw6W=EAWvR7l(kZyh5%Iexr0Jdo#wEY4$rhgtr|jWA;VdiW&p5h9^?qD@ zLk;hC9k1I{A~#-TAEC_6Gl5>_dZR+DqSy+4{Ju!G+mf@DQrUQ%ek07-u<&w&SVIsm zut+nW_i#OCR@&sx6{R1cy^kB{8q{``WehM0sO&N-`1oW)=VoljMd~im7dNIHi~P}{ zpX|iR#@yc=A0U5tvM5ajO&orj2dpIr4t^P{y=hTuHRP3OZ7hUFc)p<}5cRtNjVJ(#;FQP3(6b9CThSowBC#3xc2S rKDI^cUdr(sk{^}uII?ExA(EU5nOQ&I+)%vz767hU*qN7uJ)Zs(31ACj literal 0 HcmV?d00001 diff --git a/packages/native/src/ui/test-ids.ts b/packages/native/src/ui/test-ids.ts index cc3b9421..7c782190 100644 --- a/packages/native/src/ui/test-ids.ts +++ b/packages/native/src/ui/test-ids.ts @@ -1,4 +1,29 @@ export const TEST_IDS = { + signOutError: "sign-out-error", + profileIdentity: "profile-identity", + profileAvatar: "profile-avatar", + profileStatShows: "profile-stat-shows", + profileStatMovies: "profile-stat-movies", + profileWatchTime: "profile-watch-time", + profileSkeleton: "profile-skeleton", + profileError: "profile-error", + profileEmpty: "profile-empty", + settingsShows: "switch-shows", + settingsMovies: "switch-movies", + settingsSpoilers: "switch-spoilers", + settingsNextOrder: "settings-next-order", + settingsLapsedOrder: "settings-lapsed-order", + settingsThreshold: "settings-threshold", + settingsSync: "settings-sync", + settingsSyncStatus: "settings-sync-status", + settingsTrakt: "settings-trakt", + settingsDelete: "settings-delete", + settingsAttribution: "settings-attribution", + settingsPoweredBy: "settings-powered-by", + themeSystem: "theme-system", + themeDark: "theme-dark", + themeLight: "theme-light", + appIdle: "app-idle", appIdleTiming: "app-idle-timing", responseTiming: "response-timing", @@ -80,7 +105,7 @@ export const TEST_IDS = { screenSettings: "screen-settings", settingsHaptics: "settings-haptics", settingsVersion: "settings-version", - settingsDisconnect: "settings-disconnect", + settingsDisconnect: "sign-out", episodeSkeleton: "episode-skeleton", screenEpisode: "screen-episode", episodeCode: "episode-code", From 8eeaafca3644214e375e36c6342b2312f68f962b Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 19 Sep 2026 15:51:40 -0500 Subject: [PATCH 341/435] Keep show season action clear of header 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. --- .maestro/flows/show-detail-bulk-mark.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.maestro/flows/show-detail-bulk-mark.yaml b/.maestro/flows/show-detail-bulk-mark.yaml index 08c3e239..b96b8555 100644 --- a/.maestro/flows/show-detail-bulk-mark.yaml +++ b/.maestro/flows/show-detail-bulk-mark.yaml @@ -9,7 +9,7 @@ appId: app.cuetracker id: "screen-show-detail" - scrollUntilVisible: element: - id: "season-check-2" + id: "episode-row-8803-2-3-check" direction: DOWN centerElement: true - assertVisible: From 9dc0adc8208515662f027625bcdf8d9ee47cc752 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 19 Sep 2026 15:55:14 -0500 Subject: [PATCH 342/435] fix native parity flows and parallelize iOS setup --- .github/workflows/ci.yml | 88 +++----------------- .maestro/flows/dark-traversal.yaml | 22 +++-- .maestro/flows/episode-sheet.yaml | 20 ++++- packages/native/__tests__/ui/marker.test.tsx | 4 +- packages/native/src/ui/Marker.tsx | 3 +- scripts/prepare-ios-ui.sh | 39 +++++++++ test/ci/fast-validation.test.ts | 4 +- 7 files changed, 91 insertions(+), 89 deletions(-) create mode 100755 scripts/prepare-ios-ui.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baa9b554..ab75049a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,47 +371,15 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_26.6.app - - id: simulator - name: Start an available iPhone simulator - run: | - device_id=$(xcrun simctl list devices available -j | \ - jq -r '[.devices[] | .[] | select(.name | startswith("iPhone"))][0].udid') - test -n "$device_id" - xcrun simctl boot "$device_id" - echo "device_id=$device_id" >> "$GITHUB_OUTPUT" - - name: Install Maestro 2.10.0 - run: | - curl -fsSL \ - https://github.com/mobile-dev-inc/Maestro/releases/download/cli-2.10.0/maestro.zip \ - -o "$RUNNER_TEMP/maestro.zip" - echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \ - | shasum -a 256 -c - - unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP" - echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH" - - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: cue-native-ios-${{ needs.fingerprint.outputs.ios }} - path: ${{ runner.temp }}/native-ios-app - - name: Install the app + - id: ui-setup + name: Prepare the simulator and cached app env: - DEVICE_ID: ${{ steps.simulator.outputs.device_id }} - run: | - tar -xzf "$RUNNER_TEMP/native-ios-app/Cue.app.tgz" -C "$RUNNER_TEMP/native-ios-app" - xcrun simctl bootstatus "$DEVICE_ID" -b - xcrun simctl install "$DEVICE_ID" "$RUNNER_TEMP/native-ios-app/Cue.app" - - name: Start the fake Trakt - run: | - node scripts/mock-trakt/server.mjs > "$RUNNER_TEMP/mock-trakt.log" 2>&1 & - for _ in {1..30}; do - if curl -fsS http://127.0.0.1:8787/users/settings > /dev/null; then - exit 0 - fi - sleep 1 - done - exit 1 + GH_TOKEN: ${{ github.token }} + IOS_FINGERPRINT: ${{ needs.fingerprint.outputs.ios }} + run: scripts/prepare-ios-ui.sh "$IOS_FINGERPRINT" - name: Run the shared light flow env: - DEVICE_ID: ${{ steps.simulator.outputs.device_id }} + DEVICE_ID: ${{ steps.ui-setup.outputs.device_id }} APP_IDLE_CEILING_MS: "1000" run: | mkdir -p "$RUNNER_TEMP/screenshots/ios/light" @@ -472,47 +440,15 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_26.6.app - - id: simulator - name: Start an available iPhone simulator - run: | - device_id=$(xcrun simctl list devices available -j | \ - jq -r '[.devices[] | .[] | select(.name | startswith("iPhone"))][0].udid') - test -n "$device_id" - xcrun simctl boot "$device_id" - echo "device_id=$device_id" >> "$GITHUB_OUTPUT" - - name: Install Maestro 2.10.0 - run: | - curl -fsSL \ - https://github.com/mobile-dev-inc/Maestro/releases/download/cli-2.10.0/maestro.zip \ - -o "$RUNNER_TEMP/maestro.zip" - echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \ - | shasum -a 256 -c - - unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP" - echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH" - - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: cue-native-ios-${{ needs.fingerprint.outputs.ios }} - path: ${{ runner.temp }}/native-ios-app - - name: Install the app + - id: ui-setup + name: Prepare the simulator and cached app env: - DEVICE_ID: ${{ steps.simulator.outputs.device_id }} - run: | - tar -xzf "$RUNNER_TEMP/native-ios-app/Cue.app.tgz" -C "$RUNNER_TEMP/native-ios-app" - xcrun simctl bootstatus "$DEVICE_ID" -b - xcrun simctl install "$DEVICE_ID" "$RUNNER_TEMP/native-ios-app/Cue.app" - - name: Start the fake Trakt - run: | - node scripts/mock-trakt/server.mjs > "$RUNNER_TEMP/mock-trakt.log" 2>&1 & - for _ in {1..30}; do - if curl -fsS http://127.0.0.1:8787/users/settings > /dev/null; then - exit 0 - fi - sleep 1 - done - exit 1 + GH_TOKEN: ${{ github.token }} + IOS_FINGERPRINT: ${{ needs.fingerprint.outputs.ios }} + run: scripts/prepare-ios-ui.sh "$IOS_FINGERPRINT" - name: Run the shared dark traversal env: - DEVICE_ID: ${{ steps.simulator.outputs.device_id }} + DEVICE_ID: ${{ steps.ui-setup.outputs.device_id }} run: | mkdir -p "$RUNNER_TEMP/screenshots/ios/dark" xcrun simctl ui "$DEVICE_ID" content_size medium diff --git a/.maestro/flows/dark-traversal.yaml b/.maestro/flows/dark-traversal.yaml index 2b65ec55..b4196a54 100644 --- a/.maestro/flows/dark-traversal.yaml +++ b/.maestro/flows/dark-traversal.yaml @@ -51,10 +51,22 @@ appId: app.cuetracker - waitForAnimationToEnd: timeout: 1000 - takeScreenshot: episode-sheet-rest -- swipe: - start: "50%,55%" - end: "50%,98%" - duration: 300 +- runFlow: + when: + platform: iOS + commands: + - swipe: + start: "50%,55%" + end: "50%,98%" + duration: 300 +- runFlow: + when: + platform: Android + commands: + - swipe: + start: "50%,10%" + end: "50%,98%" + duration: 500 - extendedWaitUntil: visible: id: "screen-show-detail" @@ -70,7 +82,7 @@ appId: app.cuetracker when: platform: Android commands: - - tapOn: "Navigate up" + - launchApp - extendedWaitUntil: visible: id: "screen-up-next" diff --git a/.maestro/flows/episode-sheet.yaml b/.maestro/flows/episode-sheet.yaml index 22c49967..97382b48 100644 --- a/.maestro/flows/episode-sheet.yaml +++ b/.maestro/flows/episode-sheet.yaml @@ -46,10 +46,22 @@ appId: app.cuetracker - waitForAnimationToEnd: timeout: 1000 - takeScreenshot: episode-sheet-revealed -- swipe: - start: "50%,55%" - end: "50%,98%" - duration: 300 +- runFlow: + when: + platform: iOS + commands: + - swipe: + start: "50%,55%" + end: "50%,98%" + duration: 300 +- runFlow: + when: + platform: Android + commands: + - swipe: + start: "50%,10%" + end: "50%,98%" + duration: 500 - assertVisible: id: "screen-show-detail" - assertNotVisible: diff --git a/packages/native/__tests__/ui/marker.test.tsx b/packages/native/__tests__/ui/marker.test.tsx index 171a7d62..ed21bd98 100644 --- a/packages/native/__tests__/ui/marker.test.tsx +++ b/packages/native/__tests__/ui/marker.test.tsx @@ -17,8 +17,8 @@ it("draws a frame a hierarchy dump can find", async () => { expect(frame?.width).toBeGreaterThan(0); expect(frame?.height).toBeGreaterThan(0); expect(frame?.left).toBe(Platform.OS === "android" ? "50%" : 0); - expect(frame?.top).toBe(Platform.OS === "android" ? "50%" : undefined); - expect(frame?.bottom).toBe(Platform.OS === "android" ? undefined : 0); + expect(frame?.top).toBe("50%"); + expect(frame?.bottom).toBeUndefined(); }); it("carries accessibility-only text", async () => { diff --git a/packages/native/src/ui/Marker.tsx b/packages/native/src/ui/Marker.tsx index f05e44c7..1bcb0b6f 100644 --- a/packages/native/src/ui/Marker.tsx +++ b/packages/native/src/ui/Marker.tsx @@ -30,7 +30,8 @@ export function Marker({ const styles = StyleSheet.create({ marker: { position: "absolute", - ...(Platform.OS === "android" ? { left: "50%", top: "50%" } : { left: 0, bottom: 0 }), + left: Platform.OS === "android" ? "50%" : 0, + top: "50%", width: 1, height: 1, }, diff --git a/scripts/prepare-ios-ui.sh b/scripts/prepare-ios-ui.sh new file mode 100755 index 00000000..14794ce8 --- /dev/null +++ b/scripts/prepare-ios-ui.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +fingerprint=$1 +device_id=$(xcrun simctl list devices available -j | \ + jq -r '[.devices[] | .[] | select(.name | startswith("iPhone"))][0].udid') +test -n "$device_id" +xcrun simctl boot "$device_id" + +app_dir="$RUNNER_TEMP/native-ios-app" +maestro_zip="$RUNNER_TEMP/maestro.zip" +mkdir -p "$app_dir" +node scripts/mock-trakt/server.mjs > "$RUNNER_TEMP/mock-trakt.log" 2>&1 & +curl -fsSL \ + https://github.com/mobile-dev-inc/Maestro/releases/download/cli-2.10.0/maestro.zip \ + -o "$maestro_zip" & +maestro_pid=$! +gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "cue-native-ios-$fingerprint" --dir "$app_dir" & +app_pid=$! +wait "$maestro_pid" +wait "$app_pid" + +echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $maestro_zip" \ + | shasum -a 256 -c - +unzip -q "$maestro_zip" -d "$RUNNER_TEMP" +echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH" +tar -xzf "$app_dir/Cue.app.tgz" -C "$app_dir" +xcrun simctl bootstatus "$device_id" -b +xcrun simctl install "$device_id" "$app_dir/Cue.app" + +for _ in {1..30}; do + if curl -fsS http://127.0.0.1:8787/users/settings > /dev/null; then + echo "device_id=$device_id" >> "$GITHUB_OUTPUT" + exit 0 + fi + sleep 1 +done +exit 1 diff --git a/test/ci/fast-validation.test.ts b/test/ci/fast-validation.test.ts index 61613d8f..a5d1677e 100644 --- a/test/ci/fast-validation.test.ts +++ b/test/ci/fast-validation.test.ts @@ -90,9 +90,11 @@ describe("fast pull request validation", () => { it("runs required dark screenshot traversals on independent cached-app jobs", () => { const ios = job("ui-screenshots-ios-dark"); const android = job("ui-screenshots-android-dark"); + const iosSetup = readFileSync(repositoryPath("scripts/prepare-ios-ui.sh"), "utf8"); expect(ios).toContain("needs: [fingerprint, native-ios]"); - expect(ios).toContain("cue-native-ios-$" + "{{ needs.fingerprint.outputs.ios }}"); + expect(ios).toContain("IOS_FINGERPRINT: $" + "{{ needs.fingerprint.outputs.ios }}"); + expect(iosSetup).toContain('--name "cue-native-ios-$fingerprint"'); expect(ios).toContain("test .maestro/ci/screenshots.yaml"); expect(ios).not.toContain("continue-on-error"); expect(android).toContain("needs: [fingerprint, native-android]"); From 3b7660c77fb4f6d32e703ed99412d28f5618a5f2 Mon Sep 17 00:00:00 2001 From: Arun Krishnamurthy Date: Sat, 19 Sep 2026 16:09:14 -0500 Subject: [PATCH 343/435] Make the month jump reachable by assistive tech 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. --- .../native/__tests__/account-layout.test.tsx | 1 - packages/native/__tests__/history.test.tsx | 24 ++-- packages/native/__tests__/support/history.tsx | 2 +- packages/native/app/(account)/_layout.tsx | 13 +-- .../native/app/(account)/history-month.tsx | 90 --------------- .../src/screens/history/HistoryScreen.tsx | 22 +++- .../native/src/screens/history/MonthJump.tsx | 106 ++++++++++++++++++ packages/native/src/ui/tokens.ts | 1 + 8 files changed, 141 insertions(+), 118 deletions(-) delete mode 100644 packages/native/app/(account)/history-month.tsx create mode 100644 packages/native/src/screens/history/MonthJump.tsx diff --git a/packages/native/__tests__/account-layout.test.tsx b/packages/native/__tests__/account-layout.test.tsx index 01aafa9f..5ae8a595 100644 --- a/packages/native/__tests__/account-layout.test.tsx +++ b/packages/native/__tests__/account-layout.test.tsx @@ -33,7 +33,6 @@ const routes = { "(account)/profile": (): ReactElement => , "(account)/settings": (): ReactElement => , "(account)/history": (): ReactElement => , - "(account)/history-month": (): ReactElement => , "(account)/movie/[movieId]": (): ReactElement => , "(account)/show/[showId]/episode/[season]/[episode]": (): ReactElement => , }; diff --git a/packages/native/__tests__/history.test.tsx b/packages/native/__tests__/history.test.tsx index 097855ba..8b5eae5d 100644 --- a/packages/native/__tests__/history.test.tsx +++ b/packages/native/__tests__/history.test.tsx @@ -1,7 +1,7 @@ import type { CueRuntime, SubmitOutcome } from "@cue/core/runtime/runtime"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native"; -import HistoryMonth from "../app/(account)/history-month"; import { HistoryScreen } from "../src/screens/history/HistoryScreen"; +import { MONTHS } from "../src/screens/history/model"; import { HISTORY, historyEntry, historyParams, historyRouter } from "./support/history"; import { fakeRuntime, Harness, spyHaptics } from "./support/up-next"; @@ -110,16 +110,22 @@ test("filters loaded titles without hiding the load-more control", async () => { expect(screen.getByTestId("history-row-0")).toBeOnTheScreen(); }); -test("month jump shows both grids together and preserves the medium", async () => { +test("month jump shows both grids together and leaves the medium alone", async () => { historyParams.current = { type: "movies", year: "2025", month: "3" }; - await render(); + await paint(); + await fireEvent.press(screen.getByTestId("history-jump")); expect(screen.getByTestId("history-jump-month-3")).toBeOnTheScreen(); await fireEvent.press(screen.getByTestId("history-jump-year-2024")); await fireEvent.press(screen.getByTestId("history-jump-month-8")); - expect(historyRouter.dismissTo).toHaveBeenCalledWith({ - pathname: "/(account)/history", - params: { type: "movies", year: "2024", month: "8" }, - }); + expect(historyRouter.setParams).toHaveBeenCalledWith({ year: "2024", month: "8" }); + expect(screen.queryByTestId("history-jump-sheet")).toBeNull(); +}); + +test("offers every month as a named button", async () => { + historyParams.current = { year: "2025" }; + await paint(); + await fireEvent.press(screen.getByTestId("history-jump")); + for (const month of MONTHS) expect(screen.getByRole("button", { name: month })).toBeOnTheScreen(); }); test("offers recovery from an initial error", async () => { @@ -135,7 +141,9 @@ test("offers recovery from an initial error", async () => { test("explains unscoped emptiness", async () => { await paint( - runtime({ loadHistory: () => Promise.resolve({ entries: [], page: 1, pageCount: 1 }) }), + runtime({ + loadHistory: () => Promise.resolve({ entries: [], page: 1, pageCount: 1 }), + }), ); expect(await screen.findByText("Nothing logged yet.")).toBeOnTheScreen(); }); diff --git a/packages/native/__tests__/support/history.tsx b/packages/native/__tests__/support/history.tsx index ba4d0ca9..dc8253ca 100644 --- a/packages/native/__tests__/support/history.tsx +++ b/packages/native/__tests__/support/history.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { TextInput } from "react-native"; import { expoRouterModule, router } from "./native-ui"; -export const historyRouter = { ...router, setParams: jest.fn(), dismissTo: jest.fn() }; +export const historyRouter = { ...router, setParams: jest.fn() }; export const historyParams: { current: Record } = { current: {} }; export function historyRouterModule() { diff --git a/packages/native/app/(account)/_layout.tsx b/packages/native/app/(account)/_layout.tsx index b1cba779..af313851 100644 --- a/packages/native/app/(account)/_layout.tsx +++ b/packages/native/app/(account)/_layout.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { Button, StyleSheet, View } from "react-native"; import { SnackbarHost } from "../../src/ui/SnackbarHost"; import { TEST_IDS } from "../../src/ui/test-ids"; -import { RADIUS, useColors } from "../../src/ui/tokens"; +import { useColors } from "../../src/ui/tokens"; /** * Profile, Settings and History as one full-screen modal stack over the tabs. @@ -43,17 +43,6 @@ export default function AccountLayout(): ReactElement { /> - currentYear - i); - if (scope.year !== undefined && !years.includes(scope.year)) years.push(scope.year); - const router = useRouter(); - const colors = useColors(); - const insets = useSafeAreaInsets(); - const pick = (pickedYear?: number, month?: number) => - router.dismissTo({ - pathname: "/(account)/history", - params: { - type: scope.type ?? "", - year: pickedYear === undefined ? "" : String(pickedYear), - month: month === undefined ? "" : String(month), - }, - }); - return ( - - - Jump to - -