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 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, bare inline-editor variant
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.7.0",
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
- ## Show the search box only when it earns its place
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 && options.length >= 8);
89
+ : props.searchable === true || (props.searchable !== false && !props.bare);
90
90
  ```
91
91
 
92
- Under eight options a search box is noise — the picker should still read as a picker.
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
- - [ ] Search box hidden under 8 options
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
@@ -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
@@ -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