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