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.
@@ -210,6 +210,8 @@ Before writing any block code through the MCP, call `vibe_coding_block_get_docs`
210
210
 
211
211
  Editable settings via MCP are the same fields as the block's **Content → Settings** panel; sort and record filters are the same as the **Source** tab. Duplicating from a version is the safe way to try an alternative — the original keeps working while you experiment on the copy.
212
212
 
213
+ **Set a new block's visibility with `vibe_coding_block_set_visibility` right after creating it, before any further code push.** A new block is visible to All users, and ADD_RECORD's default follows the block's visibility at compile time ([why](#the-array-argument-rejection-and-why-it-is-a-security-issue)). So every push to an ungated block reopens its ADD actions to logged-out visitors: a rebuild's pushes left 28 ADD_RECORD actions at All users (LCDB, 2026-10-05). `customUserGroupIds` is accepted only together with `LOGGED_IN_USERS`, and it narrows that group. Running the app as a user outside an Administrator-only block's group confirmed it: that user did not get the block (impersonation, 2026-10-08). Set groups by id, from `application_list_user_groups`, so a group renamed in Studio later changes nothing on the block (checked 2026-10-07).
214
+
213
215
  **Which read to use** (from the tools' own descriptions, 2026-10-01):
214
216
 
215
217
  - `vibe_coding_block_get_settings` returns the block's settings, its `actions` (type, `dataSourceId`,
@@ -220,9 +222,15 @@ Editable settings via MCP are the same fields as the block's **Content → Setti
220
222
  "is a datasource actually wired to this block?" — the compiler never sees the wiring — and each entry's
221
223
  `fieldReferenceKey` (`id` or `name`) says how that source's fields must be referenced in `q.select()`.
222
224
  - `vibe_coding_block_get_code` with **`includeCode: false`** skips the source text but still returns
223
- `sourceSha256` and `sourceBytes` — about 1 KB however large the block is. Use it to [verify a
224
- push](#verifying-a-push--the-deployed-source-is-the-only-proof) (verified live 2026-10-01).
225
+ `sourceSha256` and `sourceBytes`. Use it to [verify a
226
+ push](#verifying-a-push--the-deployed-source-is-the-only-proof) (verified live 2026-10-01). It drops
227
+ only the source: the response still carries every data source's field list. It came to about 1 KB for
228
+ a block with few connections, but about 40 KB on a block with 11, and `vibe_coding_block_get_settings`
229
+ was the same size (LCDB QA rounds, 2026-10-08). Read it once after a push, not after every call.
225
230
  - Push results now report `sourceSha256` and `sourceBytes` too, for the source Softr actually stored.
231
+ A search-replace result also carries `actions`, so one `includeCode: false` read afterwards is enough.
232
+ - **"Page not found" from a block tool** means `pageId` and `blockId` were passed the wrong way round, or
233
+ one of them is wrong. Three agents swapped the two (same rounds).
226
234
 
227
235
  ### Which edit tool: full replace vs. targeted search-replace
228
236
 
@@ -246,10 +254,32 @@ context. Keep the local mirror in step mechanically rather than by hand:
246
254
  1. Prove deployed == disk first ([below](#verifying-a-push--the-deployed-source-is-the-only-proof)).
247
255
  2. Write the ops once, as data. Send them to the tool, and apply the **identical** ops to the local
248
256
  mirror with a script that asserts each `search` occurs exactly once before replacing it.
249
- 3. Several rounds of ops are fine — **verify once at the end**: compare the `sourceSha256` of the
250
- last push result with the mirror's SHA-256. A mismatch means an op landed differently on one
251
- side. The digest describes the source Softr stored after merging your edits, not the edits you
252
- sent, which is what makes it usable here: on this path you never see the merged file yourself.
257
+ 3. Several rounds of ops are fine, but **each call is its own push**: it compiles and saves on its
258
+ own. Three things follow.
259
+ - **Every intermediate state must compile.** Replay the ops on the base and gate the text after
260
+ each planned call before you send any of them. Top-down ops can leave a helper undefined
261
+ halfway (only 3 of 54 op prefixes compiled in one plan), so keep the old helper in call 1 and
262
+ delete it in the last call.
263
+ - **Write down the `sourceSha256` each call must return, and check it per call**, not only at the
264
+ end. A mismatch means an op landed differently on one side. The digest describes the source
265
+ Softr stored after merging your edits, not the edits you sent, which is what makes it usable
266
+ here: on this path you never see the merged file yourself.
267
+ - **Each call resets the block's Action permissions and leaves its intermediate code in the
268
+ draft.** Send the calls back to back, re-apply restricted permissions once after the last call,
269
+ then read them back. Anyone who publishes in between ships the intermediate state. In one
270
+ two-call push, an Administrator-only void action was open to any logged-in user for about two
271
+ minutes, between call 1 and the re-apply (LCDB, 2026-10-08). Mark the calls in their
272
+ `versionName` ("… (part 1 of 2)").
273
+
274
+ **Shrink the ops before you send them.** Trim the common prefix and suffix of each search/replace
275
+ pair, then grow the search until it is unique (24 characters or more worked). Check uniqueness in the
276
+ state each op applies to: ops apply in order, and only the first occurrence is replaced. Replay the
277
+ ops on the base to rebuild the target byte for byte. Smaller payloads mean less text retyped through
278
+ the model, and every shrunk push matched its hash on the first try: one plan went from 20,665 B to
279
+ 10,721 B, another from 19.8 to 16.5 KB, and one header edit's search from 1,173 B to 40 B (LCDB,
280
+ 2026-10-08). A large insertion needs no full replace: one op anchored on a short unique line carried a
281
+ 33 KB component, and the returned hash matched the replayed file. On a long one-line header, use a
282
+ short unique tail of the line as the search.
253
283
 
254
284
  One encoding trap: JSON `\uXXXX` escapes inside the ops are **decoded to the real characters** on
255
285
  Softr's side (`"\u2014"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
@@ -289,13 +319,34 @@ definition says, check that `sourceCode` came back `null`.
289
319
 
290
320
  1. **Before editing, prove deployed == disk.** Call `vibe_coding_block_get_code` with
291
321
  `includeCode: false` and compare `sourceSha256` and `sourceBytes` to `shasum -a 256 <file>` and
292
- `wc -c < <file>`. If they differ, someone changed the block in Studio since your last push: fetch
293
- the full source, diff it against yours and reconcile. Do not overwrite work you have not seen.
322
+ `wc -c < <file>`. If they differ, the block has changed since your last push: fetch the full
323
+ source, diff it against yours and reconcile. Do not overwrite work you have not seen.
324
+ - **A Studio edit is one cause, not the only one.** Another session or agent can push (and
325
+ publish) in between: a second session pushed to five blocks and published while a QA pass was
326
+ running, which left every scratch copy stale (LCDB, 2026-10-08). And an agent can push, then die
327
+ before it writes the mirror: twice the mirror held a version older than the deployed block, and
328
+ the version history rebuilt what was live.
329
+ - **So compare again at review time and right before the push**, not only before editing, and
330
+ start every edit from the project mirror, never from a scratch copy.
331
+ - **`vibe_coding_block_list_versions` with `includeCode: false` dates every save.** Each entry
332
+ carries its `title` (the `versionName` you passed, or a generated summary) and its `prompt` (the
333
+ `userPrompt` you passed). Pass a descriptive `versionName` and `userPrompt` on every push, and
334
+ the history reads as a log.
294
335
  2. Edit the local file. Run a parser and `no-undef` lint on it first — `node --check` does **not**
295
336
  accept a `.jsx` extension, so use esbuild (`esbuild file.jsx --loader:.jsx=jsx --jsx=automatic
296
337
  --log-level=error --outfile=/dev/null`) plus eslint with `@babel/eslint-parser`. The bugs that
297
338
  actually bite Softr blocks are semantic — `useRecordUpdate({ select: … })` instead of `fields:`,
298
339
  an invented identifier — and the push is the first thing that reports them.
340
+ - **This gate is weaker than Softr's compiler for TypeScript.** esbuild strips types without
341
+ checking them, so a duplicate `type X` passes, and Softr then refuses the push with `Identifier
342
+ 'X' has already been declared` (a reviewed block, refused at the second declaration; LCDB,
343
+ 2026-10-08). The `jsx` loader cannot parse type annotations at all, so TypeScript source in a
344
+ `.jsx` file needs `--loader:.jsx=tsx`. Add a duplicate-declaration check, for example `tsc
345
+ --noEmit` failing on TS2300, TS2451 and TS2393, and grep the file for each new top-level name
346
+ before you add it.
347
+ - **In any script that asserts a fix, assert on the exact code**, such as `new Date().toISOString()`,
348
+ never on a bare word a comment may also hold: `assert "toISOString" not in out` failed on the
349
+ comment that explained the fix (same pass).
299
350
  3. Push the **entire** file — or, for a targeted patch on a large block, send search-replace ops
300
351
  and apply the identical ops to the mirror
301
352
  ([recipe above](#which-edit-tool-full-replace-vs-targeted-search-replace)).
@@ -328,9 +379,30 @@ records the other page's pair. Search-replace would avoid the swap altogether
328
379
  ([above](#which-edit-tool-full-replace-vs-targeted-search-replace)) — when the client can send its
329
380
  array argument ([below](#the-array-argument-rejection-and-why-it-is-a-security-issue)).
330
381
 
382
+ **The `versionId` in a push result is the script's build id, not a version-history id.** It is the
383
+ segment in the path the page loads, `…/vibe-coding/…/<blockId>/<buildId>/index.js`.
384
+ `vibe_coding_block_list_versions` returns `NOT_FOUND` for it, even though that tool's own description
385
+ says it accepts a write tool's `versionId`. Every pushing agent in two QA rounds met both ids, and
386
+ two looked the build id up in `list_versions` and got `NOT_FOUND` (LCDB, 2026-10-08). The two ids do
387
+ two jobs:
388
+
389
+ - **To prove the preview serves a push**, compare the push result's `versionId` with the build id in the
390
+ served script's URL. [browser-checks.md](browser-checks.md#2-session-preview-cookie-page) (step 2) has the check.
391
+ - **To name the version**, use the `versionNumber` from `vibe_coding_block_list_versions`, with its list
392
+ id. Report both ids.
393
+
394
+ **A push call that times out may still have landed.** Read `sourceSha256` with `includeCode: false`
395
+ before you send anything again. A call that answered "server isn't responding" had landed: the
396
+ read-back matched the planned hash, and nothing was retried (LCDB, 2026-10-08). A re-sent search-replace
397
+ fails on text it already changed, or applies twice where the replacement still contains the search. A
398
+ refused push stores nothing, so read the deployed hash back before you send the fix as well. And
399
+ when the deployed block, the working copy and the mirror already hash the same, skip the push: every
400
+ code write adds a version and resets Action permissions for nothing (a no-op push skipped this way
401
+ in the same rounds).
402
+
331
403
  **Do not read a 100KB block into a model's context to push it.** The full-replace tool takes the
332
404
  whole file as a string parameter, so the source has to pass through whatever is making the call.
333
- Verification no longer has to: the digest is a few hundred bytes. A large multi-block deploy is still
405
+ Verification no longer has to: the digest is a few hundred bytes (the read that returns it can be larger on a block with many data sources, [above](#vibe-coding-block-tools)). A large multi-block deploy is still
334
406
  safer farmed out one file per subagent — a fresh context per file means no compaction can land
335
407
  mid-file — and the hash comparison is what makes that delegation safe, not trust in the agent. The
336
408
  steps that need judgement are the *edit* and the *review of the diff*; the hashing, the compare and
@@ -526,9 +598,24 @@ Both edit paths recompile, so both reset Action permissions either way (Hard Con
526
598
 
527
599
  *Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
528
600
  captured from the app iframe); the block-visibility row verified 2026-10-05 (HubSpot; preview link,
529
- impersonated users, direct POSTs).* A block's data lives behind per-connection endpoints —
530
- `/blocks/<blockId>/datasources/<connection>/records` for lists, `/records/<id>` for one record —
531
- and these are the gates that actually exist on them (`<connection>` was recorded as the connection's id in the 2026-09-18 Softr Database capture and seen as its alias in a 2026-10-05 HubSpot capture; unresolved, and it only matters when reading a network log):
601
+ impersonated users, direct POSTs).* A block's data lives behind per-connection endpoints, and these
602
+ are the gates that actually exist on them. Which name sits in the path matters when you read a network
603
+ log or write a request guard:
604
+
605
+ - **In a block that declares `datasource.define`, reads use the define name** (HubSpot capture,
606
+ 2026-10-05; Softr Database capture, 2026-10-08). A list is
607
+ `POST …/blocks/<blockId>/datasources/<name>/records`, and one record is
608
+ `POST …/datasources/<name>/records/<recordId>`. A `*/datasources/*/records` pattern does not match
609
+ the single-record read.
610
+ - **Writes do not use the name.** A create is `POST …/datasources/<uuid>/records-trigger/new`, and an
611
+ update is `PATCH …/records-trigger/<recordId>`. The uuid differed between runs (in the HubSpot capture
612
+ it changed on recompile), so key write guards and log filters on `records-trigger`, never on the name
613
+ or the uuid.
614
+ - **One earlier capture is not explained by this.** A 2026-09-18 Softr Database capture recorded a
615
+ read under the connection's id. That case (possibly a block without `datasource.define`) was not
616
+ re-checked.
617
+
618
+ The gates:
532
619
 
533
620
  | Gate | Enforced server-side? |
534
621
  |---|---|
@@ -554,6 +641,11 @@ the page VIEW permission at `LOGGED_IN_USERS` throughout.
554
641
  This is also what makes the open-`ADD_RECORD` finding above severity-dependent on the page's VIEW
555
642
  permission rather than uniformly critical.
556
643
 
644
+ **Saves carry the page URL.** Every save body holds `context.pageURL` and `context.URLParameter`. A
645
+ value a block keeps in the URL, such as a `?q=` search holding client names, therefore goes to Softr's
646
+ server with every save on that page (seen on a blocked save, 2026-10-08). Decide on purpose whether
647
+ such a value may sit in the URL, and record the decision.
648
+
557
649
  #### Logged-in-user values in Source conditions
558
650
 
559
651
  *The email token was verified on Softr Database in a production project: set in Studio's Source
@@ -660,23 +752,41 @@ The Applications area goes well beyond reads (roster as delivered 2026-10-01; be
660
752
 
661
753
  **Email senders, as the MCP shows them** (read 2026-09-10 and 2026-09-18): `workspace_list_email_senders` lists each workspace sender with a `confirmed` flag, and an address that was added but never verified reads `confirmed: false`. `application_get` showed the app's own sender as `<subdomain>@softr.app`. In a workflow, a `SOFTR_SEND_EMAIL` node chooses its sender through the optional `emailSenderSignatureId` input. Before publishing a workflow that must send from a particular address, check that the address is in the list and confirmed.
662
754
 
755
+ **Three setup facts from an app build** (LCDB, 2026-10-05 to 07):
756
+
757
+ - **A plan caps its custom user groups, so check the cap before designing roles.** The app's public config, `window.application_context` on its `/login` page, carried `numberOfCustomUserGroups` (3 on that plan) and `signUpSettings.policy`. It is a read-only check and needs no login. The cap forced a late redesign from four groups to three.
758
+ - **A new app comes with a default test user** (`testuser@example.com`, seen in the one app we created) in no group. Deactivate it before go-live, or keep it as the no-group test account.
759
+ - **After a subdomain change, the old address returns 404 with no redirect**, so every old link is dead. Re-read `application_get` afterwards for the app's email sender, which showed the subdomain-based address before the change. Whether the sender follows the change was not checked.
760
+
663
761
  Combined with the database tools (`database_create` / `database_create_table` / `database_create_field`) and `vibe_coding_block_create` + `application_publish`, the tool set for scaffolding a full app end to end now exists. (Existence-verified only — that pipeline hasn't been run live; treat the first full scaffold as an experiment, not a routine.)
664
762
 
665
763
  **Etiquette from the server's own instructions:** after changing a block, link the page as `https://studio.softr.io/applications/{applicationId}/pages/{pageId}`; offer `application_preview` or `application_publish`, but **only publish when the user asks**.
666
764
 
667
765
  > **application_preview links are auth tokens.** Per the server's own instructions, a preview link **signs its opener in as the user who requested it** and lasts about a day. Give it only to that user, and mint a fresh one with another `application_preview` call rather than re-sending an old link. Never paste a preview link into a shared channel.
668
766
  >
669
- > **A preview link also pins the app version.** Its URL carries `&version=<n>`, so it keeps serving the version it was minted for. That is by design, not a caching bug. After every push, mint a new link before you check anything.
767
+ > **The `&version=<n>` in a preview link is an app-level number, not a block version, and it does not prove which code is served.** By Softr's design a link keeps serving the version it was minted for, but in one app every freshly minted link read the same `&version=153` for about 16 hours, across dozens of block pushes and two app publishes, and each link served the newest block build (LCDB QA pass, 2026-10-07 to 08). Prove what is served by the script's build id, as in [the `versionId` note](#verifying-a-push--the-deployed-source-is-the-only-proof). After every push, still mint a new link (or press the preview's `#refresh-button`) before you check anything. Whether a link minted *before* a push keeps serving the old build was not re-tested.
670
768
 
671
769
  **Reading pages and blocks:**
672
770
 
673
771
  - `application_page_get` lists a page's blocks **in page order, with no `order` field**. That field
674
772
  was always `null` and was removed on 2026-10-01 (verified live that day; the tool's description
675
773
  still mentions it). For a block nested in a column or tab container, the container's slots set its
676
- position, not its place in the list.
774
+ position, not its place in the list. The list is not visual order where shared blocks are involved
775
+ either: it showed the shared navigation header block after the page's content on every page except
776
+ Home, in two apps (LCDB, 2026-10-05 to 08).
677
777
  - **A block created over MCP still lands at the bottom of the page**, and no tool places or reorders
678
778
  blocks yet. Softr has said placement will come later. Until then, a human drags it into place in
679
779
  Studio. Say so when you hand the block over.
780
+ - **Studio-only jobs, with no MCP tool** (checked against the roster of 2026-10-08):
781
+ - setting a page's VIEW permissions (`application_page_get_permissions` only reads them);
782
+ - Page Rules, which are not readable either (`application_get_access_overview` gives only counts of redirections);
783
+ - navigation links (`application_page_get_block` on the shared Navigation block does not return them);
784
+ - renaming a block (a block deployed into a new page's placeholder keeps the title "Vibe coding block");
785
+ - the app's Custom Code and theme.
786
+ - **Before a human deletes a user group, prove it unused.** First by id over MCP: block visibility,
787
+ action permissions and page permissions. Then in Studio, where the MCP cannot see: Page Rules rows
788
+ and navigation-link visibility. A group that came back clean on both was deleted by hand
789
+ (LCDB, 2026-10-07).
680
790
  - **Timestamps are UTC with a `Z`.** Since 2026-10-01, timestamps such as `publishedAt` or a
681
791
  version's `createdAt` are ISO-8601 UTC with millisecond precision, studio-side and tables-side
682
792
  alike (per Softr; verified on `vibe_coding_block_list_versions` that day). Before then, studio-side timestamps came back with
@@ -713,6 +823,13 @@ connection's field reference key set to `id`. The HubSpot version (2026-10-05) i
713
823
  condition fails. HubSpot behaved the same way.
714
824
  - **Check membership by running the app as that user** and reading the groups the app gives them
715
825
  ([how](#testing-as-any-app-user-without-logins--the-preview-as-switcher), below).
826
+ - **After any group change, in Studio or over MCP, read the groups back with
827
+ `application_list_user_groups`.** A group left in condition mode with nothing filled in reads back as
828
+ a condition holding one blank rule (no field, no operator). What that rule matches is unverified, so
829
+ switch the group to a manual list before anyone relies on it. A read-back found this on a group
830
+ that was meant to be manual (LCDB, 2026-10-07).
831
+ - **Keep the top administrator group a manual list.** A condition on users-table fields hands
832
+ membership to anyone who can edit those fields in the table, so they could promote themselves.
716
833
 
717
834
  ### Testing as any app user without logins — the "Preview as" switcher
718
835
 
@@ -744,10 +861,17 @@ passwords, no test accounts to create:
744
861
  then read `iframe.contentWindow.__softr_current_user.userGroups`.
745
862
  - The link carries `?show-toolbar=true`, so the top document is the toolbar shell and the app runs
746
863
  in `document.querySelector('iframe')` (the `#preview-iframe` of
747
- [browser-checks.md](browser-checks.md)). `window.__softr_current_user` exists only in that
748
- iframe's `contentWindow`. The top window has no user globals at all.
864
+ [browser-checks.md](browser-checks.md)). In the shell, `window.__softr_current_user` exists only
865
+ in that iframe's `contentWindow`; the shell itself has no user globals.
866
+ - **On a page URL opened directly on the preview origin, the app is the top window**, and
867
+ `window.__softr_current_user` (email and groups) is readable there (verified 2026-10-08, for an
868
+ administrator and a volunteer). That is the flow [browser-checks.md](browser-checks.md) uses.
749
869
  - To reload, set the iframe's `src` again with a fresh `t=<timestamp>` query parameter, after the
750
- impersonate call.
870
+ impersonate call. On a direct page URL, reopen the page instead.
871
+ - **Read the email and groups after every switch, before any role check.** The impersonate fetch
872
+ returns 200 while the already-open page keeps the old user, until the page is reopened. A mistyped
873
+ id returns 400 and the preview silently stays as the previous user, so a role check can run as
874
+ the wrong person without any error (2026-10-08).
751
875
  - Observed group names: a user who matched the Volunteer condition above read
752
876
  `["Logged in users", "All users", "Volunteer"]`; a user with no login email read
753
877
  `["Logged in users", "All users"]`.
@@ -871,6 +995,31 @@ Known limits and behaviors (per official docs):
871
995
  comment). A block that appends to such a field has to keep the total under it; what a longer write
872
996
  does was not tested. Use LONG_TEXT for anything that grows.
873
997
  - Limits: 100 records per `database_create_records` call, 200 records per read (silently capped, not an error), 2 group-by fields in `database_aggregate_records`. For big tables prefer a filter or aggregate over paging. The read cap is per call, not a ceiling: `database_list_records` takes `offset`, and on 2026-09-01 `limit` 200 with `offset` 0 to 2,400 read a 2,549-row table in 13 calls, every record id unique, no gap or overlap (ours).
998
+ - **`database_aggregate_records`: one metric per call, and prove every filter narrows** (ours, LCDB,
999
+ 2026-10-08). Two metrics on different fields returned `BAD_REQUEST` every time; a SUM and a COUNT on
1000
+ the same field worked once. Group-by acceptance was inconsistent across agents on the same day:
1001
+ SELECT and CHECKBOX were refused, while text, LINKED_RECORD, DATETIME by MONTH and DISTINCT on a
1002
+ link each worked in one run and returned `BAD_REQUEST` in another. So prefer filter-only,
1003
+ single-metric calls (one per value or range), and check that the parts add up to the unfiltered
1004
+ total. A filter in a shape the tool does not expect (`{logicalOperator, conditions}`) was ignored
1005
+ with no error, and the count came back as the whole table. The tool expects
1006
+ `{ condition: { operator: "AND"|"OR", conditions: [...] } }`, with field ids in `leftSide`. Compare
1007
+ every filtered count with the unfiltered one. To leave out rows flagged by a checkbox, filter
1008
+ `IS_NOT true` instead of grouping by it.
1009
+ - **`database_search_records`:** filters name fields by id in `leftSide`. A sort on `updatedAt` was
1010
+ silently ignored, and a filter on `id` was rejected.
1011
+ - **`database_create_field` on a LINKED_RECORD always creates a single-valued inverse** on the target
1012
+ table, named after the source table. Links defined inside `database_create_table` got none (LCDB,
1013
+ 2026-10-05). Make the inverse multi-valued (`database_update_field`, `options.allowMultipleEntries:
1014
+ true`) unless one-to-one is meant. Otherwise a second link silently overwrites the first, as in
1015
+ [the single-valued pair trap](../datasources/writing.md#linked-record-write-traps-verified-live-2026-08-26).
1016
+ - **On the workspace server, a new database starts with a starter table, "Table 1"** (Name, Description
1017
+ and Status fields). Per the `database_create` tool's own description (checked 2026-10-08), the vibe
1018
+ coding application endpoints create it empty instead. Reshape the starter table with
1019
+ `database_update_table`, `database_create_field` and `database_update_field` rather than adding a
1020
+ table beside it. A database holding it is not empty, so `database_delete` refuses it without `force`.
1021
+ - **`database_create` rejected a description with `BAD_REQUEST` once** (2026-10-05). If it does,
1022
+ create the database without one.
874
1023
 
875
1024
  Typical Vibe Coding uses: "list every field on `Wigs` with id, name, type, and dropdown options", "what's the option id for `Payment status` = 'Partially paid'?", "show 3 sample records so we know value shapes", "verify the field id in my `q.select()` exists". This eliminates the field-id-typo / wrong-option-uuid class of bugs entirely.
876
1025
 
@@ -911,7 +1060,7 @@ those; see [the rename note](#tool-names--the-2026-10-01-rename).
911
1060
  **Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows; bullets dated later come from a second production build, 2026-09-18/19):**
912
1061
 
913
1062
  - **`workflow_create` instantiates an OLD version of the trigger node.** Immediately call `workflow_replace_trigger_node` with the **same trigger type** — the replacement lands at the current version with the current inputs. Example: `updateField` on `SOFTR_TABLES_RECORD_UPDATED` (fire only when a watched field changes; it takes an UPDATED_AT-type field, see "Trigger scope" below) only exists at v1.2.0; the version `workflow_create` instantiates doesn't have it. Do it before anything references the trigger: the replacement gets a **new node id** (see the `workflow_replace_node` bullet below).
914
- - **A FILTER condition written over MCP is inert — set it in Studio** (corrected 2026-10-06; this bullet used to say the MCP writes it). `workflow_update_node_inputs` with inputName `"condition"` accepts an `{operator, conditions: [...]}` object and stores it in the node's `inputs.condition`, a field the engine does not read. The engine evaluates the condition on the FILTER node's **outgoing path** (the `paths` entry whose `fromActionId` is the filter), and only the Studio builder writes that. **A filter built over MCP passes every run.** A 2026-09-19 audit of this very build showed it three ways: the one workflow actually running had empty `inputs` and its whole condition on the path; two others, edited in Studio afterwards, held one condition in `inputs.condition` and a different one on the path, so Studio reads and writes only the path.
1063
+ - **A Workflow FILTER node's condition written over MCP is inert — set it in Studio** (corrected 2026-10-06; this bullet used to say the MCP writes it). This is about the Workflows FILTER node only. A block's Source condition written with `vibe_coding_block_set_data_source_record_filters` **is** enforced (verified 2026-09-18 and 2026-10-05, [above](#what-the-server-enforces-on-a-blocks-data-endpoints)), so do not route it to Studio. A project agent once applied this rule to a block's Source filter and routed it to Studio for nothing. `workflow_update_node_inputs` with inputName `"condition"` accepts an `{operator, conditions: [...]}` object and stores it in the node's `inputs.condition`, a field the engine does not read. The engine evaluates the condition on the FILTER node's **outgoing path** (the `paths` entry whose `fromActionId` is the filter), and only the Studio builder writes that. **A filter built over MCP passes every run.** A 2026-09-19 audit of this very build showed it three ways: the one workflow actually running had empty `inputs` and its whole condition on the path; two others, edited in Studio afterwards, held one condition in `inputs.condition` and a different one on the path, so Studio reads and writes only the path.
915
1064
  - **The official MCP docs agree:** "Branch and filter conditions can't be set through MCP yet ... deciding what sends a run down each path is something you finish in the builder" (docs.softr.io/mcp/workflows, checked 2026-10-05), and the FILTER spec declares `inputs: {}`. BRANCH conditions were not tested separately here; treat them the same way.
916
1065
  - **How to build one:** add the FILTER over MCP if that is convenient, then open it in Studio, set its clauses and save. Never trust `inputs.condition` as a record of what the filter does; it can disagree with the path.
917
1066
  - **How to check one:** read the workflow back with `workflow_get` and find the `paths` entry whose `fromActionId` is the filter; the condition must be there. FILTER nodes cannot be run with `workflow_test_node`, so this read-back is the only check before a real run.
@@ -360,6 +360,17 @@ Vestibular disorders affect ~35% of adults over 40. Always respect `prefers-redu
360
360
  - **Error messages:** Always inline, directly below the field. Write as instruction: "Enter a valid email address" not "Invalid email."
361
361
  - **Success states:** Show green check icon on validated fields.
362
362
  - **Required fields:** Mark explicitly.
363
+ - **A required field has no silent default, and an enumerated required field offers no empty option.** In one app a pre-filled "Parent" was saved on every pickup where nobody changed it, and a required status could be blanked through its empty option. The form's mode follows the record's own type: a group record asks for its number of people, not one person's hours (LCDB QA pass, 2026-10-08).
364
+ - **Dates of things that already happened refuse the future** (a pickup, a receipt). In that app, future-dated rows were counted in this month's tiles.
365
+ - **Implausible amounts ask for confirmation instead of being refused** ([inline confirm](references/common-patterns.md#inline-confirm-in-place-of-a-button)).
366
+ - **A fallback error ("Add a line") shows only when no specific line error did, and the error strip is cleared at the start of every attempt.** In that app, an old save error sat beside a newer field error.
367
+
368
+ ### Number inputs:
369
+ - **A number input reports `""` for text it cannot read** (`5e`, `--5`, `1600-`, and `1,000` in Firefox), and React fires no change while the value stays `""`. So an optional quantity quietly becomes blank or 0. Read `validity.badInput` on both `input` and `change` (or at Save), keep a marker, refuse it with a message, and render `""` so React leaves the typed text alone.
370
+ - **In a browser set to a region that writes a decimal comma, `1,000` is read as 1, not refused.** No `badInput` is raised, so test the raw string as well (headless Chromium on a Mac followed the system region). Probing with `5e` works in any region.
371
+ - **Whole numbers:** test the raw string against `^\d+$` and parse with `Number`, never `parseInt` (`parseInt("1e3")` is 1) or `Math.floor` (2.5 cut to 2). **Money:** a text input with `inputMode="decimal"`.
372
+ - **One validator for Save, the live total and the hint**, with the field's upper bound in it ([writing.md → Number](datasources/writing.md#number)). A total or warning built on an invalid entry stays hidden until the entry is fixed (a total once warned with a wrong figure while one line was invalid).
373
+ - **Test by typing with the keyboard, in Firefox too.** Browser-automation `fill` cannot enter unreadable text. Saved values seen in one QA pass (LCDB, 2026-10-08): "1600-" saved a drive's quantity as blank, "2..5" saved 0 packs, "1,200" wrote 1 (Chromium under a decimal-comma region).
363
374
 
364
375
  ### Error Message Formula:
365
376
 
@@ -368,11 +379,13 @@ Vestibular disorders affect ~35% of adults over 40. Always respect `prefers-redu
368
379
  | Format error | "[Field] needs to be [format]. Example: [example]" |
369
380
  | Missing required | "Please enter [what's missing]" |
370
381
  | Permission denied | "You don't have access to [thing]. [What to do instead]" |
371
- | Network error | "We couldn't reach [thing]. Check your connection and [action]." |
382
+ | Network error (a read) | "We couldn't reach [thing]. Check your connection and [action]." For a save, use the helper sentence below |
372
383
  | Server error | "Something went wrong on our end. [Alternative action]" |
373
384
 
374
385
  **Never blame the user.** "Please enter a date in MM/DD/YYYY format" not "You entered an invalid date."
375
386
 
387
+ **Network errors come from one helper.** The browser's own words ("Failed to fetch", Safari's "Load failed", "NetworkError") reach users unless something maps them. Map them to one plain sentence through one helper per block, with the same text in every block: "[What] was not saved: the app could not reach the database. Check the connection and try again." Log the raw error with `console.error`. Messages the code writes itself don't go through the helper, `[what]` is singular, and a partial save never reads "was not saved. The family was saved but…". A `TypeError` from a code bug also reads as a connection failure, so look at the console when the message looks wrong (LCDB QA pass, 2026-10-08).
388
+
376
389
  ### Layout:
377
390
  - **Single-column forms** are easier to complete than multi-column.
378
391
  - **Group related fields** under labeled sections.
@@ -420,6 +433,8 @@ Vestibular disorders affect ~35% of adults over 40. Always respect `prefers-redu
420
433
  ### Implementation:
421
434
  - Check `status === 'pending'` and render skeleton.
422
435
  - Check `status === 'error'` and render error state with retry.
436
+ - **Try again re-reads every read that is in error**, not only the main list. A Try again that re-read only the main list left the other reads failed: rows then said "No children on record" and the duplicate check ran without children.
437
+ - **Say "did not load" only after a read has failed;** while it loads, just disable the action. A create that checks for duplicates waits for a complete, successful read of what it checks against (a product was created while the product list had failed, and passed its own duplicate-name check; LCDB QA pass, 2026-10-08).
423
438
  - Never render an empty block.
424
439
 
425
440
  ---
@@ -436,7 +451,8 @@ Empty states are a critical UX moment — not an afterthought. They are onboardi
436
451
 
437
452
  ### Rules:
438
453
  - Do not say "No data" — be specific and human. "You don't have any projects yet."
439
- - If empty due to search/filter, offer to clear the filter.
454
+ - If empty due to search/filter, offer to clear the filter. When a filter hides every match for the search, say how many it hides and offer to drop only that filter, keeping the search text ("No partners match" while an inactive one did).
455
+ - **Copy fits the role reading it.** Never send a user to a page they cannot open: in one app, empty states told volunteers to use a Settings page that only administrators can open (LCDB QA pass, 2026-10-08).
440
456
  - Center-align with generous `py-16` or `py-24` padding.
441
457
 
442
458
  ---
@@ -452,9 +468,10 @@ Empty states are a critical UX moment — not an afterthought. They are onboardi
452
468
  | Skeleton to content | Progressive (no delay) |
453
469
 
454
470
  ### Rules:
455
- - **Mutations:** Always show `toast.success()` or `toast.error()`.
471
+ - **Mutations:** successes and row-level results show `toast.success()` or `toast.error()`. A save that fails inside a dialog shows its error inside the dialog instead (`role="alert"`, above the footer): a toast covers the dialog's own buttons and is gone in seconds.
456
472
  - **Call `refetch()` before showing success toast** so UI reflects new state.
457
- - **Button loading states:** Disable and show spinner while pending.
473
+ - **Button loading states:** Disable and show spinner while pending. Busy labels come from the click and reset when that request settles, never from `isRefetching`: Softr's hooks refetch on window focus, so that flag flickers with no click.
474
+ - **At the end of a save, clear or refill the form before dropping the saving flag.** Otherwise Save is enabled again with the old values on screen during the re-read, and a second press writes twice.
458
475
  - **Form submission:** Disable submit, show spinner inside button.
459
476
 
460
477
  ---
@@ -465,6 +482,9 @@ Empty states are a critical UX moment — not an afterthought. They are onboardi
465
482
  - **Use before any irreversible action.** Restate what will be deleted, warn it cannot be undone.
466
483
  - **Cancel button should be default focused** — keyboard users should not accidentally confirm.
467
484
  - For **reversible soft-deletes**, skip the modal. Show undo toast for 5-8 seconds.
485
+ - **Confirm a status change only in the direction that takes something away.** Set inactive asks; Set active acts at once.
486
+ - **A void or reversal lists everything it undoes** (the lines, the sizes, the total), so the question can be answered.
487
+ - **Warn before a write takes a running balance (stock) below zero,** naming the product. A bulk write (a receipt of eight lines) gets a review step that lists each row.
468
488
 
469
489
  ### Spacing:
470
490
  - Never place Delete/Remove adjacent to Save/Confirm.
@@ -473,6 +493,7 @@ Empty states are a critical UX moment — not an afterthought. They are onboardi
473
493
  ### Prevent data loss:
474
494
  - Prompt "You have unsaved changes. Discard them?" when dismissing with unsaved edits.
475
495
  - Never silently discard user input.
496
+ - **Compute "dirty" per field against the values the form opened with,** so Cancel on a form prefilled from a search doesn't ask to discard text nobody typed. A dialog whose write queue has started is no longer dirty: its Cancel reads Close.
476
497
 
477
498
  ---
478
499
 
@@ -496,7 +517,7 @@ Show users what they need now, reveal more on demand.
496
517
  | **Tabs** | Multiple views of same data | `Tabs` |
497
518
 
498
519
  ### Rules:
499
- - Primary actions and data always on screen.
520
+ - Primary actions and data always on screen. Every stored field the user may need shows on the record's page, not only inside Edit (staff could see a partner's notes and a drive's scheduled date only by opening the editor).
500
521
  - Always make it obvious more content exists (chevron, "Show more", count badge).
501
522
  - **DO NOT** use modals as a reflex — consider if there is a better place for the interaction first.
502
523
 
@@ -529,9 +550,15 @@ Users always need to know: *Where am I? Where can I go? How do I get back?*
529
550
 
530
551
  ### Mobile adaptation:
531
552
  - Transform table rows into **stacked card layouts** on small screens.
532
- - `hidden md:block` on the table, `block md:hidden` on mobile cards. That is a window breakpoint: beside Softr's sidebar navigation, key the swap to the block's width with container variants instead (`hidden @min-[48rem]:block` / `@min-[48rem]:hidden`, under an `@container` wrapper; see [§21](#21-mobile-first-responsive-design)).
553
+ - `hidden md:block` on the table, `block md:hidden` on mobile cards. That is a window breakpoint: beside Softr's sidebar navigation, key the swap to the `@container` that holds the table with container variants instead (`hidden @min-[48rem]:block` / `@min-[48rem]:hidden`). That container is the block's wrapper, or the card's own when the table sits in a narrower card: one log table keyed on a 47rem block overflowed its 650px card. See [§21](#21-mobile-first-responsive-design).
533
554
  - Never require horizontal scrolling for important data.
534
555
 
556
+ ### Search, caps and columns:
557
+ - **A list's search covers every field the list shows,** including child records shown with it (children under a family, a roster's interests and training). A home search could not find a family by a child's name, and a roster search ignored the interests and training it displayed (two searches that found nothing found 4 and 5 rows after the fix).
558
+ - **No silent caps.** Show "Showing N of M" and page through every matching row: a history list stopped at 200 of 481 rows without a hint.
559
+ - **Size columns from real rows at the block's real width.** Give text columns minimum widths and badges `whitespace-nowrap`, and break emails at the `@` with `<wbr>` ([anti-patterns.md](references/anti-patterns.md#layout--styling)). Names and emails broke mid-word in narrow columns, and a log table overflowed a tablet (which container to key the table/card switch on: Mobile adaptation above).
560
+ - **Rows dated after today stay listed and marked,** not dropped. **An open inline edit closes when the list's period changes,** or it points at a row the new range no longer lists. (The cases in this subsection are from the LCDB QA pass, 2026-10-08.)
561
+
535
562
  ---
536
563
 
537
564
  ## 19. KPI / Metric Cards and Dashboards
@@ -547,6 +574,9 @@ Users always need to know: *Where am I? Where can I go? How do I get back?*
547
574
  - Every chart needs: title, axis labels, legend (if multi-series), hover tooltip.
548
575
  - **Bar charts** for comparisons, **line charts** for trends, **pie/donut charts** sparingly.
549
576
  - **Avoid 3D charts** — they distort values.
577
+ - **One counting rule per figure, the same on every page.** Leave voided rows out and drop entities whose rows net to zero (a partner with only voided rows got a row of zeros). Label periods honestly: a clipped month, "to date" for the current year, all from one period helper. In QA of 15 blocks, the "same number on every page" check found 7 of the 15 major findings (LCDB, 2026-10-08).
578
+ - **Print each ratio through one helper, and normalise `-0` in the display formatter** (a report showed "-0 items out" for an empty year, and 72% against 72.1% for the same figure). When splitting a total by a ratio, round one share and give the other the remainder, set a fallback for a zero denominator and say so in its label.
579
+ - **A note that belongs to a row of tiles is a grid item of that grid,** and `grid-rows-subgrid` keeps the figures aligned when a label wraps.
550
580
 
551
581
  ### Dashboard Anti-Pattern:
552
582
  Avoid the "hero metric layout template" — big number, small label, supporting stats, gradient accent strip. This is the most recognizable AI design template and should be avoided unless intentionally chosen.
@@ -574,6 +604,9 @@ Avoid the "hero metric layout template" — big number, small label, supporting
574
604
  - **Focus rings:** Never `outline: none` without replacement. Always keep `focus-visible:ring-2`. Focus ring must be 2-3px thick, high contrast, offset from the element.
575
605
  - **`focus-within:` for containers, `focus-visible:` for the focusable element itself.** A card or row that *holds* buttons is usually a plain `<div>` and never takes focus, so `focus-visible:` on it is dead CSS that reads like an accessibility feature and does nothing. Use `focus-within:` there, so tabbing to a control inside the card lights the same affordance a mouse user gets from `hover:` -- and keep `focus-visible:ring-2` on the button or link itself. Whenever a card has a `hover:` treatment and contains tab stops, it wants the matching `focus-within:` variant.
576
606
  - Tables: proper `<thead>`, `<tbody>`, `<th scope="col">`.
607
+ - **A row button whose accessible name joins several values gets visible `", "` separators or an `aria-label`.** Screen-reader-only spans got padded with stray spaces in Chrome, and names ran together ("Oct 1, 202622 children").
608
+ - **A `::before` hit area is measured from the padding box.** `-inset-y-1` on a 36px pill with a 1px border gives 42px, not 44.
609
+ - **A visually hidden copy of a visible table doubles it for screen readers.** Point a chart's summary at the visible table instead.
577
610
 
578
611
  ---
579
612
 
@@ -583,6 +616,8 @@ Avoid the "hero metric layout template" — big number, small label, supporting
583
616
  - **Start with `flex-col`**, stack to `md:flex-row` or grid layouts at larger screens. Beside Softr's sidebar navigation, switch on the block's width instead (`@min-[48rem]:flex-row` under an `@container` wrapper). See [Breakpoint strategy](#breakpoint-strategy) below.
584
617
  - **No horizontal overflow.** Use `overflow-x-hidden` as safety net.
585
618
  - **Touch targets: 44px minimum.**
619
+ - **Filter pills wrap (`flex-wrap`) instead of scrolling sideways,** and are 44px tall below the phone container width. In one app, type pills were cut off on phones and portrait tablets with no hint, and measured 36px (LCDB QA pass, 2026-10-08).
620
+ - **Measure a lengthened placeholder at every size** (canvas `measureText` with the input's font against its content box): a new search placeholder was cut at three sizes.
586
621
  - **Hover states are desktop-only.** Never depend on `:hover` for essential actions.
587
622
  - Form fields: `w-full` on mobile.
588
623
  - Navigation: collapse to hamburger or bottom nav on small screens. This applies only to navigation a block draws itself. In apps with Softr's sidebar / top-bar navigation layout, Softr switches its own navigation to a phone tab bar below a 768px window: 767px gives the tab bar, 768px gives the top bar and sidebar (verified live 2026-10-05; a top-bar-only app was not checked). On a page with Softr navigation, don't build a second one into the block.
@@ -630,6 +665,11 @@ Consider pointer and hover capabilities, not just viewport width. A laptop with
630
665
  - **Empty states:** Speak directly. "You don't have any projects yet."
631
666
  - **Confirmation dialogs:** State the consequences.
632
667
  - **Placeholders:** Example values ("you@company.com"), not repeated labels.
668
+ - **Write for the role reading it.** In one app, volunteers were told to do things only administrators can.
669
+ - **Describe end states:** "would then be over the limit", not "would take it over".
670
+ - **A partial save says "not fully saved", never "not saved".** The second invites entering the whole session again.
671
+ - **A hint that names a button comes from the same condition that shows the button.** In one app, a hint named a button that was not on screen.
672
+ - **When a short message becomes a sentence, check every place it shows** for `truncate`, `max-w` or title-only display: in one app a new 100-character error was cut to "the app c…" (LCDB QA pass, 2026-10-08).
633
673
 
634
674
  ### Voice vs Tone:
635
675