Plan 10 — Frontend: design
Authority: the architecture spec
(2026-09-20-erp-architecture-design.md) §13,
with §5.5 and §12.2 where they touch the UI, and §11A.3 for locales. This document fixes
what the spec leaves open and records every departure, with the reason.
The user delegated every design decision on screens the Figma file does not cover. The bar they set is “user friendly”. They did not review this document or its plan before the build, so a later reader should treat the rulings below (F1–F21) as mine and open to challenge.
0. What exists
Section titled “0. What exists”web/is empty. The only TypeScript in the repo ispackages/contracts/vocab.ts, generated bytools/vocabgen.api/openapi.yamlis the contract. Authentication is a bearer access token (short-lived, from login, refresh or switch-org) plus an HttpOnly, SameSite=Lax refresh cookie. There is no CORS, so the browser app must be served same-origin with/v1.- Figma file
Uz1j7iSQYOYdCLKTm6bRsT(Mcom (3)). I read these frames through the Figma MCP:
| Node | What |
|---|---|
123:7183 |
Claims list (我的报销): KPI cards, scope tabs, status chips with counts, search, project and month filters, table |
278:24022, 286:9929 |
Claim detail drawer, closed and pending. Prev/next pager, receipts, basic info, progress timeline, comments, approve/reject bar |
233:13631, 166:12082, 166:11488 |
Submit-claim wizard, 3 steps: info → amount & receipts → payout method & confirm |
407:17221 |
Petty cash page: KPIs, “to approve” table, per-project usage, movement ledger |
426:18791, 446:20155 |
Request float (申请备用金) and return float (归还备用金) dialogs |
477:16079 |
Dark mode (English copy) |
426:24658, 94:27703 |
1280px small-screen layout; 390px mobile claims |
0:1 |
Components page |
Figma variables (read with get_variable_defs): Dark purple #6a4b8d,
light purple #f1e9f1, outline #d3d2dc, light outline #efeef2, Color #8e8e93
(muted text), White, black. The type ramp is Roboto: Caption 12/14, Body copy 14/16,
Sub bold 14/16 w700, Sub title 16/20 w500, tracking 0.5.
The design is purple, not the “dark navy” §13.2 describes. I follow the design (F1).
1. Scope
Section titled “1. Scope”Plan 10 delivers the four roadmap lines under “Plan 10 — Frontend”:
- Shell, org switcher, i18n. Locales are
en,msandzh, not the roadmap’sen/zh.msis statutory (§11A.3), and §13 already lists all three. The roadmap line is corrected when it is ticked (F2). - Claims list and detail, from Figma.
- Workflow-driven UI: status chips, filter chips and action buttons all come from the API.
- The §13.5 screens Figma lacks, in the spec’s order: workflow definition editor, payout channel editor, chart of accounts, journal entries and GL, fiscal periods and close, reports, advance acquittal, float register.
It also delivers the screens a user needs in order to use items 2–4 end to end, even though no roadmap line names them. Without these, the claims screens are a demo:
- Login, invitation acceptance, the session list (sign out other devices).
- My payout details (管理收款资料), which the wizard’s step 3 links to.
- Petty cash from Figma: the float page, request and return.
- Advances list and detail (no Figma; built from the components).
- Delegations (my out-of-office delegate).
- Team (团队 in the Figma nav): members, invitations, roles and permissions, and org
structure (units, positions, who holds them). Without these, a new organization can’t be
made usable from the browser. Only the Owner role is seeded with grants
(
provisioning/steps.go,seededRoles), so an invited Employee holds no permission until an administrator grants one. And approval routing needs positions (theno_primary_positionwarning). Today both need raw SQL or API calls (F19).
Out of scope, and deferred with a reason:
- Claim comments (评论). Figma shows a thread. No comment model exists, and adding one means a new record-bearing table with retention, locale and audit rules. The drawer shows the decision log’s reasons in its place (F3). This is a candidate for Phase 2.
- Global search, AI chat, notifications bell, Home/Projects/Asset booking/Announcements nav items. No backend exists. The top bar keeps the layout, but these controls are not rendered until a backend exists. Nav items are rendered only for modules the API serves (F4). A dead button is less friendly than a missing one.
- Company profile editing. There is no API (Plan 2 note), and the profile is set at provisioning.
2. Backend seam changes (built first)
Section titled “2. Backend seam changes (built first)”The design needs data the API does not expose. Every change is additive to
api/openapi.yaml. Nothing is renamed and no response loses a field. The generated
TypeScript client proves the shapes. Migration range 00170–00189 and provisioning steps
130–139 are reserved for Plan 10. Only S7 needs a migration: the one-shot grant of
org.roles.manage to existing Owner roles. Every other addition reads columns that already
exist.
| # | Change | Why |
|---|---|---|
| S1 | GET /v1/me: user id, email, display name, member id, current org (id, name, base currency, country), organizations[], permissions[] (strings from vocab) |
The UI must know who is signed in and which nav items and actions to show. The token carries only ids and a permission version. Listed in [no-permission]: it reads only the caller’s own identity, taken from the signed claim, like switch-org |
| S2 | GET /v1/org/members: directory of the org’s members {member_id, name, active} |
Claims, advances and decisions carry member ids only, and the timeline and pickers need names. It is listed in [no-permission], with this argument: it exposes only names and ids of the caller’s own organization (RLS-isolated), the same names any member already meets on the records they can read. Gating it would leave every non-Owner role (zero grants at seed) seeing raw uuids |
| S3 | GET /v1/expenses/claims gains state, q (reference or title, case-insensitive prefix/contains), month (YYYY-MM, on created_at), plus created_at, submitted_at (nullable; the first decision out of draft) and category_ids[] (distinct active-line categories, line order) on ExpenseClaim |
The table’s filters and columns. submitted_at comes from the decision log, not a new column |
| S4 | GET /v1/expenses/claims/summary?scope=&month=: [{state, semantic, count, total}] for the caller’s scope |
KPI cards and filter-chip counts. Counting a page client-side would be wrong once there is more than one page |
| S5 | scope=to_act on GET /v1/pettycash/spends and GET /v1/pettycash/topups |
The petty-cash “to approve” table (待审批). It mirrors claims and advances, which already have it |
| S6 | GET /v1/advances/summary, as S4 for advances |
The same KPI treatment on the advances page |
| S7 | Roles: GET /v1/org/permissions (the registered catalogue), GET/POST /v1/org/roles, PATCH /v1/org/roles/{id} (rename), PUT /v1/org/roles/{id}/permissions (replace the set), PUT /v1/org/members/{id}/roles (replace a member’s roles). A new permission org.roles.manage guards all of them, and it is added to the Owner seed |
There is no role editor API (roadmap, Plan 2). Every change is audited with its before and after sets, and bumps the affected members’ permission version so their next token refresh sees it. The Owner role’s org.roles.manage cannot be removed, and a member cannot remove their own last role that holds it. Both are refusals, not warnings, because either change locks the organization out (§4.2). Existing organizations’ Owner roles get org.roles.manage from a one-shot migration, as DEPLOY_NOTES.md did for the pettycash.* permissions |
Each change gets its handler test, its routes-permission test entry, and presence-then- absence tests where it filters: a state filter must be shown to return a claim before it is shown to exclude one.
3. Architecture
Section titled “3. Architecture”Stack as §13.3: React 19, Vite, TypeScript strict, TanStack Router (file-free, code-defined
routes) and TanStack Query, TanStack Table, Radix primitives styled with Tailwind v4 in the
shadcn pattern (components copied into web/src/components/ui, not a runtime dependency),
zustand for UI-only state, i18next.
TanStack Router over React Router (F5): typed search params carry list filters
(?scope=to_act&state=pending&month=2026-08). That makes every filtered view a shareable
URL with no hand-written parsing.
web/ index.html vite.config.ts tsconfig.json package.json Dockerfile src/ app/ router.tsx, providers.tsx, shell/ (Sidebar, TopBar, OrgSwitcher, UserMenu) features/ auth/ claims/ advances/ pettycash/ approvals/ payout/ workflow/ accounting/ reports/ settings/ components/ ui/ Button, Dialog, Drawer, Select, Tabs, Table, Toast, Tooltip… (Radix + tokens) workflow/ StatusChip, StateFilterChips, TransitionBar, DecisionTimeline, ReasonDialog money/ MoneyText, MoneyInput upload/ DocumentUploader (presigned PUT, complete, progress) lib/ api/ schema.d.ts (generated), client.ts, problem.ts, auth.ts i18n/ index.ts, en.json, ms.json, zh.json money.ts string-decimal format/validate — never Number() date.ts Asia/Kuala_Lumpur display, ISO on the wire styles/tokens.css3.1 The generated client
Section titled “3.1 The generated client”openapi-typescript generates web/src/lib/api/schema.d.ts from api/openapi.yaml, and
openapi-fetch provides the typed calls (F6). There is no hand-written request or response
type anywhere. make generate runs the generator, and the Go drift test that already
proves generated code matches its source is extended to cover schema.d.ts. So a spec
change that is not regenerated fails CI.
client.ts adds the middleware:
Authorization: Bearer <token>from memory. The access token is never written tolocalStorage(F7). A page load re-mints it throughPOST /auth/refresh, which uses the HttpOnly cookie.- On
401, one refresh followed by one retry, single-flight so that ten concurrent 401s cause one refresh. A refresh that fails goes to/login?next=. If-Matchfrom the resource’srevisionon writes that need it.Idempotency-Key(UUID per user action, reused on retry) where the spec requires it.application/problem+jsonis parsed into a typedProblem. Field errors map onto form fields.409revision conflicts show “this changed while you were editing — reload” with a reload action, never a silent overwrite.
3.2 Money
Section titled “3.2 Money”The invariant is that a float never touches money. The wire is a string, and so is the UI:
lib/money.tsformats a decimal string with grouping and the currency, by string manipulation ("1234.5" → "RM 1,234.50"). Display uses the currency’s minor units (2 for MYR), rounding the string half-up, which only ever happens for display.MoneyInputkeeps a string, accepts^\d+(\.\d{0,4})?$, and sends it unchanged.- The UI never adds amounts. Every total (claim total, KPI totals, float balances) comes from the API.
- Drift test (Vitest, in CI): scan
web/srcforparseFloat,Number(,+amountunary on money-named identifiers, andtoFixed. Allowed only inlib/date.tsand pagination code, by explicit allowlist. Following AGENTS.md, it first asserts that it scanned more than 0 files and found the patterns in a planted fixture. It is watched failing once.
3.3 i18n
Section titled “3.3 i18n”i18next with en, ms and zh bundles. Choosing the locale, in order: the user’s
choice (stored in localStorage, UI-only), the browser, then en. Every UI string is a
key, and a Vitest drift test fails when a key exists in one bundle and not another.
Record text is not UI text. Server-supplied labels (workflow state and edge labels,
category names, channel names) come back per locale from the API. The UI picks
labels[uiLocale] ?? labels.en. Free-text inputs that land on records (claim title,
line description, reasons) show a hint when the UI locale is zh: record text must be
English or Malay (§11A.3). The server’s 422 is still what decides; the UI does not
pre-validate script (F8).
3.4 Theming
Section titled “3.4 Theming”styles/tokens.css declares the Figma variables as CSS custom properties, mapped into
Tailwind v4 @theme. There is no hex outside tokens.css, and a Vitest drift test enforces
that. Dark mode follows frame 477:16079 and is a [data-theme=dark] override of the same
tokens. The default follows prefers-color-scheme, with a toggle in the user menu.
Status colours follow the Figma chip set and are keyed on semantic, never on state key
(F9). WorkflowState has no colour field, and inventing one would be a schema change for
styling. The mapping:
| semantic | tone | figma |
|---|---|---|
| draft | neutral | grey outline |
| pending | amber | 待审核 |
| accrual | green | 已批准 |
| payout | green (solid) | 已付款 |
| acknowledged | teal | 已结案 |
| dispute | purple | — |
| rejected | red | 已拒绝 |
| cancelled | neutral-muted | — |
| custom | blue | 已审核 |
A tenant who adds a custom step gets a correctly-labelled blue chip with no release. That is the §13.4 promise.
4. Workflow-driven UI (§13.4)
Section titled “4. Workflow-driven UI (§13.4)”The shared components under components/workflow/ are the only place workflow state is
rendered.
useWorkflowDefinition(kind)readsGET /workflow/definitions/{kind}(the pinned version, cached for the session). A list with rows on several versions fetches each version it sees once.StatusChip({kind, version, state})shows the label from the definition, with the tone from the state’s semantic.StateFilterChipsis built from the definition’s non-terminal and terminal states indisplay_order, with counts from the S4/S6 summaries. “All” comes first. A state with no rows still shows0, so the layout does not jump.TransitionBar({available_transitions})renders one button per exit, in edgedisplay_order. The primary style goes to the first allowed non-reversing edge, and danger style to edges whose target isrejectedorcancelled. A disallowed transition is shown disabled with itsrefusal.detailas a tooltip (§13.4: “including the reason a disabled action is disabled”). Arequires_reasonedge opensReasonDialogfirst. The frontend never evaluates a gate or a threshold.DecisionTimelineis built fromdecisions[](oldest first), then from the definition’s remaining states after the current one, rendered as pending steps. Figma’s “报销进度 (n/m)” is the count of distinct states reached over the states on the main path. The main path is the definition’s states indisplay_order, excluding terminal reject/cancel semantics. Actor names come from S2. The resolved eligible approvers of the current step are shown as “等待 X 审批” when the instance view carries them.
5. Screens
Section titled “5. Screens”For each screen, F means built from a Figma frame and C means composed from the Components page because Figma has no frame for it.
5.1 Shell and auth (F)
Section titled “5.1 Shell and auth (F)”- Sidebar: the org switcher at the top (
POST /auth/switch-org, then invalidate every query), then the grouped nav: 工作区 / Workspace (Activity), 申请 / Requests (Claims, Advances, Petty cash), 审批 / Approvals, 管理 / Manage (Accounting, Reports, Workflows, Payout channels, Settings). Every item is gated onme.permissions. A group with no visible items is hidden. - Top bar: breadcrumb, then the user menu (locale, theme, my payout details, sessions, sign out).
- Responsive: at ≥1280px the full sidebar shows, per the small-screen frame. From 768
to 1280px the sidebar collapses to icons. Below 768px the sidebar is a drawer, lists
become cards (frame
94:27703), and drawers go full-screen. - Login (C): email and password, and the org picker when there is more than one org. Accept invitation (C): the token from the URL, then name and password.
- Activity (C):
GET /activity, as an infinite list.
5.2 Claims (F)
Section titled “5.2 Claims (F)”- List (
/claims): the header “My claims”, with a “N claims this month · M pending” subline built from S4. KPI cards (pending / approved / paid / total submitted) are bucketed by semantic: pending =pending+custom; approved =accrual; paid =payout+acknowledged; total = everything exceptdraft. The tabs are My claims (scope=mine), My approvals (to_act) and All claims (all, only withexpenses.claim.read_all). Then state chips, search, a group (project) select, a month select, and export. The table columns are title (with the first line’s supplier as the subline), category pill (the first category, then “+N”), status chip, amount, submitted, reference, and an overflow menu (open, copy reference, withdraw draft). Keyset pagination loads 10 rows by default, per the design note on426:21251, with “Load more”, because the API is cursor-based (F10). - Detail drawer (
/claims/$id): the drawer opens over the list, and the URL is the claim id. ‹ n/N › steps through the current list without closing, per the design note on297:15745, with keyboard ←/→ and a sticky header. It shows: title, reference, submitted time, status; an amount banner (when the caller may act: “当前待办 ·” plus the amount); the TransitionBar; receipts (thumbnails through GET /documents/{id}, zoom, download); basic info (group, categories, dates, payout channel, masked); lines (Figma shows one amount; claims have lines, so a compact line table is added, F11); duplicate-receipt flags with the matching references (possible_duplicate); the DecisionTimeline; and settlements. - Submit wizard (
/claims/new, a dialog), following Figma’s three steps and mapped onto the API (F12):- Info: group (项目), line type, category, title, description. “Save draft” is
POST /expenses/claimson first save, and a PATCH after. - Amount and receipts: the upload (
POST /documentsfor a presigned PUT, PUT to the object store with a progress bar, thenPOST /documents/{id}/complete), date, amount, currency (FX shown read-only from the server’s line response), and a note. It creates or updates one line and attaches the documents. - Payout and confirm: payout-method cards from
GET /payout/me/profiles, masked, with “Manage payout details” going to §5.6, and a summary. Submit takes thesubmittransition fromavailable_transitions, never a hard-coded key.
- “Add another line” on step 2 is a secondary action, for multi-receipt claims. The single-line path stays the default because that is what the design optimizes for.
- Info: group (项目), line type, category, title, description. “Save draft” is
- Edit a draft reopens the same wizard at step 1 with the draft loaded.
5.3 Approvals (C)
Section titled “5.3 Approvals (C)”/approvals is a combined queue: claims (to_act), advances (to_act), petty cash spends
and top-ups (to_act, S5). Each row opens its own drawer, with the TransitionBar and ‹ › to
step through the queue. The sidebar badge shows the queue count, polled every 60 s and on
window focus.
5.4 Petty cash (F)
Section titled “5.4 Petty cash (F)”- Page (
/petty-cash): KPI cards. Figma’s “authorized project float total” has no backing concept, because floats are per holder, not per project. So the cards are pending (my spends and top-ups awaiting a decision), cash balance and available of the selected float, with a committed/balance bar. The selected float is my own; a custodian withpettycash.read_allpicks one from a select. There is no cross-float total, because it would be a client-side money sum (F13). Below them: the “to approve” table (S5) with inline approve/reject fromavailable_transitions; Floats in place of “by project” (code, holder, balance, committed, available); and the movement ledger (GET /pettycash/floats/{id}/movements) with type pill, signed amount, status and reference. - Request float (申请备用金) →
POST /pettycash/topups. Return float (归还备用金) → a close-out with refund proof (POST /pettycash/floats/{id}/closeouts, then the upload of proof), following frame446:20155. Record a spend (C) →POST /pettycash/spends, then the receipt upload.
5.5 Advances (C)
Section titled “5.5 Advances (C)”A list and a detail drawer, with the same components as claims. The detail shows the due
date, overdue level (AdvanceOverdue), legs, and the acquittal panel (§13.5 #7). The
panel shows advanced vs. spent, then the signed variance and the outcome it implies (top-up
to employee, refund to company, or exact), then the tax record fields and evidence upload.
The variance and outcome come from the acquittal view; the UI never computes them.
5.6 Settings (C)
Section titled “5.6 Settings (C)”- My payout details:
GET /payout/me/channelsfield schemas, rendered by a smallfield_schemaform renderer (text, select, pattern). Values are masked on read. - Sessions: list and revoke.
- Delegations: list, create (delegate, window, optional kind, reason), revoke.
5.7 The §13.5 screens (C), in the spec’s order
Section titled “5.7 The §13.5 screens (C), in the spec’s order”- Workflow definition editor (
/admin/workflows/$kind). The published version is read-only, and there is an Edit draft action (PUT /workflow/drafts/{kind}). The layout is a left state list (drag to reorderdisplay_order; semantic select; terminal toggle; labels in en/ms/zh), a centre graph (a read-only auto-laid-out diagram of states and edges usingelkjslayered layout rendered as SVG; click to select), and a right inspector for the selected state or edge: edge gate,action_key,requires_reason,reverses_accrualand labels. For a state it shows the grants table: grantee kind, grantee picker (member / position / levels up), amin_amountfloor, vacancy behaviour, and a live tiling preview of the bands built from the floors. Violations from the draft response are shown inline: a banner lists them, and eachrefhighlights its state or edge in red. Publish is disabled while there are violations, and the server still decides. Routing preview (GET /workflow/definitions/{kind}/routing) shows who would approve an amount at each step, and flags gaps (F14). Drag-editing the graph canvas itself is deliberately not built. The list plus inspector edits everything, and a canvas editor is the most expensive, least accessible option (F15). - Payout channel editor: a channel list, and an editor with name labels, kind,
active, GL account binding (an account picker filtered to eligible control kinds), and a
field_schemabuilder (rows of key, type, label per locale, required, pattern), with a live preview using the same renderer as §5.6. - Chart of accounts: a tree (header/posting accounts, control kind badge), an account editor, and module-owned accounts shown locked, with their owner.
- Journal entries and GL: an entry list (journal, period, reference), entry detail
with lines and analytics, a manual journal form (balanced-lines helper showing the
difference as a server-returned 422 plus a live string-decimal difference, F16), and
reversal (
POST /accounting/entries/{id}/reversal, with reason). - Fiscal periods and close: fiscal years with their periods, status, and
close/reopen. The close dialog first runs
GET /accounting/reconciliationand shows each check pass/fail. Close is enabled only when the server’s close call would accept, and the server still decides. - Reports: trial balance, P&L, balance sheet and GL detail, with period/date pickers,
an expandable account tree, and Export (CSV / XLSX / PDF) through
POST /reports/exports, polling status and then downloading. Aged advances and the float register are shown only if their endpoints exist; they are deferred in Plan 9 (D1/D2). - Advance acquittal: §5.5.
- Float register and reconciliation: the float list with open/closing/closed status, the movement ledger per float, opening a float, and close-out review.
5.8 Team (C)
Section titled “5.8 Team (C)”- Members: the directory (S2), with each member’s roles and primary position, and an
invite button (
POST /org/invitations, role picker). A pending-invitations tab supports revoke (DELETE /org/invitations/{id}). - Roles: a role list, and a permission matrix grouped by module (from
GET /org/permissions), with preset buttons (F20). Saving goes through S7. The two lockout refusals show as inline errors. - Org structure: a units tree and positions (
reports_to) from/org/chart, create and edit dialogs, and assigning holders (/org/members/{id}/positions). The/org/readinesswarnings (for exampleno_primary_position) are shown at the top as a checklist, with a link to fix each one. This is the screen an administrator lands on first in a new organization.
6. Error handling
Section titled “6. Error handling”- Problem details: the
titleis shown as a toast, anderrors[]/field pointers go inline on forms.403shows “you don’t have permission” (the nav should have hidden the way there).404shows “not found or not visible to you”, worded the same way for both on purpose (R21).409revision conflicts are covered in §3.1.429shows the retry time. - Network or 5xx: TanStack Query retries reads twice with backoff. Writes never
auto-retry, except with the same
Idempotency-Keywhere the endpoint supports it. - An error boundary per route shows a friendly panel with a reload button and a request id
(from the
X-Request-Idresponse header) so users can report it.
7. Testing and verification
Section titled “7. Testing and verification”- Unit and component (Vitest + Testing Library + MSW): money formatting (including large values beyond 2^53, which must format exactly), the token refresh single-flight, problem mapping, StatusChip tone by semantic, TransitionBar (disabled plus refusal tooltip, requires_reason opens the dialog), DecisionTimeline progress count, wizard step gating, the field_schema renderer, and the workflow editor’s violation highlighting.
- Drift tests (Vitest): no float on money, no hex outside
tokens.css, i18n key parity across en/ms/zh, and the generated client matching the spec (the Go test). Each is watched failing once, and each asserts a non-zero scan first. - End to end (Playwright): against the real API on a testcontainers-backed dev stack
(
make dev-stack: Postgres, migrations, a seeded demo org viaorgadmin create-orgplus a seed script, and api + worker + vite). The flows are: log in → file a claim with a receipt → submit → second user approves → verify → pay, with the status chip and timeline checked at each step. Also: a petty-cash spend round trip, publishing a workflow draft with a violation (it must show the violation inline) and then fixing it, and running a trial-balance export. Playwright runs locally and in a separate CI job (Node + Docker), not insidemake test, so the Go suite is untouched. - Manual verification: every screen checked in the in-app browser at 1440, 1280 and 390 widths, in light and dark, and in en and zh, before its task is marked done.
8. Build, CI and deployment
Section titled “8. Build, CI and deployment”web/package.jsonuses pnpm (installed on the dev machine), Node 24, and a lockfile.- CI gets a new
webjob:pnpm install --frozen-lockfile,pnpm typecheck,pnpm lint(ESLint + Prettier check),pnpm test,pnpm build. Playwright is a separate job that needs Docker. - Serving:
web/Dockerfilebuilds static assets into an unprivilegednginximage that serves/with an SPA fallback and proxies/v1to the api service. That keeps the app same-origin with no CORS and no change to the Go router (the[outside-v1]section ofpublic_routes.txtstays empty). The deployment’s TLS reverse proxy points at this container.DEPLOY_NOTES.mdgets the Compose snippet and the note that the refresh cookie’s path must match the public origin (F17). - Dev:
viteproxies/v1tolocalhost:3000..claude/launch.jsongets awebconfiguration for the in-app preview. - Content Security Policy:
default-src 'self', withimg-srcalso allowing the object store’s public origin for presigned thumbnails. It is set in the nginx config (F18).
9. Rulings
Section titled “9. Rulings”- F1 Follow the Figma purple, not §13.2’s “dark navy”.
- F2 Ship
en/ms/zh, and fix the roadmap line. - F3 No comments. The decision-log reasons take their place in the drawer.
- F4 Hide nav and top-bar controls that have no backend.
- F5 Use TanStack Router, for typed search params.
- F6 Use
openapi-typescript+openapi-fetch, with drift-tested generation. - F7 Keep the access token in memory only, restored by the refresh cookie.
- F8 Show the record-language hint, and let the server decide.
- F9 Status tone is keyed by semantic; no colour column.
- F10 Cursor pagination, 10 by default, with “Load more”.
- F11 Show a line table in the claim detail.
- F12 The wizard maps to claim + one line + documents + the submit transition.
- F13 No client-side money sums.
- F14 Include routing preview in the editor.
- F15 No drag-edit canvas; use the list plus inspector.
- F16 Show the manual journal’s difference by string-decimal subtraction in
lib/money.ts. This is the single place the UI does arithmetic on money: exact decimal on strings (a BigInt of minor units), never a float, and covered by the drift test’s allowlist and its own unit tests. The server’s balance trigger remains the authority. - F17 The nginx container serves the web app and proxies
/v1. - F18 The CSP is set at nginx.
- F19 Team screens (members, invitations, roles, org structure) are in scope; see §5.8.
- F20 Role presets (“Employee”, “Approver”, “Finance”, “Admin”) exist only as a UI helper that pre-ticks checkboxes in the role editor. Nothing is saved until the administrator saves, and the server’s seeding is unchanged.
- F21 The member directory (S2) carries no permission, as argued in §2.