softr-vibe-coding 2.5.2 → 2.7.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 CHANGED
@@ -4,6 +4,12 @@ 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.7.0] - 2026-09-09
8
+ - Add the searchable dropdown pattern (Combo) as a reference
9
+
10
+ ## [2.6.0] - 2026-09-09
11
+ - Drag-to-reorder: the pattern, the shadow-DOM hit-test trap, and two affordance rules
12
+
7
13
  ## [2.5.2] - 2026-09-09
8
14
  - Permission finding is advisory, not a veto — report severity and leave the call to the builder
9
15
 
package/README.md CHANGED
@@ -220,9 +220,14 @@ softr-vibe-coding/
220
220
  │ │ # MCP/CLI install (@latest npx + browser step),
221
221
  │ │ # extract → poll → findings → generate → write
222
222
  │ │ # flow, DESIGN.md anatomy, drift QA
223
- └── quick-reference.md # Syntax cheat sheet
224
- # Imports, hook signatures, mutation shapes,
225
- # field mapping, component skeleton
223
+ ├── quick-reference.md # Syntax cheat sheet
224
+ # Imports, hook signatures, mutation shapes,
225
+ # field mapping, component skeleton
226
+ │ └── searchable-dropdown.md # THE dropdown pattern for blocks
227
+ │ # why native <select> and shadcn <Select> both
228
+ │ # break in the shadow DOM, composedPath()
229
+ │ # click-outside, A-Z inside the component,
230
+ │ # multi-token filter, bare inline-editor variant
226
231
 
227
232
  ├── tools/ # Bundled CLI scripts (run, not read)
228
233
  │ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
package/SKILL.md CHANGED
@@ -85,6 +85,7 @@ 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
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))
89
90
  - Array-setting rows keyed by **index**, never by a builder-editable field value
90
91
  - Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
@@ -167,6 +168,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
167
168
  | Embedding third-party libraries with their own CSS (Leaflet, Mapbox, TinyMCE, Quill, FullCalendar) | [references/advanced-integrations.md](references/advanced-integrations.md) |
168
169
  | Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
169
170
  | 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) |
170
172
  | Small reusable patterns — `localStorage` cross-page state, clipboard copy button | [references/common-patterns.md](references/common-patterns.md) |
171
173
  | 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) |
172
174
  | 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.5.2",
3
+ "version": "2.7.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"
@@ -68,6 +68,7 @@ Run through this catalog before delivering any block. Every row is a violation o
68
68
  | Hardcoded domain in navigation | Relative paths: `/task-details?recordId=...` |
69
69
  | Placing a `<NavigationAction navigation={{ action: "OPEN_CHAT" }}>` Ask-AI button on a block that has no data source connected | Connect the block to the data source the AI should read from in Studio's Source tab. Softr's AI pulls context from the **block that triggered the chat**, not from the page — a button-only helper block with no data source causes `chat/prepare` → HTTP 500 ("Failed to prepare AI assistant") even though the chat UI opens fine. The block doesn't need to read or write records itself; the connection is purely for AI context. Verified by direct experiment, May 2026 |
70
70
  | Emojis in UI | lucide-react icons only |
71
+ | `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 |
71
72
  | 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 |
72
73
  | 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. See [ui-ux-guidelines.md](../ui-ux-guidelines.md) §12 |
73
74
  | `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 |
@@ -14,6 +14,7 @@ Small reusable patterns that come up across Vibe Coding blocks but don't warrant
14
14
  - [Edge-Fade Image Mask (Editorial Hero)](#edge-fade-image-mask-editorial-hero)
15
15
  - [Decorative Background Blobs (Editorial Layering)](#decorative-background-blobs-editorial-layering)
16
16
  - [Dot-Separated Inline List](#dot-separated-inline-list)
17
+ - [Drag-to-Reorder Rows](#drag-to-reorder-rows)
17
18
 
18
19
  ## Cross-Page State with localStorage + URL Parameters
19
20
 
@@ -292,3 +293,102 @@ Certifications, feature tags, meta rows: `GMP Manufacturing ● ISO 22716 ● Lo
292
293
  - **Keep `gap-y-*`** on the container for multi-line rhythm when the list wraps.
293
294
  - **If the list is expected to wrap often**, drop the dots and let the gap carry the rhythm — any inline separator looks orphaned at a line break.
294
295
  - `aria-hidden="true"` on the glyph — screen readers announce `●` as "black circle" otherwise.
296
+
297
+
298
+ ## Drag-to-Reorder Rows
299
+
300
+ Reordering a list by dragging, written against a Softr block's constraints. Four of the five decisions
301
+ below are non-obvious, and each one is a bug if you get it wrong.
302
+
303
+ ```jsx
304
+ var [drag, setDrag] = useState(null); // { from, over } while dragging, else null
305
+ var [optimisticOrder, setOptimisticOrder] = useState(null); // ids, post-drop, pre-refetch
306
+ var rowElsRef = useRef([]);
307
+
308
+ /* The pointer is CAPTURED, so no other element receives enter/leave — rects are the only
309
+ thing that can answer "what is under the cursor". Compare against each row's MIDPOINT so
310
+ the row you are over is the one that yields. */
311
+ function dropIndex(count, clientY) {
312
+ for (var i = 0; i < count; i++) {
313
+ var el = rowElsRef.current[i];
314
+ if (!el) continue;
315
+ var r = el.getBoundingClientRect();
316
+ if (clientY < r.top + r.height / 2) return i;
317
+ }
318
+ return count - 1;
319
+ }
320
+ ```
321
+
322
+ The handle — never the whole row, so text selection and the row's own buttons keep working:
323
+
324
+ ```jsx
325
+ <span
326
+ role="button"
327
+ aria-label={"Drag to reorder " + row.name}
328
+ className="touch-none select-none" // or the browser scrolls instead of dragging
329
+ style={{ cursor: "grab" }}
330
+ onPointerDown={function (e) {
331
+ e.preventDefault();
332
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
333
+ setDrag({ from: index, over: index });
334
+ }}
335
+ onPointerMove={function (e) {
336
+ if (!drag) return;
337
+ var over = dropIndex(rows.length, e.clientY);
338
+ if (over !== drag.over) setDrag({ from: drag.from, over: over });
339
+ }}
340
+ onPointerUp={function () {
341
+ if (!drag) return;
342
+ var from = drag.from, to = drag.over;
343
+ setDrag(null);
344
+ if (from !== to) {
345
+ var next = rows.slice();
346
+ next.splice(to, 0, next.splice(from, 1)[0]);
347
+ applyOrder(next);
348
+ }
349
+ }}
350
+ onPointerCancel={function () { setDrag(null); }}
351
+ >
352
+ <GripVertical className="h-3.5 w-3.5" />
353
+ </span>
354
+ ```
355
+
356
+ **Pointer capture, not mouse events.** Capture makes the handle the target of every move and of the up
357
+ *wherever the pointer travels*, and obliges the browser to send `pointercancel` if it takes the pointer
358
+ away. Without it, a drag released over another application never delivers its up and the row stays
359
+ stuck mid-drag until a reload.
360
+
361
+ **Measure rects, don't listen for `onPointerEnter` on each row.** While the pointer is captured, no
362
+ other element gets enter/leave at all, so per-row handlers silently never fire. And
363
+ `document.elementFromPoint` is not the escape hatch — inside a block it returns the shadow host (see
364
+ [anti-patterns.md](anti-patterns.md#layout--styling)).
365
+
366
+ **Draw the insertion line with an INSET box-shadow, never a border.** A real 2px border grows the row
367
+ by 2px and shoves every row below it down a notch, so the list crawls under the pointer as the target
368
+ changes:
369
+
370
+ ```jsx
371
+ style={Object.assign({}, ROW_STYLE, isTarget
372
+ ? (drag.over < drag.from
373
+ ? { boxShadow: "inset 0 2px 0 0 " + ACCENT } // landing above
374
+ : { boxShadow: "inset 0 -2px 0 0 " + ACCENT }) // landing below
375
+ : null)}
376
+ ```
377
+
378
+ **Renumber the whole run — never swap a pair.** A swap cannot express "drop three rows up", and on a
379
+ nullable order field it corrupts the sort: positions start null, so numbering only the two rows that
380
+ moved leaves the rest null, and any "nulls last" comparator then throws every untouched row to the
381
+ bottom the moment the user switches to that sort. Write `position = i + 1` for every row whose slot
382
+ actually changed. The first reorder on a fresh list costs N writes; later ones cost the distance
383
+ travelled.
384
+
385
+ **Hold an optimistic order until the refetch lands.** The position writes are in flight while the
386
+ records still carry their OLD numbers, so re-sorting on those throws the row back to where it was
387
+ dragged from for a beat — which reads as the drag having failed. Apply `optimisticOrder` ahead of both
388
+ sorts and clear it when the refetch resolves. Writes stay sequential (`await mutateAsync` per row, in
389
+ order, stop on first failure — see [../datasources/writing.md](../datasources/writing.md#sequential-multi-row-writes-mutateasync));
390
+ on failure, clear the override and refetch, because a half-applied renumber is worse than none.
391
+
392
+ **Only gate the drag on permissions, not on a sort mode.** If the list has an alternative sort, let the
393
+ drag switch to manual order rather than disabling the handle — see the disabled-control note in
394
+ [../ui-ux-guidelines.md](../ui-ux-guidelines.md#26-finishing-touches).
@@ -0,0 +1,149 @@
1
+ # The searchable dropdown (`Combo`)
2
+
3
+ **Use this instead of shadcn's `<Select>` and instead of a native `<select>`, always.**
4
+ A Vibe Coding block renders inside a **shadow DOM**, and that one fact rules out both of
5
+ the obvious choices:
6
+
7
+ | Option | Why it fails in a block |
8
+ |---|---|
9
+ | Native `<select>` | Hands the list to the OS. No keyword filter, none of your styling, and on macOS it paints a grey slab over the page. Fine for 5 options, unusable at 90. |
10
+ | shadcn `<Select>` / `<Command>` | **Portals to `document.body`, which is outside the block's shadow root**, so the styles arrive stripped. It also cannot be searched. |
11
+ | `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, drop-up. |
12
+
13
+ Copy the component into the block. A Vibe block is one self-contained file — there is no
14
+ shared module to import, so each block carries its own copy. Keep one canonical copy in the
15
+ project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
16
+
17
+ ## The three things that will bite you
18
+
19
+ **1. Click-outside must use `composedPath()`, not `contains()`.**
20
+ By the time a click reaches `document`, the shadow DOM has *retargeted* its `target` to the
21
+ shadow **host**. So the usual `root.contains(e.target)` test calls the panel's own rows
22
+ "outside" and closes the panel before the click can land on one — the dropdown appears to
23
+ ignore every selection.
24
+
25
+ ```jsx
26
+ function onDown(e) {
27
+ var root = rootRef.current;
28
+ if (!root) return;
29
+ var path = e.composedPath ? e.composedPath() : [];
30
+ for (var i = 0; i < path.length; i++) {
31
+ if (path[i] === root) return; // inside — leave it open
32
+ }
33
+ if (path.length === 0 && e.target && root.contains(e.target)) return; // fallback
34
+ setOpen(false);
35
+ }
36
+ document.addEventListener("mousedown", onDown, true);
37
+ ```
38
+
39
+ **2. Define it at MODULE scope, never inside `Block()`.**
40
+ A component redefined inside `Block()` gets a fresh identity every render, so React unmounts
41
+ and remounts the `<input>` and the search box loses focus after one keystroke. This is the
42
+ single most common Vibe Coding bug and it looks like a platform fault.
43
+
44
+ **3. `onMouseDown` on a row must `preventDefault()`.**
45
+ Otherwise focus leaves the search input before the click resolves.
46
+
47
+ ## Sort A→Z *inside* the component
48
+
49
+ Sorting at the call site gets forgotten. Do it in the component, with an opt-out:
50
+
51
+ ```jsx
52
+ function comboSortLabels(a, b) {
53
+ return String(a.label || "").localeCompare(String(b.label || ""), undefined, {
54
+ sensitivity: "base", // case-insensitive
55
+ numeric: true, // "Item 2" before "Item 10" — a plain compare gets this backwards
56
+ });
57
+ }
58
+ ```
59
+
60
+ `autoSort` defaults to **true**. Pass `autoSort={false}` only where the given order *is* the
61
+ meaning — a pipeline of statuses (Not ordered → Ordered → … → Delivered), a physical
62
+ journey, a curated short list. Alphabetising a workflow puts "Damaged" first and buries the
63
+ starting state in the middle, which is worse than not sorting at all.
64
+
65
+ Grouped lists sort **within** a group and keep first-seen group order, so a "this project's
66
+ rooms first, everyone else after" grouping survives being alphabetised.
67
+
68
+ ## Filter on every token, in any order
69
+
70
+ ```jsx
71
+ var tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
72
+ var filtered = options.filter(function (o) {
73
+ if (tokens.length === 0) return true;
74
+ var hay = String(o.label || "").toLowerCase();
75
+ for (var i = 0; i < tokens.length; i++) {
76
+ if (hay.indexOf(tokens[i]) === -1) return false;
77
+ }
78
+ return true;
79
+ });
80
+ ```
81
+
82
+ So `rural art` finds *The Rural Art Company*. A single `indexOf(query)` would not.
83
+
84
+ ## Show the search box only when it earns its place
85
+
86
+ ```jsx
87
+ var searchable = props.onCreate
88
+ ? true
89
+ : props.searchable === true || (props.searchable !== false && options.length >= 8);
90
+ ```
91
+
92
+ Under eight options a search box is noise — the picker should still read as a picker.
93
+
94
+ ## One flat row list for the keyboard
95
+
96
+ Build `rows` as a single array — the optional *clear* row, then the filtered options, then
97
+ the optional *create* row — so arrow-key navigation has one index to walk. Clamp the active
98
+ index (`Math.min(active, rows.length - 1)`): filtering shrinks the list under the highlight.
99
+
100
+ ## Drop up near the fold
101
+
102
+ ```jsx
103
+ var r = rootRef.current.getBoundingClientRect();
104
+ var below = window.innerHeight - r.bottom;
105
+ setDropUp(below < 300 && r.top > below);
106
+ ```
107
+
108
+ A filter row near the bottom of the viewport otherwise opens into nothing.
109
+
110
+ ## Variants worth having
111
+
112
+ - **`bare`** — inline-editor mode. No border, no fill; `triggerContent` (a status chip, a
113
+ cell's text) *is* the trigger. Lets a table cell become editable without every row growing
114
+ a form control.
115
+ ⚠ If any column width in your table is derived from the trigger's chrome, keep the
116
+ chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
117
+ widths in a different file.
118
+ - **`triggerStyle`** — merged over the defaults, for a trigger that is part of the design
119
+ (a chip painted in its own status colour) rather than a plain field.
120
+ - **`onCreate(text)`** — offers `Add "<typed>"` when nothing matches. If creating the record
121
+ and *linking* it happen at different times, say so in the toast: the two halves landing
122
+ separately is exactly what gets reported as "it didn't save".
123
+ - **`emptyLabel`** — a row that clears the selection. Call sites that pass a leading
124
+ `{ value: "" }` "any / none" option should have it lifted into `emptyLabel` rather than
125
+ rendered twice.
126
+
127
+ ## The one shadow you are allowed
128
+
129
+ DESIGN-system rules that ban shadows are about cards. A white panel floating over a white
130
+ card with only a hairline between them reads as part of the card, so the menu gets a shadow:
131
+
132
+ ```js
133
+ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.16)"
134
+ ```
135
+
136
+ Everything else — the trigger, the card, the rows — stays flat.
137
+
138
+ ## Checklist before shipping a dropdown
139
+
140
+ - [ ] Defined at module scope
141
+ - [ ] `composedPath()` click-outside
142
+ - [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
143
+ - [ ] Multi-token filter
144
+ - [ ] Search box hidden under 8 options
145
+ - [ ] Keyboard: ↑ ↓ Enter Esc Tab, active row scrolled into view
146
+ - [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
147
+ `aria-selected`, and an `aria-label` on the trigger
148
+ - [ ] Loading and empty states (`"Nothing matches that."`)
149
+ - [ ] No `@/components/ui/select` import anywhere in the file
@@ -719,6 +719,8 @@ Actively check for and reject these fingerprints of generic AI-generated interfa
719
719
  - **Relative timestamps:** "2 hours ago" via `date-fns/formatDistanceToNow`
720
720
  - **Truncate long text** with `truncate` or `line-clamp-2`, full value in `Tooltip`
721
721
  - **Sticky headers** for long tables
722
+ - **Never render an affordance you have not wired.** A grip glyph that does not drag, a chevron that does not sort, a card that looks clickable and is not — the signifier IS the promise, and an unfulfilled one reads as a broken feature, not a missing one. Either wire it or delete it. (Observed 2026-09-09: a `GripVertical` shipped as decoration on every row of a reorderable list; users reported the list as "can't be reordered", not as "missing drag".)
723
+ - **A control that is disabled by default is indistinguishable from a broken one.** If the only explanation lives in a `title` tooltip, nobody reads it — they file a bug. When a control depends on a mode the user has not chosen yet, prefer making the action *switch the mode and proceed* over greying it out. Disable only for genuine impossibility (permissions, first row can't move up), and when you do, say why in visible text rather than on hover. (Same 2026-09-09 report: reorder arrows were disabled until you switched the sort to Manual, which nothing on screen told you.)
722
724
 
723
725
  ---
724
726