diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 9cba6e44..5221ac16 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -5,20 +5,29 @@
# - @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.
+# - @tm/mobile changed -> same, to ITS OWN Static Web App on its own subdomain
+# (docs/plan/README.md Phase 27, Decision 3).
+# A change to @tm/shared or @tm/protocol (used by all three) or the lockfile triggers every
+# job it applies to. @tm/ui and @tm/cloud are browser-only and trigger both `web` and `mobile`.
#
# 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
# that. A push containing only apps/client changes deploys nothing.
#
+# The `mobile` job is written and merged independently of the one-time Azure setup it needs
+# (docs/09, "Adding apps/mobile's Static Web App") — its deploy step checks
+# AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE itself and stays inert, rather than the job failing
+# loudly on every push, until a human has done that setup. `job.if` cannot do this check: the
+# `secrets` context is not available there, only in a step's own `if`.
+#
# ── One-time setup (repo Settings → Secrets and variables → Actions) ──
-# AZURE_CLIENT_ID app registration (or user-assigned MI) client id
-# AZURE_TENANT_ID Entra tenant id
-# AZURE_SUBSCRIPTION_ID the subscription holding the `taskmanager` RG
-# AZURE_STATIC_WEB_APPS_API_TOKEN az staticwebapp secrets list -n taskmanager-web \
-# -g taskmanager --query properties.apiKey -o tsv
-# VITE_CLOUD_IAM_CLIENT_ID this web build's public vipper.iam client id
+# AZURE_CLIENT_ID app registration (or user-assigned MI) client id
+# AZURE_TENANT_ID Entra tenant id
+# AZURE_SUBSCRIPTION_ID the subscription holding the `taskmanager` RG
+# AZURE_STATIC_WEB_APPS_API_TOKEN az staticwebapp secrets list -n taskmanager-web \
+# -g taskmanager --query properties.apiKey -o tsv
+# AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE same, for -n taskmanager-mobile (docs/09)
+# VITE_CLOUD_IAM_CLIENT_ID this web build's public vipper.iam client id
#
# Azure login uses OIDC — no stored credential. Create a federated credential on the app
# registration and give its service principal a role on the `taskmanager` resource group:
@@ -73,6 +82,7 @@ jobs:
outputs:
server: ${{ steps.filter.outputs.server }}
web: ${{ steps.filter.outputs.web }}
+ mobile: ${{ steps.filter.outputs.mobile }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@@ -91,6 +101,7 @@ jobs:
- 'packages/shared/**'
- 'packages/protocol/**'
- 'packages/ui/**'
+ - 'packages/cloud/**'
- 'pnpm-lock.yaml'
- '.github/workflows/deploy.yml'
# The one apps/client path that is a WEB input. vite.config.ts bakes the
@@ -98,8 +109,18 @@ jobs:
# what the web says about itself and the bundle has to be rebuilt to say it.
# Without this, the deployed web kept whatever number it was built with —
# it sat on v0.78.2 while the desktop was released at v0.86.0. Nothing else
- # under apps/client belongs here: that app is release.yml's business.
+ # under apps/client belongs here: that app is release.yml's business. (Not
+ # mobile's filter below — apps/mobile/vite.config.ts bakes ITS OWN
+ # package.json's version, already covered by apps/mobile/** there.)
- 'apps/client/package.json'
+ mobile:
+ - 'apps/mobile/**'
+ - 'packages/shared/**'
+ - 'packages/protocol/**'
+ - 'packages/ui/**'
+ - 'packages/cloud/**'
+ - 'pnpm-lock.yaml'
+ - '.github/workflows/deploy.yml'
# ── @tm/server → GHCR → migrate job → Container App ────────────────────────
server:
@@ -265,9 +286,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
@@ -284,3 +305,72 @@ jobs:
skip_app_build: true
skip_api_build: true
output_location: ''
+
+ # ── @tm/mobile → its own Static Web App ─────────────────────────────────────
+ # Mirrors `web` above, deployed to a separate SWA on its own subdomain (docs/plan/README.md
+ # Phase 27, Decision 3) so a phone client never shares a route table or a deploy with the
+ # browser one.
+ mobile:
+ needs: changes
+ if: needs.changes.outputs.mobile == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # Written and merged before the one-time Azure setup this job needs necessarily exists
+ # (docs/plan/README.md Phase 27, Decision 4) — checked in a STEP, not a job-level `if`,
+ # because the `secrets` context is unavailable there. Without this, a job whose token
+ # secret is still unset would fail the whole workflow on every push to `development`
+ # that touches apps/mobile, for everyone, until a human does the one-time setup in
+ # docs/09 ("Adding apps/mobile's Static Web App").
+ - name: Check the mobile Static Web Apps token is configured
+ id: token
+ run: |
+ if [ -n "${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE }}" ]; then
+ echo "configured=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "configured=false" >> "$GITHUB_OUTPUT"
+ echo "::warning::AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE is not set — skipping the mobile deploy. See docs/09-deploying-the-cloud-service.md, 'Adding apps/mobile's Static Web App'."
+ fi
+
+ - uses: pnpm/action-setup@v4
+ if: steps.token.outputs.configured == 'true'
+ - uses: actions/setup-node@v4
+ if: steps.token.outputs.configured == 'true'
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ if: steps.token.outputs.configured == 'true'
+ run: pnpm install --frozen-lockfile
+
+ # Same reasoning as the web build's env block above — Vite compiles these into the
+ # bundle at build time. VITE_CLOUD_IAM_CLIENT_ID is `taskmanager-mobile` unconditionally
+ # (apps/mobile/.env.example): its own registered public vipper.iam client id, a separate
+ # registration from apps/web's `taskmanager-web`, so a redirect-URI allowlist entry for
+ # one build can never be replayed against the other. Not a secret, unlike the web job's
+ # (docs/11 explains that one is a secret only by storage, not by nature) — this one is
+ # never stored as one at all.
+ - name: Build the mobile client
+ if: steps.token.outputs.configured == 'true'
+ env:
+ VITE_CLOUD_API_BASE: https://tasks-api.vipper.network
+ VITE_CLOUD_IAM_ISSUER: https://auth.vipper.network/oidc
+ VITE_CLOUD_IAM_CLIENT_ID: taskmanager-mobile
+ run: pnpm exec turbo run build --filter=@tm/mobile
+
+ - name: Include the Static Web Apps config
+ if: steps.token.outputs.configured == 'true'
+ run: cp apps/mobile/staticwebapp.config.json apps/mobile/dist/
+
+ - name: Deploy to Static Web Apps
+ if: steps.token.outputs.configured == 'true'
+ uses: Azure/static-web-apps-deploy@v1
+ with:
+ azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE }}
+ action: upload
+ app_location: apps/mobile/dist
+ skip_app_build: true
+ skip_api_build: true
+ output_location: ''
diff --git a/apps/client/scripts/verify-remote-ipc.mjs b/apps/client/scripts/verify-remote-ipc.mjs
index 06d12f05..53178114 100644
--- a/apps/client/scripts/verify-remote-ipc.mjs
+++ b/apps/client/scripts/verify-remote-ipc.mjs
@@ -45,7 +45,10 @@ const app = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repo = resolve(app, '..', '..');
const sharedSrc = join(repo, 'packages', 'shared', 'src');
const protocolSrc = join(repo, 'packages', 'protocol', 'src');
-const webSrc = join(repo, 'apps', 'web', 'src');
+// `HttpTransport` and `PolledEventBus` moved out of apps/web/src and into `@tm/cloud`
+// (Phase 27 step 3, so both apps/web and apps/mobile can share one sync layer) — this
+// reads their sources directly, same as it always has, just from their new home.
+const cloudSrc = join(repo, 'packages', 'cloud', 'src');
/**
* Everything this script writes lives here, INSIDE the app rather than in the temp dir, for
@@ -99,7 +102,6 @@ export default { app, ipcMain, safeStorage };
'@tm/shared': sharedSrc,
'@tm/protocol': protocolSrc,
'@tm/ui/transport': empty,
- '@web': webSrc,
electron: electronStub,
},
},
@@ -138,8 +140,8 @@ import { createStore } from '${join(app, 'src/main/store').replace(/\\/g, '/')}'
import { RelayRegistry } from '${join(app, 'src/main/ipcRegistry').replace(/\\/g, '/')}';
import { CommandQueue } from '${join(app, 'src/main/commandQueue').replace(/\\/g, '/')}';
import { applyCloudCommand } from '${join(app, 'src/main/cloudCommands').replace(/\\/g, '/')}';
-import { HttpTransport } from '${join(webSrc, 'board/httpTransport').replace(/\\/g, '/')}';
-import { PolledEventBus } from '${join(webSrc, 'board/polledEvents').replace(/\\/g, '/')}';
+import { HttpTransport } from '${join(cloudSrc, 'board/httpTransport').replace(/\\/g, '/')}';
+import { PolledEventBus } from '${join(cloudSrc, 'board/polledEvents').replace(/\\/g, '/')}';
import {
acknowledgeable,
isDeliverable,
diff --git a/apps/client/scripts/verify-remote-sse.mjs b/apps/client/scripts/verify-remote-sse.mjs
index 4b1558f0..5421f5e8 100644
--- a/apps/client/scripts/verify-remote-sse.mjs
+++ b/apps/client/scripts/verify-remote-sse.mjs
@@ -6,7 +6,7 @@
* (`ipcEventFanout.test.ts`), the desktop's forwarder (`cloudEventForwarder.test.ts`), the
* server's ring and its subscriptions (`eventBus.test.ts`), the SSE framing
* (`sseStream.test.ts`), the browser's reader (`sseEvents.test.ts`) and the composite that
- * chooses between push and poll (`apps/web/src/board/eventBus.test.ts`). What none of them
+ * chooses between push and poll (`packages/cloud/src/board/eventBus.test.ts`). What none of them
* covers is the thing that actually has to work: an agent's line, emitted on the desktop,
* arriving in a browser — through the forwarder's queue, a real `POST /v1/events`, the
* server's replay ring, real `text/event-stream` bytes, the browser's `ReadableStream`
@@ -48,7 +48,10 @@ const app = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repo = resolve(app, '..', '..');
const sharedSrc = join(repo, 'packages', 'shared', 'src');
const protocolSrc = join(repo, 'packages', 'protocol', 'src');
-const webSrc = join(repo, 'apps', 'web', 'src');
+// `SseEventStream` and `CloudEventBus` moved out of apps/web/src and into `@tm/cloud`
+// (Phase 27 step 3, so both apps/web and apps/mobile can share one sync layer) — this
+// reads their sources directly, same as it always has, just from their new home.
+const cloudSrc = join(repo, 'packages', 'cloud', 'src');
const serverSrc = join(repo, 'apps', 'server', 'src');
/**
@@ -113,7 +116,6 @@ async function bundle(entry, outDir) {
'@tm/shared': sharedSrc,
'@tm/protocol': protocolSrc,
'@tm/ui/transport': empty,
- '@web': webSrc,
'@nestjs/common': nestStub,
electron: electronStub,
},
@@ -158,8 +160,8 @@ const SCENARIO = `
import { CloudEventForwarder } from '${posix(join(app, 'src/main/cloudEventForwarder'))}';
import { EventBus } from '${posix(join(serverSrc, 'events/eventBus'))}';
import { openEventStream } from '${posix(join(serverSrc, 'events/sseStream'))}';
-import { SseEventStream } from '${posix(join(webSrc, 'board/sseEvents'))}';
-import { CloudEventBus } from '${posix(join(webSrc, 'board/eventBus'))}';
+import { SseEventStream } from '${posix(join(cloudSrc, 'board/sseEvents'))}';
+import { CloudEventBus } from '${posix(join(cloudSrc, 'board/eventBus'))}';
import { MAX_EVENT_BYTES } from '${posix(join(sharedSrc, 'ipcEventFanout'))}';
let failures = 0;
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..ddb3c1cb
--- /dev/null
+++ b/apps/mobile/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+ VIPPER Task Manager
+
+
+
+
+
+
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000..8d4d5ba5
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,38 @@
+{
+ "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": "npm run typecheck && vite build && vite build -c vite.sw.config.ts",
+ "preview": "vite preview",
+ "typecheck:app": "tsc --noEmit -p tsconfig.app.json",
+ "typecheck:sw": "tsc --noEmit -p tsconfig.sw.json",
+ "typecheck": "npm run typecheck:app && npm run typecheck:sw",
+ "icons": "node ../../scripts/make-mobile-icons.mjs",
+ "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/public/icons/icon-192.png b/apps/mobile/public/icons/icon-192.png
new file mode 100644
index 00000000..23051e8e
Binary files /dev/null and b/apps/mobile/public/icons/icon-192.png differ
diff --git a/apps/mobile/public/icons/icon-512.png b/apps/mobile/public/icons/icon-512.png
new file mode 100644
index 00000000..a1a333f6
Binary files /dev/null and b/apps/mobile/public/icons/icon-512.png differ
diff --git a/apps/mobile/public/manifest.webmanifest b/apps/mobile/public/manifest.webmanifest
new file mode 100644
index 00000000..aa30d670
--- /dev/null
+++ b/apps/mobile/public/manifest.webmanifest
@@ -0,0 +1,26 @@
+{
+ "id": "/",
+ "name": "VIPPER Task Manager",
+ "short_name": "Task Manager",
+ "description": "VIPPER Task Manager Cloud — the Android client.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "theme_color": "#1f1f1f",
+ "background_color": "#1f1f1f",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
new file mode 100644
index 00000000..f32d37b7
--- /dev/null
+++ b/apps/mobile/src/App.tsx
@@ -0,0 +1,223 @@
+/**
+ * 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 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.
+ * Projects joined that set post-merge (it is a native ticket project — no folder, no native
+ * picker — so, like on the web, it carries no `unavailable` here either) and renders the same
+ * shared `` `@tm/ui` component unmodified, the same way Performance and Attention
+ * do. 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.
+ *
+ * The tab itself is no longer a plain `useState`: it is the `screen`-typed `tab` frame at the
+ * bottom of `useBackStack`'s nav stack (`nav/navStack.ts`), so switching tabs is one more thing
+ * Android's Back key can undo — see that file's header for why the whole stack, not just this
+ * tab, is the shared source of truth for what a phone screen is currently showing.
+ */
+import { useMemo } from 'react';
+import { makeStyles } from '@fluentui/react-components';
+import {
+ AlertRegular,
+ DataTrendingRegular,
+ FolderRegular,
+ PlayRegular,
+ SettingsRegular,
+ TaskListSquareLtrRegular,
+} from '@fluentui/react-icons';
+import { Attention } from '@tm/ui/Attention';
+import { Performance } from '@tm/ui/Performance';
+import { Projects } from '@tm/ui/projects/Projects';
+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 { BoardScreen } from './board/BoardScreen';
+import { MobileShell } from './shell/MobileShell';
+import { loadMobileConfig } from './env';
+import { topOverlay, topScreen, type Screen } from './nav/navStack';
+import { useBackStack } from './nav/useBackStack';
+
+const useStyles = makeStyles({
+ linkButton: {
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ font: 'inherit',
+ color: 'inherit',
+ cursor: 'pointer',
+ textDecoration: 'underline',
+ },
+});
+
+/** 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: 'projects', label: 'Projects', 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 },
+];
+
+const SCREEN_TITLE: Record = {
+ mytasks: 'My Tasks',
+ projects: 'Projects',
+ 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 nav = useBackStack({ type: 'tab', screen: 'mytasks' });
+ const screen = topScreen(nav.stack);
+ const overlay = topOverlay(nav.stack);
+
+ 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) => {
+ const next = id as Screen;
+ if (next !== screen) nav.push({ type: 'tab', screen: next });
+ }}
+ >
+ {/* Same unmount-on-leave discipline as apps/web's App.tsx: a screen not being
+ looked at should not keep polling. */}
+ {screen === 'mytasks' && (
+ void board.setStatus(taskId, status)}
+ onStatusNoted={board.noteStatus}
+ overlay={overlay}
+ onOpenTask={(taskId) => nav.push({ type: 'task', taskId })}
+ onOpenAddTask={() => nav.push({ type: 'addTask' })}
+ onOpenGraph={() => nav.push({ type: 'graph' })}
+ onOpenArchived={() => nav.push({ type: 'archived', openedAt: Date.now() })}
+ onBack={nav.back}
+ />
+ )}
+ {screen === 'projects' && }
+ {screen === 'performance' && }
+ {screen === 'attention' && }
+ {screen === 'settings' && (
+ auth.getAccessToken()}
+ />
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/board/BoardCardRow.tsx b/apps/mobile/src/board/BoardCardRow.tsx
new file mode 100644
index 00000000..8e705cca
--- /dev/null
+++ b/apps/mobile/src/board/BoardCardRow.tsx
@@ -0,0 +1,91 @@
+/**
+ * One card in the mobile board's list: `@tm/ui`'s `TaskCard`, unchanged, with a "Move to…"
+ * menu underneath it instead of the drag handle a mouse would use.
+ *
+ * `TaskCard` needs no changes to fit a phone — its `card` rule declares no width at all and
+ * every chip inside it is `maxWidth: 100%`/`minWidth: 0` — so the one thing this file adds is
+ * the way you move a card without a mouse to drag it with. `draggable`/`dragging`/
+ * `onDragStart`/`onDragEnd` are simply never passed: they are optional on `TaskCard` now
+ * (the same pattern `TaskDetail`'s `readOnlyNotice` set — an absent prop as the host
+ * difference), so this card is never draggable and the four props needed no stand-in no-ops.
+ *
+ * The menu sits BELOW the card rather than floating over a corner of it: a card is already
+ * carrying a project notch, an attention ring and (while a link gesture existed) a handle in
+ * that corner on the desktop, and a control you can actually tap without missing wants a real
+ * row of its own rather than a few pixels borrowed from the card underneath it.
+ */
+import {
+ Button,
+ Menu,
+ MenuItem,
+ MenuList,
+ MenuPopover,
+ MenuTrigger,
+ makeStyles,
+} from '@fluentui/react-components';
+import { ArrowRoutingRegular } from '@fluentui/react-icons';
+import type { BoardColumn } from '@tm/shared/model';
+import { COLUMN_META } from '@tm/ui/board/boardColumns';
+import { TaskCard, type TaskCardProps } from '@tm/ui/board/TaskCard';
+
+const useStyles = makeStyles({
+ wrap: { display: 'flex', flexDirection: 'column', gap: '2px' },
+ moveButton: { alignSelf: 'flex-start', minHeight: '32px' },
+});
+
+/** The four drag props this card never has a use for — see the header above. */
+type NoDragTaskCardProps = Omit<
+ TaskCardProps,
+ 'draggable' | 'dragging' | 'onDragStart' | 'onDragEnd'
+>;
+
+export interface BoardCardRowProps extends NoDragTaskCardProps {
+ /** The column this card is actually in — left out of the menu, since moving there is a no-op. */
+ column: BoardColumn;
+ /** The columns the menu may offer, in order — `visibleColumns(showDone)`'s own answer, so a
+ * card is never moved somewhere the board isn't currently showing. */
+ moveTargets: readonly BoardColumn[];
+ onMove: (column: BoardColumn) => void;
+}
+
+const COLUMN_LABEL: Record = Object.fromEntries(
+ COLUMN_META.map((c) => [c.column, c.label]),
+) as Record;
+
+export function BoardCardRow({
+ column,
+ moveTargets,
+ onMove,
+ ...cardProps
+}: BoardCardRowProps): JSX.Element {
+ const styles = useStyles();
+ const targets = moveTargets.filter((c) => c !== column);
+ return (
+
+
+ {targets.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/board/BoardScreen.tsx b/apps/mobile/src/board/BoardScreen.tsx
new file mode 100644
index 00000000..a6ee18ad
--- /dev/null
+++ b/apps/mobile/src/board/BoardScreen.tsx
@@ -0,0 +1,447 @@
+/**
+ * The mobile board: My Tasks, drawn for a phone — the same cloud mirror `apps/web`'s
+ * `BoardScreen` reads (`useCloudBoard`, `useBoardExtras`), the same cards
+ * (`sortCards`/`groupSubtasks`/`@tm/ui`'s `TaskCard`), and the same optimistic move
+ * (`useCloudBoard.setStatus` — the pending overlay, the queued command and the
+ * reconciliation on the next poll are all untouched, all still in `useCloudBoard.ts`).
+ *
+ * What differs from the web's board is the shape, not the data: one column at a time
+ * (`ColumnChips`) instead of `KanbanColumn`'s side-by-side grid, a "Move to…" menu
+ * (`BoardCardRow`) instead of HTML5 drag-and-drop, and no chain overlay — a phone showing
+ * one column at a time has nothing for an arrow to span, so chain state surfaces through
+ * the shared `TaskChain` inside the detail screen instead (docs/plan/README.md, Phase 27
+ * step 2, "Dropped"). Selecting a card now pushes that screen full-screen (`TaskScreen`,
+ * step 7) rather than opening a side pane; the commit graph opens as its own full-screen
+ * sheet (`GitGraphSheet`) for the same reason.
+ *
+ * Which of those four is open is no longer this component's own state (step 8): a task screen,
+ * `AddTaskDialog`, `GitGraphSheet` and `ArchivedCardsDialog` each cover the toolbar buttons
+ * that would open one of the others, so only one is ever showing — exactly the single `overlay`
+ * `App.tsx`'s nav stack (`nav/navStack.ts`) tracks. Opening one pushes a frame; every close in
+ * this file calls the same `onBack` Android's hardware key does, so an in-app "x" and a real
+ * Back press can never fall out of sync with the browser history entry that represents it.
+ */
+import { useCallback, useMemo, useState } from 'react';
+import { Button, Caption1, Spinner, Switch, makeStyles, tokens } from '@fluentui/react-components';
+import {
+ AddRegular,
+ ArchiveRegular,
+ ArrowSyncRegular,
+ BranchForkRegular,
+} from '@fluentui/react-icons';
+import { columnForTask, statusForColumn } from '@tm/shared/board';
+import {
+ isManualStatus,
+ PERSONAL_PROJECT_ID,
+ type BoardColumn,
+ type ManualStatus,
+ type Task,
+} from '@tm/shared/model';
+import {
+ COLUMN_META,
+ groupSubtasks,
+ hiddenDoneSummary,
+ sortCards,
+ visibleColumns,
+ type BoardCard,
+} from '@tm/ui/board/boardColumns';
+import { AddTaskDialog } from '@tm/ui/AddTaskDialog';
+import {
+ archivedCards,
+ archivedCountLabel,
+ archivedCountTitle,
+ ArchivedCardsDialog,
+} from '@tm/ui/board/ArchivedCardsDialog';
+import { chainStates } from '@tm/ui/board/chainStates';
+import { doneSwitchLabel, doneSwitchTitle } from '@tm/ui/board/doneSwitchLabel';
+import { useTransport } from '@tm/ui/transport';
+import { selectArchivedTasks, selectBoardTasks } from '@tm/cloud/board/boardSelectors';
+import {
+ displayStatus,
+ isTaskPending,
+ type CloudBoardState,
+} from '@tm/cloud/board/cloudBoardStore';
+import { byTask, mergeRequestsByTask, useBoardExtras } from '@tm/cloud/board/useBoardExtras';
+import { BoardCardRow } from './BoardCardRow';
+import { ColumnChips } from './ColumnChips';
+import { GitGraphSheet } from './GitGraphSheet';
+import { TaskScreen } from './TaskScreen';
+import type { Overlay } from '../nav/navStack';
+
+const useStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ minHeight: 0,
+ // Room for the FAB, so the last card in a long column is never sitting under it.
+ paddingBottom: '84px',
+ },
+ toolbar: {
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: '8px',
+ padding: '8px 12px 0',
+ },
+ grow: { flex: 1, minWidth: 0 },
+ error: { padding: '0 12px', color: '#f1707b' },
+ list: { display: 'flex', flexDirection: 'column', gap: '12px', padding: '4px 12px 0' },
+ empty: { color: tokens.colorNeutralForeground4, padding: '16px 4px' },
+ fab: {
+ position: 'fixed',
+ right: '16px',
+ bottom: 'calc(72px + env(safe-area-inset-bottom))',
+ minWidth: '56px',
+ width: '56px',
+ height: '56px',
+ boxShadow: tokens.shadow16,
+ },
+});
+
+export interface BoardScreenProps {
+ state: CloudBoardState;
+ everSeenClient: boolean;
+ onSetStatus: (taskId: string, status: ManualStatus) => void;
+ /**
+ * A status change the DETAIL SCREEN has already sent for itself: record the same pending
+ * overlay a "Move to…" pick gets, without putting a second identical command on the wire.
+ * `apps/web`'s own `BoardScreen`'s `onStatusNoted` — see `TaskDetail`'s `onStatusChanged`.
+ */
+ onStatusNoted: (taskId: string, status: ManualStatus) => void;
+ /** The task screen or sheet/dialog on top of this board, if any — `App.tsx`'s nav stack. */
+ overlay: Overlay | null;
+ onOpenTask: (taskId: string) => void;
+ onOpenAddTask: () => void;
+ onOpenGraph: () => void;
+ onOpenArchived: () => void;
+ /** Pops the current overlay — every close button below calls this, same as Android's Back key. */
+ onBack: () => void;
+}
+
+export function BoardScreen({
+ state,
+ everSeenClient,
+ onSetStatus,
+ onStatusNoted,
+ overlay,
+ onOpenTask,
+ onOpenAddTask,
+ onOpenGraph,
+ onOpenArchived,
+ onBack,
+}: BoardScreenProps): JSX.Element {
+ const styles = useStyles();
+ const transport = useTransport();
+ const extras = useBoardExtras();
+ const [selectedColumn, setSelectedColumn] = useState('todo');
+ const [error, setError] = useState(null);
+ const [syncing, setSyncing] = useState(false);
+
+ const selectedTaskId = overlay?.type === 'task' ? overlay.taskId : null;
+ const addOpen = overlay?.type === 'addTask';
+ const graphOpen = overlay?.type === 'graph';
+ const archivedOpenedAt = overlay?.type === 'archived' ? overlay.openedAt : null;
+
+ const { settings, saveSettings } = extras;
+ const showDone = settings.jira.showDoneColumn;
+
+ const reportError = useCallback((e: unknown) => {
+ setError(e instanceof Error ? e.message : String(e));
+ }, []);
+
+ const projects = useMemo(() => Object.values(state.projects), [state.projects]);
+ // No scope picker here yet (unlike apps/web's BoardToolbar) — mobile keeps showing every
+ // board combined, the same behaviour it had before selectBoardTasks/selectArchivedTasks
+ // grew a `scope` param for the single-board feature. Giving mobile its own scope switcher
+ // is a follow-up, not part of this merge.
+ const boardTasks = useMemo(() => selectBoardTasks(state, 'all'), [state]);
+ const removedCards = useMemo(() => archivedCards(selectArchivedTasks(state, 'all')), [state]);
+ const mrsByTask = useMemo(
+ () => mergeRequestsByTask(extras.mergeRequests),
+ [extras.mergeRequests],
+ );
+
+ const projectNameOf = (task: Task): string | undefined =>
+ task.externalSource ? task.phase || undefined : undefined;
+ const agentNameOf = (task: Task): string | undefined =>
+ extras.agentProjects.find((p) => p.id === task.agentProjectId)?.name;
+ const projectColorOf = (task: Task): string | undefined =>
+ extras.agentProjects.find((p) => p.id === task.projectTagId)?.color || undefined;
+
+ const cardsByColumn = useMemo(() => {
+ // Same optimistic overlay the web board applies before columning: a card moved from this
+ // screen jumps to its destination the instant the tap lands, from `displayStatus` rather
+ // than waiting for the next poll to confirm it.
+ const displayTasks = boardTasks.map((task) => ({
+ ...task,
+ status: displayStatus(state, task),
+ }));
+ const byColumn = new Map();
+ for (const meta of COLUMN_META) byColumn.set(meta.column, []);
+ for (const card of groupSubtasks(displayTasks, mrsByTask)) {
+ byColumn.get(columnForTask(card.task))?.push(card);
+ }
+ for (const meta of COLUMN_META) {
+ byColumn.set(
+ meta.column,
+ sortCards(byColumn.get(meta.column) ?? [], extras.attention.taskIds),
+ );
+ }
+ return byColumn;
+ }, [boardTasks, state, mrsByTask, extras.attention.taskIds]);
+
+ const hiddenDone = useMemo(
+ () => hiddenDoneSummary(cardsByColumn.get('done') ?? []),
+ [cardsByColumn],
+ );
+
+ const visible = useMemo(() => visibleColumns(showDone), [showDone]);
+ // Falls back to the first visible column the moment the current chip's column is hidden —
+ // e.g. Show Done switched off while DONE was selected — rather than rendering a chip row
+ // with nothing selected in it.
+ const effectiveColumn = visible.includes(selectedColumn)
+ ? selectedColumn
+ : (visible[0] ?? 'todo');
+
+ const pendingTaskIds = useMemo(() => {
+ const ids = new Set();
+ for (const task of Object.values(state.tasks)) {
+ if (isTaskPending(state, task.id)) ids.add(task.id);
+ }
+ return ids;
+ }, [state]);
+
+ const parentCandidates = useMemo(() => boardTasks.filter((t) => !t.parentTaskId), [boardTasks]);
+ const tasksById = useMemo(() => new Map(boardTasks.map((t) => [t.id, t])), [boardTasks]);
+ const attachmentsByTask = useMemo(() => byTask(extras.attachments), [extras.attachments]);
+
+ /**
+ * The chain's own state, over the same links and tasks `TaskChain` inside the screen
+ * reads — `apps/web`'s own `chainState`, computed here so this board can answer the
+ * same "waiting on"/"merge held" chips the desktop's Agent panel shows.
+ */
+ const chainState = useMemo(
+ () => chainStates(extras.chainLinks, tasksById, extras.liveRunTaskIds),
+ [extras.chainLinks, tasksById, extras.liveRunTaskIds],
+ );
+
+ /**
+ * The card the full-screen route is showing — with the same pending overlay its column
+ * applies, so a card just tapped to DONE does not read "In progress" once it opens.
+ */
+ const selectedTask = useMemo(() => {
+ const task = selectedTaskId ? (state.tasks[selectedTaskId] ?? null) : null;
+ return task ? { ...task, status: displayStatus(state, task) } : null;
+ }, [state, selectedTaskId]);
+
+ /**
+ * The chain the screen needs: the selected card's own steps, or — when a STEP is
+ * selected — its siblings, so the pane can say "step 2 of 5". `apps/web`'s own `chain`.
+ */
+ const chain = useMemo(() => {
+ if (!selectedTask) return [];
+ const parentId = selectedTask.parentTaskId ?? selectedTask.id;
+ return boardTasks.filter((t) => t.parentTaskId === parentId).sort((a, b) => a.order - b.order);
+ }, [boardTasks, selectedTask]);
+
+ /** The card a shown step belongs to — the screen's own breadcrumb back out of it. */
+ const parentOfSelected = useMemo(
+ () => (selectedTask?.parentTaskId ? (state.tasks[selectedTask.parentTaskId] ?? null) : null),
+ [state.tasks, selectedTask],
+ );
+
+ const move = useCallback(
+ (taskId: string, column: BoardColumn) => {
+ if (!everSeenClient) return;
+ if (pendingTaskIds.has(taskId)) return; // one edit in flight at a time per card
+ onSetStatus(taskId, statusForColumn(column));
+ },
+ [everSeenClient, pendingTaskIds, onSetStatus],
+ );
+
+ const disabledReason = everSeenClient
+ ? undefined
+ : 'No desktop app has ever synced this account — sign in and open it once first.';
+
+ const cards = cardsByColumn.get(effectiveColumn) ?? [];
+
+ return (
+
+ {boardTasks.length === 0 ? (
+
+ {projects.length === 0 && Object.keys(state.tasks).length === 0
+ ? 'No board data yet — waiting on the first sync from your desktop app.'
+ : 'No cards on your Personal board.'}
+
+ ) : cards.length === 0 ? (
+ Nothing here.
+ ) : (
+ cards.map(({ task, subtasks, mergeRequests }) => (
+ onOpenTask(task.id)}
+ column={effectiveColumn}
+ moveTargets={visible}
+ onMove={(column) => move(task.id, column)}
+ />
+ ))
+ )}
+
+
+ }
+ disabled={!everSeenClient}
+ title={disabledReason}
+ aria-label="Add task"
+ onClick={onOpenAddTask}
+ />
+
+
+
+ extras.restoreTask(taskId).catch(reportError)}
+ />
+
+
+
+ {/* The full-screen route a tap opens (Phase 27 step 7) — the desktop's own `TaskDetail`,
+ the same component, wrapped for a phone rather than sat in a 40% pane. */}
+ {selectedTask && (
+ void extras.removeLink(linkId).catch(reportError)}
+ onOpenTask={onOpenTask}
+ onClose={onBack}
+ // The screen's State dropdown has already sent its own `task:setStatus` through the
+ // transport by the time this runs (`TaskDetailsCell`) — this only records the
+ // optimistic overlay a "Move to…" pick gets, and must not send a second command.
+ onStatusChanged={(updated) => {
+ if (isManualStatus(updated.status)) onStatusNoted(updated.id, updated.status);
+ }}
+ onSubtasksChanged={extras.refresh}
+ />
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/board/ColumnChips.tsx b/apps/mobile/src/board/ColumnChips.tsx
new file mode 100644
index 00000000..fca55cb5
--- /dev/null
+++ b/apps/mobile/src/board/ColumnChips.tsx
@@ -0,0 +1,87 @@
+/**
+ * The mobile board's column picker: a horizontally scrollable row of chips, one per
+ * column, each carrying the column's label and how many cards are in it — `KanbanColumn`'s
+ * own header, turned into something a thumb can flick through instead of something a mouse
+ * scrolls past sideways.
+ *
+ * A phone has no room for `KanbanColumn`'s side-by-side columns (`boardLayout.columns` is a
+ * CSS grid of `minmax(0, 1fr)` tracks, which is exactly what does not fit a 360px screen), so
+ * this replaces the whole row with a single-column-at-a-time view: pick a chip, see that
+ * column's cards below it. The chips carry `COLUMN_META`'s own order and labels, so the same
+ * five names read in the same order they do on the desktop and the web.
+ */
+import { Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components';
+import type { BoardColumn } from '@tm/shared/model';
+import { COLUMN_META, type BoardCard } from '@tm/ui/board/boardColumns';
+
+const useStyles = makeStyles({
+ row: {
+ display: 'flex',
+ gap: '8px',
+ overflowX: 'auto',
+ padding: '8px 12px',
+ flexShrink: 0,
+ },
+ chip: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px',
+ flexShrink: 0,
+ padding: '0 14px',
+ minHeight: '36px',
+ borderRadius: '999px',
+ border: `1px solid ${tokens.colorNeutralStroke2}`,
+ backgroundColor: tokens.colorNeutralBackground1,
+ color: tokens.colorNeutralForeground2,
+ font: 'inherit',
+ fontSize: '13px',
+ fontWeight: 600,
+ cursor: 'pointer',
+ },
+ // The whole `border`, not `borderColor`: Griffel rejects the four-sided shorthand mixed
+ // with a longhand elsewhere in the same `makeStyles` call — see `TaskCard.tsx`'s own note.
+ chipSelected: {
+ backgroundColor: tokens.colorBrandBackground,
+ border: `1px solid ${tokens.colorBrandBackground}`,
+ color: tokens.colorNeutralForegroundOnBrand,
+ },
+ count: { color: 'inherit', opacity: 0.75 },
+});
+
+const COLUMN_LABEL: Record = Object.fromEntries(
+ COLUMN_META.map((c) => [c.column, c.label]),
+) as Record;
+
+export interface ColumnChipsProps {
+ /** The columns to offer, in order — `visibleColumns(showDone)`'s own answer. */
+ columns: readonly BoardColumn[];
+ cardsByColumn: ReadonlyMap;
+ selected: BoardColumn;
+ onSelect: (column: BoardColumn) => void;
+}
+
+export function ColumnChips({
+ columns,
+ cardsByColumn,
+ selected,
+ onSelect,
+}: ColumnChipsProps): JSX.Element {
+ const styles = useStyles();
+ return (
+
+ {columns.map((column) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/src/board/GitGraphSheet.tsx b/apps/mobile/src/board/GitGraphSheet.tsx
new file mode 100644
index 00000000..c1a26098
--- /dev/null
+++ b/apps/mobile/src/board/GitGraphSheet.tsx
@@ -0,0 +1,91 @@
+/**
+ * The commit graph, on a phone — `@tm/ui`'s `GitGraphPane`, the same component the desktop
+ * and the web draw beside the board, in a full-screen sheet rather than `boardLayout`'s 340px
+ * `graph` pane.
+ *
+ * A fixed side pane is a desktop-window idea: it exists because there is room beside the
+ * board for a second, narrower column. There is no "beside" on a 360px screen — a pane that
+ * width would leave the board a sliver — so this opens the same picture over the whole
+ * screen instead, the way a phone opens anything it means you to look at rather than glance
+ * at sideways. `GitGraphPane` itself is unchanged: its root is already `flex: column` with
+ * its own internal scroll, so it fills whatever height this sheet gives it.
+ */
+import {
+ Button,
+ Dialog,
+ DialogSurface,
+ Subtitle2,
+ makeStyles,
+ tokens,
+} from '@fluentui/react-components';
+import { DismissRegular } from '@fluentui/react-icons';
+import type { Project, Task } from '@tm/shared/model';
+import { GitGraphPane } from '@tm/ui/GitGraphPane';
+
+const useStyles = makeStyles({
+ surface: {
+ width: '100dvw',
+ height: '100dvh',
+ maxWidth: '100dvw',
+ maxHeight: '100dvh',
+ margin: 0,
+ padding: 0,
+ borderRadius: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '8px',
+ flexShrink: 0,
+ padding: '8px 8px 8px 16px',
+ paddingTop: 'max(8px, env(safe-area-inset-top))',
+ borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
+ },
+ body: { flex: 1, minHeight: 0, display: 'flex' },
+});
+
+export interface GitGraphSheetProps {
+ open: boolean;
+ onClose: () => void;
+ projects: readonly Project[];
+ selectedTask: Task | null;
+ tasksById: ReadonlyMap;
+ runningTaskIds: ReadonlySet;
+}
+
+export function GitGraphSheet({
+ open,
+ onClose,
+ projects,
+ selectedTask,
+ tasksById,
+ runningTaskIds,
+}: GitGraphSheetProps): JSX.Element {
+ const styles = useStyles();
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/board/TaskScreen.tsx b/apps/mobile/src/board/TaskScreen.tsx
new file mode 100644
index 00000000..59a6f465
--- /dev/null
+++ b/apps/mobile/src/board/TaskScreen.tsx
@@ -0,0 +1,114 @@
+/**
+ * A card, full screen — `@tm/ui`'s `TaskDetail`, the same component the desktop draws in
+ * its 40% pane, with nothing forked inside it: `TaskDetail`'s root is already `flex: 1;
+ * minWidth: 0` with no fixed width anywhere in the file, and 24 of its ~25 props are
+ * optional (docs/plan/README.md, Phase 27 step 2 — "already shared, staying in `@tm/ui`
+ * unchanged"). What this file adds is only the frame a phone needs around it: a header
+ * with a BACK chevron rather than the desktop's side-by-side layout, since selecting a
+ * card here is a screen pushed over the whole board rather than a pane opening beside it.
+ *
+ * `readOnlyNotice` carries the same warning `apps/web`'s own `BoardScreen` wears
+ * (`RELAY_NOTICE`): mobile edits travel the identical `@tm/cloud` `HttpTransport` relay a
+ * browser tab does, so the same "carried out by your desktop app" sentence applies here.
+ * It is absent on the desktop only because the desktop applies its own edits in-process.
+ *
+ * `chainLinks`/`chainTasksById`/`onUnlinkChain` are what let `TaskDetail`'s own `TaskChain`
+ * section stand in for the chain overlay this app dropped (step 2, "Dropped" — an arrow has
+ * nothing to span when the board shows one column at a time): the same "Waiting on" /
+ * "Releases" list the web's pane reads, reachable without a mouse.
+ *
+ * The keyboard is handled at the document level, not here: `index.html`'s
+ * `interactive-widget=resizes-content` (added this step) makes `100dvh` shrink for the
+ * on-screen keyboard the same way it already shrinks for the browser's own chrome
+ * (`MobileShell.tsx`'s own comment) — so `TaskDetail`'s fixed bottom composer band, pinned
+ * by this screen's `100dvh` height, stays above the keyboard rather than under it.
+ */
+import { Button, Subtitle2, makeStyles, tokens } from '@fluentui/react-components';
+import { ChevronLeftRegular } from '@fluentui/react-icons';
+import type { Project, Task } from '@tm/shared/model';
+import type { MergeRequest } from '@tm/shared/mergeRequest';
+import type { TaskAttachment } from '@tm/shared/attachments';
+import type { TaskLink } from '@tm/shared/taskChain';
+import type { PriorityDisplay } from '@tm/shared/settings';
+import type { StatusKeyword } from '@tm/shared/statusKeywords';
+import { TaskDetail } from '@tm/ui/TaskDetail';
+import type { AttentionIndex } from '@tm/ui/attentionIndex';
+
+const useStyles = makeStyles({
+ root: {
+ position: 'fixed',
+ inset: 0,
+ width: '100dvw',
+ height: '100dvh',
+ display: 'flex',
+ flexDirection: 'column',
+ backgroundColor: tokens.colorNeutralBackground1,
+ zIndex: 1,
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ flexShrink: 0,
+ padding: '4px 8px 4px 4px',
+ paddingTop: 'max(4px, env(safe-area-inset-top))',
+ borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
+ },
+ title: {
+ flex: 1,
+ minWidth: 0,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+ body: { flex: 1, minHeight: 0, display: 'flex', paddingBottom: 'env(safe-area-inset-bottom)' },
+});
+
+/** The same warning `apps/web`'s own `RELAY_NOTICE` wears — see the file header. */
+export const RELAY_NOTICE =
+ 'Edits here are carried out by your desktop app and show up on its next sync — a few ' +
+ 'seconds. A few controls (file pickers, credentials, window buttons) only work over there.';
+
+export interface TaskScreenProps {
+ task: Task;
+ agentProjects: Project[];
+ subtasks: Task[];
+ parentTask: Task | null;
+ mergeRequests: MergeRequest[];
+ attachments: readonly TaskAttachment[];
+ parentAttachments: readonly TaskAttachment[];
+ statusKeywords: readonly StatusKeyword[];
+ priorityDisplay: PriorityDisplay;
+ attention: AttentionIndex;
+ liveRunTaskIds: ReadonlySet;
+ mergingTaskIds: ReadonlySet;
+ chainWaitingOn?: readonly Task[];
+ chainMergeHeld?: readonly Task[];
+ chainLinks: readonly TaskLink[];
+ chainTasksById: ReadonlyMap;
+ onUnlinkChain: (linkId: string) => void;
+ onOpenTask: (taskId: string) => void;
+ onClose: () => void;
+ onStatusChanged: (task: Task) => void;
+ onSubtasksChanged: () => void;
+}
+
+export function TaskScreen({ task, onClose, ...detail }: TaskScreenProps): JSX.Element {
+ const styles = useStyles();
+ return (
+
+
+ }
+ aria-label="Back to the board"
+ onClick={onClose}
+ />
+ {task.title}
+
+
+
+
+
+ );
+}
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..2546af1a
--- /dev/null
+++ b/apps/mobile/src/main.tsx
@@ -0,0 +1,48 @@
+/**
+ * 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.
+ *
+ * The other difference is `registerServiceWorker()` below, called once at module scope —
+ * `apps/web` has no PWA manifest and registers nothing (Phase 27 Decision 1: mobile is
+ * its own app, not a responsive `apps/web`).
+ */
+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';
+import { registerServiceWorker } from './registerServiceWorker';
+
+registerServiceWorker();
+
+function ThemedApp(): JSX.Element {
+ useGlobalStyles();
+ return (
+
+
+
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+
+
+ ,
+);
diff --git a/apps/mobile/src/nav/navStack.test.ts b/apps/mobile/src/nav/navStack.test.ts
new file mode 100644
index 00000000..cf46a961
--- /dev/null
+++ b/apps/mobile/src/nav/navStack.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+import { navStackReducer, topOverlay, topScreen, type NavStack } from './navStack';
+
+const ROOT: NavStack = [{ type: 'tab', screen: 'mytasks' }];
+
+describe('navStackReducer', () => {
+ it('push appends a frame to the top of the stack', () => {
+ const next = navStackReducer(ROOT, { type: 'push', frame: { type: 'task', taskId: 't1' } });
+ expect(next).toEqual([
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'task', taskId: 't1' },
+ ]);
+ });
+
+ it('popTo trims the stack down to depth + 1 frames', () => {
+ const stack: NavStack = [
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'task', taskId: 't1' },
+ { type: 'graph' },
+ ];
+ expect(navStackReducer(stack, { type: 'popTo', depth: 1 })).toEqual([
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'task', taskId: 't1' },
+ ]);
+ });
+
+ it('popTo back to depth 0 leaves only the root frame', () => {
+ const stack: NavStack = [
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'task', taskId: 't1' },
+ ];
+ expect(navStackReducer(stack, { type: 'popTo', depth: 0 })).toEqual(ROOT);
+ });
+
+ it('popTo below the root is a no-op — the root frame can never be popped', () => {
+ expect(navStackReducer(ROOT, { type: 'popTo', depth: -1 })).toBe(ROOT);
+ });
+
+ it('popTo past the current top is a no-op — the stack never grows on pop', () => {
+ const stack: NavStack = [{ type: 'tab', screen: 'mytasks' }, { type: 'graph' }];
+ expect(navStackReducer(stack, { type: 'popTo', depth: 5 })).toBe(stack);
+ });
+});
+
+describe('topScreen', () => {
+ it('reads the screen off a stack that is just the root tab frame', () => {
+ expect(topScreen(ROOT)).toBe('mytasks');
+ });
+
+ it('is unaffected by an overlay pushed on top of the tab', () => {
+ const stack: NavStack = [
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'task', taskId: 't1' },
+ ];
+ expect(topScreen(stack)).toBe('mytasks');
+ });
+
+ it('follows the most recently pushed tab frame after a tab switch', () => {
+ const stack: NavStack = [
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'tab', screen: 'settings' },
+ ];
+ expect(topScreen(stack)).toBe('settings');
+ });
+
+ it('falls back to the nearest tab frame below an overlay pushed after a tab switch', () => {
+ const stack: NavStack = [
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'tab', screen: 'settings' },
+ { type: 'addTask' },
+ ];
+ expect(topScreen(stack)).toBe('settings');
+ });
+});
+
+describe('topOverlay', () => {
+ it('is null when the tab frame itself is on top', () => {
+ expect(topOverlay(ROOT)).toBeNull();
+ });
+
+ it('surfaces the frame pushed on top of the tab', () => {
+ const stack: NavStack = [{ type: 'tab', screen: 'mytasks' }, { type: 'addTask' }];
+ expect(topOverlay(stack)).toEqual({ type: 'addTask' });
+ });
+
+ it('reflects a task frame including its taskId', () => {
+ const stack: NavStack = [
+ { type: 'tab', screen: 'mytasks' },
+ { type: 'task', taskId: 't42' },
+ ];
+ expect(topOverlay(stack)).toEqual({ type: 'task', taskId: 't42' });
+ });
+});
+
+describe('a tab switch and back round trip', () => {
+ it('restores the earlier overlay once the tab switch itself is undone', () => {
+ const withTask: NavStack = navStackReducer(ROOT, {
+ type: 'push',
+ frame: { type: 'task', taskId: 't1' },
+ });
+ const switched = navStackReducer(withTask, {
+ type: 'push',
+ frame: { type: 'tab', screen: 'settings' },
+ });
+ expect(topScreen(switched)).toBe('settings');
+ expect(topOverlay(switched)).toBeNull();
+
+ const backOnce = navStackReducer(switched, { type: 'popTo', depth: 1 });
+ expect(topScreen(backOnce)).toBe('mytasks');
+ expect(topOverlay(backOnce)).toEqual({ type: 'task', taskId: 't1' });
+ });
+});
diff --git a/apps/mobile/src/nav/navStack.ts b/apps/mobile/src/nav/navStack.ts
new file mode 100644
index 00000000..637d1291
--- /dev/null
+++ b/apps/mobile/src/nav/navStack.ts
@@ -0,0 +1,59 @@
+/**
+ * What Android's hardware Back means, given where the app is — the piece of Phase 27 step 8
+ * that is worth testing in isolation from the `popstate`/`history.pushState` wiring around it
+ * (`useBackStack.ts`, one file over).
+ *
+ * A screen is not one flat value: it is a tab (`mytasks`/`projects`/`performance`/`attention`/
+ * `settings`, `App.tsx`'s own `Screen`) with, only on `mytasks`, at most one overlay on top of
+ * it — a task
+ * opened full-screen, or one of the board's sheets/dialogs (`BoardScreen.tsx`: `GitGraphSheet`,
+ * `AddTaskDialog`, `ArchivedCardsDialog`) — since each of those, once open, covers the toolbar
+ * button that would open one of the others. Every frame the user pushed (a tab switch or an
+ * overlay opening) stays in the stack until Back unwinds it, so switching tabs with a task open
+ * and then going Back once returns to that same task rather than dropping it — the same "each
+ * step is independently undoable" a native Back stack gives for free.
+ */
+
+export type Screen = 'mytasks' | 'projects' | 'performance' | 'attention' | 'settings';
+
+export type NavFrame =
+ | { type: 'tab'; screen: Screen }
+ | { type: 'task'; taskId: string }
+ | { type: 'addTask' }
+ | { type: 'archived'; openedAt: number }
+ | { type: 'graph' };
+
+/** Never empty — the root frame (the initial tab) can be popped to but never off. */
+export type NavStack = readonly NavFrame[];
+
+export type NavAction = { type: 'push'; frame: NavFrame } | { type: 'popTo'; depth: number };
+
+export function navStackReducer(stack: NavStack, action: NavAction): NavStack {
+ switch (action.type) {
+ case 'push':
+ return [...stack, action.frame];
+ case 'popTo': {
+ const length = action.depth + 1;
+ if (length < 1 || length > stack.length) return stack;
+ return stack.slice(0, length);
+ }
+ }
+}
+
+/** The tab bar's selected value — the nearest `tab` frame at or below the stack's top. */
+export function topScreen(stack: NavStack): Screen {
+ for (let i = stack.length - 1; i >= 0; i--) {
+ const frame = stack[i];
+ if (frame.type === 'tab') return frame.screen;
+ }
+ return 'mytasks';
+}
+
+/** A frame that sits on top of the tab rather than being one — `BoardScreen.tsx`'s own overlays. */
+export type Overlay = Exclude;
+
+/** The board overlay currently showing — `null` once nothing is pushed on top of the tab. */
+export function topOverlay(stack: NavStack): Overlay | null {
+ const top = stack[stack.length - 1];
+ return top.type === 'tab' ? null : top;
+}
diff --git a/apps/mobile/src/nav/useBackStack.ts b/apps/mobile/src/nav/useBackStack.ts
new file mode 100644
index 00000000..2ad5520e
--- /dev/null
+++ b/apps/mobile/src/nav/useBackStack.ts
@@ -0,0 +1,63 @@
+/**
+ * The wiring `navStack.ts`'s own header points to: turns the pure reducer into an installed
+ * PWA's actual Back behaviour.
+ *
+ * Every `push` calls `history.pushState` carrying the frame's depth (not the frame itself —
+ * the frame lives in React state; the history entry only needs to say how deep it is) so a
+ * hardware/gesture Back's `popstate` knows how far to unwind the reducer's stack. `back()`
+ * itself never touches the reducer directly: it calls `history.back()` and lets the resulting
+ * `popstate` do it, so an in-app close button (a dialog's X, a task screen's chevron) and
+ * Android's own Back key run through the exact same path and can never fall out of sync with
+ * each other.
+ *
+ * At the root frame, `back()` — and the hardware key itself — fall through to whatever history
+ * exists before this app's own first entry, which for an installed PWA launched from the home
+ * screen is nothing: the app exits. That is the correct behaviour and needs no code here.
+ */
+import { useCallback, useEffect, useReducer } from 'react';
+import { navStackReducer, type NavFrame, type NavStack } from './navStack';
+
+interface NavHistoryState {
+ tmNavDepth: number;
+}
+
+function depthOf(stack: NavStack): number {
+ return stack.length - 1;
+}
+
+export interface BackStack {
+ stack: NavStack;
+ push: (frame: NavFrame) => void;
+ back: () => void;
+}
+
+export function useBackStack(root: NavFrame): BackStack {
+ const [stack, dispatch] = useReducer(navStackReducer, [root]);
+
+ useEffect(() => {
+ history.replaceState({ tmNavDepth: 0 } satisfies NavHistoryState, '');
+ }, []);
+
+ useEffect(() => {
+ function onPopState(event: PopStateEvent): void {
+ const state = event.state as NavHistoryState | null;
+ dispatch({ type: 'popTo', depth: state?.tmNavDepth ?? 0 });
+ }
+ window.addEventListener('popstate', onPopState);
+ return () => window.removeEventListener('popstate', onPopState);
+ }, []);
+
+ const push = useCallback(
+ (frame: NavFrame) => {
+ history.pushState({ tmNavDepth: depthOf(stack) + 1 } satisfies NavHistoryState, '');
+ dispatch({ type: 'push', frame });
+ },
+ [stack],
+ );
+
+ const back = useCallback(() => {
+ history.back();
+ }, []);
+
+ return { stack, push, back };
+}
diff --git a/apps/mobile/src/registerServiceWorker.ts b/apps/mobile/src/registerServiceWorker.ts
new file mode 100644
index 00000000..82c0aa76
--- /dev/null
+++ b/apps/mobile/src/registerServiceWorker.ts
@@ -0,0 +1,16 @@
+/**
+ * Registers the service worker built by `vite.sw.config.ts` — see that file's header for
+ * why it is a second build rather than an entry in this one.
+ *
+ * Guarded on `PROD`: `dist/sw.js` does not exist under `vite dev`, and a dev session
+ * behind a stale cached bundle is a worse debugging experience than having no service
+ * worker at all. Guarded on the API existing at all, since `main.tsx` runs on whatever
+ * browser opened the page, not only Chrome/WebAPK.
+ */
+export function registerServiceWorker(): void {
+ if (!import.meta.env.PROD) return;
+ if (!('serviceWorker' in navigator)) return;
+ window.addEventListener('load', () => {
+ void navigator.serviceWorker.register('/sw.js');
+ });
+}
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/sw/serviceWorker.ts b/apps/mobile/src/sw/serviceWorker.ts
new file mode 100644
index 00000000..42d204bb
--- /dev/null
+++ b/apps/mobile/src/sw/serviceWorker.ts
@@ -0,0 +1,67 @@
+/**
+ * The service worker itself — thin on purpose. The one decision that matters,
+ * same-origin-GET-only routing, lives in `shouldHandle.ts` where it can be unit-tested;
+ * this file is just the two cache strategies `shouldHandle` picks between, plus the
+ * install/activate bookkeeping that keeps only the current cache around. Built separately
+ * by `vite.sw.config.ts` — see that file's header for why one Vite config cannot emit both
+ * this and the hashed main bundle — and registered from `registerServiceWorker.ts`.
+ *
+ * tsconfig.sw.json type-checks this file alone, under WebWorker libs rather than DOM —
+ * see that config's header for why the two libs can't share a program.
+ */
+import { shouldHandle } from './shouldHandle';
+
+declare const self: ServiceWorkerGlobalScope;
+
+/** Bump this to drop every previously cached response on the next activate. */
+const CACHE_NAME = 'tm-mobile-v1';
+
+self.addEventListener('install', () => {
+ void self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const strategy = shouldHandle(event.request, self.location.origin);
+ if (strategy === 'cache-first') {
+ event.respondWith(cacheFirst(event.request));
+ } else if (strategy === 'network-first') {
+ event.respondWith(networkFirst(event.request));
+ }
+ // Anything else (a different origin, a non-GET, a same-origin GET that is neither an
+ // asset nor a navigation): no respondWith call at all. The browser handles the request
+ // exactly as if this service worker were not installed — see shouldHandle.ts's header
+ // for why that has to be a bare passthrough rather than a forwarded fetch().
+});
+
+async function cacheFirst(request: Request): Promise {
+ const cache = await caches.open(CACHE_NAME);
+ const cached = await cache.match(request);
+ if (cached) return cached;
+ const response = await fetch(request);
+ if (response.ok) void cache.put(request, response.clone());
+ return response;
+}
+
+async function networkFirst(request: Request): Promise {
+ const cache = await caches.open(CACHE_NAME);
+ try {
+ const response = await fetch(request);
+ if (response.ok) void cache.put(request, response.clone());
+ return response;
+ } catch (err) {
+ const cached = await cache.match(request);
+ if (cached) return cached;
+ throw err;
+ }
+}
diff --git a/apps/mobile/src/sw/shouldHandle.test.ts b/apps/mobile/src/sw/shouldHandle.test.ts
new file mode 100644
index 00000000..6af3ecdb
--- /dev/null
+++ b/apps/mobile/src/sw/shouldHandle.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { shouldHandle } from './shouldHandle';
+
+const ORIGIN = 'https://mobile.vipper.network';
+
+describe('shouldHandle', () => {
+ it('caches same-origin hashed assets first', () => {
+ expect(shouldHandle({ method: 'GET', url: `${ORIGIN}/assets/index-abc123.js` }, ORIGIN)).toBe(
+ 'cache-first',
+ );
+ });
+
+ it('falls back to cache for a same-origin navigation', () => {
+ expect(shouldHandle({ method: 'GET', url: `${ORIGIN}/board`, mode: 'navigate' }, ORIGIN)).toBe(
+ 'network-first',
+ );
+ });
+
+ it('passes through a same-origin GET that is neither an asset nor a navigation', () => {
+ expect(
+ shouldHandle({ method: 'GET', url: `${ORIGIN}/manifest.webmanifest` }, ORIGIN),
+ ).toBeNull();
+ });
+
+ it('passes through the cloud API — a different origin, even though the method is GET', () => {
+ expect(
+ shouldHandle(
+ { method: 'GET', url: 'https://api.vipper.network/v1/events', mode: 'cors' },
+ ORIGIN,
+ ),
+ ).toBeNull();
+ });
+
+ it('passes through a cross-origin navigation-shaped request too — origin wins over mode', () => {
+ expect(
+ shouldHandle(
+ { method: 'GET', url: 'https://auth.vipper.network/oidc/authorize', mode: 'navigate' },
+ ORIGIN,
+ ),
+ ).toBeNull();
+ });
+
+ it('never intercepts a non-GET, so the same-origin OIDC token POST is left alone', () => {
+ expect(shouldHandle({ method: 'POST', url: `${ORIGIN}/assets/whatever` }, ORIGIN)).toBeNull();
+ });
+
+ it('does not cache an /assets/-shaped path on a different origin', () => {
+ expect(
+ shouldHandle({ method: 'GET', url: 'https://api.vipper.network/assets/x.js' }, ORIGIN),
+ ).toBeNull();
+ });
+
+ it('passes through a request whose URL cannot be parsed, rather than throwing', () => {
+ expect(shouldHandle({ method: 'GET', url: 'not a url' }, ORIGIN)).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/sw/shouldHandle.ts b/apps/mobile/src/sw/shouldHandle.ts
new file mode 100644
index 00000000..f0aca265
--- /dev/null
+++ b/apps/mobile/src/sw/shouldHandle.ts
@@ -0,0 +1,55 @@
+/**
+ * The one decision every request the service worker sees has to make: intercept it or
+ * not, and how. Kept pure and imported by the SW entry (`serviceWorker.ts`) rather than
+ * inlined there, so it has its own test file instead of only being provable by driving a
+ * real `ServiceWorkerGlobalScope`.
+ *
+ * The rule is same-origin GET only — not "skip /v1/*". `apps/mobile` talks to a cloud API
+ * on its OWN origin per Decision 3 (docs/plan/README.md, Phase 27), so an origin check
+ * alone already lets every cloud call pass through untouched. That matters because
+ * `event.respondWith` on anything but a bare passthrough breaks a streaming response:
+ * `GET /v1/events` is Server-Sent Events, and the OIDC token exchange is a POST (excluded
+ * on method alone, before origin is even checked). Whatever this function returns `null`
+ * for, the SW's fetch listener must not call `respondWith` at all — not even a bare
+ * `fetch(request)` forward — so the browser's own handling of streaming, credentials and
+ * redirects stays untouched. See `serviceWorker.ts`.
+ */
+
+/**
+ * Structural rather than the DOM `Request` type: lets tests build cases as plain object
+ * literals instead of going through `new Request(url, { mode: 'navigate' })`, which the
+ * Fetch spec forbids constructing directly (`navigate` is reserved for real browser
+ * navigations). A real `FetchEvent#request` already satisfies this shape as-is.
+ */
+export interface HandleableRequest {
+ readonly method: string;
+ readonly url: string;
+ readonly mode?: string;
+}
+
+export type Strategy = 'cache-first' | 'network-first' | null;
+
+/** Vite's own default — see vite.config.ts's untouched `build.assetsDir`. */
+const ASSET_PREFIX = '/assets/';
+
+export function shouldHandle(request: HandleableRequest, selfOrigin: string): Strategy {
+ if (request.method !== 'GET') return null;
+
+ let url: URL;
+ try {
+ url = new URL(request.url);
+ } catch {
+ return null;
+ }
+ if (url.origin !== selfOrigin) return null;
+
+ // Content-hashed — a stale cache entry can never be served for a live filename, which
+ // is what makes caching it unconditionally safe.
+ if (url.pathname.startsWith(ASSET_PREFIX)) return 'cache-first';
+
+ // A page load/navigation: prefer a fresh network response, but let the app open offline
+ // from whatever was cached the last time it succeeded.
+ if (request.mode === 'navigate') return 'network-first';
+
+ return null;
+}
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/staticwebapp.config.json b/apps/mobile/staticwebapp.config.json
new file mode 100644
index 00000000..d515d0af
--- /dev/null
+++ b/apps/mobile/staticwebapp.config.json
@@ -0,0 +1,25 @@
+{
+ "navigationFallback": {
+ "rewrite": "/index.html",
+ "exclude": ["*.{css,js,map,gif,jpg,jpeg,png,svg,webp,woff,woff2,ico,webmanifest,json}"]
+ },
+ "responseOverrides": {
+ "404": {
+ "rewrite": "/index.html",
+ "statusCode": 200
+ }
+ },
+ "globalHeaders": {
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "Referrer-Policy": "strict-origin-when-cross-origin"
+ },
+ "routes": [
+ {
+ "route": "/sw.js",
+ "headers": {
+ "Cache-Control": "no-cache"
+ }
+ }
+ ]
+}
diff --git a/apps/mobile/tsconfig.app.json b/apps/mobile/tsconfig.app.json
new file mode 100644
index 00000000..ca10d462
--- /dev/null
+++ b/apps/mobile/tsconfig.app.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "//": "Type-checks the app itself — the React UI, running in a browser window (DOM libs). The service worker gets its own tsconfig.sw.json one file over: it runs in a WebWorker global scope, not a window, and DOM + WebWorker in one program conflict (both declare `self` differently) — see that file's header.",
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "node"],
+ "noEmit": true
+ },
+ "include": ["src"],
+ "exclude": ["src/sw/serviceWorker.ts"]
+}
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
new file mode 100644
index 00000000..1dc7263e
--- /dev/null
+++ b/apps/mobile/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "//": "Root config that ties the two project references together so editors and `tsc --build` understand the whole package. Two build targets because the app (DOM) and the service worker (WebWorker) need different libs — see tsconfig.app.json and tsconfig.sw.json.",
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.sw.json" }]
+}
diff --git a/apps/mobile/tsconfig.sw.json b/apps/mobile/tsconfig.sw.json
new file mode 100644
index 00000000..d16c77ee
--- /dev/null
+++ b/apps/mobile/tsconfig.sw.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "//": "Type-checks the service worker entry (src/sw/serviceWorker.ts — see vite.sw.config.ts for why it is its own build too). It runs in a WebWorker global scope: `self` there is a ServiceWorkerGlobalScope, not a Window, so this needs WebWorker libs instead of DOM. `shouldHandle.ts` has to be listed too — a composite project must list every file it imports — but it has no self/caches/Request dependency of its own, so it type-checks the same way under either this project's WebWorker libs or tsconfig.app.json's DOM ones.",
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "WebWorker", "WebWorker.Iterable"],
+ "types": [],
+ "noEmit": true
+ },
+ "include": ["src/sw/serviceWorker.ts", "src/sw/shouldHandle.ts"]
+}
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/vite.sw.config.ts b/apps/mobile/vite.sw.config.ts
new file mode 100644
index 00000000..ff10a880
--- /dev/null
+++ b/apps/mobile/vite.sw.config.ts
@@ -0,0 +1,34 @@
+/**
+ * A second, separate Vite build for the service worker (`src/sw/serviceWorker.ts`). It
+ * cannot come out of the same config as `vite.config.ts`: the main entry needs
+ * content-hashed filenames so `shouldHandle.ts`'s cache-first rule is safe
+ * (`assets/index-.js`), while the service worker needs the OPPOSITE — a fixed
+ * `sw.js` at the dist root, because `registerServiceWorker.ts` registers it by that exact
+ * name on every visit, and a hashed name would mean re-registering by hand on every
+ * deploy. One Rollup output config cannot emit both naming schemes for the same build.
+ *
+ * `emptyOutDir: false` because this build always runs second (see `package.json`'s
+ * `build` script) and must not delete what the first `vite build` just wrote to `dist/`.
+ *
+ * No React plugin here — the service worker never touches JSX — and no `define` for
+ * `__APP_VERSION__`, which `serviceWorker.ts` never reads either.
+ */
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ outDir: 'dist',
+ emptyOutDir: false,
+ sourcemap: true,
+ rollupOptions: {
+ input: 'src/sw/serviceWorker.ts',
+ output: {
+ entryFileNames: 'sw.js',
+ // A classic (non-module) worker script: `registerServiceWorker.ts` registers it
+ // with no `{ type: 'module' }`, so it works the same on every browser that can
+ // install a WebAPK, not only ones with module-worker support.
+ format: 'iife',
+ },
+ },
+ },
+});
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/apps/web/package.json b/apps/web/package.json
index 3046c1c3..f7e3a89c 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 cab9a4d3..71b434b5 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -29,18 +29,18 @@ import { NavRail, type NavRailItem } from '@tm/ui/shell/NavRail';
import { StatusBar, StatusDot, StatusSpacer } from '@tm/ui/shell/StatusBar';
import { SyncCurtain } from '@tm/ui/SyncCurtain';
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 { boardIsReady, syncCurtainText, syncStatusLabel } from '@tm/cloud/board/syncGate';
+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 { boardIsReady, syncCurtainText, syncStatusLabel } from './board/syncGate';
import { UnreachableBanner } from './board/UnreachableBanner';
-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 ef7a288b..be0f5923 100644
--- a/apps/web/src/board/BoardScreen.tsx
+++ b/apps/web/src/board/BoardScreen.tsx
@@ -66,10 +66,18 @@ import {
type Task,
} from '@tm/shared/model';
import type { BoardScope } from '@tm/shared/ipc';
+import {
+ selectAgentProjects,
+ 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 { selectAgentProjects, 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 3826c040..46d30d47 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/docs/09-deploying-the-cloud-service.md b/docs/09-deploying-the-cloud-service.md
index 447e3c52..2af489f3 100644
--- a/docs/09-deploying-the-cloud-service.md
+++ b/docs/09-deploying-the-cloud-service.md
@@ -19,10 +19,78 @@ setting the GitHub secrets. **Read that first; none of what follows works until
| Changed | What happens |
| ------------------------------------------------------------------- | ------------------------------------------------------- |
| `apps/server`, `packages/shared`, `packages/protocol`, the lockfile | image → GHCR → **migrations** → Container App repointed |
-| `apps/web`, `packages/ui`, and the same shared packages | Vite build → uploaded to Static Web Apps |
-| `apps/client` only | **nothing** — the desktop app is never deployed from CI |
-
-Migrations run _before_ the app is repointed, as an Azure Container Apps job executing the
+| `apps/web`, `packages/ui`, `packages/cloud`, and the same shared packages | Vite build → uploaded to Static Web Apps |
+| `apps/mobile`, `packages/ui`, `packages/cloud`, and the same shared packages | Vite build → uploaded to its OWN Static Web App |
+| `apps/client` only | **nothing** — the desktop app is never deployed from CI |
+
+## Adding apps/mobile's Static Web App (one-time)
+
+`apps/mobile` deploys to its own Azure Static Web App, on its own subdomain, rather than
+sharing `taskmanager-web` under a path — [`docs/plan/README.md` Phase 27, Decision
+3](plan/README.md) has the reasoning: a shared SWA would need `base: '/m/'` routing, its own
+route rules, and would still leave two deployed apps sharing one `localStorage` namespace and
+service-worker scope, which is exactly what the app split (Decision 1) opted out of one layer
+up. A dedicated SWA costs one more one-time Azure resource in exchange for never having that
+problem.
+
+None of steps 2–9 of that phase needed this to exist — they built the app itself. The `mobile`
+job in `deploy.yml` was written and merged ahead of this setup on purpose (Decision 4): its
+deploy step checks `AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` itself and skips with a
+`::warning::` rather than failing the workflow while the secret is unset, so a push touching
+`apps/mobile` was never blocked on a human doing the following, and can keep not being blocked
+on it until this section's steps are done:
+
+1. **Create the Static Web App**, alongside `taskmanager-web` in the same resource group —
+ through the `infrastructure` repo's Terraform if it has grown a module for it by the time
+ you read this, otherwise directly:
+
+ ```bash
+ az staticwebapp create -n taskmanager-mobile -g taskmanager \
+ --sku Free --location "West Europe"
+ ```
+
+2. **Add the DNS record.** Pick a hostname consistent with the existing pair
+ (`tasks.vipper.network`, `tasks-api.vipper.network`) — `tasks-m.vipper.network` unless a
+ later decision changes it — and follow the same CNAME + custom-domain-validation dance
+ `docs/10` phase 3/6 describes for `tasks.vipper.network`, in the `vipper-network-dns`
+ resource group:
+
+ ```bash
+ az staticwebapp hostname set -n taskmanager-mobile -g taskmanager \
+ --hostname tasks-m.vipper.network
+ ```
+
+ Whatever hostname is chosen, it must also become `apps/mobile`'s deployed origin — nothing
+ in `deploy.yml` needs editing for this (the build has no `VITE_*` var for its own origin,
+ only for the API and IAM issuer it calls), but the redirect URI in step 4 below must match
+ it exactly.
+
+3. **Set the GitHub secret**, the same way `docs/10` phase 6 sets
+ `AZURE_STATIC_WEB_APPS_API_TOKEN`:
+
+ ```bash
+ gh secret set AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE --body "$(az staticwebapp secrets list \
+ -n taskmanager-mobile -g taskmanager --query properties.apiKey -o tsv)"
+ ```
+
+ The next push to `development` that touches `apps/mobile` (or a `workflow_dispatch`) picks
+ it up — nothing else needs re-running.
+
+4. **Register the IAM client**, or add a redirect URI to an existing one, at
+ `auth.vipper.network` — mirroring `docs/10` phase 5's `taskmanager-web` registration:
+ public, PKCE (`authorization_code` + `refresh_token`,
+ `token_endpoint_auth_method: none`), client id **`taskmanager-mobile`** (already hardcoded
+ into the `mobile` job and `apps/mobile/.env.example` — not a secret, same reasoning as
+ `taskmanager-web`'s), redirect URI `https:///callback`
+ (`packages/cloud/src/auth/cloudAuth.ts`'s `CALLBACK_PATH`). This is deliberately a
+ **separate** client registration from `taskmanager-web`, not a second redirect URI on it —
+ Decision 4 draws that line so a desktop or web build's redirect-URI allowlist entry can
+ never be replayed against the mobile one.
+
+Until all four are done, `apps/mobile` builds and typechecks in CI same as any other package,
+but nothing serves it and no phone can reach it.
+
+Migrations run *before* the app is repointed, as an Azure Container Apps job executing the
same image with `node dist/database/migrate.js`. A failed migration fails the deploy and
leaves the old app serving the old — matching — schema.
diff --git a/docs/11-ci-cd-pipeline.md b/docs/11-ci-cd-pipeline.md
index 3b4500ad..aaca35a3 100644
--- a/docs/11-ci-cd-pipeline.md
+++ b/docs/11-ci-cd-pipeline.md
@@ -8,8 +8,8 @@ built or released — see [`RELEASE.md`](../RELEASE.md#6-linux-releases-are-disc
| Workflow | Trigger | What it does |
| ------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| [`ci.yml`](../.github/workflows/ci.yml) | pull request → `development` | The gates, the server image, and (when the desktop app is touched) a full package |
-| [`release.yml`](../.github/workflows/release.yml) | push → `development`, or `workflow_dispatch` | Tags, drafts, packages Windows, publishes — [`RELEASE.md`](../RELEASE.md) run by a machine |
-| [`deploy.yml`](../.github/workflows/deploy.yml) | push → `development`, or `workflow_dispatch` | Deploys `@tm/server` to Azure Container Apps and `@tm/web` to Static Web Apps |
+| [`release.yml`](../.github/workflows/release.yml) | push → `development`, or `workflow_dispatch` | Tags, drafts, packages Windows, publishes — [`RELEASE.md`](../RELEASE.md) run by a machine |
+| [`deploy.yml`](../.github/workflows/deploy.yml) | push → `development`, or `workflow_dispatch` | Deploys `@tm/server` to Azure Container Apps, `@tm/web` to its Static Web App, and `@tm/mobile` to its own |
There is no `main` in this repository; `development` is the integration branch, and it is
the only branch anything is released or deployed from.
@@ -57,22 +57,26 @@ the tag exists.
| `windows` | windows | §5 — package into the draft, then the headless addon smoke test |
| `promote` | ubuntu | §7 — publish the draft once Windows has uploaded (rule 4) |
-`deploy.yml` filters by path, and can run either half, both, or neither:
+`deploy.yml` filters by path, and can run any subset of the three:
-| Changed | Deployed |
-| ---------------------------------------------------------------------- | ------------------------------------------------ |
-| `apps/server`, `packages/shared`, `packages/protocol`, the lockfile | image → GHCR → **migration job** → Container App |
-| `apps/web`, `packages/ui`, and the same shared packages | Vite build → Static Web Apps |
-| `apps/client/package.json` — the version of record, and only that file | Vite build → Static Web Apps |
-| anything else under `apps/client` | nothing |
+| Changed | Deployed |
+| ----------------------------------------------------------------------------------- | ------------------------------------------------ |
+| `apps/server`, `packages/shared`, `packages/protocol`, the lockfile | image → GHCR → **migration job** → Container App |
+| `apps/web`, `packages/ui`, `packages/cloud`, and the same shared packages | Vite build → Static Web Apps |
+| `apps/mobile`, `packages/ui`, `packages/cloud`, and the same shared packages | Vite build → its own Static Web App |
+| `apps/client/package.json` — the version of record, and only that file | Vite build → Static Web Apps (web only) |
+| anything else under `apps/client` | nothing |
-That third row is the one that looks wrong. The web bundle bakes the version of record into
+That fourth row is the one that looks wrong. The web bundle bakes the version of record into
its status bar at build time (`apps/web/vite.config.ts`) because a browser has no `app:getInfo`
to ask, so a bump genuinely changes the web and the bundle has to be rebuilt to say so. It was
missing for eight releases: the deployed web client sat on `v0.78.2` — the version `apps/web`'s
own manifest happened to carry the day the monorepo was split — while the desktop shipped
`v0.86.0`. Nothing else under `apps/client` belongs in that filter; that app is `release.yml`'s
-business, and `test/workflow-invariants.test.ts` asserts both halves of that sentence.
+business, and `test/workflow-invariants.test.ts` asserts both halves of that sentence. It is
+the WEB filter specifically, not the mobile one below it: `apps/mobile/vite.config.ts` bakes
+its own `apps/mobile/package.json` version instead, already covered by that filter's own
+`apps/mobile/**` glob.
Both are `concurrency: cancel-in-progress: false` — a half-finished release or deploy costs
far more than a queue. `ci.yml` is the opposite: a new push to a PR cancels the run still in
@@ -250,6 +254,7 @@ Repository → Settings → Secrets and variables → Actions. The complete list
| `AZURE_TENANT_ID` | `deploy.yml` | Entra tenant id |
| `AZURE_SUBSCRIPTION_ID` | `deploy.yml` | The subscription holding the `taskmanager` resource group |
| `AZURE_STATIC_WEB_APPS_API_TOKEN` | `deploy.yml` | `az staticwebapp secrets list -n taskmanager-web -g taskmanager --query properties.apiKey -o tsv` |
+| `AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` | `deploy.yml` | Same, for `-n taskmanager-mobile`. Read by the `mobile` job's own step, not a job-level `if` — see [`docs/09`](09-deploying-the-cloud-service.md#adding-appsmobiles-static-web-app-one-time) for the one-time setup this depends on |
| `VITE_CLOUD_IAM_CLIENT_ID` | `deploy.yml` | The web build's public vipper.iam client id — compiled into the bundle by Vite |
`ci.yml` needs **no** secrets at all. It publishes nothing, so it needs nothing to publish
@@ -257,6 +262,15 @@ with — which is also what makes it safe to run against a fork's pull request.
`VITE_CLOUD_IAM_CLIENT_ID` is a secret only by storage, not by nature: a browser bundle has
nowhere to hide anything, and PKCE is what secures the sign-in (see `apps/web/.env.example`).
+The mobile build's own client id, `taskmanager-mobile`, is not stored as a secret at all — it
+is hardcoded in the `mobile` job the same way it already is in `apps/mobile/.env.example`, for
+the same reason.
+
+`AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` is the one secret in this table that is allowed to be
+**absent**: until a human does the one-time Azure setup in `docs/09`, the `mobile` job checks
+it in a step, logs a `::warning::`, and skips its build/deploy steps rather than failing the
+whole run — so `deploy.yml` could carry this job before that setup existed, and pushes that
+touch `apps/mobile` were never blocked on it.
**Azure login uses OIDC — there is no stored Azure credential.** Create a federated
credential on the app registration and give its service principal a role on the
diff --git a/docs/plan/README.md b/docs/plan/README.md
index a6a9f445..1d200c46 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); 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
@@ -6253,6 +6254,278 @@ but real, and left for this round's own last step — "Verify projects and ticke
to catch and close out along with the rest of the gates, rather than fixed in passing by a step
scoped to docs and a test file.
+---
+
+## 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.
+
+### 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.
+
+### Step 11: the critical files, walked one by one on `9c8eabd`
+
+The plan named ten files or file groups, across six areas, as the ones this round lives or
+dies on. Every one was re-opened here — none carried forward from the step that last touched
+it — and every gate was run fresh rather than trusted from a step that measured the same
+commit.
+
+| File | What it had to be | On `9c8eabd` |
+| --- | --- | --- |
+| `test/shell-parity.test.ts` | catch a browser/desktop/mobile drift as text, since there is no DOM harness | ⚠️ mostly right, one real hole — see below |
+| `apps/web/src/env.ts` | stay per-app; never move into `@tm/cloud`, since `import.meta.env` is a Vite build-time replacement esbuild cannot emit | ✅ unchanged; `apps/mobile/src/env.ts` mirrors it with its own `taskmanager-mobile` client id |
+| `packages/cloud/src/board/httpTransport.ts`, `useBoardExtras.ts` | the one runtime (not type-only) edge where `@tm/cloud` imports `@tm/ui` — `useTransport` and `buildAttentionIndex` as values | ✅ confirmed; this is exactly what `packages/cloud/tsup.config.ts`'s `external` exists to protect |
+| `packages/ui/tsup.config.ts` | the template `packages/cloud`'s own tsup config copies | ✅ unchanged; `packages/cloud/tsup.config.ts` copies its `entry`/`format`/`dts` shape and externalizes `@tm/ui` on top, with the two-copies-of-`TransportContext` reasoning written down |
+| `packages/ui/src/theme.ts` (`useGlobalStyles`, `:313`) | `'html, body, #root'` sized to `100dvh`, not `100%` | ✅ unchanged, `:316` |
+| `packages/ui/src/board/TaskCard.tsx` | reused as-is; drag props optional | ✅ unchanged; `draggable?`/`onDragStart?`/`onDragEnd?` (`:990`, `:994`–`:995`) default to `false`/absent at `:1030` |
+| `packages/ui/src/board/boardColumns.ts` | column metadata, sorting and counts the mobile chip row and menu read | ✅ unchanged; `ColumnChips.tsx` and `BoardCardRow.tsx` both import `COLUMN_META` from it, `BoardScreen.tsx` (mobile) drives its column view from `visibleColumns`/`sortCards`/`hiddenDoneSummary` |
+| `packages/ui/src/TaskDetail.tsx`, `Performance.tsx` | full-screen reuse; `Performance`'s grid stacks under `599px` | ⚠️ reuse is exact (`TaskScreen.tsx` wraps `TaskDetail` with nothing forked inside it; mobile's Performance destination renders `` unmodified) — one stale comment fixed, see below |
+| `.github/workflows/deploy.yml` (filters, `changes` job) | `packages/cloud/**` in both the `web` and `mobile` filters; a `mobile` job that stays inert without its token | ✅ unchanged; `:104` and `:112` both list it, the `mobile` job's own step (not a job `if`) checks `AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` |
+| `apps/web/staticwebapp.config.json`, `apps/mobile/staticwebapp.config.json` | the template, and the `webmanifest` exclusion a naive copy would drop | ✅ unchanged; `apps/web`'s has no manifest to exclude and correctly has none, `apps/mobile`'s exclude list already carries `webmanifest,json` |
+
+#### Two real breakages, both the same shape
+
+`test/shell-parity.test.ts` and `TaskDetail.tsx` are named critical for the same reason two
+of `apps/client/scripts/verify-*.mjs` turned out to be broken, and the pattern is worth
+naming once: **step 3 moved `httpTransport.ts`, `polledEvents.ts`, `eventBus.ts`,
+`sseEvents.ts`, `useBoardExtras.ts` and `SettingsScreen.tsx` out of `apps/web/src` and into
+`packages/cloud/src`, and every file that named their OLD location by path — rather than by
+import specifier — went stale silently, because none of them are on the module graph
+`pnpm typecheck` or `pnpm build` walks.** A prose comment and a script that reads a source
+file as text both sit outside that graph the same way.
+
+- **`apps/client/scripts/verify-remote-ipc.mjs` and `verify-remote-sse.mjs` no longer ran at
+ all.** Both bundle `HttpTransport`/`PolledEventBus` (the IPC harness) or
+ `SseEventStream`/`CloudEventBus` (the SSE harness) by resolving a literal path into
+ `apps/web/src/board/*`, which step 3 emptied out from under them. `verify-remote-ipc.mjs`
+ failed immediately — `Rollup failed to resolve import ".../apps/web/src/board/httpTransport"`
+ — the moment it was run for this walk; `verify-remote-sse.mjs` would have failed the same
+ way. Neither is wired into `ci.yml` or `RELEASE.md`'s gates (both are ad hoc, by-hand
+ verification, same as `verify-attachments.mjs` and the rest), which is exactly why nothing
+ red ever surfaced: eight steps of this branch ran green while both harnesses were dead.
+ Fixed by pointing both at `packages/cloud/src` instead, and dropping the `@web` Vite alias
+ neither script's bundle actually used any more (confirmed by grepping every file the two
+ entry points import for it — nothing does). Re-run clean: **16 remote-IPC checks, 36
+ push-channel checks**, both exit 0.
+- **`test/shell-parity.test.ts`'s global-CSS guard silently lost `packages/cloud/src`.**
+ `HOST_TREES` names every tree the `scrollbar`/`color-scheme` redeclaration check walks, and
+ before step 3 `apps/web/src` covered `apps/web/src/settings/SettingsScreen.tsx` inside it.
+ After the move that file sits in `packages/cloud/src/settings/`, which `HOST_TREES` never
+ named — so a global rule declared there from step 3 onward would have compiled, rendered,
+ and gone unflagged by every assertion in the file. Nothing had actually declared one (the
+ guard passes clean before and after), which is what makes this a coverage hole rather than
+ a caught bug — the failure mode is the one the file's own header names for
+ `iamAuth.guard.ts` in an earlier round: a hole nothing asserts is indistinguishable from an
+ omission until something is written into it. `packages/cloud/src` now joins `HOST_TREES`,
+ with the same "covered from day one" reasoning the header already gives for
+ `apps/mobile/src`. `packages/ui/src` deliberately still does not join it — that split
+ predates this phase and is not this round's call to revisit.
+- **`packages/ui/src/TaskDetail.tsx`'s `readOnlyNotice` docstring** named
+ `apps/web/src/board/httpTransport.ts` and, more substantively, still described the OLD
+ three-tier transport ("relays only a status change and a new card") that `httpTransport.ts`
+ itself says stopped being true before this phase — the stub tier is gone, and what is
+ refused now is only the host-only tier (file pickers, credentials, window buttons), which
+ is what `RELAY_NOTICE`'s actual on-screen text already says. Only the path is this phase's
+ doing; the substance predates it and is fixed here anyway since it sits inside a file this
+ step is chartered to walk — left half-corrected would have been worse than left alone.
+
+Two more stale path references to the same step-3 move were found by the same sweep —
+`packages/shared/src/ipcEventFanout.ts:19` and `ipcRelay.ts:58,63` both still name
+`apps/web/src/board/…` and `apps/web/src/settings/…` in prose. Neither file is on this
+step's named list, so they are recorded here rather than edited — a critical-files walk that
+starts fixing files nobody asked it to walk has exceeded itself the same way one that changes
+behaviour has. Whoever picks up `packages/shared` next should know they are there.
+
+#### The gates, forced, on `9c8eabd`
+
+| Gate | Exit | Result |
+| --- | --- | --- |
+| `pnpm format:check` | 1 → **0** | two files failed before `--write` (below); clean after |
+| `pnpm typecheck --force` | **0** | 12 successful, 12 total — 0 cached, ~33s |
+| `pnpm build --force` | **0** | 8 successful, 8 total — 0 cached, ~40s |
+| `pnpm test` | **0** | 180 files passed, 1 skipped (181); 3005 passed, 11 skipped (3016) |
+| `node apps/client/scripts/verify-remote-ipc.mjs` | 1 → **0** | broken before the fix above; **16 checks** after |
+| `node apps/client/scripts/verify-remote-sse.mjs` | (would have been 1) → **0** | same fix; **36 checks** |
+
+`pnpm format:check` failed on `apps/mobile/src/board/BoardCardRow.tsx` and
+`GitGraphSheet.tsx` — both an unwrapped `import { … } from '@fluentui/react-components'` and
+one unwrapped JSX attribute list over Prettier's line width, from step 6 and step 7. Neither
+is covered by `pnpm format:check`'s own glob for `test/*.ts` or `apps/client/scripts/*.mjs`,
+so those two were checked with `pnpm exec prettier --check` directly instead and came back
+clean. Fixed with `--write`; the diff is whitespace only. Every gate above was run a second
+time after all of this step's edits, with identical counts to what is written here — a
+measurement, not an assumption.
+
+#### The merge this branch is heading into
+
+`development` sits at `0079851`, exactly the merge-base of this branch — it has not moved
+since `feat/mobile-app-for-android` forked from it, so this is a plain fast-forward with
+**zero commits to reconcile and zero conflicts possible**, not merely zero found. A
+`git merge-tree` against it produces no output for the same reason. `apps/client/package.json`
+reads `0.86.0` on both sides, so there is no version bump for a merge to swallow either — the
+normal case now that CI cuts releases from `development` rather than from a bump carried on
+this branch.
+
+### Step 12: verification, re-run one commit later on `9db1f5b`
+
+Step 11 landed its own fixes and measured the gates on the commit it produced; this step
+re-ran every one of them fresh on the tip that commit became (`9db1f5b`), plus the two
+checks step 11's numbers didn't carry forward, to confirm nothing regressed between
+"the step that fixed it" and "the step chartered to verify it."
+
+| Gate | Exit | Result |
+| --- | --- | --- |
+| `pnpm format:check` | **0** | clean — no drift since step 11's `--write` |
+| `pnpm exec turbo run typecheck --force` | **0** | 12 successful, 12 total — 0 cached, ~34s |
+| `pnpm exec turbo run build --force` | **0** | 8 successful, 8 total — 0 cached, ~44s |
+| `pnpm test` | **0** | 180 files passed, 1 skipped (181); 3005 passed, 11 skipped (3016) — identical to step 11's count |
+| `node scripts/verify-mobile-build.mjs` | **0** | all 21 checks pass — manifest, both icons, `sw.js`, and its registration in `index.html` |
+| `node apps/client/scripts/verify-remote-ipc.mjs` | **0** | 16 checks, still green after step 11's re-point |
+| `node apps/client/scripts/verify-remote-sse.mjs` | **0** | 36 checks, still green after step 11's re-point |
+
+`pnpm exec vitest list --filesOnly` gives the per-package test sum the root glob actually
+collects, rather than trusting the workspace layout: 181 files split
+`apps/client` 83, `packages/shared` 27, `apps/server` 27, `packages/ui` 23, `packages/cloud`
+13, `test` 4, `apps/mobile` 2, `scripts` 1, `packages/protocol` 1 — summing to the 181 the
+run itself reports. The two mobile-only files are exactly what the "no component tests"
+constraint predicts: `apps/mobile/src/nav/navStack.test.ts` and
+`apps/mobile/src/sw/shouldHandle.test.ts`, both pure modules. `packages/cloud`'s 13 are the
+board selectors, transports and stores step 3 moved out of `apps/web/src` — confirming
+step 11's `HOST_TREES` fix didn't just silence the shell-parity guard but that the moved
+modules are still exercised at all.
+
+No new test was written for this step: the constraints section ruled out anything a red-first
+assertion could check beyond what steps 1-11 already added, and the plan's own gate list
+(`pnpm format:check && pnpm typecheck && pnpm test && pnpm build`, plus
+`verify-mobile-build.mjs` for the parts a `noEmit` typecheck can't see) is exactly what ran
+above. Every number matches step 11's independently-measured ones on the prior commit, which
+is the outcome a verification step re-running someone else's fix should produce — agreement,
+not new findings.
+
+#### Owed to a human
+
+Nothing here can hold an Android phone. Specifically unverified by any command in this
+branch:
+
+- Installing the PWA from Chrome's "Add to Home screen" / install prompt on a real device.
+- Pressing the hardware/gesture Back button and confirming the nav-stack (step 8) pops the
+ right screen instead of exiting the app.
+- Tapping a card to move it (step 6) and watching the change land on the desktop board, and
+ the reverse — moving it on desktop and watching the phone update.
+- Opening a task full-screen (step 7) and confirming the layout, not just the route, reads
+ right on a phone-sized screen — `verify-mobile-build.mjs` and `shell-parity.test.ts` both
+ check structure and source text, neither renders a pixel.
+- That the CI deploy (step 10) actually reaches an installable URL — `deploy.yml`'s `mobile`
+ job existing and staying inert without its token is confirmed statically; a live deploy
+ needs `AZURE_STATIC_WEB_APPS_API_TOKEN_MOBILE` to be set and a run to complete.
+
+
+
---
## Conventions for every phase
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/syncGate.test.ts b/packages/cloud/src/board/syncGate.test.ts
similarity index 100%
rename from apps/web/src/board/syncGate.test.ts
rename to packages/cloud/src/board/syncGate.test.ts
diff --git a/apps/web/src/board/syncGate.ts b/packages/cloud/src/board/syncGate.ts
similarity index 100%
rename from apps/web/src/board/syncGate.ts
rename to packages/cloud/src/board/syncGate.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 74bcbaa6..26334b1b 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/ProjectsSection.tsx b/packages/cloud/src/settings/ProjectsSection.tsx
similarity index 100%
rename from apps/web/src/settings/ProjectsSection.tsx
rename to packages/cloud/src/settings/ProjectsSection.tsx
diff --git a/apps/web/src/settings/SettingsScreen.tsx b/packages/cloud/src/settings/SettingsScreen.tsx
similarity index 98%
rename from apps/web/src/settings/SettingsScreen.tsx
rename to packages/cloud/src/settings/SettingsScreen.tsx
index 046ebd8e..ae69fed1 100644
--- a/apps/web/src/settings/SettingsScreen.tsx
+++ b/packages/cloud/src/settings/SettingsScreen.tsx
@@ -88,11 +88,28 @@ import { ProjectsEmpty, ProjectsSection } from './ProjectsSection';
import { TokensSection } from './TokensSection';
const useStyles = makeStyles({
- row: { display: 'flex', gap: '16px', height: '100%', minHeight: 0 },
+ row: {
+ display: 'flex',
+ gap: '16px',
+ height: '100%',
+ minHeight: 0,
+ // A phone has no room for a 160px nav beside a pane that wants up to 1100px —
+ // stack instead, nav on top.
+ '@media (max-width: 599px)': {
+ flexDirection: 'column',
+ },
+ },
nav: {
minWidth: '160px',
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
paddingRight: '8px',
+ '@media (max-width: 599px)': {
+ minWidth: 0,
+ borderRight: 'none',
+ borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
+ paddingRight: 0,
+ paddingBottom: '8px',
+ },
},
pane: {
display: 'flex',
diff --git a/apps/web/src/settings/TokensSection.tsx b/packages/cloud/src/settings/TokensSection.tsx
similarity index 100%
rename from apps/web/src/settings/TokensSection.tsx
rename to packages/cloud/src/settings/TokensSection.tsx
diff --git a/apps/web/src/settings/tokensApi.test.ts b/packages/cloud/src/settings/tokensApi.test.ts
similarity index 100%
rename from apps/web/src/settings/tokensApi.test.ts
rename to packages/cloud/src/settings/tokensApi.test.ts
diff --git a/apps/web/src/settings/tokensApi.ts b/packages/cloud/src/settings/tokensApi.ts
similarity index 100%
rename from apps/web/src/settings/tokensApi.ts
rename to packages/cloud/src/settings/tokensApi.ts
diff --git a/apps/web/src/settings/tokensView.test.ts b/packages/cloud/src/settings/tokensView.test.ts
similarity index 100%
rename from apps/web/src/settings/tokensView.test.ts
rename to packages/cloud/src/settings/tokensView.test.ts
diff --git a/apps/web/src/settings/tokensView.ts b/packages/cloud/src/settings/tokensView.ts
similarity index 100%
rename from apps/web/src/settings/tokensView.ts
rename to packages/cloud/src/settings/tokensView.ts
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/packages/ui/src/AddTaskDialog.tsx b/packages/ui/src/AddTaskDialog.tsx
index 0d13c9e5..2c8b0481 100644
--- a/packages/ui/src/AddTaskDialog.tsx
+++ b/packages/ui/src/AddTaskDialog.tsx
@@ -81,7 +81,14 @@ const TASK_TYPES: Array<{ value: TaskType; label: string }> = [
];
const useStyles = makeStyles({
- body: { display: 'flex', flexDirection: 'column', gap: '12px', minWidth: '440px' },
+ // min(…) rather than a bare 440px: that overflows a 360px phone, and the calc side
+ // is a no-op once the viewport is wide enough to afford the fixed side.
+ body: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '12px',
+ minWidth: 'min(440px, calc(100vw - 32px))',
+ },
/**
* The staged files, skinned like `AttachmentStrip` — the same control at a different
* moment, so it should not look like a different one. Transparent border until a file is
diff --git a/packages/ui/src/Performance.tsx b/packages/ui/src/Performance.tsx
index c4b16853..2c7f4c10 100644
--- a/packages/ui/src/Performance.tsx
+++ b/packages/ui/src/Performance.tsx
@@ -87,6 +87,11 @@ const useStyles = makeStyles({
gridTemplateColumns: 'minmax(220px, 1fr) minmax(0, 3fr)',
gap: '16px',
alignItems: 'start',
+ // The rail's own 220px minimum already overflows a phone: at 360px it leaves the
+ // panel beside it ~130px. Stack instead of squeezing a column that can't shrink.
+ '@media (max-width: 599px)': {
+ gridTemplateColumns: '1fr',
+ },
},
rail: { display: 'flex', flexDirection: 'column', gap: '8px' },
panel: { display: 'flex', flexDirection: 'column', gap: '12px', minWidth: 0 },
diff --git a/packages/ui/src/TaskDetail.tsx b/packages/ui/src/TaskDetail.tsx
index 33426beb..b8908307 100644
--- a/packages/ui/src/TaskDetail.tsx
+++ b/packages/ui/src/TaskDetail.tsx
@@ -217,8 +217,10 @@ export interface TaskDetailProps {
priorityDisplay?: PriorityDisplay;
/**
* Why this pane's controls will not do anything, for a host that cannot back them — one
- * sentence, drawn as a warning bar above the card (the web app's, whose transport relays
- * only a status change and a new card; see `apps/web/src/board/httpTransport.ts`).
+ * sentence, drawn as a warning bar above the card (the web and mobile apps', whose shared
+ * `@tm/cloud` transport relays real edits to the desktop and refuses only its host-only
+ * tier — file pickers, credentials, window buttons; see
+ * `packages/cloud/src/board/httpTransport.ts`).
*
* A sentence rather than a `disabled` sweep because the pane degrades by prop ABSENCE
* already — a host that passes no merge requests, no chain and no attention index simply
diff --git a/packages/ui/src/board/ArchivedCardsDialog.tsx b/packages/ui/src/board/ArchivedCardsDialog.tsx
index b4c5f269..7160995a 100644
--- a/packages/ui/src/board/ArchivedCardsDialog.tsx
+++ b/packages/ui/src/board/ArchivedCardsDialog.tsx
@@ -131,7 +131,14 @@ export function archivedCountTitle(count: number): string {
}
const useStyles = makeStyles({
- body: { display: 'flex', flexDirection: 'column', gap: '12px', minWidth: '520px' },
+ // min(…) rather than a bare 520px: that overflows a 360px phone worse than
+ // AddTaskDialog's own 440px does, and the calc side is a no-op at desktop widths.
+ body: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '12px',
+ minWidth: 'min(520px, calc(100vw - 32px))',
+ },
// The list scrolls, the dialog does not: a board that lost thirty cards to a bad JQL is
// exactly when this screen is opened, and thirty rows must not push the buttons off-screen.
list: {
diff --git a/packages/ui/src/board/TaskCard.tsx b/packages/ui/src/board/TaskCard.tsx
index 74448311..4759e4fc 100644
--- a/packages/ui/src/board/TaskCard.tsx
+++ b/packages/ui/src/board/TaskCard.tsx
@@ -1022,13 +1022,20 @@ export interface TaskCardProps {
* restarting are one gesture apart, so they belong in one place.
*/
onResume?: () => void;
- draggable: boolean;
+ /**
+ * Whether the card can be picked up and dragged. Optional — and defaulted to `false` —
+ * because a host with no drag-and-drop (a phone has no mouse to drag with) has nothing
+ * to turn on: the four drag props below are the host difference, the same way
+ * `readOnlyNotice` is one on `TaskDetail`. `KanbanColumn` still passes all four,
+ * unconditionally, exactly as it always has.
+ */
+ draggable?: boolean;
onSelect: () => void;
/** Open a step in the detail pane (the row never drags or moves the card). */
onSelectSubtask?: (taskId: string) => void;
- onDragStart: (e: React.DragEvent) => void;
- onDragEnd: (e: React.DragEvent) => void;
- dragging: boolean;
+ onDragStart?: (e: React.DragEvent) => void;
+ onDragEnd?: (e: React.DragEvent) => void;
+ dragging?: boolean;
}
export function TaskCard({
@@ -1064,12 +1071,12 @@ export function TaskCard({
onLinkArm,
onStop,
onResume,
- draggable,
+ draggable = false,
onSelect,
onSelectSubtask,
onDragStart,
onDragEnd,
- dragging,
+ dragging = false,
}: TaskCardProps): JSX.Element {
const styles = useStyles();
const sprintShown = showSprint;
diff --git a/packages/ui/src/projects/Projects.tsx b/packages/ui/src/projects/Projects.tsx
index 1bce002c..18a265e7 100644
--- a/packages/ui/src/projects/Projects.tsx
+++ b/packages/ui/src/projects/Projects.tsx
@@ -33,8 +33,26 @@ import type { ProjectFormRepoCapability } from './ProjectForm';
import { TimelinePane } from './TimelinePane';
const useStyles = makeStyles({
- root: { display: 'flex', gap: '20px', minHeight: 0, height: '100%' },
- admin: { flex: '0 0 320px', minWidth: 0, overflowY: 'auto' },
+ root: {
+ display: 'flex',
+ gap: '20px',
+ minHeight: 0,
+ height: '100%',
+ // A phone has no room for a 320px admin rail beside a backlog table — stack instead,
+ // admin on top, same treatment as SettingsScreen's own `.row`.
+ '@media (max-width: 599px)': {
+ flexDirection: 'column',
+ },
+ },
+ admin: {
+ flex: '0 0 320px',
+ minWidth: 0,
+ overflowY: 'auto',
+ '@media (max-width: 599px)': {
+ flex: '0 0 auto',
+ maxHeight: '40vh',
+ },
+ },
backlog: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '10px' },
viewSwitch: { display: 'flex', gap: '4px' },
});
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
index 39846e55..93593af7 100644
--- a/packages/ui/src/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -303,10 +303,20 @@ export const useGlobalStyles = makeStaticStyles({
':root': {
colorScheme: 'dark',
},
+ /*
+ * `100dvh` rather than `100%`: on a phone browser tab, before it is installed, `100%`
+ * resolves against the LARGE viewport — the one that assumes the address bar has
+ * scrolled away — while the visible area is the SMALL viewport, bar and all. `body`
+ * ends up taller than the screen, and since it does not scroll (see below) whatever
+ * sits at its bottom — `MobileShell`'s tab bar — ends up under the address bar and
+ * unreachable. `100dvh` tracks the viewport that is actually visible instead, and is
+ * a no-op everywhere else: neither the desktop window nor an installed PWA has a
+ * chrome that shrinks, so their large and small viewports are the same viewport.
+ */
'html, body, #root': {
margin: 0,
padding: 0,
- height: '100%',
+ height: '100dvh',
backgroundColor: '#1f1f1f',
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5cf877a..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':
@@ -172,6 +221,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 +262,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 +2138,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/scripts/make-mobile-icons.mjs b/scripts/make-mobile-icons.mjs
new file mode 100644
index 00000000..9a8d2295
--- /dev/null
+++ b/scripts/make-mobile-icons.mjs
@@ -0,0 +1,144 @@
+/**
+ * Generates apps/mobile's two install icons — icon-192.png and icon-512.png — without
+ * adding an image library. A PWA install icon is a one-time, unmoving asset (a filled
+ * circle on the app's own background colour); pulling in `sharp` or `canvas` to draw two
+ * squares would be a native-module dependency, with its own ABI story
+ * (`verify-electron-app`), for something Node's own `zlib` already has every piece of:
+ * `deflateSync` for the IDAT stream, and a hand-rolled CRC32 (PNG's own checksum, not
+ * exposed by any Node built-in) for each chunk trailer.
+ *
+ * node scripts/make-mobile-icons.mjs
+ *
+ * Colours match the app exactly rather than approximating it: `#1f1f1f` is
+ * `useGlobalStyles`' page background (packages/ui/src/theme.ts), and `#22E4FF` is
+ * `FLUO.cyan` (packages/shared/src/theme's live/in-progress colour) — the only accent this
+ * app already uses for "this is alive", which is what an app icon is.
+ *
+ * Both icons declare `purpose: "any maskable"` in the manifest (one file serves both
+ * roles), which is why the mark sits inside a circle of radius 0.32×size rather than
+ * filling the square: Android's maskable spec crops to different shapes (circle, squircle,
+ * rounded square) and only guarantees the inner 80%-diameter safe zone survives every one
+ * of them. 0.32 leaves a visible margin inside that 0.4 limit rather than running up to it.
+ *
+ * Self-verifying: after writing each file, this script re-reads it and asserts the PNG
+ * signature and the width/height the IHDR chunk actually declares match what was asked
+ * for, in the same PASS/FAIL style as verify-resume-migration.mjs. It fails loudly if,
+ * say, the IHDR byte offsets above are wrong — prove that yourself by changing `12` to
+ * `11` in `assertPng` below and re-running.
+ */
+import { deflateSync } from 'node:zlib';
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const outDir = join(root, 'apps', 'mobile', 'public', 'icons');
+
+const BACKGROUND = [0x1f, 0x1f, 0x1f];
+const MARK = [0x22, 0xe4, 0xff];
+const SIZES = [192, 512];
+
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[n] = c >>> 0;
+ }
+ return table;
+})();
+
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (const byte of buf) {
+ c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
+ }
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function chunk(type, data) {
+ const typeBuf = Buffer.from(type, 'ascii');
+ const lenBuf = Buffer.alloc(4);
+ lenBuf.writeUInt32BE(data.length, 0);
+ const crcBuf = Buffer.alloc(4);
+ crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
+ return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
+}
+
+/** One filled circle, `MARK` on `BACKGROUND`, no transparency — see the header. */
+function renderPixels(size) {
+ const center = size / 2;
+ const radius = size * 0.32;
+ // +1 byte per row for the filter type (0 = None), 3 bytes (RGB) per pixel.
+ const raw = Buffer.alloc(size * (1 + size * 3));
+ let offset = 0;
+ for (let y = 0; y < size; y++) {
+ raw[offset++] = 0;
+ for (let x = 0; x < size; x++) {
+ const dx = x + 0.5 - center;
+ const dy = y + 0.5 - center;
+ const [r, g, b] = dx * dx + dy * dy <= radius * radius ? MARK : BACKGROUND;
+ raw[offset++] = r;
+ raw[offset++] = g;
+ raw[offset++] = b;
+ }
+ }
+ return raw;
+}
+
+function buildPng(size) {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // colour type: truecolor RGB, no alpha
+ ihdr[10] = 0; // compression method
+ ihdr[11] = 0; // filter method
+ ihdr[12] = 0; // interlace method: none
+
+ const idat = deflateSync(renderPixels(size), { level: 9 });
+
+ return Buffer.concat([
+ PNG_SIGNATURE,
+ chunk('IHDR', ihdr),
+ chunk('IDAT', idat),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+function assertPng(buffer, expectedSize) {
+ if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
+ throw new Error('not a PNG: signature mismatch');
+ }
+ const chunkType = buffer.subarray(12, 16).toString('ascii');
+ if (chunkType !== 'IHDR') {
+ throw new Error(`expected the IHDR chunk first, got "${chunkType}"`);
+ }
+ const width = buffer.readUInt32BE(16);
+ const height = buffer.readUInt32BE(20);
+ if (width !== expectedSize || height !== expectedSize) {
+ throw new Error(`expected ${expectedSize}x${expectedSize}, got ${width}x${height}`);
+ }
+}
+
+mkdirSync(outDir, { recursive: true });
+
+let failures = 0;
+for (const size of SIZES) {
+ const outPath = join(outDir, `icon-${size}.png`);
+ try {
+ writeFileSync(outPath, buildPng(size));
+ assertPng(readFileSync(outPath), size);
+ console.log(`PASS icon-${size}.png written and verified at ${size}x${size}`);
+ } catch (err) {
+ failures++;
+ console.log(`FAIL icon-${size}.png — ${err.message}`);
+ }
+}
+
+console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/scripts/verify-mobile-build.mjs b/scripts/verify-mobile-build.mjs
new file mode 100644
index 00000000..b48aea61
--- /dev/null
+++ b/scripts/verify-mobile-build.mjs
@@ -0,0 +1,114 @@
+/**
+ * Headless proof that apps/mobile builds into an installable PWA — the thing Phase 27
+ * step 9 (docs/plan/README.md) actually promises, past "the files exist". Chrome only
+ * offers the install prompt when the manifest parses and carries every required field,
+ * the icons it points at are real images of the declared size, and `sw.js` is reachable
+ * at the scope the manifest claims — none of which a green `pnpm build` on its own proves.
+ *
+ * node scripts/verify-mobile-build.mjs
+ *
+ * Builds through turbo (`--filter=@tm/mobile...`) rather than calling `vite build`
+ * directly in apps/mobile, because apps/mobile imports `@tm/cloud`, `@tm/ui`,
+ * `@tm/shared` and `@tm/protocol` as real workspace packages resolved through their built
+ * `dist/` (apps/mobile/vite.config.ts's own header) — turbo's `^build` dependency is what
+ * builds those first.
+ */
+import { spawnSync } from 'node:child_process';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const distDir = join(root, 'apps', 'mobile', 'dist');
+
+let failures = 0;
+const check = (label, ok) => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
+ if (!ok) failures++;
+};
+
+// ── Build ─────────────────────────────────────────────────────────────────────────────
+const turboBin = join(
+ root,
+ 'node_modules',
+ '.bin',
+ process.platform === 'win32' ? 'turbo.CMD' : 'turbo',
+);
+const build = spawnSync(turboBin, ['run', 'build', '--filter=@tm/mobile...', '--force'], {
+ cwd: root,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+});
+check('apps/mobile builds (turbo run build --filter=@tm/mobile...)', build.status === 0);
+if (build.status !== 0) {
+ console.log(`\n${failures} FAILED`);
+ process.exit(1);
+}
+
+// ── manifest.webmanifest ─────────────────────────────────────────────────────────────
+const manifestPath = join(distDir, 'manifest.webmanifest');
+check('manifest.webmanifest is emitted to dist', existsSync(manifestPath));
+
+const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
+check(
+ 'the manifest parses as JSON with an id',
+ typeof manifest.id === 'string' && manifest.id.length > 0,
+);
+check('display: standalone', manifest.display === 'standalone');
+check('start_url is set', typeof manifest.start_url === 'string' && manifest.start_url.length > 0);
+check('scope is set', typeof manifest.scope === 'string' && manifest.scope.length > 0);
+check('orientation: portrait', manifest.orientation === 'portrait');
+check(
+ "theme_color matches useGlobalStyles' page background (#1f1f1f)",
+ manifest.theme_color === '#1f1f1f',
+);
+check(
+ "background_color matches useGlobalStyles' page background (#1f1f1f)",
+ manifest.background_color === '#1f1f1f',
+);
+
+// ── Icons ─────────────────────────────────────────────────────────────────────────────
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+for (const size of [192, 512]) {
+ const declared = Array.isArray(manifest.icons)
+ ? manifest.icons.find((icon) => icon.sizes === `${size}x${size}`)
+ : undefined;
+ check(`manifest declares a ${size}x${size} icon`, declared != null);
+ check(`icon ${size} declares purpose "any maskable"`, declared?.purpose === 'any maskable');
+
+ if (declared?.src) {
+ const iconPath = join(distDir, declared.src.replace(/^\//, ''));
+ check(`${declared.src} is emitted to dist`, existsSync(iconPath));
+ if (existsSync(iconPath)) {
+ const buf = readFileSync(iconPath);
+ check(`${declared.src} has a PNG signature`, buf.subarray(0, 8).equals(PNG_SIGNATURE));
+ const width = buf.readUInt32BE(16);
+ const height = buf.readUInt32BE(20);
+ check(
+ `${declared.src} declares ${size}x${size} in its own IHDR chunk`,
+ width === size && height === size,
+ );
+ }
+ }
+}
+
+// ── Service worker ───────────────────────────────────────────────────────────────────
+const swPath = join(distDir, 'sw.js');
+check('sw.js is emitted at the dist root', existsSync(swPath));
+
+// ── index.html links the manifest and loads a bundle that registers the SW ─────────────
+const indexHtmlPath = join(distDir, 'index.html');
+const indexHtml = existsSync(indexHtmlPath) ? readFileSync(indexHtmlPath, 'utf8') : '';
+check('index.html links manifest.webmanifest', indexHtml.includes('manifest.webmanifest'));
+
+const assetsDir = join(distDir, 'assets');
+const jsFiles = existsSync(assetsDir)
+ ? readdirSync(assetsDir).filter((f) => f.endsWith('.js'))
+ : [];
+const registersSw = jsFiles.some((f) =>
+ readFileSync(join(assetsDir, f), 'utf8').includes('serviceWorker.register'),
+);
+check('the bundle index.html loads registers the service worker', registersSw);
+
+console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/test/shell-parity.test.ts b/test/shell-parity.test.ts
index 31d7061d..a53423d5 100644
--- a/test/shell-parity.test.ts
+++ b/test/shell-parity.test.ts
@@ -56,6 +56,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');
@@ -190,8 +191,30 @@ 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.
+ *
+ * `packages/cloud/src` joined this list here (Phase 27 step 11), to UNDO a coverage hole
+ * step 3 opened rather than to add new ground: `SettingsScreen.tsx` moved out of
+ * `apps/web/src/settings/` — inside the old `apps/web/src` entry — into
+ * `packages/cloud/src/settings/`, which this list did not yet name. Between step 3 and here
+ * a scrollbar or colour-scheme rule declared in that file would have compiled, rendered, and
+ * gone unnoticed by every assertion below. `packages/cloud` is a shared package like
+ * `packages/ui`, not a per-app host — but unlike `packages/ui`, it renders a whole screen
+ * rather than components a host assembles, which is exactly the shape of file the global-CSS
+ * guard exists to catch.
+ */
+const HOST_TREES = [
+ 'apps/web/src',
+ 'apps/client/src/renderer/src',
+ 'apps/mobile/src',
+ 'packages/cloud/src',
+];
/**
* Every file under a tree whose name `matches`, recursively and repo-relative.
@@ -250,6 +273,53 @@ 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.
+ */
+ /**
+ * Scoped to the `NAV` array literal itself, not every `{ id: '…' }` in the file — `NavRail`
+ * also takes an `accountItems` prop (`[{ id: 'signout', … }]`) for its Account dropdown,
+ * and a whole-file scan would count that entry as a sixth destination that was never one.
+ */
+ function navIds(source: string): string[] {
+ const nav = source.match(/const NAV: readonly NavRailItem\[\] = \[([\s\S]*?)\n\];/);
+ if (!nav) return [];
+ const ids: string[] = [];
+ for (const match of nav[1].matchAll(/\{\s*id:\s*'([a-z]+)'/g)) ids.push(match[1]);
+ return ids;
+ }
+
+ it('list the same 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. Six, since Projects joined the
+ // rail post-merge — a count asserted so a destination silently dropped from BOTH sides
+ // at once (which `toEqual` below cannot see) still goes red here.
+ expect(web.length, `found no NAV ids in ${WEB_APP} — has its destinations moved?`).toBe(6);
+
+ 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 ` +
+ '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
@@ -305,8 +375,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