diff --git a/AGENTS.md b/AGENTS.md index 66c67eafe..66028709f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,57 +1,119 @@ -# Repository Guidelines +# Repository Instructions -This file provides guidance to AI agents when working with code in this repository. +Multica is a task management platform where people and agents collaborate on issues. These instructions apply to all coding agents working in this repository. -> **Single source of truth:** This file is a concise pointer document. -> All authoritative architecture, coding rules, and conventions -> live in **CLAUDE.md** at the project root. Read that file first. -> Use `Makefile`, `package.json`, and `pnpm-workspace.yaml` as the -> source of truth for the full command list. +## Scope and Reading Order -## Quick Reference +- Before changing `apps/mobile/`, also read [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md), even if your tool does not load nested instructions automatically. Platform-specific sections below apply only to the named platform. +- For naming, translations, or Chinese UI/docs copy, read [conventions.mdx](apps/docs/content/docs/developers/conventions.mdx) and [conventions.zh.mdx](apps/docs/content/docs/developers/conventions.zh.mdx). +- Maintain shared rules here and mobile-specific rules in the mobile file. `CLAUDE.md` files only import them. Update instructions in the same change that alters the referenced workflow or boundary; do not add incident timelines, dependency version lists, or duplicate rules. -### Architecture +## Sharing Rules and Package Boundaries -Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages. +| Location | Responsibility and constraints | +| --- | --- | +| `server/` | Go backend; Chi, sqlc, WebSocket | +| `packages/core/` | Headless logic, API client, Query hooks, shared Zustand stores. No UI libraries, `react-dom`, `localStorage`, or `process.env`; use `StorageAdapter` for persistence. | +| `packages/ui/` | UI primitives and shared styles. No business logic or `@multica/core` imports. | +| `packages/views/` | Shared web/desktop pages and business components. No store definitions, `next/*`, or `react-router-dom`; use `NavigationAdapter`, `useNavigation()`, and ``. | +| `apps/web/` | Next.js routes/layouts and web-only UI. Framework APIs stay here; shared navigation adapters live in `apps/web/platform/`. | +| `apps/desktop/` | Electron and desktop-only UI/state. Application navigation goes through `apps/desktop/src/renderer/src/platform/`. | +| `apps/mobile/` | Independent Expo/React Native client: owns UI, state, hooks, providers, i18n, build, and release. Shares core types and pure utilities, including platform-independent schemas. | +| `apps/docs/` | Fumadocs documentation site | -- `server/` - Go backend (Chi router, sqlc, gorilla/websocket) -- `apps/web/` - Next.js frontend (App Router) -- `apps/desktop/` - Electron desktop app -- `apps/mobile/` - Expo / React Native iOS app (read `apps/mobile/CLAUDE.md` first) -- `apps/docs/` - Fumadocs documentation site -- `packages/core/` - Headless business logic (Zustand stores, React Query hooks, API client) -- `packages/ui/` - Atomic UI components (shadcn/Base UI, zero business logic) -- `packages/views/` - Shared business pages/components -- `packages/tsconfig/` - Shared TypeScript config -- `packages/eslint-config/` - Shared ESLint config +- Dependency direction is `views -> core + ui`; core and ui remain independent. Shared packages export raw TypeScript compiled by consuming apps. +- Extract logic used by both web and desktop into the appropriate shared package. Keep framework/Electron APIs in the app layer; inject platform-specific UI through props/slots. +- Wire shared features into both web routes and the desktop router or overlay. Reuse existing guards/providers such as `DashboardGuard` in `packages/views/layout/`. +- Each workspace declares its directly imported external dependencies. Use `catalog:` for shared dependencies; mobile pins Expo/React Native dependencies in its own manifest. -### State Management (critical) +## Development and Verification -- **React Query** owns all server state (issues, members, agents, inbox, workspace list) -- **Zustand** owns client/view state (view filters, drafts, modals, desktop tab state); current workspace identity is route-driven and only mirrored for platform plumbing -- All Zustand stores live in `packages/core/` - never in `packages/views/` or app directories -- WS events update React Query for server data; store writes are only for clearing client-owned pointers with a single responder/self-event guard +Use `Makefile`, workspace `package.json` files, and `pnpm-workspace.yaml` for current commands and versions. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and worktree operations. -### Package Boundaries (hard rules) +- Use the checkout's managed environment: `make up`, `make status`, `make down`. `make down` preserves data; `make destroy` removes the environment and its data. +- Worktrees share PostgreSQL but have isolated databases/ports. Use the environment scripts and `.env.worktree`; do not copy the main checkout's `.env` or manually create a database through an assumed PostgreSQL instance. +- Regenerate sqlc with `make sqlc` after SQL changes. +- Run the narrowest useful checks while iterating, then broaden when risk warrants it. Report what actually ran and any skipped checks. -- `packages/core/` - zero react-dom, zero localStorage, zero process.env -- `packages/ui/` - zero `@multica/core` imports -- `packages/views/` - zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing -- `apps/web/platform/` - only place for Next.js APIs +Run these from the repository root: -### Database Migrations (hard rules) +| Scope | Checks | +| --- | --- | +| Frontend excluding mobile | `pnpm typecheck`, `pnpm lint`, `pnpm test` | +| Go backend | `make test` | +| End-to-end | `pnpm exec playwright test` | +| Combined web/backend verification | `make check` | +| Mobile | Commands in [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md#verification) | -- Never add database foreign keys or cascading actions. Enforce relationships and perform dependent cleanup explicitly in the application layer, using transactions when the operation must be atomic. -- Every index created by a migration, including unique indexes and indexes on new tables, must use `CREATE [UNIQUE] INDEX CONCURRENTLY`. Keep each concurrent index build in its own single-statement migration file. +Root frontend commands and `make check` do not verify mobile. Docs-only changes can use link/reference checks and `git diff --check`; state that code tests were not run. -### Commands +## State Rules -```bash -make dev # Auto-setup + start everything -pnpm typecheck # TypeScript check -pnpm test # TS unit tests (Vitest) -make test # Go tests -make check # Full verification pipeline -``` +- TanStack Query owns API/server data. Zustand owns client state such as filters, drafts, modals, and tab layout; persist only durable preferences/drafts/layout, not server data or ephemeral UI state. +- Web/desktop shared stores live in `packages/core/`. Desktop platform stores remain in desktop; mobile stores remain in mobile. Do not define stores in `packages/views/`. +- On web/desktop, workspace identity is route-driven; platform mirrors exist only for request headers, storage namespaces, and reconnects. React Context is for platform plumbing, not a second server-state store. +- Among stores, only auth/workspace stores may call `api.*` directly; other server interactions belong in queries/mutations. +- Workspace-scoped query keys include `wsId`; account-level keys remain account-scoped. Hooks needing workspace context accept `wsId` unless guaranteed to run under its provider. +- Zustand selectors return stable references; use shallow comparison for allocated objects/arrays. +- WebSocket events patch or invalidate Query caches, not server payloads in Zustand. Clearing client-owned pointers is allowed with one responder and a self-initiated guard when this client can cause the event. +- Optimistic field patches require a predictable result, rare failure, trivial rollback, and staying on the current screen. Snapshot before patching, roll back on failure, and invalidate uncertain projections on settle. +- Create/delete/leave and confirmation flows await the server before navigation or cleanup; do not optimistically delete entities. Exceptions: the existing workspace-leave race noted under Desktop Rules, and mobile inbox mark-read as documented in its instructions. +- Message sends use visible pending state and retry on failure. -See CLAUDE.md for the authoritative rules and common commands. +## API Compatibility + +Installed desktop clients may talk to newer backends. Preserve response compatibility at the API boundary. + +- UI-consumed JSON passes through a zod schema and `parseWithFallback`, not an `as T` cast. Web/desktop use `packages/core/api/schema.ts`; mobile uses its own request helpers. +- Provide defaults for optional fields and fallbacks for unknown server enums. Prefer explicit boolean checks; avoid tying critical affordances to a single backend flag when other contract signals are available. +- When adding/changing an endpoint, update its schema and malformed-response tests. + +## Database and Migration Rules + +- Do not add foreign keys, cascading deletes, or cascading updates. Validate relationships and clean up dependents in application code, using a transaction when the operation must be atomic. +- Every migration-created index, including indexes on new tables, uses `CREATE [UNIQUE] INDEX CONCURRENTLY`. Each concurrent index build gets its own single-statement migration file; the runner executes files outside an explicit transaction. +- Conditionally skipped migrations are still recorded in `schema_migrations`. Later DDL touching conditional objects must be idempotent (`IF EXISTS` / `IF NOT EXISTS`); document recovery if the missing object would break runtime behavior. + +## Backend UUID Rules + +In `server/internal/handler/`, distinguish UUID sources before using them in writes: + +- UUID-or-human-readable resource params: resolve with loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`, then write using the resolved `entity.ID`. +- Pure UUID request input: `parseUUIDOrBadRequest(w, s, fieldName)`; return immediately when `ok=false`. +- Trusted sqlc/test-fixture round-trips: `parseUUID(s)`, which panics on invalid input. +- Outside handlers: `util.ParseUUID(s)` and check the error. + +Workspace-scoped queries filter by `workspace_id`; membership gates access and `X-Workspace-ID` selects the workspace. Assignees are polymorphic: interpret `assignee_id` together with `assignee_type`. + +## Desktop Rules + +- Workspace session routes are tab destinations. Pre-workspace one-shot flows (create workspace, accept invite) use `WindowOverlay` in `apps/desktop/src/renderer/src/stores/window-overlay-store.ts`, not new routes. Stale workspace tabs heal by dropping stale tab groups. +- Workspace route layouts own `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform`; leaving workspace context calls `setCurrentWorkspace(null, null)`. +- Cross-workspace navigation uses the adapter's `switchWorkspace(slug, targetPath)` flow; do not bypass it with direct router navigation. +- Workspace delete awaits the server. Existing workspace leave clears/navigates first to avoid the `member:removed` race; this is known debt in `packages/views/settings/components/workspace-tab.tsx`, not a pattern for new flows. +- Full-window views outside the dashboard shell mount `` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`. + +## Web/Desktop UI Rules + +- Prefer existing shadcn/Base UI primitives. Add components with `pnpm ui:add `. +- For `pnpm ui:add @reui/`, decline overwrite prompts. Keep `REUI_LICENSE_KEY` in the environment, never in repo files. Adapt vendored primitives into `packages/ui/components/ui/` and compositions into `packages/views/`. +- Use shared semantic tokens in `packages/ui/styles/`. Typography uses the role-named `--text-*` scale in `packages/ui/styles/tokens.css`, not Tailwind's default size ramp. +- Selected states remain identifiable on hover. Handle overflow, long text, and scrolling deliberately; avoid unnecessary local state and dividers. + +## Testing + +- Tests live beside their implementation: shared logic in core, shared components in views, platform wiring in apps, E2E in `e2e/`, Go tests in server. Do not test shared behavior in app suites. +- Give each behavior one canonical test layer: helper tests own parsing/state matrices; component tests cover wiring, accessibility, happy paths, and named regressions. Prefer a failing regression test before behavioral fixes. +- DOM-free `.test.ts` files start with `// @vitest-environment node`; do not use it if it would silently switch the code under test to an SSR path. +- Views tests must not mock `next/*` or `react-router-dom`. Mock stores with their Zustand callable shape plus `getState`; mock API calls at `@multica/core/api`. +- E2E setup/teardown uses `TestApiClient`. +- DB-backed Go tests use `server/internal/testutil` fixtures (`dbfx.Issue`, `dbfx.Task`, `dbfx.Insert`) and `testutil.Call(h, req).Want(status).JSON(&out)`. Keep product assertions and case-specific diagnostics in the test, not fixture helpers. +- Default tests must not resolve or execute user-installed agent CLIs; pass test-created fake or missing executable paths. New default agent commands go in `scripts/agent-cli-command-names.txt`. +- Only run real-agent smoke tests when explicitly authorized. Gate them behind `agentintegration` and check `MULTICA_RUN_REAL_AGENT_SMOKE=1` before executable lookup/account access. Run the specific test: `(cd server && MULTICA_RUN_REAL_AGENT_SMOKE=1 go test -tags=agentintegration ./pkg/agent -run '' -count=1 -v)`. + +## Change and Delivery Rules + +- Keep changes scoped; reuse existing patterns. Code comments are English. +- Do not add internal compatibility shims, dual writes, fallback paths, or legacy adapters unless requested. This does not relax API response compatibility above. +- New global pre-workspace routes use a single word or `/{noun}/{verb}`, not hyphenated root names. Update `server/internal/handler/reserved_slugs.json`, run `pnpm generate:reserved-slugs`, and commit `packages/core/paths/reserved-slugs.ts` when changing reserved slugs. +- Use atomic conventional commits and the repository PR template. For releases, follow [.github/RELEASING.md](.github/RELEASING.md); default to a patch bump unless specified otherwise. diff --git a/CLAUDE.md b/CLAUDE.md index fc389ca15..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,258 +1 @@ -# CLAUDE.md - -Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong. - -## Conventions - -The source of truth for code naming, i18n glossary, and Chinese product voice is: - -- `apps/docs/content/docs/developers/conventions.mdx` -- `apps/docs/content/docs/developers/conventions.zh.mdx` - -Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. - -## Project Shape - -Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status. - -- `server/`: Go backend, Chi router, sqlc, gorilla/websocket. -- `apps/web/`: Next.js App Router. -- `apps/desktop/`: Electron desktop app. -- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it. -- `apps/docs/`: Fumadocs documentation site. -- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores. -- `packages/ui/`: atomic UI components only. -- `packages/views/`: shared business pages/components for web and desktop. -- `packages/tsconfig/`: shared TypeScript config. -- `packages/eslint-config/`: shared ESLint config. - -Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent. - -## State Rules - -Keep server state and client state separate. - -- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API. -- Zustand owns client/view state: filters, drafts, modals, tab layout, and navigation history. Current workspace identity is route-driven; platform stores/singletons may mirror slug/id only for headers, persistence namespaces, and reconnects. -- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories. -- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`. -- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations. -- Workspace-scoped query keys must include `wsId`. -- Optimistic updates only when ALL hold: outcome locally predictable, user stays on the same screen (no navigation), failure is rare, rollback is trivial. Canonical: status/assignee/toggle field patches — patch determinate caches, roll back on failure, invalidate uncertain projections on settle. -- Flows that navigate or confirm (create, delete, leave) must await the server before navigating or cleaning up; never optimistically remove an entity from cache. -- Chat/message send uses the pending-message pattern: render immediately with a visible pending state and retry on failure, not silent optimism. -- WebSocket events invalidate or patch Query cache for server data. They must never mirror server payload data into Zustand; clearing client-owned pointers (active session, selection, current workspace) is allowed only with a single responder and a self-initiated guard when this client can cause the event. -- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state. -- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison. -- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider. - -## Package Boundaries - -These are hard constraints: - -- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries. -- `packages/ui/`: no `@multica/core` imports and no business logic. -- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and ``. -- `apps/web/platform/`: only place for Next.js navigation/platform APIs. -- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring. -- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`. -- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly. - -## Sharing Rules - -Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. - -If the same logic exists in both web and desktop, extract it unless it depends on platform APIs: - -1. Next.js, Electron, or router APIs stay in the app/platform layer. -2. Headless logic belongs in `packages/core/`. -3. Shared UI or business views belong in `packages/views/`. -4. Shared primitives belong in `packages/ui/`. - -Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence. - -## Commands - -Use the repo scripts as the source of truth. Common commands: - -```bash -make up # start this checkout's environment (C=api,web,daemon,desktop) -make status # what is running, with pid/commit proof it is yours -make list # every development environment on this machine -make down # stop the processes, keep the database -make destroy # stop, then drop the database and free the slot -make dev # auto-setup and start the app in the foreground -make start # start backend + frontend -make stop # stop app processes for this checkout -make db-drop # permanently drop this checkout's local database -make remove-worktree WORKTREE=../path # drop a linked worktree DB, then remove it -make server # run Go server only -make daemon # run local daemon -make test # Go tests -make sqlc # regenerate sqlc code after SQL changes -pnpm install -pnpm dev:web -pnpm dev:desktop -pnpm build -pnpm typecheck -pnpm lint -pnpm test # TS/Vitest tests through Turborepo -pnpm exec playwright test -pnpm ui:add badge # shadcn/Base UI component into packages/ui -``` - -`make up` records each environment in `~/.multica/dev/`, allocates its API, Web and Desktop renderer ports plus database name under a lock instead of recomputing them from the path, and verifies the database through `DATABASE_URL` rather than `docker exec` — a `docker exec` create lands in the wrong server whenever a native PostgreSQL owns 5432. It reuses an API only when `/health` proves its listener pid, process group and commit belong to this checkout. `make down` keeps the data; `make destroy` consumes the database, profile, daemon workspaces, Desktop userData and registry entry. Agent-owned TTL environments are collected best-effort on the next `make up`, or explicitly with `make gc`. - -Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. Direct `pnpm dev:desktop` self-isolates from the path; `make up C=desktop` overrides that fallback with the registry-allocated renderer port and app name so Desktop shares the environment ledger. - -CI runs Node 22, the latest Go 1.26 patch, and a `pgvector/pgvector:pg17` PostgreSQL service. - -## Database and Migration Rules - -These are hard requirements for every new or modified database design and production migration: - -- Do not add database foreign keys (`FOREIGN KEY` / `REFERENCES`), cascading deletes, or cascading updates. Resolve relationships, validation, and dependent cleanup explicitly in application code. Use an application transaction when cleanup and the parent operation must commit or roll back atomically. -- Every index created by a migration must use `CREATE INDEX CONCURRENTLY` or `CREATE UNIQUE INDEX CONCURRENTLY`, including indexes on newly created tables. PostgreSQL rejects concurrent index creation inside a transaction or a multi-command string, so keep each concurrent index build in its own single-statement migration file. The repository migration runner executes migration files outside an explicit transaction to support this. -- A conditionally skipped migration is still recorded in `schema_migrations`, so the ledger proves ordering, not that every migration's SQL executed. Later migrations that touch conditionally present objects must use idempotent DDL such as `IF EXISTS` / `IF NOT EXISTS`, and the introducing change must document recovery when losing the selected object would break runtime behavior. - -## Coding Rules - -- TypeScript strict mode is enabled; keep types explicit. -- Go follows standard conventions: `gofmt`, `go vet`, checked errors. -- Code comments must be English. -- Prefer existing patterns/components over new parallel abstractions. -- Avoid broad refactors unless required by the task. -- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested. -- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below. -- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both. -- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`. -- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`. - -## API Compatibility - -Frontend code must survive backend response drift, especially in installed desktop builds. - -- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`. -- Endpoint responses consumed by UI logic must pass through a schema before returning. -- Downstream UI should optional-chain and default fields defensively. -- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields. -- Do not pin critical affordances to one backend boolean; combine signals when possible. -- Server-driven enum switches need a `default` branch. -- When adding or changing an endpoint, add/update the schema and include a malformed-response test. - -## Backend UUID Rules - -In `server/internal/handler/`, always know where a UUID came from before using it in write queries. - -- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`. -- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`. -- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input. -- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error. - -## Web/Desktop Features - -When adding a shared page or feature for web and desktop: - -1. Put the page/component in `packages/views//`. -2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay. -3. Use `useNavigation().push()` or `` in shared code. -4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`. -5. Keep platform-only UI in the app or inject it through props/slots. -6. Hooks that need workspace context should accept `wsId`. - -CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles. - -## Desktop Rules - -Desktop routing has three categories: - -- Session routes: workspace-scoped tab destinations such as `/:slug/issues`. -- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes. -- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages. - -More desktop constraints: - -- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`. -- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` mirrors the active route for headers, storage namespaces, and reconnects; workspace route layouts own setting it. -- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly. -- Workspace delete must await the server before navigation/cleanup. Workspace leave currently clears/navigates before mutation only to avoid the `member:removed` realtime race; treat that as known debt, not a reusable pattern. -- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`. -- Full-window desktop views outside the dashboard shell must mount `` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`. - -## Mobile Rules - -Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow. - -Root-level reminders: - -- Mobile shares only `@multica/core` types and pure functions. -- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity. -- Mobile may differ in UI/interaction when the phone context requires it. - -## UI Rules - -- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add ` from the repo root. -- The Pro `@reui` registry is configured in `packages/ui/components.json`; add items with `pnpm ui:add @reui/` and answer `n` to every overwrite prompt so local component customizations survive. It reads `REUI_LICENSE_KEY` from the environment — agents get it from their Multica agent environment, humans export it in their own shell. Never write the key into a repo file. -- ReUI ships source, not a dependency: route the vendored output to our layout (new primitives to `packages/ui/components/ui/`, compositions to `packages/views//`) and rewrite it to our conventions before committing. -- Use design tokens and semantic classes; avoid hardcoded colors. Font sizes come from the role-named `--text-*` scale in `packages/ui/styles/tokens.css` (`text-caption`, `text-body`, `text-title`, …), which is the authoritative list — not Tailwind's default `text-sm` / `text-base` ramp. -- An active/selected state must stay identifiable while hovered. Express it on a dimension hover does not touch (weight, text color), or define the `data-active:hover:` compound explicitly — otherwise hovering a selected row visually downgrades it to plain hover. -- Do not introduce extra local state unless the design requires it. -- Handle overflow, long text, scrolling, alignment, and spacing deliberately. Prefer more spacing over adding a divider. -- If a component is identical between web and desktop, it belongs in a shared package. - -## Testing - -Tests follow the code: - -| What is tested | Location | -| --- | --- | -| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` | -| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` | -| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` | -| End-to-end flows | `e2e/*.spec.ts` | -| Backend | `server/` Go tests | - -Rules: - -- Never test shared component behavior in an app test file. -- Give each product behavior ONE canonical layer. Pure parsing, state transitions and boundary matrices belong in a `.test.ts` beside the helper; the component suite keeps the happy path, the wiring, accessibility and named regressions, and points at the canonical file in a comment. Do not re-run a helper's matrix through a DOM mount. -- A `.test.ts` that needs no DOM must start with `// @vitest-environment node`. jsdom costs ~0.8s of setup per file and buys such a suite nothing. Do not add it to a test whose code under test branches on `typeof window`/`document` — under node it would silently take the SSR path and still pass. -- `packages/views/` tests must not mock `next/*` or `react-router-dom`. -- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`). -- Mock `@multica/core/api` for API calls. -- E2E tests should use `TestApiClient` for setup/teardown. -- Prefer writing the failing test in the correct package before implementation when the change is behavioral. -- DB-backed Go tests build their rows through `server/internal/testutil` (`dbfx.Issue`, `dbfx.Task`, `dbfx.Insert`) and drive handlers through `testutil.Call(h, req).Want(status).JSON(&out)`. Do not open-code an `INSERT ... RETURNING id` with its matching `t.Cleanup(DELETE ...)`, or a `httptest.NewRecorder()` / status-check / decode quartet, in a new test. `internal/handler` held ~1000 of the first and ~1200 of the second before the shared fixtures landed, and every change to a shared contract had to be made once per copy. -- Keep an assertion where the test wrote it when its message says something the shared one cannot. `.Want()` prints the request line, both statuses and the response body; it does not know which case in a loop was running. -- Helpers in `internal/testutil` insert rows, run handlers and report mismatches. They must not assert a product rule on a test's behalf — a helper that knows what a correct response looks like has taken the assertion away from the test making it. -- Default tests must never resolve or execute user-installed agent CLIs. Pass a test-created fake executable path or a test-created missing path to agent subprocess code. -- Real-agent smoke tests belong behind the `agentintegration` build tag and must check `MULTICA_RUN_REAL_AGENT_SMOKE=1` before executable lookup or account access. -- Run an explicitly authorized real-agent smoke test with `(cd server && MULTICA_RUN_REAL_AGENT_SMOKE=1 go test -tags=agentintegration ./pkg/agent -run '' -count=1 -v)`. This command may access an authenticated account and consume quota. -- When adding a default agent command, add it to `scripts/agent-cli-command-names.txt`; the normal Linux/macOS test entry points fail on ambient agent CLI execution. - -## Verification - -For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked. - -Useful checks: - -```bash -pnpm typecheck -pnpm test -make test -pnpm exec playwright test -make check -``` - -Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so. - -## Commits and Releases - -- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`. -- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap. -- Bump patch by default unless the user specifies a version. - -## Domain Reminders - -- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace. -- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent. +@AGENTS.md diff --git a/apps/docs/content/docs/developers/contributing.ja.mdx b/apps/docs/content/docs/developers/contributing.ja.mdx index 3a7a64a36..db717536e 100644 --- a/apps/docs/content/docs/developers/contributing.ja.mdx +++ b/apps/docs/content/docs/developers/contributing.ja.mdx @@ -89,7 +89,7 @@ pnpm lint pnpm test ``` -ルートコマンドはデフォルトで Mobile を除外します。Mobile には独自のスクリプトと CI があるため、変更前に `apps/mobile/CLAUDE.md` を読んでください。 +ルートコマンドはデフォルトで Mobile を除外します。Mobile には独自のスクリプトと CI があるため、変更前に `apps/mobile/AGENTS.md` を読んでください。 ### バックエンド @@ -118,7 +118,7 @@ Web と Desktop の両方で必要な機能は、責務に応じて配置しま 4. Next.js、Electron、ルーティングアダプターは対応する app に残します。 5. 共有ページは Web と Desktop の両方へ接続します。 -サーバーデータは TanStack Query、フィルター、下書き、レイアウトなどのクライアント状態は Zustand が管理します。具体的な境界は[プロジェクトアーキテクチャ](/developers/architecture)とルートの `CLAUDE.md` を参照してください。 +サーバーデータは TanStack Query、フィルター、下書き、レイアウトなどのクライアント状態は Zustand が管理します。具体的な境界は[プロジェクトアーキテクチャ](/developers/architecture)とルートの `AGENTS.md` を参照してください。 API を追加または変更するときは `packages/core/api/` の zod schema も更新し、欠落フィールド、未知の enum、形式不正に対する解析テストを追加します。 @@ -186,7 +186,7 @@ make start ## 提出前の確認 -- ルートの `CLAUDE.md` と関連するサブディレクトリの説明を読みます。 +- ルートの `AGENTS.md` と関連するサブディレクトリの説明を読みます。 - 現在の作業に必要な範囲だけを変更します。 - コードコメントは英語で記述します。 - `.env`、トークン、ビルド成果物、ローカルパスをコミットしません。 diff --git a/apps/docs/content/docs/developers/contributing.ko.mdx b/apps/docs/content/docs/developers/contributing.ko.mdx index cb6d2abef..d5169282c 100644 --- a/apps/docs/content/docs/developers/contributing.ko.mdx +++ b/apps/docs/content/docs/developers/contributing.ko.mdx @@ -89,7 +89,7 @@ pnpm lint pnpm test ``` -루트 명령은 기본적으로 Mobile을 제외합니다. Mobile에는 별도 스크립트와 CI가 있으므로 변경하기 전에 `apps/mobile/CLAUDE.md`를 읽으세요. +루트 명령은 기본적으로 Mobile을 제외합니다. Mobile에는 별도 스크립트와 CI가 있으므로 변경하기 전에 `apps/mobile/AGENTS.md`를 읽으세요. ### Backend @@ -118,7 +118,7 @@ Web과 Desktop에 모두 필요한 기능은 역할에 따라 배치합니다. 4. Next.js, Electron, 라우팅 adapter는 해당 app에 남깁니다. 5. 공유 페이지는 Web과 Desktop 양쪽에 연결합니다. -서버 데이터는 TanStack Query가 관리하고 필터, 초안, 레이아웃 같은 클라이언트 상태는 Zustand가 관리합니다. 구체적인 경계는 [프로젝트 아키텍처](/developers/architecture)와 루트 `CLAUDE.md`를 참고하세요. +서버 데이터는 TanStack Query가 관리하고 필터, 초안, 레이아웃 같은 클라이언트 상태는 Zustand가 관리합니다. 구체적인 경계는 [프로젝트 아키텍처](/developers/architecture)와 루트 `AGENTS.md`를 참고하세요. API를 추가하거나 변경하면 `packages/core/api/`의 zod schema도 업데이트하고 누락된 필드, 알 수 없는 enum, 잘못된 형식에 대한 parsing 테스트를 추가해야 합니다. @@ -186,7 +186,7 @@ make start ## 제출 전 확인 -- 루트 `CLAUDE.md`와 관련 하위 디렉터리 안내를 읽습니다. +- 루트 `AGENTS.md`와 관련 하위 디렉터리 안내를 읽습니다. - 현재 작업에 필요한 범위만 변경합니다. - 코드 주석은 영어로 작성합니다. - `.env`, token, 빌드 결과물, 로컬 경로를 commit하지 않습니다. diff --git a/apps/docs/content/docs/developers/contributing.mdx b/apps/docs/content/docs/developers/contributing.mdx index 534375d00..e600604fb 100644 --- a/apps/docs/content/docs/developers/contributing.mdx +++ b/apps/docs/content/docs/developers/contributing.mdx @@ -103,7 +103,7 @@ pnpm lint pnpm test ``` -Root commands exclude Mobile by default. Mobile has separate scripts and CI; read `apps/mobile/CLAUDE.md` before changing it. +Root commands exclude Mobile by default. Mobile has separate scripts and CI; read `apps/mobile/AGENTS.md` before changing it. ### Backend @@ -132,7 +132,7 @@ Place features needed by both Web and Desktop according to responsibility: 4. Keep Next.js, Electron, and routing adapters in the corresponding app. 5. Wire shared pages into both Web and Desktop. -TanStack Query owns server data. Zustand owns client state such as filters, drafts, and layout. See [Project architecture](/developers/architecture) and the root `CLAUDE.md` for the exact boundary. +TanStack Query owns server data. Zustand owns client state such as filters, drafts, and layout. See [Project architecture](/developers/architecture) and the root `AGENTS.md` for the exact boundary. When adding or changing an API, update the zod schema in `packages/core/api/` and add parsing tests for missing fields, unknown enum values, and malformed data. @@ -200,7 +200,7 @@ make start ## Before submitting -- Read the root `CLAUDE.md` and relevant nested instructions. +- Read the root `AGENTS.md` and relevant nested instructions. - Change only the scope required by the task. - Write code comments in English. - Do not commit `.env`, tokens, build artifacts, or local paths. diff --git a/apps/docs/content/docs/developers/contributing.zh.mdx b/apps/docs/content/docs/developers/contributing.zh.mdx index 74563ac50..28516438c 100644 --- a/apps/docs/content/docs/developers/contributing.zh.mdx +++ b/apps/docs/content/docs/developers/contributing.zh.mdx @@ -89,7 +89,7 @@ pnpm lint pnpm test ``` -根命令默认排除 Mobile。移动端有独立脚本和 CI,修改前先阅读 `apps/mobile/CLAUDE.md`。 +根命令默认排除 Mobile。移动端有独立脚本和 CI,修改前先阅读 `apps/mobile/AGENTS.md`。 ### Backend @@ -118,7 +118,7 @@ Web 与 Desktop 都需要的功能按职责放置: 4. Next.js、Electron 和路由适配留在对应 app; 5. 共享页面需要同时接入 Web 与 Desktop。 -服务端数据由 TanStack Query 管理,筛选、草稿和布局等客户端状态由 Zustand 管理。具体边界见[项目架构](/developers/architecture)和根目录 `CLAUDE.md`。 +服务端数据由 TanStack Query 管理,筛选、草稿和布局等客户端状态由 Zustand 管理。具体边界见[项目架构](/developers/architecture)和根目录 `AGENTS.md`。 新增或修改 API 时,要同步更新 `packages/core/api/` 中的 zod schema,并为缺失字段、未知枚举或格式错误增加解析测试。 @@ -186,7 +186,7 @@ make start ## 提交前检查 -- 阅读根目录 `CLAUDE.md` 和相关子目录说明; +- 阅读根目录 `AGENTS.md` 和相关子目录说明; - 只修改当前 task 需要的范围; - 代码注释使用英文; - 不提交 `.env`、令牌、构建产物或本机路径; diff --git a/apps/docs/content/docs/developers/conventions.ja.mdx b/apps/docs/content/docs/developers/conventions.ja.mdx index 0ebc43ebb..2b96d9fdd 100644 --- a/apps/docs/content/docs/developers/conventions.ja.mdx +++ b/apps/docs/content/docs/developers/conventions.ja.mdx @@ -62,7 +62,7 @@ Multica のコードを書く、翻訳を変更する、あるいは中国語の - 標準の `gofmt` + `go vet`。例外なし。 - Handler ファイルはドメインを反映する: `agent.go`, `auth.go`, `runtime.go` - テスト: `_test.go` を同じ場所に配置 -- handler での UUID パースは、ルートの `CLAUDE.md` のルールに従ってください — 境界の入力には `parseUUIDOrBadRequest`、信頼できる往復には `parseUUID`(panic 版)を使い、error を確認せずに `util.ParseUUID` を直接使用しないでください。 +- handler での UUID パースは、ルートの `AGENTS.md` のルールに従ってください — 境界の入力には `parseUUIDOrBadRequest`、信頼できる往復には `parseUUID`(panic 版)を使い、error を確認せずに `util.ParseUUID` を直接使用しないでください。 ### TypeScript @@ -308,7 +308,7 @@ i18next は `_one` / `_other` を使います。中国語には文法的な数 ここのルールを変更した場合は、次も併せて行ってください。 -1. 関連する locale JSON / CLAUDE.md / ドキュメントページに適用する +1. 関連する locale JSON / AGENTS.md / ドキュメントページに適用する 2. PR の説明に変更点を記録し、レビュアーが下流の一括対応を確認できるようにする このページが契約です。他の何ものもこれを上書きできません。 diff --git a/apps/docs/content/docs/developers/conventions.ko.mdx b/apps/docs/content/docs/developers/conventions.ko.mdx index a6cf9fb08..eb417ee21 100644 --- a/apps/docs/content/docs/developers/conventions.ko.mdx +++ b/apps/docs/content/docs/developers/conventions.ko.mdx @@ -62,7 +62,7 @@ Multica 코드를 작성하거나, 번역을 변경하거나, 중국어 제품 - 표준 `gofmt` + `go vet`. 예외 없음. - Handler 파일은 도메인을 반영: `agent.go`, `auth.go`, `runtime.go` - 테스트: `_test.go`로 같은 위치에 배치 -- handler에서의 UUID 파싱은 루트 `CLAUDE.md`의 규칙을 따릅니다 — 경계 입력에는 `parseUUIDOrBadRequest`, 신뢰할 수 있는 왕복에는 `parseUUID`(panic 버전), 절대 error를 확인하지 않고 `util.ParseUUID`를 직접 사용하지 마세요. +- handler에서의 UUID 파싱은 루트 `AGENTS.md`의 규칙을 따릅니다 — 경계 입력에는 `parseUUIDOrBadRequest`, 신뢰할 수 있는 왕복에는 `parseUUID`(panic 버전), 절대 error를 확인하지 않고 `util.ParseUUID`를 직접 사용하지 마세요. ### TypeScript @@ -308,7 +308,7 @@ i18next는 `_one` / `_other`를 사용합니다; 중국어에는 문법적 수 이곳의 규칙을 변경하면 다음도 함께 수행하세요: -1. 관련 locale JSON / CLAUDE.md / 문서 페이지에 적용 +1. 관련 locale JSON / AGENTS.md / 문서 페이지에 적용 2. PR 설명에 변경 사항을 기록하여 리뷰어가 다운스트림 정리를 살펴보도록 알리기 이 페이지가 계약입니다; 다른 어떤 것도 이를 무시할 수 없습니다. diff --git a/apps/docs/content/docs/developers/conventions.mdx b/apps/docs/content/docs/developers/conventions.mdx index 37f97688b..c3da4a53a 100644 --- a/apps/docs/content/docs/developers/conventions.mdx +++ b/apps/docs/content/docs/developers/conventions.mdx @@ -62,7 +62,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a - Standard `gofmt` + `go vet`. No exceptions. - Handler files mirror domain: `agent.go`, `auth.go`, `runtime.go` - Tests: `_test.go` colocated -- For UUID parsing in handlers, follow the rule in the root `CLAUDE.md` — `parseUUIDOrBadRequest` for boundary input, `parseUUID` (panicking) for trusted round-trips, never `util.ParseUUID` directly without checking the error. +- For UUID parsing in handlers, follow the rule in the root `AGENTS.md` — `parseUUIDOrBadRequest` for boundary input, `parseUUID` (panicking) for trusted round-trips, never `util.ParseUUID` directly without checking the error. ### TypeScript @@ -319,7 +319,7 @@ When the glossary doesn't cover a term, look at: If you change a rule here, also: -1. Apply it in the relevant locale JSONs / CLAUDE.md / docs page +1. Apply it in the relevant locale JSONs / AGENTS.md / docs page 2. Note the change in the PR description so reviewers know to look for downstream sweep This page is the contract; nothing else overrides it. diff --git a/apps/docs/content/docs/developers/conventions.zh.mdx b/apps/docs/content/docs/developers/conventions.zh.mdx index 788b2f40a..6f20e83e2 100644 --- a/apps/docs/content/docs/developers/conventions.zh.mdx +++ b/apps/docs/content/docs/developers/conventions.zh.mdx @@ -62,7 +62,7 @@ Web 和 Desktop 都有的逻辑,**必须**抽到共享包。"小段重复"也 - 标准 `gofmt` + `go vet`,无例外 - Handler 文件按域命名:`agent.go`、`auth.go`、`runtime.go` - 测试:`_test.go` 同目录 -- handler 里 UUID 解析遵守根 `CLAUDE.md` 的规则:边界输入用 `parseUUIDOrBadRequest`,可信回环用 `parseUUID`(panic 版),永远不要直接用 `util.ParseUUID` 不查 error +- handler 里 UUID 解析遵守根 `AGENTS.md` 的规则:边界输入用 `parseUUIDOrBadRequest`,可信回环用 `parseUUID`(panic 版),永远不要直接用 `util.ParseUUID` 不查 error ### TypeScript @@ -319,7 +319,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other` 改本页规则的同时还要: -1. 把规则在相关 locale JSON / CLAUDE.md / docs 页面里同步落地 +1. 把规则在相关 locale JSON / AGENTS.md / docs 页面里同步落地 2. PR 描述里写明改了什么,方便 reviewer 检查下游是否跟着改了 本页是契约,其他文档不能 override。 diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md new file mode 100644 index 000000000..8fcc8391c --- /dev/null +++ b/apps/mobile/AGENTS.md @@ -0,0 +1,92 @@ +# Mobile App Instructions + +These rules apply only to `apps/mobile/`, in addition to the [root instructions](../../AGENTS.md). Paths below are relative to `apps/mobile/` unless prefixed with `packages/` or `.github/`. + +## Sharing and Dependencies + +- Mobile owns its UI, state, hooks, providers, API client, QueryClient, i18n, and build/release workflow. Import core types with `import type`; runtime imports are limited to pure utilities and platform-independent schemas. Do not import web/desktop stores, hooks, Query factories, or WS updaters. +- Use `package.json` and the lockfile for current versions. Mobile pins Expo/React Native dependencies rather than taking the root React catalog. +- Add SDK-aligned native packages with `pnpm exec expo install ` from this directory. Check compatibility before adding other dependencies; do not pick versions from memory. +- Follow [README.md](README.md) for setup, simulator/device builds, and environment variants. Keep iOS scripts routed through `scripts/ios-run.sh`, which prebuilds before running iOS so config plugins are reapplied. Preserve the caller's `APP_ENV`; avoid clean prebuilds in the normal edit loop. +- Generated `ios/` and `android/` directories are not source. Check new source paths with `git check-ignore -v ` when they could match the root ignore rules, particularly `data/`, `build/`, and `bin/`. + +## Behavioral Parity + +- Before implementing a feature, inspect its web/desktop implementation in `packages/views/` and `packages/core/`: endpoints, permissions, state transitions, display transforms, and cross-cache side effects. +- Match product semantics: counts/visibility, permissions, enums, and canonical IDs/fields. Adapt UI and navigation to the phone; document meaningful divergences with the source behavior they preserve. +- Mirror the relevant behavior using mobile-owned caches and helpers; do not copy cache shapes or UI preprocessing blindly. List transforms must preserve pagination, grouping, and visibility semantics. +- Reuse `lib/inbox-display.ts` for inbox list display. Badge counts come from the server unread summary via `lib/unread-counts.ts` and `data/queries/inbox.ts`; do not derive them from the downloaded list. Inbox writes/events refresh both the list and unread summary through `data/realtime/inbox-ws-updaters.ts`. +- An explicit implementation request authorizes work. Explain significant interaction choices and parity points briefly; ask when an unresolved product decision affects the implementation, not for a second approval using special wording. + +## UI Components + +- Inspect existing rows, pickers, forms, and domain visuals before adding components. Extend a suitable existing pattern; do not rewrite a domain component merely because a new feature uses it. +- For a new interaction, prefer a native iOS/RN API, then an RNR component. If neither fits, compose existing primitives inline for a local need. A new generic primitive requires at least three callers and no suitable native/RNR alternative; clarify unresolved interaction requirements before inventing one. +- Native examples: `Alert.prompt` for text prompts, `Alert.alert` for confirmation, `ActionSheetIOS` for action menus, existing native date/media/document pickers, `Share.share`, and `expo-haptics`. +- Add RNR components with `npx @react-native-reusables/cli@latest add `. Review generated changes and preserve local customizations. Keep default variants/spacing unless a concrete product need requires changes. +- Generic primitives live in `components/ui/`; domain compositions live in `components//`. Use the existing `cn()` in `lib/utils.ts` and semantic tokens. +- Screens have titles, tab bars have icons, secondary labels use type-aware helpers, and multiple trailing row elements stack vertically. Check long text, keyboard, safe areas, scrolling, and both themes. + +### Theming model + +- `global.css` defines mobile color variables; `tailwind.config.js` maps classes and `lib/theme.ts` supplies native style/navigation values. Update the corresponding mappings together when changing tokens. +- Preserve class-based light/dark/system switching through `lib/use-color-scheme.ts` and its secure-store persistence. React Navigation uses `NAV_THEME` from `lib/theme.ts`. +- Keep mobile tokens local; do not import web/desktop CSS. Read [docs/markdown-rendering-adr.md](docs/markdown-rendering-adr.md) before changing the Markdown renderer or its native styling. + +### Sheets and navigation + +| Content | Container | +| --- | --- | +| Confirmation or text prompt | Native alert/prompt | +| Short action menu | Native action sheet; reuse existing local popover patterns when appropriate | +| Long list, search, form, or keyboard interaction | Expo Router `presentation: "formSheet"` route | +| Multi-screen modal flow | Expo Router `presentation: "modal"` | + +- All pickers in one attribute row use the same formSheet interaction, including short option lists. +- Place picker routes under their owning context and register them in `app/(app)/[workspace]/_layout.tsx` using `SHEET_OPTIONS`. Reuse that configuration rather than copying its current values; document necessary overrides at the route. +- Sheet bodies own their data lookup and mutation, then return with `router.back()`. Draft flows use the appropriate local draft store when there is no persisted record. Follow the existing body-header pattern unless the route explicitly requires native header chrome. +- Check the return destination when opening a sheet from another modal, and handle deep links without assuming the record is already cached. +- Destructive swipes reveal an action that requires a tap; never auto-execute on full swipe. Reuse `components/inbox/swipeable-inbox-row.tsx`, including its one-shot haptic feedback. + +## Data Layer + +Use the existing helpers rather than rebuilding request or subscription plumbing. + +| Concern | Implementation | +| --- | --- | +| Requests | `data/api.ts`: `fetchValidated` for GET, `fetchValidatedWith` for writes with consumed response bodies | +| Response schemas/fallbacks | `data/schemas.ts`, `lib/parse-response.ts`; reuse compatible pure schemas from core | +| Query keys and options | `data/queries/`; mutations import the corresponding key factory | +| Query lifecycle | `data/query-client.ts`: AppState focus and NetInfo connectivity | +| Realtime lifecycle | `data/realtime/ws-client.ts`, `data/realtime/realtime-provider.tsx`, `lib/use-ws-subscriptions.ts` | + +- UI-consumed responses follow the root API compatibility rules. Raw `fetch` is reserved for writes whose response body is unused. Fallback values must satisfy the expected success type. +- Forward each query's `signal` through the API method to fetch. Preserve the request timeout and caller-abort forwarding in `data/api.ts`; do not replace its cancellation implementation without checking the supported mobile runtime. +- Preserve the API client's request IDs/logging and the idempotent 401 cleanup in `app/_layout.tsx` (auth, workspace, Query cache, navigation). Mobile uses bearer auth; do not copy browser cookie/CSRF plumbing. +- Use feature key factories, with `wsId` for workspace data and separate account-level keys for cross-workspace summaries. Do not hardcode keys or require every key to have the same number of segments. +- **Optimistic-update exception:** inbox mark-read may patch synchronously before navigation to avoid an unread row in the iOS transition snapshot. Keep this inside the mutation, capture rollback state before patching, and refresh on settle. It is not permission to optimistically create/delete/leave or to generalize navigation-time optimism. +- Other mutations follow the root optimistic-update policy; message sends retain visible pending/retry states. + +## Realtime + +- Keep listing subscriptions mounted for the workspace session in `RealtimeSubscriptions` in `app/(app)/[workspace]/_layout.tsx`. Record subscriptions belong to the owning screen, filter by record ID, and clean up on unmount/ID changes. +- Use `useWSSubscriptions` and typed `ws.on`. When adding protocol events, update both `WSEventType` and `WSEventPayloadMap` in `packages/core/types/events.ts`; narrow unknown payloads rather than adding unchecked casts. +- Updaters belong to the feature whose cache they change. That feature subscribes to relevant foreign events; for example, inbox owns its reactions to issue status/deletion events. +- Patch when the payload and cache shape make the result determinate. Invalidate when information is incomplete, list membership is uncertain, or an infrequent event makes refetching simpler. Use existing refresh helpers that handle in-flight request races. +- Each feature refreshes its affected caches on reconnect, including any account-level projections it owns. Avoid a global refetch sweep or subscriptions with no mobile consumer. +- Accept authoritative WS updates over local optimistic state. Do not add timestamp gates to protect optimistic patches without a demonstrated ordering problem. + +## Verification + +From the repository root: + +```bash +pnpm --filter @multica/mobile typecheck +pnpm --filter @multica/mobile lint +pnpm --filter @multica/mobile test +``` + +- Root frontend checks exclude mobile. `.github/workflows/mobile-verify.yml` defines the current mobile CI scope; these checks do not build an IPA or verify native rendering. +- For UI changes, verify the affected flow in the simulator/device, including themes, keyboard/scrolling, and navigation. For shared semantics or realtime changes, change the same data from web and confirm mobile catches up without manual refresh, including after reconnect. +- Test parsing/transforms in the existing Vitest setup. Preserve `scripts/ios-run.test.sh` coverage when changing the native build wrapper. +- Report which checks ran and which native/cross-client checks were unavailable. Do not claim visual or release verification from typecheck/unit tests alone. diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md index 53563cc19..43c994c2d 100644 --- a/apps/mobile/CLAUDE.md +++ b/apps/mobile/CLAUDE.md @@ -1,591 +1 @@ -# Mobile App Rules (apps/mobile/) - -For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* section. This file documents the locked tech-stack baseline and the few mobile-specific rules — so AI doesn't suggest outdated alternatives. - -## What mobile may import from `packages/` - -- `import type` from `@multica/core/types/*` (zero runtime coupling) -- Pure functions from `@multica/core/` - -Everything else, mobile writes its own. - -## Pre-flight — before you write any code - -For any new mobile feature / screen / interaction, complete the three steps below in order. **Skipping any step = no code yet** (read-only investigation and answering questions are exempt). This section overrides every other rule in this file. - -### 1. Read the real web/desktop implementation - -Until you can name the relevant code, don't reason from "general experience": - -- `packages/views//` — UI shape, information density -- `packages/core//{queries,mutations,ws-updaters}.ts` — endpoints, cache key shapes, optimistic patches, WS event coverage -- Anything matching `*-display.ts` / `dedupe*` / `coalesce*` / `useMemo(() => transform(raw))` — preprocessing between backend and JSX - -List the **must-agree points**: counts, enums, permissions, cross-cache side effects (e.g. a status change must also refresh inbox), navigation flow. Missing one of these is how the 2026-05-09 inbox duplicate-dot incident happened. - -### 2. Show the user the interaction plan + parity points (≤30s to read) - -Include: - -- What you're about to build (one sentence) -- The container / interaction you propose (after walking the iOS-native > RNR > ask waterfall in §UI components) -- Mental-model parity points pulled from step 1 (example: "counts mirror `deduplicateInboxItems`") -- What UI **must differ** and why (example: "web has a sidebar workspace switcher; mobile puts it in Settings — same switching semantics") -- **Visual baseline check** (this is baseline, not polish): tab bar has icons, every screen has a title, multiple right-side row elements stack vertically, secondary text routes through a type-aware label; place a web screenshot next to a simulator screenshot - -### 3. Wait for an explicit "do it / go / start" before writing code - -"Yes / right / sounds good" ≠ permission to act. "How should we do X?" ≠ permission to act. Only an explicit imperative ("build X / change X / start") triggers code. - -> Detailed rules live downstream: must-agree details in §Behavioral parity; component waterfall in §UI components; data / mirroring rules in §Data layer helpers and §Realtime. Pre-flight is the gate; those are the references. - -## Behavioral parity with web/desktop - -Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open. - -**The four things that must agree:** - -- **Counts / visibility** — same N for the same filter, under identical pagination / coalescing rules. -- **Permissions / access** — mirror the same logic web uses (from `packages/core`); don't re-derive from feel. -- **State enums / transitions** — render every status / priority / inbox type / comment type, with a sensible fallback for unknown values (per "API Response Compatibility" in the root CLAUDE.md). Never silently drop a category. -- **Data identity** — same `id`, same `slug`, same canonical fields. Don't invent ids or normalize differently. - -**When UI must diverge**, write at the divergence point what rule it's mirroring (point at the source function in `packages/core` or `packages/views`) and why mobile renders it differently. A future reader should be able to tell in 30 seconds that the divergence is intentional and find the web-side source of truth. - -### ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed - -**Symptom**: Web sidebar showed "Inbox 1" while mobile rendered 3+ unread dots on the same workspace, same user, same moment. - -**Root cause**: Backend `GET /api/inbox` returns raw rows that include: -1. archived items, and -2. multiple inbox notifications per issue (a comment, a status change, and an assignment on the same issue each create one row). - -Web/desktop run those raw rows through `deduplicateInboxItems` (`packages/core/inbox/queries.ts`) before rendering and before counting unread: -1. filter `archived = true` out -2. group by `issue_id`, keep the newest in each group -3. sort by `created_at` desc - -Mobile's first cut rendered the raw list directly. So a single issue with 3 notifications showed as 3 rows with 3 unread dots, while web showed 1. - -**Fix**: mirror `deduplicateInboxItems` into `apps/mobile/lib/inbox-display.ts`, run mobile's inbox tab through it before rendering and before any counting. - -**Lesson — encode this into your reflexes when adding any new mobile screen that consumes a list endpoint**: - -> Before rendering an API list response, grep `packages/core//queries.ts` and `packages/views//components/*.tsx` for any preprocessing — `dedupe*`, `coalesce*`, `filter*`, `*-display.ts`, `useMemo(() => transform(raw))`. Mirror everything that runs between `useQuery` and the JSX in web/desktop. **Do not assume the backend returns "what should be displayed"** — it usually returns the raw cache shape, and the client is responsible for shaping it. - -This pattern repeats: timeline coalescing (`buildTimelineGroups`), inbox dedup, comment thread flattening, etc. Each one is a behavioral parity hazard if mobile skips it. - -## Tech-stack baseline - -Start minimal. Add to this list when actually adopted — do NOT pre-list libraries. - -- **Expo SDK 55** -- **React Native 0.82** -- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`. -- **TypeScript** strict -- **Expo Router 55** (file-based routing — version aligns with Expo SDK) -- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.) -- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn. **Phased adoption in progress — see `apps/mobile/docs/rnr-migration.md` for the canonical plan, three-tier classification, and Phase 0/1/2/3 status.** -- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener. -- **Zustand** — mobile-local state only. -- **expo-secure-store** — auth token persistence + theme preference (`light` / `dark` / `system`). - -When upgrading any of these, update this list. - -## UI components & theming - -The full plan, file inventory, and migration phases live in `apps/mobile/docs/rnr-migration.md`. The rules below are the durable ones that must survive after the migration completes — read this section first when working on any UI. - -### Hard rule — existing pattern first, defaults first, native waterfall - -Three principles govern every UI decision on mobile. They exist to fight the temptation to recreate things that already exist — which is exactly the trap that produced the current 21 hand-written components and 18 hand-rolled sheets. - -**Principle 1 — existing pattern first.** Before reaching for ANY new component (RNR add, hand-written primitive, new sheet container), grep the mobile codebase for an already-shipped pattern that does the same thing. - -- Building a row → grep `components/inbox/`, `components/issue/`, `components/project/` for an analogous list-row first. -- Building a picker / sheet → check `components/issue/pickers/`, `components/project/pickers/` — there are 8+ pickers; one of them is probably the shape you need. -- Building a status / priority / actor visual → `components/ui/status-icon.tsx`, `priority-icon.tsx`, `actor-avatar.tsx` already exist. Re-use, don't re-skin. -- Composer / form / detail screen layout → `app/(app)/[workspace]/issue/[id]/`, `chat/`, `new-issue.tsx` — copy the structure, don't reinvent. - -If a working pattern exists, **import or copy-adapt it**. If it almost-fits but needs a small extension, extend the existing one (one PR) rather than fork a second variant. Only when no existing pattern fits, proceed to Principle 2. - -Why: every "I'll just write a fresh one" produced one of the 21 legacy components. The codebase already paid the cost of figuring out the iOS-correct shape for inbox rows, picker sheets, status icons — don't re-pay it. - -**Principle 2 — defaults first.** When you use any RNR component, accept its default variant, default size, default spacing, default palette. Do NOT add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. Reaching for shadcn defaults is correct; reaching for a hand-tuned version of them is the failure mode. - -**Principle 3 — iOS native > RNR > discuss.** When you need a new interaction, walk this waterfall in order, stop at the first hit: - -1. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it. - - Text input prompt → `Alert.prompt` - - Confirm / destructive prompt → `Alert.alert` - - Action sheet (one-of-N) → `ActionSheetIOS.showActionSheetWithOptions` - - Date / time → `@react-native-community/datetimepicker` (already installed) - - Image / camera → `expo-image-picker` (already installed) - - Documents → `expo-document-picker` (already installed) - - Share → `Share.share` from `react-native` - - Haptics → `expo-haptics` (already installed) -2. **RNR ships a matching component?** `npx @react-native-reusables/cli@latest add `. Use the default variant/size/palette. -3. **Neither.** **Stop and ask the user.** Don't silently hand-roll a replacement — that's exactly how the pre-migration legacy accumulated. - -### Component placement - -After deciding via the waterfall: - -- **Generic UI primitives** → `components/ui/`. Either RNR `add` output or hand-written with `cva` + `cn()` + semantic tokens + `@rn-primitives/*` building blocks. -- **Domain UI** (anything mentioning issues, priorities, statuses, actors, agents, presence, projects, runs) → `components//`. Composes primitives but isn't generic. - -Never copy the visual shape of an existing hand-written `components/ui/` component as a template if its RNR equivalent exists — most of them are pre-migration legacy. The migration doc tracks which files are legacy and which have been replaced. - -### Theming model — CSS variables + class-based dark mode - -- Source of truth for colors is `global.css` — CSS variables defined under `:root` (light) and `.dark:root` (dark). `tailwind.config.js` maps utilities like `bg-background` to `hsl(var(--background))`, so the same class name resolves to the right color in either mode automatically. -- `darkMode: 'class'` (NOT media-query). We control the mode explicitly so the in-app Settings → Appearance picker (`light` / `dark` / `system`) can override the OS preference. -- The mode is switched by NativeWind's `useColorScheme().setColorScheme(mode)`. Calling it sets the root class; every `bg-foo` / `text-foo` reactively rebinds to the new variable values. No manual className toggling, no re-render dance. -- React Navigation (`expo-router`'s `Stack` headers, modal chrome, drawer) is themed separately by passing `NAV_THEME[isDarkColorScheme ? 'dark' : 'light']` into `ThemeProvider`. Source of `NAV_THEME` is `lib/theme.ts`, which mirrors `global.css` in TypeScript. -- Persistence: the user's choice goes into `expo-secure-store` under the key `theme-preference` (values: `light` / `dark` / `system`). Loaded synchronously at app startup in `app/_layout.tsx` before the first paint; missing key defaults to `system`. -- **When you change a CSS variable in `global.css`, also update `lib/theme.ts`.** They mirror each other. The RNR docs include a prompt template for this sync. - -### What this replaces (and what stays) - -- The old "Visual tokens" approach — hand-transcribed hex values in `tailwind.config.js` — is being **replaced** by the CSS-variable system above. Web tokens are still inspiration only; we do NOT import `packages/ui/styles/tokens.css` (Tailwind v3.4 vs v4 mismatch makes file sharing impractical; isolation is intentional). -- The `cn()` helper at `lib/utils.ts` stays — RNR uses the same one. -- The sheet rule from Lesson 6 below still applies. RNR ships `Dialog` and other modal primitives; use them for **new** sheets. The legacy `sheet-shell.tsx` (RN ``) has been deleted — every long-list / search / form sheet now uses an Expo Router `presentation: "formSheet"` route, which instantiates iOS' `UISheetPresentationController` for native grabber, detents, and spring drag physics. - -## Build & release - -- **Main CI** (`.github/workflows/ci.yml`) excludes mobile via `--filter='!@multica/mobile'`. Mobile failures do NOT block web/desktop PRs. -- **Mobile verify** (`.github/workflows/mobile-verify.yml`): triggered on `apps/mobile/**` or `packages/core/types/**` changes — runs typecheck/lint/test only, no IPA build. -- **Mobile release** (`.github/workflows/mobile-release.yml`): triggered by `mobile-v*.*.*` tag → `eas build` + `eas submit`. -- **OTA** — EAS Update for JS-only fixes that don't change the runtime version. Manual / on-demand push to preview/production channels. - -Mobile release cadence is decoupled from main `v*.*.*` tags (server / CLI / desktop). - -### Local iOS builds go through `scripts/ios-run.sh` - -Every `ios:*` script runs `expo prebuild -p ios` before `expo run:ios`. Keep it that way, and route any new iOS script through the same wrapper. - -`expo run:ios` prebuilds **only when `ios/` is missing** (`ensureNativeProjectAsync` in `@expo/cli`) — when the directory exists it returns early and config plugins never re-run. Without the explicit prebuild, everything `app.config.ts` owns (app icon, bundle identifier, display name, URL scheme, Info.plist permission strings) stays pinned to whatever the first prebuild wrote, while the build still reports success. `ios/` is gitignored and fully generated, so re-prebuilding on every run is safe and idempotent. - -Two things the wrapper depends on: - -- `--no-install` is safe because `run:ios` installs pods itself when `Podfile.lock` is stale. -- `--clean` is avoided on purpose: `expo-build-properties` sets `buildReactNativeFromSource`, so a clean prebuild forces a from-source pod rebuild that is far too slow for the edit/run loop. Run `npx expo prebuild -p ios --clean` by hand when a native dependency changes. - -The wrapper is invoked under the caller's `dotenv`/`cross-env`, so prebuild and run resolve the same `APP_ENV` and bundle identifier. A bare `expo prebuild` inside a prod/staging script would rewrite the project to the dev variant. - -## Realtime / WebSocket strategy - -Mobile uses the same WS server protocol as web/desktop, but mounts subscriptions differently. The rules below exist because mobile-specific constraints (cellular data cost, AppState lifecycle, per-screen unmount cleanup, smaller cache surface) make a direct port of web's pattern wrong. - -### Three-layer stack - -``` -Layer 1 ws-client.ts — single socket, no React. Exponential - backoff with full jitter. Three-state - lifecycle (idle / active / paused) so - the provider can pause on background - and resume on foreground without - racing the auto-reconnect timer. -Layer 2 realtime-provider.tsx — owns the WSClient. Mounts/unmounts on - auth + workspace + AppState + NetInfo - changes. Exposes useWSClient(). -Layer 3 use--realtime.ts — per-feature subscriptions. Translate - events → cache mutations. -``` - -Layer 3 is what changes per feature; layers 1 and 2 are infrastructure and shouldn't be edited when adding event coverage. - -### Mount strategy: list-level global, per-record per-screen - -Mobile **does NOT use a single centralized `useRealtimeSync` hook** like `packages/core/realtime/use-realtime-sync.ts`. That pattern is fine on web (one tab = one mount, lives forever) but on mobile it gets in the way: most events care about a single record (one issue's comments, one chat session's messages), and the hook needs to know which record without prop-drilling. - -Two mount tiers: - -- **Listing-level (always-on for the workspace session)** — mount inside the `` component in `app/(app)/[workspace]/_layout.tsx`. These don't take parameters; they patch caches keyed only on `wsId`. Examples: `useInboxRealtime`, `useMyIssuesRealtime`. Both run from the moment the user enters a workspace until they leave it, regardless of which tab is foregrounded. - -- **Per-record (mounted with id, cleans up on unmount)** — mount inside the screen that owns the record, parameterized by the id from the route. Example: `useIssueRealtime(id, () => router.back())` in `issue/[id].tsx`. The hook filters every event by `payload.issue_id === id` and only patches the current issue's caches. When the user navigates away the `useEffect` cleanup unsubscribes all listeners, so a backgrounded screen doesn't keep mutating caches it no longer owns. - -Don't mount a per-record hook globally to "just be safe" — every filter call on every event then runs N times where N is the number of issues a user has ever opened in this session. - -### Patch over invalidate (cellular-data rule) - -When a WS payload contains the full updated object, **patch** the cache (`setQueryData` / `setQueriesData`). Only fall back to **invalidate** when: - -1. The payload is just an id (we don't know the full new shape — e.g., `issue:created` with no scope context). -2. The cache shape doesn't match what we can patch (e.g., multi-key scope-filtered lists where we'd have to predict membership). -3. The event is rare enough that the extra refetch isn't a real cost (e.g., `issue:deleted` on a list that was about to invalidate anyway). -4. After a reconnect, where we may have missed events while disconnected. - -Web is fine to invalidate generously because most users are on broadband; mobile users on cellular pay for each refetch. A `setQueryData` is free; an `invalidateQueries` is a network roundtrip per affected query key. - -### Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`) - -Mobile has its own `apps/mobile/data/realtime/issue-ws-updaters.ts` even though web has a near-identical file. **Do not import web's updaters into mobile.** Two reasons: - -1. **Key-factory binding.** Web's updaters reference `issueKeys` from `packages/core/issues/queries.ts` — a different runtime instance from mobile's `apps/mobile/data/queries/issue-keys.ts`. TanStack Query compares keys structurally so it *appears* to work, but binding cache mutation to a foreign key factory invites silent drift the moment either side adjusts its key shape (renames a segment, adds a discriminator). -2. **Cache-shape divergence.** Mobile has simpler caches: flat `Issue[]` for my-issues (web has status-bucketed); no children subtree (web does); no label-byIssue cache (web does). Web's updaters carry conditional dead-code for paths mobile doesn't have, and mobile would silently no-op on web shapes that don't exist locally. - -When the same logic needs to exist on both sides, copy the design — not the import. Document the mirror at the top of the mobile file (see `issue-ws-updaters.ts` for the pattern). - -### Event-always-wins (optimistic conflict policy) - -Mutations like `useUpdateIssue` apply an optimistic patch to the detail cache, then the server processes the request and broadcasts `issue:updated`. If a separate WS event (from another client / another user / an agent) arrives between the optimistic patch and the mutation response, the WS handler overwrites the optimistic state with the server's authoritative state. Brief UI flicker is acceptable; correctness wins. - -**Do not** add timestamp-comparison logic to "protect" the optimistic state — the server is the truth and the user benefits from seeing real changes immediately. If a specific event proves problematic in practice, add the gate at that point, not by default. - -### Reconnect handling - -Each hook registers a single `ws.onReconnect(cb)` that invalidates **only the queries it owns**: - -| Hook | Invalidates on reconnect | -|---|---| -| `useInboxRealtime` | `inboxKeys.list(wsId)` | -| `useMyIssuesRealtime` | `issueKeys.myAll(wsId)` | -| `useIssueRealtime(id)` | detail + timeline + attachments + active tasks + task history for that issue | - -No global "invalidate everything on reconnect" sweep. The fanout would be every screen the user has ever visited in this session refetching simultaneously — wasteful on cellular and prone to rate-limiting the server in low-signal areas where reconnects happen frequently. - -### Cross-cutting cache patches across features - -Some events legitimately need to mutate a foreign feature's cache. The -canonical example: `issue:updated` changing an issue's status must also -update the StatusIcon shown on the matching inbox row, and `issue:deleted` -must strip every inbox row pointing at the dead issue. - -The pattern: - -1. **The feature whose cache is being patched owns the updater.** Example: - `apps/mobile/data/realtime/inbox-ws-updaters.ts` exports - `patchInboxIssueStatus` and `dropInboxItemsByIssue` — they live with - inbox, not with issues, because they read `inboxKeys.list(wsId)`. -2. **That feature's realtime hook subscribes to the foreign event.** - `use-inbox-realtime.ts` subscribes to `issue:updated` and `issue:deleted` - alongside the `inbox:*` events. The issue-realtime hook does NOT know - that inbox cares. -3. **Mirror web's wiring.** Web's `packages/core/inbox/ws-updaters.ts` has - the same handlers; mobile copies the design. Behavioral parity hazard: - without these the mobile inbox row keeps showing the prior status (or - 404s on tap if the issue is gone) while web users see the change live. - -If you find yourself reaching across features in `use-issues-realtime` to -patch something else, you have the inversion: move the updater to the -patched feature and subscribe there. - -### Adding new event coverage — recipe - -1. **Read the payload.** Find the event in `@multica/core/types/events.ts`. Note the fields; decide if patch is possible (full object) or invalidate is required (just an id). -2. **Mirror, don't import.** If web has an updater for this event in `packages/core//ws-updaters.ts`, copy the design into `apps/mobile/data/realtime/-ws-updaters.ts`. Adapt to mobile's actual cache shapes — don't carry web's bucket/children/childProgress dead-code if mobile doesn't have those caches. -3. **Subscribe in a hook.** Either extend an existing `use--realtime.ts` or create a new one. Filter by id at the top of each handler so per-record hooks ignore unrelated events. -4. **Mount it.** Listing-level → add to `` in workspace `_layout.tsx`. Per-record → add to the owning screen's body, parameterized by the route id. -5. **Add reconnect invalidate.** Single `ws.onReconnect()` call scoped to the hook's own keys. -6. **Verify cross-client.** Open the affected screen on mobile, change the same record from a second client (web or another device), confirm mobile updates within ~500ms without pull-to-refresh. - -If a new event has no consumer on mobile (e.g., `subscriber:added` when mobile doesn't render subscriber lists yet), **don't subscribe**. Mounting a listener with no UI consumer adds CPU on every fire for zero user benefit. - -## Data layer helpers (use these — don't recreate them) - -Common boilerplate is wrapped. New code that reinvents these helpers is a -review-block, both because it makes the codebase inconsistent AND because -the helpers encode subtle correctness rules (signal forwarding, schema -fallback, sync-before-await ordering, type-safe payloads). - -### Three rails that every feature must follow - -1. **Logic mirrors web/desktop.** See §Pre-flight step 1 at the top of - this file. Restating the data-contract half here: endpoints, request - bodies, response schemas, optimistic patches, and cache key prefixes - all match web verbatim. UI / interaction can diverge freely per - §Behavioral parity. - -2. **Use the existing components — no new primitives.** Walk the - `iOS native > RNR > discuss` waterfall in §UI components. If RNR ships - it, `npx @react-native-reusables/cli@latest add `. If iOS ships - it (Alert / ActionSheetIOS / Haptics / share / picker), use it directly. - If neither has it AND it's a single-screen need, inline compose with - `` + `` + tokens. **Do NOT create a new generic - primitive in `components/ui/` for one or two callers** — the migration - doc lists "21 hand-written components" as exactly the trap we're - escaping. Threshold for a new primitive is three callers AND no - RNR/iOS-native alternative. - -3. **Use the wrapped request / WS layer.** See the helper map below. - -### API client: `fetchValidated` + `fetchValidatedWith` - -`apps/mobile/data/api.ts` exposes two private helpers on `ApiClient` that -collapse the fetch + parseWithFallback envelope. **Every new read-side -method that returns a typed body must use them.** - -| Helper | When to use | Shape | -|---|---|---| -| `this.fetchValidated(path, schema, fallback, opts?)` | GET endpoints | One-liner method body — see `getMe`, `listInbox`, `getNotificationPreferences` | -| `this.fetchValidatedWith(path, schema, fallback, init, opts?)` | Any HTTP method (PATCH / PUT / POST) whose response is consumed | Carries the body via `init.body` + method; signal forwarding handled | -| `this.fetch(path, init?)` directly | Writes whose response is `{ count }` / `void` / not consumed by UI logic | Only here is a raw `as T` acceptable, because the value never reaches a render path | - -Rules: -- The fallback object MUST match the success type exactly so downstream - code never has a partial value (see `EMPTY_USER` / `EMPTY_INBOX_LIST` - pattern in `apps/mobile/data/schemas.ts`). -- The `endpoint` label is for telemetry — defaults to the path; override - only when the path has dynamic segments and you want stable groupings - (`GET /api/issues/:id` not `GET /api/issues/abc-123`). -- Migration is progressive: not every legacy method is converted yet. - Adding a new method? Use the helpers. Touching an old method that - isn't using them? Convert it as part of the same PR. - -### Query / mutation factory pattern - -Every workspace-scoped feature exposes a key factory in -`apps/mobile/data/queries/.ts`: - -```ts -export const inboxKeys = { - all: (wsId: string | null) => ["inbox", wsId] as const, - list: (wsId: string | null) => [...inboxKeys.all(wsId), "list"] as const, -}; -``` - -Three-segment shape matches web (`packages/core/inbox/queries.ts`). -Reasons: - -- TQ does prefix matching by default — `invalidateQueries({ queryKey: - inboxKeys.all(wsId) })` invalidates the list AND any future sub-keys - (e.g. a `detail(id)`) under the same prefix. Use `.all` to clear a - workspace cleanly, `.list` to target the list specifically. -- Cross-platform mental-model parity: a reader switching between mobile - and web finds the same key shape. -- Stops bare `["inbox", wsId]` strings from spreading. Grep - `\["inbox"` in this codebase should only hit the factory file. - -Mutations import the factory and use `inboxKeys.list(wsId)` everywhere — -never inline strings. - -### WS layer: `ws.on()` + `useWSSubscriptions` - -Two helpers replace ~20 lines of boilerplate per realtime hook: - -1. **`ws.on(event, handler)`** — the handler's - `payload` parameter is auto-typed to `WSEventPayload`. **Do not - add `as XxxPayload` casts at handler bodies** — they're redundant - and (worse) silently hide drift if `WSEventPayloadMap` shifts. - The cast is only acceptable when one handler covers multiple events - that don't share a typed common ancestor (see `onTaskEvent` in - `use-issue-realtime.ts` — `task:progress` has no formal payload). -2. **`useWSSubscriptions(setup, deps)`** in - `apps/mobile/lib/use-ws-subscriptions.ts` — wraps the - `if (!ws || !wsId) return; useEffect + cleanup` template. Setup - callback receives `(ws, wsId)`, returns the unsub array (or - `undefined` to short-circuit, e.g. when a per-record id is missing). - -Adding a new event type? Extend `packages/core/types/events.ts`: - -1. Add the event to the `WSEventType` union. -2. Add the payload interface. -3. Add the `WSEventType → payload` entry in `WSEventPayloadMap`. - -Forgetting step 3 means callers get `unknown` (loud — they have to -narrow), not `any` (silent unsafe access). That's the safety net. - -### Synchronous setQueryData before `await cancelQueries` - -Optimistic mutations that flip state read by a UI element that's about -to be in a navigation snapshot (the classic case: marking an inbox row -read, then `router.push` to the issue) MUST call `setQueryData` in -`onMutate` **before** `await qc.cancelQueries(...)`. The await yields -one microtask; iOS captures the source-view snapshot during that gap and -freezes the row in its unread style inside the slide-in transition. - -Lives inside the mutation, not the caller. See `useMarkInboxRead.onMutate` -in `apps/mobile/data/mutations/inbox.ts` for the canonical example. - -### Checklist for a new feature - -Before opening a PR for a new screen / mutation / realtime hook: - -1. Grep `packages/core//` for the web equivalent — endpoints, - key shape, optimistic patch shape. Mirror, don't invent. -2. API methods → `fetchValidated` / `fetchValidatedWith` (or raw - `this.fetch` only for writes with no consumed response). -3. Query key → factory in `data/queries/.ts`, 3-segment shape. -4. Mutations → optimistic only when the post-state is locally predictable, - the user stays on the same screen, failure is rare, and rollback is - trivial. When that gate passes, use snapshot → patch → rollback + - settle invalidate, all keys via factory. Create/delete/navigate/confirm - flows await the server instead. -5. Realtime → `useWSSubscriptions(setup, deps)`, typed `ws.on()`, - per-event patching (no global invalidate) when payload carries the - full object. -6. UI → waterfall (iOS native > RNR > inline compose). No new - `components/ui/` primitive unless three callers + RNR doesn't ship. -7. Verify cross-client: change the same record from web and confirm - mobile updates within ~500ms without pull-to-refresh. - -## Lessons learned (encode into reflexes) - -These are real mistakes that have been made building the mobile shell. Each one cost time to find. Treat as enforceable rules, not suggestions. - -### 1. Install/upgrade any dependency: check `dist-tags` first - -Do NOT hardcode version numbers from memory. Run `pnpm view dist-tags` to see `latest / sdk-XX / canary` and decide which tag to lock. For Expo packages (`expo-*` / `react-native-*` that Expo aligns), use `pnpm exec expo install ` — it queries Expo's dependency manifest and picks the SDK-compatible version. `pnpm add ` will silently install the npm `latest`, which often outpaces the SDK and breaks at runtime. Past mistakes: hardcoded `expo@~54.0.0` (latest was already `55.x`); installed `lucide-react-native@0.468` without checking React 19 peer compatibility. - -### 2. New source subdirectory: verify git tracking - -Every time you create a new source subdirectory under `apps/mobile/` (e.g. `data/`, `lib/foo/`, `components/inbox/`): - -1. Run `git check-ignore -v /` immediately. The repo-root `.gitignore` has generic rules (`data/`, `build/`, `bin/`, `*.app`, `*.dmg`) that are intended for backend runtime/output dirs but will silently swallow mobile source. -2. If a rule matches, add `!/` and `!/**` to `apps/mobile/.gitignore` (subtree override beats parent rule). -3. After the commit lands, run `git ls-files ` to confirm every file is tracked. - -This rule exists because `apps/mobile/data/` was once committed-but-not-tracked — 14 source files (ApiClient, all queries, all stores) were missing from the git tree even though `git status` was clean. Local builds worked because Metro reads the filesystem; CI / clones would have died. - -### 3. ApiClient capability list (4 must-haves) - -Mobile's fetch wrapper (`apps/mobile/data/api.ts`) MUST implement all four. Missing any of them is a bug, not a deferred polish item. - -1. **Zod `parseWithFallback` for response validation.** Strictly enforced by the root CLAUDE.md "API Response Compatibility" section and the "Type drift defense" section above. **Any new endpoint method that does `as T` on the response body is a bug.** Reuse schemas from `packages/core/api/schemas.ts` (pure Zod exports, on the mobile sharing whitelist); define mobile-side fallbacks for new endpoints in `apps/mobile/data/`. - -2. **`onUnauthorized` 401 callback.** The `ApiClientOptions.onUnauthorized` hook fires on every 401 and must be wired in `app/_layout.tsx` to: clear auth token, clear workspace store, clear TanStack Query cache, navigate to `/login`. Without it a session that expired server-side puts every subsequent request into a 401 loop and the user sees opaque "API error: 401" toasts on every screen. Use a `signingOutRef` to make the callback idempotent — multiple in-flight requests will all 401 simultaneously when a session expires. - -3. **`X-Request-ID` per request.** Generate a short random ID (`createRequestId()` in `apps/mobile/lib/request-id.ts`), send as `X-Request-ID` header. The same ID goes into client-side log lines so backend telemetry can be cross-referenced (server picks it up via the same header). - -4. **Structured request logger.** Two log lines per request: `[api] → METHOD path` (start, with `rid`) and `[api] ← STATUS path` (end, with `rid` + `duration`). Use `console.error` for 5xx, `console.warn` for 404s, `console.log` for success. Without this, debugging mobile API issues means staring at the React Native Network panel; with it, the dev console is self-explanatory and prod telemetry already comes structured. - -**What mobile correctly does NOT need (don't add these):** CSRF token (`X-CSRF-Token`), `credentials: "include"`, cookie reading. Mobile is Bearer-token auth, not cookie auth — the cookie attack surface that requires CSRF protection on web doesn't exist on mobile. - -### 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout - -**Symptom that triggered the rule (2026-05-11)**: Inbox screen sometimes returned to the foreground showing the FlatList pull-to-refresh spinner stuck indefinitely. List items were rendered underneath, but `isRefetching` never flipped back to `false`. Pull-to-refresh, navigating away, and re-opening the tab did not clear it. - -**Root cause**: `apps/mobile/data/api.ts`'s `fetch()` had no timeout, no `AbortController`, and no caller-`signal` plumbing. iOS suspends backgrounded apps within ~30 seconds and can silently kill in-flight network tasks (facebook/react-native#35384 — "iOS fetch() POST fails if called too soon, with app running in background"; facebook/react-native#38711 — "JS Timers don't fire when app is launched in background"). When the app foregrounded, the suspended fetch's Promise neither resolved nor rejected. TanStack Query saw an existing query still in `fetching` state and did NOT start a new fetch on invalidate — it just waited on the dead Promise forever. `isRefetching` stayed `true`, the FlatList spinner stayed spinning. - -**Rule, three parts (every one is required — partial fixes leave a footgun)**: - -**1. `api.ts` `fetch()` MUST have a hard timeout** (currently 30s; the `FETCH_TIMEOUT_MS` constant). Without this, a single suspended request can wedge a query indefinitely. Use a manual `AbortController` + `setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)` — **DO NOT** use `AbortSignal.timeout()`: Hermes throws `TypeError: AbortSignal.timeout is not a function` (facebook/react-native#42042). Same for `AbortSignal.any()` — Hermes does not implement it (livekit/livekit#4014). To combine the timeout signal with a caller-supplied signal, attach an `"abort"` event listener manually and forward to the inner controller. - -**2. Every read-side `api.ts` method MUST accept `opts?: { signal?: AbortSignal }` and pass it to `fetch()`**. Mutations don't need this (TanStack Query doesn't pass a signal to `mutationFn`). The pattern: -```ts -async listInbox(opts?: { signal?: AbortSignal }): Promise { - return this.fetch("/api/inbox", { signal: opts?.signal }); -} -``` -Adding a new query-bound method without `opts` is a bug — the next person who writes a `queryFn` will silently drop the signal. - -**3. Every `queryFn` MUST forward the signal it receives from TanStack Query**. The official TanStack guide (tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) states: "When a query becomes out-of-date or inactive, this `signal` will become aborted." The pattern: -```ts -queryOptions({ - queryKey: [...], - queryFn: ({ signal }) => api.listInbox({ signal }), -}); -``` -Forgetting the destructure (writing `() => api.listInbox()`) defeats every benefit of (1) and (2): TQ can't cancel hung requests when the user navigates away, and on workspace switch every stale request lives until its 30s timeout. - -**Verification**: After any change to `api.ts` or a new query addition, `grep -n "queryFn: () =>" apps/mobile/data/queries/` should return zero matches. Every `queryFn` should destructure `{ signal }`. - -**Why the wiring already in `data/query-client.ts` (focusManager + AppState, onlineManager + NetInfo) is not enough on its own**: focusManager triggers a *refetch attempt* when the app comes back to the foreground, but if the prior fetch promise is hanging, TQ won't start a new request — it'll keep waiting on the dead one. Only timeout + signal cancellation actually unwedges the query. The three pieces work together: signal lets TQ proactively cancel on staleness, timeout is the safety net when nothing else fires, focusManager is the "user came back, let's recheck" trigger. - -### 5. Modal container selection: match container to content, don't copy the first sheet - -The mobile codebase started with ~15 Modal sheets. They almost all copied the same shape (`Modal transparent fade` + hand-drawn `bg-black/40` backdrop + centered/bottom card with `maxHeight`). That shape is correct for **short action menus** (the earliest sheets), wrong for **everything else**. Once the pattern was established as "the mobile sheet style," subsequent sheets inherited it regardless of content — and inherited a different bug each time: keyboard squashing the card, `maxHeight: 380` clipping FlatLists on tall phones, `useSafeAreaInsets` returning 0 inside Modal so bottom content collides with the Home Indicator, etc. - -**Choose the container by content type, not by "what the last sheet did":** - -| Content shape | Container | Why | -|---|---|---| -| < 5 fixed actions, 1-2s stay, no keyboard | `Modal transparent` + bottom action card | Short, light, dim-backdrop tap-to-dismiss is correct here | -| Yes/No or one-tap confirm | `Alert.alert` | Native, accessible, no custom UI | -| One-of-N from a server-driven short list | `ActionSheetIOS.showActionSheetWithOptions` | Native iOS action sheet, no custom UI | -| < 7 fixed picker options, no search | `Modal transparent` + small centered card | Same as action card, just centered | -| Long list / search box / content view / form / anything with a keyboard | **Expo Router `presentation: "formSheet"` route** | Instantiates iOS `UISheetPresentationController`: native grabber, drag-dismiss with spring physics, stacked-card backdrop, detents — all UIKit-managed | -| Multi-screen flow / route-level full modal | Expo Router `presentation: "modal"` | Full-page slide-up, has back-stack, swipe-dismiss, deep-linkable | - -**`SheetShell` is deleted.** It was a wrapper around RN core `` which does NOT instantiate `UISheetPresentationController` — so it never had native grabber, stacked-card backdrop, or real spring physics. Every former SheetShell call site is now an Expo Router formSheet route. - -**Rules for adding a new formSheet route:** - -1. **File goes under the parent context** so the URL reads sensibly — issue-detail pickers under `app/(app)/[workspace]/issue/[id]/picker/.tsx`; project pickers under `project/[id]/picker/.tsx`; transient action sheets under `//actions.tsx`. The new-issue draft flow has its own `new-issue-picker/.tsx` directory because routes can't share state with the modal that opened them — see the draft-store discussion below. -2. **Register the Stack.Screen in `app/(app)/[workspace]/_layout.tsx`** using the shared `SHEET_OPTIONS` constant. Do NOT inline the config per screen — every picker-row sheet must look and feel identical (grabber, detents, corner radius). Isolated sheets that have no neighbour to be consistent with may override `sheetAllowedDetents` only (e.g. the `menu` sheet uses `"fitToContents"` because it's ≤ 5 fixed actions and the two-snap default would leave 60% blank). -3. **Self-contained route bodies.** A picker route reads the record it needs from the TanStack Query cache (issue / project / timeline are already cached when the user gets there), calls its own mutation on submit, and `router.back()`s. No callbacks back up to a parent. The only legitimate exception is the new-issue draft flow, which uses `useNewIssueDraftStore` because the issue doesn't exist yet — there's nothing in cache to read. -4. **Header is drawn inside the body**, not by the Stack. SHEET_OPTIONS sets `headerShown: false`; the body renders its own `` with title + optional right action. The native Stack header on a formSheet creates a layout dance with the grabber that doesn't match iOS sheets. - -**SHEET_OPTIONS rationale (every value exists for a known bug or platform behavior):** - -- `presentation: "formSheet"` — the magic that hands the screen to `UISheetPresentationController`. -- `sheetGrabberVisible: true` — the iOS native drag handle. Users don't discover the gesture without it. -- `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55 (expo/expo#42904 padding inconsistency, #42965 zero-size). Predictable two-snap presentation across every picker-row sheet is more important than shrink-wrapping; every formSheet that lives in a chip row (issue-detail / project-detail AttributeRow) uses these explicit detents so muscle memory carries across the row. Isolated sheets (no chip-row neighbour) override with `"fitToContents"` — see the workspace `menu` sheet for the canonical example. -- `sheetCornerRadius: 20` — matches RNR card radius. Without this iOS uses a larger system default that's slightly out of sync with the rest of the app. -- `contentStyle: { height: "100%" }` — safety net against the zero-size class of bugs above. Ensures the sheet body fills the allotted detent height. - -**Caveats that still apply:** - -- **Android falls back to a regular modal** — no rounded corners, no native drag. mobile/CLAUDE.md treats iOS as the primary target so this is acceptable, but document inline at the call site if a particular feature must work identically on both. -- **A formSheet pushed from inside a `presentation: "modal"` route is supported** by Expo Router 55 / RN Screens 4, but the back gesture from the formSheet returns to the modal, not the underlying tab. This is the right UX for the new-issue draft flow (sheet dismisses back to the form), but check the navigation graph if you're adding a sheet under a non-obvious parent. - -**Carve-out — picker-row consistency wins over per-container optimisation:** - -The table above says "< 7 fixed picker options → centered card". That rule -applies in isolation, but **breaks down when multiple pickers coexist in -the same chip row** (issue-detail AttributeRow is the canonical case: -status / priority / assignee / label / project / due-date all sit next -to each other). Mixing centered cards (for status/priority, short -fixed lists) with formSheet routes (for assignee/label/project, long -lists) means the user gets two different gestures depending on which -chip they tap — there's no muscle-memory carry-over. - -When you find yourself building a row like this, **use the formSheet -route for every picker in the row**, even the ones a standalone -centered card would handle fine. The cost is some empty space below -5–7 short rows; the gain is uniform tap → slide-up-sheet + -drag-down-to-dismiss behaviour across the whole row. Linear iOS / -Things 3 / Apple Reminders all do this for the same reason. - -The centered-card pattern stays correct for **isolated short menus** -(e.g. the chat-composer's "More" popover, the timeline's coalesce- -expand) where there's no neighbour to be consistent with. - -### 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic - -iOS Mail / Linear iOS / Things: leftward swipe reveals a red Archive -button; the user **must tap it** to commit. The earlier mobile inbox -swipe auto-fired on full drag past the threshold and "felt wrong" — no -peek, easy to trigger by accident on a fast vertical scroll that -catches some horizontal motion. There is no native UX that auto-commits -a destructive action on swipe — match the platform standard. - -The rule: - -- `ReanimatedSwipeable` with `renderRightActions={}`. -- **No `onSwipeableOpen` auto-fire.** Drag → reveals the action; release - past threshold → action stays revealed; tap action → commit; tap - outside or drag back → cancel. -- One-shot `Haptics.impactAsync('medium')` when the drag crosses the - action width. Wire via `useAnimatedReaction(() => drag.value <= -ACTION_WIDTH, ...)` - + `runOnJS(Haptics.impactAsync)`. The shared-value reaction runs on - the UI thread; `runOnJS` bridges to the JS-only Haptics call. - -See `apps/mobile/components/inbox/swipeable-inbox-row.tsx` for the -reference implementation. When adding a new swipe-to-action row -elsewhere, copy that pattern; do not reinvent. - -### 7. Tier C domain components: opportunistic upgrade only — no silent rewrites - -Tier C in `apps/mobile/docs/rnr-migration.md` §4 names the domain UI -files that stay where they are but need foundation upgrades -(`ActorAvatar`, `StatusIcon`, `PriorityIcon`, `PresenceDot`, etc.). -**You don't rewrite a Tier C file just because you're rendering it in -your new feature.** That spreads scope and stalls feature PRs. - -Two rules: - -1. **Touch only what your PR needs to touch.** If `ActorAvatar` has - hardcoded `#71717a` and you're building an inbox feature that - *uses* ``, leave the hex alone. Note it for a future - doc / cleanup PR. -2. **Upgrade Tier C only when you're modifying that file for a - different real reason.** E.g. adding presence to chat header → you - were going to touch `` anyway → fold the RNR-Avatar - migration + hex → token cleanup into the same PR. - -The pre-migration legacy persists because someone "while I'm in -here…"-style touched 21 files in one PR; we don't do that anymore. -Document any Tier C smells you spotted in the PR description as -follow-ups; surface for a future grouped Tier C cleanup PR. +@AGENTS.md diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 2fdc1607a..e2e22b157 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,6 +1,6 @@ # Multica Mobile (iOS) -Expo + React Native iOS client for Multica. Independent from web/desktop — shares only types from `@multica/core/`. See [`CLAUDE.md`](./CLAUDE.md) for the locked tech-stack baseline and import rules. +Expo + React Native iOS client for Multica. Independent from web/desktop — shares types and pure utilities from `@multica/core/`. See [`AGENTS.md`](./AGENTS.md) for mobile architecture and development rules; `package.json` records the current dependency versions. ## Just want to use it on your phone? (no development) diff --git a/apps/mobile/docs/markdown-rendering-adr.md b/apps/mobile/docs/markdown-rendering-adr.md index e9f6c2029..26e474c62 100644 --- a/apps/mobile/docs/markdown-rendering-adr.md +++ b/apps/mobile/docs/markdown-rendering-adr.md @@ -255,4 +255,4 @@ React-tree renderer: - `apps/mobile/lib/markdown/markdown-style.ts` — `useMarkdownStyle()` theme bridge - `apps/mobile/lib/markdown/code-block.tsx` — Shiki-powered code segment - `apps/mobile/lib/markdown/markdown-image.tsx` — lightbox-aware image segment -- `apps/mobile/CLAUDE.md` — mobile-wide rules including theme/CSS-variable system +- `apps/mobile/AGENTS.md` — mobile-wide rules including theme/CSS-variable system diff --git a/apps/mobile/docs/rnr-migration.md b/apps/mobile/docs/rnr-migration.md deleted file mode 100644 index 647e1c010..000000000 --- a/apps/mobile/docs/rnr-migration.md +++ /dev/null @@ -1,310 +0,0 @@ -# RNR migration — design, alternatives, and three-phase rollout - -**Status**: Phase 0 (research & docs) — complete. Phase 1 (base infrastructure) — complete: `global.css` CSS variables, `tailwind.config.js` `darkMode: "class"` + `hsl(var(--))` mappings, `lib/theme.ts`, `lib/use-color-scheme.ts`, `components.json`, `ThemeProvider` + `PortalHost` in `app/_layout.tsx`, and the Settings → Appearance picker are all shipped. Phase 2 (canary component) — not started. - -**Audience**: anyone touching `apps/mobile/components/ui/` or adding new UI to the mobile app. Read this once; refer to `apps/mobile/CLAUDE.md` "UI components & theming" for the durable rules. - ---- - -## 1. Why this doc exists - -> **Baseline note.** Everything in this section describes the state **before Phase 1**, which is the problem this migration was written to solve. Phase 1 has since shipped — see the Status line above and §6. The theming half of this list is fixed; the component half is not. - -The mobile baseline (`apps/mobile/CLAUDE.md`) has named **react-native-reusables (RNR)** as the shadcn equivalent since SDK 55 was bootstrapped. RNR was never actually installed. As a result, at the time of writing: - -- `apps/mobile/components/ui/` contained **21 hand-written components, ~1,379 lines**, all built from raw ``. *(Still true — Phase 2/3 work.)* -- `apps/mobile/components/` contained **18 hand-written sheet/modal files**, all copying the same shape (`Modal transparent fade` + hand-drawn backdrop). CLAUDE.md Lesson 6 already documents that this pattern is wrong for most content and has produced a series of bugs (keyboard squashing, `maxHeight` clipping FlatLists, `useSafeAreaInsets` returning 0 inside `Modal`). *(Partly resolved — `sheet-shell.tsx` is deleted and long-list/form sheets are now Expo Router `formSheet` routes.)* -- There was no dark/light theming infrastructure: `tailwind.config.js` used hard-coded hex values, `global.css` had only the three `@tailwind` directives, and there were no CSS variables, no `darkMode`, and no theme switcher. **✅ Resolved in Phase 1** — `global.css` now defines `:root` / `.dark:root` variable blocks, `tailwind.config.js` sets `darkMode: "class"` with `hsl(var(--))` mappings, and Settings → Appearance switches Light / Dark / System. - -This doc records why we migrated to RNR, what alternatives we evaluated, and how the migration is sequenced. - -## 2. Alternatives considered - -Evaluated against four criteria: (a) fit with our existing stack (NativeWind 4, Tailwind 3.4, Expo SDK 55, React 19), (b) ownership model (lock-in vs copy-paste), (c) bundler/compiler cost, (d) accessibility baseline. - -| Library | Stack fit | Ownership | Build cost | a11y | Verdict | -|---|---|---|---|---|---| -| **react-native-reusables (RNR)** | NativeWind 4 + RN-Primitives + CVA — **identical to ours** | copy-paste, code is yours | zero (just CSS + JSX) | RN-Primitives (focus management, ARIA equivalents) | **Selected** | -| Tamagui | Own compiler + own styled API, not NativeWind | Library lock-in | Babel/bundler config + learning curve | Built-in | Rejected — main value is web+native same-codebase (we don't need it; mobile is independent) | -| Gluestack UI v2 | Own styled API; can be made to work with NativeWind but it's not native | copy-paste possible | low | `@react-native-aria` (strong) | Rejected — switching styling system has no payoff once you already have NativeWind running | -| NativeBase / RN Paper / RN UI Lib | Mature but library-bound | locked | low | varies | Rejected — copy-paste philosophy matters for a long-lived installed app where we may need to patch components without waiting for upstream | - -RNR's "components are yours" model also aligns with the desktop/web pattern (`packages/ui/components/ui/` is shadcn copy-paste). One mental model across the codebase. - -## 3. Decision — RNR - -We adopt RNR with the following commitments. The first two are **principles** that govern every PR in the migration and every UI decision after: - -### 3.1 Defaults first - -When using any RNR component, accept its default variant, default size, default spacing, default palette. Don't add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. The hand-written legacy exists precisely because someone reached for a "slightly improved" version of a standard primitive — recreating that pattern with RNR underneath defeats the migration. - -Concrete consequences: -- Phase 1 uses shadcn's default neutral palette as-is (light + dark). Multica's existing custom tokens (`brand`, `success`, `warning`, `info`, `priority`, `code-surface`) are appended but **dark-mode values are not authored ahead of demonstrated need** — they copy their light values until a screen using them actually breaks in dark mode. -- Phase 2/3: when `npx @rnr/cli add ` writes a file, do not immediately tweak its styles. Use it as-is; adjust callers if API differs. -- Tier C "foundation upgrade" means swap raw `` for RNR's `Text` and swap inline conditionals for `cva` — it does NOT mean redesign or add variants the component didn't have before. - -### 3.2 iOS native > RNR > discuss - -When adding a new interaction, walk this waterfall and stop at the first hit: - -1. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it. -2. **RNR ships a matching component?** `npx @rnr/cli add `. Use default variants. -3. **Neither.** Stop and ask the user. Don't silently hand-roll. - -The native-API tier is what §4 Tier B's revised classification depends on — many existing sheets disappear entirely when we use `ActionSheetIOS` / `Alert.prompt` / native datetime picker instead of a custom `Modal`. - -| Scenario | Native API | -|---|---| -| Text input prompt (single field) | `Alert.prompt(title, msg, callback)` | -| Confirm / destructive prompt | `Alert.alert` | -| One-of-N action sheet | `ActionSheetIOS.showActionSheetWithOptions` | -| Date / time picker | `@react-native-community/datetimepicker` (installed) | -| Image / camera | `expo-image-picker` (installed) | -| Documents | `expo-document-picker` (installed) | -| Share | `Share.share` from `react-native` | -| Haptics (add to confirm / error / select) | `expo-haptics` (installed) | - -### 3.3 Theming - -Class-based dark mode (`darkMode: 'class'`) + CSS variables, with an in-app `light` / `dark` / `system` picker stored in `expo-secure-store`. RNR's default Tailwind config uses class mode; this matches our need for an explicit user setting (a media-query-only setup would not let the user override the OS). - -### 3.4 Three-tier component classification (see §4) - -We are not blanket-migrating every file. Some legacy components are domain UI, not generic primitives; they stay where they are but use RNR's foundation (the `Text` component, semantic tokens, CVA variants). - -### 3.5 Hard rule (lives in CLAUDE.md) - -**New components come from RNR — or from the native-API tier first.** The migration removes legacy hand-written variants; the rule prevents accumulating more. - -## 4. Component inventory & three-tier classification - -### Tier A — direct RNR replacement (migrate) - -These are generic primitives where RNR ships a near-identical equivalent. Replace the hand-written file with `npx @react-native-reusables/cli@latest add ` output, then sweep callers. - -| Current file | Lines | RNR component | Notes | -|---|---|---|---| -| `components/ui/button.tsx` | 63 | `button` | Canary for Phase 2 | -| `components/ui/input.tsx` | 9 | `input` | Trivial | -| `components/ui/text-field.tsx` | 34 | `input` + `label` | Composed via RNR | -| `components/ui/card.tsx` | 36 | `card` | Drop-in | -| `components/ui/text.tsx` | 18 | `text` | Used everywhere; do this carefully (sweep all imports) | -| `components/ui/autosize-textarea.tsx` | 89 | `textarea` | Check parity — auto-grow behaviour may need re-implementation on top of RNR's textarea | -| `components/ui/otp-input.tsx` | 68 | (no RNR equivalent today) | Keep using `input-otp-native`, move under Tier B | -| `components/ui/modal-close-button.tsx` | 25 | n/a | Trivial — folds into the `Dialog` close pattern after sheets migrate | - -Total: ~270 lines to replace. ~4 hours of work including caller sweeps. - -### Tier B — sheets / modals (apply the §3.2 waterfall) - -This tier is where the biggest payoff lives (Lesson 6 in CLAUDE.md catalogues the recurring sheet bugs). Applying the iOS-native > RNR > discuss waterfall, the 18 sheets split three ways: - -**B.1 — replaced by a native API (file deletes outright)** - -| Current sheet | Replacement | Why | -|---|---|---| -| `components/issue/comment-action-sheet.tsx` | `ActionSheetIOS.showActionSheetWithOptions` | One-of-N action menu — exactly what ActionSheetIOS is for. Recommended Phase 3 starter (visible deletion, no styling questions). | -| `components/issue/pickers/due-date-picker-sheet.tsx` | `@react-native-community/datetimepicker` inline picker | Date selection — native API already installed | - -**B.2 — replaced by formSheet routes (done)** - -The original plan was to swap each picker-sheet for an RNR `Select`. The -mobile-sheet-rollout PR series instead converged on a different shape: -every former picker-sheet now ships as a pure `` component -under `components//pickers/`, embedded inside an Expo Router -formSheet route at `app/(app)/[workspace]//picker/.tsx`. -This gives the iOS UISheetPresentationController-native chrome -(grabber + detents + spring drag-dismiss) without the per-callsite -state and visibility prop dance an RNR `Select` would still require. - -Files in this row are all deleted; their bodies + routes live under the -paths above. No follow-up needed. - -**B.3 — genuinely needs a custom-content sheet (RNR `Dialog` pageSheet)** - -| Current sheet | Why this one stays as a Dialog | -|---|---| -| `components/issue/issue-filter-sheet.tsx` | Multiple controls in one sheet (filter form), not a list select | -| `components/issue/runs-sheet.tsx` | Run history with rows + actions, not a one-of-N | -| `components/chat/session-sheet.tsx` | TBD — re-inspect when reached; may belong in B.2 | -| `components/chat/agent-picker-sheet.tsx` | Likely B.2 (`Select`) — re-inspect | -| `components/project/add-resource-sheet.tsx` | TBD — depends on whether it's single-select or a mini form | - -**B.4 — RNR doesn't ship** - -Empty. The only entry — `components/issue/emoji-picker-sheet.tsx` — was -resolved by adopting `rn-emoji-keyboard` and migrating the comment -reaction flow to a formSheet route at -`app/(app)/[workspace]/issue/[id]/comment/[commentId]/emoji-picker.tsx`. -Mobile now ships the full emoji set behind the "More reactions" overflow -in the per-comment actions sheet, matching web parity. - -**Rules**: -- Do not bulk-replace `sheet-shell.tsx`. It is imported by 18 files; an atomic swap = 18 simultaneous breakages. Per CLAUDE.md Lesson 6: one PR per sheet, one verification per PR. Sequencing tracked in `~/.claude/plans/mobile-sheet-rollout.md`. -- B.1 sheets are deletions — they should go first, because each one removes a file with zero replacement code (just call `ActionSheetIOS` from the parent). Compound win: less code + matches "defaults first" + matches "iOS native first". -- B.2 sheets simplify the callsite (open a `Select` instead of pushing a sheet), but the imported component IS new code. Do these after B.1. -- B.3 sheets keep their structural complexity; the migration is mostly swapping `Modal` for RNR `Dialog`. Smallest visual change, but the biggest bug fix (drag-dismiss, focus management, safe-area handled by RNR). - -### Tier C — domain UI (stays, foundation upgraded) - -These are not generic shadcn components — RNR has no equivalent for "priority icon" or "actor avatar". They stay under `components/ui/` for backwards compatibility with current imports, but their **internal building blocks** must move to the RNR foundation: use RNR's `Text` instead of raw ``, use `cva` for variant tables (not inline conditionals), use semantic tokens (`text-foreground`, never `#1f1f23`). - -- `actor-avatar.tsx` (158 lines) — uses `avatar` primitive from RNR underneath, but business logic stays -- `app-header-actions.tsx` (51) -- `avatar-stack.tsx` (97) -- `presence-dot.tsx` (44) -- `priority-icon.tsx` (80) -- `project-icon.tsx` (49) -- `project-priority-icon.tsx` (71) -- `project-status-icon.tsx` (130) -- `status-icon.tsx` (163) -- `pulse-dot.tsx` (52) -- `screen-header.tsx` (37) - -No PR for Tier C until a bug or feature touches the file. Then update the foundation as part of that PR — opportunistic, not scheduled. - -## 5. Theming architecture - -Goal: in-app picker at Settings → Appearance with three options: **Light**, **Dark**, **System**. The selection is persisted in `expo-secure-store` (a key `theme-preference` with values `light` / `dark` / `system`). - -### 5.1 Stack - -``` -global.css CSS variables under :root and .dark:root - (the source of truth for colors) -tailwind.config.js darkMode: 'class' + utilities map to hsl(var(--...)) -lib/theme.ts TypeScript mirror of CSS vars, exports NAV_THEME - for React Navigation -lib/use-color-scheme.ts wraps NativeWind's useColorScheme + - expo-secure-store persistence -app/_layout.tsx reads persisted preference at startup, - calls setColorScheme(...), wraps Stack in - ThemeProvider(NAV_THEME[isDark ? 'dark' : 'light']), - mounts for RNR dialogs/popovers -``` - -### 5.2 Token strategy - -**Shipped in Phase 1.** The pre-Phase-1 `tailwind.config.js` carried hard-coded hex values for ~20 semantic tokens (`background`, `foreground`, `card`, `primary`, …). Those are now CSS variables under `:root` (light) and `.dark:root` (dark) in `global.css`, and the Tailwind config resolves them through `hsl(var(--background))` and friends. - -Dark-mode colours did not exist in the old config and had to be authored. Two options were considered: - -1. **Use shadcn's neutral-base dark palette** (`--background: 0 0% 3.9%`, etc., per RNR's default install) — fastest, gets a working dark mode immediately, may need a second pass for brand alignment. -2. **Author dark mode by hand** mirroring the web/desktop dark theme — slower, but stays visually consistent with desktop. - -Phase 1 took **option 1** (shadcn default dark palette) for velocity. A later pass can tune it to match desktop's `packages/ui/styles/tokens.css` dark theme now that the infrastructure is proven. - -Multica-specific tokens not in shadcn's default (`brand`, `brand-foreground`, `success`, `warning`, `info`, `priority`, `code-surface`) get their own CSS variables in both `:root` and `.dark:root`, mapped through `tailwind.config.js` the same way. - -### 5.3 Why class-mode, not media-query-mode - -Media-query mode (`@media (prefers-color-scheme: dark)`) is the simpler default but cannot be overridden by the app. Multica's Settings → Appearance picker needs to override the OS preference, which requires class mode. The cost of class mode is a single `setColorScheme()` call at app startup to apply the saved preference, paid once before first paint. - -### 5.4 `system` option - -When the user picks `system`, we call `setColorScheme('system')` (NativeWind v4 supports it natively) and the framework follows the OS via `Appearance.addChangeListener`. We don't need to subscribe ourselves. `isDarkColorScheme` updates reactively. - -## 6. Phase plan - -### Phase 0 — research & docs *(this document)* - -- [x] Read RNR's installation and customization docs verbatim -- [x] Inspect current mobile state (`tailwind.config.js`, `global.css`, `app/_layout.tsx`, `metro.config.js`, `babel.config.js`) -- [x] Update `apps/mobile/CLAUDE.md` with the new UI & theming rules -- [x] Write this migration doc -- [x] **User verification gate** - -### Phase 1 — base infrastructure — ✅ complete - -Goal: install RNR scaffolding without touching a single existing component. Verification = app builds and runs identically to before; theme switcher in settings works end-to-end. - -Every item below shipped. The checklist is kept as the record of what Phase 1 covered: - -1. ✅ **Add dependencies** (per RNR manual install Step 3): - - `npx expo install tailwindcss-animate class-variance-authority clsx tailwind-merge @rn-primitives/portal` - - Confirm `class-variance-authority`, `clsx`, `tailwind-merge`, `@rn-primitives/slot` are already present (they are) — only `tailwindcss-animate` and `@rn-primitives/portal` are new. -2. ✅ **Update `metro.config.js`** to set `inlineRem: 16` on `withNativeWind(...)`. -3. ✅ **Rewrite `global.css`** with `:root` and `.dark:root` CSS variable blocks (light + dark palettes including Multica's custom tokens). -4. ✅ **Rewrite `tailwind.config.js`** to use `hsl(var(--...))` mappings, set `darkMode: 'class'`, register `tailwindcss-animate` plugin, add `hairlineWidth()` border width. Keep mobile-specific overrides (`borderRadius`, custom tokens). -5. ✅ **Create `lib/theme.ts`** — TS mirror of CSS variables + `NAV_THEME` for React Navigation. -6. ✅ **Create `lib/use-color-scheme.ts`** — wraps NativeWind's `useColorScheme()`, loads/persists preference from `expo-secure-store` on mount, exposes `{ colorScheme, isDarkColorScheme, setColorScheme }`. -7. ✅ **Create `components.json`** with the standard RNR configuration (`style: "new-york"`, paths, aliases pointing at `@/components`, `@/lib/utils`). -8. ✅ **Update `app/_layout.tsx`**: - - Read persisted theme preference (sync read from secure-store at module init or first effect; default `system`). - - Apply it via `setColorScheme(...)` before children render. - - Wrap `Stack` in `ThemeProvider(NAV_THEME[isDarkColorScheme ? 'dark' : 'light'])` from `@react-navigation/native`. - - Mount `` from `@rn-primitives/portal` as the last child of providers. -9. ✅ **Settings → Appearance picker**: add an "Appearance" `SectionGroup` to `app/(app)/[workspace]/more/settings.tsx` with three rows (Light / Dark / System), calling `setColorScheme(mode)` + persisting. UI mirrors the existing `WorkspaceRow` pattern; no new dependencies. -10. ✅ **Verify**: app builds; all existing screens render unchanged in light mode; toggle Dark → backgrounds invert + text inverts; toggle System → matches the simulator's OS appearance; kill app, reopen → preference persisted. - -**Phase 1 does NOT replace any existing component.** Buttons, inputs, sheets are still the hand-written versions. The dark mode "works" against the existing hex-derived semantic tokens because we are simply remapping them through CSS variables — the same `bg-background` class now resolves to a CSS variable that has two values instead of one. - -**Residual risk (carried into Phase 3)**: components that style with hard-coded hex (e.g. ``, `bg-[#fafafa]`) do not respond to theme change. Phase 1 included a grep sweep for `#[0-9a-fA-F]{3,6}` in `components/` and `app/`; each hit was either replaced with a token or flagged for Phase 3 with a `TODO(rnr-migration):` comment. - -### Phase 2 — first component (canary) — not started - -Goal: prove the migration mechanics on the simplest non-trivial component before doing 20 of them. - -**Pick**: `button.tsx`. Reasons: -- High caller count (`grep -rn 'from "@/components/ui/button"' apps/mobile/`) — exercises the import-sweep pattern. -- RNR ships a button with `variant` and `size` props that mirror shadcn — confirms API parity. -- Visible everywhere — visual regressions are immediately obvious. - -Steps: - -1. `npx @react-native-reusables/cli@latest add button`. CLI writes `components/ui/button.tsx` over the existing file (the existing one becomes the diff baseline in git). -2. Diff old vs new. Note any prop or visual differences (e.g. RNR's `variant` enum vs ours, default size differences). -3. Sweep every caller. If new RNR API requires different props, adjust callers in the same PR. -4. Visual diff in simulator: open every screen that uses a button, screenshot vs main branch. -5. Verify in both light and dark mode. - -Phase 2 is one PR. Acceptance: every existing button still works; no regressions in light mode; sensible defaults in dark mode. - -### Phase 3 — everything else - -Order: - -1. **Other Tier A primitives** (input, text-field, card, text, textarea) — one PR per component or group, same canary pattern as button. -2. **`text` is special**: it's imported by virtually every file. Plan a dedicated PR; the codemod is mechanical (`import { Text } from "@/components/ui/text"` already exists; the file behind it changes). -3. **Tier B sheets** — proceed per `~/.claude/plans/mobile-sheet-rollout.md`, one sheet per PR. **Order: B.1 first (native-API replacements that delete files), then B.2 (`Select` replacements), then B.3 (`Dialog` migrations).** First sheet PR = `comment-action-sheet.tsx` → `ActionSheetIOS` because it's a clean deletion and validates the §3.2 waterfall in practice. Stop and re-verify after each. -4. **Tier C foundation upgrades** — opportunistic, no scheduled PRs. -5. **Cleanup PR**: remove legacy files that are no longer referenced; remove any TODO comments from Phase 1's hex sweep; final `pnpm typecheck && pnpm lint` clean. - -Stopping rules for Phase 3: - -- If three consecutive PRs introduce visual regressions, **pause** and reconsider token mapping. Don't power through. -- If a Tier B sheet fails to migrate cleanly (RNR `dialog` doesn't fit the use case), document the divergence in this doc and keep the hand-written version, marked as "intentional exception" rather than "to-do". - -## 7. Known pitfalls - -These are the gotchas that came up during research; encode in your reflexes before doing Phase 1. - -1. **`darkMode: 'class'` + `.dark:root`** is the *only* combination that works with NativeWind v4 for class-controlled mode. Do not use the standard `.dark` selector — NativeWind needs the `:root` suffix to apply globally. Source: RNR `customization.mdx`. -2. **`setColorScheme()` is from NativeWind, not React Native.** Importing from `react-native` gives you the read-only OS value. The NativeWind version supports `setColorScheme('light' | 'dark' | 'system')` and triggers re-render. Source: NativeWind 4 docs. -3. **Sync gotcha**: `lib/theme.ts` and `global.css` must mirror each other. If you change one without the other, components styled by Tailwind classes will look right but anything reading `THEME` directly (e.g. inline styles, animations, React Navigation chrome) will be wrong. The RNR docs ship a sync prompt template — use it. -4. **`AbortSignal.timeout` / `AbortSignal.any` still don't exist in Hermes** (Lesson 5). No bearing on RNR migration but worth re-stating since any new component that does its own network calls (autosize-textarea uses no network, button uses none — but in general) needs the manual AbortController pattern. -5. **`useSafeAreaInsets` inside RNR `Dialog`** has the same flakiness as inside raw `Modal`. The pageSheet pitfall from CLAUDE.md Lesson 6 still applies — read insets in the parent, pass `bottomInset` as a prop. -6. **`@rn-primitives/portal` `` placement matters.** It must be the last child of all providers; if mounted inside a provider that re-renders frequently, dialogs unmount unexpectedly. Place once, in `app/_layout.tsx`. -7. **CLI overwrites files without confirmation.** If `components/ui/button.tsx` exists, `add button` replaces it. This is desirable for the migration but disastrous if run accidentally on a Tier C file. Always check git status after each `add`. -8. **NativeWind 5 is on the horizon**, not adopted. CLAUDE.md baseline pins us to v4. RNR v1 supports both; we stay on the v4 install path. - -## 8. Open questions - -- **Brand color in dark mode.** Current brand is `#4571e0` (light mode only). Dark mode equivalent needs to be picked — either keep it (high contrast on dark bg) or shift. Defer to Phase 1 design pass. -- **`code-surface` token.** Currently `#e8e8eb` (5% darker than `secondary` in light mode). Dark mode equivalent is "5% lighter than dark `secondary`" — author at Phase 1. -- **Settings page UX.** Current `settings.tsx` is a flat list with `SectionGroup`s. The Appearance picker can be either inline (three rows directly) or a row that opens a picker sheet. Inline is simpler for v1; the sheet variant can come once Tier B sheets are migrated. -- **Should we share dark tokens with desktop later?** Desktop's `packages/ui/styles/tokens.css` uses `oklch` (Tailwind v4); mobile uses `hsl` (Tailwind v3.4). Cross-version sharing is impractical; we accept divergence as intentional (existing baseline). Revisit if mobile upgrades to NativeWind 5 + Tailwind v4. - -## 9. Appendix — links - -- RNR docs: -- RNR installation (manual steps): -- RNR customization: -- RNR CLI: -- NativeWind v4 dark mode: -- NativeWind v4 `useColorScheme`: -- shadcn theming reference (CSS variable list): -- RN-Primitives Portal: -- Mobile sheet rollout plan (existing): `~/.claude/plans/mobile-sheet-rollout.md` -- Mobile baseline rules (durable): `apps/mobile/CLAUDE.md` diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 15df4346c..9d904c76a 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -6,11 +6,7 @@ * Theme tokens — RNR template (verbatim) + Multica custom tokens appended. * * When changing any variable here, also update apps/mobile/lib/theme.ts — - * they mirror each other. See apps/mobile/docs/rnr-migration.md §5. - * - * Dark-mode values for the Multica custom tokens currently mirror their - * light values — they will be tuned when a screen using them actually - * needs dark-mode treatment. Don't pre-author (per §3.1 "defaults first"). + * they mirror each other. See apps/mobile/AGENTS.md, "Theming model". */ @layer base { diff --git a/apps/mobile/lib/theme.ts b/apps/mobile/lib/theme.ts index c284615b1..9aa6813fd 100644 --- a/apps/mobile/lib/theme.ts +++ b/apps/mobile/lib/theme.ts @@ -7,7 +7,7 @@ * in app/_layout.tsx so headers, modals, and the back button match. * * If you change a variable in global.css, update the matching key here. - * See apps/mobile/docs/rnr-migration.md §5 for the sync rule. + * See apps/mobile/AGENTS.md, "Theming model", for the sync rule. */ import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native"; diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index 27e1ea4fb..f32d4a9be 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -4,7 +4,7 @@ * Colors map to CSS variables in apps/mobile/global.css. When changing a * variable name there, mirror the change here AND in apps/mobile/lib/theme.ts. * - * See apps/mobile/docs/rnr-migration.md §5 for the sync rule. + * See apps/mobile/AGENTS.md, "Theming model", for the sync rule. */ const { hairlineWidth } = require("nativewind/theme"); const radiusTokens = require("./lib/radius-tokens.json");