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.
- package/CHANGELOG.md +4 -0
- package/README.md +31 -5
- package/SKILL.md +11 -4
- package/datasources/fields.md +5 -1
- package/datasources/multi-datasource.md +3 -1
- package/datasources/reading.md +26 -0
- package/datasources/softr-database.md +6 -0
- package/datasources/writing.md +34 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +23 -4
- package/references/browser-checks.md +433 -19
- package/references/common-patterns.md +193 -15
- package/references/native-chrome-styling.md +1 -1
- package/references/printing.md +2 -0
- package/references/qa-playbook.md +370 -0
- package/references/quick-reference.md +2 -0
- package/references/searchable-dropdown.md +8 -3
- package/references/softr-mcp.md +167 -18
- package/ui-ux-guidelines.md +46 -6
package/references/softr-mcp.md
CHANGED
|
@@ -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
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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,
|
|
293
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
> **
|
|
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
|
|
748
|
-
iframe's `contentWindow
|
|
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.
|
package/ui-ux-guidelines.md
CHANGED
|
@@ -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:**
|
|
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
|
|
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
|
|