softr-vibe-coding 2.14.5 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [2.15.1] - 2026-10-08
8
+ - Release 2.15.1
9
+ - softr-mcp: unicode escapes come back decoded on push
10
+
11
+ ## [2.15.0] - 2026-10-07
12
+ - Release 2.15.0
13
+ - Add the brand date picker reference
14
+
7
15
  ## [2.14.5] - 2026-10-07
8
16
  - Release 2.14.5
9
17
  - Document condition-based user groups over the MCP
package/README.md CHANGED
@@ -261,16 +261,25 @@ softr-vibe-coding/
261
261
  │ │ # Imports, hook signatures, mutation shapes,
262
262
  │ │ # field mapping, component skeleton,
263
263
  │ │ # Softr navigation variables, container queries
264
- │ └── searchable-dropdown.md # THE dropdown pattern for blocks
265
- │ # why native <select> and shadcn <Select> both
266
- │ # break in the shadow DOM, composedPath()
267
- │ # click-outside, A-Z inside the component,
268
- │ # multi-token filter, searchable BY DEFAULT
269
- │ # (bare = click-only; searchable={false} only
270
- │ # for a fixed enum being set — Sep 10 2026),
271
- │ # overflow-clipping ancestors: never clip a cell
272
- │ # holding a Combo, clip-aware drop-up + list
273
- │ # height, list-only scrolling (Sep 30 2026)
264
+ │ ├── searchable-dropdown.md # THE dropdown pattern for blocks
265
+ │ │ # why native <select> and shadcn <Select> both
266
+ │ │ # break in the shadow DOM, composedPath()
267
+ │ │ # click-outside, A-Z inside the component,
268
+ │ │ # multi-token filter, searchable BY DEFAULT
269
+ │ │ # (bare = click-only; searchable={false} only
270
+ │ │ # for a fixed enum being set — Sep 10 2026),
271
+ │ │ # overflow-clipping ancestors: never clip a cell
272
+ │ │ # holding a Combo, clip-aware drop-up + list
273
+ │ │ # height, list-only scrolling (Sep 30 2026)
274
+ │ └── date-picker.md # THE date field for blocks: no native
275
+ │ # <input type="date"> (its calendar is browser
276
+ │ # UI no CSS reaches); the DatePicker kit (API +
277
+ │ # full component), the kit-between-markers
278
+ │ # convention + sync script, rollout lessons
279
+ │ # (clipping, short modal bodies, textClass for
280
+ │ # named containers, Escape, backdrop clicks,
281
+ │ # deferred focus fix-up, Safari focus,
282
+ │ # "yyyy-MM-dd" values), verification (Oct 7 2026)
274
283
  │
275
284
  ├── tools/ # Bundled CLI scripts (run, not read)
276
285
  │ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
package/SKILL.md CHANGED
@@ -95,13 +95,14 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
95
95
  - Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
96
96
  - No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
97
97
  - **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by). Opening a Combo moves focus into it (the search box, or the trigger on a click-only list), because Safari does not focus a clicked button. Escape on an open list closes only the list ([Move focus into the Combo when it opens](references/searchable-dropdown.md#move-focus-into-the-combo-when-it-opens))
98
+ - **No native date field** — no `<input type="date">`, `type="datetime-local"` or `type="month"` in a branded block: the browser draws its calendar pop-up, and no CSS reaches it. Use the module-scope `DatePicker` kit in [references/date-picker.md](references/date-picker.md), pasted verbatim between its marker lines (block-specific code, `textClass` included, stays outside them). Values stay `"yyyy-MM-dd"` strings, compared as strings. Same rules as the Combo: nothing between the field and its scroller clips, and Escape on an open calendar closes only the calendar
98
99
  - App page with Softr navigation: **no shadcn `<Dialog>` / `<Sheet>`** — its overlay is z-50, under Softr's top bar (z-index 800), and it portals out of the shadow root. Use the in-block modal (`fixed inset-0 z-[1000]`, rendered as a sibling of the block's `@container` wrapper, with its own Escape, focus trap, scroll lock and focus return) in [references/common-patterns.md → A modal above Softr's bars](references/common-patterns.md#a-modal-above-softrs-bars) (measured live 2026-10-06)
99
100
  - No clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`, `line-clamp-*`) on any element that contains a `Combo` — its panel is absolutely positioned in local DOM, so a clipping `<td>` cuts the menu to the row's height; bound an over-wide chip at the chip (`min-w-0 truncate`), and never `scrollIntoView` inside the panel. See [references/searchable-dropdown.md](references/searchable-dropdown.md#the-four-things-that-will-bite-you), item 4
100
101
  - Any **Print** control opens a **new window with its own document** — `window.open` straight from the click, an escaped standalone HTML printout written into it, `print()` once its stylesheets, fonts and images are in, the button disabled until the data has fully loaded. No `window.print()` on the Softr page, no in-page print view (Hard Constraint 28). See [references/printing.md](references/printing.md)
101
102
  - Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
102
103
  - Array-setting rows keyed by **index**, never by a builder-editable field value
103
104
  - Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
104
- - **Deploying through the MCP:** `errors: null` on a push is not proof — compare the push result's `sourceSha256` with `shasum -a 256` of the file you sent, every byte counted, trailing newline included (Softr stores exactly what it receives; the one-byte drift we once blamed on it was a chunked read on our side). Prove deployed == disk *before* editing the same way, with `vibe_coding_block_get_code` and `includeCode: false`, so a Studio-side change is never overwritten. Fetch the full `sourceCode` only when a digest is missing or the hashes differ. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
105
+ - **Deploying through the MCP:** `errors: null` on a push is not proof — compare the push result's `sourceSha256` with `shasum -a 256` of the file you sent, every byte counted, trailing newline included (Softr stores exactly the text that reaches it; the one-byte drift we once blamed on it was a chunked read on our side). No `\uXXXX` escapes in pushed source: they arrive decoded to the characters and the hashes differ, so write the characters themselves (with a comment saying why) or build them from code points (`String.fromCharCode(0x300)`); lone surrogates are the exception ([why](references/softr-mcp.md#unicode-escapes-come-back-decoded)). Prove deployed == disk *before* editing the same way, with `vibe_coding_block_get_code` and `includeCode: false`, so a Studio-side change is never overwritten. Fetch the full `sourceCode` only when a digest is missing or the hashes differ. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
105
106
  - **Then check it in a browser.** Once the push checks pass, check rendering and behaviour in a fresh preview with saves blocked, per [references/browser-checks.md](references/browser-checks.md)
106
107
 
107
108
  ## What to Clarify
@@ -184,6 +185,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
184
185
  | Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
185
186
  | Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping, Softr navigation variables (`--nav-height` etc.), container-query syntax | [references/quick-reference.md](references/quick-reference.md) |
186
187
  | Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, **searchable by default** regardless of option count (`bare` inline editors click-only; `searchable={false}` only for a short fixed enum being set), the `bare` inline-editor variant, focus moved into the Combo on open (Safari does not focus a clicked button) and Escape that closes only the list | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
188
+ | Any **date field** in a block — why `<input type="date">` can't carry a brand (its calendar is browser UI), the `DatePicker` kit (API, the full component), the kit-between-markers convention with a sync script for shared components, lessons from a 12-block rollout (clipping, short modal bodies, named containers and `textClass`, Escape, backdrop clicks, a modal's focus fix-up, Safari focus, `"yyyy-MM-dd"` values) and how to verify a block | [references/date-picker.md](references/date-picker.md) |
187
189
  | **Printing** anything from a block — always a new window/tab holding its own document, never `window.print()` on the page or an in-page print view: the escaped HTML builder, pop-up-safe opening from the click, print-when-ready (stylesheets, fonts and images, capped), Print disabled until the data has loaded, the `?print=1` deep link from another page, paper layout (shared `<colgroup>`, `vertical-align: middle`, tick boxes) | [references/printing.md](references/printing.md) |
188
190
  | Small reusable patterns — `localStorage` cross-page state, clipboard copy button, measuring the block's own width (not the window's), clearing Softr's sticky top bar and phone tab bar, an in-block modal above Softr's bars (instead of shadcn `Dialog`) | [references/common-patterns.md](references/common-patterns.md) |
189
191
  | Writing Airtable Automation Scripts / Scripting Extension scripts / Airtable formulas — companion to Softr blocks for cross-table cascades and computed values | [references/airtable-automations.md](references/airtable-automations.md) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.14.5",
3
+ "version": "2.15.1",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "bin/cli.js"
@@ -111,6 +111,10 @@ i.dispatchEvent(new Event('input', { bubbles: true }));
111
111
  i.dispatchEvent(new Event('change', { bubbles: true }));
112
112
  ```
113
113
 
114
+ A block that uses the brand `DatePicker` ([date-picker.md](date-picker.md)) has no date input: the
115
+ field is a button with a ref in `-i`. Click it, then click the day by its ref; each day is a button
116
+ named like `Thursday, October 15, 2026`.
117
+
114
118
  ### 4. Block saves before any click, and prove it
115
119
 
116
120
  The preview writes to the live data
@@ -0,0 +1,1107 @@
1
+ # The brand date picker (`DatePicker`)
2
+
3
+ **No native date field in a branded block.** `<input type="date">`, `type="datetime-local"` and
4
+ `type="month"` open the browser's own calendar pop-up. That pop-up is browser UI, not page content,
5
+ so no CSS reaches it, from the block or from the header code: it shows the browser's colours and
6
+ fonts whatever the brand is. Use the `DatePicker` below instead. It draws its calendar in the
7
+ block's own DOM, styled with the brand tokens, and takes and returns the same `"yyyy-MM-dd"`
8
+ strings as the native field, so swapping one for the other changes nothing that is saved.
9
+
10
+ | Option | Why it fails in a block, or doesn't |
11
+ |---|---|
12
+ | `<input type="date">` and friends | The calendar pop-up is the browser's: unstyled by any CSS, and different in Chrome, Safari and Firefox. |
13
+ | shadcn `<Popover>` + `<Calendar>` | The Popover portals to `document.body`, outside the block's shadow root, like shadcn `<Select>` in [searchable-dropdown.md](searchable-dropdown.md) — so its styles would stay behind. Inferred from that same portal; not tried in a block. |
14
+ | `DatePicker` (below) | Local DOM, brand-styled, keyboard grid, min / max, Today and Clear, clip-aware placement, `"yyyy-MM-dd"` in and out. |
15
+
16
+ The kit picks a day. A time of day or a month-only value has no kit yet; build one on the same
17
+ rules before adding the native field back.
18
+
19
+ **Where it comes from.** On 2026-10-07 the Lane County Diaper Bank app (a Softr Database app with
20
+ Softr's sidebar navigation) set out to replace all 21 native date fields across 12 blocks, after
21
+ Leo flagged the browser calendar on the Reports page and again on Inventory's transaction history.
22
+ The component was built once, checked in a React 18.2 shadow-root harness (22 checks, Chromium and
23
+ macOS Firefox), pushed into the Reports and Volunteer Detail blocks first and checked there in a
24
+ browser with saves blocked, then rolled out block by block, each reviewed before its push. The
25
+ reviews produced the eight lessons below. Two of them (2 and 3) were faults in the component
26
+ itself, fixed once in the kit and synced into every block; that sync is why the kit sits between
27
+ markers.
28
+
29
+ ## The API
30
+
31
+ Define it at **module scope**, never inside `Block()` (a component redefined per render remounts
32
+ and loses focus, like the Combo).
33
+
34
+ | Prop | Type | What it does |
35
+ |---|---|---|
36
+ | `id` | `string` | The trigger button's id. The calendar is `<id>-calendar`, the shown value `<id>-value`. |
37
+ | `value` | `string` | `"yyyy-MM-dd"`, or `""` for none. A value that does not parse is shown as it is. |
38
+ | `onChange` | `(next: string) => void` | Receives `"yyyy-MM-dd"`, or `""` from Clear. |
39
+ | `min`, `max` | `string?` | `"yyyy-MM-dd"`. Days outside are dimmed and cannot be picked; the keyboard and the month arrows stop at them. |
40
+ | `labelledBy` | `string?` | The label's id. The trigger is named by the label and the shown value, so it reads "Start date Oct 15, 2026". |
41
+ | `describedBy` | `string?` | A hint or error message id, as on a text input. |
42
+ | `placeholder` | `string?` | Shown while empty. Default `"Choose a date"`. |
43
+ | `clearable` | `boolean?` | Adds a Clear button for optional dates. Default `false`. |
44
+ | `disabled` | `boolean?` | Disables the trigger and closes an open calendar. |
45
+ | `textClass` | `string?` | The trigger's text size classes. Default `"text-[16px] @min-[48rem]:text-[14px]"`; see lesson 3. |
46
+
47
+ ```tsx
48
+ // A labelled field. The label keeps htmlFor: a click on it opens the calendar, as it focused the native field.
49
+ <label id="start-label" htmlFor="start" className="mb-1.5 block text-[13px] font-medium">Start date</label>
50
+ <DatePicker id="start" labelledBy="start-label" value={start} onChange={setStart} max={end} clearable />
51
+ ```
52
+
53
+ Most blocks wrap it once, as they wrap their text inputs (a `DateField` with the label, or a
54
+ `type="date"` branch in the block's own field component that renders `DatePicker` instead of an
55
+ `<input>`). The wrapper lives outside the kit's markers, below.
56
+
57
+ ## The kit between markers
58
+
59
+ A Vibe block is one file and cannot import another (Hard Constraint 22), so every block that uses
60
+ the picker carries its own copy. Copies that are edited in place drift: a fix lands in the block
61
+ that showed the bug and in no other. So the copy is never edited in a block. The convention:
62
+
63
+ 1. **One kit file in the project** holds the component, e.g. `Assets/Softr App/Shared/DatePicker.tsx`.
64
+ It is the only place the component is edited.
65
+ 2. **Each block pastes it verbatim, at module scope, between two marker lines.** The start line names
66
+ the kit file and the first 12 hex of its sha256:
67
+
68
+ ```tsx
69
+ // ===== DatePicker: verbatim copy of Shared/DatePicker.tsx sha256 4763ca393a6c - edit there, not here =====
70
+ /** DatePicker — brand-styled date field … (the kit file, byte for byte)
71
+ …
72
+ // ===== /DatePicker =====
73
+ ```
74
+
75
+ 3. **Anything block-specific stays outside the markers**: the `DateField` wrapper, the block's own
76
+ imports (merge the kit's three import lines into the block's), and every difference between blocks,
77
+ which goes through a prop (`textClass` exists for exactly that, lesson 3).
78
+ 4. **A fix is synced mechanically.** Edit the kit file, then rewrite every marked region from it and
79
+ check that none is left behind:
80
+
81
+ ```bash
82
+ cd "Assets/Softr App" # the folder above the block folders; the marker records the kit path as given
83
+ python3 Shared/kit-sync.py sync DatePicker Shared/DatePicker.tsx */*.jsx
84
+ python3 Shared/kit-sync.py check DatePicker Shared/DatePicker.tsx */*.jsx
85
+ ```
86
+
87
+ Each synced block is then a normal deploy: push it, prove the push by `sourceSha256`
88
+ ([softr-mcp.md → Verifying a push](softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)),
89
+ re-apply its Action permissions (Hard Constraint 21: every save resets them) and update the
90
+ mirror's header date. The sha in the marker tells anyone reading a deployed block which kit it
91
+ carries without diffing 700 lines.
92
+
93
+ `kit-sync.py` (stdlib Python, keep it beside the kit file; any component can use it — the first
94
+ argument is the marker name):
95
+
96
+ ```python
97
+ #!/usr/bin/env python3
98
+ """Keep a shared component pasted verbatim between marker lines in every block.
99
+
100
+ cd "Assets/Softr App"
101
+ python3 Shared/kit-sync.py check DatePicker Shared/DatePicker.tsx */*.jsx
102
+ python3 Shared/kit-sync.py sync DatePicker Shared/DatePicker.tsx */*.jsx
103
+
104
+ The kit's markers in a block (the start line names the kit file and the first 12 hex of its sha256):
105
+ // ===== DatePicker: verbatim copy of Shared/DatePicker.tsx sha256 f5753a191077 - edit there, not here =====
106
+ ...the kit file, byte for byte...
107
+ // ===== /DatePicker =====
108
+ Blocks without the markers are skipped. check exits 1 when any copy differs from the kit; sync rewrites the
109
+ copies that differ, and nothing outside the markers.
110
+ """
111
+ import hashlib
112
+ import re
113
+ import sys
114
+
115
+
116
+ def main():
117
+ if len(sys.argv) < 5 or sys.argv[1] not in ("check", "sync"):
118
+ sys.exit(__doc__)
119
+ mode, name, kit_path, blocks = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:]
120
+ with open(kit_path, encoding="utf-8", newline="") as f: # newline="": bytes in, bytes out
121
+ kit = f.read()
122
+ if not kit.endswith("\n"):
123
+ sys.exit(kit_path + ": must end with a newline, or the end marker joins its last line")
124
+ sha = hashlib.sha256(kit.encode("utf-8")).hexdigest()[:12]
125
+ start_re = re.compile(r"^// ===== %s: verbatim copy of .*sha256 ([0-9a-f]+) .*=====\n" % re.escape(name), re.M)
126
+ end_re = re.compile(r"^// ===== /%s =====$" % re.escape(name), re.M)
127
+ start_line = "// ===== %s: verbatim copy of %s sha256 %s - edit there, not here =====\n" % (name, kit_path, sha)
128
+ bad = 0
129
+ for path in blocks:
130
+ with open(path, encoding="utf-8", newline="") as f:
131
+ src = f.read()
132
+ starts = list(start_re.finditer(src))
133
+ if not starts:
134
+ continue
135
+ end = end_re.search(src, starts[0].end())
136
+ if len(starts) > 1 or not end:
137
+ print("BROKEN " + path + ": " + ("two start markers" if end else "no end marker"))
138
+ bad += 1
139
+ continue
140
+ m = starts[0]
141
+ if src[m.end():end.start()] == kit and m.group(1) == sha:
142
+ print("ok " + path)
143
+ elif mode == "check":
144
+ print("STALE %s (marker %s, kit %s)" % (path, m.group(1), sha))
145
+ bad += 1
146
+ else:
147
+ with open(path, "w", encoding="utf-8", newline="") as f:
148
+ f.write(src[:m.start()] + start_line + kit + src[end.start():])
149
+ print("synced %s (%s -> %s)" % (path, m.group(1), sha))
150
+ sys.exit(1 if bad else 0)
151
+
152
+
153
+ main()
154
+ ```
155
+
156
+ Tested on 2026-10-07 on copies of two LCDB blocks: `check` passes against their kit, flags both as
157
+ stale against a changed kit, `sync` rewrites only the marked region, and syncing back to the
158
+ original kit gives a file byte-identical to the original block. Blocks without the markers are
159
+ skipped.
160
+
161
+ The same convention fits any shared component, the Combo in
162
+ [searchable-dropdown.md](searchable-dropdown.md) included, whose "keep one canonical copy and port
163
+ changes from there" it makes mechanical.
164
+
165
+ ## Lessons from the rollout
166
+
167
+ **1. Nothing between the field and its scroller may clip.** The calendar is an absolute panel
168
+ (`top-full` or `bottom-full`, `z-40`) inside the field's `relative` wrapper, in the block's own DOM,
169
+ for the same reasons as the Combo's menu: a portal leaves the shadow root and its styles, and
170
+ `position: fixed` breaks under any transformed ancestor
171
+ ([searchable-dropdown.md → Why not portal](searchable-dropdown.md#the-four-things-that-will-bite-you)).
172
+ So every ancestor whose `overflow` is not `visible` clips it: `overflow-hidden`, `overflow-*-auto`,
173
+ `truncate`, `line-clamp-*`. A card given `overflow-hidden` to clip its rounded corners cuts the
174
+ calendar off at the card's edge. Same rule as Combo rule 1: no clipping class on anything that
175
+ contains a date field; bound an over-wide child at the child. A scroller the field genuinely sits
176
+ in (a modal body, a table scroller) is fine, because the placement measures it: the kit's
177
+ `dpClipBox` walks every ancestor, out through the shadow host, as `comboClipBox` does.
178
+
179
+ **2. In a short modal body, the calendar may scroll its trigger partly out of view.** The panel
180
+ opens down when it fits below, up when it fits above. When it fits neither way, the box that cuts
181
+ it off is scrolled by the smaller amount that makes room. When no scroll makes it fit, it opens
182
+ down and scrolls the box as far as it can, and that limit is the trigger's **bottom** edge, not its
183
+ top. The first version stopped at the trigger's top, to keep the whole field in view. In the
184
+ family page's Add child modal, whose body is short and sized by its content, that left the
185
+ calendar's Today / Clear row below the body's edge, cut off. Allowing the trigger to scroll
186
+ partly away fixed it. Down is the fallback because what hangs past a scroller's bottom extends its
187
+ scroll range, and what hangs past its top never does. The focused day is kept in view the same
188
+ way: scroll the one clipping box, never `scrollIntoView` (it scrolls every ancestor, the page
189
+ included), and issue the scroll from a `setTimeout` (Hard Constraint 17).
190
+
191
+ **3. Blocks that size controls by named containers pass the text size in.** The trigger must
192
+ look like the block's text inputs, text size included. The kit's default,
193
+ `text-[16px] @min-[48rem]:text-[14px]`, matches inputs sized by the nearest container (16px on a
194
+ narrow block, as inputs there must be so iOS does not zoom on focus). An unnamed `@min-[48rem]:`
195
+ resolves against the nearest ancestor container, whatever its name, which need not be the
196
+ container the block's own inputs are sized by.
197
+ The family page sizes its fields by named containers (`@min-[48rem]/page:` on the page,
198
+ `@min-[30rem]/panel:` inside its modal), and inside the modal the nearest container is the panel,
199
+ which is at most 40rem wide, so the default never reached 14px: the date fields showed 16px text
200
+ beside 14px inputs. The fix was a prop, not an edit inside the kit. Pass the input's own size
201
+ classes, copied from the block's input class string, as a static string so Tailwind's scan finds
202
+ them:
203
+
204
+ ```tsx
205
+ <DatePicker … textClass="text-[16px] @min-[48rem]/page:text-[14px] @min-[30rem]/panel:text-[14px]" />
206
+ ```
207
+
208
+ A block with no `@container` at all never matches the default's `@min-` class, so its trigger
209
+ stays at 16px. Pass that block's own sizes too.
210
+
211
+ **4. Escape on an open calendar closes only the calendar.** The panel's keydown handler (and the
212
+ trigger's, while open) calls `e.preventDefault()` and `e.stopPropagation()`, closes the calendar
213
+ and puts focus back on the trigger. An in-block modal listens for Escape on `document`
214
+ ([common-patterns.md → A modal above Softr's bars](common-patterns.md#a-modal-above-softrs-bars))
215
+ and returns early on `defaultPrevented`; `stopPropagation` covers a document listener that does
216
+ not check it. React's handler runs at the block's root, inside the shadow root, before the event
217
+ reaches `document`. Without this, one Escape closes the calendar and the modal, or shows the modal's
218
+ "Discard your changes?" prompt with the calendar still open. Same rule as the Combo's
219
+ ([Move focus into the Combo when it opens](searchable-dropdown.md#move-focus-into-the-combo-when-it-opens)).
220
+
221
+ **5. A backdrop click while a calendar is open must not silently drop the form.** The calendar
222
+ closes itself on `pointerdown` (a capture listener on `document`, read through `composedPath()`).
223
+ The press continues, and its `click` lands on the modal's backdrop, which dismisses the modal. A
224
+ form modal that dismisses without asking loses everything typed, for a click the user meant only
225
+ to close a calendar. Two acceptable outcomes:
226
+
227
+ - **Close only the calendar.** The modal records, at `pointerdown`, whether a calendar inside it
228
+ was open, and the backdrop's click returns early if so:
229
+
230
+ ```tsx
231
+ // In the modal's mount effect: registered before any calendar inside can open, so it runs before the
232
+ // calendar's own pointerdown close (capture listeners on one node run in registration order).
233
+ const onPointerDownCapture = () => {
234
+ pickerOpenAtDownRef.current = !!panel && !!panel.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]');
235
+ };
236
+ document.addEventListener("pointerdown", onPointerDownCapture, true);
237
+ // …and in the effect's cleanup: document.removeEventListener("pointerdown", onPointerDownCapture, true);
238
+
239
+ // The backdrop:
240
+ onClick={() => {
241
+ if (pickerOpenAtDownRef.current) {
242
+ pickerOpenAtDownRef.current = false; // that press only closed the calendar
243
+ return;
244
+ }
245
+ dismissRef.current();
246
+ }}
247
+ ```
248
+
249
+ Read it at `pointerdown`, the event the calendar closes on: a `mousedown` listener may already find
250
+ it closed. A modal that also holds Combos (which close on `mousedown`) keeps its `mousedown` guard
251
+ for them as well.
252
+ - **Ask.** The backdrop goes through the modal's dismiss request, which shows "Discard your
253
+ changes?" when anything was typed — the in-block modal's own rule for Escape, the X and Cancel.
254
+
255
+ Either is fine. A backdrop that closes a dirty form without asking is not.
256
+
257
+ **6. A modal's focus fix-up must wait one task.** Some in-block modals watch their panel with a
258
+ `MutationObserver` and pull focus back to the panel when the focused control unmounts (Go back,
259
+ Keep editing, a failed confirm), so Tab stays inside the dialog. The calendar closes on blur when
260
+ Tab leaves it, so it unmounts in the middle of Tab's focus move. In Chromium, a synchronous
261
+ `panel.focus()` from the observer at that moment cancels the move: Tab landed on the modal panel
262
+ instead of the next field. Check one task later, and only refocus if focus really fell out:
263
+
264
+ ```tsx
265
+ let observerTimer = 0;
266
+ const observer = new MutationObserver(() => {
267
+ window.clearTimeout(observerTimer);
268
+ observerTimer = window.setTimeout(() => {
269
+ const a = root.activeElement as HTMLElement | null; // root = panel.getRootNode(): the shadow root
270
+ if (!panel.isConnected) return;
271
+ if (!a || a === document.body || !panel.contains(a)) panel.focus({ preventScroll: true });
272
+ }, 0);
273
+ });
274
+ observer.observe(panel, { childList: true, subtree: true });
275
+ // cleanup: window.clearTimeout(observerTimer); observer.disconnect();
276
+ ```
277
+
278
+ **7. Safari and macOS Firefox do not focus a clicked button.** Chrome does. So the kit never relies
279
+ on the click: once the panel is placed, it moves focus itself to the active day (the selected day,
280
+ else today, else the nearest day `min` / `max` allow), with `preventScroll`. On those browsers,
281
+ focus would otherwise stay in the text field above the date field, and keys and Escape would go
282
+ there. Two related details the kit handles: a blur with no `relatedTarget` (a click on nothing
283
+ focusable, the window losing focus) is not a Tab out, so it leaves the calendar open for the
284
+ click-outside listener to judge; and Chrome sends no `mousedown` for a disabled button (a dimmed
285
+ day, Today out of range) but focuses the panel itself instead, so the panel hands that focus on to
286
+ the active day. To reproduce Safari in any browser: focus a text field, call `.click()` on the
287
+ trigger from the console (a synthetic click moves focus nowhere either), then read the block's
288
+ shadow-root `activeElement`, which must be the day marked `data-dp-active="true"`.
289
+
290
+ **8. Date-only values stay `"yyyy-MM-dd"` strings.** In, out, `min`, `max`:
291
+
292
+ - **Compare as strings.** Zero-padded ISO days sort in date order, so `start <= end`,
293
+ `max={end}` and "not after today" need no `Date` at all.
294
+ - **Load a stored date-only field by its first ten characters.** It arrives as midnight UTC
295
+ ([fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc)), whose date part
296
+ is the intended day: `String(raw ?? "").slice(0, 10)`. Only for fields you know are date-only.
297
+ - **Write the string back as it is.** That is what the native field sent, so the saved bytes don't
298
+ change (verify it, below).
299
+ - **Display through a local date:** `toLocalDate()` from fields.md, or the kit's `dpParse`. Never
300
+ `new Date("yyyy-MM-dd")`, which is UTC midnight and a day early west of Greenwich.
301
+ - **Today is the browser's local day:** `format(new Date(), "yyyy-MM-dd")`. Not
302
+ `new Date().toISOString().slice(0, 10)`, which is the UTC day: tomorrow, on a US evening.
303
+
304
+ ## Verifying a block
305
+
306
+ **1. No native date field is left.** In the source, every hit of
307
+ `grep -nE 'type="(date|datetime-local|month)"' <block files>` must be a prop on the block's own
308
+ wrapper that renders the kit (`<FText type="date" …>`), never an `<input>`. Then in the preview,
309
+ with every form and modal that holds a date field opened, the count across all shadow roots is 0
310
+ ([browser-checks.md](browser-checks.md) for the preview session and `eval`):
311
+
312
+ ```js
313
+ (() => {
314
+ const roots = [...document.querySelectorAll('*')].map(e => e.shadowRoot).filter(Boolean);
315
+ const sel = 'input[type="date"], input[type="datetime-local"], input[type="month"]';
316
+ return roots.reduce((n, r) => n + r.querySelectorAll(sel).length, 0) + document.querySelectorAll(sel).length;
317
+ })()
318
+ ```
319
+
320
+ And `kit-sync.py check` passes, so every marked copy is the current kit.
321
+
322
+ **2. Nothing clips or covers the open calendar.** Open the calendar in the tight spots: the last
323
+ field of a modal body, a field low in a table scroller, near the window's right edge, at phone
324
+ width (375px, above Softr's tab bar). Then hit-test points on the panel through the shadow root
325
+ (`document.elementFromPoint` only returns the host):
326
+
327
+ ```js
328
+ (() => {
329
+ const root = [...document.querySelectorAll('*')].map(e => e.shadowRoot).filter(Boolean)
330
+ .find(s => s.querySelector('[role="dialog"][id$="-calendar"]'));
331
+ if (!root) return 'no open calendar';
332
+ const panel = root.querySelector('[role="dialog"][id$="-calendar"]');
333
+ const r = panel.getBoundingClientRect();
334
+ const pts = { topLeft: [r.left + 6, r.top + 6], topRight: [r.right - 6, r.top + 6],
335
+ bottomLeft: [r.left + 6, r.bottom - 6], bottomRight: [r.right - 6, r.bottom - 6] };
336
+ for (const b of panel.querySelectorAll('button')) {
337
+ const t = b.textContent.trim();
338
+ if (t === 'Today' || t === 'Clear') { const q = b.getBoundingClientRect(); pts[t] = [q.left + q.width / 2, q.top + q.height / 2]; }
339
+ }
340
+ const out = {};
341
+ for (const [k, [x, y]] of Object.entries(pts)) { const el = root.elementFromPoint(x, y); out[k] = !!el && panel.contains(el); }
342
+ return JSON.stringify(out); // every value true; a false names the corner a clip or a bar covers
343
+ })()
344
+ ```
345
+
346
+ Wait a beat after opening: when the panel needs room, the placement scrolls a box from a
347
+ `setTimeout`.
348
+
349
+ **3. Escape order.** With real key presses (`ab press Escape`), in a modal with something typed in
350
+ another field and the calendar open: the first Escape closes the calendar only (the modal is open,
351
+ no discard prompt, focus is on the date field's trigger); the second Escape reaches the modal
352
+ (it closes, or asks to discard). Then the backdrop case of lesson 5, and Tab out of the open calendar
353
+ inside the modal: focus lands on the next field, not on the modal panel (lesson 6).
354
+
355
+ **4. The saved value is byte-identical to the native version's.** Block saves first
356
+ ([browser-checks.md → Block saves before any click, and prove it](browser-checks.md#4-block-saves-before-any-click-and-prove-it)),
357
+ pick a day, save, and read the aborted request's payload: the field holds the same string the native
358
+ field sent for that day (`"2026-10-15"`, not a timestamp). Clear sends what an emptied native field
359
+ sent, unless the block deliberately changed it (one LCDB block now saves a cleared optional date as
360
+ `null`).
361
+
362
+ ## Re-skinning the kit
363
+
364
+ The tokens sit in `DP_C` at the top, and the Tailwind class strings repeat some of them as literal
365
+ hex, because a class string cannot read a constant (each string has a comment saying which hex is
366
+ which token). Change both, with a find-and-replace per hex over the kit file:
367
+
368
+ | Token | Kit value (LCDB) | Used for |
369
+ |---|---|---|
370
+ | `primary` | `#680058` | Selected day, today's ring, focus rings, Today button, the open trigger's border |
371
+ | `primaryDeep` | `#4E0042` | Hover on the selected day |
372
+ | `primaryTint` | `#F5EAF3` | Hover on Today |
373
+ | `ink` | `#030712` | Text |
374
+ | `muted` | `#6B7280` | Placeholder, weekday names, days of the next and previous month |
375
+ | `canvas` | `#F3F4F6` | Hover on days and icon buttons |
376
+ | `border` | `#D1D5DB` | Trigger and panel border |
377
+ | `divider` | `#E5E7EB` | The rule above Today / Clear (inline only) |
378
+ | `faint` | `#C4C8CF` | Days outside `min` / `max` |
379
+
380
+ Also: `DP_FONT_BODY` (the trigger's value, as the block's text inputs) and `DP_FONT_DISPLAY` (the
381
+ calendar), the trigger's height and radius in `DP_TRIGGER` (match the block's inputs), and
382
+ `DP_SOFTR_TOP_BAR`: `0` as verified (LCDB has no top bar), `56` when the app shows Softr's sticky
383
+ top bar, so the calendar never opens up under it (untested at 56; the bar heights are in
384
+ [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you)). After a
385
+ re-skin, the kit file's sha changes; sync it into every block.
386
+
387
+ ## The kit file
388
+
389
+ Below is the whole kit, ready to save as the project's kit file. It is the LCDB kit (sha256
390
+ `f5753a191077…`, deployed 2026-10-07) with its comments made client-neutral and `DP_SOFTR_TOP_BAR`
391
+ added; at `0` it behaves exactly as deployed. This copy (sha256 `4763ca393a6c…`) passed the same
392
+ 22 harness checks in Chromium 149 on 2026-10-07; the Firefox run was not repeated for it.
393
+
394
+ <details>
395
+ <summary>DatePicker.tsx (694 lines)</summary>
396
+
397
+ ```tsx
398
+ /** DatePicker — brand-styled date field for Softr Vibe Coding blocks, replacing native <input type="date">: its
399
+ * calendar pop-up is browser UI that no CSS reaches, so this one is drawn in the block's own DOM. Paste everything below
400
+ * this comment at MODULE scope (never inside Block()). From softr-vibe-coding references/date-picker.md: the Lane County
401
+ * Diaper Bank kit of 2026-10-07 (sha256 f5753a191077), comments generalized and DP_SOFTR_TOP_BAR added (0 = the
402
+ * verified behaviour). That kit passed 22 checks in a React 18.2 shadow-root harness with Tailwind v4 (Chromium 149 and
403
+ * macOS Firefox 157, TZ America/Los_Angeles, today fixed at 2026-10-07) and shipped in 12 blocks.
404
+ *
405
+ * Imports it needs (merge the names into the block's existing import lines):
406
+ * import { useEffect, useLayoutEffect, useRef, useState } from "react";
407
+ * import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
408
+ * import { addDays, addMonths, addYears, format, startOfMonth, startOfWeek } from "date-fns";
409
+ *
410
+ * Usage:
411
+ * <label id="start-label" htmlFor="start">Start date</label>
412
+ * <DatePicker id="start" labelledBy="start-label" value={start} onChange={setStart} max={end} clearable />
413
+ * value, min and max are "yyyy-MM-dd" strings or ""; onChange(next) receives "yyyy-MM-dd", or "" from Clear.
414
+ * textClass sets the trigger's text size. The default suits blocks that size controls by the nearest container; a block
415
+ * that sizes them by named containers passes its own (e.g. "text-[16px] @min-[48rem]/page:text-[14px]").
416
+ * The label's htmlFor may stay: a click on the label opens the picker, as it focuses a native date field.
417
+ * Nothing between the field and the scroller it belongs to may clip (overflow-hidden, truncate, line-clamp): the
418
+ * calendar is an absolute panel in the block's DOM (softr-vibe-coding references/searchable-dropdown.md, rule 1).
419
+ */
420
+
421
+ // Brand tokens this component uses (the project's DESIGN.md): a copy, because blocks cannot import each other (Hard
422
+ // Constraint 22). Tailwind class strings repeat some as literal hex; each says which. Re-skin both.
423
+ const DP_C = {
424
+ primary: "#680058",
425
+ primaryDeep: "#4E0042", // hover on primary
426
+ primaryTint: "#F5EAF3",
427
+ ink: "#030712",
428
+ muted: "#6B7280", // labels, weekday names, days of the next and previous month
429
+ canvas: "#F3F4F6", // hover on days and icon buttons
430
+ border: "#D1D5DB",
431
+ divider: "#E5E7EB",
432
+ faint: "#C4C8CF", // days outside min / max: dimmed, not selectable (not a text colour for anything to read)
433
+ } as const;
434
+ const DP_SHADOW_MENU = "0 8px 24px rgba(3,7,18,0.08)";
435
+ const DP_FONT_BODY = "Verdana, Geneva, 'DejaVu Sans', Tahoma, sans-serif"; // the field's value, as in the text inputs
436
+ const DP_FONT_DISPLAY = "'Poppins', ui-sans-serif, system-ui, sans-serif"; // the calendar: heading, days, buttons
437
+ const DP_PANEL_REM = 18.5; // panel width: 296px, fits a 343px phone content column
438
+ const DP_GAP = 4; // trigger to panel
439
+ const DP_EDGE = 8; // room kept free at the edges of the strip the panel can paint into
440
+ const DP_SOFTR_TAB_BAR = 57; // Softr's phone tab bar, window below 768px: measured 57px
441
+ const DP_SOFTR_TOP_BAR = 0; // 56 when the app shows Softr's sticky top bar (window 768px and up); untested at 56
442
+ const DP_WEEKDAYS = [
443
+ ["Su", "Sunday"],
444
+ ["Mo", "Monday"],
445
+ ["Tu", "Tuesday"],
446
+ ["We", "Wednesday"],
447
+ ["Th", "Thursday"],
448
+ ["Fr", "Friday"],
449
+ ["Sa", "Saturday"],
450
+ ] as const;
451
+
452
+ // Days: a 36px circle in a ~39px column. Hover lives in classes (inline would beat it).
453
+ // #680058 = DP_C.primary · #4E0042 = DP_C.primaryDeep · #030712 = DP_C.ink · #6B7280 = DP_C.muted
454
+ // #F3F4F6 = DP_C.canvas · #C4C8CF = DP_C.faint
455
+ const DP_DAY =
456
+ "mx-auto flex h-9 w-9 items-center justify-center rounded-full text-[14px] leading-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]";
457
+ const DP_DAY_SELECTED = "bg-[#680058] font-semibold text-white hover:bg-[#4E0042]";
458
+ const DP_DAY_TODAY = "font-semibold text-[#680058] hover:bg-[#F3F4F6]";
459
+ const DP_DAY_IN = "text-[#030712] hover:bg-[#F3F4F6]";
460
+ const DP_DAY_OUT = "text-[#6B7280] hover:bg-[#F3F4F6]";
461
+ const DP_DAY_OFF = "cursor-default text-[#C4C8CF]";
462
+ // Months in the month / year view: same states, as 44px pills.
463
+ const DP_MONTH =
464
+ "flex h-11 w-full items-center justify-center rounded-full text-[14px] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]";
465
+ // Month arrows: 44px targets. aria-disabled (not disabled), so an arrow keeps focus when it reaches min or max.
466
+ const DP_ICON_BTN =
467
+ "flex h-11 w-11 items-center justify-center rounded-full transition-colors hover:bg-[#F3F4F6] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:hover:bg-transparent";
468
+ // The trigger is the blocks' text input: 44px. Its text size comes from the textClass prop: by default 16px below a
469
+ // 48rem container (stops iOS zoom) and 14px from it.
470
+ // #D1D5DB = DP_C.border · #680058 = DP_C.primary (focus border and ring; kept while the calendar is open)
471
+ const DP_TRIGGER =
472
+ "flex h-11 w-full items-center justify-between gap-2 rounded-xl border border-[#D1D5DB] bg-white px-3 text-left transition-colors focus:border-[#680058] focus:outline-none focus:ring-2 focus:ring-[#680058]/25 aria-expanded:border-[#680058] aria-expanded:ring-2 aria-expanded:ring-[#680058]/25 disabled:cursor-not-allowed disabled:opacity-60";
473
+
474
+ type DpPlace = { up: boolean; x: number; maxW: number | null; ready: boolean };
475
+
476
+ // "yyyy-MM-dd" -> a local-midnight Date, or null. Never new Date("yyyy-MM-dd"): that is UTC midnight, a day early
477
+ // west of Greenwich. Rejects impossible dates (2026-02-30) instead of rolling them over.
478
+ function dpParse(s: string | null | undefined): Date | null {
479
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s ?? "").trim());
480
+ if (!m) return null;
481
+ const y = Number(m[1]);
482
+ const mo = Number(m[2]) - 1;
483
+ const day = Number(m[3]);
484
+ const d = new Date(2000, 0, 1);
485
+ d.setFullYear(y, mo, day); // setFullYear: years below 100 stay themselves
486
+ return d.getFullYear() === y && d.getMonth() === mo && d.getDate() === day ? d : null;
487
+ }
488
+ const dpIso = (d: Date) => format(d, "yyyy-MM-dd");
489
+ const dpKey = (d: Date) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate(); // compare days, not times
490
+ function dpToday() {
491
+ const n = new Date();
492
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate());
493
+ }
494
+ function dpAllowed(d: Date, min: Date | null, max: Date | null) {
495
+ return (!min || dpKey(d) >= dpKey(min)) && (!max || dpKey(d) <= dpKey(max));
496
+ }
497
+ function dpClamp(d: Date, min: Date | null, max: Date | null) {
498
+ if (min && dpKey(d) < dpKey(min)) return min;
499
+ if (max && dpKey(d) > dpKey(max)) return max;
500
+ return d;
501
+ }
502
+
503
+ // The strip of screen the panel can paint into: the window (minus Softr's bars), cut down by every ancestor that
504
+ // clips, as comboClipBox in searchable-dropdown.md. Also returns the boxes that set the top and bottom edges (null =
505
+ // the window), for the nudge in dpPlace.
506
+ function dpClipBox(node: HTMLElement) {
507
+ const phone = window.innerWidth < 768;
508
+ let top = phone ? 0 : DP_SOFTR_TOP_BAR;
509
+ let left = 0;
510
+ let right = window.innerWidth;
511
+ let bottom = window.innerHeight - (phone ? DP_SOFTR_TAB_BAR : 0);
512
+ let topEl: HTMLElement | null = null;
513
+ let bottomEl: HTMLElement | null = null;
514
+ let el: any = node;
515
+ while (el && el !== document.body && el !== document.documentElement) {
516
+ // clientHeight / clientWidth are 0 for the boxes overflow does not apply to (inline, display: contents)
517
+ if (el.clientHeight > 0 || el.clientWidth > 0) {
518
+ const cs = window.getComputedStyle(el);
519
+ const r = el.getBoundingClientRect();
520
+ if (cs.overflowY !== "visible") {
521
+ const t = r.top + el.clientTop; // the clip edge is the padding box: inside the border
522
+ const b = t + el.clientHeight; // and above a horizontal scrollbar
523
+ if (t > top) {
524
+ top = t;
525
+ topEl = el;
526
+ }
527
+ if (b < bottom) {
528
+ bottom = b;
529
+ bottomEl = el;
530
+ }
531
+ }
532
+ if (cs.overflowX !== "visible") {
533
+ const l = r.left + el.clientLeft;
534
+ const rr = l + el.clientWidth;
535
+ if (l > left) left = l;
536
+ if (rr < right) right = rr;
537
+ }
538
+ }
539
+ // Where the parent chain ends at the shadow root, carry on from its host.
540
+ el = el.parentElement || (el.getRootNode ? (el.getRootNode() as any).host : null) || null;
541
+ }
542
+ return { top, bottom, left, right, topEl, bottomEl };
543
+ }
544
+
545
+ // Can this edge box (null = the window) be scrolled, and how far is it scrolled now?
546
+ function dpScrollable(el: HTMLElement | null) {
547
+ return el
548
+ ? /(auto|scroll)/.test(window.getComputedStyle(el).overflowY)
549
+ : window.getComputedStyle(document.documentElement).overflowY !== "hidden";
550
+ }
551
+
552
+ // focus({ preventScroll: true }) does not reveal what it focuses. When the active day sits past the edge of the box
553
+ // that clips it (a short modal body), scroll that one box just far enough; never scrollIntoView, which scrolls every
554
+ // ancestor including the page.
555
+ function dpKeepVisible(el: HTMLElement) {
556
+ const clip = dpClipBox(el);
557
+ const r = el.getBoundingClientRect();
558
+ if (r.bottom > clip.bottom - DP_EDGE && dpScrollable(clip.bottomEl)) {
559
+ const by = r.bottom - (clip.bottom - DP_EDGE);
560
+ if (clip.bottomEl) clip.bottomEl.scrollTop += by;
561
+ else window.scrollBy(0, by);
562
+ } else if (r.top < clip.top + DP_EDGE && dpScrollable(clip.topEl)) {
563
+ const by = clip.top + DP_EDGE - r.top;
564
+ if (clip.topEl) clip.topEl.scrollTop -= by;
565
+ else window.scrollBy(0, -by);
566
+ }
567
+ }
568
+
569
+ // Where the panel goes. Down when it fits below, else up when it fits above. When it fits neither way (a short modal
570
+ // body, a small window), the box that cuts it off is nudged by the smaller scroll that makes it fit with the trigger
571
+ // still in view: up into room above (only as far as that box is scrolled down), or down into room below. When no
572
+ // nudge makes it fit: down, scrolled as far as the trigger allows, so the rest can be scrolled to (what hangs past a
573
+ // scroller's bottom extends its scroll range; past its top it never does); with no scroller at all, the larger side.
574
+ // Horizontally it hangs from the trigger's left edge and shifts left to stay inside the strip.
575
+ function dpPlace(wrapper: HTMLElement, panelH: number) {
576
+ const clip = dpClipBox(wrapper);
577
+ const r = wrapper.getBoundingClientRect();
578
+ const below = clip.bottom - r.bottom - DP_GAP - DP_EDGE;
579
+ const above = r.top - clip.top - DP_GAP - DP_EDGE;
580
+ let up = below < panelH && above >= panelH;
581
+ let scroller: HTMLElement | Window | null = null;
582
+ let scrollBy = 0; // positive scrolls down (the content moves up)
583
+ if (below < panelH && above < panelH) {
584
+ const needUp = panelH - above; // scroll the top box back this far to make room above
585
+ const needDown = panelH - below; // scroll the bottom box on this far to make room below
586
+ const topScroll = clip.topEl ? clip.topEl.scrollTop : window.scrollY;
587
+ const canUp =
588
+ dpScrollable(clip.topEl) && topScroll >= needUp && r.bottom + needUp <= clip.bottom - DP_EDGE;
589
+ const canDown = dpScrollable(clip.bottomEl);
590
+ const downFits = canDown && r.top - needDown >= clip.top + DP_EDGE;
591
+ if (canUp && (!downFits || needUp <= needDown)) {
592
+ up = true;
593
+ scroller = clip.topEl || window;
594
+ scrollBy = -needUp;
595
+ } else if (canDown) {
596
+ up = false;
597
+ scroller = clip.bottomEl || window;
598
+ // Up to the trigger's bottom edge, not its top: in a short modal body the whole calendar then fits, and only
599
+ // part of the trigger scrolls out of view.
600
+ scrollBy = Math.max(0, Math.min(needDown, r.bottom - clip.top - DP_EDGE));
601
+ } else {
602
+ up = above > below;
603
+ }
604
+ }
605
+ const remPx = parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
606
+ const room = clip.right - clip.left - 2 * DP_EDGE;
607
+ const w = Math.min(DP_PANEL_REM * remPx, Math.max(256, room));
608
+ let x = 0;
609
+ if (r.left + w > clip.right - DP_EDGE) x = clip.right - DP_EDGE - w - r.left;
610
+ if (r.left + x < clip.left + DP_EDGE) x = clip.left + DP_EDGE - r.left;
611
+ return { up, x: Math.round(x), maxW: w < DP_PANEL_REM * remPx ? Math.floor(w) : null, scroller, scrollBy };
612
+ }
613
+
614
+ function DatePicker({
615
+ id,
616
+ value,
617
+ onChange,
618
+ placeholder = "Choose a date",
619
+ min,
620
+ max,
621
+ labelledBy,
622
+ describedBy,
623
+ clearable = false,
624
+ disabled = false,
625
+ textClass = "text-[16px] @min-[48rem]:text-[14px]",
626
+ }: {
627
+ id: string;
628
+ value: string;
629
+ onChange: (next: string) => void;
630
+ placeholder?: string;
631
+ min?: string;
632
+ max?: string;
633
+ labelledBy?: string;
634
+ describedBy?: string;
635
+ clearable?: boolean;
636
+ disabled?: boolean;
637
+ textClass?: string;
638
+ }) {
639
+ const rootRef = useRef<HTMLDivElement | null>(null);
640
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
641
+ const panelRef = useRef<HTMLDivElement | null>(null);
642
+ const focusPending = useRef(false); // move focus to the active day / month after the next render
643
+ const triggerPointer = useRef(false); // a pointer press on the trigger is under way (its click toggles)
644
+ const [open, setOpen] = useState(false);
645
+ const [view, setView] = useState<"days" | "months">("days");
646
+ const [focusDate, setFocusDate] = useState<Date>(() => dpToday());
647
+ const [today, setToday] = useState<Date>(() => dpToday());
648
+ const [place, setPlace] = useState<DpPlace>({ up: false, x: 0, maxW: null, ready: false });
649
+
650
+ const selected = dpParse(value);
651
+ const minD = dpParse(min);
652
+ const maxD = dpParse(max);
653
+ const shown = selected ? format(selected, "MMM d, yyyy") : value; // an unreadable value is shown as it is
654
+ const dialogId = `${id}-calendar`;
655
+ const valueId = `${id}-value`;
656
+ const monthLabelId = `${id}-month`;
657
+
658
+ function openPicker() {
659
+ if (disabled) return;
660
+ const t = dpToday();
661
+ setToday(t);
662
+ // The selected day, else today, else the nearest day min / max allow.
663
+ setFocusDate(dpClamp(selected || t, minD, maxD));
664
+ setView("days");
665
+ setPlace({ up: false, x: 0, maxW: null, ready: false });
666
+ focusPending.current = true;
667
+ setOpen(true);
668
+ }
669
+ function closePicker(refocus: boolean) {
670
+ setOpen(false);
671
+ setView("days");
672
+ focusPending.current = false;
673
+ if (refocus) triggerRef.current?.focus({ preventScroll: true });
674
+ }
675
+ function pick(d: Date) {
676
+ if (!dpAllowed(d, minD, maxD)) return;
677
+ onChange(dpIso(d));
678
+ closePicker(true);
679
+ }
680
+ function moveTo(d: Date) {
681
+ focusPending.current = true;
682
+ setFocusDate(dpClamp(d, minD, maxD));
683
+ }
684
+
685
+ // Place the panel before it paints: measured hidden, then shown up or down, shifted to stay inside the strip.
686
+ useLayoutEffect(() => {
687
+ if (!open) return;
688
+ const wrap = rootRef.current;
689
+ const panel = panelRef.current;
690
+ if (!wrap || !panel) return;
691
+ const p = dpPlace(wrap, panel.offsetHeight);
692
+ setPlace({ up: p.up, x: p.x, maxW: p.maxW, ready: true });
693
+ if (p.scroller && p.scrollBy !== 0) {
694
+ const sc = p.scroller;
695
+ const by = p.scrollBy;
696
+ // Hard Constraint 17: programmatic scrolls go through setTimeout. Only this one box scrolls (no scrollIntoView).
697
+ window.setTimeout(() => {
698
+ if (sc === window) window.scrollBy(0, by);
699
+ else (sc as HTMLElement).scrollTop += by;
700
+ }, 0);
701
+ }
702
+ }, [open]);
703
+
704
+ // Move focus into the calendar on open, and onto the active day or month after keyboard moves and view changes.
705
+ // Done here, not by the click: Safari and macOS Firefox do not focus a clicked button, and a synthetic .click()
706
+ // moves focus nowhere. preventScroll: focusing must not scroll the page or a modal body.
707
+ useEffect(() => {
708
+ if (!open || !place.ready || !focusPending.current) return;
709
+ focusPending.current = false;
710
+ const el = panelRef.current?.querySelector('[data-dp-active="true"]') as HTMLElement | null;
711
+ if (!el) return;
712
+ el.focus({ preventScroll: true });
713
+ // After the placement's own scroll (queued first, so it runs first).
714
+ window.setTimeout(() => {
715
+ if (el.isConnected) dpKeepVisible(el);
716
+ }, 0);
717
+ });
718
+
719
+ // Click outside closes: pointerdown on the document, read through composedPath() because the shadow root
720
+ // retargets the event's target to the block's host.
721
+ useEffect(() => {
722
+ if (!open) return;
723
+ const onDown = (e: PointerEvent) => {
724
+ const root = rootRef.current;
725
+ if (!root) return;
726
+ const path = e.composedPath ? e.composedPath() : [];
727
+ if (path.indexOf(root) !== -1) return;
728
+ if (path.length === 0 && e.target && root.contains(e.target as Node)) return;
729
+ closePicker(false);
730
+ };
731
+ document.addEventListener("pointerdown", onDown, true);
732
+ return () => document.removeEventListener("pointerdown", onDown, true);
733
+ }, [open]);
734
+
735
+ // A window resize (a turned tablet) re-places the open panel.
736
+ useEffect(() => {
737
+ if (!open) return;
738
+ const onResize = () => {
739
+ const wrap = rootRef.current;
740
+ const panel = panelRef.current;
741
+ if (!wrap || !panel) return;
742
+ const p = dpPlace(wrap, panel.offsetHeight);
743
+ setPlace({ up: p.up, x: p.x, maxW: p.maxW, ready: true });
744
+ };
745
+ window.addEventListener("resize", onResize);
746
+ return () => window.removeEventListener("resize", onResize);
747
+ }, [open]);
748
+
749
+ useEffect(() => {
750
+ if (disabled && open) closePicker(false);
751
+ }, [disabled, open]);
752
+
753
+ // Keyboard on the day grid (WAI-ARIA date picker dialog). Enter and Space are the buttons' own clicks.
754
+ function onDayKey(e: React.KeyboardEvent) {
755
+ const d = focusDate;
756
+ let next: Date | null = null;
757
+ if (e.key === "ArrowLeft") next = addDays(d, -1);
758
+ else if (e.key === "ArrowRight") next = addDays(d, 1);
759
+ else if (e.key === "ArrowUp") next = addDays(d, -7);
760
+ else if (e.key === "ArrowDown") next = addDays(d, 7);
761
+ else if (e.key === "Home") next = startOfWeek(d, { weekStartsOn: 0 });
762
+ else if (e.key === "End") next = addDays(startOfWeek(d, { weekStartsOn: 0 }), 6);
763
+ else if (e.key === "PageUp") next = e.shiftKey ? addYears(d, -1) : addMonths(d, -1);
764
+ else if (e.key === "PageDown") next = e.shiftKey ? addYears(d, 1) : addMonths(d, 1);
765
+ else if (e.key === "Enter" && e.repeat) e.preventDefault(); // a held Enter that opened the picker must not pick
766
+ if (!next) return;
767
+ e.preventDefault();
768
+ moveTo(next);
769
+ }
770
+ function onMonthKey(e: React.KeyboardEvent) {
771
+ const d = focusDate;
772
+ let next: Date | null = null;
773
+ if (e.key === "ArrowLeft") next = addMonths(d, -1);
774
+ else if (e.key === "ArrowRight") next = addMonths(d, 1);
775
+ else if (e.key === "ArrowUp") next = addMonths(d, -3);
776
+ else if (e.key === "ArrowDown") next = addMonths(d, 3);
777
+ else if (e.key === "Home") next = addMonths(d, -d.getMonth());
778
+ else if (e.key === "End") next = addMonths(d, 11 - d.getMonth());
779
+ else if (e.key === "PageUp") next = addYears(d, -1);
780
+ else if (e.key === "PageDown") next = addYears(d, 1);
781
+ else if (e.key === "Enter" && e.repeat) e.preventDefault();
782
+ if (!next) return;
783
+ e.preventDefault();
784
+ moveTo(next);
785
+ }
786
+ function chooseMonth(m: number) {
787
+ // Same day of the month where it exists (addMonths clamps the 31st), inside min / max.
788
+ moveTo(addMonths(focusDate, m - focusDate.getMonth()));
789
+ setView("days");
790
+ }
791
+
792
+ // The month on show and the arrows' limits.
793
+ const monthStart = startOfMonth(focusDate);
794
+ const year = focusDate.getFullYear();
795
+ const prevOff =
796
+ view === "days"
797
+ ? !!minD && dpKey(addDays(monthStart, -1)) < dpKey(minD)
798
+ : !!minD && year - 1 < minD.getFullYear();
799
+ const nextOff =
800
+ view === "days"
801
+ ? !!maxD && dpKey(addMonths(monthStart, 1)) > dpKey(maxD)
802
+ : !!maxD && year + 1 > maxD.getFullYear();
803
+ function step(dir: -1 | 1) {
804
+ if (dir < 0 ? prevOff : nextOff) return;
805
+ const next = dpClamp(view === "days" ? addMonths(focusDate, dir) : addYears(focusDate, dir), minD, maxD);
806
+ setFocusDate(next); // a click on an arrow leaves focus on the arrow
807
+ }
808
+ const todayOff = !dpAllowed(today, minD, maxD);
809
+
810
+ const gridStart = startOfWeek(monthStart, { weekStartsOn: 0 });
811
+ const weeks: Date[][] = [];
812
+ for (let w = 0; w < 6; w++) {
813
+ const row: Date[] = [];
814
+ for (let i = 0; i < 7; i++) row.push(addDays(gridStart, w * 7 + i));
815
+ weeks.push(row);
816
+ }
817
+ const heading = view === "days" ? format(focusDate, "MMMM yyyy") : String(year);
818
+
819
+ return (
820
+ <div ref={rootRef} className="relative">
821
+ <button
822
+ ref={triggerRef}
823
+ id={id}
824
+ type="button"
825
+ disabled={disabled}
826
+ aria-haspopup="dialog"
827
+ aria-expanded={open}
828
+ aria-controls={dialogId}
829
+ aria-labelledby={labelledBy ? `${labelledBy} ${valueId}` : undefined}
830
+ aria-describedby={describedBy}
831
+ onPointerDown={() => {
832
+ triggerPointer.current = true;
833
+ }}
834
+ onClick={() => {
835
+ triggerPointer.current = false;
836
+ if (open) closePicker(true);
837
+ else openPicker();
838
+ }}
839
+ onKeyDown={(e) => {
840
+ triggerPointer.current = false;
841
+ if (!open && e.key === "ArrowDown") {
842
+ e.preventDefault();
843
+ openPicker();
844
+ } else if (open && e.key === "Escape") {
845
+ e.preventDefault();
846
+ e.stopPropagation();
847
+ closePicker(false);
848
+ }
849
+ }}
850
+ className={DP_TRIGGER + " " + textClass}
851
+ style={{ fontFamily: DP_FONT_BODY, color: shown ? DP_C.ink : DP_C.muted }}
852
+ >
853
+ <span id={valueId} className="min-w-0 truncate">
854
+ {shown || placeholder}
855
+ </span>
856
+ <CalendarDays
857
+ className="h-[18px] w-[18px] shrink-0"
858
+ style={{ color: open ? DP_C.primary : DP_C.muted }}
859
+ aria-hidden="true"
860
+ />
861
+ </button>
862
+
863
+ {open ? (
864
+ <div
865
+ ref={panelRef}
866
+ id={dialogId}
867
+ role="dialog"
868
+ aria-label="Choose a date"
869
+ tabIndex={-1}
870
+ onMouseDown={(e) => {
871
+ // A press on the padding or a weekday name leaves focus on the active day, so the keys keep working.
872
+ if (!(e.target as HTMLElement).closest("button")) e.preventDefault();
873
+ }}
874
+ onFocus={(e) => {
875
+ // Chrome sends no mousedown for a disabled button (a dimmed day, Today out of range) and focuses the
876
+ // panel itself instead: hand that focus on to the active day or month.
877
+ if (e.target !== e.currentTarget) return;
878
+ const el = e.currentTarget.querySelector('[data-dp-active="true"]') as HTMLElement | null;
879
+ el?.focus({ preventScroll: true });
880
+ }}
881
+ onKeyDown={(e) => {
882
+ if (e.key !== "Escape") return;
883
+ // Escape closes this calendar and nothing else: preventDefault for a surrounding modal that checks
884
+ // defaultPrevented, stopPropagation for any document listener that does not.
885
+ e.preventDefault();
886
+ e.stopPropagation();
887
+ closePicker(true);
888
+ }}
889
+ onBlur={(e) => {
890
+ // Tab or Shift+Tab out of the calendar closes it. A null relatedTarget (a click on nothing focusable,
891
+ // the window losing focus) is not a leave: clicks outside are the pointerdown listener's job.
892
+ const next = e.relatedTarget as Node | null;
893
+ if (!next || panelRef.current?.contains(next)) return;
894
+ if (next === triggerRef.current && triggerPointer.current) return; // the trigger's click will toggle
895
+ closePicker(false);
896
+ }}
897
+ className={`absolute z-40 w-[18.5rem] rounded-xl border bg-white p-3 outline-none ${
898
+ place.up ? "bottom-full mb-1" : "top-full mt-1"
899
+ }`}
900
+ style={{
901
+ left: place.x,
902
+ maxWidth: place.maxW ?? undefined,
903
+ visibility: place.ready ? "visible" : "hidden",
904
+ borderColor: DP_C.border,
905
+ boxShadow: DP_SHADOW_MENU,
906
+ fontFamily: DP_FONT_DISPLAY,
907
+ color: DP_C.ink,
908
+ }}
909
+ >
910
+ <div className="flex items-center justify-between">
911
+ <button
912
+ type="button"
913
+ aria-label={view === "days" ? `${heading}, choose month and year` : `${heading}, back to days`}
914
+ onClick={() => {
915
+ focusPending.current = true;
916
+ setView(view === "days" ? "months" : "days");
917
+ }}
918
+ // #F3F4F6 = DP_C.canvas · #680058 = DP_C.primary
919
+ className="-ml-1 inline-flex h-11 items-center gap-1 rounded-full pl-2.5 pr-2 text-[15px] font-semibold transition-colors hover:bg-[#F3F4F6] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]"
920
+ >
921
+ {heading}
922
+ <ChevronRight
923
+ className={`h-4 w-4 transition-transform ${view === "days" ? "rotate-90" : "-rotate-90"}`}
924
+ style={{ color: DP_C.muted }}
925
+ aria-hidden="true"
926
+ />
927
+ </button>
928
+ <div className="-mr-1.5 flex items-center">
929
+ <button
930
+ type="button"
931
+ aria-label={view === "days" ? "Previous month" : "Previous year"}
932
+ aria-disabled={prevOff || undefined}
933
+ onClick={() => step(-1)}
934
+ className={DP_ICON_BTN}
935
+ >
936
+ <ChevronLeft className="h-[18px] w-[18px]" aria-hidden="true" />
937
+ </button>
938
+ <button
939
+ type="button"
940
+ aria-label={view === "days" ? "Next month" : "Next year"}
941
+ aria-disabled={nextOff || undefined}
942
+ onClick={() => step(1)}
943
+ className={DP_ICON_BTN}
944
+ >
945
+ <ChevronRight className="h-[18px] w-[18px]" aria-hidden="true" />
946
+ </button>
947
+ </div>
948
+ </div>
949
+
950
+ {/* Announces the month as keys move through it; also the grid's name. */}
951
+ <div id={monthLabelId} aria-live="polite" className="sr-only">
952
+ {format(focusDate, "MMMM yyyy")}
953
+ </div>
954
+
955
+ {/* Both views are the same height, so switching never moves the panel. */}
956
+ <div className="mt-1 h-[16rem]">
957
+ {view === "days" ? (
958
+ <table
959
+ role="grid"
960
+ aria-labelledby={monthLabelId}
961
+ className="w-full table-fixed border-collapse"
962
+ onKeyDown={onDayKey}
963
+ >
964
+ <thead>
965
+ <tr>
966
+ {DP_WEEKDAYS.map(([short, long]) => (
967
+ <th
968
+ key={short}
969
+ scope="col"
970
+ abbr={long}
971
+ className="h-7 p-0 text-center text-[12px] font-medium"
972
+ style={{ color: DP_C.muted }}
973
+ >
974
+ {short}
975
+ </th>
976
+ ))}
977
+ </tr>
978
+ </thead>
979
+ <tbody>
980
+ {weeks.map((row, w) => (
981
+ <tr key={w}>
982
+ {row.map((d, i) => {
983
+ const isSel = !!selected && dpKey(d) === dpKey(selected);
984
+ const isToday = dpKey(d) === dpKey(today);
985
+ const inMonth = d.getMonth() === focusDate.getMonth();
986
+ const ok = dpAllowed(d, minD, maxD);
987
+ const active = dpKey(d) === dpKey(focusDate);
988
+ const tone = isSel
989
+ ? DP_DAY_SELECTED
990
+ : !ok
991
+ ? DP_DAY_OFF
992
+ : isToday
993
+ ? DP_DAY_TODAY
994
+ : inMonth
995
+ ? DP_DAY_IN
996
+ : DP_DAY_OUT;
997
+ return (
998
+ // Keyed by position, so a month change keeps the focused button in the DOM.
999
+ <td key={i} role="gridcell" aria-selected={isSel} className="p-0 py-px text-center">
1000
+ <button
1001
+ type="button"
1002
+ tabIndex={active ? 0 : -1}
1003
+ data-dp-active={active ? "true" : undefined}
1004
+ disabled={!ok}
1005
+ aria-label={format(d, "EEEE, MMMM d, yyyy")}
1006
+ aria-current={isToday ? "date" : undefined}
1007
+ onClick={() => pick(d)}
1008
+ className={`${DP_DAY} ${tone}`}
1009
+ // Today: a 1px primary ring, inline so it does not rest on Tailwind's ring variables.
1010
+ style={isToday && !isSel && ok ? { boxShadow: `inset 0 0 0 1px ${DP_C.primary}` } : undefined}
1011
+ >
1012
+ {d.getDate()}
1013
+ </button>
1014
+ </td>
1015
+ );
1016
+ })}
1017
+ </tr>
1018
+ ))}
1019
+ </tbody>
1020
+ </table>
1021
+ ) : (
1022
+ <div
1023
+ role="grid"
1024
+ aria-label={`Months of ${year}`}
1025
+ className="flex h-full flex-col justify-center gap-3"
1026
+ onKeyDown={onMonthKey}
1027
+ >
1028
+ {[0, 1, 2, 3].map((r) => (
1029
+ <div key={r} role="row" className="grid grid-cols-3 gap-x-2">
1030
+ {[0, 1, 2].map((c) => {
1031
+ const m = r * 3 + c;
1032
+ const first = new Date(year, m, 1);
1033
+ const last = addDays(addMonths(first, 1), -1);
1034
+ const ok = (!minD || dpKey(last) >= dpKey(minD)) && (!maxD || dpKey(first) <= dpKey(maxD));
1035
+ const isSel = !!selected && selected.getFullYear() === year && selected.getMonth() === m;
1036
+ const isNow = today.getFullYear() === year && today.getMonth() === m;
1037
+ const active = focusDate.getMonth() === m;
1038
+ const tone = isSel ? DP_DAY_SELECTED : !ok ? DP_DAY_OFF : isNow ? DP_DAY_TODAY : DP_DAY_IN;
1039
+ return (
1040
+ <div key={m} role="gridcell" aria-selected={isSel}>
1041
+ <button
1042
+ type="button"
1043
+ tabIndex={active ? 0 : -1}
1044
+ data-dp-active={active ? "true" : undefined}
1045
+ disabled={!ok}
1046
+ aria-label={format(first, "MMMM yyyy")}
1047
+ aria-current={isNow ? "date" : undefined}
1048
+ onClick={() => chooseMonth(m)}
1049
+ className={`${DP_MONTH} ${tone}`}
1050
+ style={isNow && !isSel && ok ? { boxShadow: `inset 0 0 0 1px ${DP_C.primary}` } : undefined}
1051
+ >
1052
+ {format(first, "MMM")}
1053
+ </button>
1054
+ </div>
1055
+ );
1056
+ })}
1057
+ </div>
1058
+ ))}
1059
+ </div>
1060
+ )}
1061
+ </div>
1062
+
1063
+ <div className="mt-2 flex items-center justify-between border-t pt-2" style={{ borderColor: DP_C.divider }}>
1064
+ <button
1065
+ type="button"
1066
+ disabled={todayOff}
1067
+ onClick={() => pick(today)}
1068
+ // #680058 = DP_C.primary · #F5EAF3 = DP_C.primaryTint
1069
+ className="-ml-1 inline-flex h-10 items-center rounded-full px-3 text-[14px] font-semibold text-[#680058] transition-colors hover:bg-[#F5EAF3] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058] disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
1070
+ >
1071
+ Today
1072
+ </button>
1073
+ {clearable ? (
1074
+ <button
1075
+ type="button"
1076
+ onClick={() => {
1077
+ onChange("");
1078
+ closePicker(true);
1079
+ }}
1080
+ // #6B7280 = DP_C.muted · #F3F4F6 = DP_C.canvas · #030712 = DP_C.ink · #680058 = DP_C.primary
1081
+ className="-mr-1 inline-flex h-10 items-center rounded-full px-3 text-[14px] font-medium text-[#6B7280] transition-colors hover:bg-[#F3F4F6] hover:text-[#030712] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]"
1082
+ >
1083
+ Clear
1084
+ </button>
1085
+ ) : null}
1086
+ </div>
1087
+ </div>
1088
+ ) : null}
1089
+ </div>
1090
+ );
1091
+ }
1092
+ ```
1093
+
1094
+ </details>
1095
+
1096
+ ## Checklist before shipping a date field
1097
+
1098
+ - [ ] No `<input type="date">`, `datetime-local` or `month` in the block (source grep, then the DOM count)
1099
+ - [ ] The kit pasted verbatim between its markers, at module scope; `kit-sync.py check` passes
1100
+ - [ ] Block-specific code (wrapper, imports, `textClass`) outside the markers
1101
+ - [ ] No clipping class between the field and its scroller
1102
+ - [ ] Trigger text size matches the block's inputs (`textClass` where containers are named)
1103
+ - [ ] Escape closes only the calendar; the second Escape reaches the modal
1104
+ - [ ] Backdrop click with a calendar open: closes only the calendar, or asks to discard
1105
+ - [ ] A modal's focus fix-up deferred with `setTimeout(…, 0)`; Tab out of the calendar reaches the next field
1106
+ - [ ] Values are `"yyyy-MM-dd"` strings; compared as strings; displayed through a local date
1107
+ - [ ] The saved payload is the same string the native field sent
@@ -10,9 +10,15 @@ the obvious choices:
10
10
  | shadcn `<Select>` / `<Command>` | **Portals to `document.body`, which is outside the block's shadow root**, so the styles arrive stripped. It also cannot be searched. |
11
11
  | `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, clip-aware drop-up. |
12
12
 
13
+ The date field has the same problem with the same answer: `<input type="date">` hands its calendar
14
+ to the browser, so a block uses the in-DOM `DatePicker` kit in [date-picker.md](date-picker.md),
15
+ which follows rule 1 of item 4 below, the Escape rule and the focus-on-open rule of this page.
16
+
13
17
  Copy the component into the block. A Vibe block is one self-contained file — there is no
14
18
  shared module to import, so each block carries its own copy. Keep one canonical copy in the
15
- project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
19
+ project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there. Pasting it
20
+ verbatim between marker lines that carry its sha makes that port mechanical:
21
+ [date-picker.md → The kit between markers](date-picker.md#the-kit-between-markers).
16
22
 
17
23
  ## The four things that will bite you
18
24
 
@@ -280,7 +280,7 @@ reports neither field. With `includeCode: false`, `vibe_coding_block_get_code` r
280
280
  `sourceCode: null`. Verified live 2026-10-01: a deployed block's `sourceSha256` equalled the SHA-256
281
281
  of the exact source last pushed to it (taken from the push call), and the read came back at about
282
282
  1 KB for a 15 KB block. The same check showed that block's local mirror had picked up three comment
283
- edits since that push, which is exactly what step 1 below exists to catch. (The digest on push results is per Softr's release notes; no push of ours has shown it yet.)
283
+ edits since that push, which is exactly what step 1 below exists to catch. (The digest on push results was first known from Softr's release notes; our own pushes have returned it since at least 2026-10-08.)
284
284
  Right after that release our client's copy of the tool definition did not declare `includeCode`, so
285
285
  the argument went out as the string `"false"` and the server still honoured it. Whatever the loaded
286
286
  definition says, check that `sourceCode` came back `null`.
@@ -308,10 +308,12 @@ definition says, check that `sourceCode` came back `null`.
308
308
 
309
309
  Matching hashes prove what Softr stored, not how the block behaves: for that, check it in a fresh preview with saves blocked, per [browser-checks.md](browser-checks.md).
310
310
 
311
- **Hash the exact bytes, trailing newline included.** Softr stores exactly what it receives: across
311
+ **Hash the exact bytes, trailing newline included.** Softr stores exactly the text that reaches it: across
312
312
  112 push→fetch pairs between 2026-09-09 and 2026-09-30 the fetched
313
313
  `sourceCode` was byte- and MD5-identical to the text sent, including two pushes sent *without* a
314
- final newline and stored without one. The "deployed block is one byte shorter" we chased on
314
+ final newline and stored without one. (One thing does not reach Softr as written: a `\uXXXX`
315
+ escape in the source arrives decoded, see [below](#unicode-escapes-come-back-decoded).)
316
+ The "deployed block is one byte shorter" we chased on
315
317
  2026-09-09 was our own read: an agent that reads a large file in chunks can drop the final
316
318
  newline (or a blank line at a chunk boundary) before transmission. A comparison that normalises
317
319
  the trailing newline hides exactly that class of error — so do not normalise anything; a mismatch
@@ -356,6 +358,41 @@ from the docs):
356
358
  (`application_get`) with the version's `createdAt` (`vibe_coding_block_list_versions`): on
357
359
  2026-09-01 a block we believed staged went live with a publish 28 minutes after it was saved.
358
360
 
361
+ #### Unicode escapes come back decoded
362
+
363
+ Verified 2026-10-08 on a large dashboard block pushed in stages with `vibe_coding_block_update_code`
364
+ and `vibe_coding_block_update_code_search_replace`: exactly the calls whose text held a JavaScript
365
+ `\uXXXX` escape came back with a `sourceSha256` that differed from the hash of the planned text. The
366
+ block's accent-folding regex, `/[\u0300-\u036f]/g`, was stored with both escapes replaced by the
367
+ characters themselves, two combining marks sitting raw between the brackets. `\d`, `\D` and `\s` in
368
+ the same block arrived as written.
369
+
370
+ The decoding happens somewhere between the tool call and storage (the client's JSON handling of
371
+ the argument, or the server), not in Softr's compiler: the stored source text itself changed. It
372
+ looks like the [encoding trap](#which-edit-tool-full-replace-vs-targeted-search-replace) above,
373
+ reaching a backslash-u that was meant to stay in the source. The exception is a lone surrogate:
374
+ `\udc00-\udfff` in the same regexes has no character form, and those pushes stored it unchanged.
375
+ (The `\u{…}` form is untested; treat it the same.)
376
+
377
+ **So: no `\uXXXX` in pushed source, lone surrogates aside.** Find them before a push with
378
+ `grep -n '\\u[0-9a-fA-F]\{4\}' <file>`, then either:
379
+
380
+ - **Write the characters themselves**, with a comment saying why, since combining marks and other
381
+ invisible characters cannot be read in an editor. Spell "backslash-u" out in that comment so it
382
+ holds no escape either. Check first that the character means the same raw in that spot: a range
383
+ of combining marks in a character class does (the block's regexes, escaped and raw, matched the
384
+ same set across all 65,536 BMP code points), but written raw, a `/` ends a regex literal, a `]`
385
+ closes a class, a backslash or quote changes the token, and U+2028/U+2029 are line terminators a
386
+ regex literal may not contain.
387
+ - **Build it from code points** where a raw character is unwanted or unsafe:
388
+ `new RegExp("[" + String.fromCharCode(0x300) + "-" + String.fromCharCode(0x36f) + "]", "g")`,
389
+ created once at module scope. No backslash-u reaches the tool, so there is nothing to decode.
390
+ Strings likewise: `String.fromCharCode(0x2014)`, not `"\u2014"`.
391
+
392
+ If a push has already stored the decoded form, applying the same replacement to the mirror brings
393
+ the hashes back together; confirm the raw characters behave the same (first bullet) before calling
394
+ it done.
395
+
359
396
  ### The array-argument rejection, and why it is a security issue
360
397
 
361
398
  **Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*