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 && ( + + + + + + + {targets.map((target) => ( + onMove(target)}> + {COLUMN_LABEL[target]} + + ))} + + + + )} +
+ ); +} 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 ( +
+
+ + void saveSettings({ + ...settings, + jira: { ...settings.jira, showDoneColumn: d.checked }, + }).catch(reportError) + } + /> + + + )} +
+ + {error && {error}} + + + +
+ {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)} + /> + )) + )} +
+ +
+ ); +} 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 ( + !d.open && onClose()}> + +
+ Commit graph +
+
+ +
+
+
+ ); +} 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 ( +
+
+
+
+ +
+
+ ); +} 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