softr-vibe-coding 2.13.3 → 2.13.5
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 +9 -0
- package/README.md +21 -12
- package/SKILL.md +96 -14
- package/datasources/fields.md +25 -3
- package/datasources/multi-datasource.md +13 -3
- package/datasources/reading.md +29 -6
- package/datasources/writing.md +5 -1
- package/package.json +1 -1
- package/references/anti-patterns.md +15 -8
- package/references/browser-checks.md +156 -1
- package/references/common-patterns.md +223 -1
- package/references/dembrandt.md +7 -2
- package/references/helper-blocks.md +6 -2
- package/references/native-chrome-styling.md +163 -14
- package/references/quick-reference.md +38 -0
- package/references/searchable-dropdown.md +28 -1
- package/references/softr-mcp.md +36 -25
- package/references/static-blocks.md +7 -4
- package/ui-ux-guidelines.md +38 -4
|
@@ -19,9 +19,10 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
19
19
|
| Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
|
|
20
20
|
| Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
|
|
21
21
|
| Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
|
|
22
|
-
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<
|
|
22
|
+
| "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<connection>/records`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission and the block's own Visibility gate the endpoint (403 on list and by-id; block gate verified 2026-10-05), and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
|
|
23
23
|
| `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
|
|
24
24
|
| `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
|
|
25
|
+
| `where: q.text("status")…` or `orderBy` naming an alias that is not in **that hook's own** `select` | Crashes the whole block at runtime — "Could not find an alias for subject \"undefined\"", Softr's "Oh snap" panel — though the push compiled clean (seen live 2026-09-18 on a `useMetric`). Aliases resolve per hook, not per connection: add the field to the hook's select (it then joins the connection's read payload). Hard Constraint 29; see [datasources/reading.md](../datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select) |
|
|
25
26
|
| `useRecords({ ..., enabled: false })` / `enabled: someFlag` to defer or withhold a list query | **`useRecords` ignores `enabled: false`** — literal or variable, it fetches anyway (verified live 2026-09-18). `useRecord` honours it. To make a list query conditional, mount the hook in a **child component rendered only when needed** (the only option that sends no request), or give it a match-nothing `where`. Never rely on `enabled` to keep a table away from viewers who should not load it. See [datasources/reading.md](../datasources/reading.md#userecords-ignores-enabled-false) |
|
|
26
27
|
|
|
27
28
|
## Mutations
|
|
@@ -57,6 +58,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
57
58
|
|---|---|
|
|
58
59
|
| `field.toLowerCase()` on selects | `getFieldValue(field).toLowerCase()` |
|
|
59
60
|
| `item.fields.formula === true` | Formula booleans: `=== "1"` |
|
|
61
|
+
| `new Date(value)` / date-fns `format(new Date(value))` on a **date-only** field | Date-only values arrive as midnight UTC, so west of Greenwich they render one day early (verified 2026-09-18). Parse them with `toLocalDate()` — see [datasources/fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc). Keep `new Date()` for real timestamps |
|
|
60
62
|
|
|
61
63
|
## Hooks & React
|
|
62
64
|
|
|
@@ -80,19 +82,24 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
80
82
|
| `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 |
|
|
81
83
|
| `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 |
|
|
82
84
|
| 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 |
|
|
83
|
-
| 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 |
|
|
85
|
+
| 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. Under container queries, find where a skeleton's text lines wrap by measuring at BLOCK widths with the real fonts: a padding change moves the wrap, and a skeleton keyed to the old width jumped when data landed (measured 2026-10-05 in headless Chromium, blocks 300–1400px wide). See [ui-ux-guidelines.md](../ui-ux-guidelines.md) §12 |
|
|
84
86
|
| `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 |
|
|
85
87
|
| `[&_svg]:opacity-0` on SelectTrigger | `<style>` + `data-fix-chevron` attribute (Softr bundler limitation) |
|
|
86
|
-
| Relying on `custom-code-header.html` (Softr → Settings → Custom Code → Code inside header) to apply brand fonts/colors INSIDE a Vibe Coding block | Vibe Coding blocks render inside a shadow DOM. CSS custom properties (`--brand-*`) pierce that boundary, but `html, body { font-family: ... !important }` rules **do not** — `<html>` and `<body>` don't exist inside the shadow root. Apply brand fonts/colors at the block's **own outermost wrapper** via inline style: `style={{ fontFamily: "'Manrope', system-ui, sans-serif", color: BRAND_INK }}` on the outer `<div>` so every descendant inherits brand defaults. Override per-element with explicit inline `fontFamily` (e.g., `"'Fraunces', Georgia, serif"` on h1/h2). Google `<link>` tags in the page head DO load `@font-face` globally — the fonts are available inside shadow DOM, they just need to be applied. |
|
|
87
|
-
| Painting `backgroundColor: BRAND_CANVAS` on a Vibe Coding block's outer wrapper
|
|
88
|
-
| Relying on the page background on a **dark** brand, and shipping a block whose own canvas is unpainted |
|
|
89
|
-
| Setting only `html, body { background }` in `custom-code-header.html` and expecting the app to change colour | Softr paints the
|
|
88
|
+
| Relying on `custom-code-header.html` (Softr → Settings → Custom Code → Code inside header) to apply brand fonts/colors INSIDE a Vibe Coding block | Vibe Coding blocks render inside a shadow DOM. CSS custom properties (`--brand-*`) pierce that boundary, but `html, body { font-family: ... !important }` rules **do not** — `<html>` and `<body>` don't exist inside the shadow root. Apply brand fonts/colors at the block's **own outermost wrapper** via inline style: `style={{ fontFamily: "'Manrope', system-ui, sans-serif", color: BRAND_INK }}` on the outer `<div>` so every descendant inherits brand defaults. Override per-element with explicit inline `fontFamily` (e.g., `"'Fraunces', Georgia, serif"` on h1/h2). Google `<link>` tags in the page head DO load `@font-face` globally — the fonts are available inside shadow DOM, they just need to be applied. Give the header's font link an id (`<link id="brand-fonts" rel="stylesheet" href="…">`), and have each block append the same link to `document.head` only when `document.getElementById("brand-fonts")` finds nothing (the head is outside the shadow root, so this `document` lookup works). The block then works with or without the header code and never loads the fonts twice. Verified 2026-10-05: the header's link sat in `<head>` and the blocks skipped theirs. |
|
|
89
|
+
| Painting `backgroundColor: BRAND_CANVAS` on a Vibe Coding block's outer wrapper to match a page colour that `custom-code-header.html` paints — or leaving it unset and expecting that page colour to show through | **An unpainted block does not let the page show through.** Its shadow host, `div[data-role="vibe-block-root"]`, paints the Studio theme background through the block's compiled `:host { background-color: var(--background) }` (in `@layer base`; `--background` maps to the theme's hashed background variable). Measured 2026-10-05: the host computes rgb(255,255,255) from its own compiled `:host` rule (read from the block's stylesheet), so it paints white whatever the page behind it is. It turns transparent only when header CSS clears the host: the app-frame rule `#main-content [data-role="vibe-block-root"] { background-color: transparent !important; }` (verified 2026-10-05; it wins because the `:host` rule is not `!important`), or, in a top-bar-only app, the page-background recipe's `#page-content div:not(.softr-topbar)…` clear, which hits the host too ([native-chrome-styling.md → Page background](native-chrome-styling.md#page-background)). The verified app-frame form is unscoped and applies on every page; the scoped form `#page-content:has(.softr-sidebar, .softr-bottombar) [data-role="vibe-block-root"]` is untested. With the host cleared, leave the block's outer wrapper unpainted too, so the header is the one painter of the page colour (older notes reported a seam from painting it twice; its cause was never measured). The header reaches the host but nothing inside the shadow root, so the block still paints its own cards and borders, and sets `fontFamily` and `color` on its wrapper (row above). Exception: a brand-tinted *section* that differs from the page (a card-style admin shell) paints its own container, not the outer wrapper. Recipe: [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout). **Dark brands: see the next row.** |
|
|
90
|
+
| Relying on the page background on a **dark** brand, and shipping a block whose own canvas is unpainted | Same mechanism as the row above: whatever `custom-code-header.html` paints on `body` (`body { background-color: #000 !important }`), the block's host paints the Studio theme background, white by default — so a block with white text, a white-only logo, or a white primary button renders **invisibly on white**. Verified July 2026: a black-canvas feedback form shipped with its wordmark and its Submit button both white-on-white; the button was there and clickable, just unseeable. On a dark brand, paint `backgroundColor` on the block's own outer wrapper AND set the Studio theme background to the same value. The theme background is the no-CSS lever, because it is what the host's `--background` reads (measured 2026-10-05); changing it to fix a block is untested. Two identical pure blacks composite with no seam, so the double-paint concern above doesn't bite there, and painting it in the block also keeps it correct if the custom-code snippet is ever removed. |
|
|
91
|
+
| Setting only `html, body { background }` in `custom-code-header.html` and expecting the app to change colour | Softr paints the page fill on several stacked layers, so styling one gets covered by the ones above it and `body` alone appears to do nothing. Measured 2026-10-05 in an app with Softr's sidebar: `html`, `body`, `#page-content` (Softr's own `.spr-content-root` rule), each Vibe block's shadow host `div[data-role="vibe-block-root"]` (most likely the "class-less wrapper div" of earlier notes, inferred; its `:host` rule paints the theme background, row above) and each native block's outer `<section>`. **Top bar only** (the June 2026 recipe): paint the backdrop on `html`, then clear `body, #page-content { background: transparent }` plus `#page-content div:not(.softr-topbar):not(.softr-topbar *)`. The `:not()` exclusion is required — the nav renders inside `#page-content` and that id's specificity out-ranks `.softr-topbar` rules, so a blanket clear silently flattens the dropdown panel. Full recipe in [native-chrome-styling.md](native-chrome-styling.md#page-background). **With Softr's sidebar or phone tab bar**, don't use that blanket `div` clear: `.softr-sidebar` is a div inside `#page-content` that paints the theme colour, so the clear would strip it too (inferred from the measured DOM, not injected), and it never reaches native blocks' `<section>`s. Name the layers instead, scoped to pages with navigation by `:has(.softr-sidebar, .softr-bottombar)` — see [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout) |
|
|
92
|
+
| On an app page inside a header-painted frame: keeping Softr's `container` / `content` wrappers, a padded outer wrapper, and a rounded panel with its own background (`rounded-[20px] p-8` on the brand surface, or a gradient) | Users read it as cards floating on white: "building blocks on top of each other" (Leo, 2026-10-05, asking for one full-width application instead). Make the block **full-bleed and transparent**: no `container` / `content` wrappers (their gutters step on the WINDOW), no background on any wrapper, and one `@container` shell whose gutters follow the block's width — the shell, its measured paddings and the placement comment are in [SKILL.md → App pages beside Softr navigation](../SKILL.md#app-pages-beside-softr-navigation). Record the full-bleed choice in the `// BLOCK PLACEMENT:` comment and use the same shell on every app page, so the page header lands in the same place. The header code paints the frame and the sheet; the block still paints its own cards and borders. |
|
|
93
|
+
| Window breakpoints (`sm:` / `md:` / `lg:`) for the layout of a block that sits beside Softr's sidebar | The sidebar takes 57px (collapsed) to 360px (dragged; 280px by default) of the window, so the block is far narrower than the window: 744px at a 1024px window, 488px at 768 with the sidebar open. `lg:` still fires at 1024 — measured 2026-10-05: a squeezed five-column chart row with cut-off labels. Lay out by the block's own width: `@container` on a root wrapper plus Tailwind v4 container variants (`@min-[52rem]:grid-cols-4`), which Softr's Tailwind 4.1.13 compiles (verified live 2026-10-05). A container query resolves against the nearest ANCESTOR container, never the element itself, so `@container` goes on a wrapper. For decisions CSS can't make, measure the block in JS — see [common-patterns.md → Measure the block, not the window](common-patterns.md#measure-the-block-not-the-window) |
|
|
94
|
+
| `sticky top-4` (any `top-N`) on an element inside a block, on a page with Softr's top bar | Softr's `#topbar-root` is itself sticky (top 0, z-index 800, 56px tall), so the block's sticky element slides under it. Offset by the nav height the block host exposes, as an inline style: `style={{ top: "calc(var(--nav-height, 0px) + 16px)", maxHeight: "calc(100dvh - var(--nav-height, 0px) - 32px)" }}` (verified 2026-10-05: the list pane stuck at 72px). `--nav-height` is 56px with the top bar and falls back to 0px on phones, where the tab bar is `--bottombar-height`. JS that scrolls the window or fits a popover has to keep the bars clear too (inferred). See [common-patterns.md → Clear Softr's sticky bars](common-patterns.md#clear-softrs-sticky-bars) |
|
|
95
|
+
| Reading the navigation variables in JS — `parseFloat(getComputedStyle(host).getPropertyValue("--bottombar-height"))` | They are unregistered custom properties, so JS gets the token string, not a length: on phones `--bottombar-height` reads `calc(0px + 55px)` (measured 2026-10-05). `parseFloat` of that is NaN (inferred, not run), and a fallback to 0 then hides the tab bar from the code. Use them only inside CSS `calc()`. Where JS needs a number (scroll insets, the room a popover has), take the bars' heights as constants (56px top bar; about 57px phone tab bar as rendered, though Softr's variable says 55px; switching at a 768px window) — see [common-patterns.md → Clear Softr's sticky bars](common-patterns.md#clear-softrs-sticky-bars). The shipped block's 72px (a bar plus about 16px) is a project choice, not a rule |
|
|
90
96
|
| `document.getElementById(...)` / `document.querySelector(...)` to find an element inside the block — for example, a hidden `<input type="file">` triggered by a visible "Upload" button via `getElementById('myInput').click()` | Vibe Coding blocks render inside a shadow DOM. The global `document` traversal stops at the shadow boundary, so id/selector lookups for elements inside the block return `null`. The user-visible symptom is a control that does nothing — no error, no file picker, no focus, no scroll — because the chained `.click()` / `.focus()` / `.scrollIntoView()` was called on `null`. Use a **React `useRef`** instead: `var inputRef = useRef(null)`, then `<input ref={inputRef} />` and `<button onClick={function() { if (inputRef.current) inputRef.current.click(); }}>`. Refs hold direct node references and don't depend on DOM traversal, so they work regardless of which DOM tree the node lives in. This applies to every "trigger a hidden element" pattern: hidden file inputs, programmatic focus, scroll-into-view, `.click()` on a non-visible button. |
|
|
91
97
|
| Inlining brand hexes at every point of use (the signature failure of Studio-AI-generated styling) | Hoist the palette to module-scope constants — `const BRAND = { terracotta: "#B4603D", ink: "#211C18" }` — and reference those. Inlined hexes drift into near-duplicates: observed live 2026-08 in one Studio-emitted hero, `#AE5E3D` vs `#B4603D` for the same terracotta and three near-identical near-blacks. The literals-only static-analysis rule applies ONLY to `datasource.define()` / `q.select()` / data-hook options — style objects and JS expressions use constants freely (the BRAND_INK/BRAND_CANVAS rows above and helper-blocks.md's `BRAND_PRIMARY` are existing house precedent). Constants reach the DOM via inline `style` or by choosing between STATIC class strings — never template-interpolated into arbitrary classes (`` bg-[${BRAND.x}] ``): Tailwind's JIT extracts classes by static source scan (standard-Tailwind inference, not Softr-verified) |
|
|
92
98
|
| Using `window.addEventListener("beforeunload", ...)` as the only unsaved-changes guard in a form block | Softr is a SPA. Internal nav (Softr's nav bar, sidebar links, `<NavigationAction>`) changes the route via the client-side router — `beforeunload` only fires on full page unload (tab close, refresh, external link), so the warning silently misses every in-app navigation. Use `useNavigationBlocker(isDirty)` from `@/lib/use-navigation-blocker` instead; it covers SPA nav AND browser unload with one API. Softr's Vibe Coding bundler often wires this automatically when a form is detected as dirty — you only need to add it manually for advanced cases (multi-step forms, custom dirty tracking, blocking on non-form state). See [common-patterns.md](common-patterns.md#navigation-blocker-for-unsaved-changes). |
|
|
93
|
-
| Targeting Softr's hashed build classes (e.g. `.f8f11e5_m9ntthp`) when restyling the native header/nav from `custom-code-header.html` | Softr regenerates the hash on every deploy, so the rule silently dies. Target stable hooks: `.softr-topbar`, `.softr-nav-link`, `.softr-nav-button`, `.softr-nav-logo`, `#topbar-root`; for dropdown menus (no `softr-*` class) use the Radix/ARIA attrs `[role="menu"]` / `[role="menuitem"]` / `[role="group"]` / `[aria-expanded="true"]`, scoped under `.softr-topbar`. The native header is Softr chrome (main document), not a block — it can't be built as a Vibe Coding block. (A landing page with the native header HIDDEN may instead ship a block-owned in-block header — see [static-blocks.md](static-blocks.md#block-owned-landing-page-header); that's a different pattern, not a rebuild of native chrome.) See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
99
|
+
| Targeting Softr's hashed build classes (e.g. `.f8f11e5_m9ntthp` in June 2026; the navigation block's prefix was `_5f91d6c_` by October) when restyling the native header/nav from `custom-code-header.html` | Softr regenerates the hash on every deploy, so the rule silently dies. Target stable hooks: `.softr-topbar`, `.softr-nav-link`, `.softr-nav-button`, `.softr-nav-logo`, `#topbar-root`; for dropdown menus (no `softr-*` class) use the Radix/ARIA attrs `[role="menu"]` / `[role="menuitem"]` / `[role="group"]` / `[aria-expanded="true"]`, scoped under `.softr-topbar`. The native header is Softr chrome (main document), not a block — it can't be built as a Vibe Coding block. (A landing page with the native header HIDDEN may instead ship a block-owned in-block header — see [static-blocks.md](static-blocks.md#block-owned-landing-page-header); that's a different pattern, not a rebuild of native chrome.) See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
100
|
+
| Taking the frame colour from Softr's hashed theme variables (`var(--_5f91d6c_vnohg20)`) or hashed classes in header CSS | The hash is set per native block package, not once per app — `_5f91d6c_` on the navigation block, `_03ef538_` on the user-accounts block (seen 2026-10-05) — and the theme variables carry the same prefix, so a rule that names one breaks when that package changes. Copy the Studio theme colour of the top bar and sidebar into your own token, e.g. `--app-frame: #A85935`, with a comment saying to update it when the theme changes. Copy the THEME colour (Studio's theme settings, or the rendered sidebar's computed background), not the brand primary from DESIGN.md: they differed (theme `#A85935`, DESIGN.md primary `#B4532A`; measured 2026-10-05). See [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout) |
|
|
94
101
|
| Softr nav dropdown panel shows a tall blank gap below the items, and `height: auto` won't shrink it | The items sit in a CSS grid Softr sets to `grid-auto-flow: column` with pre-sized empty row tracks (`grid-template-rows: 60px 60px…`). Override the flow on `.softr-topbar [role="menu"] [role="group"]`: `grid-auto-flow: row !important; grid-template-rows: none !important; grid-auto-rows: auto !important` (leave `grid-template-columns` to preserve the menu width). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
95
|
-
| Setting the page background on `body` (or any single element) — it appears to do nothing | Softr paints the
|
|
102
|
+
| Setting the page background on `body` (or any single element) — it appears to do nothing | Softr paints the page fill on several stacked layers, so styling one gets covered: `html`, `body`, `#page-content`, each Vibe block's host and each native block's outer `<section>` (measured 2026-10-05; the layer list is in the `html, body` row above). **Top bar only:** paint your backdrop on `html`, then clear the duplicates above it: `body`, `#page-content`, and `#page-content div` — but EXCLUDE the header subtree with `:not(.softr-topbar):not(.softr-topbar *)` (it renders inside `#page-content`, and `#page-content`'s id specificity would otherwise flatten the dropdown panel). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md#page-background). **With Softr's sidebar,** that `div` clear would also strip `.softr-sidebar`'s fill (inferred) and never reaches native `<section>`s: use the scoped rules in [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout). |
|
|
96
103
|
|
|
97
104
|
## Printing
|
|
98
105
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
How to check a deployed block's rendering and behaviour in a Softr preview with the
|
|
4
4
|
[agent-browser](https://github.com/vercel-labs/agent-browser) CLI. **Verified 2026-10-01** with
|
|
5
5
|
agent-browser v0.38.1 on macOS (Node 22) against a real Softr preview; only the commands under
|
|
6
|
-
[Untested but promising](#untested-but-promising) were not run.
|
|
6
|
+
[Untested but promising](#untested-but-promising) were not run. [Testing Custom Code header
|
|
7
|
+
CSS](#testing-custom-code-header-css) was verified 2026-10-05, except where it says otherwise.
|
|
7
8
|
|
|
8
9
|
## When to use it
|
|
9
10
|
|
|
@@ -156,6 +157,160 @@ ab close # ✓ Browser closed (no process left beh
|
|
|
156
157
|
Give a path; without one, it writes to a temp directory. A saved screenshot costs no tokens until
|
|
157
158
|
someone opens it; one shown inline costs about 1.5k. Left alone, the daemon exits after an hour idle.
|
|
158
159
|
|
|
160
|
+
## Testing Custom Code header CSS
|
|
161
|
+
|
|
162
|
+
CSS in **Settings → Custom Code → Code inside header** applies to every page of the app, and the
|
|
163
|
+
builder usually pastes it, not you. So test it in the preview before it is pasted, then prove what
|
|
164
|
+
went live. **Verified 2026-10-05** on one app with Softr's top bar and sidebar, using the app-frame
|
|
165
|
+
code in [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout)
|
|
166
|
+
with its Vibe-host rule in the unscoped form (see step 4), with agent-browser and the desktop app's
|
|
167
|
+
Browser pane; anything else is marked.
|
|
168
|
+
|
|
169
|
+
**Where header code shows:** on the published app, and in the preview: after a paste and a publish,
|
|
170
|
+
a fresh preview load applied it with nothing injected (verified 2026-10-05). Whether the preview
|
|
171
|
+
shows header code that is pasted but not yet published is untested. The Studio editor canvas is not
|
|
172
|
+
a test surface: header code is not known to render there.
|
|
173
|
+
|
|
174
|
+
### 1. Before pasting: inject it into the preview
|
|
175
|
+
|
|
176
|
+
Open the page as in [step 1](#1-session-preview-cookie-page), as the user whose navigation you are
|
|
177
|
+
styling: on the preview origin run `fetch('/studio/impersonate/<softrUserId>')`, then open the page
|
|
178
|
+
again ([how](softr-mcp.md#testing-as-any-app-user-without-logins--the-preview-as-switcher)). Then
|
|
179
|
+
inject the file exactly as it will be pasted, tagged so that a re-run replaces it:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
node -e '
|
|
183
|
+
const h = require("fs").readFileSync("custom-code-header.html", "utf8");
|
|
184
|
+
process.stdout.write(`(() => {
|
|
185
|
+
document.querySelectorAll("[data-hdr-test]").forEach(n => n.remove());
|
|
186
|
+
const t = document.createElement("template"); t.innerHTML = ${JSON.stringify(h)};
|
|
187
|
+
for (const n of [...t.content.children]) { n.setAttribute("data-hdr-test", ""); document.head.appendChild(n); }
|
|
188
|
+
return "injected";
|
|
189
|
+
})()`);' > inject.js
|
|
190
|
+
ab eval --stdin < inject.js # "injected"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- The injected copy lasts until the next load: inject again after every `open` or reload. A width
|
|
194
|
+
change keeps it.
|
|
195
|
+
- This tests the `<link>` and `<style>` parts. A `<script>` in the header is out of scope.
|
|
196
|
+
- If an older version is already live, the preview carries it too and the injected copy only adds
|
|
197
|
+
to it, so a rule you deleted still applies. Remove the live `<style>` first, found by a token
|
|
198
|
+
only it contains (inferred, not run).
|
|
199
|
+
- A Browser pane opened on the preview link shows the toolbar shell, with the app in the
|
|
200
|
+
same-origin `#preview-iframe`. Open the direct page URL instead, so that `document` is the app's.
|
|
201
|
+
|
|
202
|
+
### 2. Measure; screenshots are the extra
|
|
203
|
+
|
|
204
|
+
Computed values answer the question; a screenshot only illustrates it. A hidden pane times out on
|
|
205
|
+
screenshots ([why](#tool-choice-and-why)) but measures fine, so measure first and take any
|
|
206
|
+
screenshot with agent-browser, to disk. Save this as `measure.js`:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
(() => {
|
|
210
|
+
const q = s => document.querySelector(s), bg = e => e ? getComputedStyle(e).backgroundColor : 'none';
|
|
211
|
+
const main = q('#main-content'), sr = q('#sidebar-root'), a = sr ? getComputedStyle(sr, '::after') : null;
|
|
212
|
+
return JSON.stringify({
|
|
213
|
+
w: innerWidth, sidebar: !!q('.softr-sidebar'), tabBar: !!q('.softr-bottombar'),
|
|
214
|
+
html: bg(document.documentElement), body: bg(document.body), page: bg(q('#page-content')),
|
|
215
|
+
main: bg(main), radius: main ? getComputedStyle(main).borderTopLeftRadius : 'none',
|
|
216
|
+
host: bg(q('#main-content [data-role="vibe-block-root"]')),
|
|
217
|
+
corner: a ? a.content + ' @ ' + a.left : 'none',
|
|
218
|
+
overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
219
|
+
});
|
|
220
|
+
})()
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
What the verified live code gave (2026-10-05; its Vibe-host rule was the unscoped
|
|
224
|
+
`#main-content [data-role="vibe-block-root"]`, and the recipe's scoped form is untested):
|
|
225
|
+
|
|
226
|
+
| Window | Navigation | `html`, `body`, `#page-content` | `#main-content` | `corner` |
|
|
227
|
+
|---|---|---|---|---|
|
|
228
|
+
| 768px and wider | top bar + sidebar | frame colour | sheet colour, 24px radius | `"" @ 280px` open, `"" @ 57px` collapsed |
|
|
229
|
+
| 767px and narrower | phone tab bar | sheet colour | transparent (`rgba(0, 0, 0, 0)`), 0px radius; the paper is on `html`, `body` and `#page-content` | `none @ auto` |
|
|
230
|
+
|
|
231
|
+
At the widths checked for it (1440, 1024 and 390/375) the Vibe host was `rgba(0, 0, 0, 0)` and
|
|
232
|
+
there was no sideways scroll. On phones `#sidebar-root` is still in the DOM, empty, 0px wide and
|
|
233
|
+
`position: static` (not sticky), which is why the corner rule is guarded with
|
|
234
|
+
`:has(.softr-sidebar)`. Without the guard the `::after` still renders there and is placed against
|
|
235
|
+
the page: likely off the right edge, adding sideways scroll (seen in a mock, not on Softr).
|
|
236
|
+
|
|
237
|
+
### 3. The width sweep
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
for w in 1440 1280 1024 900 768 767 390; do ab set viewport $w 900; ab wait 1200; ab eval --stdin < measure.js; done
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Then collapse the sidebar (the top bar's "Toggle sidebar" button, a ref from `snapshot -i`; it
|
|
244
|
+
writes nothing) and measure 1024 and 768 again.
|
|
245
|
+
|
|
246
|
+
- **767 / 768 is Softr's switch, to the pixel:** 767 gives the phone tab bar, 768 the top bar and
|
|
247
|
+
sidebar. At 768 with the sidebar open, a block gets 488px.
|
|
248
|
+
- **A plain width change switches the layout live.** `ab set viewport` alone moved between sidebar
|
|
249
|
+
and tab bar; no reload needed.
|
|
250
|
+
- **Reload after leaving a mobile-device emulation.** A pane loaded under a mobile preset (an
|
|
251
|
+
Android user agent and touch points, not just a width) kept the phone layout when widened to
|
|
252
|
+
1440, until a reload (seen 2026-10-05; most likely a device check at load, inferred).
|
|
253
|
+
- **The collapsed sidebar stayed collapsed** at later widths in one headless run (seen once):
|
|
254
|
+
open it again, or expect 57px.
|
|
255
|
+
|
|
256
|
+
### 4. Pages without navigation
|
|
257
|
+
|
|
258
|
+
The recipe scopes its rules with `:has(.softr-sidebar, .softr-bottombar)`, so pages without Softr's
|
|
259
|
+
navigation (log in, sign up, 404) should keep Softr's own colours.
|
|
260
|
+
|
|
261
|
+
- **Before pasting:** inject on a 404 page in the preview (any path that does not exist): `html`
|
|
262
|
+
and `body` stayed rgb(255,255,255). A logged-in preview sends `/login` to the home page, so
|
|
263
|
+
`/login` cannot be checked there.
|
|
264
|
+
- **Once pasted:** open `/login` and a 404 page on the published app, logged out. With the code
|
|
265
|
+
live, `html`, `body` and `#page-content` stayed white on both, with no top bar, sidebar or tab
|
|
266
|
+
bar (verified 2026-10-05).
|
|
267
|
+
- **The Vibe-host rule depends on which form you have.** In the verified live code it was unscoped
|
|
268
|
+
(`#main-content [data-role="vibe-block-root"]`) and applied on these pages too; on a page without
|
|
269
|
+
navigation that holds a Vibe block it is probably invisible, because the page behind the block
|
|
270
|
+
is the same theme colour (inferred). The recipe scopes it with `:has(.softr-sidebar,
|
|
271
|
+
.softr-bottombar)`, so nothing should apply there (untested as written). Either way, no page
|
|
272
|
+
without navigation but with a Vibe block was tested: on one, check that the host keeps the
|
|
273
|
+
theme white.
|
|
274
|
+
|
|
275
|
+
### 5. After the paste: prove what is live
|
|
276
|
+
|
|
277
|
+
**A publish publishes everything.** Header code reaches the published app with a publish, and a
|
|
278
|
+
publish also pushes every unpublished page live (seen 2026-10-05: unfinished pages went live with a
|
|
279
|
+
header-code publish). Before asking anyone to publish header code, check what else is unpublished,
|
|
280
|
+
and say so in the ask.
|
|
281
|
+
|
|
282
|
+
Then fetch the published page and pull the code out. Softr carries it in an inline script as
|
|
283
|
+
`appCustomHeaderCode: "…"`, an unquoted key inside `SoftrPageRenderer.render({…})`: JavaScript, not
|
|
284
|
+
JSON, so read the string literal rather than parsing the object. `json.loads` read Softr's string on
|
|
285
|
+
2026-10-05:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
curl -sL 'https://<subdomain>.softr.app/' -o pub.html
|
|
289
|
+
python3 - <<'EOF'
|
|
290
|
+
import json, re
|
|
291
|
+
s = open('pub.html').read()
|
|
292
|
+
m = re.search(r'appCustomHeaderCode:\s*("(?:[^"\\]|\\.)*")', s)
|
|
293
|
+
if not m: raise SystemExit('appCustomHeaderCode not found: wrong page, a redirect or an empty body; fetch / again')
|
|
294
|
+
live = json.loads(m.group(1))
|
|
295
|
+
mine = open('custom-code-header.html').read()
|
|
296
|
+
rules = lambda t: re.sub(r'\s+', '', re.sub(r'<!--.*?-->|/\*.*?\*/', '', t, flags=re.S))
|
|
297
|
+
print(len(live), 'bytes live;', 'rules match' if rules(live) == rules(mine) else 'RULES DIFFER')
|
|
298
|
+
EOF
|
|
299
|
+
# 1516 bytes live; rules match
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- **Compare the rules, not the text.** The pasted copy can lose or shorten comments; it did on
|
|
303
|
+
2026-10-05, and the rules still matched.
|
|
304
|
+
- **`pageCustomHeaderCode`** is the page-level header code, and `appCustomFooterCode` /
|
|
305
|
+
`pageCustomFooterCode` are the footers: check that they are empty, or hold what you expect.
|
|
306
|
+
- **The page source opens with `<!-- Last Published: … -->`.** Check that it moved, so you are not
|
|
307
|
+
reading the previous publish.
|
|
308
|
+
- The key is in every page's source, logged out too: it was the same on Home, `/login` and a 404
|
|
309
|
+
page.
|
|
310
|
+
|
|
311
|
+
Then run the sweep again in a fresh preview with nothing injected, and the pages without navigation
|
|
312
|
+
on the published app, logged out.
|
|
313
|
+
|
|
159
314
|
## Gotchas
|
|
160
315
|
|
|
161
316
|
- **zsh does not word-split.** `AB="agent-browser --session x"; $AB open …` fails with "command not
|
|
@@ -17,6 +17,9 @@ Small reusable patterns that come up across Vibe Coding blocks but don't warrant
|
|
|
17
17
|
- [Drag-to-Reorder Rows](#drag-to-reorder-rows)
|
|
18
18
|
- [Create → open](#create--open)
|
|
19
19
|
- [Clickable Row with an Inner Link](#clickable-row-with-an-inner-link)
|
|
20
|
+
- [Measure the block, not the window](#measure-the-block-not-the-window)
|
|
21
|
+
- [Clear Softr's sticky bars](#clear-softrs-sticky-bars)
|
|
22
|
+
- [A modal above Softr's bars](#a-modal-above-softrs-bars)
|
|
20
23
|
|
|
21
24
|
## Cross-Page State with localStorage + URL Parameters
|
|
22
25
|
|
|
@@ -515,4 +518,223 @@ function onKeyDown(e) {
|
|
|
515
518
|
|
|
516
519
|
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
520
|
|
|
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.
|
|
521
|
+
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. On a page with Softr's top bar, a row scrolled in from above the window can land under the bar: give the rows a `scroll-margin-top` or scroll the window yourself, as in [Clear Softr's sticky bars](#clear-softrs-sticky-bars). 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.
|
|
522
|
+
|
|
523
|
+
## Measure the block, not the window
|
|
524
|
+
|
|
525
|
+
Beside Softr's sidebar navigation, the window over-reports the block's width by the width of the sidebar: 280px by default, 57px collapsed, 200 to 360px when dragged. Lay the block out by its own width. CSS container queries do most of it (`@container` on a wrapper, `@min-[NNrem]:` on what is inside it; see [ui-ux-guidelines.md → Breakpoint strategy](../ui-ux-guidelines.md#breakpoint-strategy)), so reach for CSS first. When a decision can't be made in CSS, measure the block in JS. Typical cases: rendering a different tree (list and detail side by side, or a phone flow with its own back control), or choosing how many chart ticks to draw.
|
|
526
|
+
|
|
527
|
+
```tsx
|
|
528
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
529
|
+
|
|
530
|
+
// Module scope, like any hook or component. The block's own width: the space Softr gives it.
|
|
531
|
+
function useElementWidth(ref: { current: HTMLElement | null }) {
|
|
532
|
+
const [width, setWidth] = useState<number>(() => (typeof window !== "undefined" ? window.innerWidth : 1200));
|
|
533
|
+
// useLayoutEffect, not useEffect: measured before the first paint, so no frame is laid out at the window's width.
|
|
534
|
+
useLayoutEffect(() => {
|
|
535
|
+
const el = ref.current;
|
|
536
|
+
if (!el) return;
|
|
537
|
+
const update = () => setWidth(el.getBoundingClientRect().width);
|
|
538
|
+
update();
|
|
539
|
+
if (typeof ResizeObserver === "undefined") {
|
|
540
|
+
window.addEventListener("resize", update);
|
|
541
|
+
return () => window.removeEventListener("resize", update);
|
|
542
|
+
}
|
|
543
|
+
const ro = new ResizeObserver(update);
|
|
544
|
+
ro.observe(el);
|
|
545
|
+
return () => ro.disconnect();
|
|
546
|
+
}, []);
|
|
547
|
+
return width;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export default function Block() {
|
|
551
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
552
|
+
const width = useElementWidth(rootRef);
|
|
553
|
+
const wide = width >= 860; // 340px list + 20px gap + at least 440px of detail + padding
|
|
554
|
+
return (
|
|
555
|
+
<div ref={rootRef} className="@container">
|
|
556
|
+
{wide ? <ListAndDetail /> : <PhoneFlow />}
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
- **`useLayoutEffect`, not `useEffect`.** A passive `useEffect` runs after the browser paints, so the first frame is laid out with the initial guess: the window's width. Beside a sidebar that guess is out by up to 360px, and the layout visibly flips. At a 1200px window with a 360px sidebar the block is 840px, but the first frame would paint the two-column skeleton and then switch to one column. `useLayoutEffect` measures and re-renders before the first paint. (A code-review finding, 2026-10-05; the fix shipped in two blocks.)
|
|
563
|
+
- **Put the ref on the block's outer wrapper, and render that wrapper in every state, loading included.** The effect runs once, so a ref that attaches only after the data loads is never observed. Measure the wrapper, not an element whose width depends on the decision the width drives.
|
|
564
|
+
- **Keep padding off the `@container` element** when CSS and JS both switch on width. Container queries read its content box and `getBoundingClientRect()` reads its border box. With no padding or border they are the same number, so `@min-[860px]:` and `width >= 860` agree.
|
|
565
|
+
- The window `resize` listener is only the fallback for a browser without `ResizeObserver`. The observer also sees what a resize event never reports: the sidebar collapsing or being dragged while the window stays the same size.
|
|
566
|
+
- Thresholds one app uses: a chart labels every other month below a 640px block, and list and detail sit side by side from an 860px block.
|
|
567
|
+
|
|
568
|
+
## Clear Softr's sticky bars
|
|
569
|
+
|
|
570
|
+
On a page with Softr navigation, Softr's bars live in the main document, outside the block, and the page scrolls under them (measured live 2026-10-05):
|
|
571
|
+
|
|
572
|
+
| Bar | Shows at | Element | Height | Position |
|
|
573
|
+
|---|---|---|---|---|
|
|
574
|
+
| Top bar | a window of 768px and up | `#topbar-root` | 56px | sticky, top 0, z-index 800 |
|
|
575
|
+
| Phone tab bar | a window below 768px | `#bottombar-root` | 57px rendered (`#bottombar-root` and its `ul` both measured 57px; Softr's variable says 55px) | sticky in the page grid's bottom row (not `fixed`), z-index 800 |
|
|
576
|
+
|
|
577
|
+
The block host hands their sizes to block CSS. `--nav-height` is 56px with the top bar; on phones Softr leaves its own variable empty and the host's fallback gives 0px. `--bottombar-height` is `calc(0px + 55px)` on phones (2px short of the rendered bar) and 0px otherwise. `--sidebar-width` is 280px with the sidebar open, 57px collapsed and 0px on phones. The host maps them from Softr's `:root` variables `--sticky-nav-height`, `--softr-bottombar-height` and `--softr-sidebar-width`, each with a `0px` fallback (all measured live 2026-10-05). The variable table is in [quick-reference.md → Softr navigation variables](quick-reference.md#softr-navigation-variables); the whole page layout is in [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout).
|
|
578
|
+
|
|
579
|
+
**Sticky elements inside a block.** A `sticky top-4` slides under the top bar. Offset it by the bar, and cap a sticky pane so its foot stays on screen:
|
|
580
|
+
|
|
581
|
+
```tsx
|
|
582
|
+
<section
|
|
583
|
+
className="sticky flex flex-col"
|
|
584
|
+
style={{
|
|
585
|
+
top: "calc(var(--nav-height, 0px) + 16px)",
|
|
586
|
+
maxHeight: "calc(100dvh - var(--nav-height, 0px) - 32px)", // 16px of air above and below
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
Measured live 2026-10-05 in the preview at a 1440px window: the pane's top sat at 72px (56 + 16). The mirror image for a phone, `bottom: calc(var(--bottombar-height, 0px) + 16px)`, is untested.
|
|
592
|
+
|
|
593
|
+
**Scripted window scrolls and room checks.** JS sees the window, not the bars. Code that scrolls the window to bring something into view, or asks whether a popover has room above or below, must take the top bar (56px) off the top edge and, on phones, the tab bar (57px as rendered) off the bottom edge. Softr switches its navigation on the window width, so here the window is the right thing to test:
|
|
594
|
+
|
|
595
|
+
```tsx
|
|
596
|
+
const TOP_BAR = 56; // Softr's sticky top bar, window 768px and up
|
|
597
|
+
const TAB_BAR = 57; // Softr's phone tab bar, window below 768px: measured 57px; --softr-bottombar-height says 55px
|
|
598
|
+
const AIR = 16;
|
|
599
|
+
|
|
600
|
+
// The strip of the window that Softr's bars leave visible.
|
|
601
|
+
function visibleStrip() {
|
|
602
|
+
const phone = window.innerWidth < 768; // 767px = tab bar, 768px = top bar + sidebar
|
|
603
|
+
return {
|
|
604
|
+
top: (phone ? 0 : TOP_BAR) + AIR,
|
|
605
|
+
bottom: window.innerHeight - (phone ? TAB_BAR : 0) - AIR,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function keepInView(el: HTMLElement) {
|
|
610
|
+
const { top, bottom } = visibleStrip();
|
|
611
|
+
const r = el.getBoundingClientRect();
|
|
612
|
+
if (r.top < top) window.scrollBy(0, r.top - top);
|
|
613
|
+
else if (r.bottom > bottom) window.scrollBy(0, r.bottom - bottom);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// It issues a window scroll, so call it as setTimeout(() => keepInView(el), 0) (Hard Constraint 17).
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
The project this comes from hard-coded 72px (a bar plus 16px) at the top, and at the bottom wherever a tab bar could be. That number is a project choice; subtracting the bars is the rule. `visibleStrip` generalises it and is untested as written. A confirm strip that opens below a row, or a drop-up test, uses the same strip in place of `0` and `window.innerHeight`. For the dropdown, see [searchable-dropdown.md → rule 2](searchable-dropdown.md#the-four-things-that-will-bite-you).
|
|
620
|
+
|
|
621
|
+
**Read the host variables only inside CSS `calc()`.** They are unregistered custom properties, so in JS `getComputedStyle(el).getPropertyValue(...)` returns the token that was set, not a length. `--nav-height` reads `56px` on desktop, but `--bottombar-height` reads `calc(0px + 55px)` on phones (both measured live 2026-10-05). `parseFloat` turns that into `NaN` (inferred, not run in a block), and a `|| 0` fallback would then scroll content under the tab bar without a sound. In CSS both forms work. In JS, use the bar heights above.
|
|
622
|
+
|
|
623
|
+
**Fragment jumps are offset; inner scrolls are not.** Softr's page CSS gives every block's outer wrapper (`div[data-block]`, with a page-assigned id such as `ai1`; the Vibe host sits two levels inside it) `#main-content [data-block] { scroll-margin-top: var(--sticky-nav-height, 0px) }` (the rule was read from Softr's live page CSS on 2026-10-05; the jump itself is untested), so a URL fragment that targets that wrapper lands below the top bar. Nothing offsets a scroll to an element *inside* the block: `scrollIntoView` on a row or a section can put it at the window's top edge, under the bar. Give the target `scroll-margin-top: calc(var(--nav-height, 0px) + 16px)`, which `scrollIntoView` honours (untested in a block), or scroll the window with `keepInView`. A fragment can't reach inside the shadow root in the first place; see [static-blocks.md → Section anchors](static-blocks.md#section-anchors-on-landing-pages).
|
|
624
|
+
|
|
625
|
+
## A modal above Softr's bars
|
|
626
|
+
|
|
627
|
+
On an app page with Softr's navigation, shadcn's `Dialog` and `Sheet` don't work cleanly. Two things go wrong:
|
|
628
|
+
|
|
629
|
+
- **It sits under the top bar.** shadcn's overlay is `z-50`; Softr's sticky `#topbar-root` is z-index 800. The top bar stays undimmed and clickable, and a tall modal slides under it. Measured live 2026-10-06 in the preview at 1440×900 with the top bar and sidebar: a `position: fixed` layer inside a block's shadow root at z-index 50 lost to the top bar (`document.elementFromPoint` inside the bar returned the bar); at z-index 801 and at 1000 the same layer covered the top bar and the sidebar. A walk up from the block host to `<html>` found no stacking context (every ancestor static / `auto`, no `transform`, `contain` or `container-type`), so a block's z-index competes directly with Softr's bars.
|
|
630
|
+
- **It leaves the shadow root.** Radix portals the dialog to `document.body`, outside the block's shadow root, and the block's styles stay behind — the same reason shadcn `<Select>` is out ([searchable-dropdown.md](searchable-dropdown.md)).
|
|
631
|
+
|
|
632
|
+
Render the modal in the block's own DOM instead. This is the reusable core of the record modal that shipped in the Partner Spotlight review block on 2026-10-06:
|
|
633
|
+
|
|
634
|
+
```tsx
|
|
635
|
+
import { useEffect, useRef } from "react";
|
|
636
|
+
import { X } from "lucide-react";
|
|
637
|
+
|
|
638
|
+
const FOCUSABLE =
|
|
639
|
+
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])';
|
|
640
|
+
|
|
641
|
+
// Module scope. onDismiss must itself refuse while a save runs (Escape and the backdrop call it too).
|
|
642
|
+
function InBlockModal({ labelledBy, describedBy, onDismiss, dismissDisabled, children }: {
|
|
643
|
+
labelledBy: string; describedBy?: string; onDismiss: () => void; dismissDisabled?: boolean; children: React.ReactNode;
|
|
644
|
+
}) {
|
|
645
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
646
|
+
const dismissRef = useRef(onDismiss); // the latest handler; the listener is attached once per opening
|
|
647
|
+
dismissRef.current = onDismiss;
|
|
648
|
+
|
|
649
|
+
useEffect(() => {
|
|
650
|
+
const panel = panelRef.current;
|
|
651
|
+
// Inside a shadow root document.activeElement is the block's host; the root knows the real element.
|
|
652
|
+
const root: any = panel ? panel.getRootNode() : document;
|
|
653
|
+
const opener = (root.activeElement as HTMLElement | null) || null;
|
|
654
|
+
|
|
655
|
+
// Lock the page behind the modal; the stable gutter stops a sideways jump where scrollbars take space.
|
|
656
|
+
const html = document.documentElement;
|
|
657
|
+
const prevOverflow = html.style.overflow;
|
|
658
|
+
const prevGutter = html.style.getPropertyValue("scrollbar-gutter");
|
|
659
|
+
html.style.overflow = "hidden";
|
|
660
|
+
html.style.setProperty("scrollbar-gutter", "stable");
|
|
661
|
+
const focusTimer = window.setTimeout(() => panel?.focus({ preventScroll: true }), 0);
|
|
662
|
+
|
|
663
|
+
const onKey = (e: KeyboardEvent) => {
|
|
664
|
+
if (e.defaultPrevented || e.isComposing) return;
|
|
665
|
+
if (e.key === "Escape") { e.preventDefault(); dismissRef.current(); return; }
|
|
666
|
+
if (e.key !== "Tab" || !panel) return;
|
|
667
|
+
// Visible controls only: getClientRects() is empty for anything display: none.
|
|
668
|
+
const items = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.getClientRects().length > 0);
|
|
669
|
+
if (!items.length) { e.preventDefault(); panel.focus(); return; }
|
|
670
|
+
const current = root.activeElement as HTMLElement | null;
|
|
671
|
+
const inside = !!current && current !== panel && panel.contains(current);
|
|
672
|
+
const first = items[0], last = items[items.length - 1];
|
|
673
|
+
if (e.shiftKey && (!inside || current === first)) { e.preventDefault(); last.focus(); }
|
|
674
|
+
else if (!e.shiftKey && (!inside || current === last)) { e.preventDefault(); first.focus(); }
|
|
675
|
+
};
|
|
676
|
+
document.addEventListener("keydown", onKey);
|
|
677
|
+
|
|
678
|
+
return () => {
|
|
679
|
+
window.clearTimeout(focusTimer);
|
|
680
|
+
document.removeEventListener("keydown", onKey);
|
|
681
|
+
html.style.overflow = prevOverflow;
|
|
682
|
+
if (prevGutter) html.style.setProperty("scrollbar-gutter", prevGutter);
|
|
683
|
+
else html.style.removeProperty("scrollbar-gutter");
|
|
684
|
+
if (opener && opener !== panel && opener.isConnected) opener.focus({ preventScroll: true });
|
|
685
|
+
};
|
|
686
|
+
}, []);
|
|
687
|
+
|
|
688
|
+
return (
|
|
689
|
+
// z-[1000]: above Softr's top bar, sidebar and phone tab bar (all z-index 800 or below).
|
|
690
|
+
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-2 sm:p-6">
|
|
691
|
+
<div aria-hidden="true" className="absolute inset-0 bg-gray-950/50" onClick={() => dismissRef.current()} />
|
|
692
|
+
<div
|
|
693
|
+
ref={panelRef}
|
|
694
|
+
role="dialog"
|
|
695
|
+
aria-modal="true"
|
|
696
|
+
aria-labelledby={labelledBy}
|
|
697
|
+
aria-describedby={describedBy}
|
|
698
|
+
tabIndex={-1}
|
|
699
|
+
className="@container relative flex max-h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl outline-none"
|
|
700
|
+
>
|
|
701
|
+
{children}
|
|
702
|
+
<button
|
|
703
|
+
type="button"
|
|
704
|
+
onClick={() => dismissRef.current()}
|
|
705
|
+
disabled={dismissDisabled}
|
|
706
|
+
aria-label="Close"
|
|
707
|
+
className="absolute right-3 top-3 rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:pointer-events-none disabled:opacity-40"
|
|
708
|
+
>
|
|
709
|
+
<X className="h-4 w-4" />
|
|
710
|
+
</button>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export default function Block() {
|
|
717
|
+
// ... state, `active` record, `saving`, a `close` that returns early while saving ...
|
|
718
|
+
return (
|
|
719
|
+
<>
|
|
720
|
+
<div className="@container">{/* the block's page */}</div>
|
|
721
|
+
{/* A sibling of the @container wrapper, not a child of it. */}
|
|
722
|
+
{active && (
|
|
723
|
+
<InBlockModal labelledBy="modal-title" describedBy="modal-desc" onDismiss={close} dismissDisabled={saving}>
|
|
724
|
+
{/* header with id="modal-title" / id="modal-desc"; give it right padding (pr-12) for the X */}
|
|
725
|
+
{/* a scrolling body: min-h-0 flex-1 overflow-y-auto */}
|
|
726
|
+
</InBlockModal>
|
|
727
|
+
)}
|
|
728
|
+
</>
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
- **Outside the block's `@container` wrapper.** `@container` sets `container-type: inline-size`, which brings layout containment, and containment on an ancestor can capture `position: fixed` (it becomes the fixed box's containing block). Render the modal as a sibling of that wrapper, then put `@container` on the panel so everything inside it sizes by the panel. The overlay itself is fixed to the window, so its own padding may use `sm:`.
|
|
734
|
+
- **z-index 1000, not 50.** Anything from 801 up clears the bars; 1000 leaves room. It also keeps Softr's links out of reach while an edit is open.
|
|
735
|
+
- **Focus lives in the shadow root.** Read the focused element from `panel.getRootNode().activeElement`; `document.activeElement` is only the block's host. The opener is captured on open, and focus goes back to it on close if it is still on the page.
|
|
736
|
+
- **Saving.** Disable the X while a save runs, and make the dismiss handler return early while saving, because Escape and the backdrop call the same handler. In the shipped block that handler also asks before discarding unsaved edits.
|
|
737
|
+
- **Scroll lock on `<html>`**, not `body`: the page scroller is the document. Restore both properties exactly as they were.
|
|
738
|
+
- The shipped component also has `animate-in fade-in-0 zoom-in-95` on the panel and `backdrop-blur-[2px]` on the backdrop.
|
|
739
|
+
|
|
740
|
+
**Status (2026-10-06).** Verified live, harness: the shipped component's exact source was bundled with `deno bundle` and mounted into a shadow root under `#main-content` on the live preview page, rendered with Softr's own React 18.2 (`window.__softr_React` / `window.__softr_ReactDOM`), 1440×900 window with top bar and sidebar. Passed: opens with focus on the panel; covers the top bar and the sidebar; the panel centres at 896px; Tab and Shift+Tab wrap both ways and skip hidden controls; Escape and the X do nothing while saving; Escape, the backdrop and the X close it; `overflow` and `scrollbar-gutter` are restored; focus returns to the opener. **Not yet seen:** the full review block opening the modal with real data — there was no Social Media Manager member to preview as. The trimmed version above was not run on its own. Phones (tab bar) untested.
|
package/references/dembrandt.md
CHANGED
|
@@ -73,7 +73,7 @@ dembrandt emits Google's DESIGN.md draft format (spec 0.4) `[official]`: YAML fr
|
|
|
73
73
|
| `rounded` | `sm` / `md` / `lg` / `xl` radii, plus `none` (0) and `full` (pill) when observed `[official, emitter source]` |
|
|
74
74
|
| `components` | `button-observed` / `input-observed` with backgroundColor, textColor, rounded, padding (buttons may add `height`) — values may reference other tokens (`"{rounded.lg}"`) |
|
|
75
75
|
|
|
76
|
-
Body sections in order: `# Design System` → Overview → Colors → Typography → Layout (spacing scale + responsive breakpoints) → Elevation & Depth → Shapes → Components. Two body-only nuggets matter for Softr work `[behavior-tested]`:
|
|
76
|
+
Body sections in order: `# Design System` → Overview → Colors → Typography → Layout (spacing scale + responsive breakpoints, which are the source site's window widths; for a Softr app with a sidebar, restate them as block widths, see below) → Elevation & Depth → Shapes → Components. Two body-only nuggets matter for Softr work `[behavior-tested]`:
|
|
77
77
|
|
|
78
78
|
- **Font URLs** (in the Typography section): direct `.woff2` links to the site's real webfonts. The frontmatter `fontFamily` reports the *computed* value, which can be a generic fallback (`ui-sans-serif`) while the Font URLs reveal the actual brand font — cross-check before declaring the brand font, and use these URLs when authoring page-level `@font-face` CSS.
|
|
79
79
|
- Motion tokens exist in dembrandt's JSON extraction but are **not** part of DESIGN.md `[official]` — don't expect an animation section.
|
|
@@ -82,7 +82,12 @@ Companion-skill note: the `building-design-md` skill (v2+) drives this same demb
|
|
|
82
82
|
|
|
83
83
|
## Authoring `custom-code-header.html` from DESIGN.md
|
|
84
84
|
|
|
85
|
-
dembrandt generates **no Softr-ready CSS** — its only CSS export is a CLI-side Tailwind v4 `@theme` file (`--tailwind`), which Softr's Custom Code header has no use for. When the app needs global brand CSS in Softr's **Settings → Custom Code → Code inside header** (webfont loading, `--brand-*` custom properties, native-chrome restyling), the agent authors that CSS from the DESIGN.md: `<link>`/`@font-face` from the Font URLs, custom properties from `colors`. House convention keeps this CSS in a `custom-code-header.html` file in the project folder — see [native-chrome-styling.md](native-chrome-styling.md). The shadow-DOM rules are unchanged: that global CSS reaches native chrome but never the inside of a block ([anti-patterns.md](anti-patterns.md)).
|
|
85
|
+
dembrandt generates **no Softr-ready CSS** — its only CSS export is a CLI-side Tailwind v4 `@theme` file (`--tailwind`), which Softr's Custom Code header has no use for. When the app needs global brand CSS in Softr's **Settings → Custom Code → Code inside header** (webfont loading, `--brand-*` custom properties, native-chrome restyling), the agent authors that CSS from the DESIGN.md: `<link>`/`@font-face` from the Font URLs, custom properties from `colors`. Give the font `<link>` an id that the blocks check before injecting their own copy, so the fonts load once with or without the header ([anti-patterns.md](anti-patterns.md), the custom-code-header font row). House convention keeps this CSS in a `custom-code-header.html` file in the project folder — see [native-chrome-styling.md](native-chrome-styling.md). The shadow-DOM rules are unchanged: that global CSS reaches native chrome but never the inside of a block ([anti-patterns.md](anti-patterns.md)).
|
|
86
|
+
|
|
87
|
+
**Apps with Softr's sidebar navigation** need two things the extraction cannot know (measured on one app, 2026-10-05):
|
|
88
|
+
|
|
89
|
+
- **Layout thresholds are block widths, not window breakpoints.** The sidebar takes 57–360px of the window (280px by default), so a block is 744px wide at a 1024px window and 488px at 768. Write DESIGN.md's Layout rules as block widths ("four figures per row from a 52rem block", not "from `md`"), because the blocks build them with container queries — see [SKILL.md → App pages beside Softr navigation](../SKILL.md#app-pages-beside-softr-navigation).
|
|
90
|
+
- **The app frame needs its own tokens.** Record a frame colour that copies the Studio theme colour of the top bar and sidebar, e.g. `#A85935`, not the brand primary: on that app they differed (DESIGN.md primary `#B4532A`). Add the sheet colour the blocks sit on (often DESIGN.md's `surface`, which then describes the sheet the header paints, not a block background) and the sheet's corner radius (24px there). The header carries them as `--app-frame`, `--app-sheet` and `--app-radius` — recipe in [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout). Softr's own theme variables are hashed, so the header cannot reference them: when the theme colour changes in Studio, change `--app-frame` by hand.
|
|
86
91
|
|
|
87
92
|
## Optional: brand-drift QA with `compute_drift`
|
|
88
93
|
|
|
@@ -83,7 +83,11 @@ function getLinkedItems(f) {
|
|
|
83
83
|
if (x && typeof x === "object") return { id: x.id || "", title: x.label || x.name || x.title || "" };
|
|
84
84
|
return { id: "", title: String(x) };
|
|
85
85
|
}).filter(function(o) { return o.id || o.title; });
|
|
86
|
-
|
|
86
|
+
if (typeof f === "object") { /* a single link arrives as one { id, label } object */
|
|
87
|
+
var t = f.label || f.name || f.title || "";
|
|
88
|
+
return (f.id || t) ? [{ id: f.id || "", title: t }] : [];
|
|
89
|
+
}
|
|
90
|
+
return [{ id: "", title: String(f) }];
|
|
87
91
|
}
|
|
88
92
|
```
|
|
89
93
|
|
|
@@ -362,7 +366,7 @@ When you change a helper's output shape (e.g., `advisorOffice` from array to str
|
|
|
362
366
|
1. Document the published shape as a comment at the top of the helper file and update all consumers in the same commit.
|
|
363
367
|
2. Version the namespace (`__myapp_projects_v2`) -- old consumers keep reading v1 until migrated.
|
|
364
368
|
|
|
365
|
-
Defensive consumers can use `Array.isArray(x) ? x.map(...) : x` when shape might vary, but don't lean on this -- it hides bugs.
|
|
369
|
+
Defensive consumers can use `Array.isArray(x) ? x.map(...) : x` when shape might vary, but don't lean on this -- it hides bugs. (That is about the shape of a global *you* publish. Linked-record values read from Softr are different: they arrive as a single `{ id, label }` object or as an array, and must always be normalised -- see [fields.md](../datasources/fields.md).)
|
|
366
370
|
|
|
367
371
|
### useState, Not useRef, for IDs Consumed by useMemo
|
|
368
372
|
|