softr-vibe-coding 2.9.0 → 2.9.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 +4 -0
- package/README.md +4 -1
- package/SKILL.md +1 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +2 -0
- package/references/common-patterns.md +1 -1
- package/references/searchable-dropdown.md +188 -13
- package/references/softr-mcp.md +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.9.1] - 2026-09-30
|
|
8
|
+
- Add right-edge placement and the schema-less workspace-tool correction (2026-09-30)
|
|
9
|
+
- Document dropdown clipping by overflow ancestors (verified live 2026-09-30)
|
|
10
|
+
|
|
7
11
|
## [2.9.0] - 2026-09-29
|
|
8
12
|
- Add runtime facts verified live 2026-09-18
|
|
9
13
|
|
package/README.md
CHANGED
|
@@ -232,7 +232,10 @@ softr-vibe-coding/
|
|
|
232
232
|
│ # click-outside, A-Z inside the component,
|
|
233
233
|
│ # multi-token filter, searchable BY DEFAULT
|
|
234
234
|
│ # (bare = click-only; searchable={false} only
|
|
235
|
-
│ # for a fixed enum being set — Sep 10 2026)
|
|
235
|
+
│ # for a fixed enum being set — Sep 10 2026),
|
|
236
|
+
│ # overflow-clipping ancestors: never clip a cell
|
|
237
|
+
│ # holding a Combo, clip-aware drop-up + list
|
|
238
|
+
│ # height, list-only scrolling (Sep 30 2026)
|
|
236
239
|
│
|
|
237
240
|
├── tools/ # Bundled CLI scripts (run, not read)
|
|
238
241
|
│ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
|
package/SKILL.md
CHANGED
|
@@ -90,6 +90,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
90
90
|
- 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)
|
|
91
91
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
92
92
|
- **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)
|
|
93
|
+
- No clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`, `line-clamp-*`) on any element that contains a `Combo` — its panel is absolutely positioned in local DOM, so a clipping `<td>` cuts the menu to the row's height; bound an over-wide chip at the chip (`min-w-0 truncate`), and never `scrollIntoView` inside the panel. See [references/searchable-dropdown.md](references/searchable-dropdown.md#the-four-things-that-will-bite-you), item 4
|
|
93
94
|
- 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))
|
|
94
95
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
95
96
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.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"
|
|
@@ -74,6 +74,8 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
74
74
|
| 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 |
|
|
75
75
|
| Emojis in UI | lucide-react icons only |
|
|
76
76
|
| `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 |
|
|
77
|
+
| `overflow-hidden`, `truncate`, `line-clamp-*` or `overflow-*-auto` on a container that holds a dropdown or popover — typically a `<td>` clipped so an over-wide status chip stops at its own column | **Symptom:** the menu opens cut to the height of its row or its scroller: one or two options showing, the rest unreachable by mouse. **Cause:** the `Combo` panel is `position: absolute` in the block's own DOM (a portal would leave the shadow root and lose its styles), and an absolutely positioned box is clipped by every ancestor whose `overflow` is not `visible`. `truncate` and `line-clamp-*` set `overflow: hidden`; `overflow-x-auto` turns `overflow-y` to `auto` as well. **Fix:** no clipping class between the Combo and the scroller it belongs to; bound the chip at the chip (`min-w-0 truncate` on the chip inside the flex trigger); measure the drop-up and the list height against the clipping ancestors, not the window. Hit in production 2026-09-30: three ROSIE item tables clipped the status cell as a 2px backstop, next to a comment claiming the menu was portaled — it had stopped being portaled when the tables moved from shadcn `<Select>` to `Combo`. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4 |
|
|
78
|
+
| `el.scrollIntoView({ block: "nearest" })` to keep a dropdown's highlighted option in view (or a plain `focus()` on its search box) | **Symptom:** the table or the page jumps when a menu opens near an edge; inside a clipped cell the trigger itself scrolls out of view. **Cause:** `scrollIntoView` scrolls EVERY scrollable ancestor until the element shows, and `overflow: hidden` boxes are still scrollable from script; `focus()` scrolls ancestors the same way. **Fix:** scroll the list element only — compare the option's rect with the list's and adjust `list.scrollTop` — and focus with `{ preventScroll: true }`. Verified in Chromium 2026-09-30: `scrollIntoView` scrolled an `overflow: hidden` cell by 164px, the list-only scroll moved nothing outside the list. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3 |
|
|
77
79
|
| 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 |
|
|
78
80
|
| 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 |
|
|
79
81
|
| `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 |
|
|
@@ -515,4 +515,4 @@ function onKeyDown(e) {
|
|
|
515
515
|
|
|
516
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
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`)
|
|
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`). That is right here, because these rows are page content and the table's scroller and the page *should* move to them. Inside a dropdown it is wrong, and the Combo scrolls only its own list — see [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3. 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.
|
|
@@ -8,13 +8,13 @@ the obvious choices:
|
|
|
8
8
|
|---|---|
|
|
9
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
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. |
|
|
11
|
+
| `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, clip-aware drop-up. |
|
|
12
12
|
|
|
13
13
|
Copy the component into the block. A Vibe block is one self-contained file — there is no
|
|
14
14
|
shared module to import, so each block carries its own copy. Keep one canonical copy in the
|
|
15
15
|
project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
|
|
16
16
|
|
|
17
|
-
## The
|
|
17
|
+
## The four things that will bite you
|
|
18
18
|
|
|
19
19
|
**1. Click-outside must use `composedPath()`, not `contains()`.**
|
|
20
20
|
By the time a click reaches `document`, the shadow DOM has *retargeted* its `target` to the
|
|
@@ -44,6 +44,182 @@ single most common Vibe Coding bug and it looks like a platform fault.
|
|
|
44
44
|
**3. `onMouseDown` on a row must `preventDefault()`.**
|
|
45
45
|
Otherwise focus leaves the search input before the click resolves.
|
|
46
46
|
|
|
47
|
+
**4. The panel is clipped by any ancestor whose `overflow` is not `visible`.**
|
|
48
|
+
The panel is `position: absolute` inside the trigger's `relative` wrapper, in the block's own
|
|
49
|
+
DOM, because it cannot portal out of the shadow root (see *Why not portal* below). Its
|
|
50
|
+
containing block is that wrapper, so *every* ancestor whose `overflow` is `hidden`, `auto`,
|
|
51
|
+
`scroll` or `clip` clips it, however far up. In Tailwind that is `overflow-hidden`,
|
|
52
|
+
`overflow-auto`, `overflow-y-auto`, `overflow-x-auto` (an `overflow-x` of `hidden`, `auto` or
|
|
53
|
+
`scroll` turns `overflow-y: visible` into `auto`, so it clips vertically too), `truncate` (it
|
|
54
|
+
sets `overflow: hidden`) and `line-clamp-*` (so does that). A clipped `<td>` is the height of
|
|
55
|
+
its row, and the menu opens inside it. Three rules follow.
|
|
56
|
+
|
|
57
|
+
**Rule 1 — never put a clipping class on an element that contains a `Combo`:** a `<td>`, a
|
|
58
|
+
card, a flex cell, anything between the Combo and the scroller it belongs to. If a chip or
|
|
59
|
+
label inside the trigger can overflow, bound it at the chip: `truncate` (or `overflow-hidden`)
|
|
60
|
+
plus `min-w-0` on the chip itself. The trigger is a flex row, and `min-w-0` is what lets a flex
|
|
61
|
+
item shrink below its text — strictly redundant while the chip clips itself, load-bearing the
|
|
62
|
+
moment the ellipsis moves to a span inside it. Clipping at the chip clips the chip. Clipping at
|
|
63
|
+
the cell clips the menu.
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
<td className="px-4">{/* no overflow class on the cell */}
|
|
67
|
+
<Combo
|
|
68
|
+
bare
|
|
69
|
+
triggerContent={<span className="min-w-0 truncate px-2" style={chipStyle}>{label}</span>}
|
|
70
|
+
…
|
|
71
|
+
/>
|
|
72
|
+
</td>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Rule 2 — decide the drop-up and the list's height against the clipping ancestors, not the
|
|
76
|
+
window.** `window.innerHeight - rect.bottom` cannot see scroll containers: a table with its own
|
|
77
|
+
`max-height` + `overflow: auto` scroller, a dialog body with `overflow-y-auto`, a horizontally
|
|
78
|
+
scrollable table wrapper. A row near the bottom of one of those opens its menu downward into
|
|
79
|
+
the scroller's hidden area while the window still has plenty of room. Measure inside the strip
|
|
80
|
+
the panel can actually paint into:
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
var COMBO_LIST_MAX = 256; // the list's normal ceiling (max-h-64)
|
|
84
|
+
var COMBO_LIST_MIN = 120; // the floor: below this, accept a clip rather than a useless list
|
|
85
|
+
|
|
86
|
+
/* The strip of screen the panel can paint into: the viewport, cut down by every ancestor
|
|
87
|
+
that clips vertically. overflowY is enough on its own: an overflow-x of hidden, auto or
|
|
88
|
+
scroll already turns a visible overflow-y into auto in the computed style. */
|
|
89
|
+
function comboClipBox(node) {
|
|
90
|
+
var top = 0;
|
|
91
|
+
var bottom = window.innerHeight;
|
|
92
|
+
var el = node;
|
|
93
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
94
|
+
// clientHeight is 0 for the boxes overflow does not apply to (inline, display: contents)
|
|
95
|
+
if (el.clientHeight > 0 && window.getComputedStyle(el).overflowY !== "visible") {
|
|
96
|
+
var r = el.getBoundingClientRect();
|
|
97
|
+
var t = r.top + el.clientTop; // the clip edge is the padding box: inside the border
|
|
98
|
+
var b = t + el.clientHeight; // and above a horizontal scrollbar
|
|
99
|
+
if (t > top) top = t;
|
|
100
|
+
if (b < bottom) bottom = b;
|
|
101
|
+
}
|
|
102
|
+
// Where the parent chain ends at the shadow root, carry on from its host.
|
|
103
|
+
el = el.parentElement || el.getRootNode().host || null;
|
|
104
|
+
}
|
|
105
|
+
return { top: top, bottom: bottom };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* chrome = the panel's height outside the list: the search box and the borders. */
|
|
109
|
+
function comboPlace(wrapper, chrome) {
|
|
110
|
+
var clip = comboClipBox(wrapper);
|
|
111
|
+
var r = wrapper.getBoundingClientRect();
|
|
112
|
+
var below = clip.bottom - r.bottom - 10; // the 2px gap to the trigger + 8px to spare
|
|
113
|
+
var above = r.top - clip.top - 10;
|
|
114
|
+
var up = below < chrome + COMBO_LIST_MAX && above > below;
|
|
115
|
+
var room = (up ? above : below) - chrome;
|
|
116
|
+
return { up: up, listMax: Math.max(COMBO_LIST_MIN, Math.min(COMBO_LIST_MAX, room)) };
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Call it when the panel opens, and give the list its height as an inline style in place of
|
|
121
|
+
`max-h-64`:
|
|
122
|
+
|
|
123
|
+
```jsx
|
|
124
|
+
var [listMax, setListMax] = useState(COMBO_LIST_MAX);
|
|
125
|
+
|
|
126
|
+
// in toggle(), before setOpen(true):
|
|
127
|
+
if (rootRef.current) {
|
|
128
|
+
var place = comboPlace(rootRef.current, searchable ? 51 : 2);
|
|
129
|
+
setDropUp(place.up);
|
|
130
|
+
setListMax(place.listMax);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// the list:
|
|
134
|
+
<div ref={listRef} role="listbox" className="overflow-y-auto py-1" style={{ maxHeight: listMax }}>
|
|
135
|
+
{/* rows */}
|
|
136
|
+
</div>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Downward when the whole panel fits below, otherwise toward the larger side, with the list
|
|
140
|
+
capped to the room it gets. `chrome` is the panel's height outside the list — about 51px with
|
|
141
|
+
the search box (`p-2` around an `h-8` input, a 1px rule, the panel's two borders) and 2px
|
|
142
|
+
without; measure yours if the panel differs. The fit test assumes a full-height list, so a
|
|
143
|
+
three-option menu may flip up when it would have fit below, which costs nothing. The walk
|
|
144
|
+
starts at the wrapper, whose own overflow would clip the panel too; it leaves the shadow root
|
|
145
|
+
through its host, so a clipping container outside the block still counts; and it stops below
|
|
146
|
+
`<body>`, because `body` and `html` hand their overflow to the viewport — their computed
|
|
147
|
+
`overflow` can say `hidden` while they clip nothing. The four cases it has to get right: a row
|
|
148
|
+
mid-way down a tall table scroller opens down; the last visible row of a scroller whose bottom
|
|
149
|
+
edge is mid-window opens up, inside the scroller (the window-only rule opened it down, into
|
|
150
|
+
the hidden part); a picker at the bottom of a dialog body opens up, inside the dialog; a filter
|
|
151
|
+
row at the window's bottom edge opens up, as before. All four, plus a scroller outside the
|
|
152
|
+
shadow root, checked in Chromium on 2026-09-30 on a test page — not yet in a deployed block.
|
|
153
|
+
|
|
154
|
+
Rule 2 does not rescue a clipped cell. The cell is the height of its row, so neither side has
|
|
155
|
+
room, the list falls to its 120px floor and is clipped anyway. Rule 1 is not optional.
|
|
156
|
+
|
|
157
|
+
**The same box decides which edge the panel hangs from.** A panel hung from the trigger's left
|
|
158
|
+
edge with `width: max-content` runs past the right edge of a table's scroll box when the trigger
|
|
159
|
+
sits in the last column. In ROSIE the Location menu ran about 15px over, and the old
|
|
160
|
+
`scrollIntoView` then slid the whole table sideways to reveal it. Measure the box's left and right
|
|
161
|
+
edges as well (padding box, tested on `overflowX`), and when the room to the right of the trigger
|
|
162
|
+
is short (under ~300px) and there is more to the left, anchor the panel with `right: 0` instead of
|
|
163
|
+
`left: 0` and cap its `maxWidth` to the room on that side. Rows are a fixed height in this
|
|
164
|
+
component, so the fit test can also count the real rows instead of assuming a full list, and a
|
|
165
|
+
four-option menu near an edge stops flipping for room it will never use. ROSIE's
|
|
166
|
+
`Shared/combo.jsx` (2026-09-30) is the worked version: one `comboClipBox` returning all four
|
|
167
|
+
edges, one `comboPlacement` returning `{ up, right, listMax, maxW }`, deployed in twelve blocks.
|
|
168
|
+
|
|
169
|
+
**Rule 3 — keep the active row visible by scrolling the list, never with `scrollIntoView`.**
|
|
170
|
+
`scrollIntoView` scrolls *every* scrollable ancestor until the element shows, and an
|
|
171
|
+
`overflow: hidden` box is still scrollable from script. In a clipped cell it scrolls the
|
|
172
|
+
cell's content until the option shows, pushing the trigger out of view; near the edge of a
|
|
173
|
+
table it scrolls whatever the menu hangs out of — the table's own scroller, the page — so the
|
|
174
|
+
table jumps as the menu opens. Scroll the list element and nothing else:
|
|
175
|
+
|
|
176
|
+
```jsx
|
|
177
|
+
useEffect(
|
|
178
|
+
function () {
|
|
179
|
+
var list = listRef.current;
|
|
180
|
+
if (!open || !list) return;
|
|
181
|
+
var el = list.querySelector('[data-active="true"]');
|
|
182
|
+
if (!el) return;
|
|
183
|
+
var lr = list.getBoundingClientRect();
|
|
184
|
+
var er = el.getBoundingClientRect();
|
|
185
|
+
var viewTop = lr.top + list.clientTop; // inside the list's top border
|
|
186
|
+
var viewBottom = viewTop + list.clientHeight; // clientHeight excludes border and scrollbar
|
|
187
|
+
if (er.top < viewTop) list.scrollTop -= viewTop - er.top;
|
|
188
|
+
else if (er.bottom > viewBottom) list.scrollTop += er.bottom - viewBottom;
|
|
189
|
+
},
|
|
190
|
+
[open, activeIdx, query]
|
|
191
|
+
);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Rects rather than `offsetTop`: a row's `offsetParent` is the nearest positioned ancestor,
|
|
195
|
+
which is the panel, not the list, so `offsetTop` comes out too large by the search box's
|
|
196
|
+
height unless the list is made `position: relative`. Rects need no such arrangement. The
|
|
197
|
+
effect runs after the commit, outside the event cycle Hard Constraint 17's `setTimeout` is
|
|
198
|
+
there to escape — the `scrollIntoView` it replaces ran from the same kind of effect and took
|
|
199
|
+
effect. `focus()` scrolls ancestors the same way, so focus the search box with
|
|
200
|
+
`inputRef.current.focus({ preventScroll: true })`.
|
|
201
|
+
|
|
202
|
+
**Why not portal the panel, or make it `position: fixed`?** A portal to `document.body`
|
|
203
|
+
leaves the shadow root, and the styles stay behind — the shadcn row at the top of this page.
|
|
204
|
+
`position: fixed` inside the shadow root escapes the clipping only while no ancestor has a
|
|
205
|
+
`transform`, `filter`, `perspective`, `contain` or `will-change`: any of those becomes the
|
|
206
|
+
containing block for fixed descendants, and scroll-reveal animations on Softr pages commonly
|
|
207
|
+
leave a transform behind (the same trap as the block-owned header in
|
|
208
|
+
[static-blocks.md](static-blocks.md#block-owned-landing-page-header)). A fixed panel also stops
|
|
209
|
+
moving with its trigger, so it has to be re-placed on every scroll. The clip-aware absolute
|
|
210
|
+
panel has neither problem, which is why it is the design here.
|
|
211
|
+
|
|
212
|
+
**The incident — ROSIE, 2026-09-30.** Three item tables put `overflow-hidden` on the `<td>`
|
|
213
|
+
holding the status chip, as a backstop so the chip, 1–2px too wide at the column's narrowest
|
|
214
|
+
drag width, would clip at its own column instead of painting over the next one. The comment
|
|
215
|
+
beside it said the status menu was portaled to the body, so the clip could not reach it. That
|
|
216
|
+
was true of the shadcn `<Select>` the tables used before and stopped being true when they moved
|
|
217
|
+
to `Combo`; nobody re-checked. The menu opened inside the 48px cell: one and a half options
|
|
218
|
+
showing, the chip scrolled out of view by `scrollIntoView`, the other six unreachable by
|
|
219
|
+
mouse. The Location dropdown in the same rows kept working, because its cell had no overflow
|
|
220
|
+
class. A 2px backstop cost the whole feature. A comment that says how a component renders is
|
|
221
|
+
a claim to verify in the component, not in the comment.
|
|
222
|
+
|
|
47
223
|
## Sort A→Z *inside* the component
|
|
48
224
|
|
|
49
225
|
Sorting at the call site gets forgotten. Do it in the component, with an opt-out:
|
|
@@ -125,16 +301,7 @@ than reaching past the wrapper.
|
|
|
125
301
|
Build `rows` as a single array — the optional *clear* row, then the filtered options, then
|
|
126
302
|
the optional *create* row — so arrow-key navigation has one index to walk. Clamp the active
|
|
127
303
|
index (`Math.min(active, rows.length - 1)`): filtering shrinks the list under the highlight.
|
|
128
|
-
|
|
129
|
-
## Drop up near the fold
|
|
130
|
-
|
|
131
|
-
```jsx
|
|
132
|
-
var r = rootRef.current.getBoundingClientRect();
|
|
133
|
-
var below = window.innerHeight - r.bottom;
|
|
134
|
-
setDropUp(below < 300 && r.top > below);
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
304
|
+
Keep the highlighted row in view by scrolling the list only (rule 3 of item 4 above).
|
|
138
305
|
|
|
139
306
|
## Variants worth having
|
|
140
307
|
|
|
@@ -145,6 +312,9 @@ A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
|
145
312
|
⚠ If any column width in your table is derived from the trigger's chrome, keep the
|
|
146
313
|
chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
|
|
147
314
|
widths in a different file.
|
|
315
|
+
⚠ A `bare` Combo sits in a table cell, which is exactly where `overflow-hidden` and
|
|
316
|
+
`truncate` get added. Keep them off the cell and bound the chip instead — rule 1 of item 4
|
|
317
|
+
in [The four things that will bite you](#the-four-things-that-will-bite-you).
|
|
148
318
|
- **`triggerStyle`** — merged over the defaults, for a trigger that is part of the design
|
|
149
319
|
(a chip painted in its own status colour) rather than a plain field.
|
|
150
320
|
- **`onCreate(text)`** — offers `Add "<typed>"` when nothing matches. If creating the record
|
|
@@ -172,7 +342,12 @@ Everything else — the trigger, the card, the rows — stays flat.
|
|
|
172
342
|
- [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
|
|
173
343
|
- [ ] Multi-token filter
|
|
174
344
|
- [ ] Searchable by default; `bare` is click-only; `searchable={false}` only on a short fixed enum the user is setting
|
|
175
|
-
- [ ]
|
|
345
|
+
- [ ] No overflow-clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`,
|
|
346
|
+
`line-clamp-*`) between the Combo and the scroller it belongs to; an over-wide chip
|
|
347
|
+
bounded at the chip (`min-w-0 truncate`)
|
|
348
|
+
- [ ] Drop-up and list `maxHeight` measured against the clipping ancestors, not the window
|
|
349
|
+
- [ ] Keyboard: ↑ ↓ Enter Esc Tab; the active row kept visible by scrolling the list only
|
|
350
|
+
(never `scrollIntoView`), and the search box focused with `preventScroll`
|
|
176
351
|
- [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
|
|
177
352
|
`aria-selected`, and an `aria-label` on the trigger
|
|
178
353
|
- [ ] Loading and empty states (`"Nothing matches that."`)
|
package/references/softr-mcp.md
CHANGED
|
@@ -197,8 +197,15 @@ shapes, different payload type. The stringification happened on the client side,
|
|
|
197
197
|
calls made while the tool definitions had not been loaded into the model's context (deferred
|
|
198
198
|
schemas), so there was no type to serialise against. **Load the tool's schema before calling it,
|
|
199
199
|
and pass arrays as arrays.** (Empty schemas are real on Softr's *per-application* MCP servers —
|
|
200
|
-
every tool there is advertised as `{"type":"object"}` with a name-only description
|
|
201
|
-
|
|
200
|
+
every tool there is advertised as `{"type":"object"}` with a name-only description.)
|
|
201
|
+
|
|
202
|
+
**Correction, 2026-09-30: the workspace server's tools can arrive schema-less too.** In one
|
|
203
|
+
session every workspace tool loaded through ToolSearch showed only `{"type":"object"}` with a
|
|
204
|
+
name-only description, and a `search_replace` call written with a real array was still sent as a
|
|
205
|
+
string and rejected with the error above. Nothing was written, so the failure is safe, but no
|
|
206
|
+
amount of care on the caller's side gets an array through a schema-less tool. Look at the
|
|
207
|
+
loaded schema before relying on an array argument: if it has no `properties`, go straight to the
|
|
208
|
+
fallback in the table — a full replace, one file per subagent for a large block, byte-verified.
|
|
202
209
|
|
|
203
210
|
**Why the second row is a security problem, not an inconvenience.** Every code push resets the
|
|
204
211
|
block's auto-registered Actions to Softr's defaults, and the default for a `genericActions`
|