@structuralists/scaffolding 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: pr-screenshots
3
+ description: Attach screenshots to a GitHub PR description by uploading them as prerelease assets and embedding their download URLs. Use when a PR would benefit from visual evidence (UI changes, Storybook stories, component work) or when asked to add images to a PR.
4
+ ---
5
+
6
+ # Attach screenshots to a PR description
7
+
8
+ GitHub has no public API for uploading images into PR descriptions — the web
9
+ UI's drag-and-drop uses an internal endpoint (`/upload/policies/assets`) that
10
+ requires browser session cookies. The working CLI technique is to host the
11
+ images as **release assets on a throwaway prerelease** and embed their
12
+ `browser_download_url` in the PR body.
13
+
14
+ Origin: https://mareksuppa.com/til/github-pr-images-from-cli/ — first proven
15
+ here on PR #14.
16
+
17
+ ## 1. Capture
18
+
19
+ Use `chrome-devtools-axi` against the local Storybook (`bun run dev`, port
20
+ 6015):
21
+
22
+ - **Resize first**: `chrome-devtools-axi resize 900 640` — the default
23
+ viewport is very tall and produces screenshots that are mostly empty space.
24
+ - **Isolate the story**: open
25
+ `http://localhost:6015/iframe.html?id=<story-id>&viewMode=story` to skip
26
+ the Storybook chrome (sidebar, toolbar, addon panel).
27
+ - Interact to reach the state worth showing (filled fields, open overlays,
28
+ visible errors) — a screenshot demonstrating *behavior* beats an idle one.
29
+ - `chrome-devtools-axi screenshot <path>` — save to the session scratchpad,
30
+ not the repo.
31
+ - **Read the PNGs back** (Read tool) to verify framing and content before
32
+ publishing. Retake rather than ship a shot with dead space or missing
33
+ state.
34
+
35
+ ## 2. Upload as prerelease assets
36
+
37
+ ```bash
38
+ gh release create "pr-<N>-images" shot-1.png shot-2.png \
39
+ --title "PR #<N> screenshots" \
40
+ --notes "Image assets embedded in PR #<N>'s description. Not a software release — safe to delete after the PR merges." \
41
+ --prerelease
42
+ ```
43
+
44
+ - `--prerelease` + a non-semver tag (`pr-<N>-images`) keep it out of the real
45
+ release stream and invisible to release automation.
46
+ - Name files descriptively — the filename is how you select each asset's URL.
47
+
48
+ ## 3. Get URLs and embed
49
+
50
+ ```bash
51
+ gh api "repos/<owner>/<repo>/releases/tags/pr-<N>-images" \
52
+ --jq '.assets[] | .name + " " + .browser_download_url'
53
+ ```
54
+
55
+ Fetch the current body, append, and write back (don't clobber):
56
+
57
+ ```bash
58
+ gh pr view <N> --json body --jq .body > /tmp/pr-body.md
59
+ # append a "## Screenshots" section with ![descriptive alt](browser_download_url) images
60
+ gh pr edit <N> --body-file /tmp/pr-body.md
61
+ ```
62
+
63
+ Give each image real alt text and a one-line caption saying what state it
64
+ shows. Add a footnote that the images live on the `pr-<N>-images` prerelease
65
+ and are safe to delete after merge.
66
+
67
+ ## 4. Cleanup (after merge)
68
+
69
+ ```bash
70
+ gh release delete "pr-<N>-images" --cleanup-tag --yes
71
+ ```
72
+
73
+ ## Private-repo caveat
74
+
75
+ On a private repo, `browser_download_url` returns **404 to anonymous curl
76
+ and to token-header curl** — that endpoint authenticates via browser
77
+ session only. This is expected, not breakage: the images render for anyone
78
+ viewing the PR logged in. To verify an asset exists from the CLI, download
79
+ it through the API instead:
80
+
81
+ ```bash
82
+ gh api -H "Accept: application/octet-stream" \
83
+ "repos/<owner>/<repo>/releases/assets/<asset-id>"
84
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -147,11 +147,19 @@ precision end-to-end. So we wire it up before adding any consumers.
147
147
 
148
148
  - `useFormState/` — the hook + the value-model types (`FormValueSimple`,
149
149
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
150
- onValueChanges, errors, isValid, submitAttempted, submit }`; `errors` is
151
- live-derived from current values each render (validators are pure and
152
- cheap), `submitAttempted` lets UIs gate error display, and `submit()`
153
- performs the one honest cast to `Refine<T, V>` — earned because the
154
- validators just passed at runtime.
150
+ onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`;
151
+ `errors` is live-derived from current values each render (validators are
152
+ pure and cheap), `submitAttempted` lets UIs gate error display, and
153
+ `submit()` performs the one honest cast to `Refine<T, V>` — earned because
154
+ the validators just passed at runtime. `Debugger` is a per-instance
155
+ dev-time overlay (fixed trigger, bottom-right, portaled to `<body>`) that
156
+ opens a live `JsonTable` view of the form's internal state. Plumbing: the
157
+ hook publishes a `FormDebugSnapshot` into a tiny `snapshotStore` after
158
+ every commit; only an *open* debugger window subscribes
159
+ (`useSyncExternalStore`), so an unused Debugger costs ~nothing. The
160
+ component is created once per hook instance (lazy ref) — its identity must
161
+ stay stable across renders or it would remount on every keystroke.
162
+ `inspectable.ts` converts arrays/Sets to shapes `JsonTable` can render.
155
163
  - `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
156
164
  Will be used for granular setters and for surfacing per-field
157
165
  errors/touched state. Coupled to the form value model intentionally.
package/src/forms/plan.md CHANGED
@@ -1,8 +1,31 @@
1
1
  # Plan: composable, recursive `Validations`
2
2
 
3
- Status: **proposed** — nothing below is built yet. The baseline (flat
4
- `Validations<T>`, one validator per key, shallow `Refine`) is on
5
- `forms/useformstate-baseline`.
3
+ Status: **in progress** — order of operations below. The baseline (flat
4
+ `Validations<T>`, one validator per key, shallow `Refine`) is **merged to main**
5
+ (PR #12, squashed; released as 0.5.0).
6
+
7
+ ## Order of operations
8
+
9
+ The items were written unordered. The working order is **low-risk /
10
+ low-coupling first**: each early item is independently mergeable and valuable
11
+ even if the risky type work later stalls, and the risk zone comes last so a
12
+ TS wall can't strand finished work behind it.
13
+
14
+ 1. **Item 7 — Debugger.** Zero type risk, valuable for developing everything
15
+ after it (watching nested state and structured errors live). ← *current*
16
+ 2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
17
+ proven inside `allOf`.
18
+ 3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
19
+ are single-key paths). Doesn't depend on nested constraints; hard
20
+ prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
21
+ 4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
22
+ item 6 so the wrappers land in their final home once.
23
+ 5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
24
+ error model and the split; `Path`/`ValueAt` already validated.
25
+ 6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
26
+ `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
27
+ any runtime work; three outcomes (works / slow / intractable) each with a
28
+ known response. Worst case, everything above still shipped.
6
29
 
7
30
  ## Goal
8
31
 
@@ -15,13 +38,19 @@ Evolve `Validations<T>` so a constraints object can:
15
38
  (`Refinement` markers → `Refine<T, V>` → narrowed `onSubmit`) keeps
16
39
  working at every level.
17
40
 
18
- Plus two items that ride on top, independent of the type work:
41
+ Plus four items that ride on top, independent of the type work:
19
42
 
20
43
  5. split the forms area into **'form elements'** and **'form state'**
21
44
  (see the section at the end),
22
45
  6. **field binding**: a `getFormFieldPropsAt(path)` helper plus
23
46
  form-element shorthands (`TextInputForForm` style) so wiring a field is
24
- one expression (see the section at the end).
47
+ one expression (see the section at the end),
48
+ 7. **form `Debugger`**: a dev-time overlay component, returned from the
49
+ hook, that shows the form's live internal state (see the section at the
50
+ end),
51
+ 8. **per-field meta state**: visited/touched tracking and stable React key
52
+ generation, plus the open research question of keeping that meta state
53
+ in sync through array manipulation (see the section at the end).
25
54
 
26
55
  ## Target grammar
27
56
 
@@ -255,6 +284,104 @@ Placement note: these wrappers are the bridge between 'form elements' and
255
284
  'form state', so where they live should fall out of the split in item 5 —
256
285
  they are the one layer allowed to know about both sides.
257
286
 
287
+ ## 7. Form `Debugger` — dev-time state introspection
288
+
289
+ A component for watching a form's internal state live while interacting with
290
+ the form proper, in stories and during development. Two elements:
291
+
292
+ - **Trigger** — small, `position: fixed`, floats above all content
293
+ (z-index above everything the library renders).
294
+ - **Window** — opened by the trigger; shows a JSON-ish representation of the
295
+ form's internal state: `values`, `errors`, `isValid`, `submitAttempted`,
296
+ and whatever internal state lands later (touched map, etc.).
297
+ `JsonTable` is the obvious in-house renderer for this.
298
+
299
+ ### Hook plumbing
300
+
301
+ Each `useFormState` instance binds its internal state to its own debugger
302
+ and returns the component on the hook result:
303
+
304
+ ```tsx
305
+ const { values, submit, Debugger, ... } = useFormState({ ... });
306
+
307
+ return (
308
+ <>
309
+ <form>...</form>
310
+ <Debugger /> {/* dev/story usage; omit it and nothing renders */}
311
+ </>
312
+ );
313
+ ```
314
+
315
+ Mechanism sketch — a tiny event-emitter/subscription channel, so the
316
+ form's own render tree never pays for the debugger:
317
+
318
+ - The hook keeps a small store (current snapshot + subscriber set) in a ref
319
+ and publishes into it each render — publishing is a no-op when nobody
320
+ subscribes.
321
+ - `Debugger` is created once per hook instance (stable identity across
322
+ renders — memoized, not redefined inline, or it remounts every keystroke)
323
+ and closes over that store. When mounted and open, it subscribes —
324
+ `useSyncExternalStore` is the natural fit — so state flows hook → store →
325
+ debugger without threading props or context.
326
+ - Rendering the window is where the debugger pays its cost; a closed
327
+ trigger is ~free.
328
+
329
+ ### Open questions (for when this is picked up)
330
+
331
+ - **Snapshot transport**: display-side needs stable snapshots (rendering
332
+ live mutable refs tears); likely publish a frozen snapshot object per
333
+ render.
334
+ - **Multiple forms on one screen**: each hook returns its own `Debugger`,
335
+ so triggers could stack/overlap. May want an instance label prop and/or
336
+ offset management; punt until it hurts.
337
+ - **Production leakage**: it's dev tooling, but it ships in the library.
338
+ Decide whether it's tree-shaken naturally (only renders if placed in
339
+ JSX — probably sufficient) or needs an env gate.
340
+ - **Placement re: item 5 split**: the plumbing is 'form state'; the
341
+ trigger/window UI is presentational. Like the item-6 wrappers, it's a
342
+ bridge layer — settle its home when the split lands.
343
+
344
+ ## 8. Per-field meta state: visited tracking + stable React keys
345
+
346
+ Alongside the *value* tree, the hook needs a parallel *meta* tree — state
347
+ about fields that isn't field data:
348
+
349
+ - **visited/touched** — has the user interacted with this field yet? Feeds
350
+ the error-display policy (item 6's `errorMessage` gates on it via
351
+ `onBlur`).
352
+ - **stable React keys** — list elements need identity that survives
353
+ reorders/inserts/removals. Index keys break exactly when lists get
354
+ interesting; the meta layer is the natural place to mint and hold a
355
+ stable id per element.
356
+
357
+ ### The hard part: array manipulation keeping meta in sync (open research)
358
+
359
+ When a list is reordered, inserted into, or spliced, per-element meta
360
+ (visited flags, keys) must follow its element — not its index. Two candidate
361
+ approaches, deliberately unresolved until we get there:
362
+
363
+ - **Owned array operations.** Past projects needed a dedicated set of list
364
+ manipulation ops (`insertAt`, `removeAt`, `move`, …) that update values
365
+ and meta in the same motion. Known to work; the cost is that consumers
366
+ must use *our* ops — raw `onValueChanges` with a hand-spliced array
367
+ silently desyncs meta.
368
+ - **Identity tracking via Map/WeakMap.** Keep meta keyed by element object
369
+ identity (`WeakMap<element, meta>`) rather than by path/index, and detect
370
+ reorders by re-matching identities after each change. Frees consumers to
371
+ manipulate arrays however they like; open questions are elements that get
372
+ *replaced* rather than moved (immutable-update styles create new objects
373
+ on every edit — identity breaks precisely when fields are edited), and
374
+ primitive-element lists (`string[]`) which have no stable identity at all.
375
+
376
+ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
377
+ the guaranteed path. **Research topic for when we get here** — record
378
+ findings in the learning map before committing to either.
379
+
380
+ Sequencing: visited tracking lands with item 6 (its `onBlur` is the write
381
+ path). Keys + array-sync matter from phase 3 (list `each` specs) onward —
382
+ error paths and per-element meta both need element identity once lists are
383
+ editable.
384
+
258
385
  ## Open decisions
259
386
 
260
387
  - **Whole-value + structural constraints on the same key** (e.g. validate
@@ -0,0 +1,41 @@
1
+ .root {
2
+ position: fixed;
3
+ right: var(--ui-space-4);
4
+ bottom: var(--ui-space-4);
5
+ /* Same z-index tier as every other portal layer (Modal, Popover, Tooltip);
6
+ * stacking between layers comes from DOM-append order, not z-index. */
7
+ z-index: 100;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: flex-end;
11
+ gap: var(--ui-space-2);
12
+ font-family: var(--ui-font);
13
+ }
14
+
15
+ .trigger {
16
+ padding: var(--ui-space-1) var(--ui-space-3);
17
+ font-family: var(--ui-font-mono);
18
+ font-size: var(--ui-text-xsmall);
19
+ color: var(--ui-foreground);
20
+ background-color: var(--ui-background-1);
21
+ border: 1px solid var(--ui-border);
22
+ border-radius: var(--ui-radius-large);
23
+ box-shadow: 0 2px 8px var(--ui-shadow-soft);
24
+ cursor: pointer;
25
+ user-select: none;
26
+ }
27
+
28
+ .trigger:hover {
29
+ background-color: var(--ui-background-2);
30
+ }
31
+
32
+ .window {
33
+ width: 360px;
34
+ max-height: min(480px, 60vh);
35
+ overflow: auto;
36
+ padding: var(--ui-space-2);
37
+ background-color: var(--ui-background-0);
38
+ border: 1px solid var(--ui-border);
39
+ border-radius: var(--ui-radius-large);
40
+ box-shadow: 0 4px 16px var(--ui-shadow-soft);
41
+ }
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, test, expect } from 'bun:test';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ // The Debugger portals into document.body, so leftovers from a previous
5
+ // test are visible to `screen` queries — clean up explicitly (bun:test has
6
+ // no global afterEach for RTL's auto-cleanup to hook).
7
+ afterEach(cleanup);
8
+ import { useFormState } from './useFormState';
9
+ import { notEmpty } from '../validators/validators';
10
+
11
+ type DebugForm = { nickname: string | undefined };
12
+
13
+ const DebugHost = () => {
14
+ const { values, onValueChanges, Debugger } = useFormState({
15
+ initialValues: { nickname: undefined } as DebugForm,
16
+ constraints: { nickname: notEmpty('nickname') },
17
+ });
18
+
19
+ return (
20
+ <div>
21
+ <input
22
+ aria-label="nickname"
23
+ value={values.nickname ?? ''}
24
+ onChange={(e) => {
25
+ const nickname = e.target.value;
26
+ onValueChanges((prev) => ({ ...prev, nickname }));
27
+ }}
28
+ />
29
+ <Debugger label="debug me" />
30
+ </div>
31
+ );
32
+ };
33
+
34
+ describe('FormDebugger', () => {
35
+ test('renders the trigger with the window closed', () => {
36
+ render(<DebugHost />);
37
+ expect(screen.getByRole('button', { name: 'debug me' })).toBeTruthy();
38
+ expect(screen.queryByText('isValid')).toBeNull();
39
+ });
40
+
41
+ test('clicking the trigger opens the window with the current state', () => {
42
+ render(<DebugHost />);
43
+ fireEvent.click(screen.getByRole('button', { name: 'debug me' }));
44
+
45
+ expect(screen.getByText('isValid')).toBeTruthy();
46
+ expect(screen.getByText('submitAttempted')).toBeTruthy();
47
+ expect(screen.getByText("'nickname' cannot be empty")).toBeTruthy();
48
+ });
49
+
50
+ test('the open window live-updates as the form changes', () => {
51
+ render(<DebugHost />);
52
+ fireEvent.click(screen.getByRole('button', { name: 'debug me' }));
53
+
54
+ fireEvent.change(screen.getByLabelText('nickname'), {
55
+ target: { value: 'will' },
56
+ });
57
+
58
+ // The window survived the form re-render (stable component identity —
59
+ // a remount would have reset it to closed) and shows the new state.
60
+ expect(screen.getByText('will')).toBeTruthy();
61
+ expect(screen.queryByText("'nickname' cannot be empty")).toBeNull();
62
+ });
63
+
64
+ test('clicking the trigger again closes the window', () => {
65
+ render(<DebugHost />);
66
+ const trigger = screen.getByRole('button', { name: 'debug me' });
67
+
68
+ fireEvent.click(trigger);
69
+ expect(screen.getByText('isValid')).toBeTruthy();
70
+
71
+ fireEvent.click(trigger);
72
+ expect(screen.queryByText('isValid')).toBeNull();
73
+ });
74
+ });
@@ -0,0 +1,72 @@
1
+ import { useState, useSyncExternalStore } from 'react';
2
+ import type { ReactElement } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { JsonTable } from '../../components/Json/JsonTable';
5
+ import { toInspectable } from './inspectable';
6
+ import type { SnapshotStore } from './snapshotStore';
7
+ import type { FormDebugSnapshot, FormValuesObject } from './types';
8
+ import styles from './FormDebugger.module.css';
9
+
10
+ export type FormDebuggerProps = {
11
+ /** Trigger text. Give each form a distinct label when several debuggers
12
+ * are mounted at once — triggers stack in the same corner. */
13
+ label?: string;
14
+ };
15
+
16
+ export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
17
+
18
+ // Builds the per-hook-instance Debugger component. Called once per
19
+ // `useFormState` instance (from a lazy ref) so the returned component's
20
+ // identity is stable across renders — a component recreated each render
21
+ // would remount, and lose its open/closed state, on every keystroke.
22
+ export const createFormDebugger = <T extends FormValuesObject>(
23
+ store: SnapshotStore<FormDebugSnapshot<T>>,
24
+ ): FormDebuggerComponent => {
25
+ // The subscription lives in the window, not the trigger: a closed
26
+ // debugger never re-renders with the form.
27
+ const DebuggerWindow = (props: { label: string }) => {
28
+ const { label } = props;
29
+
30
+ const snapshot = useSyncExternalStore(
31
+ store.subscribe,
32
+ store.getSnapshot,
33
+ store.getSnapshot,
34
+ );
35
+
36
+ return (
37
+ <div className={styles.window}>
38
+ <JsonTable
39
+ title={label}
40
+ value={toInspectable({
41
+ isValid: snapshot.isValid,
42
+ submitAttempted: snapshot.submitAttempted,
43
+ values: snapshot.values,
44
+ errors: snapshot.errors,
45
+ })}
46
+ />
47
+ </div>
48
+ );
49
+ };
50
+
51
+ const FormDebugger = (props: FormDebuggerProps) => {
52
+ const { label = 'form state' } = props;
53
+
54
+ const [isOpen, setIsOpen] = useState(false);
55
+
56
+ return createPortal(
57
+ <div className={styles.root}>
58
+ {isOpen && <DebuggerWindow label={label} />}
59
+ <button
60
+ type="button"
61
+ className={styles.trigger}
62
+ onClick={() => setIsOpen((open) => !open)}
63
+ >
64
+ {label}
65
+ </button>
66
+ </div>,
67
+ document.body,
68
+ );
69
+ };
70
+
71
+ return FormDebugger;
72
+ };
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { toInspectable } from './inspectable';
3
+
4
+ describe('toInspectable', () => {
5
+ test('passes leaves through untouched', () => {
6
+ expect(toInspectable('hi')).toBe('hi');
7
+ expect(toInspectable(42)).toBe(42);
8
+ expect(toInspectable(10n)).toBe(10n);
9
+ expect(toInspectable(null)).toBeNull();
10
+ expect(toInspectable(undefined)).toBeUndefined();
11
+ expect(toInspectable(true)).toBe(true);
12
+ });
13
+
14
+ test('converts arrays to index-keyed objects', () => {
15
+ expect(toInspectable(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
16
+ });
17
+
18
+ test('converts arrays of objects recursively', () => {
19
+ expect(toInspectable([{ name: 'ada' }, { name: 'bo' }])).toEqual({
20
+ 0: { name: 'ada' },
21
+ 1: { name: 'bo' },
22
+ });
23
+ });
24
+
25
+ test('renders Sets as a descriptive string leaf', () => {
26
+ expect(toInspectable(new Set(['a', 'b']))).toBe('Set(2) { "a", "b" }');
27
+ expect(toInspectable(new Set())).toBe('Set(0) { }');
28
+ });
29
+
30
+ test('walks nested objects', () => {
31
+ const form = {
32
+ email: 'a@b.co',
33
+ address: { city: undefined, tags: ['home'] },
34
+ drivers: [{ name: 'ada', incidents: [] }],
35
+ };
36
+ expect(toInspectable(form)).toEqual({
37
+ email: 'a@b.co',
38
+ address: { city: undefined, tags: { 0: 'home' } },
39
+ drivers: { 0: { name: 'ada', incidents: {} } },
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,35 @@
1
+ // Converts a form-state snapshot into a shape JsonTable renders without
2
+ // throwing. JsonTable dispatches on "plain object vs leaf": arrays and Sets
3
+ // fall to the leaf renderer, and an array of objects (FormValueList) would
4
+ // throw as a React child. A debugger must render *any* legal form state, so:
5
+ //
6
+ // - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
7
+ // - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
8
+ // - objects → walked recursively
9
+ // - leaves → passed through untouched
10
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' &&
12
+ value !== null &&
13
+ (Object.getPrototypeOf(value) === Object.prototype ||
14
+ Object.getPrototypeOf(value) === null);
15
+
16
+ export const toInspectable = (value: unknown): unknown => {
17
+ if (value instanceof Set) {
18
+ const members = [...value].map((member) => JSON.stringify(member));
19
+ return `Set(${value.size}) { ${members.join(', ')} }`;
20
+ }
21
+
22
+ if (Array.isArray(value)) {
23
+ return Object.fromEntries(
24
+ value.map((element, index) => [index, toInspectable(element)]),
25
+ );
26
+ }
27
+
28
+ if (isPlainObject(value)) {
29
+ return Object.fromEntries(
30
+ Object.entries(value).map(([key, child]) => [key, toInspectable(child)]),
31
+ );
32
+ }
33
+
34
+ return value;
35
+ };
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect, mock } from 'bun:test';
2
+ import { createSnapshotStore } from './snapshotStore';
3
+
4
+ describe('createSnapshotStore', () => {
5
+ test('getSnapshot returns the initial snapshot', () => {
6
+ const store = createSnapshotStore({ n: 1 });
7
+ expect(store.getSnapshot()).toEqual({ n: 1 });
8
+ });
9
+
10
+ test('publish replaces the snapshot and notifies subscribers', () => {
11
+ const store = createSnapshotStore({ n: 1 });
12
+ const onChange = mock(() => {});
13
+ store.subscribe(onChange);
14
+
15
+ store.publish({ n: 2 });
16
+
17
+ expect(store.getSnapshot()).toEqual({ n: 2 });
18
+ expect(onChange).toHaveBeenCalledTimes(1);
19
+ });
20
+
21
+ test('publish keeps the snapshot reference stable between publishes', () => {
22
+ const store = createSnapshotStore({ n: 1 });
23
+ store.publish({ n: 2 });
24
+ expect(store.getSnapshot()).toBe(store.getSnapshot());
25
+ });
26
+
27
+ test('unsubscribe stops notifications', () => {
28
+ const store = createSnapshotStore({ n: 1 });
29
+ const onChange = mock(() => {});
30
+ const unsubscribe = store.subscribe(onChange);
31
+
32
+ unsubscribe();
33
+ store.publish({ n: 2 });
34
+
35
+ expect(onChange).not.toHaveBeenCalled();
36
+ });
37
+
38
+ test('publish with no subscribers is a no-op notify', () => {
39
+ const store = createSnapshotStore({ n: 1 });
40
+ expect(() => store.publish({ n: 2 })).not.toThrow();
41
+ expect(store.getSnapshot()).toEqual({ n: 2 });
42
+ });
43
+
44
+ test('notifies every subscriber', () => {
45
+ const store = createSnapshotStore({ n: 1 });
46
+ const first = mock(() => {});
47
+ const second = mock(() => {});
48
+ store.subscribe(first);
49
+ store.subscribe(second);
50
+
51
+ store.publish({ n: 2 });
52
+
53
+ expect(first).toHaveBeenCalledTimes(1);
54
+ expect(second).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -0,0 +1,31 @@
1
+ // Minimal external store connecting a hook's per-render state to observers
2
+ // outside its render tree (the form Debugger). Shaped for
3
+ // `useSyncExternalStore`: `subscribe` returns an unsubscribe function and
4
+ // `getSnapshot` must keep returning the same reference until the next
5
+ // `publish` — snapshots are replaced whole, never mutated.
6
+ export type SnapshotStore<Snapshot> = {
7
+ publish: (next: Snapshot) => void;
8
+ subscribe: (onChange: () => void) => () => void;
9
+ getSnapshot: () => Snapshot;
10
+ };
11
+
12
+ export const createSnapshotStore = <Snapshot,>(
13
+ initial: Snapshot,
14
+ ): SnapshotStore<Snapshot> => {
15
+ let current = initial;
16
+ const listeners = new Set<() => void>();
17
+
18
+ return {
19
+ publish: (next) => {
20
+ current = next;
21
+ for (const listener of listeners) listener();
22
+ },
23
+ subscribe: (onChange) => {
24
+ listeners.add(onChange);
25
+ return () => {
26
+ listeners.delete(onChange);
27
+ };
28
+ },
29
+ getSnapshot: () => current,
30
+ };
31
+ };
@@ -1,3 +1,7 @@
1
+ // Type-only import; FormDebugger.tsx imports value types from this file in
2
+ // turn, but the cycle never exists at runtime.
3
+ import type { FormDebuggerComponent } from './FormDebugger';
4
+
1
5
  export type FormValueSimple = string | number | bigint | string[] | Set<string> | undefined | null;
2
6
 
3
7
  export type FormValuesObject = { [k in string]: FormValue };
@@ -10,6 +14,16 @@ export type FormErrors<T extends FormValuesObject> = Partial<
10
14
  Record<keyof T, string>
11
15
  >;
12
16
 
17
+ // What the hook publishes to its Debugger after every commit. Snapshots are
18
+ // replaced whole (never mutated) so `useSyncExternalStore` consumers can
19
+ // rely on reference equality.
20
+ export type FormDebugSnapshot<T extends FormValuesObject> = {
21
+ values: T;
22
+ errors: FormErrors<T>;
23
+ isValid: boolean;
24
+ submitAttempted: boolean;
25
+ };
26
+
13
27
  export type FormHelpers<T extends FormValuesObject> = {
14
28
  values: T;
15
29
  // todo: likely remove once other setter are available
@@ -20,4 +34,8 @@ export type FormHelpers<T extends FormValuesObject> = {
20
34
  isValid: boolean;
21
35
  submitAttempted: boolean;
22
36
  submit: () => void;
37
+ // Dev-time introspection overlay bound to this form instance: a fixed
38
+ // trigger that opens a window showing the form's live internal state.
39
+ // Render it anywhere (it portals to <body>); omit it and nothing mounts.
40
+ Debugger: FormDebuggerComponent;
23
41
  };
@@ -194,3 +194,110 @@ export const LiveValidity: Story = {
194
194
  </div>
195
195
  ),
196
196
  };
197
+
198
+ type DebuggerDemoValues = {
199
+ email: string | undefined;
200
+ nickname: string | undefined;
201
+ tags: string[];
202
+ };
203
+
204
+ // The Debugger comes off the hook itself — drop it anywhere in the tree and
205
+ // a fixed trigger appears bottom-right; open it and watch values/errors/
206
+ // isValid/submitAttempted update live as you interact with the form.
207
+ const DebuggerDemo = () => {
208
+ const [tagDraft, setTagDraft] = useState('');
209
+
210
+ const { values, onValueChanges, errors, submitAttempted, submit, Debugger } =
211
+ useFormState({
212
+ initialValues: {
213
+ email: undefined,
214
+ nickname: undefined,
215
+ tags: [],
216
+ } as DebuggerDemoValues,
217
+ constraints: {
218
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
219
+ nickname: notEmpty('nickname'),
220
+ },
221
+ });
222
+
223
+ const shownErrors = submitAttempted ? errors : {};
224
+
225
+ return (
226
+ <form
227
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
228
+ onSubmit={(e) => {
229
+ e.preventDefault();
230
+ submit();
231
+ }}
232
+ >
233
+ <Field label="Email" error={shownErrors.email} htmlFor="debug-email">
234
+ <Input
235
+ id="debug-email"
236
+ type="email"
237
+ value={values.email ?? ''}
238
+ onChange={(e) => {
239
+ const email = e.target.value;
240
+ onValueChanges((prev) => ({ ...prev, email }));
241
+ }}
242
+ placeholder="you@example.com"
243
+ />
244
+ </Field>
245
+
246
+ <Field label="Nickname" error={shownErrors.nickname} htmlFor="debug-nickname">
247
+ <Input
248
+ id="debug-nickname"
249
+ value={values.nickname ?? ''}
250
+ onChange={(e) => {
251
+ const nickname = e.target.value;
252
+ onValueChanges((prev) => ({ ...prev, nickname }));
253
+ }}
254
+ />
255
+ </Field>
256
+
257
+ <Field
258
+ label="Tags"
259
+ hint="Unconstrained string[] — shows how the Debugger renders list values"
260
+ htmlFor="debug-tag-draft"
261
+ >
262
+ <div style={{ display: 'flex', gap: 8 }}>
263
+ <Input
264
+ id="debug-tag-draft"
265
+ value={tagDraft}
266
+ onChange={(e) => setTagDraft(e.target.value)}
267
+ placeholder="add a tag"
268
+ />
269
+ <Button
270
+ type="button"
271
+ onClick={() => {
272
+ if (!tagDraft) return;
273
+ onValueChanges((prev) => ({ ...prev, tags: [...prev.tags, tagDraft] }));
274
+ setTagDraft('');
275
+ }}
276
+ >
277
+ Add
278
+ </Button>
279
+ </div>
280
+ </Field>
281
+
282
+ {values.tags.length > 0 && (
283
+ <div style={{ fontSize: 13 }}>tags: {values.tags.join(', ')}</div>
284
+ )}
285
+
286
+ <div>
287
+ <Button type="submit" variant="primary">
288
+ Submit
289
+ </Button>
290
+ </div>
291
+
292
+ <Debugger label="signup form" />
293
+ </form>
294
+ );
295
+ };
296
+
297
+ export const WithDebugger: Story = {
298
+ render: () => (
299
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
300
+ <DebuggerDemo />
301
+ </div>
302
+ ),
303
+ };
@@ -1,5 +1,14 @@
1
- import { useState } from 'react';
2
- import type { FormErrors, FormHelpers, FormValuesObject } from './types';
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createFormDebugger } from './FormDebugger';
3
+ import type { FormDebuggerComponent } from './FormDebugger';
4
+ import { createSnapshotStore } from './snapshotStore';
5
+ import type { SnapshotStore } from './snapshotStore';
6
+ import type {
7
+ FormDebugSnapshot,
8
+ FormErrors,
9
+ FormHelpers,
10
+ FormValuesObject,
11
+ } from './types';
3
12
  import type { Refine, Validations } from '../validations/types';
4
13
 
5
14
  // `const V` freezes the inferred type of an inline `constraints` object —
@@ -38,6 +47,31 @@ export const useFormState = <
38
47
 
39
48
  const isValid = Object.keys(errors).length === 0;
40
49
 
50
+ // Debugger plumbing: one store + one component per hook instance, created
51
+ // lazily on first render. The component's identity must be stable across
52
+ // renders — recreated each render it would remount (and lose its
53
+ // open/closed state) on every keystroke.
54
+ const debugRef = useRef<{
55
+ store: SnapshotStore<FormDebugSnapshot<T>>;
56
+ Debugger: FormDebuggerComponent;
57
+ } | null>(null);
58
+ if (debugRef.current === null) {
59
+ const store = createSnapshotStore<FormDebugSnapshot<T>>({
60
+ values,
61
+ errors,
62
+ isValid,
63
+ submitAttempted,
64
+ });
65
+ debugRef.current = { store, Debugger: createFormDebugger(store) };
66
+ }
67
+ const { store, Debugger } = debugRef.current;
68
+
69
+ // Publish after every commit. With no debugger window subscribed this is a
70
+ // field write and an empty notify loop — effectively free.
71
+ useEffect(() => {
72
+ store.publish({ values, errors, isValid, submitAttempted });
73
+ });
74
+
41
75
  const submit = () => {
42
76
  setSubmitAttempted(true);
43
77
  if (!isValid) return;
@@ -46,5 +80,13 @@ export const useFormState = <
46
80
  onSubmit?.(values as Refine<T, V>);
47
81
  };
48
82
 
49
- return { values, onValueChanges, errors, isValid, submitAttempted, submit };
83
+ return {
84
+ values,
85
+ onValueChanges,
86
+ errors,
87
+ isValid,
88
+ submitAttempted,
89
+ submit,
90
+ Debugger,
91
+ };
50
92
  };