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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.16.0] - 2026-10-08
|
|
8
|
+
- Release 2.16.0
|
|
9
|
+
- QA learnings: QA playbook, build pitfalls, push and MCP facts
|
|
10
|
+
|
|
7
11
|
## [2.15.3] - 2026-10-08
|
|
8
12
|
- Release 2.15.3
|
|
9
13
|
- anti-patterns: unicode escapes in pushed source
|
package/README.md
CHANGED
|
@@ -197,8 +197,9 @@ softr-vibe-coding/
|
|
|
197
197
|
│ │ # (Sep 18 2026); Oct 1 2026: tool rename map,
|
|
198
198
|
│ │ # push verification by sourceSha256, stub tools
|
|
199
199
|
│ │ # after a resume, update_field/update_table fixes;
|
|
200
|
-
│ │ # Oct 6 2026: MCP-written FILTER conditions
|
|
201
|
-
│ │ # inert (set them in Studio
|
|
200
|
+
│ │ # Oct 6 2026: MCP-written Workflow FILTER conditions
|
|
201
|
+
│ │ # are inert (set them in Studio; Source conditions
|
|
202
|
+
│ │ # set over MCP do work), Workflows tools as
|
|
202
203
|
│ │ # workflow_*, denied by-id fetch per backend;
|
|
203
204
|
│ │ # Workflows engine facts (CUSTOM_CODE contract,
|
|
204
205
|
│ │ # string-array loops, sample-based validation,
|
|
@@ -213,7 +214,14 @@ softr-vibe-coding/
|
|
|
213
214
|
│ │ # MCP (subject USER:<field id>, not USER:::),
|
|
214
215
|
│ │ # list_users omits conditional membership,
|
|
215
216
|
│ │ # reading userGroups in the preview iframe,
|
|
216
|
-
│ │ # what __softr_current_user carries
|
|
217
|
+
│ │ # what __softr_current_user carries;
|
|
218
|
+
│ │ # Oct 8 2026 (QA pass): a push sent as several
|
|
219
|
+
│ │ # calls compiles and resets Action permissions on
|
|
220
|
+
│ │ # each call, shrinking search/replace ops, the
|
|
221
|
+
│ │ # push result's versionId is the build id, a
|
|
222
|
+
│ │ # timed-out push may have landed, endpoint names
|
|
223
|
+
│ │ # for reads vs writes, Studio-only jobs, aggregate
|
|
224
|
+
│ │ # and search tool limits
|
|
217
225
|
│ ├── browser-checks.md # Checking a pushed block in a browser with
|
|
218
226
|
│ │ # the agent-browser CLI (ask before installing):
|
|
219
227
|
│ │ # preview cookie, shadow-DOM refs grepped in the
|
|
@@ -222,7 +230,21 @@ softr-vibe-coding/
|
|
|
222
230
|
│ │ # click sent, screenshots to disk (Oct 1 2026);
|
|
223
231
|
│ │ # testing Custom Code header CSS (Oct 5 2026);
|
|
224
232
|
│ │ # the client's time zone via TZ at launch, and
|
|
225
|
-
│ │ # date-only values shown vs stored (Oct 8 2026)
|
|
233
|
+
│ │ # date-only values shown vs stored (Oct 8 2026);
|
|
234
|
+
│ │ # Oct 8 2026 (QA pass): proving the served build
|
|
235
|
+
│ │ # from the script URL, measuring and sizes rules,
|
|
236
|
+
│ │ # the create-save guard and the request log, forcing
|
|
237
|
+
│ │ # states (fake clock, held saves, failed and served
|
|
238
|
+
│ │ # reads), exports and printouts without a download
|
|
239
|
+
│ │ # or pop-up, double-tap and leave-guard tests
|
|
240
|
+
│ ├── qa-playbook.md # QA of a whole app, in the order to run it (Oct 8
|
|
241
|
+
│ │ # 2026): set-up (draft, served build, client zone,
|
|
242
|
+
│ │ # one session per agent), never writing by accident,
|
|
243
|
+
│ │ # roles, logged-out access, figures checked against
|
|
244
|
+
│ │ # the database, edges tested on purpose, proving a
|
|
245
|
+
│ │ # fix before and after in a harness, a live write
|
|
246
|
+
│ │ # pass with a read-only checker, QA with several
|
|
247
|
+
│ │ # agents and skeptics
|
|
226
248
|
│ ├── advanced-integrations.md # Shadow DOM CSS isolation
|
|
227
249
|
│ │ # Leaflet, Mapbox, TinyMCE, Quill, FullCalendar
|
|
228
250
|
│ ├── native-chrome-styling.md # Restyle Softr's native shell (header, footer,
|
|
@@ -239,7 +261,11 @@ softr-vibe-coding/
|
|
|
239
261
|
│ ├── common-patterns.md # Small reusable patterns (localStorage state, clipboard, nav blocker, drag-to-reorder, create → open, clickable row + inner link, keyboard picker, measure the block not the window, clear Softr's sticky bars, a modal above Softr's bars)
|
|
240
262
|
│ │ # localStorage cross-page state, clipboard copy,
|
|
241
263
|
│ │ # navigation blocker, scroll-condensing header,
|
|
242
|
-
│ │ # auth-aware CTA, image masks, blobs, dot lists
|
|
264
|
+
│ │ # auth-aware CTA, image masks, blobs, dot lists;
|
|
265
|
+
│ │ # Oct 8 2026 (QA pass): a search in the URL, a
|
|
266
|
+
│ │ # saved preference, failures, focus and Escape in
|
|
267
|
+
│ │ # dialogs, inline confirm in place of a button,
|
|
268
|
+
│ │ # drafts that survive a reload, CSV export
|
|
243
269
|
│ ├── editable-settings.md # Settings deep-dive: full hook catalog incl.
|
|
244
270
|
│ │ # verified-undocumented useLongTextSetting +
|
|
245
271
|
│ │ # "navigation" array-schema type, granularity
|
package/SKILL.md
CHANGED
|
@@ -75,14 +75,15 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
75
75
|
- Every alias a hook's `where` / `orderBy` names is in **that hook's own** `select` — anything else crashes the block at runtime (Hard Constraint 29)
|
|
76
76
|
- Every `where` has been **seen to narrow** the result (compare row counts with and without it) — a filter on a field outside the connection's read-select union is silently ignored and returns everything ([datasources/reading.md](datasources/reading.md#filters-fail-open))
|
|
77
77
|
- Role checks read `window.__softr_current_user.userGroups` from state with a bounded poll, and role-dependent UI waits until it settles — the global has no change event and an early `[]` means not loaded yet ([datasources/reading.md](datasources/reading.md#current-user))
|
|
78
|
-
- Date-only values
|
|
78
|
+
- Date-only values go through `toLocalDate()`, never `new Date()` or date-fns `parseISO()` — both read the stored midnight-UTC value as an instant, so it renders a day early west of Greenwich ([datasources/fields.md](datasources/fields.md#date-only-fields-arrive-as-midnight-utc)). A "today" that a save writes is `format(new Date(), "yyyy-MM-dd")` worked out in the click handler: never `toISOString().slice(0, 10)` (the UTC day), never a value computed at render or mount (stale on a page left open past midnight). A "Generated" stamp in an export or printout is local time with its UTC offset ([writing.md → Date](datasources/writing.md#date))
|
|
79
|
+
- A blank NUMBER (`null` in a block) shows blank, never `0`, and a stored `0` still shows `0`. A typed quantity is never silently changed: refuse what the input cannot read (`validity.badInput`, or a raw string that is not `^\d+$` for whole numbers) and parse with `Number`, never `parseInt` or `Math.floor` ([ui-ux-guidelines.md §11](ui-ux-guidelines.md#11-forms-and-input-fields) for typed input, [fields.md](datasources/fields.md#getfieldvalue----never-render-raw-fields) for stored values)
|
|
79
80
|
- No field is "hidden" from some viewers by a conditional / second `select` on the same connection — the browser receives the union of every read select on that connection (Hard Constraint 23)
|
|
80
81
|
- All imports use named imports (no `import React from 'react'`)
|
|
81
82
|
- `export default function Block()` is present
|
|
82
83
|
- Container + content wrappers present (`<div className="container py-0"><div className="content">`) — OR a deliberate full-bleed layout recorded in the `// BLOCK PLACEMENT:` comment (see "Block Placement & Page Spacing")
|
|
83
84
|
- `// BLOCK PLACEMENT:` comment present at top of file with wrapper classes matching the placement (see "Block Placement & Page Spacing")
|
|
84
85
|
- App page beside Softr's sidebar / top-bar navigation (the header code paints the frame): the block is **full-bleed** with **no background of its own** and no gradient/rounded page panel; every layout threshold is a container query on the block's width (`@container` on a wrapper + `@min-[NNrem]:`), with **no `sm:`/`md:`/`lg:` for layout**; sticky elements clear the top bar with `calc(var(--nav-height, 0px) + …)`. See [App pages beside Softr navigation](#app-pages-beside-softr-navigation)
|
|
85
|
-
- Loading, error, and empty states all handled
|
|
86
|
+
- Loading, error, and empty states all handled. Try again re-reads every read that is in error, not only the main list. A figure built from several reads shows the error if any of them failed, never a number from the rest. A failed read never falls through to the empty state ("No records yet"). An action that needs a complete list (a create with a duplicate check, an export) stays disabled until that read has fully loaded, and no list is cut off silently ([ui-ux-guidelines.md §12](ui-ux-guidelines.md#12-loading-states))
|
|
86
87
|
- Mutation calls gated behind `enabled` check (if using mutations)
|
|
87
88
|
- Field access uses `record.fields.alias` (not `record.alias`)
|
|
88
89
|
- Every field rendered in JSX wrapped in `getFieldValue()` -- prevents React error #31
|
|
@@ -93,6 +94,8 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
93
94
|
- Mutations use `recordId` (not `id`) and call `refetch()` in `onSuccess` — no read follows a write otherwise (measured 2026-10-05 on a HubSpot-backed block). When the source changes other fields itself (HubSpot stamps a closing deal's close date and recalculates its probability a few seconds later), also read again a few seconds later — see [writing.md → Fields the source changes after the write](datasources/writing.md#fields-the-source-changes-after-the-write)
|
|
94
95
|
- `useRecordUpdate` payload is `{ recordId, fields: { ... } }` — nested. `useRecordCreate` payload is **flat** (no `fields` wrapper). The two shapes are asymmetric by design (verified live 2026-08-25)
|
|
95
96
|
- Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
|
|
97
|
+
- A failed save shows its error inside the dialog or panel where it happened (`role="alert"`), not only in a toast. A dialog can be closed whenever nothing is in flight, keeping what was typed. After a failed step the form stays locked until Retry or Start over, so a Retry always sends what the screen shows. The double-submit guard is a ref set before the first `await` ([writing.md → Sequential Multi-Row Writes](datasources/writing.md#sequential-multi-row-writes-mutateasync))
|
|
98
|
+
- A form block calls `useNavigationBlocker(isDirty)` itself: a `beforeunload` listener alone misses Softr's in-app links. Confirm it in the preview: make the form dirty, click a sidebar link, expect the prompt ([common-patterns.md](references/common-patterns.md#navigation-blocker-for-unsaved-changes))
|
|
96
99
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
97
100
|
- **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by). Opening a Combo moves focus into it (the search box, or the trigger on a click-only list), because Safari does not focus a clicked button. Escape on an open list closes only the list ([Move focus into the Combo when it opens](references/searchable-dropdown.md#move-focus-into-the-combo-when-it-opens))
|
|
98
101
|
- **No native date field** — no `<input type="date">`, `type="datetime-local"` or `type="month"` in a branded block: the browser draws its calendar pop-up, and no CSS reaches it. Use the module-scope `DatePicker` kit in [references/date-picker.md](references/date-picker.md), pasted verbatim between its marker lines (block-specific code, `textClass` included, stays outside them). Values stay `"yyyy-MM-dd"` strings, compared as strings. Same rules as the Combo: nothing between the field and its scroller clips, and Escape on an open calendar closes only the calendar
|
|
@@ -103,7 +106,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
103
106
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
104
107
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
|
105
108
|
- **Deploying through the MCP:** `errors: null` on a push is not proof — compare the push result's `sourceSha256` with `shasum -a 256` of the file you sent, every byte counted, trailing newline included (Softr stores exactly the text that reaches it; the one-byte drift we once blamed on it was a chunked read on our side). No `\uXXXX` escapes in pushed source: they arrive decoded to the characters and the hashes differ, so write the characters themselves (with a comment saying why) or build them from code points (`String.fromCharCode(0x300)`); lone surrogates are the exception ([why](references/softr-mcp.md#unicode-escapes-come-back-decoded)). Prove deployed == disk *before* editing the same way, with `vibe_coding_block_get_code` and `includeCode: false`, so a Studio-side change is never overwritten. Fetch the full `sourceCode` only when a digest is missing or the hashes differ. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
|
|
106
|
-
- **Then check it in a browser.** Once the push checks pass, check rendering and behaviour in a fresh preview with saves blocked, in the client's time zone (a check east of UTC misses date-only values that users west of it see a day early), per [references/browser-checks.md](references/browser-checks.md)
|
|
109
|
+
- **Then check it in a browser.** Once the push checks pass, check rendering and behaviour in a fresh preview with saves blocked, in the client's time zone (a check east of UTC misses date-only values that users west of it see a day early), per [references/browser-checks.md](references/browser-checks.md); for a whole QA pass (roles, logged-out access, figures against the database, forced failures, a live write pass) follow [references/qa-playbook.md](references/qa-playbook.md)
|
|
107
110
|
|
|
108
111
|
## What to Clarify
|
|
109
112
|
|
|
@@ -191,6 +194,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
|
|
|
191
194
|
| Writing Airtable Automation Scripts / Scripting Extension scripts / Airtable formulas — companion to Softr blocks for cross-table cascades and computed values | [references/airtable-automations.md](references/airtable-automations.md) |
|
|
192
195
|
| The official **Softr MCP server** — Softr DB schema + full record/table/field/database CRUD (deletes included), field-level browsing of connected integrations (Airtable / Google Sheets / Notion / Supabase and more), **creating, editing, versioning, and deploying Vibe Coding blocks directly** (`vibe_coding_block_get_docs`, `vibe_coding_block_create`, ...), push verification by `sourceSha256`, app management/scaffolding, the **Softr Workflows** suite (28 tools, 418-node catalog), and **per-application MCP servers**. Tool names changed on 2026-10-01; the file carries the old → new map | [references/softr-mcp.md](references/softr-mcp.md) |
|
|
193
196
|
| **Checking a pushed block in a browser** — rendering and behaviour in a Softr preview with the agent-browser CLI (ask before installing it): running it in the client's time zone (`TZ` when the session starts) and comparing shown date-only values with the stored ones, the preview cookie, reaching into the block's shadow DOM through accessibility refs, measuring with `eval`, blocking and proving the save endpoint before any click, reading what a click sent, screenshots to disk, testing Custom Code header CSS | [references/browser-checks.md](references/browser-checks.md) |
|
|
197
|
+
| **QA of a whole app** — the order to run a pass after the blocks are pushed: set-up (the draft, the served build, the client's zone, one session per agent), never writing by accident, roles, logged-out access, figures checked against the database, edges tested on purpose, proving a fix before and after in a local harness, a live write pass with a read-only checker, running QA with several agents and skeptics | [references/qa-playbook.md](references/qa-playbook.md) |
|
|
194
198
|
| Restyling Softr's **native shell — header / footer / nav / dropdowns / page background** (not a block; it's Softr chrome, done with global Custom Code CSS): stable selectors vs. hashed classes, floating "island" header+footer, the dropdown blank-space grid fix, the multi-layer page-background stacking, restyle-vs-replace, and the **app frame** for sidebar apps (top bar + sidebar as one frame in the theme colour, the content as one paper sheet with a pinned rounded corner, blocks transparent, scoped with `:has()` so Log in / 404 keep Softr's colours) | [references/native-chrome-styling.md](references/native-chrome-styling.md) |
|
|
195
199
|
| Adding a **dynamic date filter or custom filter control to a native List/Grid block** (via a Custom Code Static block, not a Vibe block): drive the block's conditional filter with `{URL_PARAM:…}`, the empty-param "match nothing" wide-range sentinel, inject the control into the filter row and keep it alive across Softr's re-renders | [references/native-block-filters.md](references/native-block-filters.md) |
|
|
196
200
|
| **Editable settings deep-dive** — full hook catalog (incl. verified-undocumented `useLongTextSetting` and the `navigation` array-schema type), settings-first granularity doctrine, heading-line-split and `-text`/`-link` pairing patterns, naming conventions, rename-resets-value gotcha, empty-media gating, key-by-index rule | [references/editable-settings.md](references/editable-settings.md) |
|
|
@@ -637,6 +641,7 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
637
641
|
18. **Never call `fetchNextPage` in the render body** -- render calls `fetchNextPage`, which updates data, which triggers re-render, which calls it again: infinite loop. Call it from an event handler (the official Load More pattern: `<button onClick={() => fetchNextPage()} disabled={isFetching}>`) or from a guarded `useEffect` when intentionally auto-loading all pages.
|
|
638
642
|
19. **All hooks before any conditional `return`** -- Hooks must be called in the same order every render. A hook declared after a conditional `return` causes React error #310.
|
|
639
643
|
20. **Relative paths in navigation [house]** -- Use `/page-name?recordId=...`, never hardcoded domains like `app.client.com/page`. The platform accepts absolute self-domain URLs as navigation setting values (a Studio-generated hero shipped its CTA configured as `https://<app>.softr.app/#section` — observed in the Settings pane, 2026-08-31), but they break on custom-domain publish — rewrite them relative, including hash anchors (`/#section`).
|
|
644
|
+
**A relative link depends on the target page's slug.** Blocks build links like `/detail?recordId=…` by hand, so a slug change breaks them with no error. Never rename a slug a block links to; if one must change, change every block that builds it in the same edit. Studio's page folders and nesting don't change slugs (Softr's docs say so; not tested), but read the slug back after moving pages (LCDB Studio setup, 2026-10-07; a precaution, no breakage seen).
|
|
640
645
|
21. **Recompiles reset Action permissions** -- Every code recompile/redeploy resets the block's
|
|
641
646
|
auto-registered Actions to **default permissions**. Do the Actions-tab permission tightening pass
|
|
642
647
|
only after the LAST redeploy, and re-check it after any future one. Verified live 2026-08-25
|
|
@@ -660,6 +665,7 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
660
665
|
stubs) (see
|
|
661
666
|
[references/softr-mcp.md](references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)).
|
|
662
667
|
A push that returns `errors: null` can still have left public write access on the block.
|
|
668
|
+
**A push sent as several calls resets restricted actions after EACH call**, so a restricted action is open to its default audience from the first call until the re-apply. Send the calls back to back and re-apply straight after the last one ([softr-mcp.md → Which edit tool](references/softr-mcp.md#which-edit-tool-full-replace-vs-targeted-search-replace) for planning them). Set the block's own visibility before its first push, so ADD_RECORD's default is not All users in the meantime. Seen live (LCDB QA pass, 2026-10-08): an Administrator-only void was open to every logged-in user for about two minutes, between call 1 of a two-call push and the re-apply.
|
|
663
669
|
**If any action is still broader than intended, report it WITH its severity and let the builder decide.**
|
|
664
670
|
Check the page's own VIEW permission first (`application_page_get_permissions`): a page gated to logged-in
|
|
665
671
|
users makes an open action housekeeping, a public page makes it a real hole. Report the block's own
|
|
@@ -678,6 +684,7 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
678
684
|
are shared, and promise nothing beyond them (the class strings usually differ in layout and padding,
|
|
679
685
|
and do not need to match). The same rule governs repeated page chrome -- see **Block Placement &
|
|
680
686
|
Page Spacing**.
|
|
687
|
+
**The same goes for data rules, not only looks: one rule per figure.** A figure that appears on several pages is counted by the same rule everywhere. When a field's meaning, label or default changes, grep every reader in every block, by field id and by `fields.<alias>`. Read a free-text category or a display default (`|| "Active"`) through one helper, the same in every block that reads that field. Diff text that must match across blocks (a definitions paragraph) word for word before pushing, and compare paired fixes side by side: first focus, close button, button colour, where the error shows. Pages that each looked right disagreed on the same figure (LCDB QA pass, 2026-10-08): the "same number on every page" lens found 7 of the 15 major findings. One pair of blocks, an exact match in one and a lenient match in the other, would have shown about 265 hours on one page and 29 on the other for the same group as soon as someone typed the free-text kind as "group " (every row matched exactly, so no wrong figure was live). And two confirm dialogs "built the same way" by separate editors differed on focus, close button and colour.
|
|
681
688
|
23. **One connection = one read payload; a conditional select is not privacy** -- the records
|
|
682
689
|
endpoint is per block + connection and returns the UNION of every field named by any READ
|
|
683
690
|
`q.select` on that connection, to every viewer. A second or ternary select "only for admins"
|
|
@@ -736,7 +743,7 @@ Before delivering any block, run through [references/anti-patterns.md](reference
|
|
|
736
743
|
|
|
737
744
|
## Code Quality Guidelines
|
|
738
745
|
|
|
739
|
-
- Use `sonner`'s `toast` for
|
|
746
|
+
- Use `sonner`'s `toast` for row-level results and successes. Also: `toast.info()`, `toast.message("Note", { description: "..." })`. A save that fails inside a dialog shows its error inside the dialog (`role="alert"`, above the footer): a toast covers the dialog's own buttons and disappears after a few seconds (LCDB QA pass, 2026-10-08).
|
|
740
747
|
- Show loading states with `spinner` or `skeleton` when `status === "pending"`.
|
|
741
748
|
- Show error states gracefully when `status === "error"`.
|
|
742
749
|
- After mutations, call `refetch()` before success toast.
|
package/datasources/fields.md
CHANGED
|
@@ -33,7 +33,7 @@ Property priority: `label` first (most common in Softr formatted fields), then `
|
|
|
33
33
|
Apply `getFieldValue()` everywhere you read fields:
|
|
34
34
|
- Table cells, badges, tooltips: `<td>{getFieldValue(f.subject)}</td>`
|
|
35
35
|
- Date parsing: `toLocalDate(getFieldValue(f.dueDate))` for date-only fields ([below](#date-only-fields-arrive-as-midnight-utc)), `new Date(...)` for real timestamps -- raw value might be `{label: "2025-12-01"}`
|
|
36
|
-
- Number parsing: `
|
|
36
|
+
- Number parsing: test for blank first, then use `Number` -- formula numbers come back as objects. `const raw = getFieldValue(f.count); const n = raw === "" ? null : Number(raw);` and render blank for `null`. A blank NUMBER arrives as `null` (`getFieldValue` turns it into `""`); `Number(null)` and `Number("")` are both 0, so `Number(getFieldValue(f.count))` alone shows a blank allocation as 0 and reads a blank threshold as 0 (a product was never flagged). `parseInt(…, 10)` gives NaN for a blank and drops decimals. A stored 0 still shows 0 (LCDB QA pass, 2026-10-08)
|
|
37
37
|
- String methods: `getFieldValue(f.status).toLowerCase()`
|
|
38
38
|
- Inside `useMemo` normalizers, BEFORE storing into state -- prevents the object from propagating
|
|
39
39
|
|
|
@@ -61,6 +61,10 @@ function toLocalDate(raw) {
|
|
|
61
61
|
A real timestamp at exactly midnight UTC matches the pattern too, so call `toLocalDate` only on
|
|
62
62
|
fields you know are date-only.
|
|
63
63
|
|
|
64
|
+
date-fns `parseISO()` reads a date-only value as that instant too, so `format(parseISO(v), …)` shows the day before west of UTC, the same as `new Date(v)` (a list showed 30 of 30 dated rows a day early, LCDB QA pass, 2026-10-08).
|
|
65
|
+
|
|
66
|
+
**Compare date-only values as `yyyy-MM-dd` text, on `raw.slice(0, 10)`.** A month is `d >= monthStart && d < nextMonthStart`. A raw `date <= "2026-10-31"` drops the last day, because the stored `2026-10-31T00:00:00.000Z` sorts after `2026-10-31`: a month tile left out the month's last day. How the server compares a `q.date` bound with a midnight-UTC value was not verified, so treat a server date range as a pre-filter and decide on the client; reading a day wider than needed is the safe way. For a "today" that a save writes, see [writing.md → Date](writing.md#date).
|
|
67
|
+
|
|
64
68
|
## Debugging Error #31
|
|
65
69
|
|
|
66
70
|
If React crashes with error #31 ("Objects are not valid as a React child"), open console on the first record that crashes and run:
|
|
@@ -94,11 +94,13 @@ it behaves as a **union** of both branches, not a choice between them. Which is
|
|
|
94
94
|
*Verified live 2026-09-18; the block-visibility gate 2026-10-05.*
|
|
95
95
|
|
|
96
96
|
The records endpoint is per block + connection —
|
|
97
|
-
`/blocks/<blockId>/datasources/<
|
|
97
|
+
`/blocks/<blockId>/datasources/<name>/records` (`<name>` is the name given in `datasource.define`; see the note below) — and it returns the **UNION of every field
|
|
98
98
|
named by any READ `q.select` attributed to that connection**. Two selects on one connection do
|
|
99
99
|
NOT produce two payloads: every read hook on that connection gets all the fields, for every
|
|
100
100
|
viewer.
|
|
101
101
|
|
|
102
|
+
*On `<name>`.* Block reads go to `…/blocks/<block>/datasources/<name>/records`, where `<name>` is the name given in `datasource.define` (seen on Softr Database on 2026-10-08 and on HubSpot on 2026-10-05), while saves go to `…/records-trigger/…` under a UUID. The 2026-09-18 Softr Database capture recorded an id in that position, which is still unexplained. It only matters when reading or routing a network log; the route patterns for failing reads are in the Forcing states section of [browser-checks.md](../references/browser-checks.md).
|
|
103
|
+
|
|
102
104
|
**So "request the private field only for admins" is not privacy.** A second select, or a ternary
|
|
103
105
|
between a public and an admin select, still ships the private field to every browser that loads
|
|
104
106
|
the block — it is simply not rendered. Anyone can read it in the network tab.
|
package/datasources/reading.md
CHANGED
|
@@ -65,6 +65,11 @@ var isRefetching = result.isRefetching;
|
|
|
65
65
|
var items = (data && data.pages) ? data.pages.flatMap(function(p) { return p.items; }) : [];
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
**Two behaviours that look like bugs** (measured with reads aborted, LCDB QA pass, 2026-10-08):
|
|
69
|
+
|
|
70
|
+
- **The hooks refetch when the window regains focus,** so `isRefetching` turns true with no click. A busy label driven by it flickers each time the user comes back to the tab; drive busy labels from the click.
|
|
71
|
+
- **A failed read is retried about three times before `status` turns `"error"`.** The error shows 6 to 20 seconds after the failure starts (7 to 13 seconds in most checks), and the block shows loading until then. A check that waits 3 to 5 seconds sees "Loading", not the error state.
|
|
72
|
+
|
|
68
73
|
**House rule: one `useRecords` per connection.** It is not a documented platform limit — Hard Constraint 13 in SKILL.md says when to filter client-side, when to add a connection, and when a server-side `where` is the better choice. Multiple `useMetric` calls ARE allowed.
|
|
69
74
|
|
|
70
75
|
**CRITICAL:** The options object must be an **inline literal** at the call site. Passing it
|
|
@@ -120,6 +125,8 @@ useEffect(function() {
|
|
|
120
125
|
}, [result.hasNextPage, result.isFetchingNextPage, result.status, result.error, result.fetchNextPage]);
|
|
121
126
|
```
|
|
122
127
|
|
|
128
|
+
**A total, count or export built from a paginated list waits until every page has loaded** (`hasNextPage` false) and no read is in error. Until then it shows loading, never a number from the pages so far: a summary line read "0 children" while the children read was still on its first page or had failed, and a history list was capped instead of paged. [Printing](../references/printing.md#4-the-print-button-waits-for-the-data) applies the same rule to Print.
|
|
129
|
+
|
|
123
130
|
## useRecord -- Fetch a Single Record
|
|
124
131
|
|
|
125
132
|
```jsx
|
|
@@ -493,6 +500,21 @@ Gate anything role-dependent on `groupsSettled` (show a skeleton until then), so
|
|
|
493
500
|
flashes the wrong panel. The bound settles a viewer whose list never fills in, instead of leaving
|
|
494
501
|
them on a skeleton.
|
|
495
502
|
|
|
503
|
+
**Block code can only test group names, so a rename in Studio silently breaks a name check.** `userGroups` items arrive as names (a string, or an object with a `name`; no id was seen), and nothing in the global lets block code gate by group id. Block Visibility and action permissions are set by group id and survive a rename. While a rename is pending, accept both the old and the new name, and drop the old one afterwards: the push and the rename can then land in either order. One block did this in an app that renamed its three groups (LCDB, 2026-10-07).
|
|
504
|
+
|
|
505
|
+
```jsx
|
|
506
|
+
// Module scope. userGroups items arrive as strings or as objects with a name.
|
|
507
|
+
function groupName(g) { return typeof g === "string" ? g : (g && g.name) || ""; }
|
|
508
|
+
|
|
509
|
+
// In Block(): this replaces the `isAdmin` line in the example above.
|
|
510
|
+
var isAdmin = userGroups.some(function(g) {
|
|
511
|
+
var n = groupName(g);
|
|
512
|
+
return n === "Administrator" || n === "Admin"; // new name, then the old one until the rename is done
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Before writing copy such as "they will lose access", check how each group gets its members, a manual list or a condition: that sentence was false for a user added to a group by hand.
|
|
517
|
+
|
|
496
518
|
## Metrics
|
|
497
519
|
|
|
498
520
|
```jsx
|
|
@@ -506,6 +528,10 @@ var result = useMetric({
|
|
|
506
528
|
// result.data is the aggregated value (number)
|
|
507
529
|
```
|
|
508
530
|
|
|
531
|
+
- **A per-item server sum is one `useMetric` per item.** Hooks can't run in a loop over a list of variable length, so mount one inside a module-scope probe component per item, only while the figures are needed, each reporting into a state map. Not yet seen live in a block (built in a QA round that paused before its live check); the structure follows from the rules of hooks.
|
|
532
|
+
- **A metric that is not a finite number shows "unknown," never 0.** `Number(x) || 0` hides a NaN.
|
|
533
|
+
- **Before reusing an existing read for a new figure, check that read's `where`.** A work list assumed an existing read covered on-hand stock, while its `where` cut it to one transaction type (LCDB QA pass, 2026-10-08).
|
|
534
|
+
|
|
509
535
|
Aggregations: `metric.sum(field)`, `metric.avg(field)`, `metric.max(field)`, `metric.min(field)`, `metric.distinct(field)`, `metric.count()`
|
|
510
536
|
|
|
511
537
|
## Chart Data
|
|
@@ -30,6 +30,8 @@ Find field IDs in this order of preference:
|
|
|
30
30
|
|
|
31
31
|
The generic Field Inspector pattern with empty `q.select({})` does NOT work for Softr Database — see [fields.md](fields.md#field-inspector-block).
|
|
32
32
|
|
|
33
|
+
**Field ids are per database.** Rebuilding a database (for example, to move an app into another workspace) gives every table and field a new id, so every block's field and table references have to change. Do the remap with a script: a one-to-one old-to-new map keyed on table and field names rewrote 775 references in 15 blocks (LCDB rebuild, 2026-10-05). Prove a remap by reversing it back to the original, byte for byte, and by each push's `sourceSha256` ([protocol](../references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)). Match only real field references (`q.select` values, `where` / `orderBy` aliases). Counting every quoted five-character literal overcounted by 191 ordinary words such as `error`.
|
|
34
|
+
|
|
33
35
|
## Bundled CLI script: `get-softr-database`
|
|
34
36
|
|
|
35
37
|
A Python CLI bundled with this skill that exports a complete Softr Tables database schema (every table, every field, all dropdown option UUIDs) to a timestamped JSON file on your Desktop. Stdlib only — no `pip install` required.
|
|
@@ -106,6 +108,10 @@ No API rate limits. Softr Database queries run internally without external API c
|
|
|
106
108
|
- **Formula arithmetic is floating-point.** `1.15 * 400` rendered as `459.99999999999994` (seen 2026-09-18; the stored 1.15 was exact, the product was not). Wrap money and any other displayed product in `ROUND(…, 2)`.
|
|
107
109
|
- **An EMAIL field does not enforce one address.** A full read of a production table on 2026-09-01 found EMAIL-typed fields holding comma-separated lists. Split and trim before treating the value as one address. Passed whole into an email's To field, the addresses all see each other.
|
|
108
110
|
- **A link's `label` is the linked table's display field** (verified 2026-09-19). Change that table's display field in Studio and every label changes with it, so anything that matches on a label (block code, a Source condition, a workflow reference such as `[*].label`) silently stops matching. Match on the record id, or read the value from its own field.
|
|
111
|
+
- **A NUMBER field's precision rounds only Softr's own display.** At precision 0 the database stored 1.5 while Softr's views showed it rounded. Changing precision changes no stored value, and the MCP and blocks read the stored decimals: quarter hours and cents read back unchanged before and after a precision change (LCDB, 2026-10-07 to 08). A block cannot rely on precision to reject decimals.
|
|
112
|
+
- **A CREATED_AT field added to an existing table is filled in for the existing records with their own creation time**, so a creation-order tie-breaker can be added later (LCDB, 2026-10-07: 101 existing records). Like any read-only field, it stays out of every write select ([anti-patterns.md](../references/anti-patterns.md#mutations)).
|
|
113
|
+
- **Users-table sync from a Softr Database table (set in Studio) turns every row with a value in the mapped email field into an app user** (and invites it, when the sync is set to invite; whether an invite fires for a row a block creates was not checked), whatever its other fields say: inactive rows and rows that stand for a team rather than a person included. Synced users land in no custom group unless a condition puts them in one. The sync also runs the other way: an existing app user with no matching row got a new row holding only the email (blank name and status), and blocks then showed it as a nameless active record. Map the sync to a field filled only for people who should sign in, and give blocks a rule for rows with no name or status (LCDB, 2026-10-05 to 08; the HubSpot equivalent is in [hubspot.md](hubspot.md#user-sync)).
|
|
114
|
+
- **An "entered by" or "updated by" value a block writes is self-reported.** The browser puts it in the save body, so anyone who can reach the write endpoint can write any email there. Treat it as a convenience, not an audit trail. Softr Database's CREATED_BY and UPDATED_BY system fields may be the tamper-evident alternative; whether they record the app user (not the builder) when a vibe block writes is unverified, so check on one test record first.
|
|
109
115
|
- **Softr's Zapier "Update Record" action replaces a multi-link field's whole set** (confirmed by a Studio test, 2026-09-01). A zap that writes one record id into a link that allows several wipes the earlier links. Write the existing ids plus the new one.
|
|
110
116
|
|
|
111
117
|
## Best For
|
package/datasources/writing.md
CHANGED
|
@@ -56,6 +56,7 @@ deployment; treat it as standing platform behavior, not a one-off.
|
|
|
56
56
|
read-only, so the code says what the platform does.
|
|
57
57
|
- A mutation hook's `fields:` select does **not** join the connection's read union — write-only
|
|
58
58
|
fields are not shipped to the browser by the records endpoint.
|
|
59
|
+
- **Corollary for an admin-only write (a void, an adjustment).** The server enforces it only when it is the block's only action of its type on that table. A void whose table sees no other UPDATE (normal work there only creates rows): restrict that UPDATE_RECORD action to the administrators' group. A stock adjustment written as a new ledger row is an ADD_RECORD, so it is enforceable only if nothing else in the block adds rows to that table. When normal work shares the action (receiving shares ADD_RECORD with the adjustment, toggling shares UPDATE_RECORD with the void), the write can only be hidden in the browser: move it to a separate group-gated block or a workflow if it has to be enforced. In one app, two voids were server-enforced because each was its table's only UPDATE, while one adjustment and one void shared their action with normal work and stayed browser-only (LCDB, 2026-10-07).
|
|
59
60
|
|
|
60
61
|
Practical upshot for the post-push permission pass (see
|
|
61
62
|
[softr-mcp.md](../references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)):
|
|
@@ -81,6 +82,8 @@ Verified by direct experiment (April 2026): adding a new field to `q.select` + `
|
|
|
81
82
|
|
|
82
83
|
Because the parser only inspects your hooks and `q.select` mappings (not the JSX tree), inputs rendered conditionally inside `<Dialog>`, `<Sheet>`, or any subtree gated by state are still bound to the Action correctly. Verified by direct experiment, May 2026.
|
|
83
84
|
|
|
85
|
+
**Keep each `hook.mutateAsync(...)` call on the hook variable inside the function that declares the hook.** If a module-scope helper has to write, pass it the hook (or a callback that calls it). Move the code that does not write to module scope instead: a queue runner's state, row memos, dialog state, paging effects. That also makes it testable on its own. This is by analogy, not tested either way: the parser reads a hook's call site statically, as it does for inline select options and nested update payloads, and a write call moved out of sight might lose its Action with no error (LCDB QA pass, 2026-10-08).
|
|
86
|
+
|
|
84
87
|
## Record Mutations
|
|
85
88
|
|
|
86
89
|
All mutation hooks expose an `enabled` boolean. You must check it before rendering any mutation UI or calling the mutate function.
|
|
@@ -107,6 +110,8 @@ if (createRecord.enabled) {
|
|
|
107
110
|
}
|
|
108
111
|
```
|
|
109
112
|
|
|
113
|
+
`error.message` is the browser's raw text ("Failed to fetch"); in a shipped block, map it through the block's one error helper ([Error Message Formula](../ui-ux-guidelines.md#error-message-formula)).
|
|
114
|
+
|
|
110
115
|
**Create payloads are FLAT — no `{ fields }` wrapper** (verified live 2026-08-25). The payload's
|
|
111
116
|
keys are the aliases from the hook's `fields:` q.select, at the top level. This is deliberately
|
|
112
117
|
asymmetric with `useRecordUpdate`, whose payload nests them: `{ recordId, fields: { ... } }`.
|
|
@@ -146,6 +151,8 @@ sources (inferred, untested elsewhere). So:
|
|
|
146
151
|
later as well; see
|
|
147
152
|
[Fields the source changes after the write](#fields-the-source-changes-after-the-write).
|
|
148
153
|
|
|
154
|
+
**One hook serving two surfaces** (a row's Save and a dialog's action): a hook-level `onError` cannot know where to show the error. Leave it off and show the error from each handler's catch, with that handler's context: the row toasts, the dialog shows it inside itself (LCDB QA pass, 2026-10-08).
|
|
155
|
+
|
|
149
156
|
#### CRITICAL: The `useRecordUpdate` payload shape (and the retired `.mutate()`-only rule)
|
|
150
157
|
|
|
151
158
|
**Payload must be `{ recordId, fields: {...} }` — not flat.** Field values must be nested inside a `fields: {...}` object. The flat form (`mutate({ recordId, status: "active" })`) can succeed at runtime, but Softr's Action parser doesn't see field references inside it, so no Update Action is derived — `enabled` stays `false`, the UI gated on it silently does nothing, and Studio's Actions tab shows "No actions used in this block yet":
|
|
@@ -259,6 +266,18 @@ Rules that make this safe:
|
|
|
259
266
|
to `undefined`.
|
|
260
267
|
- **Gate the whole flow on the hooks' `enabled` booleans**, same as any mutation UI.
|
|
261
268
|
|
|
269
|
+
What a QA pass over 15 blocks added to these rules (LCDB, 2026-10-08), each from a queue that sent stale values, wrote twice, or locked a dialog for good:
|
|
270
|
+
|
|
271
|
+
- **Lock the inputs after a failure.** Keep them in a `<fieldset disabled>` until Retry or Start over, so a Retry sends what the screen shows. A Retry once sent the values from the first click while the editable form showed new ones. (Inputs inside a disabled fieldset still report `.disabled === false`; test `matches(":disabled")`.)
|
|
272
|
+
- **Set the in-flight guard in a ref, before the first `await`.** State is a render late and a fast double tap gets through it: a deployed block created 2 records on a double click with a 300ms save.
|
|
273
|
+
- **An awaited check before a retry runs in try/catch.** A throw there leaves the dialog locked.
|
|
274
|
+
- **A queue that a later Retry resumes keeps its input in a ref,** not in live dialog state.
|
|
275
|
+
- **Block a new run while any line still needs Retry,** or the pending retry is lost.
|
|
276
|
+
- **A retry re-reads the server after its own failure too,** not only before the first attempt, or it can write a row twice.
|
|
277
|
+
- **Stop the queue when the block unmounts** (a ref the loop checks).
|
|
278
|
+
- **A header saved with a failed line is "not fully saved"** in the footer text, not "not saved".
|
|
279
|
+
- Show the failed step's error inside the dialog, and let the user close it whenever nothing is in flight ([common-patterns.md → Failures, focus and Escape](../references/common-patterns.md#failures-focus-and-escape)).
|
|
280
|
+
|
|
262
281
|
### Parallel writes across tables (the one sanctioned parallelism)
|
|
263
282
|
|
|
264
283
|
The no-parallel rule above is about *same-table batches*. Writes to **different tables through
|
|
@@ -550,6 +569,8 @@ createRecord.mutate({ price: 49.99, quantity: 3 });
|
|
|
550
569
|
|
|
551
570
|
To clear, `null` works on Softr Database (`""` is invalid for numeric fields). Other data sources not independently verified.
|
|
552
571
|
|
|
572
|
+
**Range.** A Softr Database NUMBER field declares a minimum of -2,147,483,648 and a maximum of 2,147,483,647 (int32) in the `dataSources` that `vibe_coding_block_get_code` returns. Bound typed quantities to that range in the shared validator ([ui-ux-guidelines.md §11](../ui-ux-guidelines.md#number-inputs)), so an over-range typo is refused before a header is written, not after (a partial save). That the server refuses a larger value was not probed; the limit was read from the schema.
|
|
573
|
+
|
|
553
574
|
### Checkbox
|
|
554
575
|
|
|
555
576
|
Boolean `true` / `false`:
|
|
@@ -577,6 +598,8 @@ a distribution date) take the `"yyyy-MM-dd"` form; **timestamp** fields (`*_at`
|
|
|
577
598
|
take `new Date().toISOString()`. Writing a full timestamp into a date-semantics field invites
|
|
578
599
|
timezone-shift bugs — compare and display such fields on the `yyyy-MM-dd` slice.
|
|
579
600
|
|
|
601
|
+
**A "today" that a save writes into a date-only field** is `format(new Date(), "yyyy-MM-dd")`, worked out in the click handler. Never `new Date().toISOString().slice(0, 10)`: that is the UTC day, which is tomorrow on a US evening. And never a value computed at render or mount: a verify action stamped the render-time day, which is yesterday on a page left open past midnight (LCDB QA pass, 2026-10-08). The same local-time rule covers a "Generated" stamp in an export or printout: local time with its UTC offset ([common-patterns.md → CSV export](../references/common-patterns.md#csv-export)).
|
|
602
|
+
|
|
580
603
|
### Date Range
|
|
581
604
|
|
|
582
605
|
Object with `from` and `to` ISO strings — mirrors the read shape (`{ from, to }` per [fields.md](fields.md)):
|
|
@@ -687,3 +710,14 @@ Notes:
|
|
|
687
710
|
- Rate limits: Reads 40 req/s, Writes 30 req/s
|
|
688
711
|
|
|
689
712
|
Verified by direct experiment (May 2026): POST to this endpoint with field IDs as keys writes successfully, returning HTTP 200 and the full record JSON. At that time the endpoint took plain string UUIDs for dropdown writes, and the response returned the dropdown value as a `{id, label}` object (matching the read shape). Note: that observation predates the 2026-08-25 finding that the **in-block hooks** write SELECTs by label — the REST endpoint is a separate surface and may still expect UUIDs; re-verify whichever shape you use here.
|
|
713
|
+
|
|
714
|
+
### Audit and change-log rows
|
|
715
|
+
|
|
716
|
+
Cross-table writes often include an audit row (who changed what, when). The write pass and its checker found these gaps (LCDB, 2026-10-08):
|
|
717
|
+
|
|
718
|
+
- **Stamp who and when (`updated_by`) on every write path,** creates and voids included. Several saves left it empty because the field was missing from the write select.
|
|
719
|
+
- **A change-log row written before a create cannot name the new record.** Write another row once the create's id comes back, and guard the log-first write so it is never re-sent. One override left a row with the record id "pending-create".
|
|
720
|
+
- **Audit rows record the stored value,** not a display default: a row logged "Active" as the old value of a field that was blank.
|
|
721
|
+
- **Reversal rows use one note prefix across blocks.** Three blocks wrote three different prefixes for the same kind of reversal.
|
|
722
|
+
- **First/last date stamps are the min/max of the stored dates,** never `existing || date`: a backdated pickup did not move the first-pickup date.
|
|
723
|
+
- **A "who" the block writes is self-reported**, so it is a convenience, not an audit trail ([softr-database.md → Gotchas](softr-database.md#gotchas)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "bin/cli.js"
|
|
@@ -20,7 +20,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
20
20
|
| Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
|
|
21
21
|
| Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
|
|
22
22
|
| Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
|
|
23
|
-
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<
|
|
23
|
+
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<name>/records`, `<name>` being the name given in `datasource.define`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission and the block's own Visibility gate the endpoint (403 on list and by-id; block gate verified 2026-10-05), and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
|
|
24
24
|
| `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
|
|
25
25
|
| `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
|
|
26
26
|
| `where: q.text("status")…` or `orderBy` naming an alias that is not in **that hook's own** `select` | Crashes the whole block at runtime — "Could not find an alias for subject \"undefined\"", Softr's "Oh snap" panel — though the push compiled clean (seen live 2026-09-18 on a `useMetric`). Aliases resolve per hook, not per connection: add the field to the hook's select (it then joins the connection's read payload). Hard Constraint 29; see [datasources/reading.md](../datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select) |
|
|
@@ -38,6 +38,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
38
38
|
| `deleteRecord.mutate({ id: r.id })` | `deleteRecord.mutate(r.id)` -- just the string |
|
|
39
39
|
| `var { mutateAsync } = useRecordUpdate({...})` -- destructuring the mutate function off the hook | `var updateRecord = useRecordUpdate({...})` -- keep the full object so `.enabled`, `.status`, `.reset()` stay reachable (using `.mutateAsync` itself is fine) |
|
|
40
40
|
| Not calling `refetch()` after mutations | Always `refetch()` in `onSuccess`. The runtime issued no read after a write (measured live 2026-10-05 on a HubSpot-backed block), so without it the list is never re-read. If the source changes other fields itself (HubSpot stamps a closing deal's close date and recalculates its probability a few seconds later), read again a few seconds later too — see [writing.md](../datasources/writing.md#fields-the-source-changes-after-the-write) |
|
|
41
|
+
| Writing and re-reading in one `try`: `try { await update.mutateAsync(…); await refetch(); } catch { setError("Not saved") }` | When the re-read fails after a good write, the save reads as failed. A dialog that stays open on errors then invites a second write, and a retry writes a second change-log row. Keep the write in its own try/catch, then re-read with `await refetch().catch(() => undefined)` (or check its result). Seen in a reviewed fix whose stubbed `refetch` could throw (LCDB QA pass, 2026-10-08); whether Softr's own `refetch()` rejects or resolves with an error state was not checked, so treat this as defensive |
|
|
41
42
|
| Including read-only fields (formula / rollup / aiText / lookup / createdTime / lastModifiedTime / autoNumber) in the `fields` q.select passed to `useRecordCreate` or `useRecordUpdate` | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Same all-or-nothing failure mode as a renamed/missing column: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve to "not yet ready", every OTHER writable field in the same q.select is also lost. Reads handle these field types fine — only the write q.select chokes. Fix: split into separate q.selects per the Three Mappings Pattern (`useRecord` / `useRecords` gets the full select with read-only fields; `useRecordCreate` / `useRecordUpdate` gets a writable-only subset). Diagnostic when the symptom shows up: same bisection procedure as the renamed-column case — strip the write q.select to a known-writable minimum, then add fields back in halves until the Action drops out. Verified 2026-05-22: `blocks/wig-details/wig-details-page.jsx` shared one `wigSelect` between `useRecord` and `useRecordUpdate`; the select included `Wig Tag ID` (formula), `Total client price` (rollup), `Total worker pay` (rollup), `Instrucciones` (aiText) — Update Action stayed disabled until those four read-only fields were lifted out into a separate write-only select. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) and [datasources/writing.md](../datasources/writing.md). |
|
|
42
43
|
| Linked record as bare string, or `[{ id }]` objects on Softr Database | Array of record-id **strings**: `familyLink: [familyId]` — verified live 2026-08-25 on Softr DB. (The `[{ id }]` object shape was the May 2026 verified form on Airtable-backed blocks; try it if a string-array write fails there.) HubSpot associations: the string array worked on create, but an update needs `[{ id }]` (the string array returned 500; verified live 2026-10-05). See [datasources/writing.md](../datasources/writing.md#linked-record-format-for-mutations) |
|
|
43
44
|
| Adding one HubSpot association with `useRecordUpdate`, e.g. `fields: { company: [{ id: newId }] }` | The update **replaced** the whole company list (verified live 2026-10-05, one ticket), and Softr's read afterwards showed no primary company (not checked in HubSpot). Writing the full list back with the new id is untested and would likely drop the primary label too (inferred); or add the link from a workflow that calls HubSpot's associations API — see [hubspot.md](../datasources/hubspot.md#association-writes) |
|
|
@@ -61,7 +62,10 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
61
62
|
|---|---|
|
|
62
63
|
| `field.toLowerCase()` on selects | `getFieldValue(field).toLowerCase()` |
|
|
63
64
|
| `item.fields.formula === true` | Formula booleans: `=== "1"` |
|
|
64
|
-
| `new Date(value)` / date-fns `format(new Date(value))` on a **date-only** field | Date-only values arrive as midnight UTC, so west of Greenwich they render one day early (verified 2026-09-18). Parse them with `toLocalDate()` — see [datasources/fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc). Keep `new Date()` for real timestamps |
|
|
65
|
+
| `new Date(value)` / date-fns `parseISO(value)` / `format(new Date(value))` on a **date-only** field | Date-only values arrive as midnight UTC, so west of Greenwich they render one day early (verified 2026-09-18). `parseISO` reads them as an instant too, and it got past a checklist line that named only `new Date` (LCDB QA pass, 2026-10-08: one list showed 30 of 30 dated rows a day early). Parse them with `toLocalDate()` — see [datasources/fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc). Keep `new Date()` for real timestamps. A "today" that a save writes is `format(new Date(), "yyyy-MM-dd")` worked out in the click handler: a value computed at render stamped yesterday on a page left open past midnight, and `toISOString().slice(0, 10)` is the UTC day ([writing.md → Date](../datasources/writing.md#date)) |
|
|
66
|
+
| `Number(x)` or `Number(getFieldValue(x))` on a NUMBER field that can be blank | A blank NUMBER reaches the block as `null`, and `Number(null)` and `Number("")` are both `0`: a partner with no allocation showed `0` (LCDB QA pass, 2026-10-08). Test for blank first (`getFieldValue()` turns `null` into `""`): `raw === "" ? "" : Number(raw)`. A stored `0` still shows `0`. See [fields.md](../datasources/fields.md#getfieldvalue----never-render-raw-fields) |
|
|
67
|
+
| `x \|\| "Active"` or `x ?? "Active"` to default a text field | `\|\|` also replaces a stored `0`, and `??` does not catch an empty string. A volunteer with a blank status showed Active in the row badge but was left out of the Active count and filter (LCDB QA pass, 2026-10-08). Pick on purpose: `String(x ?? "").trim() \|\| "Active"` for a text default, and read a free-text category or a display default through one helper, the same in every block that reads that field (Hard Constraint 22) |
|
|
68
|
+
| Feeding a display default into an audit row or into a sentence about access | The default is for display only. An audit row logged "Active" as the old value of a field that was blank, and "they will lose access" was false for blank-status rows (LCDB QA pass, 2026-10-08). Audit rows and sentences about what a rule does read the stored value the rule itself reads |
|
|
65
69
|
|
|
66
70
|
## Hooks & React
|
|
67
71
|
|
|
@@ -73,6 +77,14 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
73
77
|
| `fetchNextPage()` in render body | Never in the render body (render → data update → re-render → infinite loop). Call from an event handler — the official Load More pattern: `<button onClick={() => fetchNextPage()} disabled={isFetching}>` — or a guarded `useEffect` for auto-load-all |
|
|
74
78
|
| `useRef` for IDs used in `useMemo` | `useState` -- ref mutations don't trigger recomputation |
|
|
75
79
|
| Defining a sub-component INSIDE the `Block()` function body | Define ALL sub-components at MODULE scope (above `export default function Block()`). Sub-components defined inside `Block()` get a brand-new function reference on every render, which makes React unmount/remount their entire DOM subtree every time `Block` re-renders. The user-visible symptom: **inputs lose focus after typing one character** (because each keystroke triggers a `setState` -> re-render -> the `<input>` is destroyed and recreated). Move `function FieldLabel`, `function TextInput`, `function ChipButton`, `function SectionCard`, etc. above `export default function Block()` so React sees stable component identity across renders. Closure-captured `Block`-internal state must be passed as props, not closed over. |
|
|
80
|
+
| `onClick={save}` where `save(confirmed?)` takes an optional flag | React passes the click event as the first argument, so `confirmed` is truthy on the first click and the check behind it is skipped. Wire `onClick={() => save()}`, or treat only `confirmed === true` as confirmed. Seen in a confirm-before-save flow (LCDB QA pass, 2026-10-08) |
|
|
81
|
+
| An effect that fills form state from query data, relied on to refill the form after a reset | The effect runs only when its data changes. After a save the reset empties the form and the re-read returns equal data, so nothing refills it: a pickup form showed no rows after a save until the page was reloaded (LCDB write pass, 2026-10-08). Refill from the reset itself: move the fill into a function and call it from the reset, or bump a counter the effect depends on |
|
|
82
|
+
| React keys built from labels or messages (`key={child.name}`, `key={line.error}`) | Two children with the same name, or two lines with the same error, collide and React warns about duplicate keys. Add the index or the id to the key |
|
|
83
|
+
| A busy label driven by `isRefetching` | Softr's data hooks refetch when the window regains focus, so `isRefetching` turns true with no click and the label flickers whenever the user comes back to the tab ([reading.md](../datasources/reading.md#userecords----fetch-a-paginated-list)). Drive it from the click: set it in the handler, reset it when that request settles |
|
|
84
|
+
| `setSaving(false)` before `await refetch()` | Save is enabled again with the old values still on screen while the re-read runs, so a second press can write the same distribution twice. Clear or refill the form first, then drop the saving flag |
|
|
85
|
+
| Rendering a dialog as `open && !loadError` | A background read that fails while the dialog is open unmounts it under the user's hands. Keep an open dialog mounted whatever a read does |
|
|
86
|
+
| A button that removes itself on success (Discard on a restored-draft note, Set active on an Inactive banner, a paging button on the last page) | The focused control leaves the DOM and focus drops to `<body>`: a keyboard user starts again from the top of the page (LCDB QA pass, 2026-10-08). Move focus to a stable, always-mounted target first. Details: [common-patterns.md → Failures, focus and Escape](common-patterns.md#failures-focus-and-escape) |
|
|
87
|
+
| A local variable named `q` | It shadows the `q` query-builder import, so the `q.select(...)` or `q.text(...)` below it throws. Name locals `query`, `search` or `needle`. Caught at build time in a reviewed fix (LCDB QA pass, 2026-10-08), not by a deployed block |
|
|
76
88
|
|
|
77
89
|
## Layout & Styling
|
|
78
90
|
|
|
@@ -83,7 +95,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
83
95
|
| Emojis in UI | lucide-react icons only |
|
|
84
96
|
| `document.elementFromPoint(x, y)` to hit-test during a drag or custom pointer interaction | It returns the block's shadow **host**, not the element under the cursor — same boundary that stops `getElementById` and URL-fragment lookup. Either call it on the shadow root (`ref.current.getRootNode().elementFromPoint(x, y)`) or, better, keep refs to the candidate elements and compare `getBoundingClientRect()` yourself: rects need no shadow-root plumbing and work identically when the block is later reused elsewhere |
|
|
85
97
|
| `overflow-hidden`, `truncate`, `line-clamp-*` or `overflow-*-auto` on a container that holds a dropdown or popover — typically a `<td>` clipped so an over-wide status chip stops at its own column | **Symptom:** the menu opens cut to the height of its row or its scroller: one or two options showing, the rest unreachable by mouse. **Cause:** the `Combo` panel is `position: absolute` in the block's own DOM (a portal would leave the shadow root and lose its styles), and an absolutely positioned box is clipped by every ancestor whose `overflow` is not `visible`. `truncate` and `line-clamp-*` set `overflow: hidden`; `overflow-x-auto` turns `overflow-y` to `auto` as well. **Fix:** no clipping class between the Combo and the scroller it belongs to; bound the chip at the chip (`min-w-0 truncate` on the chip inside the flex trigger); measure the drop-up and the list height against the clipping ancestors, not the window. Hit in production 2026-09-30: three ROSIE item tables clipped the status cell as a 2px backstop, next to a comment claiming the menu was portaled — it had stopped being portaled when the tables moved from shadcn `<Select>` to `Combo`. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4 |
|
|
86
|
-
| `el.scrollIntoView({ block: "nearest" })` to keep a dropdown's highlighted option in view (or a plain `focus()` on its search box) | **Symptom:** the table or the page jumps when a menu opens near an edge; inside a clipped cell the trigger itself scrolls out of view. **Cause:** `scrollIntoView` scrolls EVERY scrollable ancestor until the element shows, and `overflow: hidden` boxes are still scrollable from script; `focus()` scrolls ancestors the same way. **Fix:** scroll the list element only — compare the option's rect with the list's and adjust `list.scrollTop` — and focus with `{ preventScroll: true }`. Verified in Chromium 2026-09-30: `scrollIntoView` scrolled an `overflow: hidden` cell by 164px, the list-only scroll moved nothing outside the list. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3 |
|
|
98
|
+
| `el.scrollIntoView({ block: "nearest" })` to keep a dropdown's highlighted option in view (or a plain `focus()` on its search box) | **Symptom:** the table or the page jumps when a menu opens near an edge; inside a clipped cell the trigger itself scrolls out of view. **Cause:** `scrollIntoView` scrolls EVERY scrollable ancestor until the element shows, and `overflow: hidden` boxes are still scrollable from script; `focus()` scrolls ancestors the same way. **Fix:** scroll the list element only — compare the option's rect with the list's and adjust `list.scrollTop` — and focus with `{ preventScroll: true }`. Verified in Chromium 2026-09-30: `scrollIntoView` scrolled an `overflow: hidden` cell by 164px, the list-only scroll moved nothing outside the list. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3. **The same goes for an error inside a modal:** reveal it by setting the modal body's `scrollTop`, because `scrollIntoView` also scrolls the locked page behind the dialog (a page went from 345 to 880px with a dialog open; LCDB QA pass, 2026-10-08) |
|
|
87
99
|
| Positioning repeated page chrome (back button, title, primary action) per-block, without checking the pages that already have it | Chrome the user meets on more than one screen is a cross-page contract. Copy the exact offset from the blocks that already ship it, and change every page in one edit. Let the wrapper's padding be the only thing positioning it — `mb-4` and NO top margin on a back button — so one number per page governs it. Verified 2026-09-09: an extra `mt-6` sat one detail page's back button 24px lower than another's, and **each block looked correct in isolation**. See SKILL.md's Block Placement section |
|
|
88
100
|
| A loading skeleton carrying a different border / offset from the component it stands in for | The skeleton must track the component's REST state (border colour, padding, chrome offsets), never its hover state. If they disagree the layout visibly re-draws the instant data lands — the exact thing a skeleton exists to prevent. Verified 2026-09-09 twice in one session: a card grid re-outlined itself on load, and a back button jumped 24px. Under container queries, find where a skeleton's text lines wrap by measuring at BLOCK widths with the real fonts: a padding change moves the wrap, and a skeleton keyed to the old width jumped when data landed (measured 2026-10-05 in headless Chromium, blocks 300–1400px wide). See [ui-ux-guidelines.md](../ui-ux-guidelines.md) §12 |
|
|
89
101
|
| `focus-visible:` on a card or row that only *contains* buttons | The container is a plain `<div>` and never takes focus, so it is dead CSS. Use `focus-within:` on the container (pairs with its `hover:` treatment) and keep `focus-visible:ring-2` on the button/link itself |
|
|
@@ -98,11 +110,17 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
98
110
|
| Reading the navigation variables in JS — `parseFloat(getComputedStyle(host).getPropertyValue("--bottombar-height"))` | They are unregistered custom properties, so JS gets the token string, not a length: on phones `--bottombar-height` reads `calc(0px + 55px)` (measured 2026-10-05). `parseFloat` of that is NaN (inferred, not run), and a fallback to 0 then hides the tab bar from the code. Use them only inside CSS `calc()`. Where JS needs a number (scroll insets, the room a popover has), take the bars' heights as constants (56px top bar; about 57px phone tab bar as rendered, though Softr's variable says 55px; switching at a 768px window) — see [common-patterns.md → Clear Softr's sticky bars](common-patterns.md#clear-softrs-sticky-bars). The shipped block's 72px (a bar plus about 16px) is a project choice, not a rule |
|
|
99
111
|
| `document.getElementById(...)` / `document.querySelector(...)` to find an element inside the block — for example, a hidden `<input type="file">` triggered by a visible "Upload" button via `getElementById('myInput').click()` | Vibe Coding blocks render inside a shadow DOM. The global `document` traversal stops at the shadow boundary, so id/selector lookups for elements inside the block return `null`. The user-visible symptom is a control that does nothing — no error, no file picker, no focus, no scroll — because the chained `.click()` / `.focus()` / `.scrollIntoView()` was called on `null`. Use a **React `useRef`** instead: `var inputRef = useRef(null)`, then `<input ref={inputRef} />` and `<button onClick={function() { if (inputRef.current) inputRef.current.click(); }}>`. Refs hold direct node references and don't depend on DOM traversal, so they work regardless of which DOM tree the node lives in. This applies to every "trigger a hidden element" pattern: hidden file inputs, programmatic focus, scroll-into-view, `.click()` on a non-visible button. |
|
|
100
112
|
| Inlining brand hexes at every point of use (the signature failure of Studio-AI-generated styling) | Hoist the palette to module-scope constants — `const BRAND = { terracotta: "#B4603D", ink: "#211C18" }` — and reference those. Inlined hexes drift into near-duplicates: observed live 2026-08 in one Studio-emitted hero, `#AE5E3D` vs `#B4603D` for the same terracotta and three near-identical near-blacks. The literals-only static-analysis rule applies ONLY to `datasource.define()` / `q.select()` / data-hook options — style objects and JS expressions use constants freely (the BRAND_INK/BRAND_CANVAS rows above and helper-blocks.md's `BRAND_PRIMARY` are existing house precedent). Constants reach the DOM via inline `style` or by choosing between STATIC class strings — never template-interpolated into arbitrary classes (`` bg-[${BRAND.x}] ``): Tailwind's JIT extracts classes by static source scan (standard-Tailwind inference, not Softr-verified) |
|
|
101
|
-
| Using `window.addEventListener("beforeunload", ...)` as the only unsaved-changes guard in a form block | Softr is a SPA. Internal nav (Softr's nav bar, sidebar links, `<NavigationAction>`) changes the route via the client-side router — `beforeunload` only fires on full page unload (tab close, refresh, external link), so the warning silently misses every in-app navigation. Use `useNavigationBlocker(isDirty)` from `@/lib/use-navigation-blocker` instead; it covers SPA nav AND browser unload with one API. Softr's
|
|
113
|
+
| Using `window.addEventListener("beforeunload", ...)` as the only unsaved-changes guard in a form block | Softr is a SPA. Internal nav (Softr's nav bar, sidebar links, `<NavigationAction>`) changes the route via the client-side router — `beforeunload` only fires on full page unload (tab close, refresh, external link), so the warning silently misses every in-app navigation. Use `useNavigationBlocker(isDirty)` from `@/lib/use-navigation-blocker` instead; it covers SPA nav AND browser unload with one API. Don't count on Softr's bundler to wire it: a form block pushed through the MCP had no blocker, and a click on a sidebar link left the page with typed rows and no prompt (LCDB QA pass, 2026-10-08). Call the hook in every form block and see the prompt in the preview. See [common-patterns.md](common-patterns.md#navigation-blocker-for-unsaved-changes). |
|
|
102
114
|
| Targeting Softr's hashed build classes (e.g. `.f8f11e5_m9ntthp` in June 2026; the navigation block's prefix was `_5f91d6c_` by October) when restyling the native header/nav from `custom-code-header.html` | Softr regenerates the hash on every deploy, so the rule silently dies. Target stable hooks: `.softr-topbar`, `.softr-nav-link`, `.softr-nav-button`, `.softr-nav-logo`, `#topbar-root`; for dropdown menus (no `softr-*` class) use the Radix/ARIA attrs `[role="menu"]` / `[role="menuitem"]` / `[role="group"]` / `[aria-expanded="true"]`, scoped under `.softr-topbar`. The native header is Softr chrome (main document), not a block — it can't be built as a Vibe Coding block. (A landing page with the native header HIDDEN may instead ship a block-owned in-block header — see [static-blocks.md](static-blocks.md#block-owned-landing-page-header); that's a different pattern, not a rebuild of native chrome.) See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
103
115
|
| Taking the frame colour from Softr's hashed theme variables (`var(--_5f91d6c_vnohg20)`) or hashed classes in header CSS | The hash is set per native block package, not once per app — `_5f91d6c_` on the navigation block, `_03ef538_` on the user-accounts block (seen 2026-10-05) — and the theme variables carry the same prefix, so a rule that names one breaks when that package changes. Copy the Studio theme colour of the top bar and sidebar into your own token, e.g. `--app-frame: #A85935`, with a comment saying to update it when the theme changes. Copy the THEME colour (Studio's theme settings, or the rendered sidebar's computed background), not the brand primary from DESIGN.md: they differed (theme `#A85935`, DESIGN.md primary `#B4532A`; measured 2026-10-05). See [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout) |
|
|
104
116
|
| Softr nav dropdown panel shows a tall blank gap below the items, and `height: auto` won't shrink it | The items sit in a CSS grid Softr sets to `grid-auto-flow: column` with pre-sized empty row tracks (`grid-template-rows: 60px 60px…`). Override the flow on `.softr-topbar [role="menu"] [role="group"]`: `grid-auto-flow: row !important; grid-template-rows: none !important; grid-auto-rows: auto !important` (leave `grid-template-columns` to preserve the menu width). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
105
117
|
| Setting the page background on `body` (or any single element) — it appears to do nothing | Softr paints the page fill on several stacked layers, so styling one gets covered: `html`, `body`, `#page-content`, each Vibe block's host and each native block's outer `<section>` (measured 2026-10-05; the layer list is in the `html, body` row above). **Top bar only:** paint your backdrop on `html`, then clear the duplicates above it: `body`, `#page-content`, and `#page-content div` — but EXCLUDE the header subtree with `:not(.softr-topbar):not(.softr-topbar *)` (it renders inside `#page-content`, and `#page-content`'s id specificity would otherwise flatten the dropdown panel). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md#page-background). **With Softr's sidebar,** that `div` clear would also strip `.softr-sidebar`'s fill (inferred) and never reaches native `<section>`s: use the scoped rules in [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout). |
|
|
118
|
+
| A card in a CSS grid without `min-w-0` | A `min-w-[30rem]` table inside the card's `overflow-x-auto` widens the whole grid track, so the page scrolls sideways once a row is expanded (163px at a 375px window; it showed only after a history session was opened; LCDB QA pass, 2026-10-08). Give the grid item `min-w-0`, and open every expandable row before calling "no sideways scroll" |
|
|
119
|
+
| A screen-reader-only header (`sr-only`, absolutely positioned) inside an `overflow-x-auto` scroller that is not `relative` | The absolute box is clipped only by a positioned ancestor, so it escapes the scroller and adds page-level sideways scroll (41px; LCDB QA pass, 2026-10-08). Make the scroller `relative` |
|
|
120
|
+
| `<fieldset disabled className="contents">` to lock a form | `contents` removes the fieldset's box, so the parent's `space-y` gaps stop applying to what is inside. Use a normal fieldset with `m-0 min-w-0 border-0 p-0` |
|
|
121
|
+
| `break-all` on an email address | It splits the address mid-word, over three lines in a narrow column. Put `<wbr>` before the `@` and use `overflow-wrap: anywhere` |
|
|
122
|
+
| `pr-14 @min-[32rem]:px-6` on a header that leaves room for a close button | At the breakpoint `px` overrides `pr`, so the header text slides under the X. Set the sides separately (`pl-* pr-14`) |
|
|
123
|
+
| A row of filter pills in `overflow-x-auto` | On a phone the pills past the edge are hidden with no hint. Let them wrap (`flex-wrap`); the 44px height rule for phones is in [ui-ux-guidelines.md §21](../ui-ux-guidelines.md#21-mobile-first-responsive-design) |
|
|
106
124
|
|
|
107
125
|
## Printing
|
|
108
126
|
|
|
@@ -116,6 +134,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
116
134
|
|---|---|
|
|
117
135
|
| `currentUser.role` for tiers | `window.__softr_current_user.userGroups` |
|
|
118
136
|
| Reading `window.__softr_current_user.userGroups` once at mount and treating `[]` as "no groups" | The global has **no change event**: nothing re-renders the block when the shell fills it in, and an early empty `userGroups` means not loaded yet. Read once, an admin can be settled as a non-admin for good. Hold it in state, poll with a bound (about 2 s), and gate role-dependent UI on the poll having settled. Pattern from two production blocks, 2026-09-18; see [datasources/reading.md](../datasources/reading.md#current-user) |
|
|
137
|
+
| Testing a user group by NAME in block code, then renaming the group in Studio | Block code can only test group **names** (`userGroups` items arrive as strings, or as objects with a `name`; no id was seen), so a rename silently breaks every name check and locks staff out of what it guarded. Block Visibility and action permissions are set by group id and survive a rename. While a rename is pending, accept both the old and the new name, and drop the old one afterwards, so the push and the rename can land in either order. Before writing copy such as "they will lose access", check how each group gets its members (a manual list or a condition): that sentence was false for a user added by hand (LCDB, 2026-10-07 and 2026-10-08). See [reading.md → Current User](../datasources/reading.md#current-user) |
|
|
119
138
|
|
|
120
139
|
## Editable Settings
|
|
121
140
|
|