softr-vibe-coding 2.15.2 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,9 @@ Small reusable patterns that come up across Vibe Coding blocks but don't warrant
20
20
  - [Measure the block, not the window](#measure-the-block-not-the-window)
21
21
  - [Clear Softr's sticky bars](#clear-softrs-sticky-bars)
22
22
  - [A modal above Softr's bars](#a-modal-above-softrs-bars)
23
+ - [Inline confirm in place of a button](#inline-confirm-in-place-of-a-button)
24
+ - [Drafts that survive a reload](#drafts-that-survive-a-reload)
25
+ - [CSV export](#csv-export)
23
26
 
24
27
  ## Cross-Page State with localStorage + URL Parameters
25
28
 
@@ -72,6 +75,22 @@ var currentUser = useCurrentUser();
72
75
  var key = "softr_myapp_selected_event_" + ((currentUser && currentUser.email) || "anon");
73
76
  ```
74
77
 
78
+ ### A search in the URL
79
+
80
+ Keep a list's search in the URL (`?q=`) so Back and a reload bring it back. Write it with `replaceState` inside try/catch, and pass the current state through:
81
+
82
+ ```jsx
83
+ try { window.history.replaceState(window.history.state, "", url); } catch (e) {}
84
+ ```
85
+
86
+ Softr's page renderer wraps `pushState` and `replaceState` (read from the renderer's source): it throws on a state that is not an object, and it stamps its own `__histIdx` into the state. Passing `history.state` keeps that index; `null` loses it. With this, going back from a record returned to `?q=…` with the results showing, and a reload kept the search (LCDB QA pass, 2026-10-08).
87
+
88
+ **What goes in the URL goes to Softr's server.** Every save request carries the page URL and its parameters (`context.pageURL`, `context.URLParameter`), so personal data in a parameter is a decision to record, not a side effect. A blocked save on a page with `?q=<surname>` carried the surname in both fields.
89
+
90
+ ### A preference that follows the user
91
+
92
+ A preference that should follow the user across devices (a dashboard layout) lives in a table, one row per user per view. Keep a `localStorage` copy only to avoid a jump while the row loads. A `where` on the email is not access control: without a Source condition on `{USER:::EMAIL}`, any logged-in user can read every row, and whether that condition also stops updates to other users' rows is untested. One app shipped its layout table without the condition, and a QA pass found that any login could overwrite another user's row (LCDB, 2026-10-07).
93
+
75
94
  ## Clipboard Copy Button
76
95
 
77
96
  Standard browser `navigator.clipboard.writeText` works inside Vibe Coding blocks. Softr-published apps run on HTTPS, which is the only requirement for the Clipboard API, so no fallback is needed.
@@ -167,13 +186,15 @@ The hook automatically handles:
167
186
  - Softr's in-app confirmation modal when the user clicks an internal Softr link or `<NavigationAction>`.
168
187
  - Letting navigation through if the user confirms; cancelling if they decline.
169
188
 
170
- **Most form blocks don't need to wire this manually** — Softr's Vibe Coding bundler often adds the blocker automatically when it detects form dirty state. You only need to add it explicitly for advanced cases:
189
+ **Wire it yourself in every form block, and see it fire in the preview.** Don't count on Softr's bundler to add the blocker. A form block pushed through the MCP had none: after typing rows, a click on a sidebar link left the page with no prompt, and none of that app's 15 block sources called the hook (LCDB QA pass, 2026-10-08; observed from a source grep and one live block, so it is a report of what we saw, not a statement about the bundler). Test it: make the form dirty, click a sidebar link, expect the prompt. The hook itself was not exercised in that app, so see it work before relying on it. It matters most for:
171
190
 
172
191
  - Multi-step forms where the dirty state spans several panels.
173
192
  - Manual dirty tracking that doesn't go through standard form-state hooks.
174
193
  - Blocks where you want to block on something other than form dirtiness (e.g., a pending background upload).
175
194
 
176
- **Asking Softr to add the blocker automatically:** when generating or refining a form block in the Vibe Coding editor, you can prompt with "Block the navigation when the form is dirty" and Softr will wire `useNavigationBlocker` for you — useful when you don't want to write the import + hook call yourself.
195
+ The blocker only asks. Rows the user chooses to throw away anyway are gone, so a long entry form also wants [a draft that survives a reload](#drafts-that-survive-a-reload).
196
+
197
+ **Asking Softr to add the blocker:** the bundler did not add it to a block pushed through the MCP, so wire it yourself. In Studio's own editor, prompting "Block the navigation when the form is dirty" may write the import and the hook call for you (untested here, as no block went through that route). Check that it fires either way.
177
198
 
178
199
  ## Scroll-Condensing Fixed Header (Landing-Page Hero)
179
200
 
@@ -447,6 +468,10 @@ createItem.mutate(payload, {
447
468
 
448
469
  **Where the id comes from is not always where it goes.** For an `onCreate` inside a Combo — a vendor typed into a picker — the created id is patched into the form and the user keeps editing; there is nothing to open. See [searchable-dropdown.md](searchable-dropdown.md#variants-worth-having). Navigation is for records that have their own page and that the user will work on next.
449
470
 
471
+ ### New from a search with no results
472
+
473
+ A New button on a search that found nothing opens the form prefilled from the query: digits go to the phone, and both "Last, First" and "First Last" go to the name fields (that order is what made the search miss). Compare the form's unsaved-changes check against the prefilled draft, not against an empty one, or Cancel asks to discard text nobody typed. A duplicate check before the create says why each match matched and ranks strong reasons above a weak substring match: a household's second parent was not flagged as a duplicate until it matched on the other caregiver and the address (LCDB QA pass, 2026-10-08).
474
+
450
475
  ## Clickable Row with an Inner Link
451
476
 
452
477
  An index table exists to get the user into a record, so the hit area is the whole row. But a row is not a link: cmd-click, middle-click, right-click → "Copy link" and hover-to-see-the-URL all come from a real `<a>`. Keep both — the row handler for the plain click, an anchor on the name for everything the browser does with anchors — and make sure they do not fight:
@@ -562,6 +587,28 @@ export default function Block() {
562
587
  - **`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
588
  - **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
589
  - **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.
590
+ - **When JS must agree exactly with a CSS container breakpoint** (the page size of a table that turns into a stacked list at that breakpoint), don't redo the rem math in JS and don't read the window's width. Render a zero-size probe that carries the breakpoint's own classes under the same `@container`, and read whether it is displayed:
591
+
592
+ ```tsx
593
+ // Module scope. The probe shows exactly when the table shows, so JS and CSS cannot disagree.
594
+ function useTableMode() {
595
+ const [probe, setProbe] = useState<HTMLElement | null>(null); // state, not a ref: see below
596
+ const [tableMode, setTableMode] = useState(false);
597
+ useLayoutEffect(() => {
598
+ if (!probe || !probe.parentElement) return;
599
+ const read = () => setTableMode(probe.getClientRects().length > 0); // display: none has none
600
+ read();
601
+ const ro = new ResizeObserver(read);
602
+ ro.observe(probe.parentElement);
603
+ return () => ro.disconnect();
604
+ }, [probe]);
605
+ return { tableMode, probeRef: setProbe };
606
+ }
607
+ // In the JSX, inside the @container wrapper:
608
+ // <span ref={probeRef} aria-hidden="true" className="pointer-events-none absolute hidden h-0 w-0 @min-[48rem]:block" />
609
+ ```
610
+
611
+ If the probe mounts only after loading, hold it as state (`ref={setProbe}`) and key the effect on it: a ref with `[]` deps never sees it, and a plain `useEffect` paints 50 rows first. In a harness the page size matched the CSS switch exactly at 767 and 768px (LCDB QA round 2, 2026-10-08). Key a table/stack switch on the content card's own `@container`, not on the block's width: a log table keyed on the block's 47rem still overflowed in its 650px card.
565
612
  - 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
613
  - 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
614
 
@@ -571,7 +618,7 @@ On a page with Softr navigation, Softr's bars live in the main document, outside
571
618
 
572
619
  | Bar | Shows at | Element | Height | Position |
573
620
  |---|---|---|---|---|
574
- | Top bar | a window of 768px and up | `#topbar-root` | 56px | sticky, top 0, z-index 800 |
621
+ | Top bar | a window of 768px and up, in an app that has one | `#topbar-root` | 56px | sticky, top 0, z-index 800 |
575
622
  | 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
623
 
577
624
  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).
@@ -593,7 +640,7 @@ Measured live 2026-10-05 in the preview at a 1440px window: the pane's top sat a
593
640
  **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
641
 
595
642
  ```tsx
596
- const TOP_BAR = 56; // Softr's sticky top bar, window 768px and up
643
+ const TOP_BAR = 56; // Softr's sticky top bar, window 768px and up; 0 in an app with no top bar (see below)
597
644
  const TAB_BAR = 57; // Softr's phone tab bar, window below 768px: measured 57px; --softr-bottombar-height says 55px
598
645
  const AIR = 16;
599
646
 
@@ -609,13 +656,18 @@ function visibleStrip() {
609
656
  function keepInView(el: HTMLElement) {
610
657
  const { top, bottom } = visibleStrip();
611
658
  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);
659
+ // behavior: "instant": the Softr page sets html { scroll-behavior: smooth }, see below.
660
+ if (r.top < top) window.scrollBy({ top: r.top - top, behavior: "instant" });
661
+ else if (r.bottom > bottom) window.scrollBy({ top: r.bottom - bottom, behavior: "instant" });
614
662
  }
615
663
 
616
664
  // It issues a window scroll, so call it as setTimeout(() => keepInView(el), 0) (Hard Constraint 17).
617
665
  ```
618
666
 
667
+ **An app with no top bar.** When the menu lives in the sidebar and Softr shows no top bar on desktop, `--nav-height` is `0px` and `TOP_BAR` is 0, or every scripted scroll holds content 56px lower than it needs to. Set the constant per app, or test the main document for the bar: `document.querySelector(".softr-topbar") ? 56 : 0` (the selector is the one the header CSS scopes on; reading it from block code is untested). Why that layout has no bar: [native-chrome-styling.md → Caveats](native-chrome-styling.md#caveats).
668
+
669
+ **Scroll with `behavior: "instant"`.** Softr sets `html { scroll-behavior: smooth }` in the preview (and the published app's login page), so a plain `scrollBy` animates. A second scroll issued before the first lands replaces its target, and a measure right after the call reads a position mid-scroll: in one check, repeated `scrollBy` calls netted +26px instead of +135. `keepInView` calls once, so the risk is in code that scrolls again or measures straight after it. A test harness without that CSS rule passes the broken code, so copy the app's `scroll-behavior` into the harness (LCDB QA pass, 2026-10-08). Outside a dialog, prefer `keepInView` to bring a Retry button into view. If you do call `scrollIntoView` on it, pass `block: "center"` and `behavior: "instant"`, because `"nearest"` leaves it at the bottom edge, under a toast or the phone tab bar. Inside a dialog, set the body's `scrollTop` instead ([Failures, focus and Escape](#failures-focus-and-escape)).
670
+
619
671
  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
672
 
621
673
  **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.
@@ -639,8 +691,9 @@ const FOCUSABLE =
639
691
  'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])';
640
692
 
641
693
  // 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;
694
+ function InBlockModal({ labelledBy, describedBy, onDismiss, dismissDisabled, returnFocusTo, returnFocusFallback, children }: {
695
+ labelledBy: string; describedBy?: string; onDismiss: () => void; dismissDisabled?: boolean;
696
+ returnFocusTo?: HTMLElement | null; returnFocusFallback?: string; children: React.ReactNode;
644
697
  }) {
645
698
  const panelRef = useRef<HTMLDivElement | null>(null);
646
699
  const dismissRef = useRef(onDismiss); // the latest handler; the listener is attached once per opening
@@ -650,7 +703,9 @@ function InBlockModal({ labelledBy, describedBy, onDismiss, dismissDisabled, chi
650
703
  const panel = panelRef.current;
651
704
  // Inside a shadow root document.activeElement is the block's host; the root knows the real element.
652
705
  const root: any = panel ? panel.getRootNode() : document;
653
- const opener = (root.activeElement as HTMLElement | null) || null;
706
+ // The opener is handed in by the caller (the clicked button, kept in a ref at click): Safari and macOS
707
+ // Firefox do not focus a clicked button, so root.activeElement can be anything when the dialog opens.
708
+ const opener = returnFocusTo || (root.activeElement as HTMLElement | null) || null;
654
709
 
655
710
  // Lock the page behind the modal; the stable gutter stops a sideways jump where scrollbars take space.
656
711
  const html = document.documentElement;
@@ -681,14 +736,20 @@ function InBlockModal({ labelledBy, describedBy, onDismiss, dismissDisabled, chi
681
736
  html.style.overflow = prevOverflow;
682
737
  if (prevGutter) html.style.setProperty("scrollbar-gutter", prevGutter);
683
738
  else html.style.removeProperty("scrollbar-gutter");
684
- if (opener && opener !== panel && opener.isConnected) opener.focus({ preventScroll: true });
739
+ // The opener, or (a dialog restored at page load has none; the opener left the list) a stable
740
+ // tabIndex={-1} target found by selector in the root captured at open: the panel is gone by now.
741
+ const target = opener && opener !== panel && opener.isConnected
742
+ ? opener
743
+ : (returnFocusFallback ? root.querySelector?.(returnFocusFallback) as HTMLElement | null : null);
744
+ target?.focus({ preventScroll: true });
685
745
  };
686
746
  }, []);
687
747
 
688
748
  return (
689
749
  // z-[1000]: above Softr's top bar, sidebar and phone tab bar (all z-index 800 or below).
690
750
  <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()} />
751
+ {/* preventDefault on pointerdown: pressing the backdrop would move focus to <body> before the click lands. */}
752
+ <div aria-hidden="true" className="absolute inset-0 bg-gray-950/50" onPointerDown={(e) => e.preventDefault()} onClick={() => dismissRef.current()} />
692
753
  <div
693
754
  ref={panelRef}
694
755
  role="dialog"
@@ -714,14 +775,15 @@ function InBlockModal({ labelledBy, describedBy, onDismiss, dismissDisabled, chi
714
775
  }
715
776
 
716
777
  export default function Block() {
717
- // ... state, `active` record, `saving`, a `close` that returns early while saving ...
778
+ // ... state, `active` record, `saving`, the opener (event.currentTarget kept in a ref at click),
779
+ // a `close` that returns early while saving ...
718
780
  return (
719
781
  <>
720
782
  <div className="@container">{/* the block's page */}</div>
721
783
  {/* A sibling of the @container wrapper, not a child of it. */}
722
784
  {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 */}
785
+ <InBlockModal labelledBy="modal-title" describedBy="modal-desc" onDismiss={close} dismissDisabled={saving} returnFocusTo={openerRef.current} returnFocusFallback="#page-title">
786
+ {/* header with id="modal-title" / id="modal-desc"; give it right padding (`pl-* pr-14`) for the X */}
725
787
  {/* a scrolling body: min-h-0 flex-1 overflow-y-auto */}
726
788
  </InBlockModal>
727
789
  )}
@@ -732,9 +794,125 @@ export default function Block() {
732
794
 
733
795
  - **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
796
  - **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.
797
+ - **Focus lives in the shadow root.** Read the focused element from `panel.getRootNode().activeElement`; `document.activeElement` is only the block's host. Focus goes back to the opener on close if it is still on the page. The caller hands the opener in as `returnFocusTo` (the clicked button, kept in a ref at click) and the component falls back to the root's `activeElement` at open, because Safari and macOS Firefox do not focus a clicked button. When there may be no opener, see [Failures, focus and Escape](#failures-focus-and-escape).
736
798
  - **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
799
  - **Scroll lock on `<html>`**, not `body`: the page scroller is the document. Restore both properties exactly as they were.
738
800
  - The shipped component also has `animate-in fade-in-0 zoom-in-95` on the panel and `backdrop-blur-[2px]` on the backdrop.
739
801
 
740
802
  **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.
803
+
804
+ ### Failures, focus and Escape
805
+
806
+ What a QA pass of 15 app blocks found in dialogs like this one (LCDB QA pass, 2026-10-08): dialogs that could not be closed after a failed save, errors hidden below the fold (21px under it at 1280×800), focus lost to `<body>`, and a page that scrolled from 345 to 880px behind an open dialog.
807
+
808
+ - **Pass the opener in explicitly** (`returnFocusTo` above). Safari and macOS Firefox don't focus a clicked button, so `root.activeElement` at open is not the opener, and focus was lost on close in Firefox until the caller handed the button over.
809
+ - **Give it a fallback target when there may be no opener.** A dialog restored at page load (a draft) mounts with nothing focused, and an opener that can leave the list (a row that drops out after the save) is gone at close. Pass a selector for a stable `tabIndex={-1}` element (`returnFocusFallback` above) and look it up in the root captured at open: after cleanup, `getRootNode()` no longer finds it.
810
+ - **`preventDefault` on the backdrop's `pointerdown`.** Pressing the mouse on a backdrop moves focus to `<body>` before the click lands, and every close path after that loses focus.
811
+ - **Lock only while a request is in flight.** After a failure Close works again and keeps what was typed. A dialog the user cannot leave is a trap; the same applies to a form locked after a failed step (see [writing.md → Sequential Multi-Row Writes](../datasources/writing.md#sequential-multi-row-writes-mutateasync)).
812
+ - **Put the error in a `role="alert"` well and reveal it by setting the body's `scrollTop`**, re-run whenever the error list changes. Not `scrollIntoView`: it also scrolls the locked page. A harness check "the error is inside the dialog" passed while the error sat below the fold, so compare the error's rect with the scroller's edges.
813
+ - **Page-level scroll and focus effects skip while an `[aria-modal]` dialog is open.** An effect that scrolls or focuses on an async failure otherwise moves the page behind the dialog and lets `focus()` out of the trap.
814
+ - **After a failure, return focus to the action button** unless focus is already in a field. A submit button disabled while saving drops focus to `<body>`. If the clicked control was removed (Retry, Start over), focus the first `:not(:disabled)` control after `setTimeout(…, 0)`, so a fieldset unlocked in the same click is already enabled, from a root captured at open.
815
+ - **A control that unmounts on its own success drops focus to `<body>`.** Discard on a restored-draft note, Set active on an Inactive banner, a paging button on the last page: move focus to a stable, always-mounted target first. For a dismissed note, reuse the form's focus-first-control call (`setTimeout(…, 0)`, through a ref in the shadow root, not `document.getElementById`). For a paging button, keep the footer and make it a `tabIndex={-1}` paragraph ("All N shown") that takes the focus.
816
+ - **Escape closes only the innermost layer**: an open list, a calendar, an inline question, then the dialog. Each inner layer calls `preventDefault` + `stopPropagation`; the modal's listener returns on `defaultPrevented`.
817
+ - **Don't render the dialog as `open && !loadError`.** A background read that fails while it is open would unmount it. Keep an open dialog mounted whatever a read does.
818
+ - **Room for the X at every breakpoint**: set the header's sides separately (`pl-* pr-14`), because `px-*` at a breakpoint overrides `pr-*` ([anti-patterns.md](anti-patterns.md#layout--styling)).
819
+
820
+ The opener and focus-return changes were checked in a harness in Chromium and in Firefox (27 checks passing in both). The sketch's `returnFocusFallback` writes out what the app did with an element id, and was not run as written; nor has the full set of rules above been run together in one trimmed component.
821
+
822
+ ## Inline confirm in place of a button
823
+
824
+ When a click needs one more question ("Log anyway?", "Set inactive?"), the question can take the place of the button under the pointer instead of opening a second dialog. It is quick, and it is one click from a write, so it needs guards. These came out of a QA pass over 15 blocks (LCDB, 2026-10-08):
825
+
826
+ - **Ignore taps for about 400ms after it appears.** A double click on Save otherwise lands on the question and answers it unread. With the guard, a double click 12ms apart sent no write.
827
+ - **Remount it for each question** (`key={text}`), and make `text` carry the values it asked about, so changing a field asks again. Without the remount, a double click on the confirm button answered the next question unread.
828
+ - **Move focus to the safe answer with refs.** React reuses the focused button's DOM node, so focus lands on the new action. Check the shadow root's `activeElement` (`panel.getRootNode().activeElement`) after Enter, not after a mouse click (a click moves focus to the button it hit in Chromium).
829
+ - **Escape closes only the question** and returns focus to the footer. Inside a dialog it must not go on to the "discard your changes?" question ([Failures, focus and Escape](#failures-focus-and-escape)).
830
+ - **The action behind it returns its error** instead of toasting it, so the question can show it in place. It is not dropped by a "one at a time" guard (another row's save in flight silently dropped a confirmed Set inactive), and it swallows a failed re-read after a good write, or a retry writes a second change-log row ([anti-patterns.md](anti-patterns.md#mutations)).
831
+ - **A handler wired as `onClick={fn}` with an optional "confirmed" flag** gets the click event as that flag ([anti-patterns.md](anti-patterns.md#hooks--react)).
832
+ - **Ask only in the direction that takes something away.** Set inactive asks; Set active acts at once ([ui-ux-guidelines.md §15](../ui-ux-guidelines.md#15-error-prevention-and-destructive-actions)).
833
+
834
+ The arming guard, as a sketch of the mechanism (not a copy of a shipped component):
835
+
836
+ ```tsx
837
+ // Module scope. Mount it as <ConfirmStrip key={text} … />, so each question is a fresh instance.
838
+ function ConfirmStrip({ text, safeLabel, actionLabel, onSafe, onAction }: {
839
+ text: string; safeLabel: string; actionLabel: string; onSafe: () => void; onAction: () => void;
840
+ }) {
841
+ const mountedAt = useRef(Date.now());
842
+ const safeRef = useRef<HTMLButtonElement | null>(null);
843
+ useEffect(() => { safeRef.current?.focus({ preventScroll: true }); }, []); // the safe answer, by ref
844
+ const armed = () => Date.now() - mountedAt.current > 400; // a double click lands inside this
845
+ return (
846
+ <div role="alert">
847
+ <p>{text}</p>
848
+ <button type="button" ref={safeRef} onClick={() => armed() && onSafe()}>{safeLabel}</button>
849
+ <button type="button" onClick={() => armed() && onAction()}>{actionLabel}</button>
850
+ </div>
851
+ );
852
+ }
853
+ ```
854
+
855
+ ## Drafts that survive a reload
856
+
857
+ A long entry form (a multi-row hours or distribution entry) loses everything on a reload or an in-app link. [`useNavigationBlocker`](#navigation-blocker-for-unsaved-changes) asks first; a draft in `sessionStorage` is what survives when the user goes anyway (LCDB QA pass, 2026-10-08: after a failed save, a sidebar link and then Back showed "Restored 1 unsaved row" with its people, hours and error text intact).
858
+
859
+ - **Wrap every storage read and write in try/catch**, and make the form work without it (it can throw in a private window).
860
+ - **Store the signed-in user's email with the draft and drop a mismatch.** `sessionStorage` outlives a Softr sign-out in the same tab, so a draft keyed by record alone would restore one staff member's quantities for the next. Key by record too.
861
+ - **Until the email is known, neither restore the draft nor remove it; restore it when the email resolves.** Don't accept a draft while the user is unknown: on a shared computer that shows the previous person's rows. Whether the email is there at the first render was not proven live, so write the restore to work either way.
862
+ - **Run the restore from an effect keyed on the email, and gate it on nothing else that resolves late** (write access, a mutation's `enabled`, the product list). A restore gated on something late is skipped, and the effect that removes empty drafts then deletes the draft on mount. Measure "dirty" from the form's own state, not from a list that is still loading.
863
+ - **Remove a draft only after the dialog has been open in this mount** (a `seen` ref), never at mount. An effect that saves the empty form on mount wipes the draft it should have read, and so does one that clears storage before the restore has run.
864
+ - **Persist only while no save is running.** A row that was mid-save comes back as failed, with a "may have been saved" note, never as a draft: restoring it as a draft would send the same row twice. Never restore a confirm or review step, which is one click from a write.
865
+ - **A save queue stops when the block unmounts** (a ref the loop checks), or it keeps writing for a page the user has left.
866
+ - **Discard removes only the restored rows**, not the rows typed since. Discard removes its own note, so move focus to a stable control first, and give a dialog restored at load a fallback return-focus target, as it has no opener ([Failures, focus and Escape](#failures-focus-and-escape)).
867
+
868
+ ```tsx
869
+ // Module scope. A sketch of the keying, not a copy of a shipped block.
870
+ const DRAFT_KEY = "softr_myapp_hours_draft"; // namespaced, as in the localStorage section above
871
+
872
+ function loadDraft(email: string, recordId: string) {
873
+ if (!email) return null; // user not resolved yet: no restore, and the caller must not remove the draft either
874
+ try {
875
+ const d = JSON.parse(sessionStorage.getItem(DRAFT_KEY) || "null");
876
+ return d && d.email === email && d.recordId === recordId ? d.rows : null;
877
+ } catch { return null; }
878
+ }
879
+
880
+ function saveDraft(email: string, recordId: string, rows: unknown[]) {
881
+ try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ email, recordId, rows })); } catch {}
882
+ }
883
+
884
+ // In Block(): restore when the email becomes known, once. The save/remove effect returns early until then.
885
+ // const restored = useRef(false);
886
+ // useEffect(() => {
887
+ // if (!email || restored.current) return;
888
+ // restored.current = true;
889
+ // const rows = loadDraft(email, recordId);
890
+ // if (rows) setRows(rows);
891
+ // }, [email, recordId]);
892
+ ```
893
+
894
+ ## CSV export
895
+
896
+ A CSV export is a small feature with six ways to go wrong (LCDB QA pass, 2026-10-08):
897
+
898
+ - **Name the file for the period shown** (`volunteer-hours-2026-10.csv`), not the export day.
899
+ - **Put a "Generated" line in local time with its UTC offset**: `format(new Date(), "yyyy-MM-dd HH:mm xxx")` gives `2026-10-07 22:49 -07:00`. A UTC stamp read as the next day after 5 pm Pacific.
900
+ - **Prepend a UTF-8 BOM** so spreadsheet apps read accents, built from a code point, with no invisible character in the source and no `\uFEFF` escape ([escapes in pushed source arrive decoded](softr-mcp.md#unicode-escapes-come-back-decoded)).
901
+ - **Give a unit with every figure**, and list an item in a different unit once, apart from the others (wipes counted in packs showed twice, in the size table and again in the CSV).
902
+ - **Prefix free-text cells that start with `=`, `+`, `-` or `@` with an apostrophe**, because a spreadsheet runs them as formulas. Apply it to text, never to numbers. A precaution: no formula ran.
903
+ - **Disable the button until the data it exports has fully loaded**: every page fetched, no read in error.
904
+
905
+ ```tsx
906
+ const CSV_BOM = String.fromCharCode(0xfeff); // prepend to the file text
907
+
908
+ function csvCell(v: unknown): string {
909
+ if (typeof v === "number") return String(v);
910
+ let t = v == null ? "" : String(v);
911
+ if (/^[=+\-@]/.test(t)) t = "'" + t; // text only: a spreadsheet would run it as a formula
912
+ return /[",\r\n]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t;
913
+ }
914
+
915
+ // new Blob([CSV_BOM + lines.join("\r\n")], { type: "text/csv;charset=utf-8" })
916
+ ```
917
+
918
+ To read what an export contains without downloading it (and to see the BOM, which `Blob.text()` hides), see the Exports and printouts section of [browser-checks.md](browser-checks.md).
@@ -349,7 +349,7 @@ html:has(.softr-sidebar) body,
349
349
 
350
350
  ### Caveats
351
351
 
352
- - **Decide where the menu lives first.** If the Studio menu items sit in the top bar, `.softr-sidebar` renders as an empty coloured column, and the frame reads as a blank strip (seen live 2026-10-05). For the full "app" look, put the menu in the sidebar in Studio; that variant was not built or seen, so check it.
352
+ - **Decide where the menu lives first.** If the Studio menu items sit in the top bar, `.softr-sidebar` renders as an empty coloured column, and the frame reads as a blank strip (seen live 2026-10-05). For the full "app" look, put the menu in the sidebar in Studio. That variant has since been built: Softr's sidebar navigation can run with **no top bar on desktop**. Then `--sticky-nav-height` is empty, so a block's `--nav-height` is `0px` on desktop too, and header CSS that assumes a top bar must be scoped, e.g. `html:not(:has(.softr-topbar)) .softr-sidebar { … }`. Header code written for a top bar left the sidebar card 4px from the window's top edge (LCDB, 2026-10-07).
353
353
  - **The frame colour is a hand copy.** The bars paint the Studio theme colour (e.g. `#A85935`) through hashed variables, so `--app-frame` cannot reference it. Read it off the page, `getComputedStyle(document.querySelector('.softr-sidebar')).backgroundColor`, rather than taking the DESIGN.md primary: the two can differ, and in the verified app they did. If the theme colour changes in Studio and `--app-frame` does not, the frame and the bars split.
354
354
  - **Rule order.** Rule 2 has the same specificity as rule 1. Put it first and sidebar pages lose the frame.
355
355
  - **The Vibe-host rule's scope.** The live code used the unscoped `#main-content [data-role="vibe-block-root"]`, which applies on every page. That is harmless where the page behind the block is the same theme background (inferred; no page without navigation but with a Vibe block was tested). The recipe scopes it like every other rule; that exact form is untested.
@@ -502,6 +502,8 @@ the popup, or mock on a page that loaded normally (the `?print=1` path), where r
502
502
  fresh preview link before you verify anything; otherwise you are testing the old Print. (What else a preview link is, and why
503
503
  it is never shared: [softr-mcp.md](softr-mcp.md#application-management-tools).)
504
504
 
505
+ **Check a printout for overflow, not only for content.** A `white-space: nowrap` cell (a mono figure column) overflows its column silently when a label gets longer, and the page still prints. Measure `scrollWidth > clientWidth` on those cells in the printout (LCDB QA pass, 2026-10-08: longer month labels overflowed a column with no error). A date stamp in the printout is local time with its UTC offset ([common-patterns.md → CSV export](common-patterns.md#csv-export)). To capture the printout without a pop-up, see the Exports and printouts section of [browser-checks.md](browser-checks.md).
506
+
505
507
  **A browser that is not painting does not run the page.** A hidden browser pane or a background
506
508
  tab fires no `requestAnimationFrame` and no IntersectionObserver callbacks, so anything that waits
507
509
  on them (a reveal-on-scroll, lazy content, a check timed off a frame) stalls there, and a check