toga-ai 1.0.832 → 1.0.833

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.
@@ -2,22 +2,23 @@
2
2
 
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
- | [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform |
5
+ | [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM pages, state, API, multi-tenant resolution); open when working anywhere in this |
6
6
  | [Bundle item visibility & selectability (the three flags, and why they are enforced nowhere but the client)](features/bundle-item-visibility-and-selectability.md) | Which components of a kit a shopper can see and choose is decided **entirely in the browser**. |
7
7
  | [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every |
8
- | [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. |
8
+ | [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | Cart Notifications section: CC-email duplicate prevention + auto-add/remove of order-for-user & delegate-manager emails; open when touching notification emails |
9
9
  | [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | The Cart summary section (subtotal / shipping / tax / total) is **data-driven** from `cartData`. |
10
10
  | [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. |
11
11
  | [Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it](features/catalog-cache-freshness.md) | TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it to **`localStorage["commerce"]`** through `PersistQueryClientP |
12
12
  | [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one |
13
- | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. |
13
+ | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
14
14
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh |
15
15
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | The storefront has **two distinct search entry points that render the same card component through completely different code paths and different FIELDS files**. |
16
+ | [Header "Order for <Name>" dropdown + the pending-pick hand-off to the cart](features/header-order-for-dropdown.md) | Header pill that lets a buyer pick who they are ordering for from any page, and the in-memory store that hands that pick to the cart's order-for cascade; open b |
16
17
  | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a standalone line** on the storefront — but the **same flag is |
17
18
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. |
18
19
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s |
19
20
  | [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | On the toga2-commerce **Cart** page, a shipping option's **cost** can be waived by config using the same `PrimaryItemShippingRule` vocabulary that drives expedi |
20
21
  | [Storefront persona gating — the Home page's kit / category / item counts, and the persona switcher](features/storefront-persona-gating-and-home-counts.md) | What a storefront user sees on the Home page is decided **in the front end**, from `user._personaIds`, not by the API's ACL. |
21
22
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. |
22
- | [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | The Cypress **e2e** convention set for `toga2-commerce`, and the first **active** e2e coverage for the **Cart** page (`cartV2.cy.ts`, slice 1 — 12 tests, verifi |
23
+ | [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | Cypress e2e conventions + the Cart harness (cartV2.cy.ts) for toga2-commerce; read before adding any spec here, and note it doubles as the config-cart refactor |
23
24
  | [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | When a Chromium browser (Chrome / Edge) shows **`ERR_HTTP2_PROTOCOL_ERROR`** loading a `*.togacommerce.com` tenant for **one client/network but works for the TO |
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-09-17
10
10
  owners: ["apeterson", "tcox"]
11
11
  files:
12
12
  - src/main.tsx
@@ -29,22 +29,15 @@ related:
29
29
  - 2.0/apps/toga2-commerce/features/filter-search-results-page.md
30
30
  ---
31
31
 
32
+ Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM pages, state, API, multi-tenant resolution); open when working anywhere in this repo.
33
+
32
34
  ## Summary
33
35
 
34
- `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the
35
- customer-facing **B2B commerce storefront** of the 2.0 platform. It is a **React 18 + TypeScript +
36
- Vite 5 single-page app** styled with **Tailwind**, and it is the React front end in the 2.0
37
- "decoupled" architecture: it talks to **`api2`** (the TOGa v2 JSON API) for all data, and renders
38
- nothing server-side.
36
+ `toga2-commerce` (npm package **`commerce2-react`**, product **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform: a **React 18 + TypeScript + Vite 5 SPA** styled with **Tailwind**. It is the React front end in the 2.0 "decoupled" architecture — talks to **`api2`** (the TOGa v2 JSON API) for all data, renders nothing server-side. It is a 2.0 *app with no PHP*: registry `dependsOn` is `api2`; it consumes `_underscore`/`api2` over HTTP but contains no framework PHP classes.
39
37
 
40
- It is **multi-tenant**: a single codebase serves several clients (currently **COMPASS**,
41
- **COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback). The tenant is resolved at runtime from
42
- the **hostname** and drives three independent systems — the API base URL, the theme, and the
43
- per-tenant field/content config. See [multi-tenant-theming](features/multi-tenant-theming.md) and
44
- [client-fields](features/client-fields.md) for those subsystems in depth.
38
+ **Multi-tenant:** one codebase serves several clients (**COMPASS**, **COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback). The tenant is resolved at runtime from the **hostname** and drives three independent systems — API base URL, theme, per-tenant field/content config. See [multi-tenant-theming](features/multi-tenant-theming.md) and [client-fields](features/client-fields.md).
45
39
 
46
- > Note: this is a **2.0 *app* with no PHP**. Its registry `dependsOn` is `api2`; it consumes the
47
- > `_underscore`/`api2` backend over HTTP but contains no framework PHP classes itself.
40
+ **Critical rules:** Tenant is derived from hostname only (`hostname.toUpperCase().split(".")[0]`) — wrong host = wrong tenant, and `localhost` falls back (fields→COMPASS, theme→DEFAULT). The single `QueryClient` uses a **24h default `staleTime`** and persists the whole cache to **`localStorage["commerce"]`**, so catalog AND config/copy (FIELDS) can be up to 24h stale for a returning user — verify any data/copy change after `localStorage.removeItem('commerce')` or logout, never on a warm browser. `AuthLayout` never remounts, so `refetchOnMount` fires once per session — route-change refreshes must be wired explicitly. The registry French language key is **`fr-CA`**, not `fr` — branch on `startsWith("fr")`. api2 returns the standard `{success,data,errors}` envelope; do not break the shape.
48
41
 
49
42
  ## Tech stack (verified versions, package.json)
50
43
 
@@ -67,10 +60,8 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
67
60
 
68
61
  ## Bootstrap & provider stack
69
62
 
70
- - `src/main.tsx` — initializes **Sentry** (browser tracing + replay) and mounts `<App/>` in React
71
- `StrictMode`; imports global CSS.
72
- - `src/App.tsx` — creates a single `QueryClient` (default `staleTime: 24h`) and a
73
- `createSyncStoragePersister` (localStorage key **`"commerce"`**). Provider nesting, **outermost → innermost**:
63
+ - `src/main.tsx` — initializes **Sentry** (browser tracing + replay), mounts `<App/>` in React `StrictMode`, imports global CSS.
64
+ - `src/App.tsx` — creates a single `QueryClient` (default `staleTime: 24h`) and a `createSyncStoragePersister` (localStorage key **`"commerce"`**). Provider nesting, **outermost → innermost**:
74
65
 
75
66
  ```
76
67
  PersistQueryClientProvider (client=queryClient, persister → localStorage "commerce")
@@ -81,13 +72,8 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
81
72
  + ReactQueryDevtools (initialIsOpen=false)
82
73
  ```
83
74
 
84
- `App` also runs a mount effect managing **edit-order mode**: it reads a `localStorage.synced`
85
- flag and calls `exitEditOrderModeGlobalSyncReset()` when an edit-order session was abandoned
86
- outside `/cart`, and subscribes to `useReturnOriginalUserFromViewAsStore` (the "view-as" feature).
87
-
88
- > The cache is **persisted**: query results survive refreshes via `PersistQueryClientProvider`.
89
- > Combined with the 24h default `staleTime`, catalog data is aggressively cached — invalidate
90
- > deliberately with `queryClient.invalidateQueries(...)` when freshness matters.
75
+ - `App` runs a mount effect managing **edit-order mode**: reads a `localStorage.synced` flag and calls `exitEditOrderModeGlobalSyncReset()` when an edit-order session was abandoned outside `/cart`; subscribes to `useReturnOriginalUserFromViewAsStore` (the "view-as" feature).
76
+ - The cache is **persisted**: query results survive refreshes. Combined with the 24h default `staleTime`, catalog data is aggressively cached — invalidate deliberately with `queryClient.invalidateQueries(...)` when freshness matters.
91
77
 
92
78
  ## Routing (`src/routes.tsx`, createBrowserRouter)
93
79
 
@@ -97,17 +83,11 @@ Public:
97
83
  - `/reset-password` → `ResetPasswordPage`
98
84
 
99
85
  Authenticated (wrapped by `PrivateRoute` → `AuthLayout`):
100
- - `/home`, `/bundle-view`, `/filter`, `/cart`, `/get-support`, `/account`, `/item-view`,
101
- `/order-details`, `/privacy-policy`, `/terms-conditions`; unmatched → `Navigate to="/home"`.
86
+ - `/home`, `/bundle-view`, `/filter`, `/cart`, `/get-support`, `/account`, `/item-view`, `/order-details`, `/privacy-policy`, `/terms-conditions`; unmatched → `Navigate to="/home"`.
102
87
 
103
88
  Mechanics:
104
- - **`PrivateRoute`** gates on `useAuth().isAuthenticated` + `useAuthenticationFlow().isLoading`;
105
- shows an `AuthLoading` spinner while loading, redirects to `/login` if unauthenticated.
106
- - **`AuthLayout`** wraps every authenticated route and **stays mounted across route changes**
107
- (header, footer, nav, notifications live here). Its `errorElement` renders `<ErrorMessage errorType={500}/>`.
108
- ⚠️ Because it never remounts, React Query `refetchOnMount` only fires **once per session** for
109
- queries owned by AuthLayout — route-change refreshes must be wired explicitly (e.g. the
110
- notifications query invalidates on route change keyed on `user?.uuid`).
89
+ - **`PrivateRoute`** gates on `useAuth().isAuthenticated` + `useAuthenticationFlow().isLoading`; shows an `AuthLoading` spinner while loading, redirects to `/login` if unauthenticated.
90
+ - **`AuthLayout`** wraps every authenticated route and **stays mounted across route changes** (header, footer, nav, notifications live here). Its `errorElement` renders `<ErrorMessage errorType={500}/>`. Because it never remounts, `refetchOnMount` fires **once per session** for queries owned by AuthLayout — route-change refreshes must be wired explicitly (e.g. the notifications query invalidates on route change keyed on `user?.uuid`).
111
91
  - Routes are **statically imported** (no `React.lazy`); Vite handles chunking at build time.
112
92
 
113
93
  ## Page architecture — MVVM convention
@@ -128,10 +108,7 @@ src/pages/<Page>/
128
108
  └── helpers/ # pure compute/format helpers
129
109
  ```
130
110
 
131
- Pages: `Account, BundleView, Cart, Filter, GetSupport, Home, ItemsView, Login, OrderDetails,
132
- PrivacyPolicy, ResetPassword, TermsConditions`. The **ViewModel hook is the seam**: it calls
133
- `useAssignClientFields(fieldKey, language, user)` to load the right tenant/role/language content,
134
- runs the page's queries, and returns ready-to-render data to the View. (See client-fields doc.)
111
+ Pages: `Account, BundleView, Cart, Filter, GetSupport, Home, ItemsView, Login, OrderDetails, PrivacyPolicy, ResetPassword, TermsConditions`. The **ViewModel hook is the seam**: it calls `useAssignClientFields(fieldKey, language, user)` to load the right tenant/role/language content, runs the page's queries, and returns ready-to-render data to the View. (See client-fields doc.)
135
112
 
136
113
  ## State management
137
114
 
@@ -147,6 +124,7 @@ Most use the `persist` middleware (localStorage). Verified files:
147
124
  | `useEditOrderZu` | "Edit existing order" mode flag + original snapshot |
148
125
  | `useEditOrderUuidStore` | UUID of the order being edited |
149
126
  | `useSelectedUserZu` | "Order for" user selected in cart |
127
+ | `useOrderForZu` | Header "order for" **pending pick** handed to the cart cascade — **in memory only, never `persist`ed** |
150
128
  | `useReturnOriginalUserFromViewAsStore` | "View as another user" return-to-self tracking |
151
129
  | `useEmailOptionsStore` | Notification CC email options (see cart-notification-emails) |
152
130
  | `useBundleBuilderZu` | Bundle-builder wizard selections |
@@ -159,59 +137,35 @@ Most use the `persist` middleware (localStorage). Verified files:
159
137
  - Cache persisted to localStorage (`"commerce"`); rehydrated on load.
160
138
  - Page queries are typically `enabled` on `isAuthenticated && user?.uuid && !settingsModalEnabled`.
161
139
 
162
- **Two-layer state model:** React Query owns *server* state (catalog, orders, user); Zustand owns
163
- *local* state (cart, selected user, edit mode, tenant/language). The cart is synced to the backend
164
- sales order via `src/api/syncSalesOrder*.ts` helpers.
140
+ **Two-layer state model:** React Query owns *server* state (catalog, orders, user); Zustand owns *local* state (cart, selected user, edit mode, tenant/language). The cart is synced to the backend sales order via `src/api/syncSalesOrder*.ts` helpers.
165
141
 
166
142
  ## API layer (`src/api/`)
167
143
 
168
- `src/api/axiosInstance.ts` is the shared client. Key behaviors (all verified):
169
- - **Base URL by tenant:** `host = "VITE_API_" + window.location.hostname.toUpperCase().split(".")[0]`;
170
- `baseURL = import.meta.env[host] || import.meta.env.VITE_API`. So `compass.togacommerce` →
171
- `VITE_API_COMPASS`, falling back to `VITE_API`. `timeout: 180000`.
144
+ `src/api/axiosInstance.ts` is the shared client. Verified behaviors:
145
+ - **Base URL by tenant:** `host = "VITE_API_" + window.location.hostname.toUpperCase().split(".")[0]`; `baseURL = import.meta.env[host] || import.meta.env.VITE_API`. So `compass.togacommerce` → `VITE_API_COMPASS`, falling back to `VITE_API`. `timeout: 180000`.
172
146
  - **Transaction id:** a request interceptor stamps every call with `params.transactionId = uuidv4()`.
173
- - **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the
174
- stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via
175
- `POST {baseURL}/auth/public`.
147
+ - **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via `POST {baseURL}/auth/public`.
176
148
  - **1 ms delay interceptor:** a third request interceptor `await delay(1)` before sending.
177
- - **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh
178
- token) once (`_retry`), retries the original request on success, else calls `performLogout()`
179
- (clears all auth + cart + `commerce` cache keys and redirects to `/`).
149
+ - **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh token) once (`_retry`), retries the original request on success, else calls `performLogout()` (clears all auth + cart + `commerce` cache keys and redirects to `/`).
180
150
  - Failures are reported to **Sentry** with `errorType: "API"` tags.
181
151
 
182
- The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch);
183
- generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`,
184
- `deleteData`, optional `?depth=-1` for nested responses).
152
+ The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch); generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`, `deleteData`, optional `?depth=-1` for nested responses).
185
153
 
186
154
  ## Authentication & roles
187
155
 
188
- - `src/contexts/AuthContext.tsx` derives `host` from the hostname (`hostname.toUpperCase().split(".")[0]`),
189
- loads `getClientLoginFields(host)` (tenant config), decides persona-switcher visibility
190
- (`determineShouldShowPersonaSwitcher` — QUAD special-cased), then runs
191
- `getLoginSettings(...)` which writes `useFieldsStore.fieldKey = host` and the language, and
192
- hydrates `useUserStore`.
193
- - `src/hooks/useAuthenticationFlow.ts` orchestrates the post-login fetch sequence
194
- (user data → persona → contact → settings).
156
+ - `src/contexts/AuthContext.tsx` derives `host` from the hostname (`hostname.toUpperCase().split(".")[0]`), loads `getClientLoginFields(host)` (tenant config), decides persona-switcher visibility (`determineShouldShowPersonaSwitcher` — QUAD special-cased), then runs `getLoginSettings(...)` which writes `useFieldsStore.fieldKey = host` and the language, and hydrates `useUserStore`.
157
+ - `src/hooks/useAuthenticationFlow.ts` orchestrates the post-login fetch sequence (user data → persona → contact → settings).
195
158
  - **Role flags** on the user object are tenant-specific:
196
- - COMPASS / COMPASSCANADA → `ADMIN` (`_isAdmin`), `SUPERUSER` (`_isSuperAdmin`),
197
- `MANAGER` (`_isSupervisor`), else `USER`.
159
+ - COMPASS / COMPASSCANADA → `ADMIN` (`_isAdmin`), `SUPERUSER` (`_isSuperAdmin`), `MANAGER` (`_isSupervisor`), else `USER`.
198
160
  - QUAD → `GLOBALADMIN` (`_isGlobalAdmin`), `BUYER` (`_isBuyer`), `ITSHOPPER` (`_isItShopper`), else `USER`.
199
- - **Personas:** `user._personaIds` is a colon-delimited string; `getLoginSettings` has special
200
- handling collapsing persona-24 cases for non-QUAD tenants.
161
+ - **Personas:** `user._personaIds` is a colon-delimited string; `getLoginSettings` has special handling collapsing persona-24 cases for non-QUAD tenants.
201
162
 
202
163
  ## Build & deploy
203
164
 
204
- - **Per-tenant dev/build scripts** (`package.json`): `compass`, `compasscanada`, `quad`,
205
- `togacommerce` run `vite --mode development --host <tenant>.togacommerce`; production builds are
206
- `build` / `buildAlpha` / `buildBeta` / `buildGamma` / `buildQcSecurity` (each installs the matching
207
- `@agilant/toga-blox` npm channel, then `tsc` + `vite build --mode <env>`).
208
- - `vite.config.ts`: React plugin + Sentry Vite plugin (sourcemap upload); dev `server.allowedHosts`
209
- = `compass.togacommerce`, `compasscanada.togacommerce`, `quad.togacommerce`; PostCSS via
210
- `postcss.config.cjs`; `optimizeDeps.include: ["react-router-dom"]`.
211
- - Local dev requires `*.togacommerce` hostnames to resolve to localhost (hosts file) so tenant
212
- resolution works.
213
- - Deployment runs on **AWS Amplify** — see [amplify-build-and-deploy](workflows/amplify-build-and-deploy.md).
214
- A `Dockerfile`/`docker-compose.yml` also exist for containerized dev.
165
+ - **Per-tenant dev/build scripts** (`package.json`): `compass`, `compasscanada`, `quad`, `togacommerce` run `vite --mode development --host <tenant>.togacommerce`; production builds are `build` / `buildAlpha` / `buildBeta` / `buildGamma` / `buildQcSecurity` (each installs the matching `@agilant/toga-blox` npm channel, then `tsc` + `vite build --mode <env>`).
166
+ - `vite.config.ts`: React plugin + Sentry Vite plugin (sourcemap upload); dev `server.allowedHosts` = `compass.togacommerce`, `compasscanada.togacommerce`, `quad.togacommerce`; PostCSS via `postcss.config.cjs`; `optimizeDeps.include: ["react-router-dom"]`.
167
+ - Local dev requires `*.togacommerce` hostnames to resolve to localhost (hosts file) so tenant resolution works.
168
+ - Deployment runs on **AWS Amplify** — see [amplify-build-and-deploy](workflows/amplify-build-and-deploy.md). A `Dockerfile`/`docker-compose.yml` also exist for containerized dev.
215
169
 
216
170
  ## Directory map (`src/`)
217
171
 
@@ -234,26 +188,8 @@ generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `save
234
188
 
235
189
  ## Gotchas
236
190
 
237
- - **`AuthLayout` never remounts** → `refetchOnMount` fires once per session; wire explicit
238
- invalidation for data that must refresh on navigation.
239
- - **24h `staleTime` + persisted cache** → users can see stale catalog/pricing; invalidate on the
240
- events that should bust it. **This also masks config/copy deploys:** `FIELDS` is served *through*
241
- the `["clientFields", …]` query, so a returning user rehydrates the old labels from
242
- `localStorage["commerce"]` and sees stale copy for up to 24h after a build that contains the new
243
- JSON. Verify any FIELDS change after `localStorage.removeItem('commerce')` or a logout, never on a
244
- warm browser. A build-version `buster` in `persistOptions` would fix this at deploy time but
245
- invalidates every persisted query app-wide — **open team decision, not implemented.**
246
- - **Tenant resolution depends on the hostname.** On `localhost` with no `*.togacommerce` host the
247
- first DNS label won't match a tenant, so config falls back (`getClientLoginFields` → COMPASS,
248
- theme → DEFAULT). Use the per-tenant dev scripts.
249
- - **Theme vs. fields language keys differ** — theme/field *folder* names are uppercase
250
- (`COMPASSCANADA`, `ENGLISH`/`FRENCH`) but the runtime `FIELDS` registry keys language as
251
- `en` / **`fr-CA`** (not `fr`). Any language branch must use `startsWith("fr")` — a `=== "fr"`
252
- comparison silently never matches. See the client-fields doc.
253
-
254
- ## Change history
255
- - 2026-07-28 — Gotchas: recorded that the 24h persisted React Query cache also masks `FIELDS`
256
- config/copy deploys (verify after clearing `localStorage["commerce"]`; `persistOptions.buster` is
257
- an open team decision), and corrected the runtime language key to `fr-CA` requiring
258
- `startsWith("fr")` (tcox)
259
- </content>
191
+ - **`AuthLayout` never remounts** → `refetchOnMount` fires once per session; wire explicit invalidation for data that must refresh on navigation.
192
+ - **24h `staleTime` + persisted cache** → users can see stale catalog/pricing; invalidate on the events that should bust it. **This also masks config/copy deploys:** `FIELDS` is served *through* the `["clientFields", …]` query, so a returning user rehydrates the old labels from `localStorage["commerce"]` and sees stale copy for up to 24h after a build that contains the new JSON. Verify any FIELDS change after `localStorage.removeItem('commerce')` or a logout, never on a warm browser. A build-version `buster` in `persistOptions` would fix this at deploy time but invalidates every persisted query app-wide — **open team decision, not implemented.**
193
+ - **Tenant resolution depends on the hostname.** On `localhost` with no `*.togacommerce` host the first DNS label won't match a tenant, so config falls back (`getClientLoginFields` → COMPASS, theme → DEFAULT). Use the per-tenant dev scripts.
194
+ - **Theme vs. fields language keys differ** — theme/field *folder* names are uppercase (`COMPASSCANADA`, `ENGLISH`/`FRENCH`) but the runtime `FIELDS` registry keys language as `en` / **`fr-CA`** (not `fr`). Any language branch must use `startsWith("fr")` — a `=== "fr"` comparison silently never matches. See the client-fields doc.
195
+ - **Tailwind: not every `<n>px` name is a token.** `max-h-300px` does not exist (300px is only on the height/width scales) — write `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist. A non-existent class silently renders unstyled.
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-10
9
+ updated: 2026-09-17
10
10
  owners: ["bala", "tcox"]
11
11
  files:
12
12
  - src/pages/Cart/CartPage.tsx
@@ -19,84 +19,47 @@ files:
19
19
  related:
20
20
  - 2.0/apps/toga2-commerce/features/cart-page-config-architecture.md
21
21
  - 2.0/apps/toga2-commerce/features/client-fields.md
22
+ - 2.0/apps/toga2-commerce/features/header-order-for-dropdown.md
22
23
  - 2.0/apps/toga2-commerce/workflows/cypress-testing.md
23
24
  ---
24
25
 
26
+ Cart Notifications section: CC-email duplicate prevention + auto-add/remove of order-for-user & delegate-manager emails; open when touching notification emails on the cart.
27
+
25
28
  ## Summary
26
- On the cart "Notifications" section a user can add CC email addresses to an order. Two jobs live
27
- here:
28
-
29
- 1. **Duplicate prevention.** Adding the same address twice (often with different casing) used to
30
- slip through to the API and blow up on submit with a MySQL 1062 duplicate-key error on the
31
- `SalesOrderEmailAddresses.salesOrderId_emailAddress` unique index (collation
32
- `utf8mb4_0900_ai_ci` is case-insensitive). The frontend blocks duplicates case-insensitively and
33
- shows a sapphire info message instead of letting the error reach the user.
34
- 2. **Auto-add/remove lifecycle.** Picking an **order-for user** or a **Delegate Manager**
35
- auto-adds their notification emails. **Changing the pick must remove the previous person's
36
- auto-added emails.** Until 2026-09-10 it did not: the old person and their manager stayed in the
37
- list, still checked, and still in the submit payload — so notifications went to the wrong
38
- people unless the buyer noticed and unchecked them.
39
-
40
- ## Key files / entry points
41
- - `CartPage.tsx` — `handleAddEmail`: validates the email regex, then on success calls
42
- `addEmailOption` (UI options list) and `addEmail` (the cart sales-quote store that builds the
43
- `salesOrderEmailAddresses` payload). Normalizes the input once with `.trim()`.
44
- - `CartForm.tsx` — owns the duplicate UX. `handleAddEmailWithDuplicateCheck` wraps the passed-in
45
- `handleAddEmail`: it case-insensitively checks the existing `emails` list and, on a match, shows
46
- the message instead of adding. The **Add Email** button calls this wrapper.
47
- - `CartPage.tsx` — also owns the **Delegate Manager** lifecycle:
48
- `advancedSelectConfig.delegateManager.onSelect` / `.onClearInput`, the
49
- `lastDelegateManagerEmailRef` (useRef), the `removeDelegateManagerEmail` guard, the
50
- `watch("hasDelegateManager")` and `watch("delegateManager")` effects, and `handleClearUser`.
51
- - `EditCart.tsx` — cart-checkout mode. The order-for change effect (`onSuccess` of the user fetch)
52
- owns add **and** remove of the order-for user's emails, plus the local `ensureEmailSelected`
53
- helper and the `orderForUserChanged` flag.
54
- - `EditOrder.tsx` — edit-order mode. Still has the **original add-only** order-for pattern.
55
- - `useEmailOptionsStore.ts` — `addEmailOption` de-dupes the checkbox list; `removeEmailOption`
56
- removes case-insensitively; `selectEmailOption(email)` marks an option checked (idempotent —
57
- never toggles).
58
- - `useCartSalesQuoteZu.ts` — `addEmail` de-dupes and `removeEmail` removes from the actual
59
- `salesOrderEmailAddresses` payload, both case-insensitively.
29
+
30
+ On the cart "Notifications" section a user can add CC email addresses to an order. Two jobs live here:
31
+
32
+ 1. **Duplicate prevention.** Adding the same address twice (often with different casing) used to slip through to the API and blow up on submit with a MySQL 1062 duplicate-key error on the `SalesOrderEmailAddresses.salesOrderId_emailAddress` unique index (collation `utf8mb4_0900_ai_ci` is case-insensitive). The frontend blocks duplicates case-insensitively and shows a sapphire info message instead of letting the error reach the user.
33
+ 2. **Auto-add/remove lifecycle.** Picking an **order-for user** or a **Delegate Manager** auto-adds their notification emails. **Changing the pick must remove the previous person's auto-added emails.** Until 2026-09-10 it did not: the old person and their manager stayed in the list, still checked, and still in the submit payload — so notifications went to the wrong people unless the buyer noticed and unchecked them.
60
34
 
61
35
  ## How it works
62
36
 
37
+ ### Key files / entry points
38
+ - `CartPage.tsx` — `handleAddEmail`: validates the email regex, then on success calls `addEmailOption` (UI options list) and `addEmail` (the cart sales-quote store that builds the `salesOrderEmailAddresses` payload). Normalizes the input once with `.trim()`.
39
+ - `CartForm.tsx` — owns the duplicate UX. `handleAddEmailWithDuplicateCheck` wraps the passed-in `handleAddEmail`: it case-insensitively checks the existing `emails` list and, on a match, shows the message instead of adding. The **Add Email** button calls this wrapper.
40
+ - `CartPage.tsx` — also owns the **Delegate Manager** lifecycle: `advancedSelectConfig.delegateManager.onSelect` / `.onClearInput`, the `lastDelegateManagerEmailRef` (useRef), the `removeDelegateManagerEmail` guard, the `watch("hasDelegateManager")` and `watch("delegateManager")` effects, and `handleClearUser`.
41
+ - `EditCart.tsx` — cart-checkout mode. The order-for change effect (`onSuccess` of the user fetch) owns add **and** remove of the order-for user's emails, plus the local `ensureEmailSelected` helper and the `orderForUserChanged` flag.
42
+ - `EditOrder.tsx` — edit-order mode. Still has the **original add-only** order-for pattern.
43
+ - `useEmailOptionsStore.ts` — `addEmailOption` de-dupes the checkbox list; `removeEmailOption` removes case-insensitively; `selectEmailOption(email)` marks an option checked (idempotent — never toggles).
44
+ - `useCartSalesQuoteZu.ts` — `addEmail` de-dupes and `removeEmail` removes from the actual `salesOrderEmailAddresses` payload, both case-insensitively.
45
+
63
46
  ### Duplicate prevention
64
- 1. **Before (manual add):** clicking Add Email runs `handleAddEmailWithDuplicateCheck` in
65
- `CartForm`. It compares `(e.email ?? "").trim().toLowerCase()` against the normalized input. On a
66
- match it sets `duplicateEmailMessage` and returns (does not add).
67
- 2. **After (auto-add on user select):** when the order user is chosen, their email (and manager's)
68
- is auto-added via `addEmailOption`. Both stores de-dupe case-insensitively, so a case-variant of
69
- an already-present address is silently dropped rather than duplicated.
70
- 3. **Display:** the message renders via the shared `InfoBanner` component (the same one used for the
71
- "view as" / "edit order" banners), styled `text-sapphire-700 text-xs`, no icon. It is NOT a
72
- toaster and NOT a red `setError` validation — it is a sapphire info message.
73
- 4. The message text comes from the `notifications.duplicateEmailAddressError.label` field config,
74
- present in every `CARTPAGE.ts` (all client/role/language variants, English + French).
75
- 5. A `useEffect` clears the message when the input, selected user (`orderForUser?.uuid`), or email
76
- list (`emails.length`) changes.
47
+ 1. **Manual add:** clicking Add Email runs `handleAddEmailWithDuplicateCheck` in `CartForm`. It compares `(e.email ?? "").trim().toLowerCase()` against the normalized input. On a match it sets `duplicateEmailMessage` and returns (does not add).
48
+ 2. **Auto-add on user select:** when the order user is chosen, their email (and manager's) is auto-added via `addEmailOption`. Both stores de-dupe case-insensitively, so a case-variant of an already-present address is silently dropped rather than duplicated.
49
+ 3. **Display:** the message renders via the shared `InfoBanner` component (the same one used for the "view as" / "edit order" banners), styled `text-sapphire-700 text-xs`, no icon. It is NOT a toaster and NOT a red `setError` validation — it is a sapphire info message.
50
+ 4. The message text comes from the `notifications.duplicateEmailAddressError.label` field config, present in every `CARTPAGE.ts` (all client/role/language variants, English + French).
51
+ 5. A `useEffect` clears the message when the input, selected user (`orderForUser?.uuid`), or email list (`emails.length`) changes.
77
52
 
78
53
  ### Order-for user — add AND remove (`EditCart.tsx`, cart-checkout mode only)
79
- Auto-added set per user: **their email**, `supervisorUser.email`, and
80
- `c_supportedByUserId.email`.
81
-
82
- 1. On success of the order-for user fetch, read the **previous** user from
83
- `useSelectedUserZu.getState().selectedUser` — the store, **not** the render closure (the closure
84
- is stale inside the query callback).
85
- 2. If the previous uuid differs from the new one (`orderForUserChanged`), remove that person's three
86
- auto-added addresses from **both** `useEmailOptionsStore` and
87
- `useCartSalesQuoteZu.salesOrder.salesOrderEmailAddresses`. **Never remove the logged-in user's
88
- own address** even if it matches one of the three.
89
- 3. Re-seed the new user's addresses through the local **`ensureEmailSelected(email)`** helper:
90
- `addEmailOption` → `selectEmailOption` → `addEmail`. Idempotent, so re-running it is safe.
91
- 4. The seeding block runs when `salesOrderEmailAddresses` is empty **or** when
92
- `orderForUserChanged`. Before the fix it ran only on empty, so the second pick's emails were
93
- added **un-checked** and never reached the payload.
54
+ Auto-added set per user: **their email**, `supervisorUser.email`, and `c_supportedByUserId.email`.
55
+
56
+ 1. On success of the order-for user fetch, read the **previous** user from `useSelectedUserZu.getState().selectedUser` — the store, **not** the render closure (the closure is stale inside the query callback).
57
+ 2. If the previous uuid differs from the new one (`orderForUserChanged`), remove that person's three auto-added addresses from **both** `useEmailOptionsStore` and `useCartSalesQuoteZu.salesOrder.salesOrderEmailAddresses`. **Never remove the logged-in user's own address** even if it matches one of the three.
58
+ 3. Re-seed the new user's addresses through the local **`ensureEmailSelected(email)`** helper: `addEmailOption` → `selectEmailOption` → `addEmail`. Idempotent, so re-running it is safe.
59
+ 4. The seeding block runs when `salesOrderEmailAddresses` is empty **or** when `orderForUserChanged`. Before the fix it ran only on empty, so the second pick's emails were added **un-checked** and never reached the payload.
94
60
 
95
61
  ### Delegate Manager email lifecycle (`CartPage.tsx`, cart-checkout mode)
96
- "Associate" in the Compass UI = Delegate Manager. Its email is tracked by
97
- **`lastDelegateManagerEmailRef`** (a `useRef`), because the form value cannot be used (see
98
- Gotchas). The email is parsed out of the option label, which has the shape
99
- `"First Last (email)"`.
62
+ "Associate" in the Compass UI = Delegate Manager. Its email is tracked by **`lastDelegateManagerEmailRef`** (a `useRef`), because the form value cannot be used (see Gotchas). The email is parsed out of the option label, which has the shape `"First Last (email)"`.
100
63
 
101
64
  | Action | What happens |
102
65
  |---|---|
@@ -106,80 +69,33 @@ Gotchas). The email is parsed out of the option label, which has the shape
106
69
  | Clear the order-for user | `handleClearUser` also un-checks the box, empties the field, and clears the ref |
107
70
  | Restore a saved cart | the `watch("delegateManager")` effect seeds the ref; a restored name has no `"(email)"`, so it falls back to `salesOrder.approvalUser.email` |
108
71
 
109
- **`removeDelegateManagerEmail` never removes an address that is also** the order-for user's, their
110
- supervisor's, their support tech's, or the logged-in user's — those are owned by the order-for
111
- lifecycle above.
112
-
113
- ## Data model
114
- Frontend only. The payload list maps to the `SalesOrderEmailAddresses` table (api2/backend), which
115
- has a case-insensitive unique index on `(salesOrderId, emailAddress)`.
116
-
117
- ## Client variations
118
- The de-dupe mechanism is uniform across clients. Only the message label text differs per client
119
- config: English `"This email has already been added"`; French (Compass Canada)
120
- `"Cette adresse e-mail a déjà été ajoutée"`. The label must exist in every `CARTPAGE.ts` variant or
121
- the message resolves to `undefined` and renders blank.
122
-
123
- ## Gotchas / known issues
124
- - The real source of the 1062 was `useCartSalesQuoteZu.addEmail` comparing with `===`
125
- (case-sensitive). Both stores must compare case-insensitively; fixing only the UI list is not
126
- enough because `addEmail` builds the payload.
127
- - **Removes must be case-insensitive too, not just adds.** Both stores' *adds* were already
128
- case-insensitive while their *removes* used exact match, so a hand-typed `Bob@X.com` survived a
129
- remove of `bob@x.com` and shipped in the payload. `removeEmailOption` and `removeEmail` are now
130
- case-insensitive.
131
- - **`handleEmailChange` TOGGLES — never use it to seed.** Re-running it on an already-checked
132
- address **un-checks** it. Use the idempotent `ensureEmailSelected` /
133
- `selectEmailOption` path for any auto-seeding.
134
- - **`CartFormSection` overwrites the form value BEFORE your handler runs.** It calls
135
- `field.onChange(value)` and *then* `config.onSelect(value, valueKey)` (same for
136
- `field.onChange(null)` before `config.onClearInput`). So
137
- `formMethods.getValues("delegateManager")` inside those handlers already holds the **new** value
138
- — you **cannot** read the previous pick from the form. Keep the previous value in a `useRef`.
139
- - **Read the previous order-for user from the store, not the closure.** Inside the user-fetch
140
- `onSuccess` the render closure's `selectedUser` is stale; use
141
- `useSelectedUserZu.getState().selectedUser`.
142
- - **`delegateManager` defaults to a truthy object** (`{uuid:"",name:""}`) now that it is a typed
143
- form field, so `if (formValues.delegateManager)` is always true. Check
144
- `formValues.delegateManager?.uuid` instead (3 call sites: `EditCart.tsx` ×2, `EditOrder.tsx` ×1).
145
- - **`EditOrder.tsx` (edit-order mode) still has the add-only order-for pattern** — the same
146
- wrong-recipient bug very likely exists there. Left alone deliberately: edit-order has different
147
- manager rules (`assignedTo`). Fix it as its own task.
148
- - Normalize with `(x ?? "").trim().toLowerCase()`, not `x?.trim().toLowerCase()` — both are
149
- nullish-safe (optional chaining short-circuits the whole chain, it does not throw), but `(x ?? "")`
150
- guarantees a string and avoids an `undefined === undefined` match edge.
151
- - Adding the label to only some `CARTPAGE.ts` files leaves other client/role/language users with a
152
- blank message.
72
+ **A failed order-for user fetch runs the same `handleClearUser()`** (plus an error toast) instead of only reporting to Sentry, so the ref, the emails and the field are cleared on that path too. Same doc for the other new entry point: the header pill sets `orderFor` through `useOrderForZu`, then this identical cascade runs — see [header-order-for-dropdown](header-order-for-dropdown.md). Both are branch `TRUE-81851`, not yet merged.
73
+
74
+ **`removeDelegateManagerEmail` never removes an address that is also** the order-for user's, their supervisor's, their support tech's, or the logged-in user's — those are owned by the order-for lifecycle above.
75
+
76
+ ### Data model
77
+ Frontend only. The payload list maps to the `SalesOrderEmailAddresses` table (api2/backend), which has a case-insensitive unique index on `(salesOrderId, emailAddress)`.
78
+
79
+ ### Client variations
80
+ The de-dupe mechanism is uniform across clients. Only the message label text differs per client config: English `"This email has already been added"`; French (Compass Canada) `"Cette adresse e-mail a déjà été ajoutée"`. The label must exist in every `CARTPAGE.ts` variant or the message resolves to `undefined` and renders blank.
81
+
82
+ ## Gotchas
83
+
84
+ - The real source of the 1062 was `useCartSalesQuoteZu.addEmail` comparing with `===` (case-sensitive). Both stores must compare case-insensitively; fixing only the UI list is not enough because `addEmail` builds the payload.
85
+ - **Removes must be case-insensitive too, not just adds.** Both stores' *adds* were already case-insensitive while their *removes* used exact match, so a hand-typed `Bob@X.com` survived a remove of `bob@x.com` and shipped in the payload. `removeEmailOption` and `removeEmail` are now case-insensitive.
86
+ - **`handleEmailChange` TOGGLES — never use it to seed.** Re-running it on an already-checked address **un-checks** it. Use the idempotent `ensureEmailSelected` / `selectEmailOption` path for any auto-seeding.
87
+ - **`CartFormSection` overwrites the form value BEFORE your handler runs.** It calls `field.onChange(value)` and *then* `config.onSelect(value, valueKey)` (same for `field.onChange(null)` before `config.onClearInput`). So `formMethods.getValues("delegateManager")` inside those handlers already holds the **new** value — you **cannot** read the previous pick from the form. Keep the previous value in a `useRef`.
88
+ - **Read the previous order-for user from the store, not the closure.** Inside the user-fetch `onSuccess` the render closure's `selectedUser` is stale; use `useSelectedUserZu.getState().selectedUser`.
89
+ - **`delegateManager` defaults to a truthy object** (`{uuid:"",name:""}`) now that it is a typed form field, so `if (formValues.delegateManager)` is always true. Check `formValues.delegateManager?.uuid` instead (3 call sites: `EditCart.tsx` ×2, `EditOrder.tsx` ×1).
90
+ - **`EditOrder.tsx` (edit-order mode) still has the add-only order-for pattern** — the same wrong-recipient bug very likely exists there. Left alone deliberately: edit-order has different manager rules (`assignedTo`). Fix it as its own task.
91
+ - Normalize with `(x ?? "").trim().toLowerCase()`, not `x?.trim().toLowerCase()` — both are nullish-safe (optional chaining short-circuits the whole chain, it does not throw), but `(x ?? "")` guarantees a string and avoids an `undefined === undefined` match edge.
92
+ - Adding the label to only some `CARTPAGE.ts` files leaves other client/role/language users with a blank message.
153
93
  - Do not use a toaster or `setError` (red) for this — product wants the sapphire info style.
154
- - **e2e coverage is branch-dependent — check before you rely on it.** On `_production`,
155
- `cypress/e2e/cartPage/cartV2.cy.ts` covers both the auto-population (order-for user + supervisor
156
- emails, protected rows unremovable) and the case-insensitive duplicate block surfacing the
157
- *"This email has already been added"* banner — the exact parity behaviors the config-cart
158
- refactor spike silently lost, so keep it green (see
159
- [cypress-testing](../workflows/cypress-testing.md)). But on `#sprint86` that file **does not
160
- exist** and every spec in `cypress/e2e/cartPage/cart.cy.ts` is **commented out**, so there is no
161
- net for this flow there. Verify manually on such a branch.
162
- - **ESLint cannot run in this repo checkout** (pre-existing): `.eslintrc.cjs` references
163
- `eslint-plugin-react-compiler`, which is not installed. Type-check with
164
- `npx tsc --noEmit -p tsconfig.app.json` instead.
165
-
166
- ## Change history
167
- - 2026-09-10 — FIXED: changing the order-for user or the Delegate Manager left the previous
168
- person's auto-added emails checked and in the submit payload, so notifications went to the wrong
169
- people (reported by a Compass USA user on `compass.togacommerce.com`; fix is client-neutral).
170
- Order-for path (`EditCart.tsx`) now removes the prior user's three auto-added addresses and
171
- re-seeds via the idempotent `ensureEmailSelected` (replacing the toggling `handleEmailChange`),
172
- and seeds on `orderForUserChanged` as well as on empty. Delegate Manager path (`CartPage.tsx`)
173
- gained `lastDelegateManagerEmailRef` + `removeDelegateManagerEmail` covering pick / re-pick / X /
174
- un-check / clear-user / restore. Stores: added `selectEmailOption`, made `removeEmailOption` and
175
- `removeEmail` case-insensitive. `hasDelegateManager` / `delegateManager` are now typed form fields
176
- with defaults, so delegate checks moved to `?.uuid`. Edit-order mode not fixed (different manager
177
- rules). (tcox)
178
- - 2026-07-27 — No behavior change. This duplicate-prevention UX (email auto-population +
179
- case-insensitive duplicate banner) is now covered by cart e2e slice 1 (`cartV2.cy.ts`); linked
180
- the [cypress-testing](../workflows/cypress-testing.md) workflow doc. (tcox)
181
- - 2026-06-18 — Initial: case-insensitive de-dupe in both cart stores, sapphire `InfoBanner` message in
182
- CartForm, `duplicateEmailAddressError` label added to all CARTPAGE.ts variants (bala)
183
-
184
- ## Related docs
94
+ - **e2e coverage is branch-dependent — check before you rely on it.** On `_production`, `cypress/e2e/cartPage/cartV2.cy.ts` covers both the auto-population (order-for user + supervisor emails, protected rows unremovable) and the case-insensitive duplicate block surfacing the *"This email has already been added"* banner — the exact parity behaviors the config-cart refactor spike silently lost, so keep it green (see [cypress-testing](../workflows/cypress-testing.md)). But on `#sprint86` that file **does not exist** and every spec in `cypress/e2e/cartPage/cart.cy.ts` is **commented out**, so there is no net for this flow there. Verify manually on such a branch.
95
+ - **ESLint cannot run in this repo checkout** (pre-existing): `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not installed. Type-check with `npx tsc --noEmit -p tsconfig.app.json` instead.
96
+
97
+ ## Related
98
+
99
+ - [cart-page-config-architecture](cart-page-config-architecture.md)
100
+ - [client-fields](client-fields.md)
185
101
  - [cypress-testing](../workflows/cypress-testing.md) — e2e coverage that pins this behavior.