softr-vibe-coding 2.13.3 → 2.13.4

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.
@@ -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.
@@ -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
 
@@ -1,16 +1,17 @@
1
- # Styling Softr's Native Shell (Header · Footer · Page Background) via Custom Code
1
+ # Styling Softr's Native Shell (Header · Sidebar · Footer · Page Background · App Frame) via Custom Code
2
2
 
3
- **This is NOT about Vibe Coding blocks.** Softr's top bar, navigation, dropdown menus, footer, and the page background are part of the *native app shell* — configured in Softr Studio and rendered in the **main document**, not inside a block's shadow DOM. You **cannot** build or replace the native chrome itself as a Vibe Coding block. To re-skin it, add **CSS to Settings → Custom Code → Code inside header** (the same place brand fonts/tokens live — house convention keeps that CSS in a `custom-code-header.html` file in the project folder and pastes it into the setting; you author it from the project's DESIGN.md tokens, since dembrandt supplies the palette and webfont URLs but generates no Softr-ready CSS — see [dembrandt.md](dembrandt.md#authoring-custom-code-headerhtml-from-designmd)). Pure CSS — no markup, no JS — and the native chrome stays in place, so Softr's auth-aware nav (account menu, sign-out, user-group gating) keeps working. (Separate pattern, different problem: a landing page with the native header **hidden** can carry a block-owned in-block header — see [Restyle vs. replace vs. block-owned header](#restyle-vs-replace-vs-block-owned-header).)
3
+ **This is NOT about Vibe Coding blocks.** Softr's top bar, sidebar, phone tab bar, navigation, dropdown menus, footer, and the page background are part of the *native app shell* — configured in Softr Studio and rendered in the **main document**, not inside a block's shadow DOM. You **cannot** build or replace the native chrome itself as a Vibe Coding block. To re-skin it, add **CSS to Settings → Custom Code → Code inside header** (the same place brand fonts/tokens live — house convention keeps that CSS in a `custom-code-header.html` file in the project folder and pastes it into the setting; you author it from the project's DESIGN.md tokens, since dembrandt supplies the palette and webfont URLs but generates no Softr-ready CSS — see [dembrandt.md](dembrandt.md#authoring-custom-code-headerhtml-from-designmd)). Pure CSS — no markup, no JS — and the native chrome stays in place, so Softr's auth-aware nav (account menu, sign-out, user-group gating) keeps working. (Separate pattern, different problem: a landing page with the native header **hidden** can carry a block-owned in-block header — see [Restyle vs. replace vs. block-owned header](#restyle-vs-replace-vs-block-owned-header).)
4
4
 
5
- This doc covers the **header / nav / dropdowns**, the **footer**, the **floating "island" treatment** for both, and the **page background** — which is trickier than it looks, because Softr stacks the same fill on several layers.
5
+ This doc covers the **header / nav / dropdowns**, the **footer**, the **floating "island" treatment** for both, the **page background** — which is trickier than it looks, because Softr stacks the same fill on several layers — and the **app frame**: in an app with Softr's sidebar navigation, painting around the top bar and sidebar so the content reads as one sheet inside them ([App frame](#app-frame-navigation-layout)).
6
6
 
7
- > **Mirror of the block rule.** Global `custom-code-header.html` CSS reaches native chrome (main document) but **not** blocks (shadow DOM). Inside a block you apply brand styles inline; for native chrome you apply them with this global CSS. (See [anti-patterns.md](anti-patterns.md) for the block side.)
7
+ > **Mirror of the block rule.** Global `custom-code-header.html` CSS reaches native chrome (main document) but **not** blocks (shadow DOM). Inside a block you apply brand styles inline; for native chrome you apply them with this global CSS. (See [anti-patterns.md](anti-patterns.md) for the block side.) Of a Vibe block it reaches only the **host `<div>`**, which sits in the main document — that is how the app frame makes a block's background transparent — never anything inside the shadow root. Native Softr blocks render in the main document, so it reaches them whole.
8
8
 
9
9
  ## Selector discipline — the #1 rule
10
10
 
11
- Softr's rendered markup carries two kinds of classes:
11
+ Softr's rendered markup carries two kinds of names:
12
12
 
13
13
  - **Hashed build classes** like `f8f11e5_m9ntthp` — **NEVER target these.** Softr regenerates the hash on every deploy, so your rules silently die.
14
+ - **Hashed CSS variables** like `--_5f91d6c_vnohg20` — same rule: **never target them and never `var()` them.** Softr's theme colours reach the page through these (the sidebar fill, the theme background), so when you need a theme value, copy it by hand and keep it in your own token ([App frame](#app-frame-navigation-layout) does this). The prefix belongs to a **native block package**, not to the app: on 2026-10-05 the prefixes carried a leading underscore, `_5f91d6c_` on the navigation and 404 blocks and `_03ef538_` on the Account settings (user-accounts) block; the June 2026 notes recorded `f8f11e5_` without one. Don't hard-code a prefix either; it regenerates with the hash.
14
15
  - **Stable hooks** — target these instead:
15
16
 
16
17
  | Element | Stable selector |
@@ -22,8 +23,24 @@ Softr's rendered markup carries two kinds of classes:
22
23
  | Nav buttons / dropdown triggers | `.softr-nav-button` |
23
24
  | Overflow "…" trigger | `.softr-nav-category` |
24
25
  | Active / current link | `.softr-nav-link[data-active="true"]` |
26
+ | Active-link underline | `.softr-nav-link::before` — a 2px bar 41px down the 56px bar (verified live 2026-10-05) |
25
27
  | Open dropdown trigger | `.softr-nav-button[aria-expanded="true"]` |
26
28
 
29
+ **Navigation-layout shell** — apps whose Studio navigation puts a sidebar beside the content (seen in one such app's `featureFlags` as `navigationLayout: true`; whether the flag marks sidebar apps is untested, so test for `.softr-sidebar` instead). All verified live 2026-10-05:
30
+
31
+ | Element | Stable selector | Notes |
32
+ |---|---|---|
33
+ | Page grid | `#page-content` (classes `content spr-content-root`) | CSS grid: `grid-template-areas: "topbar topbar" "sidebar main" "bottombar bottombar"`, `grid-template-columns: auto minmax(0, 1fr)`. Children: the three roots below, the navigation placeholder and `#main-content` |
34
+ | Top bar root | `#topbar-root` | grid-area `topbar`; sticky, top 0, z-index 800; the bar is 56px tall |
35
+ | Sidebar root | `#sidebar-root` | sticky, top 56px, z-index 1 (static on phones). **Also present on phones, empty and 0px wide** — scope rules on `.softr-sidebar`, never on this id |
36
+ | Sidebar | `.softr-sidebar[data-testid="sidebar"]` | Paints the Studio theme colour. `[data-open="true"]` 280px by default; `[data-open="false"]` 57px, collapsed from the top-bar toggle |
37
+ | Sidebar resize handle | `.softr-sidebar > [role="separator"]` | Drag range 200–360px (`aria-valuemin` / `aria-valuemax`). Softr already keeps it at opacity 0 until hover — no CSS needed to hide it |
38
+ | Sidebar toggle | the top-bar `<button>` holding a visually hidden span "Toggle sidebar" | No `aria-label` and no `softr-*` class; find it by that text in scripts and tests |
39
+ | Phone tab bar root | `#bottombar-root` | sticky (not fixed), z-index 800. Also present on desktop, 0px tall |
40
+ | Phone tab bar | `ul.softr-bottombar[data-testid="bottombar"]` | Only below a 768px window. White; items are `a.softr-nav-link[data-active]`, plus buttons that open dialogs (`aria-haspopup="dialog"`) |
41
+ | Content column | `main#main-content` | grid-area `main`; `display: flex; flex-direction: column; min-height: 100dvh`; transparent. Every block's wrapper is a child of it |
42
+ | Navigation placeholder | `.spr-navigation-placeholder` | The nav block's own node, 0×0; its UI renders into the three roots, so there is nothing to style here |
43
+
27
44
  **Dropdown menus have NO `softr-*` class** — they're **Radix UI**, so target ARIA / Radix attributes (stable across deploys):
28
45
 
29
46
  | Element | Stable selector |
@@ -75,7 +92,8 @@ Scope dropdown rules under `.softr-topbar` (Softr renders the header menu *insid
75
92
 
76
93
  - **Nav font defaults to Inter.** Your brand `@font-face`/`<link>` loads globally, but the bar's `font-family` is set on Softr's classes — you must target `.softr-nav-link` / `.softr-nav-button` to change it.
77
94
  - **Icon + label color** comes from Softr's classes, so a plain `color:` on the link often doesn't take — use the `* { color: inherit !important }` trick above (SVGs use `currentColor`, so they follow too).
78
- - **Custom code renders on the PUBLISHED app only — not in the Studio editor.** The header looks unchanged in the builder; always verify on the live app.
95
+ - **Header custom code renders on the published app AND in Softr's preview — not in the Studio editor.** The preview half is verified live 2026-10-05: a fresh preview load, with nothing injected, applied the app-level header code (seen after a publish; whether the preview shows a pasted but unpublished change is untested — inject it instead, see [browser-checks.md](browser-checks.md#testing-custom-code-header-css)). The editor canvas is still not known to render it: the header looks unchanged in the builder, so check in preview or on the live app.
96
+ - **Confirm the code is live from the page source (verified live 2026-10-05).** The published page's HTML carries the app-level code as `appCustomHeaderCode: "…"` inside `SoftrPageRenderer.render({…})` — an unquoted key in an inline script, not a JSON key, so search for `appCustomHeaderCode:` without quotes around the key. `appCustomHeaderCode: ""` means nothing is published. Every page carries it, `/login` and a 404 page too, so a logged-out fetch of the published app is enough. `pageCustomHeaderCode` sits beside it: **page-level** header code also exists, so check it too when a page behaves differently from the rest. To test CSS before it is pasted, see [browser-checks.md](browser-checks.md#testing-custom-code-header-css).
79
97
  - **Account avatar on a dark bar:** Softr's logged-in account button can blend into a dark bar — give it a contrasting ring if you darken the surface.
80
98
 
81
99
  ## Gotcha: dropdown panel has a tall blank gap below the items
@@ -188,14 +206,26 @@ Two details worth copying:
188
206
  never appears under the logo or the social glyphs, and use `currentColor` so the footer's own colour
189
207
  rules keep working untouched.
190
208
 
191
- Same caveat as everything else here: **it renders on the published app only.** The Studio editor keeps
192
- showing the old arrangement, which reads exactly like "the script did not run."
209
+ Same caveat as everything else here: **it does not render in the Studio editor.** The editor keeps
210
+ showing the old arrangement, which reads exactly like "the script did not run." Check on the published
211
+ app (Softr's preview applies header code too; verified for CSS after a publish on 2026-10-05, not for a script).
193
212
 
194
213
  ## Page background
195
214
 
196
- **The trickiest one — Softr paints the SAME fill on FOUR stacked layers:** `html`, `body`, `#page-content` (stable id; classes `content spr-content-root`), AND a deeper **class-less wrapper div** nested a few levels inside `#page-content`. Style any one layer and the ones above cover it — this is why setting `body` alone appears to "do nothing."
215
+ **The trickiest one — Softr paints the SAME fill, the Studio theme background, on several stacked layers.** Measured live 2026-10-05:
216
+
217
+ | Layer | What paints it |
218
+ |---|---|
219
+ | `html`, `body` | the theme background |
220
+ | `#page-content` (stable id; classes `content spr-content-root`) | Softr's `.spr-content-root { background-color: … }` rule |
221
+ | every Vibe block host, `div[data-role="vibe-block-root"]` — it has no class: most likely the "class-less wrapper div" the June 2026 notes found (inferred; that app was not re-measured) | the block's compiled `@layer base { :host { background-color: var(--background) } }`, where `--background` maps to the theme background through a hashed variable. Not `!important`, so a main-document rule on the host wins |
222
+ | a native block's outer `<section>` | an inline hashed variable holding the theme background |
223
+
224
+ `main#main-content` itself is transparent. Style any one layer and the ones above cover it — this is why setting `body` alone appears to "do nothing", and why a block that sets no background still shows the theme white over a coloured `body`.
225
+
226
+ > **App with Softr's sidebar navigation? Use [App frame](#app-frame-navigation-layout), not this recipe.** The clear below hits every `div` inside `#page-content`. `.softr-sidebar` is one of them and paints the theme colour, so it would lose its fill too (inferred from the DOM and specificity, not injected). And native blocks' outer `<section>`s are not divs, so they keep painting the theme white.
197
227
 
198
- **Pattern: paint the backdrop on the bottom layer (`html`), then clear the duplicate fills off everything stacked above it.**
228
+ **Pattern (top-bar apps): paint the backdrop on the bottom layer (`html`), then clear the duplicate fills off everything stacked above it.**
199
229
 
200
230
  ```css
201
231
  /* 1. Paint the backdrop on the bottom layer. A layered "combo" reads premium:
@@ -212,8 +242,8 @@ html {
212
242
  }
213
243
 
214
244
  /* 2. Clear the duplicate fills stacked above <html> so the backdrop shows through —
215
- but EXCLUDE the header subtree (see gotcha). The inner content wrapper is
216
- class-less and nested deep, so clear ALL divs inside #page-content. */
245
+ but EXCLUDE the header subtree (see gotcha). The block hosts are class-less
246
+ divs nested inside #page-content, so clear ALL divs inside it. */
217
247
  body,
218
248
  #page-content { background-color: transparent !important; background-image: none !important; }
219
249
  #page-content div:not(.softr-topbar):not(.softr-topbar *) {
@@ -222,7 +252,122 @@ body,
222
252
  }
223
253
  ```
224
254
 
225
- **Gotcha — don't clear the header into oblivion.** The header (`#topbar-root` → `.softr-topbar`, *including its dropdown panel*) renders **inside** `#page-content`, so a blanket `#page-content div { background: transparent }` flattens the dropdown's white panel too. And `#page-content`'s **id specificity (1,0,1) out-specifies** class/attr rules like `.softr-topbar [role="menu"]` (0,2,0) — so the clear wins silently and your earlier menu styling vanishes. Always exclude the header subtree: `:not(.softr-topbar):not(.softr-topbar *)`. (Cards are shadow-DOM blocks → their backgrounds are untouched; the footer is a `<footer>`, not a div → safe.)
255
+ **Gotcha — don't clear the header into oblivion.** The header (`#topbar-root` → `.softr-topbar`, *including its dropdown panel*) renders **inside** `#page-content`, so a blanket `#page-content div { background: transparent }` flattens the dropdown's white panel too. And `#page-content`'s **id specificity (1,0,1) out-specifies** class/attr rules like `.softr-topbar [role="menu"]` (0,2,0) — so the clear wins silently and your earlier menu styling vanishes. Always exclude the header subtree: `:not(.softr-topbar):not(.softr-topbar *)`.
256
+
257
+ **What the div clear reaches.** Each Vibe block's **host** div — that is what removes the block's theme white — but nothing inside its shadow root, so a block's own cards and panels keep the fills the block sets. It never reaches native blocks' outer `<section>`s, which keep painting the theme background; the outer-section rule in [App frame](#app-frame-navigation-layout) is the fix (untested outside a sidebar app). The footer is a `<footer>`, not a div → safe.
258
+
259
+ ## App frame (navigation layout)
260
+
261
+ **When:** the app uses Softr's **sidebar navigation** (top bar + sidebar from a 768px window, a tab bar below it) and should read as **one application**, not as blocks stacked on a white page. The bars' theme colour wraps the content as a frame, and the content becomes one paper **sheet** whose top-left corner curves in under the top bar and the sidebar.
262
+
263
+ **The method: the header CSS paints the frame and the sheet; blocks paint nothing.** Leave the top bar and sidebar exactly as the theme draws them. Nothing needs hiding: the top bar has no border or shadow, and the sidebar's inner border is transparent (verified live 2026-10-05). The CSS extends the bars' colour to `html`, `body` and `#page-content`, turns `#main-content` into the sheet, and makes block hosts transparent. Each block on such a page is full-bleed, sets no background of its own and lays out by its own width — the block side is in [SKILL.md](../SKILL.md#app-pages-beside-softr-navigation). The header CSS cannot reach inside a block, so a block's own cards and borders are still set in the block.
264
+
265
+ ### DOM and variables (verified live 2026-10-05)
266
+
267
+ The shell selectors are in the [navigation-layout table](#selector-discipline--the-1-rule) above. Below `#main-content`:
268
+
269
+ - A Vibe block: `#main-content > div[data-block="vibe-coding-…"] > div[data-block-id] > div[data-role="vibe-block-root"]`. The last div is the **host**, with an open shadow root.
270
+ - A native block: `#main-content > div#<block id> > div > section` for the Account settings block; the 404 block is one level shallower (see the caveats).
271
+
272
+ Variables Softr sets on `:root`:
273
+
274
+ | Variable | Top bar + sidebar (window ≥ 768px) | Phone (window < 768px) |
275
+ |---|---|---|
276
+ | `--sticky-nav-height` | `56px` | empty |
277
+ | `--softr-sidebar-width` | `280px`; `57px` collapsed (drag handle range 200–360) | empty |
278
+ | `--softr-bottombar-height` | empty (blocks read 0px) | `calc(0px + 55px)` — the raw token, not a computed length |
279
+
280
+ Each Vibe host maps them for the block: `--nav-height: var(--sticky-nav-height, 0px)`, `--sidebar-width: var(--softr-sidebar-width, 0px)`, `--bottombar-height: var(--softr-bottombar-height, 0px)` (the block-side table: [quick-reference.md → Softr navigation variables](quick-reference.md#softr-navigation-variables)). Blocks use those inside CSS `calc()` ([common-patterns.md](common-patterns.md#clear-softrs-sticky-bars)). The variable says 55px for the tab bar; the rendered bar measured 57px tall.
281
+
282
+ **The layout switches on the window width, exactly at 768px:** 767px gives the phone tab bar, 768px gives the top bar and sidebar.
283
+
284
+ ### The recipe
285
+
286
+ Paste into Settings → Custom Code → Code inside header (every page). That setting takes HTML, so the rules go inside a `<style>` element, as below. Rule order matters: keep it as written.
287
+
288
+ ```html
289
+ <style>
290
+ /* App frame. Pages with Softr's navigation: the bars' colour becomes a frame around one content sheet.
291
+ Pages without it (Log in, Sign up, 404) match none of these rules and keep Softr's own colours. */
292
+ :root {
293
+ --app-frame: #A85935; /* a COPY of the Studio theme colour of the top bar + sidebar (hashed vars can't be
294
+ referenced). Change it by hand whenever the theme colour changes in Studio. */
295
+ --app-sheet: #FBF8F3; /* the app's paper colour (e.g. the DESIGN.md surface) */
296
+ --app-radius: 24px; /* the corner where the sheet meets the frame */
297
+ }
298
+
299
+ /* 1. Any page with Softr navigation (sidebar OR phone tab bar): one paper surface. */
300
+ html:has(.softr-sidebar, .softr-bottombar),
301
+ html:has(.softr-sidebar, .softr-bottombar) body,
302
+ #page-content:has(.softr-sidebar, .softr-bottombar) {
303
+ background-color: var(--app-sheet) !important;
304
+ }
305
+
306
+ /* ...and blocks stop painting the theme background on top of it:
307
+ - Vibe block hosts: their compiled :host { background-color: var(--background) } is not !important.
308
+ This scoped form is UNTESTED as written. The line verified live was the unscoped
309
+ #main-content [data-role="vibe-block-root"], which also applies on pages without navigation.
310
+ - Native blocks: the OUTER <section> only, so their nested cards, list items and form groups keep their
311
+ fills. Nesting depth differs per native block; check yours (see the caveats). */
312
+ #page-content:has(.softr-sidebar, .softr-bottombar) [data-role="vibe-block-root"],
313
+ #page-content:has(.softr-sidebar, .softr-bottombar) #main-content > div > div > section {
314
+ background-color: transparent !important;
315
+ }
316
+
317
+ /* 2. With the sidebar (window ≥ 768px): the frame colour behind everything.
318
+ Same specificity as rule 1, so it MUST come after it; phones (tab bar only) keep the paper. */
319
+ html:has(.softr-sidebar),
320
+ html:has(.softr-sidebar) body,
321
+ #page-content:has(.softr-sidebar) {
322
+ background-color: var(--app-frame) !important;
323
+ }
324
+
325
+ /* The content column becomes the sheet. Softr's own min-height: 100dvh fills short pages. */
326
+ #page-content:has(.softr-sidebar) > #main-content {
327
+ background-color: var(--app-sheet) !important;
328
+ border-top-left-radius: var(--app-radius);
329
+ }
330
+
331
+ /* 3. The pinned inverse corner. #main-content's own radius scrolls away with the page; this one rides
332
+ the sticky #sidebar-root (its containing block), so it stays under the top bar while the content
333
+ scrolls and follows the sidebar's width. Guarded by :has(.softr-sidebar) because #sidebar-root
334
+ also exists on phones, empty and 0px wide. */
335
+ #sidebar-root:has(.softr-sidebar)::after {
336
+ content: "";
337
+ position: absolute;
338
+ top: 0;
339
+ left: 100%;
340
+ width: var(--app-radius);
341
+ height: var(--app-radius);
342
+ background: radial-gradient(circle at 100% 100%,
343
+ transparent calc(var(--app-radius) - 0.5px), /* the -0.5px stop: anti-aliased edge, no seam */
344
+ var(--app-frame) var(--app-radius));
345
+ pointer-events: none; /* never blocks clicks on the sheet */
346
+ }
347
+ </style>
348
+ ```
349
+
350
+ ### Caveats
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.
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
+ - **Rule order.** Rule 2 has the same specificity as rule 1. Put it first and sidebar pages lose the frame.
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.
356
+ - **Native blocks' outer section.** The child path `#main-content > div > div > section` matches the DOM measured on the Account settings block, but it was never re-injected in that form: the descendant form `#main-content section` was injected there and turned the section transparent. Nesting depth differs per native block: the 404 block's section sits one level shallower (`#main-content > div > section`), which does not matter there (no navigation). For each native block type on a navigation page, check the depth first; fall back to the descendant form only after checking the block holds no nested `<section>`s. **Side effect:** on navigation pages this overrides a background colour set on purpose in a native block's Style settings. A no-CSS alternative, setting the Studio theme background to the sheet colour, is untested.
357
+ - **Phones (window < 768px).** Softr renders no top bar and no sidebar, only the tab bar: white, with the active item in the theme colour. Only rule 1 matches, so the page is paper throughout with no frame and no corner (the guarded `::after` computes `content: none`); `#main-content` stays transparent and the paper is on `html`, `body` and `#page-content`. Without the guard the `::after` still renders on phones, where `#sidebar-root` is static, so it is placed against the page: likely off the right edge, adding sideways scroll (seen in a mock, not on Softr). Restyling the tab bar to match the frame is untested.
358
+ - **Tablets.** The bars appear from a 768px window. With the sidebar open, the content column there is only 488px wide (711px collapsed), so a block's window breakpoints fire for a column far narrower than the window. Blocks must size by their own width: [SKILL.md](../SKILL.md#app-pages-beside-softr-navigation), [common-patterns.md](common-patterns.md#measure-the-block-not-the-window).
359
+ - **Collapsed and resized sidebar.** The top-bar toggle collapses the sidebar to 57px (`data-open="false"`, `--softr-sidebar-width: 57px`), and the corner follows it (measured at left 280px open, 57px collapsed). Drag-resizing (200–360px) was not tested; that the corner follows is inferred from `left: 100%`. In one headless run the collapsed state carried over to later checks (seen once; it may persist per browser).
360
+ - **Stacking.** `#topbar-root` and `#bottombar-root` sit at z-index 800 and `#sidebar-root` at 1; block content scrolls under the top bar. A block's own sticky pane or header must clear the bars: [common-patterns.md](common-patterns.md#clear-softrs-sticky-bars). A block's modal must sit above them: no stacking context lies between a block and the page, so a fixed layer in a block at z-index 50 (shadcn's `Dialog` overlay) stays under the top bar, and one at 801 or more covers the top bar and sidebar (measured live 2026-10-06); use the in-block modal in [common-patterns.md → A modal above Softr's bars](common-patterns.md#a-modal-above-softrs-bars). Softr already sets `#main-content [data-block] { scroll-margin-top: var(--sticky-nav-height, 0px) }`, so a fragment jump to a block's outer wrapper (`div[data-block]`, with a page-assigned id such as `ai1`; the host sits two levels inside it) clears the top bar (the rule is read from Softr's CSS; a jump was not tested). Softr's floating "Made with Softr" badge (`div.made-with-softr`, fixed, z-index 1, 296px from the left beside a 280px sidebar) floats over the bottom left of the content on desktop and phones (seen in the preview). It is controlled by the app's badge setting (`showMadeWithBadge` in the page source; `false` after a later publish of the same app), so turn it off there rather than with CSS.
361
+ - **`:has()` support.** Every scoped rule needs `:has()`: Safari 15.4+, Chrome 105+, Firefox 121+ (a reviewer's judgement, not tested on each browser). A browser without it drops those rules and shows Softr's default colours (inferred).
362
+
363
+ ### How the frame was verified
364
+
365
+ Verified 2026-10-05 on a demo app with Softr's navigation layout. First by injecting the project's `<style>` (the recipe above, except that its Vibe-host rule was the unscoped form) into the preview and measuring computed backgrounds, the sheet's radius and the `::after` position at 1440, 1280, 1024, 900, 768, 767 and 390px windows, with the sidebar open and collapsed; the corner was cropped at rest and scrolled, with no seam. Then live, after the code was pasted into Code inside header and published:
366
+
367
+ - **Preview, fresh load, nothing injected.** At 1024px with the sidebar open: frame on `html`, `body` and `#page-content`; `#main-content` the sheet with a 24px corner; Vibe hosts transparent; the corner `::after` at left 280px. At 375px (after a mobile emulation and a reload): tab bar only, paper throughout, no corner. No sideways scroll at either width.
368
+ - **Published, logged out.** `/login` and a 404 page loaded the code and kept `html`, `body` and `#page-content` white, with no top bar, sidebar or tab bar. This is the real test of the `:has()` scoping.
369
+
370
+ How to run these checks: [browser-checks.md](browser-checks.md#testing-custom-code-header-css).
226
371
 
227
372
  ## Finding the element to target
228
373
 
@@ -247,12 +392,16 @@ body,
247
392
  })();
248
393
  ```
249
394
 
395
+ In an app with Softr's sidebar, lower the `1200`: the content column is the window minus the sidebar (1160px at a 1440px window), so block hosts and native sections drop out of the list otherwise.
396
+
250
397
  ## Restyle vs. replace vs. block-owned header
251
398
 
252
399
  **Restyle the native bar (recommended):** robust, global, keeps Softr's auth-aware nav (account menu, user-group-gated items) and stays editable in Studio.
253
400
 
401
+ **Paint around the bars (apps with sidebar navigation):** leave the top bar and sidebar exactly as the Studio theme draws them, and extend their colour around a content sheet: `html`, `body` and `#page-content` take the bars' colour, `#main-content` becomes one paper sheet with a rounded corner tucked under both bars, and blocks paint nothing behind themselves. Choose it when the app uses Softr's top bar + sidebar and should read as one application rather than blocks on a white page. It keeps everything restyling keeps (auth-aware nav, Studio editing) and touches no nav markup. It can sit beside a restyle only if `--app-frame` matches the colour the restyled bars then paint (untested combination). Recipe, scoping and caveats: [App frame](#app-frame-navigation-layout).
402
+
254
403
  **Replace it** (hide `#topbar-root`, inject a fully custom HTML/JS header globally): only if you need structure the native nav can't do — e.g. multi-column mega-menus with icon cards. It's **fragile**: you lose Softr's logged-in account menu + user-group gating, you must re-init the JS on every SPA route change (Softr swaps pages without a full reload), and the custom header won't render in the Studio editor. Steer users to restyle unless the structure genuinely requires replacement.
255
404
 
256
405
  **Block-owned header (landing pages only):** on a marketing/landing page where the native header is **hidden in Studio**, a full-bleed hero block can render its own `<header>` with `position: fixed` — fixed elements inside a block's shadow root still anchor to the viewport, and window scroll listeners work from block code. Proven by Softr Studio AI's own hero output (2026-08-31), and the official user guide lists "a page header" as a supported static layout. How the replace-option caveats transfer: **per-page only** and **no auth-aware nav / user-group gating** carry over (same losses as replacing globally — it's for public landing pages, not logged-in app pages); **"won't render in the Studio editor" does NOT** (a Vibe-block header renders in Studio like any block); **"manual SPA re-init" does not apply** (React owns the block's lifecycle). Two caveats of its own: don't ship it on a page where the native `#topbar-root` is still visible (the z-index contest between the block's header and the native sticky bar is untested — hide one), and it exists only on pages containing the block. Full pattern, mobile-nav requirement, and caveat set: [static-blocks.md](static-blocks.md#block-owned-landing-page-header).
257
406
 
258
- Decision order: restyle when the native structure suffices → block-owned header for landing pages that hide native chrome → global replacement only when a logged-in app needs structure the native nav can't do.
407
+ Decision order: restyle when the native structure suffices (or paint around the bars when a sidebar app should read as one application) → block-owned header for landing pages that hide native chrome → global replacement only when a logged-in app needs structure the native nav can't do.
@@ -255,6 +255,42 @@ useNavigationBlocker(function() { return dirtyRef.current; });
255
255
 
256
256
  Catches BOTH Softr's in-app SPA navigation (nav bar, sidebar, `<NavigationAction>`) AND browser unload (tab close, refresh, external links). A plain `window.addEventListener("beforeunload", ...)` does NOT catch Softr's in-app nav. See [common-patterns.md](common-patterns.md#navigation-blocker-for-unsaved-changes).
257
257
 
258
+ ## Softr navigation variables
259
+
260
+ Softr sets these on the page's `:root` when the app uses its sidebar / top-bar navigation; the Vibe host's `:host` maps them for the block, each with a `0px` fallback (measured live 2026-10-05; phone = below a 768px window).
261
+
262
+ | Page (`:root`, header CSS) | Block (Vibe host) | Desktop / tablet (≥ 768px window) | Phone (< 768px window) |
263
+ |---|---|---|---|
264
+ | `--sticky-nav-height` | `--nav-height` | `56px` (sticky top bar) | not set → host `0px` |
265
+ | `--softr-sidebar-width` | `--sidebar-width` | `280px` open, `57px` collapsed (drag handle 200–360px) | not set → host `0px` |
266
+ | `--softr-bottombar-height` | `--bottombar-height` | host `0px` | `calc(0px + 55px)` (sticky tab bar; the rendered bar measured 57px) |
267
+
268
+ ```tsx
269
+ style={{ top: "calc(var(--nav-height, 0px) + 16px)", maxHeight: "calc(100dvh - var(--nav-height, 0px) - 32px)" }}
270
+ ```
271
+
272
+ - **Use them only inside CSS `calc()`.** In JS, `getComputedStyle(...).getPropertyValue("--bottombar-height")` hands back the token string (`calc(0px + 55px)` on phones), so `parseFloat` gives `NaN` (the string is measured; the `NaN` is reasoned, not run).
273
+ - The same `:host` rule maps `--background`, `--font-family-sans` / `--font-family-serif` and `--container-max-width` onto Softr's hashed theme variables (`--_5f91d6c_…`), which is why an unpainted block still paints the Studio theme background (verified 2026-10-05). Never reference the hashed names: the prefix is per Softr block package and has changed before.
274
+ - How blocks use them: [SKILL.md → App pages beside Softr navigation](../SKILL.md#app-pages-beside-softr-navigation) and [common-patterns.md → Clear Softr's sticky bars](common-patterns.md#clear-softrs-sticky-bars). The page side (header CSS, the `:root` values): [native-chrome-styling.md → App frame (navigation layout)](native-chrome-styling.md#app-frame-navigation-layout).
275
+
276
+ ## Container queries
277
+
278
+ Lay a block out by its OWN width when it sits beside Softr's sidebar (57–360px of the window). Softr's Tailwind compiles container variants (verified live 2026-10-05):
279
+
280
+ ```tsx
281
+ <div className="@container"> {/* the container: a wrapper */}
282
+ <div className="px-4 @min-[40rem]:px-6 @min-[64rem]:px-10"> {/* resolves against the wrapper's width */}
283
+ <div className="grid grid-cols-2 @min-[52rem]:grid-cols-4">…</div>
284
+ <section className="@container …"> {/* a pane: its children follow the pane */}
285
+ <div className="text-[20px] @min-[24rem]:text-[24px]">…</div>
286
+ </section>
287
+ </div>
288
+ </div>
289
+ ```
290
+
291
+ - A container query never resolves against the element that carries `@container`, only against the nearest ancestor container — so put `@container` on a wrapper.
292
+ - Beside a sidebar, no `sm:` / `md:` / `lg:` for layout: they fire on the window. Full rules and worked thresholds: [SKILL.md → App pages beside Softr navigation](../SKILL.md#app-pages-beside-softr-navigation). When CSS can't decide, [measure the block](common-patterns.md#measure-the-block-not-the-window).
293
+
258
294
  ## Component Skeleton
259
295
 
260
296
  ```jsx
@@ -277,3 +313,5 @@ export default function Block() {
277
313
  );
278
314
  }
279
315
  ```
316
+
317
+ App pages beside Softr's sidebar navigation, inside a frame the header code paints, drop these wrappers for the full-bleed `@container` shell: [SKILL.md → App pages beside Softr navigation](../SKILL.md#app-pages-beside-softr-navigation).