softr-vibe-coding 2.7.0 → 2.8.1
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 +7 -0
- package/README.md +5 -3
- package/SKILL.md +3 -2
- package/package.json +1 -1
- package/references/common-patterns.md +124 -0
- package/references/searchable-dropdown.md +35 -5
- package/references/softr-mcp.md +51 -0
- package/ui-ux-guidelines.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ 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.8.1] - 2026-09-10
|
|
8
|
+
- Verifying a push: errors:null is not proof — fetch-back byte-compare, deployed==disk pre-check, two-block swaps (2.8.1)
|
|
9
|
+
- Inventory + keyboard-picker snippet reconciled with the shipped projects-table shape
|
|
10
|
+
|
|
11
|
+
## [2.8.0] - 2026-09-09
|
|
12
|
+
- Dropdowns searchable by default, reset controls only when dirty, create → open the new record
|
|
13
|
+
|
|
7
14
|
## [2.7.0] - 2026-09-09
|
|
8
15
|
- Add the searchable dropdown pattern (Combo) as a reference
|
|
9
16
|
|
package/README.md
CHANGED
|
@@ -185,7 +185,7 @@ softr-vibe-coding/
|
|
|
185
185
|
│ │ # Scripting Extension, cross-table cascades,
|
|
186
186
|
│ │ # batch update gotchas, field-ID discipline,
|
|
187
187
|
│ │ # Airtable formulas
|
|
188
|
-
│ ├── softr-mcp.md # Official Softr MCP server — vibe coding block
|
|
188
|
+
│ ├── softr-mcp.md # Official Softr MCP server — vibe coding block (+ push verification protocol)
|
|
189
189
|
│ │ # tools (create/edit/version/deploy), integrations
|
|
190
190
|
│ │ # browsing (Airtable/Sheets/Notion/Supabase),
|
|
191
191
|
│ │ # Softr DB schema + record tools incl. deletes,
|
|
@@ -204,7 +204,7 @@ softr-vibe-coding/
|
|
|
204
204
|
│ ├── anti-patterns.md # Categorized violation catalog
|
|
205
205
|
│ │ # Data access, mutations, hooks, layout,
|
|
206
206
|
│ │ # permissions, editable settings, helper blocks
|
|
207
|
-
│ ├── common-patterns.md # Small reusable patterns
|
|
207
|
+
│ ├── common-patterns.md # Small reusable patterns (localStorage state, clipboard, nav blocker, drag-to-reorder, create → open, clickable row + inner link, keyboard picker)
|
|
208
208
|
│ │ # localStorage cross-page state, clipboard copy,
|
|
209
209
|
│ │ # navigation blocker, scroll-condensing header,
|
|
210
210
|
│ │ # auth-aware CTA, image masks, blobs, dot lists
|
|
@@ -227,7 +227,9 @@ softr-vibe-coding/
|
|
|
227
227
|
│ # why native <select> and shadcn <Select> both
|
|
228
228
|
│ # break in the shadow DOM, composedPath()
|
|
229
229
|
│ # click-outside, A-Z inside the component,
|
|
230
|
-
│ # multi-token filter,
|
|
230
|
+
│ # multi-token filter, searchable BY DEFAULT
|
|
231
|
+
│ # (bare = click-only; searchable={false} only
|
|
232
|
+
│ # for a fixed enum being set — Sep 10 2026)
|
|
231
233
|
│
|
|
232
234
|
├── tools/ # Bundled CLI scripts (run, not read)
|
|
233
235
|
│ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
|
package/SKILL.md
CHANGED
|
@@ -85,10 +85,11 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
85
85
|
- `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)
|
|
86
86
|
- 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)
|
|
87
87
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
88
|
-
- **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)
|
|
88
|
+
- **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)
|
|
89
89
|
- Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
|
|
90
90
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
91
91
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
|
92
|
+
- **Deploying through the MCP:** `errors: null` on a push is not proof — fetch the block's `sourceCode` back and byte-compare it to the file you sent (trailing newline normalised, nothing else), and prove deployed == disk *before* editing so a Studio-side change is never overwritten. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
|
|
92
93
|
|
|
93
94
|
## What to Clarify
|
|
94
95
|
|
|
@@ -168,7 +169,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
|
|
|
168
169
|
| Embedding third-party libraries with their own CSS (Leaflet, Mapbox, TinyMCE, Quill, FullCalendar) | [references/advanced-integrations.md](references/advanced-integrations.md) |
|
|
169
170
|
| Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
|
|
170
171
|
| Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping | [references/quick-reference.md](references/quick-reference.md) |
|
|
171
|
-
| Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, the `bare` inline-editor variant | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
|
|
172
|
+
| Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, **searchable by default** regardless of option count (`bare` inline editors click-only; `searchable={false}` only for a short fixed enum being set), the `bare` inline-editor variant | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
|
|
172
173
|
| Small reusable patterns — `localStorage` cross-page state, clipboard copy button | [references/common-patterns.md](references/common-patterns.md) |
|
|
173
174
|
| 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) |
|
|
174
175
|
| The official **Softr MCP server** — Softr DB schema + full record/table/field/database CRUD (deletes included), field-level browsing of connected Airtable / Google Sheets / Notion / Supabase integrations, **creating, editing, versioning, and deploying Vibe Coding blocks directly** (`get_vibe_coding_docs`, `create_vibe_coding_block`, ...), app management/scaffolding, the **Softr Workflows** suite (26 tools, 418-node catalog), and **per-application MCP servers** | [references/softr-mcp.md](references/softr-mcp.md) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
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"
|
|
@@ -15,6 +15,8 @@ Small reusable patterns that come up across Vibe Coding blocks but don't warrant
|
|
|
15
15
|
- [Decorative Background Blobs (Editorial Layering)](#decorative-background-blobs-editorial-layering)
|
|
16
16
|
- [Dot-Separated Inline List](#dot-separated-inline-list)
|
|
17
17
|
- [Drag-to-Reorder Rows](#drag-to-reorder-rows)
|
|
18
|
+
- [Create → open](#create--open)
|
|
19
|
+
- [Clickable Row with an Inner Link](#clickable-row-with-an-inner-link)
|
|
18
20
|
|
|
19
21
|
## Cross-Page State with localStorage + URL Parameters
|
|
20
22
|
|
|
@@ -392,3 +394,125 @@ on failure, clear the override and refetch, because a half-applied renumber is w
|
|
|
392
394
|
**Only gate the drag on permissions, not on a sort mode.** If the list has an alternative sort, let the
|
|
393
395
|
drag switch to manual order rather than disabling the handle — see the disabled-control note in
|
|
394
396
|
[../ui-ux-guidelines.md](../ui-ux-guidelines.md#26-finishing-touches).
|
|
397
|
+
|
|
398
|
+
## Create → open
|
|
399
|
+
|
|
400
|
+
When the user creates a record they are about to work on — a new item, a new saved list — land them on it. They made it in order to fill it in, so closing the dialog back into the list and leaving them to find the row they just made is a step nobody asked for. Leo, 2026-09-10: "when you create a new item anywhere in the interface, once it's created, you need to open the item details page of the item you just created … when you create a new list, once it's created, you need to open this list details, as you'll likely want to fill the list straight after."
|
|
401
|
+
|
|
402
|
+
`useRecordCreate`'s `onSuccess` receives the created record, but the wrapper around its id has moved before. Read it defensively — a miss is survivable, a crash is not:
|
|
403
|
+
|
|
404
|
+
```jsx
|
|
405
|
+
/* What useRecordCreate hands onSuccess is the created record. The exact wrapper has moved
|
|
406
|
+
before, so read the id defensively — a miss is survivable (the row still gets created and
|
|
407
|
+
the list still refetches, the user just picks it) but a crash is not. */
|
|
408
|
+
function getCreatedId(created) {
|
|
409
|
+
if (!created) return "";
|
|
410
|
+
if (typeof created === "string") return created;
|
|
411
|
+
if (created.id) return String(created.id);
|
|
412
|
+
if (created.recordId) return String(created.recordId);
|
|
413
|
+
if (created.record && created.record.id) return String(created.record.id);
|
|
414
|
+
if (created.data && created.data.id) return String(created.data.id);
|
|
415
|
+
return "";
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Then navigate on success — and only when there is an id to navigate to:
|
|
420
|
+
|
|
421
|
+
```jsx
|
|
422
|
+
createItem.mutate(payload, {
|
|
423
|
+
onSuccess: function (created) {
|
|
424
|
+
setBusy(false);
|
|
425
|
+
var id = getCreatedId(created);
|
|
426
|
+
if (id) {
|
|
427
|
+
window.location.href = "/item?recordId=" + encodeURIComponent(id);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// The record exists; we just can't address it. Stay put, refetch, say so.
|
|
431
|
+
setOpen(false);
|
|
432
|
+
itemsResult.refetch();
|
|
433
|
+
toast.success('"' + name + '" created — it is in the list below.');
|
|
434
|
+
},
|
|
435
|
+
onError: function () {
|
|
436
|
+
setBusy(false);
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Never navigate with an empty id.** `/item?recordId=` would render the detail page's own "record not found" state, and the user would conclude the create failed when it did not. The fallback keeps them where the new row will appear once the refetch lands.
|
|
442
|
+
|
|
443
|
+
**Keep the path relative.** `/item?recordId=…`, never the app's domain — the same block runs on the preview URL and on the custom domain (SKILL.md's no-hardcoded-domains rule).
|
|
444
|
+
|
|
445
|
+
**Where the id comes from is not always where it goes.** For an `onCreate` inside a Combo — a vendor typed into a picker — the created id is patched into the form and the user keeps editing; there is nothing to open. See [searchable-dropdown.md](searchable-dropdown.md#variants-worth-having). Navigation is for records that have their own page and that the user will work on next.
|
|
446
|
+
|
|
447
|
+
## Clickable Row with an Inner Link
|
|
448
|
+
|
|
449
|
+
An index table exists to get the user into a record, so the hit area is the whole row. But a row is not a link: cmd-click, middle-click, right-click → "Copy link" and hover-to-see-the-URL all come from a real `<a>`. Keep both — the row handler for the plain click, an anchor on the name for everything the browser does with anchors — and make sure they do not fight:
|
|
450
|
+
|
|
451
|
+
```jsx
|
|
452
|
+
function ProjectRow(props) {
|
|
453
|
+
var row = props.row;
|
|
454
|
+
var href = "/project?recordId=" + encodeURIComponent(row.id);
|
|
455
|
+
|
|
456
|
+
// The row handler bows out the moment a modifier is held — those clicks belong to the
|
|
457
|
+
// anchor (new tab, new window, select).
|
|
458
|
+
function openRow(event) {
|
|
459
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
460
|
+
if (typeof window !== "undefined") window.location.href = href;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<TableRow className="cursor-pointer hover:bg-[#FFF7EF]" onClick={openRow}>
|
|
465
|
+
<TableCell>
|
|
466
|
+
<a
|
|
467
|
+
href={href}
|
|
468
|
+
className="hover:underline"
|
|
469
|
+
onClick={function (e) {
|
|
470
|
+
e.stopPropagation(); // the anchor navigates itself; without this the row handler ALSO fires
|
|
471
|
+
}}
|
|
472
|
+
>
|
|
473
|
+
{row.name}
|
|
474
|
+
</a>
|
|
475
|
+
</TableCell>
|
|
476
|
+
{/* … */}
|
|
477
|
+
</TableRow>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Two things go wrong without the two guards. Without `stopPropagation` on the anchor, a cmd-click on the name opens the new tab AND navigates the current one, because the row handler runs too. Without the modifier check on the row, a cmd-click on the cell padding (next to the anchor, not on it) navigates the current tab — the opposite of what the user asked for.
|
|
483
|
+
|
|
484
|
+
Any *other* control inside the row — an inline status chip, a checkbox, a menu — needs `stopPropagation` on its own handler as well, or every click on it opens the record. (The drag handle in [Drag-to-Reorder Rows](#drag-to-reorder-rows) already does this.)
|
|
485
|
+
|
|
486
|
+
### Keyboard picker over the same rows
|
|
487
|
+
|
|
488
|
+
Where a table has a quick-find box, wire it like a picker so the keyboard alone gets into a record: the box takes focus, ↑ ↓ move a highlight, Enter opens the highlighted row, Escape clears the query (and the highlight with it).
|
|
489
|
+
|
|
490
|
+
```jsx
|
|
491
|
+
var [active, setActive] = useState(0);
|
|
492
|
+
// Filtering shrinks the list under the highlight, so clamp rather than index past the end.
|
|
493
|
+
var activeIdx = rows.length === 0 ? 0 : Math.min(active, rows.length - 1);
|
|
494
|
+
|
|
495
|
+
function onKeyDown(e) {
|
|
496
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
497
|
+
e.preventDefault();
|
|
498
|
+
var step = e.key === "ArrowDown" ? 1 : -1;
|
|
499
|
+
setActive(function (a) {
|
|
500
|
+
return Math.min(Math.max(a + step, 0), Math.max(rows.length - 1, 0));
|
|
501
|
+
});
|
|
502
|
+
} else if (e.key === "Enter") {
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
var hit = rows[activeIdx];
|
|
505
|
+
if (hit) window.location.href = "/project?recordId=" + encodeURIComponent(hit.id);
|
|
506
|
+
} else if (e.key === "Escape") {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
setQuery("");
|
|
509
|
+
setActive(0);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
<input autoFocus value={query} onChange={function (e) { setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown} … />
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Make the rows themselves focusable too (`tabIndex={0}`, an `onKeyDown` that opens on Enter only when `event.target === event.currentTarget`, so an Enter on the anchor inside the row is not handled twice), and let `onMouseEnter` *and* `onFocus` both move the highlight onto the row — the highlight is the single answer to "which record does Enter open", whichever device last touched it. That is the shape `projects-table.jsx` shipped on 2026-09-10.
|
|
517
|
+
|
|
518
|
+
Paint the highlighted row with the same colour the mouse hover gets (`data-active="true"` + `bg-[#FFF7EF]`) and scroll it into view when it moves (`querySelector('[data-active="true"]').scrollIntoView({ block: "nearest" })` in a `useEffect` on `activeIdx`) — the Combo in [searchable-dropdown.md](searchable-dropdown.md#one-flat-row-list-for-the-keyboard) does the same. Reset `active` to 0 whenever the query changes: the old index points at a row that may no longer be in the list. `autoFocus` is right only when the block *is* the page's reason to exist — an index page whose first act is always a search; on a page with content above the table, a focus steal scrolls the page to the box.
|
|
@@ -81,15 +81,44 @@ var filtered = options.filter(function (o) {
|
|
|
81
81
|
|
|
82
82
|
So `rural art` finds *The Rural Art Company*. A single `indexOf(query)` would not.
|
|
83
83
|
|
|
84
|
-
##
|
|
84
|
+
## Searchable by default — not by option count
|
|
85
85
|
|
|
86
86
|
```jsx
|
|
87
87
|
var searchable = props.onCreate
|
|
88
88
|
? true
|
|
89
|
-
: props.searchable === true || (props.searchable !== false &&
|
|
89
|
+
: props.searchable === true || (props.searchable !== false && !props.bare);
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
Every **framed** dropdown — a table filter, a form field — gets the search box, whatever the
|
|
93
|
+
option count. `bare` inline editors are click-only. `onCreate` forces the box on, because the
|
|
94
|
+
typed text is what gets created.
|
|
95
|
+
|
|
96
|
+
**Why not a threshold (changed 2026-09-10).** The first version of this component showed the
|
|
97
|
+
search box at 8+ options. On one filter row that made "All projects" (2 options) a plain
|
|
98
|
+
picker and "All vendors" (99) a type-to-filter, side by side, and the client read the
|
|
99
|
+
difference as a bug — the project filter looked like the broken one. The count of options is
|
|
100
|
+
*data*; whether a dropdown is searchable is *design*, and design must not change under the
|
|
101
|
+
user's hands the day a third project is added. Leo, 2026-09-10: type-to-filter is the default
|
|
102
|
+
for table filters, even a two-option one, so the desk never has to check whether *this*
|
|
103
|
+
dropdown is the searchable kind. The other half of the same instruction: a status update in a
|
|
104
|
+
table row is a click, not a search — which is what `bare` already is.
|
|
105
|
+
|
|
106
|
+
### Which dropdowns get a search box
|
|
107
|
+
|
|
108
|
+
| Dropdown | Search box | How |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| Table filter — project, vendor, status, room, any of them | yes | default |
|
|
111
|
+
| Open-ended or data-driven picker in a form — vendor, project, room, item, purchase order, saved list | yes | default |
|
|
112
|
+
| Short **fixed** enum the user is **setting** — a status, a location, a purpose, a group-by / sort-by | no | `searchable={false}` |
|
|
113
|
+
| Inline `bare` editor in a table cell — a status chip, a location string | no | `bare` is click-only |
|
|
114
|
+
| Anything with `onCreate` | yes, always | forced |
|
|
115
|
+
|
|
116
|
+
The third row is the only place `searchable={false}` belongs: four fixed options the user is
|
|
117
|
+
choosing *between*, where a search box is noise. A *filter* on that same status field still
|
|
118
|
+
gets the box — filtering and setting are different jobs, and the filter row is exactly where
|
|
119
|
+
the rule has to hold uniformly. If a wrapper sits between the call site and `Combo`
|
|
120
|
+
(`ColumnFilter`, `SelectInput`, …), thread `searchable={props.searchable}` through it rather
|
|
121
|
+
than reaching past the wrapper.
|
|
93
122
|
|
|
94
123
|
## One flat row list for the keyboard
|
|
95
124
|
|
|
@@ -111,7 +140,8 @@ A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
|
111
140
|
|
|
112
141
|
- **`bare`** — inline-editor mode. No border, no fill; `triggerContent` (a status chip, a
|
|
113
142
|
cell's text) *is* the trigger. Lets a table cell become editable without every row growing
|
|
114
|
-
a form control.
|
|
143
|
+
a form control. Click-only: the search box is off in this variant, because a status update
|
|
144
|
+
in a row is a click, not a search (Leo, 2026-09-10).
|
|
115
145
|
⚠ If any column width in your table is derived from the trigger's chrome, keep the
|
|
116
146
|
chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
|
|
117
147
|
widths in a different file.
|
|
@@ -141,7 +171,7 @@ Everything else — the trigger, the card, the rows — stays flat.
|
|
|
141
171
|
- [ ] `composedPath()` click-outside
|
|
142
172
|
- [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
|
|
143
173
|
- [ ] Multi-token filter
|
|
144
|
-
- [ ]
|
|
174
|
+
- [ ] Searchable by default; `bare` is click-only; `searchable={false}` only on a short fixed enum the user is setting
|
|
145
175
|
- [ ] Keyboard: ↑ ↓ Enter Esc Tab, active row scrolled into view
|
|
146
176
|
- [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
|
|
147
177
|
`aria-selected`, and an `aria-label` on the trigger
|
package/references/softr-mcp.md
CHANGED
|
@@ -101,6 +101,57 @@ components, adding a hook — where being sure of "the exact current text" of a
|
|
|
101
101
|
is harder than being sure of the whole file. Also use it when the local file is the source of truth and
|
|
102
102
|
has drifted from the deployed block in ways you have not enumerated.
|
|
103
103
|
|
|
104
|
+
### Verifying a push — the deployed source is the only proof
|
|
105
|
+
|
|
106
|
+
`update_vibe_coding_block_code` returning `errors: null, warnings: null` proves the code **compiled**.
|
|
107
|
+
It does not prove the block now holds the code you meant to send. Verified 2026-09-09: a push of a
|
|
108
|
+
67KB block came back clean and had silently dropped one blank line at a read-chunk boundary — valid
|
|
109
|
+
JavaScript, so the compiler had nothing to say. Only a byte comparison caught it. Treat every push as
|
|
110
|
+
unverified until you have pulled the source back down and compared it.
|
|
111
|
+
|
|
112
|
+
**The protocol, per block:**
|
|
113
|
+
|
|
114
|
+
1. **Before editing, prove deployed == disk.** Call `get_vibe_coding_block_code`, extract
|
|
115
|
+
`sourceCode`, and compare it byte-for-byte to your local mirror. If they differ, someone changed
|
|
116
|
+
the block in Studio since your last push — stop and reconcile; do not overwrite work you have not
|
|
117
|
+
seen. (Large results are persisted to a file by most clients rather than returned inline; compare
|
|
118
|
+
from that file with a script, never by eye.)
|
|
119
|
+
2. Edit the local file. Run a parser and `no-undef` lint on it first — `node --check` does **not**
|
|
120
|
+
accept a `.jsx` extension, so use esbuild (`esbuild file.jsx --loader:.jsx=jsx --jsx=automatic
|
|
121
|
+
--log-level=error --outfile=/dev/null`) plus eslint with `@babel/eslint-parser`. The bugs that
|
|
122
|
+
actually bite Softr blocks are semantic — `useRecordUpdate({ select: … })` instead of `fields:`,
|
|
123
|
+
an invented identifier — and the push is the first thing that reports them.
|
|
124
|
+
3. Push the **entire** file.
|
|
125
|
+
4. **Fetch it back and compare again.** Identical, or you are not done: diff, fix, re-push.
|
|
126
|
+
|
|
127
|
+
**Tolerate exactly one difference: the trailing newline.** Softr sometimes strips the file's final
|
|
128
|
+
`\n` on save and sometimes keeps it — stripped on every push on 2026-09-09, kept on every push on
|
|
129
|
+
2026-09-10, same app, same tools. A comparison that demands byte equality will report a phantom
|
|
130
|
+
mismatch on some days; one that ignores *all* whitespace will miss the dropped-blank-line case above.
|
|
131
|
+
Compare with the trailing newline normalised and nothing else.
|
|
132
|
+
|
|
133
|
+
**One file, two blocks, two datasource pairs.** When the same source is deployed to two pages, the
|
|
134
|
+
local file holds ONE page's `datasource.define()` pair. Push it as-is to that block; for the other,
|
|
135
|
+
build the swapped text in a scratch location, push that, and verify each block against its own
|
|
136
|
+
expectation (disk for the first, disk-with-swap for the second). Never save the swapped copy over
|
|
137
|
+
the local mirror — the mirror records which page it belongs to, and the block's header comment
|
|
138
|
+
records the other page's pair. Search-replace would avoid the swap altogether
|
|
139
|
+
([above](#which-edit-tool-full-replace-vs-targeted-search-replace)) — when the client can send its
|
|
140
|
+
array argument ([below](#the-array-argument-serialization-quirk-and-why-it-is-a-security-issue)).
|
|
141
|
+
|
|
142
|
+
**Do not read a 100KB block into a model's context to push it.** The full-replace tool takes the
|
|
143
|
+
whole file as a string parameter, so the source has to pass through whatever is making the call. A
|
|
144
|
+
large multi-block deploy is safer farmed out one file per subagent — a fresh context per file means
|
|
145
|
+
no compaction can land mid-file — and the byte comparison is what makes that delegation safe, not
|
|
146
|
+
trust in the agent. The steps that need judgement are the *edit* and the *review of the diff*; the
|
|
147
|
+
fetch, the compare and the push itself are mechanical, and can run on the cheapest tier available
|
|
148
|
+
without lowering the bar, because a wrong result fails loudly rather than plausibly.
|
|
149
|
+
|
|
150
|
+
**What a push also resets.** Every code push puts the block's derived Actions back on Softr's
|
|
151
|
+
default permissions (see the next section for why that can be a security problem and how to verify
|
|
152
|
+
the restoration). If page-level visibility is the access control in your app, record that decision
|
|
153
|
+
so nobody chases the reset after every round; if it is not, re-tighten and read back.
|
|
154
|
+
|
|
104
155
|
### The array-argument serialization quirk, and why it is a security issue
|
|
105
156
|
|
|
106
157
|
**Several tools on this server take an array argument, and some MCP clients serialize it as a JSON
|
package/ui-ux-guidelines.md
CHANGED
|
@@ -342,6 +342,8 @@ Vestibular disorders affect ~35% of adults over 40. Always respect `prefers-redu
|
|
|
342
342
|
- **Never disable without explaining why.** Show tooltip or helper text.
|
|
343
343
|
- **Destructive buttons** must: use `variant="destructive"`, be positioned away from primary actions, trigger confirmation for irreversible actions.
|
|
344
344
|
- **DO NOT make every button primary.** Use ghost, outline, and secondary styles for hierarchy.
|
|
345
|
+
- **A reset / undo control renders only when there is something to reset.** "Reset column widths" appears the moment a column has been dragged and is gone again once the widths are back at their defaults — not disabled as a placeholder, not `visibility: hidden` holding its space. A reset for something nobody changed is a control that can only ever do nothing, and a greyed one reads as broken (see [§26](#26-finishing-touches)). Mount it in a slot whose appearance cannot reflow its neighbours — a right-aligned end slot of the toolbar row, never the middle of a wrapping flex row, where mounting it would re-wrap the line on the first drag and un-wrap it on reset. (Leo, 2026-09-10: "ANY reset column width should ONLY show when you change the column width.")
|
|
346
|
+
- **After creating a record the user will work on next, open it.** A new item, a new saved list — the user made it in order to fill it in, so `onSuccess` navigates to that record's detail page instead of closing the dialog back into the list and leaving them to find the row they just made. Read the created id defensively with `getCreatedId()` ([common-patterns.md → Create → open](references/common-patterns.md#create--open)); when the id cannot be read, stay put, refetch and toast — never navigate to a URL with an empty id. (Leo, 2026-09-10: "when you create a new item anywhere in the interface, once it's created, you need to open the item details page of the item you just created … when you create a new list, once it's created, you need to open this list details, as you'll likely want to fill the list straight after.")
|
|
345
347
|
|
|
346
348
|
---
|
|
347
349
|
|