softr-vibe-coding 2.15.3 → 2.16.0

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,370 @@
1
+ # QA of a Softr Vibe Coding app
2
+
3
+ How to QA an app made of Vibe Coding blocks, end to end, in the order to run it. This file is the
4
+ procedure and the reasons behind it. The mechanics are linked, not repeated: the browser commands
5
+ are in [browser-checks.md](browser-checks.md), the MCP in [softr-mcp.md](softr-mcp.md) and the data
6
+ shapes in [datasources/](../datasources/overview.md).
7
+
8
+ **Verified 2026-10-08.** Written from one end-to-end QA pass of a 15-block back-office app on Softr
9
+ Database (LCDB QA pass): 68 findings, two rounds of fixes and a live write pass. Each rule below
10
+ cost someone a wrong result first.
11
+
12
+ ## When to use it
13
+
14
+ When an app's blocks are built and pushed (each push has passed the hash check in
15
+ [softr-mcp.md → Verifying a push](softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)),
16
+ before the client relies on it, and again after each round of fixes. For a quick look at one
17
+ block, [browser-checks.md](browser-checks.md) is enough.
18
+
19
+ ## The order of a pass
20
+
21
+ 1. [Set-up](#set-up): which app to test, with which build, in which zone.
22
+ 2. [Never writing by accident](#never-writing-by-accident): the guard first, the proof last.
23
+ 3. [Roles](#roles): each page as each kind of user.
24
+ 4. [Logged out](#logged-out): access from outside the browser.
25
+ 5. [Measuring](#measuring): sizes, and numbers instead of looks.
26
+ 6. [Checking numbers against the database](#checking-numbers-against-the-database).
27
+ 7. [Testing edges on purpose](#testing-edges-on-purpose): failures, clocks, odd values.
28
+ 8. [Proving a fix before and after](#proving-a-fix-before-and-after).
29
+ 9. [Live write pass with a read-only checker](#live-write-pass-with-a-read-only-checker), only when
30
+ a fix needs a real save.
31
+ 10. [Running QA with several agents and skeptics](#running-qa-with-several-agents-and-skeptics).
32
+
33
+ ## Set-up
34
+
35
+ - **Check changes in the preview (the draft), not the live app.** A push reaches the draft, and the
36
+ live app changes only when someone publishes. Only two things run on the published app: the
37
+ [logged-out checks](#logged-out) and the re-check after a publish.
38
+ - **Mint a fresh preview link for each session and after every push** (`application_preview`). The
39
+ link is a sign-in token: never print it, store it, or write it into a script file
40
+ ([browser-checks.md → Gotchas](browser-checks.md#gotchas)).
41
+ - **Prove which build is served before you judge a change**
42
+ ([browser-checks.md → step 2](browser-checks.md#2-session-preview-cookie-page)).
43
+ - **Run every check in the client's time zone**
44
+ ([browser-checks.md → step 1](browser-checks.md#1-the-clients-time-zone)).
45
+ - **Give each agent its own `--session` name.** Agents that share an agent-browser session
46
+ overwrite each other's pages.
47
+ - **Log times with `date -u`.** The machine's zone may not be the client's: the pass ran on a Mac at
48
+ UTC+3 for an app used at UTC−7, and UTC is the one clock that the stored stamps use.
49
+
50
+ Why: agents judged fixes on stale builds, shared one browser session across parallel agents and
51
+ logged times in the wrong zone (LCDB QA pass, 2026-10-08).
52
+
53
+ ## Never writing by accident
54
+
55
+ The preview is wired to the live data
56
+ ([softr-mcp.md](softr-mcp.md#testing-as-any-app-user-without-logins--the-preview-as-switcher)), so a
57
+ pass against a client's database has to leave no trace.
58
+
59
+ - **Block every save before the first click, and prove the block works**
60
+ ([browser-checks.md → step 6](browser-checks.md#6-block-saves-before-any-click-and-prove-it)).
61
+ Probe again after every page load.
62
+ - **Press Save on purpose only with the guard proven**, and after you have read the save's own
63
+ guard in the source. Confirm in the request log that each write was aborted. A write that gets
64
+ through is reported at once, with its record id.
65
+ - **Afterwards, prove with MCP reads that nothing was written:**
66
+ - exact per-table counts against a baseline taken before the pass;
67
+ - the records you opened, by `updatedAt` (get the record);
68
+ - rows whose own fields point at the QA day or the QA logins (entered by, updated by, a date
69
+ field).
70
+
71
+ `database_search_records` ignores a sort on `updatedAt` and rejects a filter on `id`, so do not
72
+ rely on either to show "nothing unlisted" (LCDB write pass checker, 2026-10-08).
73
+ - **A race you cannot reproduce live with writes blocked stays in the local harness**, and the
74
+ report says so. "A second row still saving" is one: the first write fails at once, so the second
75
+ is never reached.
76
+ - To put a state on screen without changing data, see [Forcing
77
+ states](browser-checks.md#forcing-states).
78
+
79
+ ## Roles
80
+
81
+ Role bugs were found only when a pass compared roles side by side: hints that pointed users to pages
82
+ they could not open, admin pages that opened blank for volunteers, and an update action with no
83
+ record condition (LCDB QA pass, 2026-10-08).
84
+
85
+ - **Check each page as an administrator, as an ordinary user, and as a logged-in user in no
86
+ group.** Impersonate rather than log in
87
+ ([softr-mcp.md → Preview as](softr-mcp.md#testing-as-any-app-user-without-logins--the-preview-as-switcher)).
88
+ - **Read each block's Visibility first.** A block gated to one group does not render for the
89
+ others, so its role-dependent copy cannot be checked from those roles.
90
+ - **Use the administrator as the positive control.** Count the controls the lower role must not see
91
+ (void buttons, adjustments): an administrator saw 8 and 13 void buttons on two records where the
92
+ volunteer saw none, only a hint. A missing control is then proven, not just unseen.
93
+ - **Prove each impersonation took effect.** After `fetch('/studio/impersonate/<softrUserId>')` on
94
+ the preview origin, reopen the direct page URL and read the global:
95
+
96
+ ```js
97
+ JSON.stringify(window.__softr_current_user) // name, email, avatar, userGroups
98
+ ```
99
+
100
+ On a direct page URL it sits in the top window; only the toolbar shell lacks it, where it is in
101
+ the app iframe ([Preview as](softr-mcp.md#testing-as-any-app-user-without-logins--the-preview-as-switcher)).
102
+ The `fetch` returns 200 even before the page switches user, so read the global after reopening.
103
+ Take `<softrUserId>` from `application_list_users`, which lists manual memberships only: a
104
+ conditionally matched group shows only in this global
105
+ ([softr-mcp.md → Condition-based user groups](softr-mcp.md#condition-based-user-groups)).
106
+ - **Read what each role is told, not only what it can click.** An empty state or a hint that sends a
107
+ role to a page it cannot open is a bug: three blocks pointed volunteers at Settings.
108
+ - **Check page visibility for every role**, and **every UPDATE or DELETE action on a per-user
109
+ table.** At "logged-in users" with no record condition, any login can overwrite another user's
110
+ row. What the server does and does not enforce is in
111
+ [softr-mcp.md](softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
112
+
113
+ ## Logged out
114
+
115
+ A publish can change what an anonymous visitor reaches, and nothing in the browser session shows it.
116
+ After every publish, check from outside the browser:
117
+
118
+ ```bash
119
+ HOST=<subdomain>.softr.app # or the app's custom domain
120
+ curl -sI "https://$HOST/<page>" | grep -i -E '^(HTTP|location)'
121
+ # HTTP/… 301
122
+ # location: /login?next-page=/<page>
123
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'content-type: application/json' -d '{}' \
124
+ "https://$HOST/v1/datasource/applications/<app>/pages/<page>/blocks/<block>/datasources/<name>/records"
125
+ # 403
126
+ curl -s -o /dev/null -w '%{http_code}\n' "https://$HOST/sign-up" # 404 when sign-up is off
127
+ ```
128
+
129
+ - **Every page that is not meant to be public** answers with a 301 to
130
+ `/login?next-page=<path>`. An app with a public landing page returns 200 there.
131
+ - **A POST to a block's records endpoint with no session** answers 403. The published-host form of
132
+ this URL is inferred from the one the preview calls (on `<subdomain>.preview.softr.app`); the 403
133
+ was seen on 2026-10-08.
134
+ - **If sign-up is meant to be off**, `/sign-up` returns 404, and on `/login` the public
135
+ `window.application_context` shows `signUpSettings.policy` as `DISABLED` (a read-only check).
136
+ - **Never POST to a `records-trigger` URL to test a write endpoint.** If the action is open, it
137
+ writes. Read the block's action permissions through the MCP instead (a push puts them back to
138
+ Softr's defaults: [softr-mcp.md](softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)).
139
+
140
+ ## Measuring
141
+
142
+ Measure, do not eyeball. The rules are in
143
+ [browser-checks.md → step 4](browser-checks.md#4-measuring-with-eval): prove the console capture on
144
+ every load, numbers for heights and scroll widths, an error inside its dialog's edges, a shadow root
145
+ without `innerText`, and the sizes to check, which include the width a block really gets beside a
146
+ sidebar.
147
+
148
+ - **Hit-test in two parts before a coordinate click.** `document.elementFromPoint` must be the
149
+ block's host and `root.elementFromPoint` the element you mean. The shadow root alone ignores
150
+ Softr's fixed phone tab bar, so a phone-size test that uses only it passes under the bar
151
+ ([browser-checks.md → step 3](browser-checks.md#3-reaching-into-the-block)).
152
+ - **Look at the whole page, not just the part you changed.** Two buttons wrapping onto a second line
153
+ at 1280 were noticed during an unrelated focus check and fixed the next day.
154
+
155
+ ## Checking numbers against the database
156
+
157
+ A page can look right and be wrong, and a browser cannot tell. Compare it with the database.
158
+
159
+ - **Recompute every figure with the MCP's aggregate and list reads**, using the client's month
160
+ boundaries and the app's own exclusions (voided rows, records entered in error). Compare each
161
+ page's figure with that number, and with the same figure on every other page that shows it.
162
+ - **Hold one counting rule per figure.** The "same number on every page" lens found 7 of the 15
163
+ major findings: allotments counted as served, received with or without repacked bulk, headcount
164
+ per row or per person. Where two pages cannot show the same number, compare it term by term
165
+ against the database and word the check "same rule, same terms".
166
+ - **Before you trust a boundary, check that rows exist on the boundary days** (Dec 31 and Jan 1, the
167
+ last and first day of each month in range). If none do, say so: every total matches with or
168
+ without a one-day bug. Base the boundary on the code (dates compared as `yyyy-mm-dd` text) and on
169
+ a one-day range read against the server. In one report the nearest rows were Dec 29 and Jan 2.
170
+ - **Read the live table before you trust a counting rule.** Old or migrated rows may have empty
171
+ links and drop out of past years. Check what one linked row stands for: a link counted child
172
+ rows, not visits, with voided rows included.
173
+ - **Pair each before and after with an independent count.** `DISTINCT` on a link field counted
174
+ distinct people in one call (it worked in one run and was refused in another): 57 + 296 + 43 = 396
175
+ showed that a tile reading 442 was wrong, and later matched the fixed tile. When the call is
176
+ refused, count the people from the rows.
177
+ - **Recompute the expected figure at check time.** Never use a baseline plus deltas from notes:
178
+ when agents write at the same time, the shared totals move under you.
179
+ - **Check one complete past year as well as the year in progress**, and for a header-and-lines
180
+ ledger make the period's line count equal the sum of the headers' link counts (an `IS_EMPTY`
181
+ filter on the line's header link must return 0).
182
+ - **A reconciliation flag must be seen to fire, or it is no guard.** A "table total against pivot
183
+ total" flag built from the same ledger rows cannot detect an orphaned row, because both sides
184
+ include it: build the fixture and watch it fire.
185
+ - **Rows shown rounded to two decimals can differ from their total by 0.01 per row.** That is
186
+ display rounding, not a bug: allow a tolerance of 0.01 times the row count.
187
+ - **Name the figure you now expect after each fix** (for example "442 → 396"), so the fixer and the
188
+ final cross-check prove the fix with a number.
189
+ - **Date-only values and boundaries** have their own steps: [browser-checks.md → step
190
+ 5](browser-checks.md#5-date-only-values-against-the-stored-ones).
191
+ - **Every filter must be seen to narrow the result**, with row counts with and without it: filters
192
+ fail open ([reading.md](../datasources/reading.md#filters-fail-open)).
193
+ - **Prefer filter-only calls with one metric each**, and check that the parts add up to the
194
+ unfiltered total. The aggregate tool refused some combinations in the pass, and its limits are in
195
+ [softr-mcp.md → Softr Database tools](softr-mcp.md#softr-database-tools). Leave voided rows out
196
+ with an `IS_NOT true` filter, and for a handful of rows read them with `database_search_records`
197
+ and add them up.
198
+
199
+ ## Testing edges on purpose
200
+
201
+ The happy path passed everywhere. The real bugs were on these edges, and several checks passed
202
+ without reaching the step they asserted (LCDB QA pass, 2026-10-08). The mechanics for the
203
+ first four are in [browser-checks.md → Forcing states](browser-checks.md#forcing-states).
204
+
205
+ - **A fake clock**: month end, year end, a clock change.
206
+ - **Held saves and double taps** ([step 7](browser-checks.md#7-click-then-read-what-it-sent)).
207
+ - **Partial and total read failures**, then every Try again and every section after it.
208
+ - **Served reads** for empty-like values: `null`, `''`, `0`, and the field missing. A blank number
209
+ is not zero: `Number(null)` and `Number("")` are both 0, and a partner with no monthly allocation
210
+ showed 0.
211
+ - **Detail pages with no id, and with an id that does not exist.** One page said "Partner not
212
+ found"; another opened an editable "Unnamed volunteer" that accepted Log hours; a third offered
213
+ Try again forever.
214
+ - **Keyboard number entry.** `fill` cannot enter unreadable text, so type quantities with the
215
+ keyboard, in Firefox as well, and probe with `5e`. What goes wrong and how to refuse it is in
216
+ [ui-ux-guidelines.md §11](../ui-ux-guidelines.md#number-inputs).
217
+ - **Retry after an edit**, and **leaving mid-save**
218
+ ([step 7](browser-checks.md#7-click-then-read-what-it-sent)).
219
+ - **Reusing the form after a save, without a reload.** A form filled by an effect keyed on query
220
+ data was not refilled when the re-read returned the same data.
221
+ - **A void that leaves rows of zeros** in a per-entity totals table: a report built from ledger
222
+ links includes entities whose rows all net to zero.
223
+ - **Logged out** and **roles**: the two sections above.
224
+
225
+ Two rules for every one of these:
226
+
227
+ - **Pick test values where the old and the new behaviour give different results, and that do not
228
+ collide with seed data.** A size at 2,180 on hand is Low at a default of 2,500 but not at 2,000, so
229
+ the check proves the setting was read. A five-digit test phone number collided with a seed family
230
+ and opened the duplicate panel.
231
+ - **A negative check must reach the step it asserts.** Prove it got there: the request was or was not
232
+ sent, the guard's message showed. `ab click` on a disabled calendar day prints "Done" and does
233
+ nothing, so a refused day is proven by its disabled state, not by the click.
234
+
235
+ ## Proving a fix before and after
236
+
237
+ A fix that passes against a friendly stub can fail live, and a check that cannot fail on the old
238
+ code proves nothing. Prove each fix with a figure or a failing-then-passing check.
239
+
240
+ ### A local harness
241
+
242
+ React 18.2 in a shadow root with Tailwind v4, the block file bundled exactly as it is, fake data
243
+ seeded from live rows. Run the same test on the deployed version (it must fail) and on the fix (it
244
+ must pass): a date fix showed 18 failures on the deployed copy and none after.
245
+
246
+ - **Match the block's real width.** Either a fake sidebar of the measured width at the real window
247
+ size, or no sidebar and a viewport narrowed to the block's measured width (985px copied one app's
248
+ 1280 window). Key a table-or-stack switch on the content's own `@container`, and confirm the
249
+ container-query branches in the real app, since a harness at the wrong width picks the wrong one.
250
+ - **Copy Softr's `scroll-behavior: smooth` on `html`.** Without it, block code that scrolls and then
251
+ measures passes in the harness and fails live (a calendar landed outside a landscape phone's
252
+ strip). Put any tab-bar stand-in in the light DOM at document level, as Softr's is.
253
+ - **Load the real table rows.** With them the harness matched the live column widths to the pixel,
254
+ and a column min-width fix that sounded right pushed every row button out of its card at 1280
255
+ until it was measured on the real rows. MCP records keyed by field id load into the harness as
256
+ they are.
257
+ - **Honest stubs:**
258
+ - blanks as `''` and as `null` (real blanks arrive both ways);
259
+ - a link field sometimes a single `{id, label}` object, not an array;
260
+ - `where` honoured, with a switch to ignore it: that proves the client-side filter still shows
261
+ the right rows if the server ignores `where`;
262
+ - `onError` called when a write rejects (a stub that ignores it hides the deployed block's error
263
+ toasts, and "no toast" is then an artifact);
264
+ - no list refresh after a write, and a re-read with the same rows returns the same `data`
265
+ object (otherwise a reset and a refetch land in one render and mask a refill bug);
266
+ - switches to fail the Nth write and to add per-table delays, and `root.unmount()` exposed so a
267
+ test can leave mid-save.
268
+ - **Clear `sessionStorage` between tests that share a tab.** A draft feature leaked between tests
269
+ and turned two unrelated date tests red.
270
+
271
+ ### A check that can fail
272
+
273
+ - **A check proves something only if it fails on the pre-fix bundle.** Run it there and report both
274
+ results (3 of 11 checks passed on the base, 11 of 11 on the fix).
275
+ - **Rebuild the bundle after every edit**, stubs included. A printout-against-screen check passed
276
+ on a stale bundle because both sides were stale.
277
+ - **Run the full suite before you quote counts.** A filtered run overwrote a results file with one
278
+ test.
279
+ - **Treat older suites as a no-new-fails baseline.** They fail for stale reasons (a button that is
280
+ gone, a removed default, changed copy): count fails against the previous baseline, not against zero.
281
+ - **Make every lookup require its match** ([browser-checks.md → step
282
+ 4](browser-checks.md#4-measuring-with-eval)).
283
+ - **Diff the page's text before and after, against a whitelist of the intended lines.** It catches
284
+ copy changes nobody meant.
285
+ - **Test pure logic in node.** Logic moved out of `Block()` can be compared with the old function
286
+ over thousands of generated inputs in several time zones (3,007 date ranges in three zones), and a
287
+ multi-source block renders to HTML with a `useRecords` stub keyed on `from`. Cut the logic out
288
+ between marker comments and run it with `esbuild` and `vm`, so the test runs the shipped code and
289
+ not a copy.
290
+ - **Check `uptime` before blaming a timing test.** A load average of 111 broke a 400 ms check that
291
+ passed alone.
292
+
293
+ ### Which engine ran
294
+
295
+ - **Report the engine that actually ran each check.** A harness launcher quietly ran Chromium when
296
+ asked for WebKit, and one Firefox build would not launch, so cross-engine claims were Chromium-only.
297
+ - **Test focus and number entry in Firefox as well as Chromium.** Safari and macOS Firefox do not
298
+ focus a clicked button, so code that returns focus to "the button that opened this" must be handed
299
+ that button; number fields differ by engine and by the Mac's region
300
+ ([Testing edges](#testing-edges-on-purpose)).
301
+ - **For WebKit without a download**, a small Swift `WKWebView` runner can load the harness page; it
302
+ found a WebKit-only scroll bug.
303
+ - **Give each parallel harness its own port.** A shared fixed-port Firefox wrapper killed other
304
+ agents' runs.
305
+ - **Firefox quirk:** it reports a `console.error` of an error object as just "Error".
306
+
307
+ ## Live write pass with a read-only checker
308
+
309
+ Some fixes can be proven only by a real save. Run a write pass only then, and only with the app
310
+ owner's go-ahead: the preview writes live data. On 2026-10-08 it ran after the owner said yes.
311
+
312
+ - **Use clearly marked test data**, and keep a manifest of record ids per table so that it can be
313
+ purged before go-live.
314
+ - **A test row must not create a user.** If a table is synced as the app's users table, a row with an
315
+ email creates an app user (and an invite, if the sync sends one). Test rows carry no email;
316
+ compare `application_list_users` before and after (the same users, no "QA" match).
317
+ - **Prove every save by reading it back through the MCP.** Do not rely on the logged response: an
318
+ aborted save has none, and one write-pass run showed no response body for a save. The request log
319
+ still shows the payload (`postData`) ([browser-checks.md → step
320
+ 7](browser-checks.md#7-click-then-read-what-it-sent)).
321
+ - **With several agents writing, verify through your own rows and the links they carry**, not the
322
+ shared totals. A product's total moved by an unrelated −50 while the pass ran; other agents' rows
323
+ are the ones entered after your start time.
324
+ - **Put residue in the manifest.** Saving a setting and putting it back restores the value but not
325
+ `updated_by`, which stays on the editor.
326
+ - **Run a separate read-only checker afterwards** that recomputes the figures and the counts
327
+ ([Never writing by accident](#never-writing-by-accident),
328
+ [Checking numbers](#checking-numbers-against-the-database)).
329
+ - **Re-check live after publishing:** the [logged-out checks](#logged-out) again, every block's
330
+ deployed hash against your copy, and the checker's figures once more.
331
+
332
+ ## Running QA with several agents and skeptics
333
+
334
+ - **Split agents by job, not by page.** Our lenses were front desk at the door, warehouse stock,
335
+ partner agencies, volunteer coordination, and reports and settings, plus one for the "same number
336
+ on every page" and one for roles. A person doing one job crosses several pages, and that is
337
+ where the bugs hide.
338
+ - **Tell every agent what is already known and decided**, with a link to the decision log, and
339
+ which items are deferred on purpose. Otherwise they re-report settled behaviour: a live check that
340
+ did not know a shared-component change had been rejected listed it as a failure on 11 blocks, and
341
+ the repair agents then synced the rejected change into every one of them.
342
+ - **A skeptic re-runs and recomputes each finding** and drops what does not reproduce.
343
+ - **A gap agent goes last.** It looks for what nobody checked (pages, controls, sizes, roles, month
344
+ and year ends, empty and failed states) and checks those itself.
345
+ - **Only the orchestrator edits shared documents** (the app map, the decision log, the task list),
346
+ and the prompt says so. Check anyway: one agent edited the app map despite the instruction.
347
+ Project memory reaches subagents too: after "log QA lessons in this file" was saved as a memory,
348
+ agents appended to it themselves, and two agents writing one file at once can lose an edit. Say
349
+ in the prompt whether agents may write to it.
350
+ - **Give agents an absolute scratch folder** for screenshots and scratch files: eight PNGs landed in
351
+ the project root, an agent's working directory.
352
+ - **Verify an agent's claim before you act on it.** Two agents reported the page and block ids
353
+ swapped in the app map; the map was correct.
354
+ - **For parallel work**, give each agent its own browser session and harness port, and pinned
355
+ copies of files another agent may be editing. Another session may be working on the same app: on
356
+ 2026-10-08 a second session pushed fixes to five blocks and published the app while the pass ran,
357
+ so scratch copies went stale. Compare the deployed hash with your copy right before editing and
358
+ right before pushing, and start from the project's mirror, not a scratch copy.
359
+ - **A reviewer re-reads the deployed hash, regenerates the diff, reruns the harness and checks that
360
+ the tests a report cites exist.** One review cited a test that was not on disk.
361
+ - **Compare paired fixes side by side.** When one rule lands in several places, check where each block
362
+ shows it, not only how it counts (two reports named the requests not yet fulfilled, Home did
363
+ not). Diff a shared paragraph word for word before you push, because separate editors'
364
+ sentences drift apart. Compare paired dialogs on first focus, close button, button colour and
365
+ where the error shows, and grep every other reader of a field whose label changed.
366
+ - **Let a different agent, ideally a different model, check what one agent built.**
367
+ - **A per-block pipeline** that held: author, two reviews, fixes, a hash-verified push, action
368
+ permissions re-applied and read back (a push resets them), a browser check at the sizes in
369
+ [browser-checks.md → step 4](browser-checks.md#4-measuring-with-eval) with saves blocked, and a
370
+ live write pass only where one is approved.
@@ -108,6 +108,8 @@ var createRecord = useRecordCreate({
108
108
  createRecord.mutate({ name: "Jane", email: "jane@example.com" }); // FLAT — no { fields } wrapper
109
109
  ```
110
110
 
111
+ `err.message` is the browser's raw text ("Failed to fetch"); in a shipped block, map it through the block's one error helper ([Error Message Formula](../ui-ux-guidelines.md#error-message-formula)).
112
+
111
113
  ## Update (THE CORRECT PATTERN)
112
114
 
113
115
  ```jsx
@@ -377,7 +377,7 @@ on the trigger from the console (a synthetic click does not move focus either),
377
377
  typing with real key events. A browser-automation "type" action that inserts text without key
378
378
  events drops it into the last focused text field, which makes a correct Combo look broken.
379
379
 
380
- **The incident: Lane County Diaper Bank, 2026-10-07.** A browser check reported that in B4's New
380
+ **The incident: LCDB, 2026-10-07.** A browser check reported that in B4's New
381
381
  partner modal, letters typed after opening the click-only "Partner type" list went into
382
382
  Organization name, and Escape then showed the modal's "Discard your changes?" strip while the
383
383
  list stayed open. Part of that report came from the test tool, whose "type" action wrote into the
@@ -438,7 +438,12 @@ Everything else — the trigger, the card, the rows — stays flat.
438
438
  `preventDefault` + `stopPropagation` and closes only the list
439
439
  ([Move focus into the Combo when it opens](#move-focus-into-the-combo-when-it-opens))
440
440
  - [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
441
- `aria-selected`, and an `aria-label` on the trigger; a click-only trigger that carries
442
- `aria-activedescendant` also gets `role="combobox"`
441
+ `aria-selected`; a click-only trigger that carries `aria-activedescendant` also gets
442
+ `role="combobox"`
443
+ - [ ] The trigger is named with `aria-labelledby="<label id> <trigger id>"`, so its accessible
444
+ name holds the field and the selected value. An `aria-label` with only the field name
445
+ replaces the button's own text, so a screen reader never hears the value (LCDB QA write
446
+ pass, 2026-10-08: an edit dialog's dropdowns announced "Partner type" and nothing else,
447
+ while another block's dropdowns announced both)
443
448
  - [ ] Loading and empty states (`"Nothing matches that."`)
444
449
  - [ ] No `@/components/ui/select` import anywhere in the file