softr-vibe-coding 2.5.2 → 2.6.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,9 @@ 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.6.0] - 2026-09-09
8
+ - Drag-to-reorder: the pattern, the shadow-DOM hit-test trap, and two affordance rules
9
+
7
10
  ## [2.5.2] - 2026-09-09
8
11
  - Permission finding is advisory, not a veto — report severity and leave the call to the builder
9
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.5.2",
3
+ "version": "2.6.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).
@@ -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