toga-ai 1.0.612 → 1.0.613

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,629 @@
1
+ ---
2
+ title: Front-End (React) Coding Standards
3
+ framework: "2.0"
4
+ project: _Underscore
5
+ client: shared
6
+ type: standard
7
+ status: active
8
+ updated: 2026-08-18
9
+ owners: [jcardinal]
10
+ files: []
11
+ related:
12
+ - ../apps/toga25-supply/architecture.md
13
+ - ../apps/toga-blox/architecture.md
14
+ - frontend-deploy.md
15
+ - backend-php.md
16
+ ---
17
+
18
+ ## Scope & how to read this document
19
+
20
+ This is the coding standard for **any React app on the 2.0 platform** — not a spec for any one
21
+ product. It is layered by altitude, and each rule carries a strength marker so you know how far to
22
+ trust it:
23
+
24
+ - **MUST / SHOULD** — the practice **converges across both reference apps** (`toga25-supply` and
25
+ `toga25-desk`) or is a hard platform requirement. Treat it as team law.
26
+ - **Reference pattern (desk-only, n=1)** — only one app has worked this out so far. Present tense:
27
+ "here is a well-built pattern to reuse," **not** "the team requires this." A second consumer
28
+ promotes it to MUST/SHOULD; until then, copy it with your eyes open.
29
+
30
+ The three parts:
31
+
32
+ - **Part 1 — Universal React / TypeScript.** Portable to any React app anywhere.
33
+ - **Part 2 — TOGA 2.0 platform conventions.** What consuming the shared 2.0 stack requires.
34
+ - **Part 3 — Open items.** Described and flagged, **not** prescribed as settled.
35
+
36
+ Evidence was mined from `@agilant/toga-blox` (repo `toga-blox-npm`), `toga25-supply`, and
37
+ `toga25-desk`. Where a claim was checked against source, it is stamped (e.g. "Verified 2026-08-18").
38
+ Anything about env files, build mode, Amplify, the per-environment blox channel, or
39
+ `--legacy-peer-deps` is owned by **`frontend-deploy.md`** and is only cross-referenced here.
40
+
41
+ ---
42
+
43
+ # Part 1 — Universal React / TypeScript standards
44
+
45
+ *(Portable to any React app. Convergent unless a rule is tagged as a reference pattern.)*
46
+
47
+ ## 1. Project structure & module organization — **MUST / SHOULD**
48
+
49
+ **MUST: organize feature-first.** A page folder colocates the page's view, its data hook, its
50
+ sub-components, and its CSS. Do not spread one screen's files across parallel `components/`,
51
+ `hooks/`, `styles/` trees by type — group by feature.
52
+
53
+ **MUST: split the view-model hook from the dumb view.** A `use<Feature>()` hook owns **all**
54
+ fetching, mutation wiring, and derived state, and returns a **flat presentational shape**; the
55
+ component just renders that shape. The two apps name the hook differently but the shape is
56
+ identical — `toga25-supply` uses `use*PageViewModel` in a `viewModel/` folder beside the page
57
+ (verified: `toga25-supply/src/pages/<Entity>/viewModel/`); `toga25-desk` uses `use<Feature>` beside
58
+ the page. Either name is fine; the split is the rule.
59
+
60
+ ```
61
+ pages/
62
+ Orders/
63
+ Orders.tsx // thin view — renders what the hook returns
64
+ viewModel/
65
+ useOrdersPageViewModel.ts // owns fetching + derived state, returns a flat shape
66
+ components/ // sub-components used only by this page
67
+ Orders.module.css
68
+ ```
69
+
70
+ **MUST: keep the top-level layers separated by dependency direction.**
71
+
72
+ - `api/` — pure request functions. **Imports nothing from React.** No hooks, no components.
73
+ - `lib/` — framework-agnostic helpers and the query client (also React-free).
74
+ - `components/` — reusable presentational primitives.
75
+ - `routes/`, `theme/` — routing table and design tokens.
76
+
77
+ **SHOULD: treat `.ts` vs `.tsx` as a deliberate Fast-Refresh boundary.** Pure data/wiring modules
78
+ stay `.ts` with **no JSX**, so `react-refresh` never invalidates them on an unrelated edit. A file
79
+ that must export *both* a component and a hook (a context/provider module) breaks the
80
+ one-export-kind rule on purpose and carries an explicit opt-out:
81
+
82
+ ```ts
83
+ // eslint-disable-next-line react-refresh/only-export-components
84
+ export const useAuth = () => useContext(AuthContext);
85
+ ```
86
+
87
+ **SHOULD: use barrels sparingly.** An empty or pass-through barrel is scaffolding noise — add one
88
+ only when it earns its keep by hiding a genuinely multi-file module behind one entry point.
89
+
90
+ ## 2. TypeScript configuration — **MUST**
91
+
92
+ **MUST: strict, with project references.** The root `tsconfig.json` references
93
+ `tsconfig.app.json` + `tsconfig.node.json`. Verified 2026-08-18: `toga25-supply` and `toga25-desk`
94
+ ship the **identical** app-config flag set — the two files differ only in whitespace.
95
+
96
+ ```jsonc
97
+ // tsconfig.app.json — the enforced set (both apps, verbatim)
98
+ {
99
+ "compilerOptions": {
100
+ "target": "ES2022",
101
+ "module": "ESNext",
102
+ "moduleResolution": "bundler",
103
+ "moduleDetection": "force",
104
+ "jsx": "react-jsx",
105
+ "verbatimModuleSyntax": true,
106
+ "noEmit": true,
107
+ "erasableSyntaxOnly": true,
108
+ "noUncheckedSideEffectImports": true,
109
+ /* strictness */
110
+ "strict": true,
111
+ "noUnusedLocals": true,
112
+ "noUnusedParameters": true,
113
+ "noFallthroughCasesInSwitch": true,
114
+ /* alias — mirrored in the bundler */
115
+ "baseUrl": ".",
116
+ "paths": { "@/*": ["src/*"] }
117
+ }
118
+ }
119
+ ```
120
+
121
+ **MUST: the `@/*` alias is declared in BOTH the tsconfig and the bundler** (`vite.config.ts`
122
+ `resolve.alias`). A path alias that exists in only one of the two type-checks-but-won't-build (or
123
+ the reverse). Keep them in lockstep.
124
+
125
+ ## 3. State management doctrine — **MUST** (high-value)
126
+
127
+ There are exactly **three homes** for state, and they do not overlap. Verified 2026-08-18 across
128
+ both apps.
129
+
130
+ | Home | Holds | Never holds |
131
+ |---|---|---|
132
+ | **React Query** | ALL server / async state | anything you can compute locally |
133
+ | **Zustand** | a MINIMAL durable client/session slice — current user, tenant/hostname identity, theme | server data of any kind |
134
+ | **`localStorage`** | losable pure-UI preferences — collapsed sections, pins, lock state | anything that must be correct, anything server-owned |
135
+
136
+ - **MUST: one `QueryClient` at module scope.** Constructed once (`const queryClient = new
137
+ QueryClient({...})`), not inside a component. Verified in both `App.tsx`.
138
+ - **MUST: server data never lives in Zustand.** If it came from the API, it belongs in React
139
+ Query's cache so invalidation and refetch work. Zustand is for identity and preference only.
140
+ - **SHOULD: clear the query cache on auth transitions** so a new session never inherits the prior
141
+ user's cached rows (login, logout, tenant switch). See §12 and §18.
142
+
143
+ **Reference pattern (desk-only, n=1): namespace `localStorage` keys per tenant + user.**
144
+ `toga25-desk` writes UI-preference keys as `app:{clientUuid}:{userId}:key` so one tenant's or one
145
+ user's pins/collapse state can never surface under another's. The **principle — namespace
146
+ tenant-scoped client storage so it cannot bleed across tenants (SHOULD, see §18)** — is what you
147
+ adopt; the exact key shape is the reference implementation to copy or improve.
148
+
149
+ ## 4. Data fetching with React Query — **MUST / SHOULD**
150
+
151
+ **MUST: the per-page view-model hook owns fetching** (§1). Components do not call the API directly.
152
+
153
+ **MUST: set cache policy by data kind.**
154
+
155
+ | Data kind | `staleTime` / `gcTime` | `retry` | Invalidation |
156
+ |---|---|---|---|
157
+ | session-immutable reference data (enums, table meta) | `Infinity` | `false` | none needed this session |
158
+ | editable records | short `staleTime` | default | invalidate on mutate |
159
+ | global defaults | — | `1` | `refetchOnWindowFocus: false` |
160
+
161
+ ```ts
162
+ const queryClient = new QueryClient({
163
+ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
164
+ });
165
+ ```
166
+
167
+ **SHOULD: fetch with React Query, not react-router data APIs.** Both apps **deliberately avoid**
168
+ router `loader`/`action`/`useLoaderData`; data flows through React Query so caching, invalidation,
169
+ and optimistic updates live in one system. Do not mix the two paradigms.
170
+
171
+ **Reference pattern (desk-only, n=1): optimistic + failure-tolerant preference persistence.** For
172
+ losable UI prefs, `toga25-desk` mutates local state optimistically, fires the persist
173
+ request-and-forgets, and on failure **keeps the optimistic state** plus a `console.warn` (a failed
174
+ pin write must not throw the user out of what they were doing); it also write-throughs to
175
+ `localStorage` so the very first paint is already correct. Reuse this shape for *preference* state
176
+ only — never for records that must be server-authoritative.
177
+
178
+ ## 5. URL as the source of truth for view state — **MUST (as a principle)**
179
+
180
+ **MUST: table view state lives in the URL.** Pagination, sort, filters, and column state go in the
181
+ query string so a view is shareable and back-button-safe. Both apps do this.
182
+
183
+ **The exact URL grammar is app-specific — the team does NOT yet mandate one.** The two apps encode
184
+ it differently, and both are presented here as reference examples, not as the standard:
185
+
186
+ - `toga25-supply` — a **slug-namespaced grammar**: `{slug}_page`, `{slug}_sort[i]`, and filter
187
+ operators like `_starts` / `_ends` / `_between` / `_min` / `_max`. (See
188
+ `../apps/toga25-supply/features/column-visibility.md` and `meta-driven-table-data.md`.)
189
+ - `toga25-desk` — **literal-comma CSV params**: `cols`, `sort`, `q`.
190
+
191
+ A single grammar may be standardized once a second app validates one; until then, match the app
192
+ you are in.
193
+
194
+ **The convergent sub-rules that ARE worth standardizing now — MUST:**
195
+
196
+ - **Distinguish an ABSENT param from a PRESENT-but-empty one.** "Filter not set" and "filter
197
+ explicitly cleared" are different states. Use `searchParams.has(key)`, not a truthiness check on
198
+ the value.
199
+ - **A param that changes the query result MUST be part of the React Query key.** Page, sort, and
200
+ filters belong in the key so a change refetches (and resets to page 1).
201
+ - **A presentation-only param MUST be excluded from the key.** Column visibility changes what you
202
+ see, not what the server returns — putting it in the key forces a pointless refetch. (See
203
+ `../apps/toga25-supply/features/column-visibility.md`.)
204
+
205
+ **Reference pattern (desk-only, n=1):**
206
+ - To keep **literal commas** in `cols`/`sort`, write the URL via `useNavigate({ search })`, not
207
+ `setSearchParams` (which percent-encodes commas into `%2C`).
208
+ - A **debounced-commit** text box for `q`, with a **back/forward reseed guard** so a browser
209
+ navigation re-seeds the input without the debounce clobbering the restored value.
210
+
211
+ ## 6. Routing — **MUST / SHOULD**
212
+
213
+ **MUST: `createBrowserRouter`.** Both apps use the data-router.
214
+
215
+ **MUST: one source of truth for path strings.** No bare path literals scattered at call sites.
216
+ `toga25-supply` centralizes them in a `ROUTE_REGISTRY`; `toga25-desk` uses `src/routes/paths.ts`
217
+ with a `pathForRouteKey` resolver. Pick one shape per app and route every link/navigate through it.
218
+
219
+ **SHOULD: guard wraps layout; layout carries the error boundary.** An auth-guard route **wraps** the
220
+ layout route, and the layout route sets an `errorElement` with a defensive fallback that reads
221
+ `useRouteError()`. A thrown render error lands on a real page, not a white screen.
222
+
223
+ **SHOULD: flat, kebab-case, single-segment routes by default.** Nest only for a genuine section
224
+ (e.g. a settings area with its own sub-nav), and give a nested section an **index redirect** to its
225
+ default child. Always define a **catch-all** route.
226
+
227
+ **Reference pattern (supply-only, n=1): resolve ACL-permitted routes *inside* the layout
228
+ outlet** so the router instance stays stable across tenant/ACL resolution. `toga25-supply` builds
229
+ the permitted route set inside the layout rather than rebuilding the router when the tenant or ACL
230
+ resolves — rebuilding the router remounts the tree and drops in-flight view state. Reuse this if
231
+ your permitted routes depend on async identity.
232
+
233
+ ## 7. Forms — **SHOULD**
234
+
235
+ Both apps use **react-hook-form**; this is the convergent forms library.
236
+
237
+ - **SHOULD: `useForm` under a `<FormProvider>`.** Field components read context via
238
+ `useFormContext()`.
239
+ - **SHOULD: hydrate from query data with `reset()` in an effect keyed on the record id** — so
240
+ loading a different record re-seeds the form and an in-flight edit is not silently carried over.
241
+ - **SHOULD: `mode: "onChange"`** where live validation is wanted.
242
+ - **SHOULD: submit via `handleSubmit(onValid, onInvalid)`** — handle the invalid branch explicitly;
243
+ do not let a failed validation submit silently.
244
+
245
+ ```tsx
246
+ const form = useForm({ mode: "onChange" });
247
+ useEffect(() => { form.reset(recordData); }, [record.id, recordData]); // re-seed per record
248
+ // ...
249
+ <form onSubmit={form.handleSubmit(onValid, onInvalid)}>
250
+ ```
251
+
252
+ **Reference pattern (desk-only, n=1):**
253
+ - A `<Controller>` wrapping a **typeahead select** so the **form value stays a scalar id** while the
254
+ widget displays and searches by name — the persisted value is the FK, the UI is the label.
255
+ - An **app-side named-validator layer**: a `{ email, phone }` regex map applied through RHF
256
+ `setError` / `clearErrors`. This exists **only because** the shared input component exposes no
257
+ validator passthrough (see Part 2 §22). It is a workaround, not an aspiration — if/when
258
+ `BaseInput` gains a validator prop, this layer goes away.
259
+
260
+ ## 8. Theming & design tokens — **MIXED (layer carefully)**
261
+
262
+ **Universal / convergent — SHOULD: layer your tokens semantically.**
263
+ Raw scales → semantic aliases (`--ink-*`, `--action-*`, `--status-*`) → components reference **only**
264
+ the semantic layer. No hex literal, and no raw-scale reference, appears at a component call site. A
265
+ central record-kind / identity map means no icon or color literal is inlined per entity either.
266
+
267
+ **Universal — SHOULD: dark mode is a color swap and nothing more.**
268
+ - Light tokens live on **bare `:root`**.
269
+ - Dark is a **grouped selector that out-specifies `:root`** — `html.dark, :root.dark, .dark { … }`.
270
+ A `:where(.dark)` selector sits at specificity 0 and **loses** to `:root`; do not use it for the
271
+ dark block.
272
+ - Dark **redeclares colors only.** Type, spacing, radii, and motion are shared and defined once —
273
+ never re-stated in the dark block.
274
+ - **Restate the semantic aliases inside the dark block** so a document-level `.dark` and a
275
+ subtree-level `.dark` behave identically.
276
+
277
+ ```css
278
+ :root { --ink-1: #1a1a1a; --action: #2563eb; } /* light on bare :root */
279
+ html.dark, :root.dark, .dark { /* out-specifies :root */
280
+ --ink-1: #f4f4f5; --action: #60a5fa; /* colors only — no spacing/type here */
281
+ }
282
+ ```
283
+
284
+ **Reference pattern (desk-only, n=1): the no-flash theme boot.**
285
+ - A pre-paint inline `<head>` script sets `html.dark` **before React mounts**, so there is no
286
+ light-then-dark flash.
287
+ - The theme store is **initialized FROM the DOM class** already set by that script — it does not
288
+ recompute-and-reapply on mount (recompute-on-mount is exactly what causes the flash).
289
+ - A **module-scope `matchMedia` listener** (not a React effect) tracks the OS preference, which is
290
+ StrictMode-safe and fires once.
291
+
292
+ **Layering note (do not misfile):** the requirement that a **host app define a full
293
+ `--baseInput-*` / `--field-*` token set** is **not** universal theming — it is a *blox-consumption*
294
+ requirement and lives in **Part 2 §13**. Here, only note the general truth: a shared component may
295
+ require the host app to supply a token layer.
296
+
297
+ ## 9. Reusable UI primitives & interaction — **Reference pattern (desk-only, n=1)** for the primitives; **Universal** for the a11y contract
298
+
299
+ The concrete primitives below are worked out in `toga25-desk` alone — reuse them as well-built
300
+ patterns, not as team law. The **a11y contract each one implements is universal (see §10).**
301
+
302
+ - **Anchored popover primitive** — portals to `document.body`; computes position in
303
+ `useLayoutEffect` **before paint** with `visibility: hidden` until measured (never render a
304
+ flash at `0,0`); repositions on scroll/resize; closes on outside-click / Escape and **restores
305
+ focus to the anchor**.
306
+ - **A deliberate role contrast:** a **roving-focus `role="menu"`** (arrow keys rove over
307
+ `role="menuitem"`, one tab stop) versus a **focus-trapped `role="dialog"` / `role="alertdialog"`**
308
+ (Tab cycles within). Pick by semantics: a menu roves, a dialog traps.
309
+ - **An imperative `await useConfirm({ … })` promise API** for confirmations — the caller awaits a
310
+ boolean instead of threading `isOpen` state through the tree.
311
+ - **One shared body across a desktop popover and a mobile sheet** via a `roving` prop, so the two
312
+ presentations share behavior and markup.
313
+
314
+ **Flag:** the focus-trap logic is duplicated across the dialog primitives — a candidate
315
+ `useFocusTrap` hook to extract once a second consumer needs it.
316
+
317
+ ## 10. Accessibility — **SHOULD**
318
+
319
+ ARIA here is **hand-rolled and must be correct**. When you build an interactive primitive, wire the
320
+ full contract:
321
+
322
+ - Triggers: `aria-haspopup`, `aria-expanded`.
323
+ - Roles: `menu`/`menuitem`, `dialog`/`alertdialog`, `tablist`/`tab`, and `aria-modal` on modals.
324
+ - Labelling: `aria-labelledby` / `aria-describedby`; `aria-current="page"` on the active nav item;
325
+ `aria-pressed` on toggles.
326
+ - Live regions: `aria-live="polite"` on toasts.
327
+ - Keyboard & focus: roving `tabindex`, focus traps in dialogs, focus restoration on close, Escape
328
+ to dismiss.
329
+ - `aria-hidden` on decorative icons.
330
+
331
+ **GAP (see Part 3): a11y is convention-only, not lint-enforced.** Verified 2026-08-18: **no**
332
+ `eslint-plugin-jsx-a11y` is configured in `toga25-supply`, `toga25-desk`, or `toga-blox-npm`.
333
+ Adopting it is recommended so these conventions are checked mechanically rather than by reviewer
334
+ memory.
335
+
336
+ ## 11. Build & tooling — **MUST / SHOULD**
337
+
338
+ - **MUST: Vite + `@vitejs/plugin-react`**, with the `@ -> ./src` alias mirroring the tsconfig
339
+ (§2). Verified 2026-08-18 in both `vite.config.ts`.
340
+ - **MUST: `resolve.dedupe` forces a single copy of React and any heavy shared peer** that a
341
+ linked/external library also ships. State the **principle**: dedupe `react` + `react-dom` **plus
342
+ every peer your component library provides context or hooks for**. The exact members are per-app
343
+ because they follow that app's shared-lib surface — verified 2026-08-18:
344
+
345
+ ```ts
346
+ // toga25-supply/vite.config.ts
347
+ dedupe: ["react", "react-dom", "@tanstack/react-table"]
348
+ // toga25-desk/vite.config.ts
349
+ dedupe: ["react", "react-dom", "react-hook-form"]
350
+ ```
351
+
352
+ Supply dedupes the table lib (it consumes blox's table context); desk dedupes react-hook-form (it
353
+ consumes blox's `BaseInput`, which reads `useFormContext()`). See §13(c) for why a second copy
354
+ silently breaks blox's hooks.
355
+ - **MUST: one env-read chokepoint.** Read `VITE_API` in exactly **one** module (see §17). Verified:
356
+ both apps read it (plus a per-host override) only in `src/api/api.ts`.
357
+ - **SHOULD: ESLint flat config**, verified identical in both apps:
358
+
359
+ ```js
360
+ export default defineConfig([
361
+ globalIgnores(["dist"]),
362
+ { files: ["**/*.{ts,tsx}"],
363
+ extends: [ js.configs.recommended, tseslint.configs.recommended,
364
+ reactHooks.configs.flat.recommended, reactRefresh.configs.vite ] },
365
+ ]);
366
+ ```
367
+ - **SHOULD: tab indentation in source.** This repo family indents source with **tabs** (verified;
368
+ see `../apps/toga25-supply/architecture.md`) — a string-replace edit that assumes spaces will
369
+ miss. (The ESLint config files themselves are 2-space, but application source is tabs.)
370
+ - **MUST: never commit secrets** — see the committed-`.npmrc` violation in Part 3.
371
+
372
+ ## 12. Client-side auth & security — **MIXED (layer carefully)**
373
+
374
+ **Universal / convergent — SHOULD: tokens in `localStorage`, eyes open.** The 2.0 backend is
375
+ deliberately **Bearer-token, with no cookie session**, so the client stores the token in
376
+ `localStorage`. Acknowledge the tradeoff honestly: this accepts an XSS token-exfiltration exposure
377
+ that an `HttpOnly` cookie would not. Given that, **never log a token, never place it in a URL or an
378
+ error payload,** and keep the XSS defenses of §10/Part-1 tight.
379
+
380
+ **SHOULD: logout clears auth state + the query cache, but NOT the world.** On logout, clear auth
381
+ state and call `queryClient.clear()` so the next user does not inherit cached rows. Do **not**
382
+ blanket-`localStorage.clear()` — that also wipes UI preferences. **Honest divergence:**
383
+ `toga25-desk` preserves prefs on logout; `toga25-supply` currently blanket-clears. This is a known
384
+ inconsistency; **the preserve-prefs behavior is the recommended one.**
385
+
386
+ **Reference pattern (desk-only, n=1): classify a login failure.** `toga25-desk` sorts a failed
387
+ login into **network / credentials / server** by HTTP status + API error code, and messages the
388
+ user accordingly. `toga25-supply` deliberately does **not** classify — so this is a recommendation,
389
+ not a convergent rule.
390
+
391
+ **Platform-coupled — SHOULD (conceptually Part 2): field-limit every request.** Send an explicit
392
+ `fields` list so an unfielded FK expansion cannot pull sensitive columns (a bcrypt hash, an LDAP
393
+ DN) into the client. This is coupled to api2 serialization — see Part 2 §16.
394
+
395
+ **Reference pattern (supply-only, n=1): gate cache clears behind restoration.** If you **persist**
396
+ the React Query cache (supply does, via `PersistQueryClientProvider` +
397
+ `createSyncStoragePersister` — verified 2026-08-18), a `queryClient.clear()` that races a late
398
+ `restoreClient()` can be silently undone. Gate every clear behind an `awaitRestoration()` promise
399
+ so restoration cannot resurrect a cleared cache.
400
+
401
+ ---
402
+
403
+ # Part 2 — TOGA 2.0 platform conventions
404
+
405
+ *(Consuming the shared stack. These are platform requirements, not portable advice.)*
406
+
407
+ ## 13. Consuming `@agilant/toga-blox` — **MUST**
408
+
409
+ `@agilant/toga-blox` is the front-end shared core — the React analog of `_underscore` / `library`
410
+ on the back end. A consuming app **MUST** meet every requirement below.
411
+
412
+ **(a) Pin an exact published version — never a caret.** Some published versions ship an **empty
413
+ `dist`** and break the build; a caret can float you onto one. Both apps pin exact versions
414
+ (verified 2026-08-18: supply `1.0.330-sandbox-client.128`, desk `1.0.328-sandbox-client.125`).
415
+
416
+ **(b) Declare blox's peer deps that do not hoist into your app.** blox lists them as peers, so npm
417
+ will not necessarily install them for you: verified 2026-08-18 they include `react-hook-form`,
418
+ `@tanstack/react-query`, `@tanstack/react-table`, `framer-motion`, `react-router-dom`, and `axios`
419
+ (pinned exactly by blox at `1.8.4`). A missing peer surfaces as a runtime "undefined is not a
420
+ component," not a build error.
421
+
422
+ **(c) Dedupe `react` / `react-dom` (+ `react-hook-form` / `@tanstack/react-table`) to a single
423
+ copy.** blox is shipped **unbundled** — its `build` is `tsc && copyfiles … && fix-esm-imports`
424
+ (verified 2026-08-18), so its imports stay external and resolve against **your** `node_modules`. A
425
+ second React copy silently breaks its hooks and context (the classic "Invalid hook call" / null
426
+ `useRef`). See §11.
427
+
428
+ **(d) The bundler must handle blox's assets.** It MUST support **CSS-Module imports and asset
429
+ imports from `node_modules`**, and it MUST **scan the blox package for Tailwind utilities** (add the
430
+ package path to your Tailwind content globs) or blox components render unstyled.
431
+
432
+ **(e) The app MUST define the full `--*` host-token set.** blox ships **scoped CSS-Module classes
433
+ but ZERO token values** — there is no `:root` token block anywhere in blox. An app that does not
434
+ define `--baseInput-*`, `--field-*`, `--btn-*`, `--primaryTable-*`, and the rest renders
435
+ **unstyled**. The convergent technique: **define those host tokens by resolving your own
436
+ already-semantic tokens**, so blox themes automatically with **no blox stylesheet imported**:
437
+
438
+ ```css
439
+ :root {
440
+ --baseInput-bg: var(--field-bg); /* host token resolves to YOUR semantic token */
441
+ --baseInput-text: var(--ink-1);
442
+ --btn-bg: var(--action);
443
+ }
444
+ ```
445
+
446
+ **(f) `getFontAwesomeIcon` hard-imports FontAwesome PRO packages.** So `npm install` requires the
447
+ **FA Pro registry auth** — the README's "free fallback" claim is contradicted by the code. Also:
448
+ `dist/main.css` is **not compiled** (it still contains raw `@tailwind` directives) — **do not import
449
+ it expecting styles.** Style blox through the host-token layer in (e).
450
+
451
+ ## 14. blox api-client init order — **MUST**
452
+
453
+ Initialize the blox axios client as a **side-effect import that runs before the router mounts.**
454
+ Verified 2026-08-18: both apps do a bare `import "./api/api"` **above** `import App` in `main.tsx`,
455
+ and `api.ts` does:
456
+
457
+ ```ts
458
+ // api/api.ts — runs at import time, before any component renders
459
+ const baseURL = import.meta.env["VITE_API_" + host.split(".")[0]] || import.meta.env.VITE_API;
460
+ const instance = createAxiosInstance({ baseURL, onLogout });
461
+ setAxiosInstance(instance);
462
+ ```
463
+
464
+ blox's API helpers **throw "No axios instance registered"** if any data hook or component runs
465
+ first. The order is: create → set → *then* mount. (See `../apps/toga-blox/features/api-client.md`.)
466
+
467
+ ## 15. Provider nesting order — **MUST**
468
+
469
+ One `QueryClient` at module scope (§3), and providers nest outer→inner in this order. Verified
470
+ 2026-08-18 in both `App.tsx`:
471
+
472
+ ```
473
+ QueryClientProvider // (Persist)QueryClientProvider if the app persists the cache
474
+ └─ AuthProvider // calls useQueryClient() → MUST be inside QueryClientProvider
475
+ └─ ToasterProvider // state; any route may raise a toast via useToaster()
476
+ └─ (app theme/confirm providers) // supply: blox ThemeProvider · desk: ConfirmProvider
477
+ └─ router (RouterProvider) // innermost
478
+ ```
479
+
480
+ - **AuthProvider must sit inside QueryClientProvider** — it reads `useQueryClient()` to clear the
481
+ cache on auth transitions (§3/§18). Desk's `App.tsx` documents exactly this constraint.
482
+ - **The Toaster is split: provider (state) + a portal viewport (visual).** Mount **both** — the
483
+ `ToasterProvider` and its viewport (`ToasterList` in supply, `ToasterViewport` in desk).
484
+ - **Theme placement is app-specific:** supply slots blox's `ThemeProvider` here; desk themes via a
485
+ module-scope theme store (§8) instead. Everything above the theme layer is convergent.
486
+
487
+ ## 16. blox data layer for tables — **MUST**
488
+
489
+ **Reuse blox's table data layer — do not reinvent server-side where/sort/fields.** Use
490
+ `getDataTableMeta(slug)` (GET `/table-views/meta`), `getDataTableData(...)`, the `useTableData`
491
+ hook, and the `assembleOptions` query serializer.
492
+
493
+ - **The shared query-key prefix is `["table-data", slug, …]`** (verified in
494
+ `../apps/toga25-supply/architecture.md`), so `invalidateQueries({ queryKey: ["table-data"] })`
495
+ refreshes **any** table after a mutation.
496
+ - **`additionalData` (relationship/WHERE injection) MUST be in the query key.** Omit it and the
497
+ first (null) render caches an **unfiltered** result that a later filtered render reads back stale.
498
+ (See `../apps/toga25-supply/features/record-modals-and-nested-tables.md`.)
499
+ - **Presentation-only params are stripped from the data key** (§5) — column visibility must not
500
+ refetch.
501
+ - **Field-limit requests to the visible columns** — the FK-expansion leak defense from §12.
502
+ - **Two serializer footguns to guard:** `assembleOptions` now **omits an empty `fields` param** (a
503
+ past empty-`fields` 500 is fixed), **but** an empty `where` object still emits a literal `()` —
504
+ guard against passing an empty `where`.
505
+
506
+ ## 17. Multi-tenant env & deployment — **CROSS-REFERENCE ONLY**
507
+
508
+ Env and build wiring — `.env.<mode>`, branch→`--mode`, the `VITE_*` console-var trap, the
509
+ per-environment blox channel, `--legacy-peer-deps`, Node/heap — is owned by
510
+ **`frontend-deploy.md`**. Do not restate it here.
511
+
512
+ The one code-side convention worth stating: both apps read **`VITE_API`** — plus an optional
513
+ per-host **`VITE_API_<FIRST-HOSTNAME-SEGMENT>`** override — in **exactly one module**
514
+ (`src/api/api.ts`, §14).
515
+
516
+ ## 18. Client-side multi-tenant isolation — **MUST** *(cto-added)*
517
+
518
+ This is the front-end face of the platform's hard multi-tenant rule. **On any tenant/identity
519
+ change, reset BOTH the React Query cache AND tenant-scoped `localStorage`** so one tenant's cached
520
+ data can never bleed into another tenant's session. This ties directly to §3 (clear the cache on
521
+ auth transitions) and the per-tenant/user key namespacing (§3 reference pattern). A tenant switch
522
+ that clears the cache but leaves tenant-scoped storage behind is a data-isolation defect, not a
523
+ cosmetic one.
524
+
525
+ ## 19. Consuming the `{success, data, errors}` API envelope on the client — **MUST** *(cto-added)*
526
+
527
+ The backend contract is the `{ success, data, errors }` envelope (and the V2 response envelope) —
528
+ see `backend-php.md` and `2.0/standards/framework-rules.md`. The **client** side of that contract:
529
+
530
+ - **Unwrap defensively.** The nested `data.<record>.<script>` shape is easy to get wrong; read it
531
+ through one helper, not ad hoc at each call site.
532
+ - **Treat `success: false` as a user-facing outcome:** route `errors[]` to a toast the user can
533
+ read.
534
+ - **Report the unexpected to Sentry.** A technical/unexpected failure goes to error reporting — do
535
+ **not** surface a raw server message string as a toast. (blox's api-client normalizes the wire
536
+ envelope for you; see `../apps/toga-blox/features/api-client.md`.)
537
+
538
+ ## 20. Server-driven UI (Surface) — **Reference pattern (generic shape only)**
539
+
540
+ Describe the **generic mechanism only.** The concrete engine currently lives in `toga25-desk` and
541
+ will be captured at Desk launch — **do not document Desk's specific menus/screens here.** (Supply
542
+ has a related `surface-frontend` feature doc; the generic contract is what belongs in a standard.)
543
+
544
+ The generic shape:
545
+
546
+ - A **server meta bundle carries message KEYS and token SLUGS** — never final strings, never CSS.
547
+ - **Client-side PURE, never-throw resolvers** turn keys→copy and slugs→tokens.
548
+ - **Every consumer resolves each label/icon with a hardcoded design-literal FALLBACK**, so the UI
549
+ renders **before and without** the seed.
550
+ - **The engine imports nothing from the app;** app-binding registries map action-keys→handlers.
551
+ - **An unknown action-key renders DISABLED + `console.warn`** — never a silent no-op, never a throw.
552
+
553
+ The payoff: copy and icons change as a **data edit**, with no front-end deploy.
554
+
555
+ ## 21. blox/app boundary & promotion discipline — **SHOULD**
556
+
557
+ - **The dependency arrow points app → blox ONLY.** No blox module imports app code. (Confirmed by
558
+ `../apps/toga-blox/architecture.md`: the dependency runs app→library.)
559
+ - **Build a new generic capability app-LOCAL first; promote into blox only after a 2nd consumer
560
+ validates the contract.** Hardening a shared contract from a single call site (n=1) bakes in that
561
+ app's accidents.
562
+ - **Mark intent with a per-module header** so a future reader knows which side a module belongs on:
563
+
564
+ ```ts
565
+ // TODO(blox): promote once stable — imports nothing from @/
566
+ // APP BINDING — stays in the app
567
+ ```
568
+
569
+ ## 22. Known blox gotchas — consume defensively — **SHOULD**
570
+
571
+ Work **around** these shared-lib defects; do **not** imitate them in app code:
572
+
573
+ - Empty `where` → literal `()` (§16).
574
+ - `dist/main.css` is uncompiled — do not import it (§13f).
575
+ - **Two select stacks** coexist (`react-select` + a custom `AdvancedSelect`) and **two table
576
+ stacks** (`react-table` v7 + `@tanstack/react-table` v8) — verified 2026-08-18 in blox's peer
577
+ deps. Know which one a given component uses before you wire it.
578
+ - Phantom/unused peer deps in blox's manifest.
579
+ - Supply/commerce business logic has leaked into the shared lib.
580
+ - **No custom-validator passthrough on `BaseInput`** — hence the app-side validator layer in §7.
581
+ - A **~85-prop className-string styling contract on `BaseInput`** — verbose by design; drive it from
582
+ tokens, not per-instance strings.
583
+ - **`strict: false` in blox's own tsconfig**, and **React-18-only** peers.
584
+
585
+ **Fixing these is blox-authoring work, tracked separately** (a future `blox-authoring.md`), **not
586
+ app work.** In a consuming app, guard against them and move on.
587
+
588
+ ---
589
+
590
+ # Part 3 — Open items
591
+
592
+ *(Described and flagged. NOT prescribed as settled.)*
593
+
594
+ - **Front-end testing — SHOULD-lean, not yet a settled standard.** The reality (verified
595
+ 2026-08-18): `toga25-supply` **and** `toga-blox-npm` both run **Vitest + React Testing Library**
596
+ (jsdom, `globals: true`, a setup file, co-located `*.test.ts(x)`). Supply has **real tests on the
597
+ tricky bits** — auth context, table URL-state, error classification — with `@vitest/coverage-v8`;
598
+ blox configures istanbul coverage. `toga25-desk` has **no test script and an empty Cypress
599
+ scaffold**. Recommendation: **Vitest + RTL for unit / logic-hook tests** — a firm SHOULD, since it
600
+ is real in 2 of 3 sources and mirrors how `backend-testing.md` prescribes a path even without a
601
+ formal harness — with **Cypress reserved for E2E** (acknowledged empty today). A dedicated
602
+ `frontend-testing.md` sibling is the likely eventual home. **Do not** conflate this with the
603
+ team's unresolved *backend* testing question — that is PHP-only.
604
+ - **`eslint-plugin-jsx-a11y` not configured** (§10) — a11y is convention-only, not lint-enforced in
605
+ any of the three repos. Recommend adopting it.
606
+ - **Client error-reporting gap.** Verified 2026-08-18: all three repos depend on `@sentry/react`
607
+ (`^8.55.0`), but **neither app initializes it** — there is **no `Sentry.init` and no top-level
608
+ React `ErrorBoundary`** in `toga25-supply/src` or `toga25-desk/src`. Recommend a top-level
609
+ `ErrorBoundary` + `Sentry.init`, with a client-error → backend-issue correlation story (§19).
610
+ - **Undo/redo — deliberately OUT OF SCOPE.** The team's undo architecture is an unresolved
611
+ question. This standard prescribes **no** approach; do not infer one.
612
+ - **React 18 → 19.** blox peers cap at React `^18` (verified 2026-08-18: blox declares
613
+ `react: ^18.0.0`; consumers pin `^18.3.1` and had to downgrade from 19). Upgrading blox to support
614
+ 19 is an **open, unscheduled** question affecting every consumer — do not unilaterally bump an app
615
+ to 19.
616
+ - **Committed `.npmrc` registry tokens — SECURITY VIOLATION (remediate).** The app repos commit
617
+ registry auth credentials in `.npmrc` (both the FontAwesome Pro registry and the private npm
618
+ registry). This violates `rules/common/security.md` and the `2.0/standards` secret rules. Treat
619
+ the committed credentials as **compromised: rotate them, move to CI-injected registry config, and
620
+ purge them from the tree.** This is a known issue **with a remediation pointer**, not a soft
621
+ "unsettled" item.
622
+
623
+ ---
624
+
625
+ ## Change history
626
+
627
+ - 2026-08-18 — Initial front-end (React) coding standard: universal React/TS layer + TOGA 2.0
628
+ platform-conventions layer, mined from @agilant/toga-blox, toga25-supply, and toga25-desk; per-rule
629
+ convergence tagging; testing/a11y/error-reporting/React-19 flagged as open. (jcardinal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.612",
3
+ "version": "1.0.613",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",