From 0a0d36f3d54f4b1694885ccb09dffe684cc4eb66 Mon Sep 17 00:00:00 2001 From: Wojciech Dembinski Date: Tue, 18 Aug 2026 01:24:37 +0200 Subject: [PATCH 01/12] docs(plan): take the four framing decisions for the mobile app (Phase 27) Step 1 of 12. The interactive prompt for these went unanswered in a headless session, so they are recorded here instead of guessed: a new apps/mobile (not a responsive apps/web), an installable PWA (not Capacitor/TWA, since there is no Android SDK/JDK/keystore on this machine), its own SWA on its own subdomain, and a one-time human infra setup that blocks no coding step. --- docs/plan/README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/plan/README.md b/docs/plan/README.md index 4297e3da..b75c6fcf 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -50,6 +50,7 @@ plan the orchestrator could one day run on its own repo. | 24 | Projects and their tickets (a tracker of our own) | 🚧 in progress on `feat/support-projects-and-their-tickets` β€” **the whole plan is written** (design, build steps, verification, critical files); build step 1 is next | | 25 | Cloud service (a hosted counterpart, sharing domain logic and UI) | 🚧 in progress on `feat/cloud-service` β€” target layout written, `apps/client`+`packages/shared` restructured, verified (found and fixed a broken per-package test run), Azure cost estimated, risks and open assumptions recorded, no-realtime-service/adaptive-polling design written; every package now scaffolded and the service deployed, with `apps/web` rebuilt on the desktop's own shell, board and detail pane (`feat/the-task-manager-web-should-look-like`, v0.82.0) and its layout matched to the desktop's (`feat/match-web-layout-to-desktop-client`, v0.82.5 β€” shared global CSS, the toolbar's Add button, a drift guard) β€” a human glance at the two UIs side by side is still owed | | 26 | Support all interactions in the web (relay the channel, not the command kind) | βœ… complete on `feat/support-all-interactions-in-the-web` β€” one `ipc-invoke` kind behind an exhaustive host-only policy, at-least-once delivery with a result-replay ledger, `PolledEventBus` in place of an event feed; gates green and forced, and the whole relay driven headlessly by [`verify-remote-ipc.mjs`](../../apps/client/scripts/verify-remote-ipc.mjs). A human pressing these controls against a real desktop is still owed, as is deploying the server with this schema | +| 27 | Mobile app for Android (an installable PWA, not a native build) | 🚧 in progress on `feat/mobile-app-for-android` β€” four framing decisions taken (new `apps/mobile`, PWA not Capacitor/TWA, its own subdomain, one-time human setup precedes reachability); nothing built yet | Phases 4 and 5 are already referenced by name in the docs ([`03-how-orchestration-works.md`](../03-how-orchestration-works.md) and the @@ -5607,6 +5608,75 @@ README.md` has never satisfied Prettier and is deliberately left that way β€” re --- +## Phase 27 β€” Mobile app for Android + +Twelve steps, approved before this phase started. Step 1 is not code β€” it is the four +framing decisions the approved plan left for the first session to record, because the +interactive prompt that would normally have taken them lives in a session nobody was +watching. A headless step cannot guess and cannot ask twice; it writes the decision down +instead, with the reasoning, so steps 2–12 build on a record rather than on an assumption +buried in whichever session happened to make the call first. + +### Decision 1: a new `apps/mobile`, not a responsive `apps/web` + +`apps/web` already mirrors the desktop's shell, board and detail pane (Phases 25–26) at +desktop proportions, with a shared `localStorage` namespace and service-worker scope. Making +it respond to a phone viewport would mean every future desktop-shaped change β€” a new +toolbar control, a wider dialog β€” carries a phone-shaped exception with it forever. A +separate app pays a one-time cost (its own shell, routing, PWA manifest) in exchange for +never having to ask "does this also make sense at 390px" for the rest of the project's life. +Phase 26 already had to draw a share-vs-fork line once, between the desktop and `apps/web`; +this decision draws the same kind of line one layer down. What mobile actually shares with +web/desktop is a question for step 2, not something to settle by folding it into `apps/web` +and finding out by accident which parts break at phone width. + +### Decision 2: an installable PWA, not a Capacitor/TWA native build + +There is no Android SDK, no JDK, and no keystore anywhere in this repo or on this machine, +and code signing is already a deferred backlog item, unscheduled. A Capacitor or +Trusted-Web-Activity build needs a Gradle project, and a Gradle project that nothing here can +compile, sign, or run would land unproven, the same way an Electron build cannot be verified +by actually launching it on this machine (that would kill the developer's own running copy β€” +verification there has to work headlessly, past the native-module ABI split). A PWA installs +as a WebAPK with its own icon and launches full-screen without any Android toolchain at all, +and it is the only form of "Android app" this branch can actually build *and verify* +headlessly. Play Store distribution β€” Bubblewrap/TWA, `assetlinks.json`, a keystore secret β€” +is noted under *Out of scope* as a follow-up ticket, not designed here. + +### Decision 3: its own Azure Static Web App, on its own subdomain + +Serving the mobile app from the existing SWA under a `/m/` path was the alternative +considered. It still needs a new IAM redirect URI regardless of which hosting shape is +chosen, so that cost is not avoided by sharing β€” and sharing adds `base: '/m/'` routing, +SWA route rules to keep it separate from the web app's own routes, and the same shared +`localStorage` namespace and service-worker scope problem Decision 1 opted out of, this time +between two *deployed* apps rather than two source trees. A dedicated subdomain (own SWA, +own DNS record) costs one more one-time Azure resource and buys a clean scope boundary for +the lifetime of the app. + +### Decision 4: one-time human setup is required before it is reachable, and blocks no coding step + +Creating the SWA, the DNS record, the `AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` secret, and +registering a `taskmanager-mobile` IAM client (or adding a redirect URI to the existing one) +at `auth.vipper.network` are all actions on shared infrastructure β€” exactly the kind of +action this project's own working agreement holds for a human to take deliberately, not +something a session should do on its own authority. None of steps 2–9 need it to exist: they +build the app itself. Step 10 (deploy from CI) writes the job, added to the existing secrets +model in [`docs/11-ci-cd-pipeline.md`](../11-ci-cd-pipeline.md#secrets), and should condition +the deploy step on `AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` being set so the job stays inert +rather than failing loudly while the secret does not exist yet β€” so the job can be written +and merged before the human setup happens, in either order. Step 11 (critical files) is where +the runbook for that one-time setup gets written down. + +### What this leaves for step 2 + +Nothing here touches code. `apps/mobile` does not exist yet; no dependency was added; no +config was written. Step 2 β€” what is shared and what is forked β€” is the first step that +reads `packages/shared`, `packages/protocol` and `packages/ui` against these four decisions +and decides, file by file, which of them a phone screen can use unchanged. + +--- + ## Conventions for every phase - **Contract first.** New data crossing the UI↔engine boundary gets its types in From 84eb5dbb81f48ade5c7520b5cd0cc98f90529ac8 Mon Sep 17 00:00:00 2001 From: Wojciech Dembinski Date: Tue, 18 Aug 2026 01:25:51 +0200 Subject: [PATCH 02/12] docs(plan): draw the share/fork line for the mobile app (Phase 27, step 2) Step 2 of 12. Applies the rule already used at the desktop/apps/web line in Phase 26 one layer down: share when a file has no host in it, fork when sharing means threading optional props through. A new packages/cloud absorbs apps/web's sync layer (auth, presence, the board's cloud plumbing) since two browser clients cannot each own a copy; @tm/ui's card/detail/chat/dialog components are already host-agnostic and stay put; the shell, board navigation, move interaction and detail route fork because a phone has no rail, no grid, and no drag; the chain overlay and drag handle drop, the latter for free since TaskCard.tsx already gates it on onLinkStart. --- docs/plan/README.md | 47 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/plan/README.md b/docs/plan/README.md index b75c6fcf..165d5e0c 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -50,7 +50,7 @@ plan the orchestrator could one day run on its own repo. | 24 | Projects and their tickets (a tracker of our own) | 🚧 in progress on `feat/support-projects-and-their-tickets` β€” **the whole plan is written** (design, build steps, verification, critical files); build step 1 is next | | 25 | Cloud service (a hosted counterpart, sharing domain logic and UI) | 🚧 in progress on `feat/cloud-service` β€” target layout written, `apps/client`+`packages/shared` restructured, verified (found and fixed a broken per-package test run), Azure cost estimated, risks and open assumptions recorded, no-realtime-service/adaptive-polling design written; every package now scaffolded and the service deployed, with `apps/web` rebuilt on the desktop's own shell, board and detail pane (`feat/the-task-manager-web-should-look-like`, v0.82.0) and its layout matched to the desktop's (`feat/match-web-layout-to-desktop-client`, v0.82.5 β€” shared global CSS, the toolbar's Add button, a drift guard) β€” a human glance at the two UIs side by side is still owed | | 26 | Support all interactions in the web (relay the channel, not the command kind) | βœ… complete on `feat/support-all-interactions-in-the-web` β€” one `ipc-invoke` kind behind an exhaustive host-only policy, at-least-once delivery with a result-replay ledger, `PolledEventBus` in place of an event feed; gates green and forced, and the whole relay driven headlessly by [`verify-remote-ipc.mjs`](../../apps/client/scripts/verify-remote-ipc.mjs). A human pressing these controls against a real desktop is still owed, as is deploying the server with this schema | -| 27 | Mobile app for Android (an installable PWA, not a native build) | 🚧 in progress on `feat/mobile-app-for-android` β€” four framing decisions taken (new `apps/mobile`, PWA not Capacitor/TWA, its own subdomain, one-time human setup precedes reachability); nothing built yet | +| 27 | Mobile app for Android (an installable PWA, not a native build) | 🚧 in progress on `feat/mobile-app-for-android` β€” four framing decisions taken (new `apps/mobile`, PWA not Capacitor/TWA, its own subdomain, one-time human setup precedes reachability); the share/fork boundary decided (new `packages/cloud` absorbs `apps/web`'s sync layer, `@tm/ui` is reused as-is, the shell/navigation/move/detail-route fork, the chain overlay and drag handle drop); nothing built yet | Phases 4 and 5 are already referenced by name in the docs ([`03-how-orchestration-works.md`](../03-how-orchestration-works.md) and the @@ -5675,6 +5675,51 @@ config was written. Step 2 β€” what is shared and what is forked β€” is the firs reads `packages/shared`, `packages/protocol` and `packages/ui` against these four decisions and decides, file by file, which of them a phone screen can use unchanged. +### Step 2: what is shared and what is forked + +The repo already has a rule for this, applied once already at the desktop/`apps/web` line in +Phase 26: share when a file has no host in it, fork when sharing it would mean threading a +dozen optional props through to keep two hosts happy. Applying that same rule one layer down, +between `apps/web` and the new `apps/mobile`, sorts every file in `apps/web/src` and +`packages/ui` into one of three piles. + +**Shared, moved into a new `packages/cloud` (`@tm/cloud`).** Everything under `apps/web/src` +that talks to the cloud rather than to a screen has no host in it today only by accident β€” it +happens to sit in `apps/web` because `apps/web` was the only browser client that existed. Two +browser clients cannot each own a copy of the same sync layer; the moment `apps/mobile` also +polls the board and refreshes a token, one of the two copies drifts. This moves: `auth/`, +`presence.ts`, and out of `board/` β€” `useCloudBoard`, `cloudBoardStore`, `BoardPoller`, +`httpTransport`, `eventBus`, `sseEvents`, `polledEvents`, `mediaToken`, `clientId`, +`targetClient`, `boardSelectors`, `browserFocusSignal`, and `useBoardExtras` β€” plus the three +components that render cloud connection state rather than task data, `ClientPicker`, +`SkewBanner`, `StaleBanner`, and `settings/SettingsScreen.tsx`. Step 3 does the actual +extraction; this step only decides the boundary. + +**Already shared, staying in `@tm/ui` unchanged.** `TaskCard`, `TaskDetail` and its whole +tree, `chat/*`, `Attention`, `Performance`, `AddTaskDialog`, `GitGraphPane`, and +`ArchivedCardsDialog` render the same way regardless of host β€” they take data and callbacks, +not a layout. The theme (`packages/ui/src/theme.ts`) is shared for the same reason. None of +these move; mobile imports them from `@tm/ui` exactly as `apps/web` and the desktop already +do. + +**Forked β€” mobile writes its own.** The shell (a header and a bottom tab bar, not the +desktop's 84px rail β€” a phone has no room for a rail and no mouse to hover it), the board's +navigation (one column at a time with a swipe or tab to move between them, not the grid +`KanbanColumn` lays out for a wide viewport), the move interaction (a tap-to-move flow β€” see +step 6 β€” has nothing in common with the desktop's drag handlers), and the detail *route* +(a full screen push, not the 40% side pane `TaskDetail` sits in on desktop β€” `TaskDetail` +itself is shared per above, only what wraps it differs). `env.ts` and `vite-env.d.ts` also +stay forked, per-app, on purpose β€” Decision 1 in step 1 already ruled out a shared runtime +config between hosts that build and deploy independently. + +**Dropped, per the same rule read in reverse: a control this host cannot act on is dropped +rather than disabled.** The chain overlay (`ChainOverlay.tsx`, `chainArrows.ts`) draws arrows +between cards across columns; on a phone showing one column at a time there is nothing for an +arrow to span, so chain state instead surfaces through the already-shared `TaskChain` inside +the detail view. The chain-link drag handle needs no forking work at all: `TaskCard.tsx:1247` +already renders it conditionally on `onLinkStart` being passed, so mobile gets the drop for +free simply by never passing that prop. + --- ## Conventions for every phase From c90080753c7c64bf982638564e9259347ee22289 Mon Sep 17 00:00:00 2001 From: Wojciech Dembinski Date: Tue, 18 Aug 2026 01:37:51 +0200 Subject: [PATCH 03/12] feat(cloud): extract the cloud layer into @tm/cloud (Phase 27, step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of 12. Moves everything step 2 marked shared β€” auth/, presence.ts, and out of board/ the poll/SSE loop, HTTP transport, boardSelectors, useBoardExtras and the three connection-state banners, plus settings/SettingsScreen.tsx β€” out of apps/web/src into a new packages/cloud (@tm/cloud), copying @tm/ui's tsup/exports/peer-deps conventions exactly. apps/web imports everything back through @tm/cloud/* and is down to the six files that stay host-bound: App.tsx, main.tsx, env.ts, vite-env.d.ts, BoardScreen.tsx and BoardToolbar.tsx. env.ts and vite-env.d.ts stay in apps/web on purpose β€” import.meta.env is a Vite build-time replacement esbuild cannot emit in CJS. @tm/cloud exports the WebConfig interface (packages/cloud/src/config.ts) and takes the config as a parameter instead, which is already how useCloudBoard and CloudAuth are called. @tm/ui is externalized in @tm/cloud's tsup build (alongside its subpaths) so useBoardExtras's runtime useTransport() reads the host's own TransportContext rather than a second bundled copy β€” @tm/ui must never import @tm/cloud in return, or the two externals cycle. test/shell-parity.test.ts's WEB_TREE became WEB_TREES (apps/web/src and packages/cloud/src both), since apps/web/src alone dropped from 27 to 6 non-test sources and the ">10" breach guard would otherwise pass while scanning a tree too small to ever fail. The SettingsScreen path it reads moved with the file. .github/workflows/deploy.yml's web path filter gained packages/cloud/** β€” without it, a change here would stop deploying web with nothing red in CI. pnpm-lock.yaml regenerated for the new workspace package. --- .github/workflows/deploy.yml | 11 ++-- apps/web/package.json | 1 + apps/web/src/App.tsx | 18 +++---- apps/web/src/board/BoardScreen.tsx | 10 ++-- apps/web/src/env.ts | 15 +++--- apps/web/vite.config.ts | 10 ++-- packages/cloud/package.json | 53 +++++++++++++++++++ .../cloud}/src/auth/SignInScreen.tsx | 0 .../cloud}/src/auth/cloudAuth.test.ts | 0 .../cloud}/src/auth/cloudAuth.ts | 0 .../cloud}/src/auth/useCloudAuth.ts | 0 .../cloud}/src/board/BoardPoller.test.ts | 0 .../cloud}/src/board/BoardPoller.ts | 0 .../cloud}/src/board/ClientPicker.tsx | 0 .../cloud}/src/board/SkewBanner.tsx | 0 .../cloud}/src/board/StaleBanner.tsx | 0 .../cloud}/src/board/boardSelectors.test.ts | 0 .../cloud}/src/board/boardSelectors.ts | 0 .../cloud}/src/board/browserFocusSignal.ts | 0 .../cloud}/src/board/clientId.test.ts | 0 .../cloud}/src/board/clientId.ts | 0 .../cloud}/src/board/cloudBoardStore.test.ts | 0 .../cloud}/src/board/cloudBoardStore.ts | 0 .../cloud}/src/board/eventBus.test.ts | 0 .../cloud}/src/board/eventBus.ts | 0 .../cloud}/src/board/httpTransport.test.ts | 0 .../cloud}/src/board/httpTransport.ts | 0 .../cloud}/src/board/mediaToken.test.ts | 0 .../cloud}/src/board/mediaToken.ts | 0 .../cloud}/src/board/polledEvents.test.ts | 0 .../cloud}/src/board/polledEvents.ts | 0 .../cloud}/src/board/sseEvents.test.ts | 0 .../cloud}/src/board/sseEvents.ts | 0 .../cloud}/src/board/targetClient.test.ts | 0 .../cloud}/src/board/targetClient.ts | 0 .../cloud}/src/board/useBoardExtras.test.ts | 0 .../cloud}/src/board/useBoardExtras.ts | 0 .../cloud}/src/board/useCloudBoard.ts | 2 +- packages/cloud/src/config.ts | 20 +++++++ .../cloud}/src/presence.test.ts | 0 {apps/web => packages/cloud}/src/presence.ts | 0 .../cloud}/src/settings/SettingsScreen.tsx | 0 packages/cloud/tsconfig.json | 16 ++++++ packages/cloud/tsup.config.ts | 32 +++++++++++ packages/cloud/vitest.config.ts | 15 ++++++ pnpm-lock.yaml | 44 +++++++++++++++ test/shell-parity.test.ts | 20 ++++--- 47 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 packages/cloud/package.json rename {apps/web => packages/cloud}/src/auth/SignInScreen.tsx (100%) rename {apps/web => packages/cloud}/src/auth/cloudAuth.test.ts (100%) rename {apps/web => packages/cloud}/src/auth/cloudAuth.ts (100%) rename {apps/web => packages/cloud}/src/auth/useCloudAuth.ts (100%) rename {apps/web => packages/cloud}/src/board/BoardPoller.test.ts (100%) rename {apps/web => packages/cloud}/src/board/BoardPoller.ts (100%) rename {apps/web => packages/cloud}/src/board/ClientPicker.tsx (100%) rename {apps/web => packages/cloud}/src/board/SkewBanner.tsx (100%) rename {apps/web => packages/cloud}/src/board/StaleBanner.tsx (100%) rename {apps/web => packages/cloud}/src/board/boardSelectors.test.ts (100%) rename {apps/web => packages/cloud}/src/board/boardSelectors.ts (100%) rename {apps/web => packages/cloud}/src/board/browserFocusSignal.ts (100%) rename {apps/web => packages/cloud}/src/board/clientId.test.ts (100%) rename {apps/web => packages/cloud}/src/board/clientId.ts (100%) rename {apps/web => packages/cloud}/src/board/cloudBoardStore.test.ts (100%) rename {apps/web => packages/cloud}/src/board/cloudBoardStore.ts (100%) rename {apps/web => packages/cloud}/src/board/eventBus.test.ts (100%) rename {apps/web => packages/cloud}/src/board/eventBus.ts (100%) rename {apps/web => packages/cloud}/src/board/httpTransport.test.ts (100%) rename {apps/web => packages/cloud}/src/board/httpTransport.ts (100%) rename {apps/web => packages/cloud}/src/board/mediaToken.test.ts (100%) rename {apps/web => packages/cloud}/src/board/mediaToken.ts (100%) rename {apps/web => packages/cloud}/src/board/polledEvents.test.ts (100%) rename {apps/web => packages/cloud}/src/board/polledEvents.ts (100%) rename {apps/web => packages/cloud}/src/board/sseEvents.test.ts (100%) rename {apps/web => packages/cloud}/src/board/sseEvents.ts (100%) rename {apps/web => packages/cloud}/src/board/targetClient.test.ts (100%) rename {apps/web => packages/cloud}/src/board/targetClient.ts (100%) rename {apps/web => packages/cloud}/src/board/useBoardExtras.test.ts (100%) rename {apps/web => packages/cloud}/src/board/useBoardExtras.ts (100%) rename {apps/web => packages/cloud}/src/board/useCloudBoard.ts (99%) create mode 100644 packages/cloud/src/config.ts rename {apps/web => packages/cloud}/src/presence.test.ts (100%) rename {apps/web => packages/cloud}/src/presence.ts (100%) rename {apps/web => packages/cloud}/src/settings/SettingsScreen.tsx (100%) create mode 100644 packages/cloud/tsconfig.json create mode 100644 packages/cloud/tsup.config.ts create mode 100644 packages/cloud/vitest.config.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b77b383d..8ed92660 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,8 +5,8 @@ # - @tm/server changed -> build the image, push to GHCR, run migrations, then point # the Azure Container App at the new image. # - @tm/web changed -> build the Vite bundle and upload it to the Static Web App. -# A change to a shared package (@tm/shared, @tm/protocol, @tm/ui) or the lockfile triggers -# both, since either app could be affected. +# A change to @tm/shared or @tm/protocol (used by both apps) or the lockfile triggers both. +# @tm/ui and @tm/cloud are web-only and trigger just that job. # # It does NOT touch the desktop app. apps/client is released by release.yml, which runs off # the same push and follows RELEASE.md's procedure β€” and nothing here should ever try to do @@ -91,6 +91,7 @@ jobs: - 'packages/shared/**' - 'packages/protocol/**' - 'packages/ui/**' + - 'packages/cloud/**' - 'pnpm-lock.yaml' - '.github/workflows/deploy.yml' @@ -258,9 +259,9 @@ jobs: VITE_CLOUD_API_BASE: https://tasks-api.vipper.network VITE_CLOUD_IAM_ISSUER: https://auth.vipper.network/oidc VITE_CLOUD_IAM_CLIENT_ID: ${{ secrets.VITE_CLOUD_IAM_CLIENT_ID }} - # turbo builds @tm/shared, @tm/protocol and @tm/ui first (build.dependsOn ^build); - # @tm/web imports them through their `exports`, i.e. their dist/, which does not - # exist after a clean install. + # turbo builds @tm/shared, @tm/protocol, @tm/ui and @tm/cloud first (build.dependsOn + # ^build); @tm/web imports them through their `exports`, i.e. their dist/, which does + # not exist after a clean install. run: pnpm exec turbo run build --filter=@tm/web # Vite does not copy this (there is no public/ dir) and Static Web Apps only reads diff --git a/apps/web/package.json b/apps/web/package.json index 25faf458..4db078c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "dependencies": { "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.270", + "@tm/cloud": "workspace:*", "@tm/protocol": "workspace:*", "@tm/shared": "workspace:*", "@tm/ui": "workspace:*", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 94806569..ffdab1a7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,16 +26,16 @@ import { Performance } from '@tm/ui/Performance'; import { NavRail, type NavRailItem } from '@tm/ui/shell/NavRail'; import { StatusBar, StatusDot, StatusSpacer } from '@tm/ui/shell/StatusBar'; import { TransportProvider } from '@tm/ui/transport'; -import { CloudAuth } from './auth/cloudAuth'; -import { SignInScreen } from './auth/SignInScreen'; -import { useCloudAuth } from './auth/useCloudAuth'; +import { CloudAuth } from '@tm/cloud/auth/cloudAuth'; +import { SignInScreen } from '@tm/cloud/auth/SignInScreen'; +import { useCloudAuth } from '@tm/cloud/auth/useCloudAuth'; +import { SettingsScreen } from '@tm/cloud/settings/SettingsScreen'; +import { ClientPicker } from '@tm/cloud/board/ClientPicker'; +import { SkewBanner } from '@tm/cloud/board/SkewBanner'; +import { StaleBanner } from '@tm/cloud/board/StaleBanner'; +import { versionSkew } from '@tm/cloud/board/targetClient'; +import { useCloudBoard } from '@tm/cloud/board/useCloudBoard'; import { BoardScreen } from './board/BoardScreen'; -import { SettingsScreen } from './settings/SettingsScreen'; -import { ClientPicker } from './board/ClientPicker'; -import { SkewBanner } from './board/SkewBanner'; -import { StaleBanner } from './board/StaleBanner'; -import { versionSkew } from './board/targetClient'; -import { useCloudBoard } from './board/useCloudBoard'; import { loadWebConfig } from './env'; const useStyles = makeStyles({ diff --git a/apps/web/src/board/BoardScreen.tsx b/apps/web/src/board/BoardScreen.tsx index 83a0904f..90b3cc3d 100644 --- a/apps/web/src/board/BoardScreen.tsx +++ b/apps/web/src/board/BoardScreen.tsx @@ -63,10 +63,14 @@ import { type ManualStatus, type Task, } from '@tm/shared/model'; +import { selectArchivedTasks, selectBoardTasks } from '@tm/cloud/board/boardSelectors'; +import { + displayStatus, + isTaskPending, + type CloudBoardState, +} from '@tm/cloud/board/cloudBoardStore'; +import { mergeRequestsByTask, useBoardExtras, byTask } from '@tm/cloud/board/useBoardExtras'; import { BoardToolbar } from './BoardToolbar'; -import { selectArchivedTasks, selectBoardTasks } from './boardSelectors'; -import { displayStatus, isTaskPending, type CloudBoardState } from './cloudBoardStore'; -import { mergeRequestsByTask, useBoardExtras, byTask } from './useBoardExtras'; const useStyles = makeStyles({ /** The empty state, in the board's own half of the screen rather than across all of it. */ diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 20e55535..cc35cffb 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -4,15 +4,14 @@ * unlike `apps/server/src/iam/iam.config.ts` there is no secret here and nothing to fail * fast on: an unset var falls back to the same default the desktop build's * `apps/client/src/main/iamConfig.ts` points at, one client id apart. + * + * This file stays in apps/web on purpose β€” it is not part of the `@tm/cloud` extraction. + * `import.meta.env` is a Vite build-time replacement that esbuild (what `@tm/cloud`'s tsup + * build runs on) cannot emit in CJS; a shared reader here would build clean and ship a + * production bundle pointing at the wrong client id. `@tm/cloud` only names the shape + * (`WebConfig`, from `@tm/cloud/config`) and takes it as a parameter instead. */ -export interface WebConfig { - /** The @tm/server root β€” no trailing slash. */ - cloudApiBase: string; - /** The vipper.iam OIDC issuer. */ - iamIssuer: string; - /** This build's own registered PUBLIC vipper.iam client id (PKCE, no secret). */ - iamClientId: string; -} +import type { WebConfig } from '@tm/cloud/config'; export function loadWebConfig(): WebConfig { const env = import.meta.env; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4cb15519..e0538ac9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,10 +1,10 @@ /** * apps/web is a plain Vite + React app β€” no path aliases into sibling packages, unlike - * apps/client's electron-vite config. `@tm/ui`, `@tm/shared` and `@tm/protocol` are real - * workspace packages here (built by `tsup` to `dist/`, dual ESM/CJS `exports`), resolved by - * Vite through node_modules exactly like any published dependency β€” see those packages' - * own `package.json` and electron.vite.config.ts's comment on why apps/client, alone, takes - * the source-alias shortcut instead. + * apps/client's electron-vite config. `@tm/ui`, `@tm/cloud`, `@tm/shared` and `@tm/protocol` + * are real workspace packages here (built by `tsup` to `dist/`, dual ESM/CJS `exports`), + * resolved by Vite through node_modules exactly like any published dependency β€” see those + * packages' own `package.json` and electron.vite.config.ts's comment on why apps/client, + * alone, takes the source-alias shortcut instead. */ import { readFileSync } from 'node:fs'; import { defineConfig } from 'vite'; diff --git a/packages/cloud/package.json b/packages/cloud/package.json new file mode 100644 index 00000000..af7364e9 --- /dev/null +++ b/packages/cloud/package.json @@ -0,0 +1,53 @@ +{ + "name": "@tm/cloud", + "version": "0.86.0", + "description": "The cloud sync layer shared by every browser client β€” auth, presence, the adaptive board poll/SSE loop, the HTTP transport and the connection-state banners. Extracted out of apps/web so apps/mobile does not grow a second copy of the same sync logic.", + "license": "UNLICENSED", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + "./*": { + "import": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "require": { + "types": "./dist/*.d.cts", + "default": "./dist/*.cjs" + } + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tm/protocol": "workspace:*", + "@tm/shared": "workspace:*" + }, + "peerDependencies": { + "@fluentui/react-components": "^9.54.0", + "@fluentui/react-icons": "^2.0.270", + "@tm/ui": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@fluentui/react-components": "^9.54.0", + "@fluentui/react-icons": "^2.0.270", + "@tm/ui": "workspace:*", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tsup": "^8.0.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/web/src/auth/SignInScreen.tsx b/packages/cloud/src/auth/SignInScreen.tsx similarity index 100% rename from apps/web/src/auth/SignInScreen.tsx rename to packages/cloud/src/auth/SignInScreen.tsx diff --git a/apps/web/src/auth/cloudAuth.test.ts b/packages/cloud/src/auth/cloudAuth.test.ts similarity index 100% rename from apps/web/src/auth/cloudAuth.test.ts rename to packages/cloud/src/auth/cloudAuth.test.ts diff --git a/apps/web/src/auth/cloudAuth.ts b/packages/cloud/src/auth/cloudAuth.ts similarity index 100% rename from apps/web/src/auth/cloudAuth.ts rename to packages/cloud/src/auth/cloudAuth.ts diff --git a/apps/web/src/auth/useCloudAuth.ts b/packages/cloud/src/auth/useCloudAuth.ts similarity index 100% rename from apps/web/src/auth/useCloudAuth.ts rename to packages/cloud/src/auth/useCloudAuth.ts diff --git a/apps/web/src/board/BoardPoller.test.ts b/packages/cloud/src/board/BoardPoller.test.ts similarity index 100% rename from apps/web/src/board/BoardPoller.test.ts rename to packages/cloud/src/board/BoardPoller.test.ts diff --git a/apps/web/src/board/BoardPoller.ts b/packages/cloud/src/board/BoardPoller.ts similarity index 100% rename from apps/web/src/board/BoardPoller.ts rename to packages/cloud/src/board/BoardPoller.ts diff --git a/apps/web/src/board/ClientPicker.tsx b/packages/cloud/src/board/ClientPicker.tsx similarity index 100% rename from apps/web/src/board/ClientPicker.tsx rename to packages/cloud/src/board/ClientPicker.tsx diff --git a/apps/web/src/board/SkewBanner.tsx b/packages/cloud/src/board/SkewBanner.tsx similarity index 100% rename from apps/web/src/board/SkewBanner.tsx rename to packages/cloud/src/board/SkewBanner.tsx diff --git a/apps/web/src/board/StaleBanner.tsx b/packages/cloud/src/board/StaleBanner.tsx similarity index 100% rename from apps/web/src/board/StaleBanner.tsx rename to packages/cloud/src/board/StaleBanner.tsx diff --git a/apps/web/src/board/boardSelectors.test.ts b/packages/cloud/src/board/boardSelectors.test.ts similarity index 100% rename from apps/web/src/board/boardSelectors.test.ts rename to packages/cloud/src/board/boardSelectors.test.ts diff --git a/apps/web/src/board/boardSelectors.ts b/packages/cloud/src/board/boardSelectors.ts similarity index 100% rename from apps/web/src/board/boardSelectors.ts rename to packages/cloud/src/board/boardSelectors.ts diff --git a/apps/web/src/board/browserFocusSignal.ts b/packages/cloud/src/board/browserFocusSignal.ts similarity index 100% rename from apps/web/src/board/browserFocusSignal.ts rename to packages/cloud/src/board/browserFocusSignal.ts diff --git a/apps/web/src/board/clientId.test.ts b/packages/cloud/src/board/clientId.test.ts similarity index 100% rename from apps/web/src/board/clientId.test.ts rename to packages/cloud/src/board/clientId.test.ts diff --git a/apps/web/src/board/clientId.ts b/packages/cloud/src/board/clientId.ts similarity index 100% rename from apps/web/src/board/clientId.ts rename to packages/cloud/src/board/clientId.ts diff --git a/apps/web/src/board/cloudBoardStore.test.ts b/packages/cloud/src/board/cloudBoardStore.test.ts similarity index 100% rename from apps/web/src/board/cloudBoardStore.test.ts rename to packages/cloud/src/board/cloudBoardStore.test.ts diff --git a/apps/web/src/board/cloudBoardStore.ts b/packages/cloud/src/board/cloudBoardStore.ts similarity index 100% rename from apps/web/src/board/cloudBoardStore.ts rename to packages/cloud/src/board/cloudBoardStore.ts diff --git a/apps/web/src/board/eventBus.test.ts b/packages/cloud/src/board/eventBus.test.ts similarity index 100% rename from apps/web/src/board/eventBus.test.ts rename to packages/cloud/src/board/eventBus.test.ts diff --git a/apps/web/src/board/eventBus.ts b/packages/cloud/src/board/eventBus.ts similarity index 100% rename from apps/web/src/board/eventBus.ts rename to packages/cloud/src/board/eventBus.ts diff --git a/apps/web/src/board/httpTransport.test.ts b/packages/cloud/src/board/httpTransport.test.ts similarity index 100% rename from apps/web/src/board/httpTransport.test.ts rename to packages/cloud/src/board/httpTransport.test.ts diff --git a/apps/web/src/board/httpTransport.ts b/packages/cloud/src/board/httpTransport.ts similarity index 100% rename from apps/web/src/board/httpTransport.ts rename to packages/cloud/src/board/httpTransport.ts diff --git a/apps/web/src/board/mediaToken.test.ts b/packages/cloud/src/board/mediaToken.test.ts similarity index 100% rename from apps/web/src/board/mediaToken.test.ts rename to packages/cloud/src/board/mediaToken.test.ts diff --git a/apps/web/src/board/mediaToken.ts b/packages/cloud/src/board/mediaToken.ts similarity index 100% rename from apps/web/src/board/mediaToken.ts rename to packages/cloud/src/board/mediaToken.ts diff --git a/apps/web/src/board/polledEvents.test.ts b/packages/cloud/src/board/polledEvents.test.ts similarity index 100% rename from apps/web/src/board/polledEvents.test.ts rename to packages/cloud/src/board/polledEvents.test.ts diff --git a/apps/web/src/board/polledEvents.ts b/packages/cloud/src/board/polledEvents.ts similarity index 100% rename from apps/web/src/board/polledEvents.ts rename to packages/cloud/src/board/polledEvents.ts diff --git a/apps/web/src/board/sseEvents.test.ts b/packages/cloud/src/board/sseEvents.test.ts similarity index 100% rename from apps/web/src/board/sseEvents.test.ts rename to packages/cloud/src/board/sseEvents.test.ts diff --git a/apps/web/src/board/sseEvents.ts b/packages/cloud/src/board/sseEvents.ts similarity index 100% rename from apps/web/src/board/sseEvents.ts rename to packages/cloud/src/board/sseEvents.ts diff --git a/apps/web/src/board/targetClient.test.ts b/packages/cloud/src/board/targetClient.test.ts similarity index 100% rename from apps/web/src/board/targetClient.test.ts rename to packages/cloud/src/board/targetClient.test.ts diff --git a/apps/web/src/board/targetClient.ts b/packages/cloud/src/board/targetClient.ts similarity index 100% rename from apps/web/src/board/targetClient.ts rename to packages/cloud/src/board/targetClient.ts diff --git a/apps/web/src/board/useBoardExtras.test.ts b/packages/cloud/src/board/useBoardExtras.test.ts similarity index 100% rename from apps/web/src/board/useBoardExtras.test.ts rename to packages/cloud/src/board/useBoardExtras.test.ts diff --git a/apps/web/src/board/useBoardExtras.ts b/packages/cloud/src/board/useBoardExtras.ts similarity index 100% rename from apps/web/src/board/useBoardExtras.ts rename to packages/cloud/src/board/useBoardExtras.ts diff --git a/apps/web/src/board/useCloudBoard.ts b/packages/cloud/src/board/useCloudBoard.ts similarity index 99% rename from apps/web/src/board/useCloudBoard.ts rename to packages/cloud/src/board/useCloudBoard.ts index aff5ff56..aa46952d 100644 --- a/apps/web/src/board/useCloudBoard.ts +++ b/packages/cloud/src/board/useCloudBoard.ts @@ -10,7 +10,7 @@ import type { CadenceDirective } from '@tm/protocol/cadence'; import type { ClientPresence } from '@tm/protocol/wire'; import type { ManualStatus } from '@tm/shared/model'; import type { CloudAuth } from '../auth/cloudAuth'; -import type { WebConfig } from '../env'; +import type { WebConfig } from '../config'; import { createPresenceFocusSignal, PresenceHeartbeat } from '../presence'; import { BoardPoller } from './BoardPoller'; import { createBrowserFocusSignal } from './browserFocusSignal'; diff --git a/packages/cloud/src/config.ts b/packages/cloud/src/config.ts new file mode 100644 index 00000000..3239757c --- /dev/null +++ b/packages/cloud/src/config.ts @@ -0,0 +1,20 @@ +/** + * The shape of a browser client's own cloud config β€” everything `useCloudBoard` and + * `CloudAuth` need to reach a `@tm/server` and a vipper.iam issuer, without this package + * ever reading `import.meta.env` itself. + * + * That split is deliberate, not incidental: `import.meta.env` is a Vite build-time + * replacement, and esbuild (what `tsup` runs on) cannot emit `import.meta` in a CJS output + * β€” it substitutes `{}`. A config reader living here would build clean and ship a + * production bundle silently pointing at whatever the `{}` fallback resolves to. Reading + * the environment stays a per-app job (`apps/web/src/env.ts`, and mobile's own equivalent), + * each supplying its own client id; this package only names the shape they hand it in. + */ +export interface WebConfig { + /** The @tm/server root β€” no trailing slash. */ + cloudApiBase: string; + /** The vipper.iam OIDC issuer. */ + iamIssuer: string; + /** This build's own registered PUBLIC vipper.iam client id (PKCE, no secret). */ + iamClientId: string; +} diff --git a/apps/web/src/presence.test.ts b/packages/cloud/src/presence.test.ts similarity index 100% rename from apps/web/src/presence.test.ts rename to packages/cloud/src/presence.test.ts diff --git a/apps/web/src/presence.ts b/packages/cloud/src/presence.ts similarity index 100% rename from apps/web/src/presence.ts rename to packages/cloud/src/presence.ts diff --git a/apps/web/src/settings/SettingsScreen.tsx b/packages/cloud/src/settings/SettingsScreen.tsx similarity index 100% rename from apps/web/src/settings/SettingsScreen.tsx rename to packages/cloud/src/settings/SettingsScreen.tsx diff --git a/packages/cloud/tsconfig.json b/packages/cloud/tsconfig.json new file mode 100644 index 00000000..bb7f2d1f --- /dev/null +++ b/packages/cloud/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "outDir": "dist", + "noEmit": false, + "declaration": true, + "declarationMap": true + }, + "include": ["src"] +} diff --git a/packages/cloud/tsup.config.ts b/packages/cloud/tsup.config.ts new file mode 100644 index 00000000..7d3fa854 --- /dev/null +++ b/packages/cloud/tsup.config.ts @@ -0,0 +1,32 @@ +// tsup configuration for @tm/cloud β€” same no-barrel convention as packages/shared, +// packages/protocol and packages/ui (see packages/shared/tsup.config.ts for the full +// reasoning): every module is its own build entry, package.json's "./*" export maps +// straight onto dist/*, preserving the auth/, board/ and settings/ subdirectories. +// +// react/react-dom/Fluent are peerDependencies, externalized here for the same reason as +// packages/ui: a second React copy breaks hooks in the host app's tree. +// +// @tm/ui is ALSO external, and for a sharper reason than react: `useBoardExtras.ts` imports +// `useTransport` as a runtime VALUE, not just a type. Bundling a second copy of +// `@tm/ui/dist/transport.js` alongside the host's own import of `@tm/ui/transport` would +// create two separate `TransportContext` module instances β€” same shape, different identity +// β€” and `useContext` reading the wrong one fails at runtime with a green typecheck, not a +// build error. `@tm/ui` must never import `@tm/cloud` in return, or the two externals cycle. +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/**/*.{ts,tsx}', '!src/**/*.test.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: true, + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + '@fluentui/react-components', + '@fluentui/react-icons', + '@tm/ui', + /^@tm\/ui\//, + ], +}); diff --git a/packages/cloud/vitest.config.ts b/packages/cloud/vitest.config.ts new file mode 100644 index 00000000..aebcb6b4 --- /dev/null +++ b/packages/cloud/vitest.config.ts @@ -0,0 +1,15 @@ +/** + * Vitest configuration for packages/cloud, so `pnpm --filter @tm/cloud test` works + * standalone (CONTRIBUTING.md, RELEASE.md Β§1) in addition to the aggregated root + * `pnpm test`. + * + * This package's sources import `@tm/shared/*` and `@tm/protocol/*` as real workspace + * dependencies β€” resolved through node_modules against those packages' `exports`, i.e. + * their BUILT dist/ β€” so a standalone run needs `pnpm --filter @tm/shared build` and + * `pnpm --filter @tm/protocol build` to have happened first. turbo.json's `test` task + * declares that as `dependsOn: ["^build"]`; running this script directly, outside turbo, + * does not get it for free. + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5cf877a..0464dfc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@fluentui/react-icons': specifier: ^2.0.270 version: 2.0.331(react@18.3.1) + '@tm/cloud': + specifier: workspace:* + version: link:../../packages/cloud '@tm/protocol': specifier: workspace:* version: link:../../packages/protocol @@ -210,6 +213,46 @@ importers: specifier: ^2.1.5 version: 2.1.9(@types/node@22.20.0)(supports-color@8.1.1)(terser@5.49.2) + packages/cloud: + dependencies: + '@tm/protocol': + specifier: workspace:* + version: link:../protocol + '@tm/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + '@fluentui/react-components': + specifier: ^9.54.0 + version: 9.74.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.23.2) + '@fluentui/react-icons': + specifier: ^2.0.270 + version: 2.0.331(react@18.3.1) + '@tm/ui': + specifier: workspace:* + version: link:../ui + '@types/node': + specifier: ^22.9.0 + version: 22.20.0 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.0.2 + version: 8.5.1(postcss@8.5.16)(supports-color@8.1.1)(tsx@4.23.11)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/protocol: dependencies: '@tm/shared': @@ -2046,6 +2089,7 @@ packages: '@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 '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} diff --git a/test/shell-parity.test.ts b/test/shell-parity.test.ts index 13349974..10117cd1 100644 --- a/test/shell-parity.test.ts +++ b/test/shell-parity.test.ts @@ -442,7 +442,10 @@ describe('the one configuration the web deliberately does not mirror', () => { * The block asserts the decision, not the reasoning: if the decision is ever reversed, the * fix is to change it here and in the plan doc, not to delete the assertion. */ - const WEB_TREE = 'apps/web/src'; + // A list, not a single tree: the cloud sync layer moved out of apps/web/src into + // packages/cloud/src (Phase 27 step 3), so a breach introduced there would sail past a + // scan that only ever walked apps/web/src again. + const WEB_TREES = ['apps/web/src', 'packages/cloud/src']; const SHARED_UI_TREE = 'packages/ui/src'; const DESKTOP_PANE = 'apps/client/src/renderer/src/AgentProjects.tsx'; @@ -453,10 +456,12 @@ describe('the one configuration the web deliberately does not mirror', () => { it('has no browser code that creates, edits or removes one', () => { // Tests are excluded because the web's own suite calls `project:pickDirectory` on purpose // (`httpTransport.test.ts`) to assert the transport REFUSES it β€” the opposite of a breach. - const sources = filesUnder(WEB_TREE, /\.tsx?$/).filter((path) => !/\.test\.tsx?$/.test(path)); + const sources = WEB_TREES.flatMap((tree) => filesUnder(tree, /\.tsx?$/)).filter( + (path) => !/\.test\.tsx?$/.test(path), + ); expect( sources.length, - `found no non-test sources under ${WEB_TREE} β€” has the tree moved?`, + `found no non-test sources under ${WEB_TREES.join(', ')} β€” has a tree moved?`, ).toBeGreaterThan(10); const writes = sources.filter((path) => AGENT_PROJECT_WRITE.test(read(path))); @@ -478,7 +483,10 @@ describe('the one configuration the web deliberately does not mirror', () => { it('keeps the pane itself in the desktop renderer, and nowhere else', () => { const pattern = /^AgentProjects\.tsx?$/; - const copies = [...filesUnder(WEB_TREE, pattern), ...filesUnder(SHARED_UI_TREE, pattern)]; + const copies = [ + ...WEB_TREES.flatMap((tree) => filesUnder(tree, pattern)), + ...filesUnder(SHARED_UI_TREE, pattern), + ]; expect( copies, @@ -499,10 +507,10 @@ describe('the one configuration the web deliberately does not mirror', () => { it('says so on the web, where somebody would go looking', () => { // The card is the whole of the user-facing answer: a section that is quietly absent reads // as a screen that is broken, and the fix for that is text, not a feature. - const settings = read('apps/web/src/settings/SettingsScreen.tsx'); + const settings = read('packages/cloud/src/settings/SettingsScreen.tsx'); expect( settings, - 'apps/web/src/settings/SettingsScreen.tsx must keep an "Agent projects" entry in ' + + 'packages/cloud/src/settings/SettingsScreen.tsx must keep an "Agent projects" entry in ' + 'HOST_ONLY_SECTIONS. Dropping the entry does not remove the limit β€” it removes the ' + 'only explanation of it a browser user is ever offered.', ).toMatch(/title:\s*'Agent projects'/); From 61959b3ecbd9eadf58c3534e6e87fbe8ba189e6f Mon Sep 17 00:00:00 2001 From: Wojciech Dembinski Date: Tue, 18 Aug 2026 01:48:01 +0200 Subject: [PATCH 04/12] feat(mobile): scaffold apps/mobile and its own shell (Phase 27, step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of 12. A new @tm/mobile β€” package.json, tsconfig, vite/vitest config and index.html (viewport-fit=cover), modelled on apps/web's own. Its shell is a compact top bar (title, ClientPicker, sync dot, sign out) and a bottom tab bar with the same five destinations in the same order as web/desktop, Scratch run dimmed "desktop only" β€” MobileShell is deliberately not AppShell/NavRail, a phone has no rail and no mouse to hover it. main.tsx mounts the shared theme (scaleTheme(appDarkTheme, BASE_FONT_PX), useGlobalStyles, Toaster) the same way both other hosts do, at 100dvh; TransportProvider wraps the tree in @tm/cloud's HttpTransport, and sign-in reuses SignInScreen/useCloudAuth unchanged. Performance, Attention and Settings route to their shared components as-is; My Tasks is a placeholder until step 6 builds the tap-to-move board. Extends test/shell-parity.test.ts's HOST_TREES/HOST_DOCUMENTS to cover mobile from this commit, and adds a nav-parity assertion β€” mobile and web must expose the same five destination ids in the same order β€” confirmed red against a reordered NAV before landing. Co-Authored-By: Claude Sonnet 5 --- apps/mobile/.env.example | 14 ++ apps/mobile/index.html | 15 ++ apps/mobile/package.json | 35 +++++ apps/mobile/src/App.tsx | 195 ++++++++++++++++++++++++ apps/mobile/src/env.ts | 21 +++ apps/mobile/src/main.tsx | 41 +++++ apps/mobile/src/shell/MobileShell.tsx | 210 ++++++++++++++++++++++++++ apps/mobile/src/vite-env.d.ts | 14 ++ apps/mobile/tsconfig.json | 13 ++ apps/mobile/vite.config.ts | 31 ++++ apps/mobile/vitest.config.ts | 13 ++ pnpm-lock.yaml | 49 ++++++ test/shell-parity.test.ts | 58 ++++++- 13 files changed, 705 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/.env.example create mode 100644 apps/mobile/index.html create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/src/App.tsx create mode 100644 apps/mobile/src/env.ts create mode 100644 apps/mobile/src/main.tsx create mode 100644 apps/mobile/src/shell/MobileShell.tsx create mode 100644 apps/mobile/src/vite-env.d.ts create mode 100644 apps/mobile/tsconfig.json create mode 100644 apps/mobile/vite.config.ts create mode 100644 apps/mobile/vitest.config.ts diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 00000000..0424909a --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,14 @@ +# Local development configuration for @tm/mobile. Copy to .env.local and edit if you need +# to. Vite only exposes vars prefixed VITE_ to client code β€” see apps/web/.env.example, +# whose own header explains why nothing here is secret. + +# The @tm/server root this client polls (GET /v1/board) and posts commands to +# (POST /v1/commands). No trailing slash. Matches apps/server's PORT (.env.example). +VITE_CLOUD_API_BASE=http://localhost:3100 + +# vipper.iam β€” the OIDC issuer this client sends the user's browser to, and this build's +# own registered PUBLIC client id (grants: authorization_code + refresh_token, +# token_endpoint_auth_method: none), a separate registration from apps/web's +# `taskmanager-web` (docs/plan/README.md, Phase 27, Decision 4). +VITE_CLOUD_IAM_ISSUER=https://auth.vipper.network/oidc +VITE_CLOUD_IAM_CLIENT_ID=taskmanager-mobile diff --git a/apps/mobile/index.html b/apps/mobile/index.html new file mode 100644 index 00000000..e417882e --- /dev/null +++ b/apps/mobile/index.html @@ -0,0 +1,15 @@ + + + + + + + VIPPER Task Manager + + +
+ + + diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 00000000..679f7617 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,35 @@ +{ + "name": "@tm/mobile", + "version": "0.1.0", + "description": "VIPPER Task Manager Cloud β€” the Android client, an installable PWA. Vite + React + Fluent UI, deployed to its own Azure Static Web App (docs/plan/README.md, Phase 27). Same cloud sync as apps/web, through @tm/cloud; its own shell, because a phone has no rail and no mouse.", + "license": "UNLICENSED", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@fluentui/react-components": "^9.54.0", + "@fluentui/react-icons": "^2.0.270", + "@tm/cloud": "workspace:*", + "@tm/protocol": "workspace:*", + "@tm/shared": "workspace:*", + "@tm/ui": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.5" + } +} diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx new file mode 100644 index 00000000..d980cace --- /dev/null +++ b/apps/mobile/src/App.tsx @@ -0,0 +1,195 @@ +/** + * The Android client's shell. + * + * Same sign-in and cloud-sync plumbing as `apps/web`'s own `App.tsx` β€” `CloudAuth`, + * `useCloudAuth`, `useCloudBoard`, the outage/skew banners β€” all unchanged, all from + * `@tm/cloud` (docs/plan/README.md, Phase 27 step 2: the sync layer has no host in it). + * What differs is the frame it's drawn inside: `MobileShell`, not `AppShell`/`NavRail`/ + * `StatusBar` β€” see that file's own header for why a phone gets its own. + * + * The nav carries the same five destinations, in the same order, as the desktop's and + * `apps/web`'s own (`apps/client/src/renderer/src/App.tsx`, `apps/web/src/App.tsx`) β€” a + * structural fact `test/shell-parity.test.ts` now asserts rather than leaves to eyeballing. + * Scratch run stays off for the same reason it's off on the web: it drives a live + * `session:start`, host-only by policy (`@tm/shared/ipcRelay`), and a phone is not a host + * any more than a browser tab is. + */ +import { useMemo, useState } from 'react'; +import { Body1, Caption1, makeStyles } from '@fluentui/react-components'; +import { + AlertRegular, + DataTrendingRegular, + PlayRegular, + SettingsRegular, + TaskListSquareLtrRegular, +} from '@fluentui/react-icons'; +import { Attention } from '@tm/ui/Attention'; +import { Performance } from '@tm/ui/Performance'; +import type { NavRailItem } from '@tm/ui/shell/NavRail'; +import { TransportProvider } from '@tm/ui/transport'; +import { CloudAuth } from '@tm/cloud/auth/cloudAuth'; +import { SignInScreen } from '@tm/cloud/auth/SignInScreen'; +import { useCloudAuth } from '@tm/cloud/auth/useCloudAuth'; +import { SettingsScreen } from '@tm/cloud/settings/SettingsScreen'; +import { ClientPicker } from '@tm/cloud/board/ClientPicker'; +import { SkewBanner } from '@tm/cloud/board/SkewBanner'; +import { StaleBanner } from '@tm/cloud/board/StaleBanner'; +import { versionSkew } from '@tm/cloud/board/targetClient'; +import { useCloudBoard } from '@tm/cloud/board/useCloudBoard'; +import { MobileShell } from './shell/MobileShell'; +import { loadMobileConfig } from './env'; + +const useStyles = makeStyles({ + linkButton: { + background: 'none', + border: 'none', + padding: 0, + font: 'inherit', + color: 'inherit', + cursor: 'pointer', + textDecoration: 'underline', + }, + boardPlaceholder: { padding: '16px' }, +}); + +/** Why a tile that isn't here is off. Appended to its tooltip β€” same string the web uses. */ +const DESKTOP_ONLY = 'desktop only'; + +/** + * The desktop's rail, in the desktop's order β€” kept identical to `apps/web/src/App.tsx`'s + * own `NAV` on purpose. `test/shell-parity.test.ts` reads both arrays and fails the moment + * an id is added, dropped, or reordered on one side and not the other. + */ +const NAV: readonly NavRailItem[] = [ + { id: 'mytasks', label: 'My Tasks', icon: }, + { id: 'performance', label: 'Performance', icon: }, + { id: 'attention', label: 'Attention', icon: }, + { id: 'settings', label: 'Settings', icon: }, + { id: 'scratch', label: 'Scratch run', icon: , unavailable: DESKTOP_ONLY }, +]; + +/** The rail's destinations that this app actually renders. */ +type Screen = 'mytasks' | 'performance' | 'attention' | 'settings'; + +const SCREEN_TITLE: Record = { + mytasks: 'My Tasks', + performance: 'Performance', + attention: 'Attention', + settings: 'Settings', +}; + +export function App(): JSX.Element { + const config = useMemo(loadMobileConfig, []); + const auth = useMemo( + () => + new CloudAuth({ + config: { + issuer: config.iamIssuer, + clientId: config.iamClientId, + redirectUri: `${window.location.origin}/callback`, + }, + }), + [config], + ); + const { signedIn, error, signIn, signOut } = useCloudAuth(auth); + + return ( + + ); +} + +function AuthedApp({ + auth, + config, + signedIn, + error, + signIn, + signOut, +}: { + auth: CloudAuth; + config: ReturnType; + signedIn: boolean | null; + error: string | null; + signIn: () => void; + signOut: () => void; +}): JSX.Element { + // Same call as apps/web's own: starting the poll loop before sign-in would spend the + // whole backoff curve failing, and a shell around a sign-in prompt is dead weight. + if (signedIn !== true) { + return ; + } + return ; +} + +function SignedInApp({ + auth, + config, + onSignOut, +}: { + auth: CloudAuth; + config: ReturnType; + onSignOut: () => void; +}): JSX.Element { + const styles = useStyles(); + const board = useCloudBoard(auth, config); + const [screen, setScreen] = useState('mytasks'); + + const online = board.state.clients.length > 0; + const skew = versionSkew(board.targetClient); + + return ( + + + ) : board.targetClientId !== null ? ( + 'Offline β€” queued' + ) : ( + 'Never synced' + ) + } + onSignOut={onSignOut} + banners={ + !online ? ( + + ) : skew && board.targetClient ? ( + + ) : null + } + nav={NAV} + selected={screen} + onSelect={(id) => setScreen(id as Screen)} + > + {/* Same unmount-on-leave discipline as apps/web's App.tsx: a screen not being + looked at should not keep polling. */} + {screen === 'mytasks' && ( +
+ My Tasks + + The tap-to-move board lands in step 6 of this phase (docs/plan/README.md) β€” this + screen is wired into the shell now so its route exists before its content does. + +
+ )} + {screen === 'performance' && } + {screen === 'attention' && } + {screen === 'settings' && } +
+
+ ); +} diff --git a/apps/mobile/src/env.ts b/apps/mobile/src/env.ts new file mode 100644 index 00000000..9fc5390e --- /dev/null +++ b/apps/mobile/src/env.ts @@ -0,0 +1,21 @@ +/** + * This build's own config, read from `import.meta.env` β€” apps/web's `env.ts`, one app + * over, and its own header explains why this stays a per-app file rather than moving into + * `@tm/cloud`: `import.meta.env` is a Vite build-time replacement `@tm/cloud`'s esbuild + * build cannot emit. + * + * The one difference from apps/web's defaults is `iamClientId` β€” `taskmanager-mobile` is + * its own registered vipper.iam client id (Decision 4, docs/plan/README.md Phase 27), not + * apps/web's `taskmanager-web`, so a desktop's redirect-URI allowlist entry for one build + * can never be replayed against the other. + */ +import type { WebConfig } from '@tm/cloud/config'; + +export function loadMobileConfig(): WebConfig { + const env = import.meta.env; + return { + cloudApiBase: (env.VITE_CLOUD_API_BASE ?? 'http://localhost:3100').replace(/\/+$/, ''), + iamIssuer: env.VITE_CLOUD_IAM_ISSUER ?? 'https://auth.vipper.network/oidc', + iamClientId: env.VITE_CLOUD_IAM_CLIENT_ID ?? 'taskmanager-mobile', + }; +} diff --git a/apps/mobile/src/main.tsx b/apps/mobile/src/main.tsx new file mode 100644 index 00000000..6e032873 --- /dev/null +++ b/apps/mobile/src/main.tsx @@ -0,0 +1,41 @@ +/** + * Android entry point. Mounts the same provider the desktop and browser hosts do β€” see + * `apps/web/src/main.tsx`, whose header spells out why each of the four pieces below is + * shared and not a per-host copy β€” so the same board reads the same way in the Android app + * as it does in a desktop window or a browser tab. + * + * The one difference from `apps/web/src/main.tsx` is the provider's `height`: `100dvh` + * rather than `100vh`, so the root shrinks with the browser chrome / PWA gesture strip + * instead of running a viewport-height under it β€” `MobileShell`'s own root does the same, + * stated here too because this is the outermost box the app ever draws into. + */ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { FluentProvider, Toaster } from '@fluentui/react-components'; +import { appDarkTheme, BASE_FONT_PX, TOASTER_ID, scaleTheme, useGlobalStyles } from '@tm/ui/theme'; +import { App } from './App'; + +function ThemedApp(): JSX.Element { + useGlobalStyles(); + return ( + + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/apps/mobile/src/shell/MobileShell.tsx b/apps/mobile/src/shell/MobileShell.tsx new file mode 100644 index 00000000..b79e989f --- /dev/null +++ b/apps/mobile/src/shell/MobileShell.tsx @@ -0,0 +1,210 @@ +/** + * The mobile shell β€” deliberately NOT `@tm/ui/shell/AppShell`. + * + * That shell is a left rail plus a bottom status bar, built for a window wide enough to + * hold both without either stealing space a phone doesn't have. A phone gets the opposite + * arrangement: a compact top bar carrying the one line of ambient state every screen wants + * (title, which desktop is being driven, whether it's reachable, sign out), and a full-width + * bottom TAB bar β€” thumb reach, not mouse hover β€” for the five destinations. Structurally + * this is `apps/web`'s `AppShell`/`NavRail`/`StatusBar` triad turned ninety degrees, which is + * exactly why it can't just import them: threading "which edge is the nav on" through that + * shared shell would be the dozen-optional-props fork the plan's step 2 ruled out in the + * other direction (docs/plan/README.md, Phase 27, "Forked β€” mobile writes its own"). + * + * The small atoms it DOES reuse β€” `StatusDot`, the destination list's shape β€” are shared for + * the same reason a colour is shared: two dots for "is the desktop reachable" would drift the + * moment one host's got recoloured for contrast and the other didn't. + */ +import { + Caption1, + Subtitle2, + Tab, + TabList, + Tooltip, + makeStyles, + tokens, +} from '@fluentui/react-components'; +import type { ReactNode } from 'react'; +import type { NavRailItem } from '@tm/ui/shell/NavRail'; +import { StatusDot } from '@tm/ui/shell/StatusBar'; +import { fontPx } from '@tm/ui/theme'; + +const useStyles = makeStyles({ + /** + * `100dvh` rather than `100vh`: the dynamic viewport unit shrinks when the mobile browser + * chrome (address bar, PWA nav gesture strip) is on screen, so the shell's bottom tab bar + * stays above it instead of being pushed off under a `100vh` that assumed the chrome gone. + */ + shell: { + display: 'flex', + flexDirection: 'column', + height: '100dvh', + overflow: 'hidden', + backgroundColor: tokens.colorNeutralBackground2, + }, + topBar: { + display: 'flex', + alignItems: 'center', + gap: '10px', + flexShrink: 0, + padding: '8px 12px', + // The one other edge a phone can put content under β€” a notch or a status bar β€” the tab + // bar's own `env()` below covers the other. + paddingTop: 'max(8px, env(safe-area-inset-top))', + backgroundColor: tokens.colorNeutralBackground1, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + }, + title: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + status: { + display: 'flex', + alignItems: 'center', + gap: '6px', + flexShrink: 0, + minWidth: 0, + }, + statusLabel: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '120px', + }, + signOutButton: { + background: 'none', + border: 'none', + padding: '0 4px', + font: 'inherit', + color: 'inherit', + cursor: 'pointer', + textDecoration: 'underline', + flexShrink: 0, + // A link this small is still a tap target β€” the touch area grows even though the text + // painted inside it does not. + minHeight: '44px', + minWidth: '44px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + banners: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + padding: '8px 12px 0', + '&:empty': { display: 'none' }, + }, + body: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'auto', + }, + tabBar: { + display: 'flex', + flexShrink: 0, + backgroundColor: tokens.colorNeutralBackground1, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + // The gesture bar / nav buttons a fullscreen PWA sits above on Android. + paddingBottom: 'env(safe-area-inset-bottom)', + }, + tab: { + flex: 1, + minHeight: '44px', + justifyContent: 'center', + }, + /** See `NavRail.unavailable` β€” same reasoning, dimmed rather than removed. */ + tabUnavailable: { + flex: 1, + minHeight: '44px', + justifyContent: 'center', + opacity: 0.4, + cursor: 'default', + }, + smallCaption: { fontSize: fontPx(11) }, +}); + +export interface MobileShellProps { + /** The current screen's name, in the top bar. */ + title: string; + /** True when a desktop Client is reachable β€” colours the sync dot, same question `StatusDot` answers on the web. */ + online: boolean; + /** The line under/beside the title: a `ClientPicker`, an offline note, or nothing yet. */ + status: ReactNode; + onSignOut: () => void; + /** Above the content, below the top bar β€” outage/skew banners, same slot `AppShell.banners` is. */ + banners?: ReactNode; + nav: readonly NavRailItem[]; + selected: string; + onSelect: (id: string) => void; + children: ReactNode; +} + +export function MobileShell({ + title, + online, + status, + onSignOut, + banners, + nav, + selected, + onSelect, + children, +}: MobileShellProps): JSX.Element { + const styles = useStyles(); + return ( +
+
+ {title} +
+ + {status} +
+ + + +
+ +
{banners}
+ +
{children}
+ + { + const id = String(d.value); + // Same refusal `NavRail` makes, and for the same reason: a disabled `
+ ); +} diff --git a/apps/mobile/src/vite-env.d.ts b/apps/mobile/src/vite-env.d.ts new file mode 100644 index 00000000..72507d16 --- /dev/null +++ b/apps/mobile/src/vite-env.d.ts @@ -0,0 +1,14 @@ +/// + +/** This build's `package.json` version, substituted by `vite.config.ts`'s `define`. */ +declare const __APP_VERSION__: string; + +interface ImportMetaEnv { + readonly VITE_CLOUD_API_BASE?: string; + readonly VITE_CLOUD_IAM_ISSUER?: string; + readonly VITE_CLOUD_IAM_CLIENT_ID?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 00000000..27baa71d --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts new file mode 100644 index 00000000..e5d2a102 --- /dev/null +++ b/apps/mobile/vite.config.ts @@ -0,0 +1,31 @@ +/** + * apps/mobile is a plain Vite + React app, same shape as apps/web's own config (that + * file's header explains why: `@tm/ui`, `@tm/cloud`, `@tm/shared` and `@tm/protocol` are + * real workspace packages here, resolved by Vite through node_modules per their own + * `exports`, unlike apps/client's source-alias shortcut). + * + * Port is apps/web's own plus one, so both dev servers can run side by side. + */ +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +/** + * This build's own version, for the status bar. Baked in at build time from this + * package's own `package.json`, exactly as apps/web's does β€” see that file's comment. + */ +const { version } = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8'), +) as { version: string }; + +export default defineConfig({ + plugins: [react()], + define: { __APP_VERSION__: JSON.stringify(version) }, + server: { + port: 5176, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +}); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts new file mode 100644 index 00000000..53f294f6 --- /dev/null +++ b/apps/mobile/vitest.config.ts @@ -0,0 +1,13 @@ +/** + * Vitest configuration for apps/mobile, so `pnpm --filter @tm/mobile test` works + * standalone (CONTRIBUTING.md, RELEASE.md Β§1) in addition to the aggregated root + * `pnpm test` β€” same reasoning as apps/web/vitest.config.ts, one app over. + * + * It extends vite.config.ts rather than replacing it, so the React plugin lands here too. + */ +import { mergeConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; + +import viteConfig from './vite.config'; + +export default mergeConfig(viteConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0464dfc3..22a26d65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,55 @@ importers: specifier: ^2.1.5 version: 2.1.9(@types/node@22.20.0)(supports-color@8.1.1)(terser@5.49.2) + apps/mobile: + dependencies: + '@fluentui/react-components': + specifier: ^9.54.0 + version: 9.74.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.23.2) + '@fluentui/react-icons': + specifier: ^2.0.270 + version: 2.0.331(react@18.3.1) + '@tm/cloud': + specifier: workspace:* + version: link:../../packages/cloud + '@tm/protocol': + specifier: workspace:* + version: link:../../packages/protocol + '@tm/shared': + specifier: workspace:* + version: link:../../packages/shared + '@tm/ui': + specifier: workspace:* + version: link:../../packages/ui + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/node': + specifier: ^22.9.0 + version: 22.20.0 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.7.0(supports-color@8.1.1)(vite@5.4.21(@types/node@22.20.0)(terser@5.49.2)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@22.20.0)(terser@5.49.2) + vitest: + specifier: ^2.1.5 + version: 2.1.9(@types/node@22.20.0)(supports-color@8.1.1)(terser@5.49.2) + apps/server: dependencies: '@azure/identity': diff --git a/test/shell-parity.test.ts b/test/shell-parity.test.ts index 10117cd1..51621664 100644 --- a/test/shell-parity.test.ts +++ b/test/shell-parity.test.ts @@ -54,6 +54,7 @@ const WEB_BOARD = 'apps/web/src/board/BoardScreen.tsx'; const DESKTOP_BOARD = 'apps/client/src/renderer/src/MyTasks.tsx'; const WEB_MAIN = 'apps/web/src/main.tsx'; const DESKTOP_MAIN = 'apps/client/src/renderer/src/main.tsx'; +const MOBILE_APP = 'apps/mobile/src/App.tsx'; function read(path: string): string { return readFileSync(join(repoRoot, ...path.split('/')), 'utf8'); @@ -188,8 +189,15 @@ function topLevelKeys(literal: string): string[] { return keys; } -/** Where each host's own source lives β€” `dist` and `node_modules` are nobody's source. */ -const HOST_TREES = ['apps/web/src', 'apps/client/src/renderer/src']; +/** + * Where each host's own source lives β€” `dist` and `node_modules` are nobody's source. + * + * `apps/mobile/src` joined this list the same commit that created it (Phase 27 step 4), on + * purpose: the global-CSS guard below is only worth having if it covers a host from its + * first commit, rather than retroactively blessing whatever landed there before anyone + * thought to add it. + */ +const HOST_TREES = ['apps/web/src', 'apps/client/src/renderer/src', 'apps/mobile/src']; /** * Every file under a tree whose name `matches`, recursively and repo-relative. @@ -248,6 +256,44 @@ describe('the shell both hosts render through', () => { } }); +describe('the destinations both the browser and the Android client expose', () => { + /** + * The ticket's whole claim for `apps/mobile` (docs/plan/README.md, Phase 27) is "same + * features" β€” and a nav rail is the one place that claim can be read off as a literal list. + * `apps/web/src/App.tsx` and `apps/mobile/src/App.tsx` each declare a `NAV`/destinations + * array of `{ id: '…', label: '…', icon: … }` objects; this reads the `id`s off both, in + * the order they're written, so a destination added, dropped, or reordered on one side and + * not the other goes red here instead of waiting to be noticed by eye on a phone. + * + * Not compared against the desktop's own `apps/client/src/renderer/src/App.tsx`: that one + * already has no counterpart-parity guard today (nothing here enforces `apps/web`'s NAV + * against the desktop's either), and giving mobile a stricter guard than web already has + * would be a new rule invented in this step rather than the one asked for β€” mobile mirrors + * web's ids, which is what "modelled on apps/web" means for this file. + */ + function navIds(source: string): string[] { + const ids: string[] = []; + for (const match of source.matchAll(/\{\s*id:\s*'([a-z]+)'/g)) ids.push(match[1]); + return ids; + } + + it('list the same five destination ids, in the same order', () => { + const web = navIds(read(WEB_APP)); + const mobile = navIds(read(MOBILE_APP)); + + // A guard that found nothing to compare passes for the wrong reason β€” same discipline + // as the global-CSS block's own `toBeGreaterThan` below. + expect(web.length, `found no NAV ids in ${WEB_APP} β€” has its destinations moved?`).toBe(5); + + expect( + mobile, + `${MOBILE_APP}'s destination ids (${mobile.join(', ')}) must match ${WEB_APP}'s ` + + `(${web.join(', ')}), in the same order β€” the two hosts claim the same five ` + + 'destinations, and a mismatch here is that claim going false silently.', + ).toEqual(web); + }); +}); + describe('the board frame both hosts render through', () => { /** * The four rules that ARE the board's frame β€” the flex row, the board half, the scrolling @@ -303,8 +349,12 @@ describe("the app's global CSS rules", () => { { pattern: /\bcolor-scheme\s*:|\bcolorScheme\s*:/, what: 'the dark colour-scheme' }, ]; - /** The two entry documents, which live above those trees and can carry a